Files
bi-agents/src/dashboard.js
root 95958e9a96 feat: dark/light mode + trading console BI + Chart.js local + fix themeScript
- Dark/light mode toggle across all pages (login, dashboard, corporate, admin, BI)
- BI Executive redesigned as permanent dark trading console (Bloomberg-style)
- Floating vertical nav with anchor scroll for mobile navigation
- Chart.js bundled locally (eliminates CDN dependency)
- Chart.js inlined in HTML for guaranteed loading
- Fix: themeScript </script> tag had literal backslash breaking HTML parser
- Fix: each chart wrapped in individual try/catch for graceful degradation
- No-cache headers on BI page to prevent stale HTML
- Robust init that handles DOMContentLoaded already fired

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

1586 lines
63 KiB
JavaScript

/**
* Gera HTML do dashboard — parametrizado por agente
* Updated: 2026-02-09 - 4 decimal places for spread
*/
const { buildHeader, buildFooter, buildHead, getChartJsScript } = require('./ui-template');
function buildHTML(data, agente, isAgentDashboard = true, diasPeriodo = null, asyncLoad = false, isEmulating = false) {
const now = new Date().toLocaleString('pt-BR');
const isAdminDash = diasPeriodo !== null;
// When emulating, use the emulator's role if provided, otherwise default to admin
const emulatorRole = agente.emulatorRole || 'admin';
const role = isEmulating ? emulatorRole : (isAdminDash ? 'admin' : 'agente');
// Determine the back URL based on emulator's role
const backUrl = emulatorRole === 'corporate' ? '/corporate' : '/admin';
const pageScripts = getChartJsScript();
const dashboardCSS = `
/* Emulation Banner */
.emulation-banner {
background: linear-gradient(90deg, #FF6B35, #F7931E);
color: white;
padding: 10px 24px;
text-align: center;
font-size: 13px;
font-weight: 600;
display: flex;
justify-content: center;
align-items: center;
gap: 16px;
}
.emulation-banner a {
background: rgba(255,255,255,0.2);
color: white;
padding: 6px 16px;
border-radius: 6px;
text-decoration: none;
font-size: 12px;
border: 1px solid rgba(255,255,255,0.3);
}
.emulation-banner a:hover {
background: rgba(255,255,255,0.3);
}
/* Live Badge */
.live-badge {
background: rgba(255,255,255,0.15);
padding: 6px 14px;
border-radius: 20px;
font-size: 11px;
font-weight: 600;
border: 1px solid rgba(255,255,255,0.2);
margin-right: 12px;
}
.live-dot {
display: inline-block;
width: 8px;
height: 8px;
background: #4ADE80;
border-radius: 50%;
margin-right: 6px;
animation: pulse 2s infinite;
}
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.4; } }
/* Filters */
.filters {
background: var(--card);
border-bottom: 1px solid var(--border);
box-shadow: 0 1px 2px rgba(0,0,0,0.04);
}
.filters-inner {
max-width: 1600px;
margin: 0 auto;
padding: 14px 40px;
display: flex;
gap: 24px;
align-items: center;
flex-wrap: wrap;
}
.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); }
.btn-export {
background: var(--green); color: white; border: none; padding: 9px 20px;
border-radius: 8px; font-size: 13px; font-weight: 600; font-family: inherit;
cursor: pointer; transition: all 0.15s; box-shadow: 0 1px 3px rgba(30,142,62,0.3);
margin-left: 8px;
}
.btn-export:hover { background: #25a244; transform: translateY(-1px); }
/* 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; }
}
.rate-separator {
width: 1px; height: 40px; background: rgba(255,255,255,0.08);
}
.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;
}
.container { padding: 28px 40px; max-width: 1600px; 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; }
.kpi-card .kpi-change { font-size: 11px; margin-top: 4px; font-weight: 600; display: inline-block; padding: 2px 6px; border-radius: 4px; }
.kpi-card .kpi-change.up { background: var(--green-bg); color: var(--green); }
.kpi-card .kpi-change.down { background: #FEE2E2; color: #DC2626; }
.kpi-card .kpi-change.neutral { background: #F3F4F6; color: var(--text-muted); }
.charts-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 20px;
margin-bottom: 28px;
}
.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), 0 1px 2px rgba(0,0,0,0.06);
display: flex;
flex-direction: column;
min-height: 380px;
}
.chart-card h3 {
font-size: 14px;
font-weight: 700;
margin-bottom: 20px;
color: var(--text);
padding-bottom: 12px;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
gap: 8px;
}
.chart-card h3::before {
content: '';
width: 4px;
height: 16px;
background: var(--primary);
border-radius: 2px;
}
.chart-card .chart-container {
flex: 1;
position: relative;
min-height: 280px;
}
.chart-card canvas {
width: 100% !important;
height: 100% !important;
max-height: 320px;
}
.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; }
/* Portfolio Analysis Styles */
.section-title {
font-size: 18px; font-weight: 700; color: var(--text); margin: 32px 0 16px 0;
padding-bottom: 8px; border-bottom: 2px solid var(--primary);
}
.portfolio-kpi-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; margin-bottom: 28px; }
.client-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; }
.client-table-card h3 { font-size: 15px; font-weight: 700; padding: 18px 22px; border-bottom: 1px solid var(--border); display: flex; justify-content: space-between; align-items: center; }
.client-table-card .table-info { font-size: 12px; font-weight: 400; color: var(--text-muted); }
.client-table thead th { cursor: pointer; user-select: none; transition: background 0.15s; }
.client-table thead th:hover { background: var(--primary-bg); }
.client-table thead th.sorted-asc::after { content: ' \\25B2'; font-size: 10px; color: var(--primary); }
.client-table thead th.sorted-desc::after { content: ' \\25BC'; font-size: 10px; color: var(--primary); }
.recency-green { background: var(--green-bg) !important; color: var(--green); font-weight: 600; }
.recency-yellow { background: var(--orange-bg) !important; color: var(--orange); font-weight: 600; }
.recency-red { background: #FEE2E2 !important; color: #DC2626; font-weight: 600; }
/* Spread Liquido Styles */
.spread-high { background: var(--green-bg) !important; color: var(--green); font-weight: 600; }
.spread-medium { background: var(--orange-bg) !important; color: var(--orange); font-weight: 600; }
.spread-low { background: #FEE2E2 !important; color: #DC2626; font-weight: 600; }
.spread-negative { background: #DC2626 !important; color: #FFFFFF; font-weight: 700; }
/* Netting Chart Styles */
.netting-section { margin-top: 32px; }
.netting-kpi-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; margin-bottom: 20px; }
.netting-kpi { background: var(--card); border-radius: 12px; padding: 18px 22px; border: 1px solid var(--border); text-align: center; }
.netting-kpi .label { font-size: 11px; font-weight: 600; color: var(--text-muted); text-transform: uppercase; margin-bottom: 6px; }
.netting-kpi .value { font-size: 22px; font-weight: 800; }
.netting-kpi .value.positive { color: var(--green); }
.netting-kpi .value.negative { color: #DC2626; }
.netting-kpi .value.neutral { color: var(--blue); }
.netting-kpi .sub { font-size: 11px; color: var(--text-muted); margin-top: 4px; }
/* Additional Charts Grid */
.charts-grid-3 {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 20px;
margin-bottom: 28px;
}
.chart-card.full-width {
grid-column: span 2;
}
.chart-card.half-width {
grid-column: span 1;
}
/* Section Headers */
.section-header {
display: flex;
align-items: center;
gap: 12px;
margin: 36px 0 20px 0;
padding-bottom: 12px;
border-bottom: 2px solid var(--primary);
}
.section-header h2 {
font-size: 18px;
font-weight: 800;
color: var(--text);
margin: 0;
}
.section-header .section-badge {
background: var(--primary-bg);
color: var(--primary);
font-size: 11px;
font-weight: 600;
padding: 4px 10px;
border-radius: 12px;
}
/* Responsive */
@media (max-width: 1400px) {
.charts-grid-3 { grid-template-columns: repeat(2, 1fr); }
.chart-card.full-width { grid-column: span 2; }
}
@media (max-width: 1100px) {
.kpi-grid { grid-template-columns: repeat(2, 1fr); }
.portfolio-kpi-grid { grid-template-columns: repeat(2, 1fr); }
.netting-kpi-grid { grid-template-columns: repeat(2, 1fr); }
}
@media (max-width: 900px) {
.trading-terminal { padding: 12px 16px; }
.live-rate-bar { flex-wrap: wrap; gap: 12px; }
.terminal-title { display: none; }
.rate-pair-group { padding: 6px 10px; }
.live-rate-btn { min-width: 100px; padding: 6px 12px; }
.live-rate-btn .rate-value { font-size: 16px; }
.rate-separator { display: none; }
.charts-grid { grid-template-columns: 1fr; }
.charts-grid-3 { grid-template-columns: 1fr; }
.chart-card.full-width { grid-column: span 1; }
.chart-card { min-height: 320px; }
.container { padding: 20px; }
.filters-inner { padding: 12px 20px; }
.header-inner { padding: 16px 20px; flex-direction: column; gap: 12px; }
}
@media (max-width: 600px) {
.trading-terminal { padding: 10px 12px; }
.live-rate-bar { gap: 8px; }
.rate-pair-label { font-size: 9px; }
.live-rate-btn { min-width: 80px; padding: 5px 8px; }
.live-rate-btn .rate-value { font-size: 14px; }
.live-rate-btn .rate-type { font-size: 7px; }
.rate-flags { font-size: 8px; }
.live-rate-time { font-size: 8px; }
.kpi-grid { grid-template-columns: 1fr; }
.portfolio-kpi-grid { grid-template-columns: 1fr; }
.netting-kpi-grid { grid-template-columns: 1fr; }
}
/* Dark Mode overrides */
[data-theme="dark"] thead th { background: var(--bg); }
[data-theme="dark"] tbody td { border-bottom-color: var(--border); }
[data-theme="dark"] tbody tr:hover { background: rgba(255,255,255,0.04); }
[data-theme="dark"] tbody tr:nth-child(even) { background: rgba(255,255,255,0.02); }
[data-theme="dark"] tbody tr:nth-child(even):hover { background: rgba(255,255,255,0.04); }
[data-theme="dark"] .kpi-card .kpi-change.neutral { background: var(--border); }
[data-theme="dark"] .kpi-card .kpi-change.down { background: rgba(248,81,73,0.15); color: var(--red); }
[data-theme="dark"] .filters { background: var(--card); border-bottom-color: var(--border); }
[data-theme="dark"] .filters input,
[data-theme="dark"] .filters select { background: var(--bg); color: var(--text); border-color: var(--border); }
[data-theme="dark"] .btn-export { background: var(--green); }
[data-theme="dark"] .recency-red { background: rgba(248,81,73,0.15) !important; color: var(--red); }
[data-theme="dark"] .spread-low { background: rgba(248,81,73,0.15) !important; color: var(--red); }
[data-theme="dark"] .spread-negative { background: var(--red) !important; }
`;
return `<!DOCTYPE html>
<html lang="pt-BR">
<head>
${buildHead('Dashboard', dashboardCSS, pageScripts)}
</head>
<body>
${isEmulating ? `
<div class="emulation-banner">
<span>Modo Emulacao - Visualizando como: ${agente.nome}</span>
<a href="${backUrl}">Voltar ao BI - CCC</a>
</div>
` : ''}
${buildHeader({ role, userName: agente.nome, activePage: 'dashboard', showNav: !isEmulating })}
<div class="filters">
<div class="filters-inner">
<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>
<button class="btn-export" onclick="exportCSV()">&#x2B07; Exportar CSV</button>
</div>
</div>
<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>
<div class="rate-separator"></div>
<div class="rate-pair-group">
<span class="rate-pair-label">EUR</span>
<button class="live-rate-btn compra">
<span class="rate-flags">\uD83C\uDDEA\uD83C\uDDFA \u2192 \uD83C\uDDE7\uD83C\uDDF7</span>
<span class="rate-type">Compra</span>
<span class="rate-value" id="rateEurCompra">--</span>
</button>
<button class="live-rate-btn venda">
<span class="rate-flags">\uD83C\uDDE7\uD83C\uDDF7 \u2192 \uD83C\uDDEA\uD83C\uDDFA</span>
<span class="rate-type">Venda</span>
<span class="rate-value" id="rateEurVenda">--</span>
</button>
</div>
<span class="live-rate-time" id="rateTime">--</span>
</div>
</div>
<div class="container">
<div class="kpi-grid" id="kpiGrid"></div>
<!-- Volume Charts Section -->
<div class="section-header">
<h2>Volume e Taxas</h2>
<span class="section-badge">Principais</span>
</div>
<div class="charts-grid">
<div class="chart-card">
<h3>Volume BRL / USD por Periodo</h3>
<div class="chart-container"><canvas id="chartVolume"></canvas></div>
</div>
<div class="chart-card">
<h3>Volume por Cliente (Top 10)</h3>
<div class="chart-container"><canvas id="chartClientes"></canvas></div>
</div>
</div>
<div class="charts-grid">
<div class="chart-card">
<h3>Taxa Cobrada vs PTAX</h3>
<div class="chart-container"><canvas id="chartTaxas"></canvas></div>
</div>
<div class="chart-card">
<h3>Evolucao de Clientes Ativos</h3>
<div class="chart-container"><canvas id="chartClientesAtivos"></canvas></div>
</div>
</div>
<!-- Portfolio Analysis Charts -->
<div class="section-header">
<h2>Analise de Carteira</h2>
<span class="section-badge">Portfolio</span>
</div>
<div class="charts-grid">
<div class="chart-card">
<h3>Distribuicao de Clientes por Volume</h3>
<div class="chart-container"><canvas id="chartDistribuicao"></canvas></div>
</div>
<div class="chart-card">
<h3>Curva de Pareto (80/20)</h3>
<div class="chart-container"><canvas id="chartPareto"></canvas></div>
</div>
</div>
<!-- Netting / Balance In-Out Section -->
<div class="section-header">
<h2>Balance In/Out - Eficiencia IOF</h2>
<span class="section-badge">Netting</span>
</div>
<div class="netting-kpi-grid" id="nettingKpis"></div>
<div class="charts-grid">
<div class="chart-card">
<h3>Fluxo: Entrada vs Saida (USD)</h3>
<div class="chart-container"><canvas id="chartNetting"></canvas></div>
</div>
<div class="chart-card">
<h3>Eficiencia de Netting por Periodo</h3>
<div class="chart-container"><canvas id="chartEficiencia"></canvas></div>
</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>
<!-- Portfolio Analysis Section -->
<h2 class="section-title">Analise de Carteira</h2>
<div class="portfolio-kpi-grid" id="portfolioKpiGrid"></div>
<div class="client-table-card">
<h3>Metricas por Cliente <span class="table-info" id="clientTableInfo"></span></h3>
<div class="table-wrap">
<table class="client-table"><thead id="clientTableHead"></thead><tbody id="clientTableBody"></tbody></table>
</div>
</div>
</div>
${buildFooter()}
<script>
let RAW_DATA = ${asyncLoad ? '[]' : JSON.stringify(data)};
const ASYNC_LOAD = ${asyncLoad};
const DIAS_PERIODO = ${diasPeriodo || 90};
let filtered = [];
let charts = {};
// Portfolio Analysis Variables (must be declared before init)
let clientMetrics = [];
let clientSortColumn = 'volumeTotal';
let clientSortDirection = 'desc';
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) + '%';
const fmtPct4 = v => fmtNum(v, 4) + '%'; // 4 decimal places for spread
// Loading overlay
function showLoading(msg) {
let overlay = document.getElementById('loadingOverlay');
if (!overlay) {
overlay = document.createElement('div');
overlay.id = 'loadingOverlay';
overlay.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.7);display:flex;align-items:center;justify-content:center;z-index:9999;flex-direction:column;gap:16px;';
overlay.innerHTML = '<div style="width:50px;height:50px;border:4px solid #fff;border-top-color:#7600be;border-radius:50%;animation:spin 1s linear infinite;"></div><div style="color:#fff;font-size:16px;" id="loadingText">Carregando...</div><style>@keyframes spin{to{transform:rotate(360deg)}}</style>';
document.body.appendChild(overlay);
}
document.getElementById('loadingText').textContent = msg || 'Carregando...';
overlay.style.display = 'flex';
}
function hideLoading() {
const overlay = document.getElementById('loadingOverlay');
if (overlay) overlay.style.display = 'none';
}
// Async data loading
async function loadDataAsync() {
showLoading('Carregando dados (' + DIAS_PERIODO + ' dias)...');
try {
const resp = await fetch('/admin/api/data?dias=' + DIAS_PERIODO);
const json = await resp.json();
if (json.success) {
RAW_DATA = json.data;
document.querySelector('.subtitle').textContent = 'Dashboard de Transacoes BRL ↔ USD (' + json.count.toLocaleString('pt-BR') + ' registros)';
initDashboard();
} else {
alert('Erro ao carregar dados: ' + json.error);
}
} catch (err) {
alert('Erro de conexao: ' + err.message);
}
hideLoading();
}
// Live USD/BRL + EUR/BRL Rate
let _lastUsdBid = null, _lastUsdAsk = null, _lastEurBid = null, _lastEurAsk = 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;
const eur = json.EURBRL;
if (usd) {
const bidRaw = parseFloat(usd.bid), askRaw = parseFloat(usd.ask);
const bid = bidRaw * (1 - 0.0043); // compra: -0.38% -5bps = -0.43%
const ask = askRaw * (1 + 0.0005); // venda: +5bps = +0.05%
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;
}
if (eur) {
const bid = parseFloat(eur.bid), ask = parseFloat(eur.ask);
const elC = document.getElementById('rateEurCompra'), elV = document.getElementById('rateEurVenda');
elC.textContent = bid.toFixed(4); elV.textContent = ask.toFixed(4);
if (_lastEurBid !== null && bid !== _lastEurBid) pulseEl(elC);
if (_lastEurAsk !== null && ask !== _lastEurAsk) pulseEl(elV);
_lastEurBid = bid; _lastEurAsk = ask;
}
document.getElementById('rateTime').textContent = new Date().toLocaleTimeString('pt-BR');
} catch (e) { /* silently retry next cycle */ }
}
function startLiveRate() { fetchLiveRate(); setInterval(fetchLiveRate, 3000); }
function initDashboard() {
const clientes = [...new Set(RAW_DATA.map(r => r.cliente))].sort();
const sel = document.getElementById('filterCliente');
sel.innerHTML = '<option value="">Todos</option>';
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();
startLiveRate();
}
(function init() {
if (ASYNC_LOAD) {
loadDataAsync();
return;
}
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();
startLiveRate();
})();
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();
renderPortfolioAnalysis();
try { renderPortfolioCharts(gran); } catch(e) { console.error('Portfolio chart error:', e); }
try { renderNettingAnalysis(gran); } catch(e) { console.error('Netting chart error:', e); }
}
function calcPreviousPeriod(start, end) {
if (!start || !end) return { start: null, end: null };
const s = new Date(start);
const e = new Date(end);
const diffDays = Math.ceil((e - s) / (1000 * 60 * 60 * 24)) + 1;
const prevEnd = new Date(s);
prevEnd.setDate(prevEnd.getDate() - 1);
const prevStart = new Date(prevEnd);
prevStart.setDate(prevStart.getDate() - diffDays + 1);
return {
start: prevStart.toISOString().slice(0, 10),
end: prevEnd.toISOString().slice(0, 10)
};
}
function calcKPIsForData(data) {
const n = data.length;
const totalBRL = data.reduce((s, r) => s + r.valor_reais, 0);
const totalUSD = data.reduce((s, r) => s + r.valor_dolar, 0);
const taxaMedia = n && totalUSD ? data.reduce((s, r) => s + r.taxa_cobrada * r.valor_dolar, 0) / totalUSD : 0;
const spreadMedio = n ? data.reduce((s, r) => s + r.spread_pct, 0) / n : 0;
const iofTotal = data.reduce((s, r) => s + r.iof_valor_rs, 0);
const ticketMedio = n ? totalUSD / n : 0;
const clientes = new Set(data.map(r => r.cliente)).size;
return { n, totalBRL, totalUSD, taxaMedia, spreadMedio, iofTotal, ticketMedio, clientes };
}
function fmtChange(current, previous, invert = false) {
if (!previous || previous === 0) return '';
const pct = ((current - previous) / previous) * 100;
if (Math.abs(pct) < 0.5) return '<span class="kpi-change neutral">~0%</span>';
const isUp = invert ? pct < 0 : pct > 0;
const cls = isUp ? 'up' : 'down';
const arrow = pct > 0 ? '&#x25B2;' : '&#x25BC;';
return '<span class="kpi-change ' + cls + '">' + arrow + ' ' + Math.abs(pct).toFixed(1) + '%</span>';
}
function renderKPIs() {
const start = document.getElementById('filterStart').value;
const end = document.getElementById('filterEnd').value;
const cliente = document.getElementById('filterCliente').value;
const fluxo = document.getElementById('filterFluxo').value;
const curr = calcKPIsForData(filtered);
const prev = calcPreviousPeriod(start, end);
let prevData = [];
if (prev.start && prev.end) {
prevData = RAW_DATA.filter(r => {
const dateOnly = r.data_operacao ? r.data_operacao.slice(0, 10) : null;
if (!dateOnly || dateOnly < prev.start || dateOnly > prev.end) return false;
if (cliente && r.cliente !== cliente) return false;
if (fluxo && r.fluxo !== fluxo) return false;
return true;
});
}
const prevKPIs = calcKPIsForData(prevData);
const hasPrev = prevData.length > 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">\${curr.n}</div><div class="kpi-sub">operacoes</div>\${hasPrev ? fmtChange(curr.n, prevKPIs.n) : ''}</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(curr.totalBRL)}</div><div class="kpi-sub">total movimentado</div>\${hasPrev ? fmtChange(curr.totalBRL, prevKPIs.totalBRL) : ''}</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(curr.totalUSD)}</div><div class="kpi-sub">total movimentado</div>\${hasPrev ? fmtChange(curr.totalUSD, prevKPIs.totalUSD) : ''}</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(curr.taxaMedia, 4)}</div><div class="kpi-sub">ponderada BRL/USD</div>\${hasPrev ? fmtChange(curr.taxaMedia, prevKPIs.taxaMedia) : ''}</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(curr.spreadMedio)}</div><div class="kpi-sub">sobre taxa cobrada</div>\${hasPrev ? fmtChange(curr.spreadMedio, prevKPIs.spreadMedio) : ''}</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(curr.iofTotal)}</div><div class="kpi-sub">recolhido no periodo</div>\${hasPrev ? fmtChange(curr.iofTotal, prevKPIs.iofTotal) : ''}</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(curr.ticketMedio)}</div><div class="kpi-sub">por operacao</div>\${hasPrev ? fmtChange(curr.ticketMedio, prevKPIs.ticketMedio) : ''}</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">\${curr.clientes}</div><div class="kpi-sub">no periodo</div>\${hasPrev ? fmtChange(curr.clientes, prevKPIs.clientes) : ''}</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);
const chartDefaults = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { position: 'top', labels: { font: { size: 11, family: 'Inter' }, padding: 16, usePointStyle: true, pointStyle: 'circle' } },
tooltip: { backgroundColor: 'rgba(0,0,0,0.8)', titleFont: { size: 12 }, bodyFont: { size: 11 }, padding: 12, cornerRadius: 8 }
}
};
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.75)', borderRadius: 6, yAxisID: 'yBRL' },
{ label: 'Volume USD', data: periods.map(p => p.totalUSD), backgroundColor: 'rgba(26,115,232,0.75)', borderRadius: 6, yAxisID: 'yUSD' }
]
},
options: {
...chartDefaults,
scales: {
x: { grid: { display: false }, ticks: { font: { size: 10 } } },
yBRL: { type: 'linear', position: 'left', ticks: { callback: v => 'R$ ' + (v >= 1000000 ? (v/1000000).toFixed(1) + 'M' : v >= 1000 ? (v/1000).toFixed(0) + 'k' : v.toFixed(0)), font: { size: 10 } }, title: { display: true, text: 'BRL', font: { size: 11 } }, grid: { color: 'rgba(0,0,0,0.06)' } },
yUSD: { type: 'linear', position: 'right', ticks: { callback: v => '$ ' + (v >= 1000000 ? (v/1000000).toFixed(1) + 'M' : 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 = ['#7600be','#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 > 18 ? c[0].slice(0,18) + '...' : c[0]),
datasets: [{ label: 'Volume USD', data: topClientes.map(c => c[1]), backgroundColor: colors, borderRadius: 6, borderSkipped: false }]
},
options: {
...chartDefaults,
indexAxis: 'y',
plugins: { ...chartDefaults.plugins, legend: { display: false } },
scales: {
x: { grid: { color: 'rgba(0,0,0,0.06)' }, ticks: { callback: v => '$ ' + (v >= 1000000 ? (v/1000000).toFixed(1) + 'M' : v >= 1000 ? (v/1000).toFixed(0) + 'k' : v.toFixed(0)), font: { size: 10 } } },
y: { grid: { display: false }, ticks: { font: { size: 11 } } }
}
}
});
charts.taxas = new Chart(document.getElementById('chartTaxas'), {
type: 'line',
data: {
labels,
datasets: [
{ label: 'Taxa Cobrada', data: periods.map(p => p.taxaMedia), borderColor: '#7600be', backgroundColor: 'rgba(108,63,160,0.08)', fill: true, tension: 0.4, pointRadius: 4, pointBackgroundColor: '#7600be', borderWidth: 2 },
{ label: 'PTAX', data: periods.map(p => p.ptaxMedia), borderColor: '#2980B9', backgroundColor: 'rgba(41,128,185,0.08)', fill: true, tension: 0.4, pointRadius: 4, pointBackgroundColor: '#2980B9', borderWidth: 2 }
]
},
options: {
...chartDefaults,
scales: {
x: { grid: { display: false }, ticks: { font: { size: 10 } } },
y: { grid: { color: 'rgba(0,0,0,0.06)' }, ticks: { callback: v => v.toFixed(4), font: { size: 10 } } }
}
}
});
}
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 exportCSV() {
if (!filtered.length) { alert('Nenhuma transacao para exportar.'); return; }
const headers = ['Fluxo','Data/Hora','Cliente','Valor BRL','Valor USD','IOF %','IOF R$','PTAX','Taxa Cobrada','Spread','Spread %','Spread Liq %','Status'];
const rows = filtered.map(r => {
const spreadLiq = r.spread_pct - 0.20;
return [
r.fluxo,
r.data_operacao || '',
'"' + (r.cliente || '').replace(/"/g, '""') + '"',
r.valor_reais.toFixed(2),
r.valor_dolar.toFixed(2),
r.iof_pct.toFixed(2),
r.iof_valor_rs.toFixed(2),
r.taxa_ptax.toFixed(4),
r.taxa_cobrada.toFixed(4),
r.spread_bruto.toFixed(4),
r.spread_pct.toFixed(4),
spreadLiq.toFixed(4),
r.status || ''
].join(';');
});
const bom = '\\uFEFF';
const csv = bom + headers.join(';') + '\\n' + rows.join('\\n');
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
const today = new Date().toISOString().slice(0,10);
a.download = 'transacoes_' + today + '.csv';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
function calcSpreadLiquido(spreadPct) {
// Spread Líquido = Spread Cobrado - 0.20% (custo operacional)
const CUSTO_OPERACIONAL = 0.20;
return spreadPct - CUSTO_OPERACIONAL;
}
function getSpreadLiqClass(spreadLiq) {
if (spreadLiq < 0) return 'spread-negative';
if (spreadLiq >= 0.5) return 'spread-high';
if (spreadLiq >= 0.2) return 'spread-medium';
return 'spread-low';
}
function renderTable() {
// Filtrar apenas transações finalizadas
const brlUsd = filtered.filter(r => r.fluxo === 'BRL \\u2192 USD' && r.status === 'finalizado');
const usdBrl = filtered.filter(r => r.fluxo === 'USD \\u2192 BRL' && r.status && r.status !== '0000-00-00');
document.getElementById('tableTitle').textContent = 'Transacoes (' + filtered.length + ')';
let html = '';
if (brlUsd.length) {
html += '<tr><td colspan="12" 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 => {
const spreadLiq = calcSpreadLiquido(r.spread_pct);
return \`<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">\${fmtPct4(r.spread_pct)}</td>
<td class="num \${getSpreadLiqClass(spreadLiq)}">\${fmtPct4(spreadLiq)}</td>
<td>\${r.status || '-'}</td>
</tr>\`;
}).join('');
}
if (usdBrl.length) {
html += '<tr><td colspan="12" 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 => {
const spreadLiq = calcSpreadLiquido(r.spread_pct);
return \`<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">\${fmtPct4(r.spread_pct)}</td>
<td class="num \${getSpreadLiqClass(spreadLiq)}">\${fmtPct4(spreadLiq)}</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>Spread Liq %</th><th>Status</th></tr>';
document.getElementById('tableBody').innerHTML = html;
}
// Portfolio Analysis Functions
function calcClientMetrics() {
const today = new Date();
const clientMap = {};
// Calculate metrics for each client using ALL historical data (RAW_DATA)
RAW_DATA.forEach(r => {
if (!clientMap[r.cliente]) {
clientMap[r.cliente] = {
cliente: r.cliente,
transactions: [],
volumeTotal: 0,
totalOperacoes: 0
};
}
clientMap[r.cliente].transactions.push(r);
clientMap[r.cliente].volumeTotal += r.valor_dolar;
clientMap[r.cliente].totalOperacoes += 1;
});
// Calculate derived metrics
return Object.values(clientMap).map(c => {
const dates = c.transactions
.map(t => t.data_operacao ? new Date(t.data_operacao.replace(' ', 'T')) : null)
.filter(d => d && !isNaN(d.getTime()))
.sort((a, b) => b - a);
const lastDate = dates[0];
const firstDate = dates[dates.length - 1];
// Recency: days since last transaction
const recencia = lastDate ? Math.floor((today - lastDate) / (1000 * 60 * 60 * 24)) : 999;
// Frequency: average transactions per month
let frequencia = 0;
if (firstDate && lastDate && c.totalOperacoes > 0) {
const monthsDiff = Math.max(1, (lastDate - firstDate) / (1000 * 60 * 60 * 24 * 30));
frequencia = c.totalOperacoes / monthsDiff;
}
// LTV: Total USD volume historically
const ltv = c.volumeTotal;
// Ticket Medio: Average USD per transaction
const ticketMedio = c.totalOperacoes > 0 ? c.volumeTotal / c.totalOperacoes : 0;
return {
cliente: c.cliente,
ltv,
recencia,
frequencia,
ticketMedio,
totalOperacoes: c.totalOperacoes,
volumeTotal: c.volumeTotal,
lastDate
};
});
}
function calcPortfolioKPIs(metrics) {
metrics = metrics || [];
const start = document.getElementById('filterStart').value;
const end = document.getElementById('filterEnd').value;
// Get current period clients
const currentClients = new Set(filtered.map(r => r.cliente));
// Calculate previous period
const prev = calcPreviousPeriod(start, end);
let prevClients = new Set();
if (prev.start && prev.end) {
const prevData = RAW_DATA.filter(r => {
const dateOnly = r.data_operacao ? r.data_operacao.slice(0, 10) : null;
return dateOnly && dateOnly >= prev.start && dateOnly <= prev.end;
});
prevClients = new Set(prevData.map(r => r.cliente));
}
// Top 10 concentration: % of volume from top 10 clients
const clientVolumes = {};
filtered.forEach(r => {
clientVolumes[r.cliente] = (clientVolumes[r.cliente] || 0) + r.valor_dolar;
});
const sortedClients = Object.entries(clientVolumes).sort((a, b) => b[1] - a[1]);
const totalVolume = sortedClients.reduce((sum, c) => sum + c[1], 0);
const top10Volume = sortedClients.slice(0, 10).reduce((sum, c) => sum + c[1], 0);
const concentracaoTop10 = totalVolume > 0 ? (top10Volume / totalVolume) * 100 : 0;
// Retention rate: % of clients who operated in both current and previous period
const retainedClients = [...currentClients].filter(c => prevClients.has(c));
const taxaRetencao = prevClients.size > 0 ? (retainedClients.length / prevClients.size) * 100 : 0;
// New clients: Clients with first transaction in current period
const allClientsBeforeCurrent = new Set();
if (start) {
RAW_DATA.filter(r => {
const dateOnly = r.data_operacao ? r.data_operacao.slice(0, 10) : null;
return dateOnly && dateOnly < start;
}).forEach(r => allClientsBeforeCurrent.add(r.cliente));
}
const novosClientes = [...currentClients].filter(c => !allClientsBeforeCurrent.has(c)).length;
// Clients at risk: Clients with recency > 30 days who were previously active
const clientsEmRisco = metrics.filter(c => {
return c.recencia > 30 && c.totalOperacoes >= 2;
}).length;
return { concentracaoTop10, taxaRetencao, novosClientes, clientsEmRisco };
}
function renderPortfolioKPIs(metrics) {
const kpis = calcPortfolioKPIs(metrics);
document.getElementById('portfolioKpiGrid').innerHTML = \`
<div class="kpi-card"><div class="kpi-icon purple">&#x1F4CA;</div><div class="kpi-info"><div class="kpi-label">Concentracao Top 10</div><div class="kpi-value">\${fmtPct(kpis.concentracaoTop10)}</div><div class="kpi-sub">do volume total</div></div></div>
<div class="kpi-card"><div class="kpi-icon green">&#x21BB;</div><div class="kpi-info"><div class="kpi-label">Taxa Retencao</div><div class="kpi-value">\${fmtPct(kpis.taxaRetencao)}</div><div class="kpi-sub">vs periodo anterior</div></div></div>
<div class="kpi-card"><div class="kpi-icon blue">&#x2795;</div><div class="kpi-info"><div class="kpi-label">Novos Clientes</div><div class="kpi-value">\${kpis.novosClientes}</div><div class="kpi-sub">primeira operacao no periodo</div></div></div>
<div class="kpi-card"><div class="kpi-icon orange">&#x26A0;</div><div class="kpi-info"><div class="kpi-label">Clientes em Risco</div><div class="kpi-value">\${kpis.clientsEmRisco}</div><div class="kpi-sub">recencia > 30 dias</div></div></div>
\`;
}
function getRecencyClass(recencia) {
if (recencia < 15) return 'recency-green';
if (recencia <= 30) return 'recency-yellow';
return 'recency-red';
}
function sortClientMetrics(column) {
if (clientSortColumn === column) {
clientSortDirection = clientSortDirection === 'asc' ? 'desc' : 'asc';
} else {
clientSortColumn = column;
clientSortDirection = 'desc';
}
clientMetrics.sort((a, b) => {
let valA = a[column];
let valB = b[column];
if (column === 'cliente') {
valA = valA.toLowerCase();
valB = valB.toLowerCase();
return clientSortDirection === 'asc' ? valA.localeCompare(valB) : valB.localeCompare(valA);
}
return clientSortDirection === 'asc' ? valA - valB : valB - valA;
});
renderClientTable();
}
function renderClientTable() {
const headers = [
{ key: 'cliente', label: 'Cliente' },
{ key: 'ltv', label: 'LTV (USD)' },
{ key: 'recencia', label: 'Recencia (dias)' },
{ key: 'frequencia', label: 'Frequencia (op/mes)' },
{ key: 'ticketMedio', label: 'Ticket Medio (USD)' },
{ key: 'totalOperacoes', label: 'Total Operacoes' },
{ key: 'volumeTotal', label: 'Volume Total (USD)' }
];
const headerHtml = headers.map(h => {
const sortClass = clientSortColumn === h.key ? (clientSortDirection === 'asc' ? 'sorted-asc' : 'sorted-desc') : '';
return \`<th class="\${sortClass}" onclick="sortClientMetrics('\${h.key}')">\${h.label}</th>\`;
}).join('');
document.getElementById('clientTableHead').innerHTML = '<tr>' + headerHtml + '</tr>';
document.getElementById('clientTableInfo').textContent = '(' + clientMetrics.length + ' clientes)';
const bodyHtml = clientMetrics.map(c => \`
<tr>
<td>\${c.cliente}</td>
<td class="num">\${fmtUSD(c.ltv)}</td>
<td class="num \${getRecencyClass(c.recencia)}">\${c.recencia}</td>
<td class="num">\${fmtNum(c.frequencia, 2)}</td>
<td class="num">\${fmtUSD(c.ticketMedio)}</td>
<td class="num">\${c.totalOperacoes}</td>
<td class="num">\${fmtUSD(c.volumeTotal)}</td>
</tr>
\`).join('');
document.getElementById('clientTableBody').innerHTML = bodyHtml;
}
function renderPortfolioAnalysis() {
clientMetrics = calcClientMetrics();
clientMetrics.sort((a, b) => b[clientSortColumn] - a[clientSortColumn]);
renderPortfolioKPIs(clientMetrics);
renderClientTable();
}
// ============================================
// ============================================
// PORTFOLIO ANALYSIS CHARTS
// ============================================
function renderPortfolioCharts(gran) {
// Destroy existing portfolio charts
if (charts.distribuicao) charts.distribuicao.destroy();
if (charts.pareto) charts.pareto.destroy();
if (charts.clientesAtivos) charts.clientesAtivos.destroy();
// 1. Client Distribution by Volume (Doughnut Chart)
const volumeRanges = {
'<$1k': 0,
'$1k-5k': 0,
'$5k-20k': 0,
'$20k-50k': 0,
'>$50k': 0
};
const clientVolumes = {};
filtered.forEach(r => {
clientVolumes[r.cliente] = (clientVolumes[r.cliente] || 0) + r.valor_dolar;
});
Object.values(clientVolumes).forEach(vol => {
if (vol < 1000) volumeRanges['<$1k']++;
else if (vol < 5000) volumeRanges['$1k-5k']++;
else if (vol < 20000) volumeRanges['$5k-20k']++;
else if (vol < 50000) volumeRanges['$20k-50k']++;
else volumeRanges['>$50k']++;
});
const distLabels = Object.keys(volumeRanges);
const distData = Object.values(volumeRanges);
const distColors = ['#E74C3C', '#E67E22', '#F1C40F', '#2ECC71', '#3498DB'];
charts.distribuicao = new Chart(document.getElementById('chartDistribuicao'), {
type: 'doughnut',
data: {
labels: distLabels,
datasets: [{
data: distData,
backgroundColor: distColors,
borderWidth: 3,
borderColor: '#fff',
hoverBorderWidth: 4,
hoverOffset: 8
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
cutout: '55%',
plugins: {
legend: {
position: 'right',
labels: { font: { size: 11, family: 'Inter' }, boxWidth: 14, padding: 12, usePointStyle: true }
},
tooltip: {
backgroundColor: 'rgba(0,0,0,0.8)',
padding: 12,
cornerRadius: 8,
callbacks: {
label: function(ctx) {
const total = ctx.dataset.data.reduce((a, b) => a + b, 0);
const pct = total > 0 ? ((ctx.parsed / total) * 100).toFixed(1) : 0;
return ctx.label + ': ' + ctx.parsed + ' clientes (' + pct + '%)';
}
}
}
}
}
});
// 2. Pareto Chart (80/20)
const sortedClients = Object.entries(clientVolumes).sort((a, b) => b[1] - a[1]);
const totalVolume = sortedClients.reduce((sum, c) => sum + c[1], 0);
let cumulative = 0;
let pareto80Index = -1;
const paretoData = sortedClients.map((c, i) => {
cumulative += c[1];
const cumPct = totalVolume > 0 ? (cumulative / totalVolume) * 100 : 0;
if (pareto80Index === -1 && cumPct >= 80) {
pareto80Index = i;
}
return {
cliente: c[0].length > 15 ? c[0].slice(0, 15) + '...' : c[0],
volume: c[1],
cumPct
};
}).slice(0, 20); // Limit to top 20 for readability
const paretoLabels = paretoData.map(p => p.cliente);
const paretoVolumes = paretoData.map(p => p.volume);
const paretoCumPct = paretoData.map(p => p.cumPct);
// Calculate background colors to highlight 80% threshold
const paretoBarColors = paretoData.map((p, i) => {
if (pareto80Index !== -1 && i <= pareto80Index) {
return 'rgba(108, 63, 160, 0.8)';
}
return 'rgba(108, 63, 160, 0.4)';
});
charts.pareto = new Chart(document.getElementById('chartPareto'), {
type: 'bar',
data: {
labels: paretoLabels,
datasets: [
{
type: 'bar',
label: 'Volume USD',
data: paretoVolumes,
backgroundColor: paretoBarColors,
borderRadius: 6,
yAxisID: 'yVolume'
},
{
type: 'line',
label: '% Acumulado',
data: paretoCumPct,
borderColor: '#E67E22',
backgroundColor: 'transparent',
borderWidth: 3,
tension: 0.4,
pointRadius: 4,
pointBackgroundColor: '#E67E22',
yAxisID: 'yPct'
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { position: 'top', labels: { font: { size: 11, family: 'Inter' }, padding: 16, usePointStyle: true } },
tooltip: {
backgroundColor: 'rgba(0,0,0,0.8)',
padding: 12,
cornerRadius: 8,
callbacks: {
label: function(ctx) {
if (ctx.datasetIndex === 0) {
return 'Volume: ' + fmtUSD(ctx.parsed.y);
}
return '% Acumulado: ' + ctx.parsed.y.toFixed(1) + '%';
}
}
}
},
scales: {
x: {
grid: { display: false },
ticks: { font: { size: 9 }, maxRotation: 45, minRotation: 45 }
},
yVolume: {
type: 'linear',
position: 'left',
grid: { color: 'rgba(0,0,0,0.06)' },
ticks: {
callback: v => '$ ' + (v >= 1000000 ? (v/1000000).toFixed(1) + 'M' : v >= 1000 ? (v/1000).toFixed(0) + 'k' : v.toFixed(0)),
font: { size: 10 }
},
title: { display: true, text: 'Volume USD', font: { size: 11 } }
},
yPct: {
type: 'linear',
position: 'right',
min: 0,
max: 100,
ticks: {
callback: v => v + '%',
font: { size: 10 }
},
title: { display: true, text: '% Acumulado', font: { size: 11 } },
grid: { display: false }
}
}
}
});
// 3. Active Clients Evolution (Line Chart)
const periodMap = {};
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 (!periodMap[key]) periodMap[key] = new Set();
periodMap[key].add(r.cliente);
});
const periodKeys = Object.keys(periodMap).sort();
const activeClientsLabels = periodKeys.map(k =>
gran === 'dia' ? k.split('-').reverse().join('/') :
gran === 'mes' ? k.split('-').reverse().join('/') : k
);
const activeClientsData = periodKeys.map(k => periodMap[k].size);
charts.clientesAtivos = new Chart(document.getElementById('chartClientesAtivos'), {
type: 'line',
data: {
labels: activeClientsLabels,
datasets: [{
label: 'Clientes Ativos',
data: activeClientsData,
borderColor: '#1E8E3E',
backgroundColor: 'rgba(30, 142, 62, 0.1)',
fill: true,
tension: 0.4,
pointRadius: 5,
pointBackgroundColor: '#1E8E3E',
borderWidth: 3
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: { backgroundColor: 'rgba(0,0,0,0.8)', padding: 12, cornerRadius: 8 }
},
scales: {
y: {
beginAtZero: true,
grid: { color: 'rgba(0,0,0,0.06)' },
ticks: {
stepSize: 1,
font: { size: 10 }
},
title: { display: true, text: 'Qtd Clientes', font: { size: 11 } }
},
x: {
grid: { display: false },
ticks: { font: { size: 10 } }
}
}
}
});
}
// Netting Analysis - Balance In/Out for IOF Efficiency
function renderNettingAnalysis(gran) {
const brlUsd = filtered.filter(r => r.fluxo === 'BRL \\u2192 USD');
const usdBrl = filtered.filter(r => r.fluxo === 'USD \\u2192 BRL');
// Calculate totals
const totalOut = brlUsd.reduce((s, r) => s + r.valor_dolar, 0); // Money going OUT (to USA)
const totalIn = usdBrl.reduce((s, r) => s + r.valor_dolar, 0); // Money coming IN (from USA)
const netBalance = totalIn - totalOut;
const nettingEfficiency = totalOut > 0 ? Math.min(100, (totalIn / totalOut) * 100) : 0;
// Render KPIs
document.getElementById('nettingKpis').innerHTML = \`
<div class="netting-kpi">
<div class="label">Saida (BRL\\u2192USD)</div>
<div class="value negative">\${fmtUSD(totalOut)}</div>
<div class="sub">\${brlUsd.length} operacoes</div>
</div>
<div class="netting-kpi">
<div class="label">Entrada (USD\\u2192BRL)</div>
<div class="value positive">\${fmtUSD(totalIn)}</div>
<div class="sub">\${usdBrl.length} operacoes</div>
</div>
<div class="netting-kpi">
<div class="label">Posicao Liquida (Net)</div>
<div class="value \${netBalance >= 0 ? 'positive' : 'negative'}">\${netBalance >= 0 ? '+' : ''}\${fmtUSD(netBalance)}</div>
<div class="sub">\${netBalance >= 0 ? 'Posicao comprada' : 'Posicao vendida'}</div>
</div>
<div class="netting-kpi">
<div class="label">Eficiencia Netting</div>
<div class="value \${nettingEfficiency >= 80 ? 'positive' : nettingEfficiency >= 50 ? 'neutral' : 'negative'}">\${fmtNum(nettingEfficiency, 1)}%</div>
<div class="sub">\${nettingEfficiency >= 100 ? 'IOF 100% compensado' : netBalance >= 0 ? 'Saldo positivo' : 'Saldo negativo'}</div>
</div>
\`;
// Group by period for charts
const periodMap = {};
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 (!periodMap[key]) periodMap[key] = { inflow: 0, outflow: 0 };
if (r.fluxo === 'BRL \\u2192 USD') {
periodMap[key].outflow += r.valor_dolar;
} else {
periodMap[key].inflow += r.valor_dolar;
}
});
const periods = Object.keys(periodMap).sort();
const labels = periods.map(k => gran === 'dia' ? k.split('-').reverse().join('/') : gran === 'mes' ? k.split('-').reverse().join('/') : k);
const inflowData = periods.map(k => periodMap[k].inflow);
const outflowData = periods.map(k => periodMap[k].outflow);
const netData = periods.map(k => periodMap[k].inflow - periodMap[k].outflow);
const efficiencyData = periods.map(k => {
const out = periodMap[k].outflow;
const eff = out > 0 ? Math.min(100, (periodMap[k].inflow / out) * 100) : 0;
return eff;
});
// Destroy existing charts
if (charts.netting) charts.netting.destroy();
if (charts.eficiencia) charts.eficiencia.destroy();
// Netting Flow Chart
charts.netting = new Chart(document.getElementById('chartNetting'), {
type: 'bar',
data: {
labels,
datasets: [
{ label: 'Entrada (USD\\u2192BRL)', data: inflowData, backgroundColor: 'rgba(30,142,62,0.75)', borderRadius: 6 },
{ label: 'Saida (BRL\\u2192USD)', data: outflowData.map(v => -v), backgroundColor: 'rgba(220,38,38,0.75)', borderRadius: 6 },
{ label: 'Posicao Net', data: netData, type: 'line', borderColor: '#1A73E8', backgroundColor: 'rgba(26,115,232,0.1)', borderWidth: 3, pointRadius: 5, pointBackgroundColor: netData.map(v => v >= 0 ? '#1E8E3E' : '#DC2626'), fill: false, tension: 0.4 }
]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { position: 'top', labels: { font: { size: 11, family: 'Inter' }, padding: 16, usePointStyle: true } },
tooltip: {
backgroundColor: 'rgba(0,0,0,0.8)',
padding: 12,
cornerRadius: 8,
callbacks: {
label: ctx => {
if (ctx.dataset.label === 'Posicao Net') {
return ctx.dataset.label + ': ' + (ctx.raw >= 0 ? '+' : '') + fmtUSD(ctx.raw);
}
return ctx.dataset.label + ': ' + fmtUSD(Math.abs(ctx.raw));
}
}
}
},
scales: {
x: { grid: { display: false }, ticks: { font: { size: 10 } } },
y: {
grid: { color: 'rgba(0,0,0,0.06)' },
ticks: { callback: v => (v >= 0 ? '+' : '') + (Math.abs(v) >= 1000000 ? (v/1000000).toFixed(1) + 'M' : Math.abs(v) >= 1000 ? (v/1000).toFixed(0) + 'k' : v.toFixed(0)), font: { size: 10 } },
title: { display: true, text: 'USD', font: { size: 11 } }
}
}
}
});
// Efficiency Chart
charts.eficiencia = new Chart(document.getElementById('chartEficiencia'), {
type: 'line',
data: {
labels,
datasets: [{
label: 'Eficiencia Netting %',
data: efficiencyData,
borderColor: '#7600be',
backgroundColor: 'rgba(108,63,160,0.1)',
fill: true,
tension: 0.4,
pointRadius: 5,
pointBackgroundColor: '#7600be',
borderWidth: 3
}, {
label: 'Meta 100%',
data: periods.map(() => 100),
borderColor: '#1E8E3E',
borderDash: [6, 4],
borderWidth: 2,
pointRadius: 0,
fill: false
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { position: 'top', labels: { font: { size: 11, family: 'Inter' }, padding: 16, usePointStyle: true } },
tooltip: { backgroundColor: 'rgba(0,0,0,0.8)', padding: 12, cornerRadius: 8 }
},
scales: {
x: { grid: { display: false }, ticks: { font: { size: 10 } } },
y: {
min: 0, max: 120,
grid: { color: 'rgba(0,0,0,0.06)' },
ticks: { callback: v => v + '%', font: { size: 10 } },
title: { display: true, text: 'Eficiencia', font: { size: 11 } }
}
}
}
});
}
<\/script>
</body>
</html>`;
}
module.exports = { buildHTML };