Files
bi-agents/src/dashboard.js
C. Cassel 39900c3fe8 Initial commit: BI Agentes platform
Independent dashboard for CambioReal agents with local SQLite auth
and read-only RDS connection. Features login, per-agent transaction
filtering, KPIs, charts (Chart.js), and detailed transaction table.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 15:47:07 -05:00

380 lines
19 KiB
JavaScript

/**
* Gera HTML do dashboard — parametrizado por agente
*/
function buildHTML(data, agente) {
const now = new Date().toLocaleString('pt-BR');
return `<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>BI — ${agente.nome}</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.7/dist/chart.umd.min.js"><\/script>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<style>
:root {
--primary: #6C3FA0;
--primary-light: #8B5FBF;
--primary-dark: #4A2570;
--primary-bg: #F3EEFA;
--bg: #F0F2F5;
--card: #FFFFFF;
--text: #1A1D23;
--text-secondary: #5F6368;
--text-muted: #9AA0A6;
--border: #E8EAED;
--green: #1E8E3E;
--green-bg: #E6F4EA;
--blue: #1A73E8;
--blue-bg: #E8F0FE;
--orange: #E8710A;
--orange-bg: #FEF3E8;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: var(--bg); color: var(--text); line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
.header {
background: linear-gradient(135deg, var(--primary) 0%, var(--primary-dark) 100%);
color: white; padding: 24px 40px;
display: flex; justify-content: space-between; align-items: center;
box-shadow: 0 2px 8px rgba(74,37,112,0.3);
}
.header h1 { font-size: 24px; font-weight: 800; letter-spacing: -0.5px; }
.header .subtitle { font-size: 13px; opacity: 0.8; margin-top: 4px; font-weight: 400; }
.header-right { display: flex; align-items: center; gap: 16px; }
.header .badge {
background: rgba(255,255,255,0.15); backdrop-filter: blur(10px);
padding: 8px 16px; border-radius: 24px; font-size: 12px; font-weight: 600;
border: 1px solid rgba(255,255,255,0.2);
}
.header .live-dot {
display: inline-block; width: 8px; height: 8px; background: #4ADE80;
border-radius: 50%; margin-right: 6px; animation: pulse 2s infinite;
}
.btn-logout {
background: rgba(255,255,255,0.15); color: white; border: 1px solid rgba(255,255,255,0.3);
padding: 8px 16px; border-radius: 8px; font-size: 12px; font-weight: 600;
cursor: pointer; text-decoration: none; font-family: inherit; transition: all 0.15s;
}
.btn-logout:hover { background: rgba(255,255,255,0.25); }
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.4; } }
.filters {
background: var(--card); border-bottom: 1px solid var(--border);
padding: 14px 40px; display: flex; gap: 24px; align-items: center; flex-wrap: wrap;
box-shadow: 0 1px 2px rgba(0,0,0,0.04);
}
.filter-group { display: flex; align-items: center; gap: 8px; }
.filter-group label {
font-size: 12px; font-weight: 600; color: var(--text-secondary);
text-transform: uppercase; letter-spacing: 0.3px;
}
.filter-group input, .filter-group select {
padding: 8px 14px; border: 1.5px solid var(--border); border-radius: 8px;
font-size: 13px; font-family: inherit; background: white; color: var(--text); transition: all 0.15s;
}
.filter-group input:focus, .filter-group select:focus {
outline: none; border-color: var(--primary); box-shadow: 0 0 0 3px rgba(108,63,160,0.12);
}
.btn-apply {
background: var(--primary); color: white; border: none; padding: 9px 24px;
border-radius: 8px; font-size: 13px; font-weight: 600; font-family: inherit;
cursor: pointer; transition: all 0.15s; box-shadow: 0 1px 3px rgba(108,63,160,0.3);
}
.btn-apply:hover { background: var(--primary-light); transform: translateY(-1px); }
.container { padding: 28px 40px; max-width: 1480px; margin: 0 auto; }
.kpi-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; margin-bottom: 28px; }
.kpi-card {
background: var(--card); border-radius: 12px; padding: 20px 22px;
border: 1px solid var(--border); box-shadow: 0 1px 3px rgba(0,0,0,0.06);
display: flex; align-items: flex-start; gap: 14px; transition: box-shadow 0.15s; overflow: hidden;
}
.kpi-card:hover { box-shadow: 0 4px 12px rgba(0,0,0,0.08); }
.kpi-icon {
width: 44px; height: 44px; border-radius: 10px;
display: flex; align-items: center; justify-content: center; font-size: 20px; flex-shrink: 0;
}
.kpi-icon.purple { background: var(--primary-bg); color: var(--primary); }
.kpi-icon.green { background: var(--green-bg); color: var(--green); }
.kpi-icon.blue { background: var(--blue-bg); color: var(--blue); }
.kpi-icon.orange { background: var(--orange-bg); color: var(--orange); }
.kpi-info { flex: 1; min-width: 0; }
.kpi-card .kpi-label { font-size: 11px; font-weight: 600; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 4px; }
.kpi-card .kpi-value { font-size: 20px; font-weight: 800; color: var(--text); line-height: 1.2; letter-spacing: -0.3px; word-break: break-word; font-variant-numeric: tabular-nums; }
.kpi-card .kpi-sub { font-size: 11px; color: var(--text-muted); margin-top: 3px; font-weight: 400; }
.charts-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-bottom: 28px; }
.chart-card { background: var(--card); border-radius: 12px; padding: 22px; border: 1px solid var(--border); box-shadow: 0 1px 3px rgba(0,0,0,0.06); }
.chart-card h3 { font-size: 14px; font-weight: 700; margin-bottom: 16px; color: var(--text); }
.chart-card canvas { width: 100% !important; }
.table-card { background: var(--card); border-radius: 12px; border: 1px solid var(--border); box-shadow: 0 1px 3px rgba(0,0,0,0.06); overflow: hidden; margin-bottom: 28px; }
.table-card h3 { font-size: 15px; font-weight: 700; padding: 18px 22px; border-bottom: 1px solid var(--border); }
.table-wrap { overflow-x: auto; }
table { width: 100%; border-collapse: collapse; font-size: 13px; }
thead th { background: #FAFBFC; padding: 11px 16px; text-align: left; font-weight: 600; color: var(--text-secondary); font-size: 11px; text-transform: uppercase; letter-spacing: 0.4px; border-bottom: 2px solid var(--border); white-space: nowrap; position: sticky; top: 0; }
tbody td { padding: 11px 16px; border-bottom: 1px solid #F3F4F6; white-space: nowrap; font-variant-numeric: tabular-nums; }
tbody tr:hover { background: #F8F5FF; }
tbody tr:nth-child(even) { background: #FAFBFC; }
tbody tr:nth-child(even):hover { background: #F8F5FF; }
.num { text-align: right; }
.footer { text-align: center; padding: 20px; font-size: 12px; color: var(--text-muted); }
@media (max-width: 1100px) { .kpi-grid { grid-template-columns: repeat(2, 1fr); } }
@media (max-width: 900px) { .charts-grid { grid-template-columns: 1fr; } .kpi-grid { grid-template-columns: repeat(2, 1fr); } .container { padding: 20px; } .filters { padding: 12px 20px; } .header { padding: 20px; } }
@media (max-width: 600px) { .kpi-grid { grid-template-columns: 1fr; } }
</style>
</head>
<body>
<div class="header">
<div>
<h1>${agente.nome} &mdash; Agente ${agente.agente_id}</h1>
<div class="subtitle">Dashboard de Transacoes BRL &harr; USD</div>
</div>
<div class="header-right">
<div class="badge"><span class="live-dot"></span>Ao vivo &mdash; ${now}</div>
<a href="/logout" class="btn-logout">Sair</a>
</div>
</div>
<div class="filters">
<div class="filter-group"><label>De:</label><input type="date" id="filterStart"></div>
<div class="filter-group"><label>Ate:</label><input type="date" id="filterEnd"></div>
<div class="filter-group">
<label>Granulacao:</label>
<select id="filterGran">
<option value="dia">Dia</option>
<option value="mes" selected>Mes</option>
<option value="ano">Ano</option>
</select>
</div>
<div class="filter-group">
<label>Fluxo:</label>
<select id="filterFluxo">
<option value="">Todos</option>
<option value="BRL \\u2192 USD">BRL &rarr; USD</option>
<option value="USD \\u2192 BRL">USD &rarr; BRL</option>
</select>
</div>
<div class="filter-group">
<label>Cliente:</label>
<select id="filterCliente"><option value="">Todos</option></select>
</div>
<button class="btn-apply" onclick="applyFilters()">Aplicar</button>
</div>
<div class="container">
<div class="kpi-grid" id="kpiGrid"></div>
<div class="charts-grid">
<div class="chart-card"><h3>Volume BRL / USD por Periodo</h3><canvas id="chartVolume"></canvas></div>
<div class="chart-card"><h3>Volume por Cliente (Top 10)</h3><canvas id="chartClientes"></canvas></div>
<div class="chart-card"><h3>Taxa Cobrada vs PTAX</h3><canvas id="chartTaxas"></canvas></div>
</div>
<div class="table-card">
<h3 id="tableTitle">Transacoes</h3>
<div class="table-wrap">
<table><thead id="tableHead"></thead><tbody id="tableBody"></tbody></table>
</div>
</div>
</div>
<div class="footer">CambioReal &mdash; ${agente.nome} BI Dashboard &mdash; Dados ao vivo</div>
<script>
const RAW_DATA = ${JSON.stringify(data)};
let filtered = [];
let charts = {};
const fmtBRL = v => v.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
const fmtUSD = v => v.toLocaleString('pt-BR', { style: 'currency', currency: 'USD' });
const fmtNum = (v, d=2) => v.toLocaleString('pt-BR', { minimumFractionDigits: d, maximumFractionDigits: d });
const fmtPct = v => fmtNum(v, 2) + '%';
(function init() {
const clientes = [...new Set(RAW_DATA.map(r => r.cliente))].sort();
const sel = document.getElementById('filterCliente');
clientes.forEach(c => { const o = document.createElement('option'); o.value = c; o.textContent = c; sel.appendChild(o); });
const dates = RAW_DATA.map(r => r.data_operacao ? r.data_operacao.slice(0, 10) : null).filter(Boolean).sort();
if (dates.length) {
document.getElementById('filterStart').value = dates[0];
document.getElementById('filterEnd').value = dates[dates.length - 1];
}
applyFilters();
})();
function applyFilters() {
const start = document.getElementById('filterStart').value;
const end = document.getElementById('filterEnd').value;
const gran = document.getElementById('filterGran').value;
const cliente = document.getElementById('filterCliente').value;
const fluxo = document.getElementById('filterFluxo').value;
filtered = RAW_DATA.filter(r => {
const dateOnly = r.data_operacao ? r.data_operacao.slice(0, 10) : null;
if (start && dateOnly && dateOnly < start) return false;
if (end && dateOnly && dateOnly > end) return false;
if (cliente && r.cliente !== cliente) return false;
if (fluxo && r.fluxo !== fluxo) return false;
return true;
});
renderKPIs();
try { renderCharts(gran); } catch(e) { console.error('Chart error:', e); }
renderTable();
}
function renderKPIs() {
const n = filtered.length;
const totalBRL = filtered.reduce((s, r) => s + r.valor_reais, 0);
const totalUSD = filtered.reduce((s, r) => s + r.valor_dolar, 0);
const taxaMedia = n && totalUSD ? filtered.reduce((s, r) => s + r.taxa_cobrada * r.valor_dolar, 0) / totalUSD : 0;
const spreadMedio = n ? filtered.reduce((s, r) => s + r.spread_pct, 0) / n : 0;
const iofTotal = filtered.reduce((s, r) => s + r.iof_valor_rs, 0);
const ticketMedio = n ? totalUSD / n : 0;
document.getElementById('kpiGrid').innerHTML = \`
<div class="kpi-card"><div class="kpi-icon purple">&#x2194;</div><div class="kpi-info"><div class="kpi-label">Transacoes</div><div class="kpi-value">\${n}</div><div class="kpi-sub">operacoes</div></div></div>
<div class="kpi-card"><div class="kpi-icon green">R$</div><div class="kpi-info"><div class="kpi-label">Volume BRL</div><div class="kpi-value">\${fmtBRL(totalBRL)}</div><div class="kpi-sub">total movimentado</div></div></div>
<div class="kpi-card"><div class="kpi-icon blue">US$</div><div class="kpi-info"><div class="kpi-label">Volume USD</div><div class="kpi-value">\${fmtUSD(totalUSD)}</div><div class="kpi-sub">total movimentado</div></div></div>
<div class="kpi-card"><div class="kpi-icon orange">&#x2195;</div><div class="kpi-info"><div class="kpi-label">Taxa Media</div><div class="kpi-value">\${fmtNum(taxaMedia, 4)}</div><div class="kpi-sub">ponderada BRL/USD</div></div></div>
<div class="kpi-card"><div class="kpi-icon purple">%</div><div class="kpi-info"><div class="kpi-label">Spread Medio</div><div class="kpi-value">\${fmtPct(spreadMedio)}</div><div class="kpi-sub">sobre taxa cobrada</div></div></div>
<div class="kpi-card"><div class="kpi-icon green">&#x00A7;</div><div class="kpi-info"><div class="kpi-label">IOF Total</div><div class="kpi-value">\${fmtBRL(iofTotal)}</div><div class="kpi-sub">recolhido no periodo</div></div></div>
<div class="kpi-card"><div class="kpi-icon blue">&#x00D8;</div><div class="kpi-info"><div class="kpi-label">Ticket Medio</div><div class="kpi-value">\${fmtUSD(ticketMedio)}</div><div class="kpi-sub">por operacao</div></div></div>
<div class="kpi-card"><div class="kpi-icon orange">&#x263A;</div><div class="kpi-info"><div class="kpi-label">Clientes Ativos</div><div class="kpi-value">\${new Set(filtered.map(r => r.cliente)).size}</div><div class="kpi-sub">no periodo</div></div></div>
\`;
}
function groupByPeriod(gran) {
const map = {};
filtered.forEach(r => {
if (!r.data_operacao) return;
const dateOnly = r.data_operacao.slice(0, 10);
let key;
if (gran === 'dia') key = dateOnly;
else if (gran === 'mes') key = dateOnly.slice(0, 7);
else key = dateOnly.slice(0, 4);
if (!map[key]) map[key] = { totalUSD: 0, totalBRL: 0, count: 0, sumWeightTaxa: 0, sumWeightPtax: 0 };
map[key].totalUSD += r.valor_dolar;
map[key].totalBRL += r.valor_reais;
map[key].count += 1;
map[key].sumWeightTaxa += r.taxa_cobrada * r.valor_dolar;
map[key].sumWeightPtax += r.taxa_ptax * r.valor_dolar;
});
const keys = Object.keys(map).sort();
return keys.map(k => ({
label: gran === 'dia' ? k.split('-').reverse().join('/') : gran === 'mes' ? k.split('-').reverse().join('/') : k,
...map[k],
taxaMedia: map[k].totalUSD ? map[k].sumWeightTaxa / map[k].totalUSD : 0,
ptaxMedia: map[k].totalUSD ? map[k].sumWeightPtax / map[k].totalUSD : 0,
}));
}
function destroyCharts() { Object.values(charts).forEach(c => c.destroy()); charts = {}; }
function renderCharts(gran) {
destroyCharts();
const periods = groupByPeriod(gran);
const labels = periods.map(p => p.label);
charts.volume = new Chart(document.getElementById('chartVolume'), {
type: 'bar',
data: {
labels,
datasets: [
{ label: 'Volume BRL', data: periods.map(p => p.totalBRL), backgroundColor: 'rgba(30,142,62,0.65)', borderRadius: 4, yAxisID: 'yBRL' },
{ label: 'Volume USD', data: periods.map(p => p.totalUSD), backgroundColor: 'rgba(26,115,232,0.7)', borderRadius: 4, yAxisID: 'yUSD' }
]
},
options: {
responsive: true,
plugins: { legend: { position: 'top', labels: { font: { size: 11 } } } },
scales: {
yBRL: { type: 'linear', position: 'left', ticks: { callback: v => 'R$ ' + (v >= 1000 ? (v/1000).toFixed(0) + 'k' : v.toFixed(0)), font: { size: 10 } }, title: { display: true, text: 'BRL', font: { size: 11 } }, grid: { display: true } },
yUSD: { type: 'linear', position: 'right', ticks: { callback: v => '$ ' + (v >= 1000 ? (v/1000).toFixed(0) + 'k' : v.toFixed(0)), font: { size: 10 } }, title: { display: true, text: 'USD', font: { size: 11 } }, grid: { display: false } }
}
}
});
const clientMap = {};
filtered.forEach(r => { clientMap[r.cliente] = (clientMap[r.cliente] || 0) + r.valor_dolar; });
const topClientes = Object.entries(clientMap).sort((a,b) => b[1] - a[1]).slice(0, 10);
const colors = ['#6C3FA0','#8B5FBF','#2980B9','#27AE60','#E67E22','#E74C3C','#3498DB','#1ABC9C','#9B59B6','#F39C12'];
charts.clientes = new Chart(document.getElementById('chartClientes'), {
type: 'bar',
data: {
labels: topClientes.map(c => c[0].length > 20 ? c[0].slice(0,20) + '...' : c[0]),
datasets: [{ label: 'Volume USD', data: topClientes.map(c => c[1]), backgroundColor: colors, borderRadius: 4 }]
},
options: { indexAxis: 'y', responsive: true, plugins: { legend: { display: false } }, scales: { x: { ticks: { callback: v => '$ ' + (v >= 1000 ? (v/1000).toFixed(0) + 'k' : v.toFixed(0)) } } } }
});
charts.taxas = new Chart(document.getElementById('chartTaxas'), {
type: 'line',
data: {
labels,
datasets: [
{ label: 'Taxa Cobrada', data: periods.map(p => p.taxaMedia), borderColor: '#6C3FA0', backgroundColor: 'rgba(108,63,160,0.1)', fill: true, tension: 0.3, pointRadius: 3 },
{ label: 'PTAX', data: periods.map(p => p.ptaxMedia), borderColor: '#2980B9', backgroundColor: 'rgba(41,128,185,0.1)', fill: true, tension: 0.3, pointRadius: 3 }
]
},
options: { responsive: true, scales: { y: { ticks: { callback: v => v.toFixed(4) } } } }
});
}
function fmtDate(d) {
if (!d) return '-';
const parts = d.split(' ');
const ymd = parts[0].split('-').reverse().join('/');
return parts[1] ? ymd + ' ' + parts[1] : ymd;
}
function renderTable() {
const brlUsd = filtered.filter(r => r.fluxo === 'BRL \\u2192 USD');
const usdBrl = filtered.filter(r => r.fluxo === 'USD \\u2192 BRL');
document.getElementById('tableTitle').textContent = 'Transacoes (' + filtered.length + ')';
let html = '';
if (brlUsd.length) {
html += '<tr><td colspan="11" style="background:var(--primary-bg);font-weight:700;padding:10px 16px;color:var(--primary);font-size:13px;">BRL \\u2192 USD (' + brlUsd.length + ')</td></tr>';
html += brlUsd.map(r => \`<tr>
<td>\${fmtDate(r.data_operacao)}</td><td>\${r.cliente}</td>
<td class="num">\${fmtBRL(r.valor_reais)}</td><td class="num">\${fmtUSD(r.valor_dolar)}</td>
<td class="num">\${fmtPct(r.iof_pct)}</td><td class="num">\${fmtBRL(r.iof_valor_rs)}</td>
<td class="num">\${fmtNum(r.taxa_ptax, 4)}</td><td class="num">\${fmtNum(r.taxa_cobrada, 4)}</td>
<td class="num">\${fmtNum(r.spread_bruto, 4)}</td><td class="num">\${fmtPct(r.spread_pct)}</td>
<td>\${r.status || '-'}</td>
</tr>\`).join('');
}
if (usdBrl.length) {
html += '<tr><td colspan="11" style="background:var(--blue-bg);font-weight:700;padding:10px 16px;color:var(--blue);font-size:13px;">USD \\u2192 BRL (' + usdBrl.length + ')</td></tr>';
html += usdBrl.map(r => \`<tr>
<td>\${fmtDate(r.data_operacao)}</td><td>\${r.cliente}</td>
<td class="num">\${fmtBRL(r.valor_reais)}</td><td class="num">\${fmtUSD(r.valor_dolar)}</td>
<td class="num">\${fmtPct(r.iof_pct)}</td><td class="num">\${fmtBRL(r.iof_valor_rs)}</td>
<td class="num">\${fmtNum(r.taxa_ptax, 4)}</td><td class="num">\${fmtNum(r.taxa_cobrada, 4)}</td>
<td class="num">\${fmtNum(r.spread_bruto, 4)}</td><td class="num">\${fmtPct(r.spread_pct)}</td>
<td>\${r.status || '-'}</td>
</tr>\`).join('');
}
document.getElementById('tableHead').innerHTML = '<tr><th>Data/Hora</th><th>Cliente</th><th>Valor BRL</th><th>Valor USD</th><th>IOF %</th><th>IOF R$</th><th>PTAX</th><th>Taxa Cobrada</th><th>Spread</th><th>Spread %</th><th>Status</th></tr>';
document.getElementById('tableBody').innerHTML = html;
}
<\/script>
</body>
</html>`;
}
module.exports = { buildHTML };