From 022993b20190dcf52650fa17aca8b55df8c0888d Mon Sep 17 00:00:00 2001 From: root Date: Sun, 8 Feb 2026 13:53:55 -0500 Subject: [PATCH] feat: novo dashboard admin com KPIs, tendencias e ranking - Adiciona src/admin-dashboard.js com lazy loading - KPIs: hoje vs media 30 dias por fluxo - Graficos de tendencia 30 dias (consolidado e por fluxo) - Ranking top 5 agentes com filtro de periodo - Adiciona sistema de cache (src/cache.js) - Cache com TTL e auto-refresh periodico (5-10min) - APIs: /admin/api/kpis, /admin/api/trend, /admin/api/top-agentes Co-Authored-By: Claude Opus 4.5 --- server.js | 64 +++++- src/admin-dashboard.js | 463 +++++++++++++++++++++++++++++++++++++++++ src/cache.js | 164 +++++++++++++++ src/queries.js | 158 +++++++++++++- 4 files changed, 844 insertions(+), 5 deletions(-) create mode 100644 src/admin-dashboard.js create mode 100644 src/cache.js 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' })} + +
+
+ + +
+
+
Carregando...
+
+
+
+
+
+
+
+
+
+
+
+ + +
+
+

Tendencia 30 dias - Total Consolidado

+
+
Carregando grafico...
+
+
+
+

Tendencia 30 dias - Por Fluxo

+
+
Carregando grafico...
+
+
+
+ + +
+
+

Top 5 Agentes

+ +
+
+
Carregando ranking...
+
+
+ +
+
+ +${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 };