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:
root
2026-02-08 13:53:55 -05:00
parent 0669214d47
commit 022993b201
4 changed files with 844 additions and 5 deletions

463
src/admin-dashboard.js Normal file
View 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 &rarr; 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 &rarr; 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 &rarr; 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 };