feat: admin BI executive dashboard - visão estratégica completa

Novo dashboard admin-only (/admin/bi) com BI profissional:
- 5 hero KPIs: receita spread, volume USD, transações, clientes ativos, ticket médio
- Revenue por corredor (donut) + spread médio + volume diário (dual-axis chart)
- Top 10 clientes por volume + taxa retenção + clientes em risco (30+ dias)
- Volume stacked por corredor + netting entrada/saída + ranking agentes
- Filtros: 7d, 30d, 90d, este mês, ou período custom
- Comparativo automático vs período anterior em todos os KPIs
- Responsivo mobile (5→2→1 cols) + trading terminal USD/BRL

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
root
2026-02-14 10:52:55 -05:00
parent 3514755491
commit e24fcd64e3
4 changed files with 1071 additions and 3 deletions

View File

@@ -11,11 +11,12 @@ 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, fetchKPIs, fetchTrend30Days, fetchTopAgentes, fetchTrendByPeriod, fetchKPIsByPeriod } = require('./src/queries');
const { fetchTransacoes, fetchAllTransacoes, serialize, fetchDailyStats, fetchKPIs, fetchTrend30Days, fetchTopAgentes, fetchTrendByPeriod, fetchKPIsByPeriod, fetchBIData } = 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 { buildAdminBIHTML } = require('./src/admin-bi');
const bcrypt = require('bcrypt');
const db = require('./src/db-local');
const cache = require('./src/cache');
@@ -324,6 +325,36 @@ app.get('/admin/dashboard', requireRole('admin'), (req, res) => {
res.redirect('/corporate/dashboard');
});
// --- Admin BI Dashboard (admin only) ---
app.get('/admin/bi', requireRole('admin'), (req, res) => {
try {
const html = buildAdminBIHTML(req.session.user);
res.send(html);
} catch (err) {
console.error('Admin BI error:', err);
res.status(500).send('Erro ao carregar BI: ' + err.message);
}
});
app.get('/admin/api/bi', requireRole('admin'), async (req, res) => {
try {
const start = req.query.start;
const end = req.query.end;
if (!start || !end) return res.status(400).json({ error: 'start and end required' });
const getAgenteName = (agenteId) => {
const row = db.prepare('SELECT nome FROM agentes WHERE agente_id = ?').get(agenteId);
return row ? row.nome : null;
};
const data = await fetchBIData(start, end, getAgenteName);
res.json(data);
} catch (err) {
console.error('Admin BI API error:', err);
res.status(500).json({ error: err.message });
}
});
// Create user (admin only)
app.post('/admin/agentes', requireRole('admin'), async (req, res) => {
const { nome, email, agente_id, senha, role } = req.body;

777
src/admin-bi.js Normal file
View File

@@ -0,0 +1,777 @@
/**
* Admin BI Dashboard - Business Intelligence Executive View
* Admin-only: comprehensive KPIs, revenue analysis, client intelligence, operational metrics
*/
const { buildHeader, buildFooter, buildHead } = require('./ui-template');
function buildAdminBIHTML(user) {
const role = user.role || 'admin';
const pageScripts = '<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"><\/script>';
const now = new Date();
const firstDayOfMonth = new Date(now.getFullYear(), now.getMonth(), 1).toISOString().slice(0, 10);
const today = now.toISOString().slice(0, 10);
const thirtyDaysAgo = new Date(now.getTime() - 30 * 86400000).toISOString().slice(0, 10);
const pageCSS = `
/* Trading Terminal - Live Rates */
.trading-terminal {
background: linear-gradient(135deg, #0F1923 0%, #1A2332 50%, #0D1B2A 100%);
border-top: 1px solid rgba(0,255,136,0.15);
border-bottom: 1px solid rgba(0,255,136,0.15);
padding: 16px 40px;
position: relative; overflow: hidden;
}
.trading-terminal::before {
content: ''; position: absolute; top: 0; left: 0; right: 0; height: 1px;
background: linear-gradient(90deg, transparent, rgba(0,255,136,0.3), transparent);
}
.trading-terminal::after {
content: ''; position: absolute; bottom: 0; left: 0; right: 0; height: 1px;
background: linear-gradient(90deg, transparent, rgba(0,255,136,0.3), transparent);
}
.live-rate-bar {
display: flex; align-items: center; justify-content: center; gap: 20px;
max-width: 1600px; margin: 0 auto;
}
.terminal-title {
font-size: 10px; font-weight: 700; color: rgba(0,255,136,0.6);
text-transform: uppercase; letter-spacing: 2px; margin-right: 8px;
font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace;
}
.live-rate-dot {
width: 7px; height: 7px; border-radius: 50%; background: #00FF88;
display: inline-block; animation: blink 1.5s infinite;
box-shadow: 0 0 6px rgba(0,255,136,0.6);
}
@keyframes blink { 0%, 100% { opacity: 1; } 50% { opacity: 0.2; } }
.rate-pair-group {
display: flex; align-items: center; gap: 8px;
background: rgba(255,255,255,0.03);
border: 1px solid rgba(255,255,255,0.06);
border-radius: 8px; padding: 8px 14px;
}
.rate-pair-label {
font-size: 11px; font-weight: 800; color: rgba(255,255,255,0.35);
text-transform: uppercase; letter-spacing: 1px; writing-mode: vertical-rl;
text-orientation: mixed; font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace;
}
.live-rate-btn {
display: flex; flex-direction: column; align-items: center; gap: 1px;
padding: 8px 18px; border-radius: 6px; border: 1px solid transparent;
font-family: inherit; font-size: 14px; font-weight: 700; cursor: default;
transition: all 0.25s; min-width: 150px; justify-content: center; position: relative;
}
.rate-flags { font-size: 9px; opacity: 0.5; letter-spacing: 1px; line-height: 1; }
.live-rate-btn.compra { background: rgba(0,255,136,0.08); color: #00FF88; border-color: rgba(0,255,136,0.2); }
.live-rate-btn.compra:hover { background: rgba(0,255,136,0.14); }
.live-rate-btn.venda { background: rgba(255,68,68,0.08); color: #FF4444; border-color: rgba(255,68,68,0.2); }
.live-rate-btn.venda:hover { background: rgba(255,68,68,0.14); }
.live-rate-btn .rate-value {
font-size: 22px; font-weight: 800; letter-spacing: -0.5px;
font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace;
font-variant-numeric: tabular-nums; text-shadow: 0 0 12px currentColor;
}
.live-rate-btn .rate-type {
font-size: 9px; text-transform: uppercase; letter-spacing: 1.5px;
opacity: 0.5; font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace;
}
.live-rate-btn.pulse .rate-value { animation: ratePulse 0.4s ease; }
@keyframes ratePulse {
0% { transform: scale(1); text-shadow: 0 0 12px currentColor; }
50% { transform: scale(1.06); text-shadow: 0 0 24px currentColor, 0 0 48px currentColor; }
100% { transform: scale(1); text-shadow: 0 0 12px currentColor; }
}
.live-rate-time {
font-size: 10px; color: rgba(255,255,255,0.25);
font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace; letter-spacing: 0.5px;
}
/* Filter Bar */
.filter-bar {
background: var(--card); border-radius: 16px; padding: 20px 24px;
border: 1px solid var(--border); box-shadow: 0 2px 8px rgba(0,0,0,0.04);
margin-bottom: 24px; display: flex; align-items: center; gap: 16px; flex-wrap: wrap;
}
.filter-bar-label { font-size: 13px; font-weight: 600; color: var(--text-secondary); }
.filter-presets { display: flex; gap: 8px; }
.preset-btn {
padding: 8px 16px; border: 1px solid var(--border); border-radius: 8px;
background: var(--bg); font-size: 13px; font-weight: 600; cursor: pointer;
color: var(--text-secondary); transition: all 0.15s; font-family: inherit;
}
.preset-btn:hover { border-color: var(--admin-accent); color: var(--admin-accent); }
.preset-btn.active {
background: var(--admin-accent); color: white; border-color: var(--admin-accent);
}
.filter-divider { width: 1px; height: 32px; background: var(--border); }
.date-inputs { display: flex; align-items: center; gap: 8px; }
.date-inputs label { font-size: 12px; font-weight: 600; color: var(--text-muted); }
.date-inputs input[type="date"] {
padding: 8px 12px; border: 1px solid var(--border); border-radius: 8px;
font-size: 13px; font-family: inherit; background: var(--bg); color: var(--text);
}
.period-info {
margin-left: auto; font-size: 12px; color: var(--text-muted);
font-weight: 500; background: var(--bg); padding: 6px 12px; border-radius: 6px;
}
/* Hero KPI Cards */
.hero-grid {
display: grid; grid-template-columns: repeat(5, 1fr); gap: 16px; margin-bottom: 28px;
}
.hero-card {
background: var(--card); border-radius: 16px; padding: 20px 24px;
border: 1px solid var(--border); box-shadow: 0 2px 8px rgba(0,0,0,0.04);
position: relative; overflow: hidden;
}
.hero-card::before {
content: ''; position: absolute; top: 0; left: 0; right: 0; height: 3px;
}
.hero-card.spread::before { background: linear-gradient(90deg, var(--green), #4CAF50); }
.hero-card.volume::before { background: linear-gradient(90deg, var(--blue), #42A5F5); }
.hero-card.transactions::before { background: linear-gradient(90deg, var(--purple), #AB47BC); }
.hero-card.clients::before { background: linear-gradient(90deg, var(--orange), #FFA726); }
.hero-card.ticket::before { background: linear-gradient(90deg, #00897B, #26A69A); }
.hero-label {
font-size: 11px; font-weight: 700; color: var(--text-muted);
text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 8px;
}
.hero-value {
font-size: 28px; font-weight: 800; color: var(--text); margin-bottom: 4px;
font-variant-numeric: tabular-nums;
}
.hero-badge {
display: inline-block; font-size: 11px; font-weight: 700; padding: 3px 10px;
border-radius: 12px;
}
.hero-badge.up { background: var(--green-bg); color: var(--green); }
.hero-badge.down { background: var(--red-bg); color: var(--red); }
.hero-badge.neutral { background: var(--blue-bg); color: var(--blue); }
.hero-sub { font-size: 12px; color: var(--text-muted); margin-top: 6px; }
/* Section Headers */
.section-title {
font-size: 14px; font-weight: 700; color: var(--text-secondary);
text-transform: uppercase; letter-spacing: 1px; margin-bottom: 16px;
display: flex; align-items: center; gap: 8px;
}
.section-title .icon {
width: 28px; height: 28px; border-radius: 8px; display: flex;
align-items: center; justify-content: center; font-size: 14px;
}
/* Charts Grid */
.charts-row {
display: grid; grid-template-columns: 1fr 2fr; gap: 20px; margin-bottom: 28px;
}
.charts-row.equal { grid-template-columns: 1fr 1fr; }
.charts-row.triple { grid-template-columns: 1fr 1fr 1fr; }
.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);
}
.chart-card h3 {
font-size: 14px; font-weight: 700; color: var(--text); margin-bottom: 16px;
display: flex; align-items: center; gap: 8px;
}
.chart-card h3 .badge {
font-size: 10px; padding: 3px 8px; border-radius: 10px;
font-weight: 700; background: var(--bg); color: var(--text-muted);
}
.chart-wrap { position: relative; height: 280px; }
.chart-wrap.short { height: 220px; }
/* Data Tables */
.data-table {
width: 100%; border-collapse: collapse; font-size: 13px;
}
.data-table th {
text-align: left; padding: 10px 12px; font-weight: 700; font-size: 11px;
text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-muted);
border-bottom: 2px solid var(--border);
}
.data-table td {
padding: 10px 12px; border-bottom: 1px solid var(--border);
color: var(--text); font-variant-numeric: tabular-nums;
}
.data-table tr:last-child td { border-bottom: none; }
.data-table tr:hover td { background: var(--bg); }
.data-table .rank {
width: 32px; height: 32px; border-radius: 50%; display: inline-flex;
align-items: center; justify-content: center; font-weight: 800; font-size: 12px;
}
.rank-1 { background: #FFF8E1; color: #F9A825; }
.rank-2 { background: #F5F5F5; color: #757575; }
.rank-3 { background: #FBE9E7; color: #D84315; }
.rank-default { background: var(--bg); color: var(--text-muted); }
/* Gauge / Metric Cards */
.metric-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);
text-align: center;
}
.metric-card h3 {
font-size: 14px; font-weight: 700; color: var(--text); margin-bottom: 16px;
}
.gauge-value {
font-size: 48px; font-weight: 800; margin: 12px 0;
font-variant-numeric: tabular-nums;
}
.gauge-value.green { color: var(--green); }
.gauge-value.red { color: var(--red); }
.gauge-value.blue { color: var(--blue); }
.gauge-bar {
height: 8px; border-radius: 4px; background: var(--bg); overflow: hidden; margin: 12px 0;
}
.gauge-bar-fill {
height: 100%; border-radius: 4px; transition: width 0.6s ease;
}
.gauge-bar-fill.green { background: linear-gradient(90deg, var(--green), #4CAF50); }
.gauge-bar-fill.red { background: linear-gradient(90deg, var(--red), #EF5350); }
.gauge-bar-fill.blue { background: linear-gradient(90deg, var(--blue), #42A5F5); }
.gauge-label { font-size: 12px; color: var(--text-muted); }
/* Netting Card */
.netting-row {
display: flex; justify-content: space-between; align-items: center;
padding: 10px 0; border-bottom: 1px solid var(--border);
}
.netting-row:last-child { border-bottom: none; }
.netting-label { font-size: 13px; color: var(--text-secondary); font-weight: 600; }
.netting-value { font-size: 15px; font-weight: 700; font-variant-numeric: tabular-nums; }
.netting-value.green { color: var(--green); }
.netting-value.red { color: var(--red); }
/* Loading */
.loading-overlay {
position: absolute; inset: 0; background: rgba(255,255,255,0.8);
display: flex; align-items: center; justify-content: center;
border-radius: 16px; z-index: 10; font-size: 13px; color: var(--text-muted);
}
/* Responsive */
@media (max-width: 768px) {
.trading-terminal { padding: 12px 16px; }
.live-rate-bar { flex-wrap: wrap; gap: 10px; justify-content: center; }
.terminal-title { display: none; }
.rate-pair-group { padding: 8px 12px; width: 100%; justify-content: center; }
.live-rate-btn { min-width: 120px; padding: 8px 14px; flex: 1; }
.live-rate-btn .rate-value { font-size: 18px; }
.live-rate-time { width: 100%; text-align: center; }
.hero-grid { grid-template-columns: repeat(2, 1fr); }
.hero-card:last-child { grid-column: span 2; }
.hero-value { font-size: 22px; }
.charts-row, .charts-row.equal, .charts-row.triple { grid-template-columns: 1fr; }
.filter-bar { padding: 14px 16px; gap: 10px; }
.filter-presets { flex-wrap: wrap; }
.period-info { margin-left: 0; width: 100%; text-align: center; }
.filter-divider { display: none; }
}
@media (max-width: 480px) {
.trading-terminal { padding: 10px 12px; }
.live-rate-bar { gap: 8px; flex-direction: column; align-items: center; }
.rate-pair-group { padding: 10px; gap: 6px; }
.rate-pair-label { writing-mode: horizontal-tb; font-size: 10px; }
.live-rate-btn { min-width: 110px; padding: 8px 12px; }
.live-rate-btn .rate-value { font-size: 16px; }
.live-rate-btn .rate-type { font-size: 8px; }
.rate-flags { font-size: 9px; }
.live-rate-time { font-size: 9px; }
.hero-grid { grid-template-columns: 1fr; }
.hero-card:last-child { grid-column: span 1; }
.hero-value { font-size: 20px; }
.chart-card { padding: 16px; }
.chart-wrap { height: 220px; }
.chart-wrap.short { height: 180px; }
.data-table { font-size: 11px; }
.data-table th, .data-table td { padding: 8px 6px; }
.gauge-value { font-size: 36px; }
}
`;
return `<!DOCTYPE html>
<html lang="pt-BR">
<head>
${buildHead('BI Executive', pageCSS, pageScripts)}
</head>
<body>
${buildHeader({ role: role, userName: user.nome, activePage: 'bi' })}
<div class="trading-terminal">
<div class="live-rate-bar">
<span class="live-rate-dot"></span>
<span class="terminal-title">Live Rates</span>
<div class="rate-pair-group">
<span class="rate-pair-label">USD</span>
<button class="live-rate-btn compra">
<span class="rate-flags">\\uD83C\\uDDFA\\uD83C\\uDDF8 \\u2192 \\uD83C\\uDDE7\\uD83C\\uDDF7</span>
<span class="rate-type">Compra</span>
<span class="rate-value" id="rateUsdCompra">--</span>
</button>
<button class="live-rate-btn venda">
<span class="rate-flags">\\uD83C\\uDDE7\\uD83C\\uDDF7 \\u2192 \\uD83C\\uDDFA\\uD83C\\uDDF8</span>
<span class="rate-type">Venda</span>
<span class="rate-value" id="rateUsdVenda">--</span>
</button>
</div>
<span class="live-rate-time" id="rateTime">--</span>
</div>
</div>
<div class="app-container">
<!-- Filter Bar -->
<div class="filter-bar">
<span class="filter-bar-label">Periodo:</span>
<div class="filter-presets">
<button class="preset-btn" data-preset="7d">7 Dias</button>
<button class="preset-btn active" data-preset="30d">30 Dias</button>
<button class="preset-btn" data-preset="90d">90 Dias</button>
<button class="preset-btn" data-preset="thisMonth">Este Mes</button>
</div>
<div class="filter-divider"></div>
<div class="date-inputs">
<label>De:</label>
<input type="date" id="dateStart" value="${thirtyDaysAgo}">
<label>Ate:</label>
<input type="date" id="dateEnd" value="${today}">
</div>
<span class="period-info" id="periodInfo">Carregando...</span>
</div>
<!-- Hero KPI Cards -->
<div class="hero-grid" id="heroGrid">
<div class="hero-card spread">
<div class="hero-label">Receita de Spread</div>
<div class="hero-value" id="kpiSpreadRevenue">--</div>
<span class="hero-badge neutral" id="kpiSpreadBadge">--</span>
<div class="hero-sub" id="kpiSpreadSub">vs periodo anterior</div>
</div>
<div class="hero-card volume">
<div class="hero-label">Volume Processado</div>
<div class="hero-value" id="kpiVolume">--</div>
<span class="hero-badge neutral" id="kpiVolumeBadge">--</span>
<div class="hero-sub" id="kpiVolumeSub">USD total movimentado</div>
</div>
<div class="hero-card transactions">
<div class="hero-label">Transacoes</div>
<div class="hero-value" id="kpiTransactions">--</div>
<span class="hero-badge neutral" id="kpiTransBadge">--</span>
<div class="hero-sub" id="kpiTransSub">operacoes no periodo</div>
</div>
<div class="hero-card clients">
<div class="hero-label">Clientes Ativos</div>
<div class="hero-value" id="kpiClients">--</div>
<div class="hero-sub">clientes unicos no periodo</div>
</div>
<div class="hero-card ticket">
<div class="hero-label">Ticket Medio</div>
<div class="hero-value" id="kpiTicket">--</div>
<div class="hero-sub">USD por operacao</div>
</div>
</div>
<!-- Section: Revenue & Spread -->
<div class="section-title">
<span class="icon" style="background:var(--green-bg);color:var(--green);">$</span>
Revenue & Spread
</div>
<div class="charts-row">
<div class="chart-card">
<h3>Revenue por Corredor</h3>
<div class="chart-wrap short"><canvas id="chartDonut"></canvas></div>
</div>
<div class="chart-card">
<h3>Spread Medio + Volume Diario <span class="badge" id="spreadBadge">--</span></h3>
<div class="chart-wrap"><canvas id="chartSpreadTrend"></canvas></div>
</div>
</div>
<!-- Section: Client Intelligence -->
<div class="section-title">
<span class="icon" style="background:var(--orange-bg);color:var(--orange);">&#x1F465;</span>
Inteligencia de Clientes
</div>
<div class="charts-row equal">
<div class="chart-card">
<h3>Top 10 Clientes por Volume</h3>
<div class="chart-wrap"><canvas id="chartTopClients"></canvas></div>
</div>
<div class="chart-card" style="display:flex;flex-direction:column;gap:20px;">
<div class="metric-card" style="box-shadow:none;border:1px solid var(--border);padding:16px;">
<h3 style="margin-bottom:8px;">Taxa de Retencao</h3>
<div class="gauge-value green" id="retentionValue">--%</div>
<div class="gauge-bar"><div class="gauge-bar-fill green" id="retentionBar" style="width:0%"></div></div>
<div class="gauge-label" id="retentionLabel">-- de -- clientes retidos</div>
</div>
<div style="flex:1;overflow:auto;">
<h3 style="font-size:14px;font-weight:700;margin-bottom:12px;color:var(--text);">
Clientes em Risco <span style="font-size:11px;color:var(--red);font-weight:600;">(30+ dias sem operar)</span>
</h3>
<table class="data-table" id="riskTable">
<thead><tr><th>Cliente</th><th>Ultima Op</th><th>Volume USD</th></tr></thead>
<tbody id="riskTableBody"><tr><td colspan="3" style="text-align:center;color:var(--text-muted);">Carregando...</td></tr></tbody>
</table>
</div>
</div>
</div>
<!-- Section: Operational -->
<div class="section-title">
<span class="icon" style="background:var(--blue-bg);color:var(--blue);">&#x2699;</span>
Operacional
</div>
<div class="charts-row triple">
<div class="chart-card">
<h3>Volume por Corredor</h3>
<div class="chart-wrap"><canvas id="chartVolFlow"></canvas></div>
</div>
<div class="chart-card" style="display:flex;flex-direction:column;gap:16px;">
<h3 style="font-size:14px;font-weight:700;color:var(--text);margin:0;">Netting & Balanco</h3>
<div id="nettingContent">
<div class="netting-row">
<span class="netting-label">Saida (BRL&#x2192;USD)</span>
<span class="netting-value red" id="nettingSaida">--</span>
</div>
<div class="netting-row">
<span class="netting-label">Entrada (USD&#x2192;BRL)</span>
<span class="netting-value green" id="nettingEntrada">--</span>
</div>
<div class="netting-row" style="border-top:2px solid var(--border);padding-top:12px;">
<span class="netting-label" style="font-weight:700;">Posicao Liquida</span>
<span class="netting-value" id="nettingPosicao">--</span>
</div>
<div style="margin-top:16px;">
<div style="font-size:12px;color:var(--text-muted);margin-bottom:4px;">Eficiencia Netting</div>
<div class="gauge-value blue" id="nettingEficiencia" style="font-size:36px;margin:4px 0;">--%</div>
<div class="gauge-bar"><div class="gauge-bar-fill blue" id="nettingBar" style="width:0%"></div></div>
</div>
</div>
</div>
<div class="chart-card" style="overflow:auto;">
<h3>Ranking Agentes</h3>
<table class="data-table">
<thead><tr><th>#</th><th>Agente</th><th>Volume</th><th>Ops</th><th>Spread R$</th><th>Clientes</th></tr></thead>
<tbody id="agentTableBody"><tr><td colspan="6" style="text-align:center;color:var(--text-muted);">Carregando...</td></tr></tbody>
</table>
</div>
</div>
</div>
${buildFooter()}
<script>
// === Formatters ===
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 => v.toLocaleString('pt-BR');
const fmtPct = (curr, prev) => {
if (!prev || prev === 0) return { text: 'N/A', cls: 'neutral' };
const pct = ((curr - prev) / prev * 100).toFixed(1);
return { text: (pct > 0 ? '+' : '') + pct + '%', cls: pct >= 0 ? 'up' : 'down' };
};
// === Date & Filter Logic ===
let currentStart = '${thirtyDaysAgo}';
let currentEnd = '${today}';
function setPreset(preset) {
const now = new Date();
const today = now.toISOString().slice(0, 10);
let start;
switch(preset) {
case '7d':
start = new Date(now.getTime() - 7 * 86400000).toISOString().slice(0, 10); break;
case '30d':
start = new Date(now.getTime() - 30 * 86400000).toISOString().slice(0, 10); break;
case '90d':
start = new Date(now.getTime() - 90 * 86400000).toISOString().slice(0, 10); break;
case 'thisMonth':
start = new Date(now.getFullYear(), now.getMonth(), 1).toISOString().slice(0, 10); break;
default:
start = new Date(now.getTime() - 30 * 86400000).toISOString().slice(0, 10);
}
document.getElementById('dateStart').value = start;
document.getElementById('dateEnd').value = today;
document.querySelectorAll('.preset-btn').forEach(b => b.classList.remove('active'));
document.querySelector('[data-preset="'+preset+'"]').classList.add('active');
currentStart = start; currentEnd = today;
loadBI();
}
document.querySelectorAll('.preset-btn').forEach(btn => {
btn.addEventListener('click', () => setPreset(btn.dataset.preset));
});
document.getElementById('dateStart').addEventListener('change', () => {
currentStart = document.getElementById('dateStart').value;
document.querySelectorAll('.preset-btn').forEach(b => b.classList.remove('active'));
loadBI();
});
document.getElementById('dateEnd').addEventListener('change', () => {
currentEnd = document.getElementById('dateEnd').value;
document.querySelectorAll('.preset-btn').forEach(b => b.classList.remove('active'));
loadBI();
});
// === Chart instances ===
let chartDonut, chartSpreadTrend, chartTopClients, chartVolFlow;
function destroyCharts() {
[chartDonut, chartSpreadTrend, chartTopClients, chartVolFlow].forEach(c => { if (c) c.destroy(); });
}
// === Main Data Loader ===
async function loadBI() {
const periodDays = Math.round((new Date(currentEnd) - new Date(currentStart)) / 86400000) + 1;
document.getElementById('periodInfo').textContent = periodDays + ' dias | ' + currentStart.split('-').reverse().join('/') + ' a ' + currentEnd.split('-').reverse().join('/');
try {
const resp = await fetch('/admin/api/bi?start=' + currentStart + '&end=' + currentEnd);
const d = await resp.json();
// Hero KPIs
document.getElementById('kpiSpreadRevenue').textContent = fmtBRL(d.kpis.total.spread_revenue);
const sBadge = fmtPct(d.kpis.total.spread_revenue, d.comparison.prev_spread);
document.getElementById('kpiSpreadBadge').textContent = sBadge.text;
document.getElementById('kpiSpreadBadge').className = 'hero-badge ' + sBadge.cls;
document.getElementById('kpiVolume').textContent = fmtUSD(d.kpis.total.vol_usd);
const vBadge = fmtPct(d.kpis.total.vol_usd, d.comparison.prev_vol_usd);
document.getElementById('kpiVolumeBadge').textContent = vBadge.text;
document.getElementById('kpiVolumeBadge').className = 'hero-badge ' + vBadge.cls;
document.getElementById('kpiTransactions').textContent = fmtNum(d.kpis.total.qtd);
const tBadge = fmtPct(d.kpis.total.qtd, d.comparison.prev_qtd);
document.getElementById('kpiTransBadge').textContent = tBadge.text;
document.getElementById('kpiTransBadge').className = 'hero-badge ' + tBadge.cls;
document.getElementById('kpiClients').textContent = fmtNum(d.kpis.total.clientes);
document.getElementById('kpiTicket').textContent = fmtUSD(d.kpis.total.ticket_medio);
// Spread badge
const avgSpread = d.kpis.brlUsd.avg_spread_pct || d.kpis.usdBrl.avg_spread_pct || 0;
document.getElementById('spreadBadge').textContent = 'Media: ' + avgSpread.toFixed(2) + '%';
// === Donut Chart: Revenue por Corredor ===
destroyCharts();
chartDonut = new Chart(document.getElementById('chartDonut'), {
type: 'doughnut',
data: {
labels: ['BRL \\u2192 USD', 'USD \\u2192 BRL'],
datasets: [{
data: [d.kpis.brlUsd.spread_revenue, d.kpis.usdBrl.spread_revenue],
backgroundColor: ['#1A73E8', '#1E8E3E'],
borderWidth: 0, hoverOffset: 8
}]
},
options: {
responsive: true, maintainAspectRatio: false,
plugins: {
legend: { position: 'bottom', labels: { padding: 16, font: { size: 12, weight: 600 } } },
tooltip: { callbacks: { label: ctx => ctx.label + ': ' + fmtBRL(ctx.raw) } }
},
cutout: '65%'
}
});
// === Spread Trend + Volume Line Chart ===
const allDays = new Set();
d.trend.brlUsd.forEach(t => allDays.add(t.dia));
d.trend.usdBrl.forEach(t => allDays.add(t.dia));
d.trend.usdUsd.forEach(t => allDays.add(t.dia));
const days = [...allDays].sort();
const labels = days.map(d => { const p = d.split('-'); return p[2] + '/' + p[1]; });
const spreadByDay = {};
const volByDay = {};
days.forEach(day => { spreadByDay[day] = []; volByDay[day] = 0; });
d.trend.brlUsd.forEach(t => { spreadByDay[t.dia].push(t.avg_spread); volByDay[t.dia] += t.vol_usd; });
d.trend.usdBrl.forEach(t => { spreadByDay[t.dia].push(t.avg_spread); volByDay[t.dia] += t.vol_usd; });
d.trend.usdUsd.forEach(t => { volByDay[t.dia] += t.vol_usd; });
const avgSpreads = days.map(day => {
const vals = spreadByDay[day];
return vals.length > 0 ? vals.reduce((a,b) => a+b, 0) / vals.length : null;
});
const volumes = days.map(day => volByDay[day]);
chartSpreadTrend = new Chart(document.getElementById('chartSpreadTrend'), {
type: 'bar',
data: {
labels,
datasets: [
{
label: 'Volume USD', data: volumes, type: 'bar',
backgroundColor: 'rgba(26,115,232,0.15)', borderColor: 'rgba(26,115,232,0.4)',
borderWidth: 1, borderRadius: 4, yAxisID: 'y', order: 2
},
{
label: 'Spread Medio %', data: avgSpreads, type: 'line',
borderColor: '#1E8E3E', backgroundColor: 'rgba(30,142,62,0.1)',
borderWidth: 2, pointRadius: 2, pointHoverRadius: 5, fill: true,
tension: 0.3, yAxisID: 'y1', order: 1, spanGaps: true
}
]
},
options: {
responsive: true, maintainAspectRatio: false, interaction: { mode: 'index', intersect: false },
plugins: {
legend: { position: 'top', labels: { font: { size: 11, weight: 600 }, padding: 12 } },
tooltip: {
callbacks: {
label: ctx => ctx.dataset.label === 'Volume USD'
? 'Volume: ' + fmtUSD(ctx.raw)
: 'Spread: ' + (ctx.raw !== null ? ctx.raw.toFixed(2) + '%' : 'N/A')
}
}
},
scales: {
y: { position: 'left', grid: { color: 'rgba(0,0,0,0.04)' }, ticks: { callback: v => fmtUSD(v), font: { size: 10 } } },
y1: { position: 'right', grid: { display: false }, ticks: { callback: v => v.toFixed(1) + '%', font: { size: 10 } }, min: 0 },
x: { grid: { display: false }, ticks: { font: { size: 10 }, maxRotation: 45 } }
}
}
});
// === Top 10 Clients Horizontal Bar ===
const clientNames = d.topClients.map(c => c.nome.length > 25 ? c.nome.slice(0, 22) + '...' : c.nome);
const clientVols = d.topClients.map(c => c.vol_usd);
chartTopClients = new Chart(document.getElementById('chartTopClients'), {
type: 'bar',
data: {
labels: clientNames,
datasets: [{
label: 'Volume USD', data: clientVols,
backgroundColor: 'rgba(118,0,190,0.12)', borderColor: 'rgba(118,0,190,0.5)',
borderWidth: 1, borderRadius: 4
}]
},
options: {
responsive: true, maintainAspectRatio: false, indexAxis: 'y',
plugins: {
legend: { display: false },
tooltip: { callbacks: { label: ctx => fmtUSD(ctx.raw) + ' (' + d.topClients[ctx.dataIndex].qtd + ' ops)' } }
},
scales: {
x: { grid: { color: 'rgba(0,0,0,0.04)' }, ticks: { callback: v => fmtUSD(v), font: { size: 10 } } },
y: { grid: { display: false }, ticks: { font: { size: 11, weight: 600 } } }
}
}
});
// Retention
document.getElementById('retentionValue').textContent = d.retention.rate + '%';
document.getElementById('retentionBar').style.width = d.retention.rate + '%';
document.getElementById('retentionLabel').textContent = d.retention.retained + ' de ' + d.retention.prev_clients + ' clientes retidos';
const retEl = document.getElementById('retentionValue');
retEl.className = 'gauge-value ' + (d.retention.rate >= 70 ? 'green' : d.retention.rate >= 40 ? 'blue' : 'red');
document.getElementById('retentionBar').className = 'gauge-bar-fill ' + (d.retention.rate >= 70 ? 'green' : d.retention.rate >= 40 ? 'blue' : 'red');
// Clients at risk table
const riskBody = document.getElementById('riskTableBody');
if (d.clientsAtRisk.length === 0) {
riskBody.innerHTML = '<tr><td colspan="3" style="text-align:center;color:var(--green);font-weight:600;">Nenhum cliente em risco</td></tr>';
} else {
riskBody.innerHTML = d.clientsAtRisk.map(c =>
'<tr><td style="font-weight:600;">' + (c.nome.length > 30 ? c.nome.slice(0,27)+'...' : c.nome) + '</td>' +
'<td style="color:var(--red);">' + c.last_op.slice(0,10) + '</td>' +
'<td>' + fmtUSD(c.vol_usd) + '</td></tr>'
).join('');
}
// === Volume by Flow Stacked Chart ===
const volBrlUsd = days.map(day => { const t = d.trend.brlUsd.find(x => x.dia === day); return t ? t.vol_usd : 0; });
const volUsdBrl = days.map(day => { const t = d.trend.usdBrl.find(x => x.dia === day); return t ? t.vol_usd : 0; });
const volUsdUsd = days.map(day => { const t = d.trend.usdUsd.find(x => x.dia === day); return t ? t.vol_usd : 0; });
chartVolFlow = new Chart(document.getElementById('chartVolFlow'), {
type: 'bar',
data: {
labels,
datasets: [
{ label: 'BRL\\u2192USD', data: volBrlUsd, backgroundColor: '#1A73E8', borderRadius: 2 },
{ label: 'USD\\u2192BRL', data: volUsdBrl, backgroundColor: '#1E8E3E', borderRadius: 2 },
{ label: 'USD\\u2192USD', data: volUsdUsd, backgroundColor: '#7B1FA2', borderRadius: 2 }
]
},
options: {
responsive: true, maintainAspectRatio: false,
plugins: {
legend: { position: 'top', labels: { font: { size: 11, weight: 600 }, padding: 12 } },
tooltip: { callbacks: { label: ctx => ctx.dataset.label + ': ' + fmtUSD(ctx.raw) } }
},
scales: {
x: { stacked: true, grid: { display: false }, ticks: { font: { size: 10 }, maxRotation: 45 } },
y: { stacked: true, grid: { color: 'rgba(0,0,0,0.04)' }, ticks: { callback: v => fmtUSD(v), font: { size: 10 } } }
}
}
});
// Netting
document.getElementById('nettingSaida').textContent = fmtUSD(d.netting.saida_usd);
document.getElementById('nettingEntrada').textContent = fmtUSD(d.netting.entrada_usd);
const posEl = document.getElementById('nettingPosicao');
posEl.textContent = fmtUSD(Math.abs(d.netting.posicao_liquida));
posEl.className = 'netting-value ' + (d.netting.posicao_liquida >= 0 ? 'green' : 'red');
posEl.textContent = (d.netting.posicao_liquida >= 0 ? '+' : '-') + fmtUSD(Math.abs(d.netting.posicao_liquida));
document.getElementById('nettingEficiencia').textContent = d.netting.eficiencia + '%';
document.getElementById('nettingBar').style.width = d.netting.eficiencia + '%';
// Agent ranking table
const agentBody = document.getElementById('agentTableBody');
if (d.agentRanking.length === 0) {
agentBody.innerHTML = '<tr><td colspan="6" style="text-align:center;color:var(--text-muted);">Sem dados</td></tr>';
} else {
agentBody.innerHTML = d.agentRanking.map(a => {
const rankCls = a.rank <= 3 ? 'rank-' + a.rank : 'rank-default';
return '<tr>' +
'<td><span class="rank ' + rankCls + '">' + a.rank + '</span></td>' +
'<td style="font-weight:600;">' + a.nome + '</td>' +
'<td>' + fmtUSD(a.vol_usd) + '</td>' +
'<td>' + fmtNum(a.qtd) + '</td>' +
'<td style="color:var(--green);font-weight:600;">' + fmtBRL(a.spread_revenue) + '</td>' +
'<td>' + a.clientes + '</td></tr>';
}).join('');
}
} catch (err) {
console.error('BI load error:', err);
document.getElementById('periodInfo').textContent = 'Erro ao carregar dados';
}
}
// === Live Rates ===
let _lastUsdBid = null, _lastUsdAsk = null;
function pulseEl(el) { el.closest('.live-rate-btn').classList.add('pulse'); setTimeout(() => el.closest('.live-rate-btn').classList.remove('pulse'), 500); }
async function fetchLiveRate() {
try {
const resp = await fetch('/api/cotacao');
const json = await resp.json();
const usd = json.USDBRL;
if (usd) {
const bidRaw = parseFloat(usd.bid), askRaw = parseFloat(usd.ask);
const bid = bidRaw * (1 - 0.0043);
const ask = askRaw * (1 + 0.0005);
const elC = document.getElementById('rateUsdCompra'), elV = document.getElementById('rateUsdVenda');
elC.textContent = bid.toFixed(4); elV.textContent = ask.toFixed(4);
if (_lastUsdBid !== null && bidRaw !== _lastUsdBid) pulseEl(elC);
if (_lastUsdAsk !== null && askRaw !== _lastUsdAsk) pulseEl(elV);
_lastUsdBid = bidRaw; _lastUsdAsk = askRaw;
}
document.getElementById('rateTime').textContent = new Date().toLocaleTimeString('pt-BR');
} catch (e) { /* retry next cycle */ }
}
// Init
document.addEventListener('DOMContentLoaded', () => { loadBI(); fetchLiveRate(); });
setInterval(fetchLiveRate, 3000);
<\/script>
</body>
</html>`;
}
module.exports = { buildAdminBIHTML };

View File

@@ -503,6 +503,264 @@ async function fetchTopAgentes(dias = 30, getAgenteName = null) {
}
}
// BI Analytics - Comprehensive data for admin BI dashboard
async function fetchBIData(dataInicio, dataFim, getAgenteName = null) {
const conn = await pool.getConnection();
try {
// Calculate previous period for comparison
const start = new Date(dataInicio);
const end = new Date(dataFim);
const periodDays = Math.round((end - start) / (1000 * 60 * 60 * 24)) + 1;
const prevEnd = new Date(start);
prevEnd.setDate(prevEnd.getDate() - 1);
const prevStart = new Date(prevEnd);
prevStart.setDate(prevStart.getDate() - periodDays + 1);
const prevStartStr = prevStart.toISOString().slice(0, 10);
const prevEndStr = prevEnd.toISOString().slice(0, 10);
// 1. BRL→USD KPIs
const [kpiBrlUsd] = await conn.execute(`
SELECT
COUNT(*) as qtd,
ROUND(COALESCE(SUM(amount_usd), 0), 2) as vol_usd,
ROUND(COALESCE(SUM(amount_brl), 0), 2) as vol_brl,
ROUND(COALESCE(SUM((exchange_rate - ptax) * amount_usd), 0), 2) as spread_revenue,
ROUND(COALESCE(AVG((exchange_rate - ptax) / exchange_rate * 100), 0), 2) as avg_spread_pct,
COUNT(DISTINCT id_conta) as clientes
FROM br_transaction_to_usa
WHERE DATE(created_at) >= ? AND DATE(created_at) <= ?
`, [dataInicio, dataFim]);
// 2. USD→BRL KPIs
const [kpiUsdBrl] = await conn.execute(`
SELECT
COUNT(*) as qtd,
ROUND(COALESCE(SUM(valor), 0), 2) as vol_usd,
ROUND(COALESCE(SUM(valor_sol), 0), 2) as vol_brl,
ROUND(COALESCE(SUM((ptax - cotacao) * valor), 0), 2) as spread_revenue,
ROUND(COALESCE(AVG(CASE WHEN cotacao > 0 THEN (ptax - cotacao) / ptax * 100 ELSE 0 END), 0), 2) as avg_spread_pct,
COUNT(DISTINCT id_conta) as clientes
FROM pagamento_br
WHERE DATE(created_at) >= ? AND DATE(created_at) <= ?
AND cotacao IS NOT NULL AND cotacao > 0
AND (pgto IS NULL OR pgto != 'balance')
`, [dataInicio, dataFim]);
// 3. USD→USD KPIs
const [kpiUsdUsd] = await conn.execute(`
SELECT
COUNT(*) as qtd,
ROUND(COALESCE(SUM(valor), 0), 2) as vol_usd,
COUNT(DISTINCT id_conta) as clientes
FROM pagamento_br
WHERE DATE(created_at) >= ? AND DATE(created_at) <= ?
AND (cotacao IS NULL OR cotacao = 0 OR pgto = 'balance')
`, [dataInicio, dataFim]);
// 4. Unique active clients across all flows
const [uniqueClients] = await conn.execute(`
SELECT COUNT(DISTINCT id_conta) as total FROM (
SELECT id_conta FROM br_transaction_to_usa
WHERE DATE(created_at) >= ? AND DATE(created_at) <= ?
UNION
SELECT id_conta FROM pagamento_br
WHERE DATE(created_at) >= ? AND DATE(created_at) <= ?
) all_clients
`, [dataInicio, dataFim, dataInicio, dataFim]);
// 5. Previous period totals for comparison
const [prevBrlUsd] = await conn.execute(`
SELECT COUNT(*) as qtd, ROUND(COALESCE(SUM(amount_usd),0),2) as vol_usd,
ROUND(COALESCE(SUM((exchange_rate - ptax) * amount_usd),0),2) as spread_revenue
FROM br_transaction_to_usa WHERE DATE(created_at) >= ? AND DATE(created_at) <= ?
`, [prevStartStr, prevEndStr]);
const [prevUsdBrl] = await conn.execute(`
SELECT COUNT(*) as qtd, ROUND(COALESCE(SUM(valor),0),2) as vol_usd,
ROUND(COALESCE(SUM((ptax - cotacao) * valor),0),2) as spread_revenue
FROM pagamento_br WHERE DATE(created_at) >= ? AND DATE(created_at) <= ?
AND cotacao IS NOT NULL AND cotacao > 0 AND (pgto IS NULL OR pgto != 'balance')
`, [prevStartStr, prevEndStr]);
const [prevUsdUsd] = await conn.execute(`
SELECT COUNT(*) as qtd, ROUND(COALESCE(SUM(valor),0),2) as vol_usd
FROM pagamento_br WHERE DATE(created_at) >= ? AND DATE(created_at) <= ?
AND (cotacao IS NULL OR cotacao = 0 OR pgto = 'balance')
`, [prevStartStr, prevEndStr]);
// 6. BRL→USD daily trend with spread
const [trendBrlUsd] = await conn.execute(`
SELECT DATE(created_at) as dia, COUNT(*) as qtd,
ROUND(SUM(amount_usd), 2) as vol_usd,
ROUND(AVG((exchange_rate - ptax) / exchange_rate * 100), 2) as avg_spread
FROM br_transaction_to_usa
WHERE DATE(created_at) >= ? AND DATE(created_at) <= ?
GROUP BY DATE(created_at) ORDER BY dia
`, [dataInicio, dataFim]);
// 7. USD→BRL daily trend with spread
const [trendUsdBrl] = await conn.execute(`
SELECT DATE(created_at) as dia, COUNT(*) as qtd,
ROUND(SUM(valor), 2) as vol_usd,
ROUND(AVG(CASE WHEN cotacao > 0 THEN (ptax - cotacao) / ptax * 100 ELSE 0 END), 2) as avg_spread
FROM pagamento_br
WHERE DATE(created_at) >= ? AND DATE(created_at) <= ?
AND cotacao IS NOT NULL AND cotacao > 0
AND (pgto IS NULL OR pgto != 'balance')
GROUP BY DATE(created_at) ORDER BY dia
`, [dataInicio, dataFim]);
// 8. USD→USD daily trend
const [trendUsdUsd] = await conn.execute(`
SELECT DATE(created_at) as dia, COUNT(*) as qtd,
ROUND(SUM(valor), 2) as vol_usd
FROM pagamento_br
WHERE DATE(created_at) >= ? AND DATE(created_at) <= ?
AND (cotacao IS NULL OR cotacao = 0 OR pgto = 'balance')
GROUP BY DATE(created_at) ORDER BY dia
`, [dataInicio, dataFim]);
// 9. Top 10 clients by volume
const [topClients] = await conn.execute(`
SELECT nome, SUM(vol) as total_usd, SUM(qtd) as total_qtd FROM (
SELECT c.nome, SUM(t.amount_usd) as vol, COUNT(*) as qtd
FROM br_transaction_to_usa t
INNER JOIN conta c ON c.id_conta = t.id_conta
WHERE DATE(t.created_at) >= ? AND DATE(t.created_at) <= ?
GROUP BY c.nome
UNION ALL
SELECT c.nome, SUM(p.valor) as vol, COUNT(*) as qtd
FROM pagamento_br p
INNER JOIN conta c ON c.id_conta = p.id_conta
WHERE DATE(p.created_at) >= ? AND DATE(p.created_at) <= ?
GROUP BY c.nome
) combined
GROUP BY nome ORDER BY total_usd DESC LIMIT 10
`, [dataInicio, dataFim, dataInicio, dataFim]);
// 10. Client retention: clients in previous period who also appear in current
const [retention] = await conn.execute(`
SELECT
COUNT(DISTINCT prev.id_conta) as prev_clients,
COUNT(DISTINCT CASE WHEN curr.id_conta IS NOT NULL THEN prev.id_conta END) as retained
FROM (
SELECT DISTINCT id_conta FROM br_transaction_to_usa WHERE DATE(created_at) >= ? AND DATE(created_at) <= ?
UNION
SELECT DISTINCT id_conta FROM pagamento_br WHERE DATE(created_at) >= ? AND DATE(created_at) <= ?
) prev
LEFT JOIN (
SELECT DISTINCT id_conta FROM br_transaction_to_usa WHERE DATE(created_at) >= ? AND DATE(created_at) <= ?
UNION
SELECT DISTINCT id_conta FROM pagamento_br WHERE DATE(created_at) >= ? AND DATE(created_at) <= ?
) curr ON prev.id_conta = curr.id_conta
`, [prevStartStr, prevEndStr, prevStartStr, prevEndStr, dataInicio, dataFim, dataInicio, dataFim]);
// 11. Clients at risk (last transaction > 30 days ago, had meaningful volume)
const [clientsAtRisk] = await conn.execute(`
SELECT nome, MAX(last_op) as last_op, SUM(vol) as total_usd, SUM(qtd) as total_qtd FROM (
SELECT c.nome, MAX(t.created_at) as last_op, SUM(t.amount_usd) as vol, COUNT(*) as qtd
FROM br_transaction_to_usa t
INNER JOIN conta c ON c.id_conta = t.id_conta
GROUP BY c.nome
UNION ALL
SELECT c.nome, MAX(p.created_at) as last_op, SUM(p.valor) as vol, COUNT(*) as qtd
FROM pagamento_br p
INNER JOIN conta c ON c.id_conta = p.id_conta
GROUP BY c.nome
) combined
GROUP BY nome
HAVING MAX(last_op) < DATE_SUB(CURDATE(), INTERVAL 30 DAY)
ORDER BY total_usd DESC LIMIT 10
`);
// 12. Agent ranking with spread revenue
const [agentRanking] = await conn.execute(`
SELECT agente_id, SUM(vol) as total_usd, SUM(qtd) as total_qtd,
ROUND(SUM(spread_rev), 2) as total_spread, COUNT(DISTINCT client_id) as clientes
FROM (
SELECT ac.agente_id, t.id_conta as client_id, SUM(t.amount_usd) as vol, COUNT(*) as qtd,
SUM((t.exchange_rate - t.ptax) * t.amount_usd) as spread_rev
FROM br_transaction_to_usa t
INNER JOIN ag_contas ac ON ac.conta_id = t.id_conta
WHERE DATE(t.created_at) >= ? AND DATE(t.created_at) <= ?
GROUP BY ac.agente_id, t.id_conta
UNION ALL
SELECT ac.agente_id, p.id_conta as client_id, SUM(p.valor) as vol, COUNT(*) as qtd,
SUM((p.ptax - p.cotacao) * p.valor) as spread_rev
FROM pagamento_br p
INNER JOIN ag_contas ac ON ac.conta_id = p.id_conta
WHERE DATE(p.created_at) >= ? AND DATE(p.created_at) <= ?
AND p.cotacao IS NOT NULL AND p.cotacao > 0
AND (p.pgto IS NULL OR p.pgto != 'balance')
GROUP BY ac.agente_id, p.id_conta
) combined
GROUP BY agente_id ORDER BY total_usd DESC LIMIT 10
`, [dataInicio, dataFim, dataInicio, dataFim]);
// Resolve agent names
const agents = agentRanking.map((r, i) => {
const nome = getAgenteName ? (getAgenteName(r.agente_id) || `Agente ${r.agente_id}`) : `Agente ${r.agente_id}`;
return { rank: i + 1, agente_id: r.agente_id, nome,
vol_usd: Number(r.total_usd), qtd: Number(r.total_qtd),
spread_revenue: Number(r.total_spread), clientes: Number(r.clientes)
};
});
// Format results
const fmtKpi = (r) => ({
qtd: Number(r?.qtd) || 0, vol_usd: Number(r?.vol_usd) || 0,
vol_brl: Number(r?.vol_brl) || 0, spread_revenue: Number(r?.spread_revenue) || 0,
avg_spread_pct: Number(r?.avg_spread_pct) || 0, clientes: Number(r?.clientes) || 0
});
const fmtTrend = (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), avg_spread: Number(r.avg_spread) || 0
}));
const brl = fmtKpi(kpiBrlUsd[0]);
const usd = fmtKpi(kpiUsdBrl[0]);
const uu = { qtd: Number(kpiUsdUsd[0]?.qtd) || 0, vol_usd: Number(kpiUsdUsd[0]?.vol_usd) || 0, clientes: Number(kpiUsdUsd[0]?.clientes) || 0 };
const totalQtd = brl.qtd + usd.qtd + uu.qtd;
const totalVolUsd = brl.vol_usd + usd.vol_usd + uu.vol_usd;
const pBrl = Number(prevBrlUsd[0]?.qtd) || 0;
const pUsd = Number(prevUsdBrl[0]?.qtd) || 0;
const pUu = Number(prevUsdUsd[0]?.qtd) || 0;
const prevQtd = pBrl + pUsd + pUu;
const prevVolUsd = (Number(prevBrlUsd[0]?.vol_usd) || 0) + (Number(prevUsdBrl[0]?.vol_usd) || 0) + (Number(prevUsdUsd[0]?.vol_usd) || 0);
const prevSpread = (Number(prevBrlUsd[0]?.spread_revenue) || 0) + (Number(prevUsdBrl[0]?.spread_revenue) || 0);
const retPrev = Number(retention[0]?.prev_clients) || 0;
const retCurr = Number(retention[0]?.retained) || 0;
return {
kpis: {
brlUsd: brl, usdBrl: usd, usdUsd: uu,
total: {
qtd: totalQtd, vol_usd: totalVolUsd,
spread_revenue: brl.spread_revenue + usd.spread_revenue,
clientes: Number(uniqueClients[0]?.total) || 0,
ticket_medio: totalQtd > 0 ? Math.round(totalVolUsd / totalQtd) : 0
}
},
comparison: { prev_qtd: prevQtd, prev_vol_usd: prevVolUsd, prev_spread: prevSpread },
trend: { brlUsd: fmtTrend(trendBrlUsd), usdBrl: fmtTrend(trendUsdBrl), usdUsd: fmtTrend(trendUsdUsd) },
topClients: topClients.map(r => ({ nome: r.nome, vol_usd: Number(r.total_usd), qtd: Number(r.total_qtd) })),
retention: { prev_clients: retPrev, retained: retCurr, rate: retPrev > 0 ? Math.round(retCurr / retPrev * 100) : 0 },
clientsAtRisk: clientsAtRisk.map(r => ({
nome: r.nome, vol_usd: Number(r.total_usd), qtd: Number(r.total_qtd),
last_op: r.last_op instanceof Date ? r.last_op.toISOString().slice(0, 10) : String(r.last_op).slice(0, 16)
})),
agentRanking: agents,
netting: {
saida_usd: brl.vol_usd, entrada_usd: usd.vol_usd,
posicao_liquida: usd.vol_usd - brl.vol_usd,
eficiencia: brl.vol_usd > 0 ? Math.min(100, Math.round(usd.vol_usd / brl.vol_usd * 100)) : 0
}
};
} finally {
conn.release();
}
}
module.exports = {
fetchTransacoes,
fetchAllTransacoes,
@@ -512,5 +770,6 @@ module.exports = {
fetchTrend30Days,
fetchTopAgentes,
fetchTrendByPeriod,
fetchKPIsByPeriod
fetchKPIsByPeriod,
fetchBIData
};

View File

@@ -302,10 +302,11 @@ function buildHeader(options = {}) {
.join('')
.toUpperCase();
// Admin navigation: Corporate Dashboard + Users (admin on the right)
// Admin navigation: Corporate Dashboard + BI + Users
const adminNav = `
<nav class="header-nav">
<a href="/corporate" class="${activePage === 'dashboard' ? 'active' : ''}">Corporate</a>
<a href="/admin/bi" class="${activePage === 'bi' ? 'active' : ''}">BI Executive</a>
<a href="/admin" class="${activePage === 'users' ? 'active' : ''}">Usuarios</a>
</nav>
`;