diff --git a/server.js b/server.js
index fae5606..d06fae1 100644
--- a/server.js
+++ b/server.js
@@ -11,12 +11,14 @@ const express = require('express');
const session = require('express-session');
const path = require('path');
const { authenticate, requireAuth, requireRole, createAgente, createUser } = require('./src/auth');
-const { fetchTransacoes, fetchAllTransacoes, serialize, fetchDailyStats } = require('./src/queries');
+const { fetchTransacoes, fetchAllTransacoes, serialize, fetchDailyStats, fetchKPIs, fetchTrend30Days, fetchTopAgentes } = require('./src/queries');
const { buildHTML } = require('./src/dashboard');
const { buildAdminHTML } = require('./src/admin-panel');
const { buildAdminHomeHTML } = require('./src/admin-home');
+const { buildAdminDashboardHTML } = require('./src/admin-dashboard');
const bcrypt = require('bcrypt');
const db = require('./src/db-local');
+const cache = require('./src/cache');
const app = express();
const PORT = process.env.PORT || 3080;
@@ -138,12 +140,11 @@ app.get('/admin/agentes', requireRole('admin'), (req, res) => {
}
});
-// Admin Dashboard - view ALL clients data (admin only)
+// Admin Dashboard - KPIs, Tendências e Ranking (com lazy load)
app.get('/admin/dashboard', requireRole('admin'), async (req, res) => {
try {
const user = req.session.user;
- const dias = parseInt(req.query.dias) || 90;
- const html = buildHTML([], { nome: `Admin - Ultimos ${dias} dias`, email: user.email }, false, dias, true);
+ const html = buildAdminDashboardHTML({ nome: user.nome, email: user.email });
res.send(html);
} catch (err) {
console.error('Admin dashboard error:', err);
@@ -164,6 +165,53 @@ app.get('/admin/api/data', requireRole('admin'), async (req, res) => {
}
});
+// API: KPIs (hoje vs média 30 dias) - com cache
+app.get('/admin/api/kpis', requireRole('admin'), async (req, res) => {
+ try {
+ const data = await cache.getOrFetch('kpis', fetchKPIs, 5 * 60 * 1000);
+ res.json({ success: true, data });
+ } catch (err) {
+ console.error('KPIs API error:', err);
+ res.status(500).json({ success: false, error: err.message });
+ }
+});
+
+// API: Tendência 30 dias - com cache
+app.get('/admin/api/trend', requireRole('admin'), async (req, res) => {
+ try {
+ const data = await cache.getOrFetch('trend30', fetchTrend30Days, 10 * 60 * 1000);
+ res.json({ success: true, data });
+ } catch (err) {
+ console.error('Trend API error:', err);
+ res.status(500).json({ success: false, error: err.message });
+ }
+});
+
+// API: Top 5 agentes - com cache por período
+app.get('/admin/api/top-agentes', requireRole('admin'), async (req, res) => {
+ try {
+ const dias = parseInt(req.query.dias) || 30;
+ const cacheKey = `top-agentes-${dias}`;
+
+ // Busca dados do RDS (com cache)
+ const rawData = await cache.getOrFetch(cacheKey, () => fetchTopAgentes(dias), 10 * 60 * 1000);
+
+ // Adiciona nomes dos agentes do SQLite local
+ const data = rawData.map(r => {
+ const agente = db.prepare('SELECT nome FROM agentes WHERE agente_id = ?').get(r.agente_id);
+ return {
+ ...r,
+ agente: agente?.nome || `Agente #${r.agente_id}`
+ };
+ });
+
+ res.json({ success: true, data });
+ } catch (err) {
+ console.error('Top Agentes API error:', err);
+ res.status(500).json({ success: false, error: err.message });
+ }
+});
+
// Admin emulate agent - view dashboard as specific agent (admin only)
app.get('/admin/emular/:agente_id', requireRole('admin'), async (req, res) => {
try {
@@ -273,4 +321,12 @@ app.delete('/admin/agentes/:id', requireRole('admin'), (req, res) => {
// Start
app.listen(PORT, () => {
console.log(`BI - CCC rodando: http://localhost:${PORT}`);
+
+ // Inicializa cache com auto-refresh (atualiza a cada 5 minutos)
+ console.log('[Cache] Inicializando cache com auto-refresh...');
+ cache.registerAutoRefresh('kpis', fetchKPIs, 5 * 60 * 1000);
+ cache.registerAutoRefresh('trend30', fetchTrend30Days, 10 * 60 * 1000);
+ cache.registerAutoRefresh('top-agentes-30', () => fetchTopAgentes(30), 10 * 60 * 1000);
+ cache.registerAutoRefresh('top-agentes-7', () => fetchTopAgentes(7), 10 * 60 * 1000);
+ cache.registerAutoRefresh('top-agentes-90', () => fetchTopAgentes(90), 10 * 60 * 1000);
});
diff --git a/src/admin-dashboard.js b/src/admin-dashboard.js
new file mode 100644
index 0000000..312fb10
--- /dev/null
+++ b/src/admin-dashboard.js
@@ -0,0 +1,463 @@
+/**
+ * Admin Dashboard - KPIs, Tendências e Ranking
+ * Lazy loading para performance
+ */
+const { buildHeader, buildFooter, buildHead } = require('./ui-template');
+
+function buildAdminDashboardHTML(admin) {
+ const pageScripts = ``;
+
+ const pageCSS = `
+ .dashboard-grid {
+ display: grid;
+ gap: 24px;
+ }
+
+ /* KPI Cards */
+ .kpi-row {
+ display: grid;
+ grid-template-columns: repeat(4, 1fr);
+ gap: 20px;
+ }
+ .kpi-card {
+ background: var(--card);
+ border-radius: 16px;
+ padding: 24px;
+ border: 1px solid var(--border);
+ box-shadow: 0 2px 8px rgba(0,0,0,0.04);
+ position: relative;
+ min-height: 140px;
+ }
+ .kpi-card.total { border-left: 4px solid var(--primary); }
+ .kpi-card.brl-usd { border-left: 4px solid var(--blue); }
+ .kpi-card.usd-brl { border-left: 4px solid var(--green); }
+ .kpi-card.usd-usd { border-left: 4px solid var(--purple); }
+ .kpi-label {
+ font-size: 12px;
+ font-weight: 600;
+ color: var(--text-secondary);
+ text-transform: uppercase;
+ letter-spacing: 0.3px;
+ margin-bottom: 12px;
+ }
+ .kpi-value {
+ font-size: 36px;
+ font-weight: 800;
+ color: var(--text);
+ margin-bottom: 4px;
+ }
+ .kpi-sub {
+ font-size: 13px;
+ color: var(--text-muted);
+ }
+ .kpi-badge {
+ position: absolute;
+ top: 16px;
+ right: 16px;
+ font-size: 11px;
+ font-weight: 700;
+ padding: 4px 10px;
+ border-radius: 12px;
+ }
+ .kpi-badge.up { background: var(--green-bg); color: var(--green); }
+ .kpi-badge.down { background: var(--red-bg); color: var(--red); }
+ .kpi-badge.neutral { background: var(--blue-bg); color: var(--blue); }
+
+ /* Chart Cards */
+ .charts-row {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 24px;
+ }
+ .chart-card {
+ background: var(--card);
+ border-radius: 16px;
+ padding: 24px;
+ border: 1px solid var(--border);
+ box-shadow: 0 2px 8px rgba(0,0,0,0.04);
+ min-height: 380px;
+ position: relative;
+ }
+ .chart-card h3 {
+ font-size: 14px;
+ font-weight: 700;
+ margin-bottom: 20px;
+ color: var(--text);
+ }
+ .chart-wrap {
+ height: 300px;
+ position: relative;
+ }
+
+ /* Ranking Card */
+ .ranking-card {
+ background: var(--card);
+ border-radius: 16px;
+ padding: 24px;
+ border: 1px solid var(--border);
+ box-shadow: 0 2px 8px rgba(0,0,0,0.04);
+ min-height: 300px;
+ position: relative;
+ }
+ .ranking-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 20px;
+ }
+ .ranking-header h3 {
+ font-size: 14px;
+ font-weight: 700;
+ color: var(--text);
+ }
+ .ranking-header select {
+ padding: 8px 12px;
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ font-size: 13px;
+ font-family: inherit;
+ background: white;
+ cursor: pointer;
+ }
+ .ranking-table {
+ width: 100%;
+ border-collapse: collapse;
+ }
+ .ranking-table th {
+ text-align: left;
+ font-size: 11px;
+ font-weight: 600;
+ color: var(--text-secondary);
+ text-transform: uppercase;
+ padding: 12px 8px;
+ border-bottom: 2px solid var(--border);
+ }
+ .ranking-table td {
+ padding: 14px 8px;
+ font-size: 14px;
+ border-bottom: 1px solid #F3F4F6;
+ }
+ .ranking-table tr:last-child td { border-bottom: none; }
+ .rank-num {
+ width: 40px;
+ font-weight: 800;
+ color: var(--primary);
+ }
+ .rank-1 { color: #FFD700; }
+ .rank-2 { color: #C0C0C0; }
+ .rank-3 { color: #CD7F32; }
+
+ /* Loading State */
+ .loading {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ height: 100%;
+ min-height: 100px;
+ }
+ .spinner {
+ width: 32px;
+ height: 32px;
+ border: 3px solid var(--border);
+ border-top-color: var(--primary);
+ border-radius: 50%;
+ animation: spin 0.8s linear infinite;
+ }
+ @keyframes spin {
+ to { transform: rotate(360deg); }
+ }
+ .loading-text {
+ margin-left: 12px;
+ font-size: 13px;
+ color: var(--text-muted);
+ }
+
+ /* Responsive */
+ @media (max-width: 1200px) {
+ .kpi-row { grid-template-columns: repeat(2, 1fr); }
+ }
+ @media (max-width: 768px) {
+ .kpi-row { grid-template-columns: 1fr; }
+ .charts-row { grid-template-columns: 1fr; }
+ }
+ `;
+
+ return `
+
+
+${buildHead('Dashboard', pageCSS, pageScripts)}
+
+
+
+${buildHeader({ role: 'admin', userName: admin.nome, activePage: 'dashboard' })}
+
+
+
+
+
+
+
+
+
+
+
Tendencia 30 dias - Total Consolidado
+
+
+
+
Tendencia 30 dias - Por Fluxo
+
+
+
+
+
+
+
+
+
+
+${buildFooter()}
+
+
+
+`;
+}
+
+module.exports = { buildAdminDashboardHTML };
diff --git a/src/cache.js b/src/cache.js
new file mode 100644
index 0000000..a41f4b4
--- /dev/null
+++ b/src/cache.js
@@ -0,0 +1,164 @@
+/**
+ * Cache System - Stale-While-Revalidate
+ *
+ * Mantém dados em memória com TTL e atualização periódica.
+ * Retorna dados do cache imediatamente enquanto atualiza em background.
+ */
+
+const cache = new Map();
+const refreshIntervals = new Map();
+
+const DEFAULT_TTL = 5 * 60 * 1000; // 5 minutos
+const REFRESH_INTERVAL = 5 * 60 * 1000; // Atualiza a cada 5 minutos
+
+/**
+ * Armazena valor no cache
+ * @param {string} key - Chave do cache
+ * @param {any} value - Valor a armazenar
+ * @param {number} ttl - Time-to-live em ms (opcional)
+ */
+function set(key, value, ttl = DEFAULT_TTL) {
+ cache.set(key, {
+ value,
+ timestamp: Date.now(),
+ ttl
+ });
+ console.log(`[Cache] SET ${key} (TTL: ${ttl/1000}s)`);
+}
+
+/**
+ * Recupera valor do cache
+ * @param {string} key - Chave do cache
+ * @returns {any|null} - Valor ou null se não encontrado/expirado
+ */
+function get(key) {
+ const entry = cache.get(key);
+ if (!entry) return null;
+
+ const age = Date.now() - entry.timestamp;
+ const isStale = age > entry.ttl;
+
+ if (isStale) {
+ console.log(`[Cache] STALE ${key} (age: ${Math.round(age/1000)}s)`);
+ }
+
+ return {
+ value: entry.value,
+ isStale,
+ age: Math.round(age / 1000)
+ };
+}
+
+/**
+ * Verifica se cache existe (mesmo que stale)
+ */
+function has(key) {
+ return cache.has(key);
+}
+
+/**
+ * Remove do cache
+ */
+function del(key) {
+ cache.delete(key);
+ if (refreshIntervals.has(key)) {
+ clearInterval(refreshIntervals.get(key));
+ refreshIntervals.delete(key);
+ }
+}
+
+/**
+ * Limpa todo o cache
+ */
+function clear() {
+ cache.clear();
+ refreshIntervals.forEach(interval => clearInterval(interval));
+ refreshIntervals.clear();
+}
+
+/**
+ * Registra função para refresh periódico
+ * @param {string} key - Chave do cache
+ * @param {Function} fetchFn - Função async que busca os dados
+ * @param {number} interval - Intervalo de refresh em ms
+ */
+function registerAutoRefresh(key, fetchFn, interval = REFRESH_INTERVAL) {
+ // Limpa interval anterior se existir
+ if (refreshIntervals.has(key)) {
+ clearInterval(refreshIntervals.get(key));
+ }
+
+ // Função de refresh
+ const refresh = async () => {
+ try {
+ console.log(`[Cache] REFRESH ${key}`);
+ const value = await fetchFn();
+ set(key, value);
+ } catch (err) {
+ console.error(`[Cache] REFRESH ERROR ${key}:`, err.message);
+ }
+ };
+
+ // Faz refresh inicial
+ refresh();
+
+ // Agenda refreshes periódicos
+ const intervalId = setInterval(refresh, interval);
+ refreshIntervals.set(key, intervalId);
+
+ console.log(`[Cache] AUTO-REFRESH registered for ${key} (every ${interval/1000}s)`);
+}
+
+/**
+ * Helper: get-or-fetch com stale-while-revalidate
+ * Retorna cache (mesmo stale) imediatamente e atualiza em background se stale
+ */
+async function getOrFetch(key, fetchFn, ttl = DEFAULT_TTL) {
+ const cached = get(key);
+
+ if (cached) {
+ // Se stale, atualiza em background
+ if (cached.isStale) {
+ fetchFn().then(value => set(key, value, ttl)).catch(err => {
+ console.error(`[Cache] Background fetch error for ${key}:`, err.message);
+ });
+ }
+ return cached.value;
+ }
+
+ // Não tem cache, busca e aguarda
+ const value = await fetchFn();
+ set(key, value, ttl);
+ return value;
+}
+
+/**
+ * Stats do cache
+ */
+function stats() {
+ const entries = [];
+ cache.forEach((entry, key) => {
+ const age = Date.now() - entry.timestamp;
+ entries.push({
+ key,
+ age: Math.round(age / 1000),
+ ttl: entry.ttl / 1000,
+ isStale: age > entry.ttl
+ });
+ });
+ return {
+ size: cache.size,
+ entries
+ };
+}
+
+module.exports = {
+ set,
+ get,
+ has,
+ del,
+ clear,
+ registerAutoRefresh,
+ getOrFetch,
+ stats
+};
diff --git a/src/queries.js b/src/queries.js
index 198a360..f3cd303 100644
--- a/src/queries.js
+++ b/src/queries.js
@@ -223,4 +223,160 @@ async function fetchDailyStats() {
}
}
-module.exports = { fetchTransacoes, fetchAllTransacoes, serialize, fetchDailyStats };
+// KPIs: hoje vs média 30 dias
+async function fetchKPIs() {
+ const conn = await pool.getConnection();
+ try {
+ // BRL -> USD: hoje e média 30 dias
+ const [brlUsd] = await conn.execute(`
+ SELECT
+ SUM(CASE WHEN DATE(created_at) = CURDATE() THEN 1 ELSE 0 END) as hoje_qtd,
+ SUM(CASE WHEN DATE(created_at) = CURDATE() THEN amount_usd ELSE 0 END) as hoje_usd,
+ COUNT(*) / 30.0 as media_qtd,
+ SUM(amount_usd) / 30.0 as media_usd
+ FROM br_transaction_to_usa
+ WHERE created_at >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
+ `);
+
+ // USD -> BRL (com cotacao)
+ const [usdBrl] = await conn.execute(`
+ SELECT
+ SUM(CASE WHEN DATE(created_at) = CURDATE() THEN 1 ELSE 0 END) as hoje_qtd,
+ SUM(CASE WHEN DATE(created_at) = CURDATE() THEN valor ELSE 0 END) as hoje_usd,
+ COUNT(*) / 30.0 as media_qtd,
+ SUM(valor) / 30.0 as media_usd
+ FROM pagamento_br
+ WHERE created_at >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
+ AND cotacao IS NOT NULL AND cotacao > 0
+ AND (pgto IS NULL OR pgto != 'balance')
+ `);
+
+ // USD -> USD (balance)
+ const [usdUsd] = await conn.execute(`
+ SELECT
+ SUM(CASE WHEN DATE(created_at) = CURDATE() THEN 1 ELSE 0 END) as hoje_qtd,
+ SUM(CASE WHEN DATE(created_at) = CURDATE() THEN valor ELSE 0 END) as hoje_usd,
+ COUNT(*) / 30.0 as media_qtd,
+ SUM(valor) / 30.0 as media_usd
+ FROM pagamento_br
+ WHERE created_at >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
+ AND (cotacao IS NULL OR cotacao = 0 OR pgto = 'balance')
+ `);
+
+ const format = (row) => ({
+ hoje_qtd: Number(row[0]?.hoje_qtd) || 0,
+ hoje_usd: Number(row[0]?.hoje_usd) || 0,
+ media_qtd: Math.round(Number(row[0]?.media_qtd) || 0),
+ media_usd: Math.round(Number(row[0]?.media_usd) || 0)
+ });
+
+ const brlUsdData = format(brlUsd);
+ const usdBrlData = format(usdBrl);
+ const usdUsdData = format(usdUsd);
+
+ return {
+ brlUsd: brlUsdData,
+ usdBrl: usdBrlData,
+ usdUsd: usdUsdData,
+ total: {
+ hoje_qtd: brlUsdData.hoje_qtd + usdBrlData.hoje_qtd + usdUsdData.hoje_qtd,
+ hoje_usd: brlUsdData.hoje_usd + usdBrlData.hoje_usd + usdUsdData.hoje_usd,
+ media_qtd: brlUsdData.media_qtd + usdBrlData.media_qtd + usdUsdData.media_qtd,
+ media_usd: brlUsdData.media_usd + usdBrlData.media_usd + usdUsdData.media_usd
+ }
+ };
+ } finally {
+ conn.release();
+ }
+}
+
+// Tendência 30 dias - dados diários para gráfico de linha
+async function fetchTrend30Days() {
+ const conn = await pool.getConnection();
+ try {
+ // BRL -> USD por dia
+ const [brlUsd] = await conn.execute(`
+ SELECT DATE(created_at) as dia, COUNT(*) as qtd, ROUND(SUM(amount_usd), 2) as vol_usd
+ FROM br_transaction_to_usa
+ WHERE created_at >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
+ GROUP BY DATE(created_at)
+ ORDER BY dia
+ `);
+
+ // USD -> BRL por dia
+ const [usdBrl] = await conn.execute(`
+ SELECT DATE(created_at) as dia, COUNT(*) as qtd, ROUND(SUM(valor), 2) as vol_usd
+ FROM pagamento_br
+ WHERE created_at >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
+ AND cotacao IS NOT NULL AND cotacao > 0
+ AND (pgto IS NULL OR pgto != 'balance')
+ GROUP BY DATE(created_at)
+ ORDER BY dia
+ `);
+
+ // USD -> USD por dia
+ const [usdUsd] = await conn.execute(`
+ SELECT DATE(created_at) as dia, COUNT(*) as qtd, ROUND(SUM(valor), 2) as vol_usd
+ FROM pagamento_br
+ WHERE created_at >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
+ AND (cotacao IS NULL OR cotacao = 0 OR pgto = 'balance')
+ GROUP BY DATE(created_at)
+ ORDER BY dia
+ `);
+
+ const formatRows = (rows) => rows.map(r => ({
+ dia: r.dia instanceof Date ? r.dia.toISOString().slice(0, 10) : String(r.dia).slice(0, 10),
+ qtd: Number(r.qtd),
+ vol_usd: Number(r.vol_usd)
+ }));
+
+ return {
+ brlUsd: formatRows(brlUsd),
+ usdBrl: formatRows(usdBrl),
+ usdUsd: formatRows(usdUsd)
+ };
+ } finally {
+ conn.release();
+ }
+}
+
+// Top 5 agentes por período (IDs do RDS, nomes do callback)
+async function fetchTopAgentes(dias = 30, getAgenteName = null) {
+ const conn = await pool.getConnection();
+ try {
+ // Busca agente_ids com totais do RDS
+ const [rows] = await conn.execute(`
+ SELECT
+ agente_id,
+ SUM(qtd) as total_qtd,
+ ROUND(SUM(vol_usd), 2) as total_usd
+ FROM (
+ SELECT ac.agente_id, COUNT(*) as qtd, SUM(t.amount_usd) as vol_usd
+ FROM br_transaction_to_usa t
+ INNER JOIN ag_contas ac ON ac.conta_id = t.id_conta
+ WHERE t.created_at >= DATE_SUB(CURDATE(), INTERVAL ? DAY)
+ GROUP BY ac.agente_id
+ UNION ALL
+ SELECT ac.agente_id, COUNT(*) as qtd, SUM(p.valor) as vol_usd
+ FROM pagamento_br p
+ INNER JOIN ag_contas ac ON ac.conta_id = p.id_conta
+ WHERE p.created_at >= DATE_SUB(CURDATE(), INTERVAL ? DAY)
+ GROUP BY ac.agente_id
+ ) combined
+ GROUP BY agente_id
+ ORDER BY total_usd DESC
+ LIMIT 5
+ `, [dias, dias]);
+
+ return rows.map((r, i) => ({
+ rank: i + 1,
+ agente_id: r.agente_id,
+ qtd: Number(r.total_qtd),
+ vol_usd: Number(r.total_usd)
+ }));
+ } finally {
+ conn.release();
+ }
+}
+
+module.exports = { fetchTransacoes, fetchAllTransacoes, serialize, fetchDailyStats, fetchKPIs, fetchTrend30Days, fetchTopAgentes };