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 <noreply@anthropic.com>
This commit is contained in:
64
server.js
64
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);
|
||||
});
|
||||
|
||||
463
src/admin-dashboard.js
Normal file
463
src/admin-dashboard.js
Normal file
@@ -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 = `<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>`;
|
||||
|
||||
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 `<!DOCTYPE html>
|
||||
<html lang="pt-BR">
|
||||
<head>
|
||||
${buildHead('Dashboard', pageCSS, pageScripts)}
|
||||
</head>
|
||||
<body>
|
||||
|
||||
${buildHeader({ role: 'admin', userName: admin.nome, activePage: 'dashboard' })}
|
||||
|
||||
<div class="app-container">
|
||||
<div class="dashboard-grid">
|
||||
|
||||
<!-- KPIs Row -->
|
||||
<div class="kpi-row" id="kpiRow">
|
||||
<div class="kpi-card total">
|
||||
<div class="loading"><div class="spinner"></div><span class="loading-text">Carregando...</span></div>
|
||||
</div>
|
||||
<div class="kpi-card brl-usd">
|
||||
<div class="loading"><div class="spinner"></div></div>
|
||||
</div>
|
||||
<div class="kpi-card usd-brl">
|
||||
<div class="loading"><div class="spinner"></div></div>
|
||||
</div>
|
||||
<div class="kpi-card usd-usd">
|
||||
<div class="loading"><div class="spinner"></div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Charts Row -->
|
||||
<div class="charts-row">
|
||||
<div class="chart-card" id="chartConsolidado">
|
||||
<h3>Tendencia 30 dias - Total Consolidado</h3>
|
||||
<div class="chart-wrap">
|
||||
<div class="loading"><div class="spinner"></div><span class="loading-text">Carregando grafico...</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chart-card" id="chartFluxos">
|
||||
<h3>Tendencia 30 dias - Por Fluxo</h3>
|
||||
<div class="chart-wrap">
|
||||
<div class="loading"><div class="spinner"></div><span class="loading-text">Carregando grafico...</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ranking -->
|
||||
<div class="ranking-card" id="rankingCard">
|
||||
<div class="ranking-header">
|
||||
<h3>Top 5 Agentes</h3>
|
||||
<select id="rankingPeriodo" onchange="loadRanking()">
|
||||
<option value="30" selected>Ultimo Mes</option>
|
||||
<option value="7">Ultima Semana</option>
|
||||
<option value="90">Ultimos 3 Meses</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="rankingContent">
|
||||
<div class="loading"><div class="spinner"></div><span class="loading-text">Carregando ranking...</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${buildFooter()}
|
||||
|
||||
<script>
|
||||
const formatUSD = (v) => '$' + Number(v).toLocaleString('en-US', { minimumFractionDigits: 0, maximumFractionDigits: 0 });
|
||||
const formatNum = (v) => Number(v).toLocaleString('pt-BR');
|
||||
|
||||
// Load KPIs
|
||||
async function loadKPIs() {
|
||||
try {
|
||||
const res = await fetch('/admin/api/kpis');
|
||||
const json = await res.json();
|
||||
if (!json.success) throw new Error(json.error);
|
||||
|
||||
const d = json.data;
|
||||
const calcVar = (hoje, media) => media > 0 ? ((hoje - media) / media * 100).toFixed(0) : 0;
|
||||
|
||||
const cards = document.querySelectorAll('#kpiRow .kpi-card');
|
||||
|
||||
// Total
|
||||
const totalVar = calcVar(d.total.hoje_qtd, d.total.media_qtd);
|
||||
cards[0].innerHTML = \`
|
||||
<div class="kpi-badge \${totalVar >= 0 ? 'up' : 'down'}">\${totalVar >= 0 ? '+' : ''}\${totalVar}%</div>
|
||||
<div class="kpi-label">Total Ordens Hoje</div>
|
||||
<div class="kpi-value">\${d.total.hoje_qtd}</div>
|
||||
<div class="kpi-sub">Media 30d: \${d.total.media_qtd} ordens</div>
|
||||
\`;
|
||||
|
||||
// BRL->USD
|
||||
const brlVar = calcVar(d.brlUsd.hoje_qtd, d.brlUsd.media_qtd);
|
||||
cards[1].innerHTML = \`
|
||||
<div class="kpi-badge \${brlVar >= 0 ? 'up' : 'down'}">\${brlVar >= 0 ? '+' : ''}\${brlVar}%</div>
|
||||
<div class="kpi-label">BRL → USD</div>
|
||||
<div class="kpi-value">\${d.brlUsd.hoje_qtd}</div>
|
||||
<div class="kpi-sub">Media 30d: \${d.brlUsd.media_qtd}</div>
|
||||
\`;
|
||||
|
||||
// USD->BRL
|
||||
const usdBrlVar = calcVar(d.usdBrl.hoje_qtd, d.usdBrl.media_qtd);
|
||||
cards[2].innerHTML = \`
|
||||
<div class="kpi-badge \${usdBrlVar >= 0 ? 'up' : 'down'}">\${usdBrlVar >= 0 ? '+' : ''}\${usdBrlVar}%</div>
|
||||
<div class="kpi-label">USD → BRL</div>
|
||||
<div class="kpi-value">\${d.usdBrl.hoje_qtd}</div>
|
||||
<div class="kpi-sub">Media 30d: \${d.usdBrl.media_qtd}</div>
|
||||
\`;
|
||||
|
||||
// USD->USD
|
||||
const usdUsdVar = calcVar(d.usdUsd.hoje_qtd, d.usdUsd.media_qtd);
|
||||
cards[3].innerHTML = \`
|
||||
<div class="kpi-badge \${usdUsdVar >= 0 ? 'up' : 'down'}">\${usdUsdVar >= 0 ? '+' : ''}\${usdUsdVar}%</div>
|
||||
<div class="kpi-label">USD → USD</div>
|
||||
<div class="kpi-value">\${d.usdUsd.hoje_qtd}</div>
|
||||
<div class="kpi-sub">Media 30d: \${d.usdUsd.media_qtd}</div>
|
||||
\`;
|
||||
} catch (err) {
|
||||
console.error('KPIs error:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Load Trend Charts
|
||||
async function loadTrend() {
|
||||
try {
|
||||
const res = await fetch('/admin/api/trend');
|
||||
const json = await res.json();
|
||||
if (!json.success) throw new Error(json.error);
|
||||
|
||||
const d = json.data;
|
||||
|
||||
// Build consolidated data
|
||||
const allDates = new Set();
|
||||
d.brlUsd.forEach(r => allDates.add(r.dia));
|
||||
d.usdBrl.forEach(r => allDates.add(r.dia));
|
||||
d.usdUsd.forEach(r => allDates.add(r.dia));
|
||||
const dates = Array.from(allDates).sort();
|
||||
|
||||
const getQtd = (arr, dia) => arr.find(r => r.dia === dia)?.qtd || 0;
|
||||
|
||||
const consolidado = dates.map(dia =>
|
||||
getQtd(d.brlUsd, dia) + getQtd(d.usdBrl, dia) + getQtd(d.usdUsd, dia)
|
||||
);
|
||||
|
||||
// Chart 1: Consolidado
|
||||
document.querySelector('#chartConsolidado .chart-wrap').innerHTML = '<canvas id="canvasConsolidado"></canvas>';
|
||||
new Chart(document.getElementById('canvasConsolidado'), {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: dates.map(d => d.slice(5)),
|
||||
datasets: [{
|
||||
label: 'Total Ordens',
|
||||
data: consolidado,
|
||||
borderColor: '#7600be',
|
||||
backgroundColor: 'rgba(118,0,190,0.1)',
|
||||
fill: true,
|
||||
tension: 0.3,
|
||||
pointRadius: 2
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: { legend: { display: false } },
|
||||
scales: {
|
||||
y: { beginAtZero: true, grid: { color: '#F3F4F6' } },
|
||||
x: { grid: { display: false }, ticks: { maxRotation: 0, autoSkip: true, maxTicksLimit: 10 } }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Chart 2: Por Fluxo
|
||||
document.querySelector('#chartFluxos .chart-wrap').innerHTML = '<canvas id="canvasFluxos"></canvas>';
|
||||
new Chart(document.getElementById('canvasFluxos'), {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: dates.map(d => d.slice(5)),
|
||||
datasets: [
|
||||
{
|
||||
label: 'BRL→USD',
|
||||
data: dates.map(dia => getQtd(d.brlUsd, dia)),
|
||||
borderColor: '#1A73E8',
|
||||
backgroundColor: 'transparent',
|
||||
tension: 0.3,
|
||||
pointRadius: 2
|
||||
},
|
||||
{
|
||||
label: 'USD→BRL',
|
||||
data: dates.map(dia => getQtd(d.usdBrl, dia)),
|
||||
borderColor: '#1E8E3E',
|
||||
backgroundColor: 'transparent',
|
||||
tension: 0.3,
|
||||
pointRadius: 2
|
||||
},
|
||||
{
|
||||
label: 'USD→USD',
|
||||
data: dates.map(dia => getQtd(d.usdUsd, dia)),
|
||||
borderColor: '#7B1FA2',
|
||||
backgroundColor: 'transparent',
|
||||
tension: 0.3,
|
||||
pointRadius: 2
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { position: 'top', labels: { usePointStyle: true, font: { size: 11 } } }
|
||||
},
|
||||
scales: {
|
||||
y: { beginAtZero: true, grid: { color: '#F3F4F6' } },
|
||||
x: { grid: { display: false }, ticks: { maxRotation: 0, autoSkip: true, maxTicksLimit: 10 } }
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Trend error:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Load Ranking
|
||||
async function loadRanking() {
|
||||
const dias = document.getElementById('rankingPeriodo').value;
|
||||
const content = document.getElementById('rankingContent');
|
||||
content.innerHTML = '<div class="loading"><div class="spinner"></div><span class="loading-text">Carregando...</span></div>';
|
||||
|
||||
try {
|
||||
const res = await fetch('/admin/api/top-agentes?dias=' + dias);
|
||||
const json = await res.json();
|
||||
if (!json.success) throw new Error(json.error);
|
||||
|
||||
if (json.data.length === 0) {
|
||||
content.innerHTML = '<p style="text-align:center;color:var(--text-muted);padding:40px;">Nenhum dado encontrado para o periodo.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = \`
|
||||
<table class="ranking-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Agente</th>
|
||||
<th>Qtd Ordens</th>
|
||||
<th>Volume USD</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
\`;
|
||||
|
||||
json.data.forEach(r => {
|
||||
html += \`
|
||||
<tr>
|
||||
<td class="rank-num rank-\${r.rank}">\${r.rank}</td>
|
||||
<td>\${r.agente}</td>
|
||||
<td>\${formatNum(r.qtd)}</td>
|
||||
<td>\${formatUSD(r.vol_usd)}</td>
|
||||
</tr>
|
||||
\`;
|
||||
});
|
||||
|
||||
html += '</tbody></table>';
|
||||
content.innerHTML = html;
|
||||
} catch (err) {
|
||||
console.error('Ranking error:', err);
|
||||
content.innerHTML = '<p style="color:var(--red);padding:20px;">Erro ao carregar ranking</p>';
|
||||
}
|
||||
}
|
||||
|
||||
// Load all sections
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
loadKPIs();
|
||||
loadTrend();
|
||||
loadRanking();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
module.exports = { buildAdminDashboardHTML };
|
||||
164
src/cache.js
Normal file
164
src/cache.js
Normal file
@@ -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
|
||||
};
|
||||
158
src/queries.js
158
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 };
|
||||
|
||||
Reference in New Issue
Block a user