- 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>
1586 lines
63 KiB
JavaScript
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 → USD</option>
|
|
<option value="USD \\u2192 BRL">USD → 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()">⬇ 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 ? '▲' : '▼';
|
|
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">↔</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">↕</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">§</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">Ø</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">☺</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">📊</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">↻</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">➕</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">⚠</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 };
|