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>
This commit is contained in:
root
2026-02-15 10:21:05 -05:00
parent ddf016a627
commit 95958e9a96
9 changed files with 547 additions and 47 deletions

20
public/chart.umd.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -4,6 +4,13 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>BI - CCC | CambioReal</title>
<script>
(function(){
var saved = localStorage.getItem('bi-theme');
var theme = saved || (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
document.documentElement.setAttribute('data-theme', theme);
})();
</script>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<style>
:root {
@@ -137,6 +144,24 @@
color: var(--text-secondary);
}
/* Dark Mode */
[data-theme="dark"] {
--bg: #0D1117;
--card: #161B22;
--text: #E6EDF3;
--text-secondary: #B1BAC4;
--border: #30363D;
--error-bg: #3D1215;
}
[data-theme="dark"] .form-group input {
background: var(--bg);
color: var(--text);
border-color: var(--border);
}
[data-theme="dark"] .login-card {
box-shadow: 0 4px 24px rgba(0,0,0,0.3);
}
/* Mobile responsive */
@media (max-width: 480px) {
.login-container { padding: 16px; }

View File

@@ -328,6 +328,8 @@ app.get('/admin/dashboard', requireRole('admin'), (req, res) => {
// --- Admin BI Dashboard (admin only) ---
app.get('/admin/bi', requireRole('admin'), (req, res) => {
try {
res.set('Cache-Control', 'no-store, no-cache, must-revalidate');
res.set('Pragma', 'no-cache');
const html = buildAdminBIHTML(req.session.user);
res.send(html);
} catch (err) {

View File

@@ -2,11 +2,11 @@
* Admin BI Dashboard - Business Intelligence Executive View
* Admin-only: comprehensive KPIs, revenue analysis, client intelligence, operational metrics
*/
const { buildHeader, buildFooter, buildHead } = require('./ui-template');
const { buildHeader, buildFooter, buildHead, getChartJsScript } = require('./ui-template');
function buildAdminBIHTML(user) {
const role = user.role || 'admin';
const pageScripts = '<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"><\/script>';
const pageScripts = getChartJsScript();
const now = new Date();
const firstDayOfMonth = new Date(now.getFullYear(), now.getMonth(), 1).toISOString().slice(0, 10);
@@ -14,6 +14,184 @@ function buildAdminBIHTML(user) {
const thirtyDaysAgo = new Date(now.getTime() - 30 * 86400000).toISOString().slice(0, 10);
const pageCSS = `
/* Smooth scroll for anchor navigation */
html { scroll-behavior: smooth; scroll-padding-top: 20px; }
/* === TRADING CONSOLE: Permanent dark terminal theme === */
body.trading-console {
--bg: #0D1117;
--card: #131A24;
--text: #E2E8F0;
--text-secondary: #94A3B8;
--text-muted: #64748B;
--border: rgba(0,255,136,0.1);
--green: #00FF88;
--green-bg: rgba(0,255,136,0.08);
--blue: #58A6FF;
--blue-bg: rgba(88,166,255,0.08);
--orange: #F0883E;
--orange-bg: rgba(240,136,62,0.08);
--red: #FF4444;
--red-bg: rgba(255,68,68,0.08);
--purple: #BC8CFF;
--purple-bg: rgba(188,140,255,0.08);
--admin-accent: #00FF88;
--admin-bg: rgba(0,255,136,0.05);
background: #0A0F18 !important;
color: var(--text);
color-scheme: dark;
}
/* Console Cards */
body.trading-console .hero-card,
body.trading-console .chart-card,
body.trading-console .metric-card,
body.trading-console .filter-bar {
background: rgba(255,255,255,0.03);
border: 1px solid rgba(0,255,136,0.1);
box-shadow: 0 2px 12px rgba(0,0,0,0.3), inset 0 1px 0 rgba(0,255,136,0.05);
}
/* Console Values - monospace terminal font */
body.trading-console .hero-value {
font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace;
text-shadow: 0 0 8px rgba(226,232,240,0.15);
}
/* Console Section Titles */
body.trading-console .section-title {
color: rgba(0,255,136,0.7);
font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace;
letter-spacing: 2px;
}
body.trading-console .section-title .icon {
background: rgba(0,255,136,0.08) !important;
color: #00FF88 !important;
}
/* Console Tables */
body.trading-console .data-table th {
background: rgba(0,255,136,0.03);
color: rgba(0,255,136,0.6);
font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace;
}
body.trading-console .data-table td {
font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace;
font-size: 12px;
border-bottom-color: rgba(0,255,136,0.06);
}
body.trading-console .data-table tr:hover td {
background: rgba(0,255,136,0.05);
}
/* Console Buttons */
body.trading-console .preset-btn {
background: rgba(255,255,255,0.03);
color: var(--text-secondary);
border-color: rgba(0,255,136,0.1);
}
body.trading-console .preset-btn:hover {
border-color: #00FF88; color: #00FF88;
}
body.trading-console .preset-btn.active {
background: rgba(0,255,136,0.15);
color: #00FF88; border-color: rgba(0,255,136,0.3);
}
body.trading-console .gran-btn {
background: rgba(255,255,255,0.03);
color: var(--text-secondary);
border-color: rgba(0,255,136,0.1);
}
body.trading-console .gran-btn:hover {
border-color: #F9A825; color: #F9A825;
}
body.trading-console .gran-btn.active {
background: rgba(249,168,37,0.15);
color: #F9A825; border-color: rgba(249,168,37,0.3);
}
body.trading-console .date-inputs input[type="date"] {
background: rgba(255,255,255,0.03);
color: var(--text); border-color: rgba(0,255,136,0.1);
}
/* Console Ranks */
body.trading-console .rank-1 { background: rgba(249,168,37,0.15); color: #F9A825; }
body.trading-console .rank-2 { background: rgba(255,255,255,0.08); color: #9AA0A6; }
body.trading-console .rank-3 { background: rgba(216,67,21,0.15); color: #FF7043; }
body.trading-console .rank-default { background: rgba(255,255,255,0.05); }
/* Console Loading */
body.trading-console .loading-overlay { background: rgba(10,15,24,0.85); }
/* Console Footer */
body.trading-console .app-footer {
background: #0A0F18; border-top-color: rgba(0,255,136,0.1); color: var(--text-muted);
}
/* Console Scrollbars */
body.trading-console ::-webkit-scrollbar { width: 6px; height: 6px; }
body.trading-console ::-webkit-scrollbar-track { background: rgba(0,0,0,0.2); }
body.trading-console ::-webkit-scrollbar-thumb { background: rgba(0,255,136,0.2); border-radius: 3px; }
body.trading-console ::-webkit-scrollbar-thumb:hover { background: rgba(0,255,136,0.35); }
/* === Floating Console Navigation === */
.console-nav {
position: fixed; right: 0; top: 50%; transform: translateY(-50%);
z-index: 1000; display: flex; flex-direction: column; gap: 2px;
padding: 6px 4px 6px 8px;
background: rgba(15,25,35,0.92);
backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(0,255,136,0.15); border-right: none;
border-radius: 12px 0 0 12px;
box-shadow: -4px 0 20px rgba(0,0,0,0.4);
}
.console-nav-btn {
display: flex; align-items: center; gap: 6px;
padding: 8px 10px; background: transparent;
border: 1px solid transparent; border-radius: 8px;
color: rgba(255,255,255,0.4); font-size: 11px; font-weight: 600;
font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace;
cursor: pointer; transition: all 0.2s; white-space: nowrap;
text-decoration: none;
}
.console-nav-btn:hover {
color: rgba(255,255,255,0.7); background: rgba(0,255,136,0.05);
}
.console-nav-btn.active {
color: #00FF88; background: rgba(0,255,136,0.08);
border-color: rgba(0,255,136,0.2);
text-shadow: 0 0 8px rgba(0,255,136,0.4);
}
.console-nav-btn .nav-icon {
font-size: 14px; width: 20px; text-align: center; flex-shrink: 0;
}
.console-nav-btn .nav-label {
font-size: 10px; letter-spacing: 0.5px; text-transform: uppercase;
}
@media (min-width: 769px) {
body.trading-console .app-container { padding-right: 70px; }
}
@media (max-width: 900px) {
.console-nav-btn .nav-label { display: none; }
.console-nav-btn { padding: 8px; }
.console-nav { padding: 6px 4px 6px 6px; }
}
@media (max-width: 768px) {
.console-nav-btn .nav-label { display: none; }
.console-nav-btn { padding: 10px; min-height: 40px; min-width: 40px; justify-content: center; }
.console-nav-btn .nav-icon { font-size: 16px; }
.console-nav { gap: 1px; padding: 4px 3px 4px 5px; }
}
@media (max-width: 480px) {
.console-nav {
top: 50%; transform: translateY(-50%); right: 0;
flex-direction: column;
padding: 3px 2px 3px 4px;
}
.console-nav-btn { padding: 7px; min-height: 34px; min-width: 34px; }
.console-nav-btn .nav-icon { font-size: 13px; }
}
/* Trading Terminal - Live Rates */
.trading-terminal {
background: linear-gradient(135deg, #0F1923 0%, #1A2332 50%, #0D1B2A 100%);
@@ -408,6 +586,19 @@ function buildAdminBIHTML(user) {
.section-title { font-size: 12px; letter-spacing: 0.5px; }
.section-title .icon { width: 24px; height: 24px; font-size: 12px; }
}
/* Dark Mode overrides */
[data-theme="dark"] .rank-1 { background: rgba(249,168,37,0.15); color: #F9A825; }
[data-theme="dark"] .rank-2 { background: rgba(255,255,255,0.08); color: #9AA0A6; }
[data-theme="dark"] .rank-3 { background: rgba(216,67,21,0.15); color: #FF7043; }
[data-theme="dark"] .rank-default { background: var(--bg); }
[data-theme="dark"] .loading-overlay { background: rgba(13,17,23,0.85); }
[data-theme="dark"] .preset-btn { background: var(--card); color: var(--text-secondary); border-color: var(--border); }
[data-theme="dark"] .preset-btn:hover { border-color: var(--admin-accent); color: var(--green); }
[data-theme="dark"] .gran-btn { background: var(--card); color: var(--text-secondary); border-color: var(--border); }
[data-theme="dark"] .gran-btn:hover { border-color: #F9A825; color: #F9A825; }
[data-theme="dark"] .date-inputs input[type="date"] { background: var(--card); color: var(--text); border-color: var(--border); }
[data-theme="dark"] .data-table tr:hover td { background: rgba(255,255,255,0.03); }
`;
return `<!DOCTYPE html>
@@ -415,7 +606,7 @@ function buildAdminBIHTML(user) {
<head>
${buildHead('BI Executive', pageCSS, pageScripts)}
</head>
<body>
<body class="trading-console">
${buildHeader({ role: role, userName: user.nome, activePage: 'bi' })}
@@ -443,7 +634,7 @@ ${buildHeader({ role: role, userName: user.nome, activePage: 'bi' })}
<div class="app-container">
<!-- Filter Bar -->
<div class="filter-bar">
<div class="filter-bar" id="filterBar">
<span class="filter-bar-label">Periodo:</span>
<div class="filter-presets">
<button class="preset-btn" data-preset="7d">7 Dias</button>
@@ -494,8 +685,8 @@ ${buildHeader({ role: role, userName: user.nome, activePage: 'bi' })}
</div>
<!-- Section: Revenue & Spread -->
<div class="section-title">
<span class="icon" style="background:var(--green-bg);color:var(--green);">$</span>
<div class="section-title" id="sectionRevSpread">
<span class="icon">$</span>
Revenue & Spread
</div>
<div class="charts-row">
@@ -510,8 +701,8 @@ ${buildHeader({ role: role, userName: user.nome, activePage: 'bi' })}
</div>
<!-- Section: Client Intelligence -->
<div class="section-title">
<span class="icon" style="background:var(--orange-bg);color:var(--orange);">&#x1F465;</span>
<div class="section-title" id="sectionClients">
<span class="icon">&#x1F465;</span>
Inteligencia de Clientes
</div>
<div class="charts-row equal">
@@ -539,8 +730,8 @@ ${buildHeader({ role: role, userName: user.nome, activePage: 'bi' })}
</div>
<!-- Section: Operational -->
<div class="section-title">
<span class="icon" style="background:var(--blue-bg);color:var(--blue);">&#x2699;</span>
<div class="section-title" id="sectionOps">
<span class="icon">&#x2699;</span>
Operacional
</div>
<div class="charts-row triple">
@@ -582,8 +773,8 @@ ${buildHeader({ role: role, userName: user.nome, activePage: 'bi' })}
</div>
<!-- Section: Revenue Analytics -->
<div class="section-title" style="margin-top:12px;">
<span class="icon" style="background:#FFF8E1;color:#F9A825;">&#x1F4B0;</span>
<div class="section-title" id="sectionPnL" style="margin-top:12px;">
<span class="icon">&#x1F4B0;</span>
Revenue Analytics (P&L Real)
</div>
@@ -645,6 +836,45 @@ ${buildHeader({ role: role, userName: user.nome, activePage: 'bi' })}
${buildFooter()}
<!-- Floating Console Navigation -->
<nav class="console-nav" id="consoleNav">
<a class="console-nav-btn" href="#filterBar" title="Filtros">
<span class="nav-icon">\uD83D\uDD0D</span><span class="nav-label">Filtros</span>
</a>
<a class="console-nav-btn" href="#heroGrid" title="KPIs">
<span class="nav-icon">\uD83D\uDCCA</span><span class="nav-label">KPIs</span>
</a>
<a class="console-nav-btn" href="#sectionRevSpread" title="Revenue">
<span class="nav-icon">$</span><span class="nav-label">Revenue</span>
</a>
<a class="console-nav-btn" href="#sectionClients" title="Clientes">
<span class="nav-icon">\uD83D\uDC65</span><span class="nav-label">Clientes</span>
</a>
<a class="console-nav-btn" href="#sectionOps" title="Operacional">
<span class="nav-icon">\u2699</span><span class="nav-label">Ops</span>
</a>
<a class="console-nav-btn" href="#sectionPnL" title="P&L">
<span class="nav-icon">\uD83D\uDCB0</span><span class="nav-label">P&L</span>
</a>
</nav>
<script>
// Force dark theme on trading console - override global toggle
document.documentElement.setAttribute('data-theme', 'dark');
var _toggle = document.querySelector('.btn-theme-toggle');
if (_toggle) _toggle.style.display = 'none';
</script>
<script>
// Global error handler - shows JS errors on screen for debugging
window.onerror = function(msg, url, line, col, err) {
var d = document.createElement('div');
d.style.cssText = 'position:fixed;top:0;left:0;right:0;padding:12px 16px;background:#FF4444;color:white;font-size:13px;z-index:99999;font-family:monospace;word-break:break-all;';
d.textContent = 'JS ERROR: ' + msg + ' (line ' + line + ')';
document.body.appendChild(d);
};
</script>
<script>
// === Formatters ===
const fmtBRL = v => v.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
@@ -709,12 +939,14 @@ function destroyCharts() {
// === Main Data Loader ===
async function loadBI() {
document.getElementById('periodInfo').textContent = 'Carregando BI...';
const periodDays = Math.round((new Date(currentEnd) - new Date(currentStart)) / 86400000) + 1;
document.getElementById('periodInfo').textContent = periodDays + ' dias | ' + currentStart.split('-').reverse().join('/') + ' a ' + currentEnd.split('-').reverse().join('/');
try {
const resp = await fetch('/admin/api/bi?start=' + currentStart + '&end=' + currentEnd);
if (!resp.ok) { document.getElementById('periodInfo').textContent = 'Erro HTTP ' + resp.status; return; }
const d = await resp.json();
if (d.error) { document.getElementById('periodInfo').textContent = 'Erro: ' + d.error; return; }
// Hero KPIs
document.getElementById('kpiSpreadRevenue').textContent = fmtBRL(d.kpis.total.spread_revenue);
@@ -739,15 +971,18 @@ async function loadBI() {
const avgSpread = d.kpis.brlUsd.avg_spread_pct || d.kpis.usdBrl.avg_spread_pct || 0;
document.getElementById('spreadBadge').textContent = 'Media: ' + avgSpread.toFixed(2) + '%';
// === Donut Chart: Revenue por Corredor ===
destroyCharts();
// === Charts (wrapped individually so data tables render even if Chart.js fails) ===
var _hasChart = typeof Chart !== 'undefined';
// Donut: Revenue por Corredor
if (_hasChart) { try { destroyCharts();
chartDonut = new Chart(document.getElementById('chartDonut'), {
type: 'doughnut',
data: {
labels: ['BRL \u2192 USD', 'USD \u2192 BRL'],
datasets: [{
data: [d.kpis.brlUsd.spread_revenue, d.kpis.usdBrl.spread_revenue],
backgroundColor: ['#1A73E8', '#1E8E3E'],
backgroundColor: ['#58A6FF', '#00FF88'],
borderWidth: 0, hoverOffset: 8
}]
},
@@ -760,8 +995,9 @@ async function loadBI() {
cutout: '65%'
}
});
} catch(e) { console.warn('Chart donut:', e.message); } }
// === Spread Trend + Volume Line Chart ===
// Spread Trend + Volume Line Chart
const allDays = new Set();
d.trend.brlUsd.forEach(t => allDays.add(t.dia));
d.trend.usdBrl.forEach(t => allDays.add(t.dia));
@@ -782,6 +1018,7 @@ async function loadBI() {
});
const volumes = days.map(day => volByDay[day]);
if (_hasChart) { try {
chartSpreadTrend = new Chart(document.getElementById('chartSpreadTrend'), {
type: 'bar',
data: {
@@ -789,12 +1026,12 @@ async function loadBI() {
datasets: [
{
label: 'Volume USD', data: volumes, type: 'bar',
backgroundColor: 'rgba(26,115,232,0.15)', borderColor: 'rgba(26,115,232,0.4)',
backgroundColor: 'rgba(88,166,255,0.15)', borderColor: 'rgba(88,166,255,0.4)',
borderWidth: 1, borderRadius: 4, yAxisID: 'y', order: 2
},
{
label: 'Spread Medio %', data: avgSpreads, type: 'line',
borderColor: '#1E8E3E', backgroundColor: 'rgba(30,142,62,0.1)',
borderColor: '#00FF88', backgroundColor: 'rgba(0,255,136,0.1)',
borderWidth: 2, pointRadius: 2, pointHoverRadius: 5, fill: true,
tension: 0.3, yAxisID: 'y1', order: 1, spanGaps: true
}
@@ -813,23 +1050,25 @@ async function loadBI() {
}
},
scales: {
y: { position: 'left', grid: { color: 'rgba(0,0,0,0.04)' }, ticks: { callback: v => fmtUSD(v), font: { size: 10 } } },
y: { position: 'left', grid: { color: 'rgba(0,255,136,0.06)' }, ticks: { callback: v => fmtUSD(v), font: { size: 10 } } },
y1: { position: 'right', grid: { display: false }, ticks: { callback: v => v.toFixed(1) + '%', font: { size: 10 } }, min: 0 },
x: { grid: { display: false }, ticks: { font: { size: 10 }, maxRotation: 45 } }
}
}
});
} catch(e) { console.warn('Chart spread:', e.message); } }
// === Top 10 Clients Horizontal Bar ===
// Top 10 Clients Horizontal Bar
const clientNames = d.topClients.map(c => c.nome.length > 25 ? c.nome.slice(0, 22) + '...' : c.nome);
const clientVols = d.topClients.map(c => c.vol_usd);
if (_hasChart) { try {
chartTopClients = new Chart(document.getElementById('chartTopClients'), {
type: 'bar',
data: {
labels: clientNames,
datasets: [{
label: 'Volume USD', data: clientVols,
backgroundColor: 'rgba(118,0,190,0.12)', borderColor: 'rgba(118,0,190,0.5)',
backgroundColor: 'rgba(188,140,255,0.15)', borderColor: 'rgba(188,140,255,0.5)',
borderWidth: 1, borderRadius: 4
}]
},
@@ -840,11 +1079,12 @@ async function loadBI() {
tooltip: { callbacks: { label: ctx => fmtUSD(ctx.raw) + ' (' + d.topClients[ctx.dataIndex].qtd + ' ops)' } }
},
scales: {
x: { grid: { color: 'rgba(0,0,0,0.04)' }, ticks: { callback: v => fmtUSD(v), font: { size: 10 } } },
x: { grid: { color: 'rgba(0,255,136,0.06)' }, ticks: { callback: v => fmtUSD(v), font: { size: 10 } } },
y: { grid: { display: false }, ticks: { font: { size: 11, weight: 600 } } }
}
}
});
} catch(e) { console.warn('Chart clients:', e.message); } }
// Retention
document.getElementById('retentionValue').textContent = d.retention.rate + '%';
@@ -871,14 +1111,15 @@ async function loadBI() {
const volUsdBrl = days.map(day => { const t = d.trend.usdBrl.find(x => x.dia === day); return t ? t.vol_usd : 0; });
const volUsdUsd = days.map(day => { const t = d.trend.usdUsd.find(x => x.dia === day); return t ? t.vol_usd : 0; });
if (_hasChart) { try {
chartVolFlow = new Chart(document.getElementById('chartVolFlow'), {
type: 'bar',
data: {
labels,
datasets: [
{ label: 'BRL\u2192USD', data: volBrlUsd, backgroundColor: '#1A73E8', borderRadius: 2 },
{ label: 'USD\u2192BRL', data: volUsdBrl, backgroundColor: '#1E8E3E', borderRadius: 2 },
{ label: 'USD\u2192USD', data: volUsdUsd, backgroundColor: '#7B1FA2', borderRadius: 2 }
{ label: 'BRL\u2192USD', data: volBrlUsd, backgroundColor: '#58A6FF', borderRadius: 2 },
{ label: 'USD\u2192BRL', data: volUsdBrl, backgroundColor: '#00FF88', borderRadius: 2 },
{ label: 'USD\u2192USD', data: volUsdUsd, backgroundColor: '#BC8CFF', borderRadius: 2 }
]
},
options: {
@@ -889,10 +1130,11 @@ async function loadBI() {
},
scales: {
x: { stacked: true, grid: { display: false }, ticks: { font: { size: 10 }, maxRotation: 45 } },
y: { stacked: true, grid: { color: 'rgba(0,0,0,0.04)' }, ticks: { callback: v => fmtUSD(v), font: { size: 10 } } }
y: { stacked: true, grid: { color: 'rgba(0,255,136,0.06)' }, ticks: { callback: v => fmtUSD(v), font: { size: 10 } } }
}
}
});
} catch(e) { console.warn('Chart volFlow:', e.message); } }
// Netting
document.getElementById('nettingSaida').textContent = fmtUSD(d.netting.saida_usd);
@@ -921,9 +1163,14 @@ async function loadBI() {
}).join('');
}
// Show period info after successful load
const periodDaysLabel = Math.round((new Date(currentEnd) - new Date(currentStart)) / 86400000) + 1;
var _chartInfo = typeof Chart !== 'undefined' ? '' : ' | Charts: OFF' + (window._chartError ? ' (' + window._chartError + ')' : '');
document.getElementById('periodInfo').textContent = periodDaysLabel + ' dias | ' + currentStart.split('-').reverse().join('/') + ' a ' + currentEnd.split('-').reverse().join('/') + _chartInfo;
} catch (err) {
console.error('BI load error:', err);
document.getElementById('periodInfo').textContent = 'Erro ao carregar dados';
document.getElementById('periodInfo').textContent = 'Erro: ' + (err.message || err);
}
}
@@ -959,25 +1206,27 @@ function destroyRevCharts() {
// Color palette for products
const productColors = {
'BR\u2192US: Checkout': '#1A73E8',
'BR\u2192US: CambioTransfer': '#42A5F5',
'US\u2192BR: balance': '#1E8E3E',
'US\u2192BR: desconhecido': '#66BB6A',
'US\u2192BR: swift': '#00897B',
'US\u2192BR: wire': '#26A69A',
'US\u2192BR: pix': '#4CAF50',
'BR\u2192US: Checkout': '#58A6FF',
'BR\u2192US: CambioTransfer': '#82BFFF',
'US\u2192BR: balance': '#00FF88',
'US\u2192BR: desconhecido': '#66FFB2',
'US\u2192BR: swift': '#00BFA5',
'US\u2192BR: wire': '#4DD0A0',
'US\u2192BR: pix': '#33FF99',
};
function getProductColor(produto, i) {
return productColors[produto] || ['#7B1FA2','#AB47BC','#F9A825','#FF7043','#78909C','#EC407A','#5C6BC0','#8D6E63'][i % 8];
return productColors[produto] || ['#BC8CFF','#D4A5FF','#F9A825','#FF7043','#78909C','#FF6B9D','#7C8CFF','#A1887F'][i % 8];
}
async function loadRevenue() {
document.getElementById('revenueStatus').textContent = 'Carregando...';
try {
const resp = await fetch('/admin/api/bi/revenue?start=' + currentStart + '&end=' + currentEnd + '&granularity=' + currentGran);
if (!resp.ok) { document.getElementById('revenueStatus').textContent = 'Erro HTTP ' + resp.status; return; }
const d = await resp.json();
if (d.error) { document.getElementById('revenueStatus').textContent = 'Erro: ' + d.error; return; }
if (!d.summary || !d.timeline) { document.getElementById('revenueStatus').textContent = 'Dados incompletos'; return; }
// KPI cards
document.getElementById('revTotal').textContent = fmtUSD(d.summary.total_receita);
@@ -1010,6 +1259,7 @@ async function loadRevenue() {
return p;
});
if (typeof Chart !== 'undefined') { try {
chartRevTimeline = new Chart(document.getElementById('chartRevTimeline'), {
type: 'bar',
data: { labels, datasets },
@@ -1021,16 +1271,18 @@ async function loadRevenue() {
},
scales: {
x: { stacked: true, grid: { display: false }, ticks: { font: { size: 10 }, maxRotation: 45 } },
y: { stacked: true, grid: { color: 'rgba(0,0,0,0.04)' }, ticks: { callback: v => fmtUSD(v), font: { size: 10 } } }
y: { stacked: true, grid: { color: 'rgba(0,255,136,0.06)' }, ticks: { callback: v => fmtUSD(v), font: { size: 10 } } }
}
}
});
} catch(e) { console.warn('Chart revTimeline:', e.message); } }
// Donut chart by product
const donutData = d.totals.map(t => t.receita);
const donutLabels = d.totals.map(t => t.produto);
const donutColors = d.totals.map((t, i) => getProductColor(t.direcao + ': ' + t.tipo, i));
if (typeof Chart !== 'undefined') { try {
chartRevDonut = new Chart(document.getElementById('chartRevDonut'), {
type: 'doughnut',
data: {
@@ -1046,6 +1298,7 @@ async function loadRevenue() {
cutout: '60%'
}
});
} catch(e) { console.warn('Chart revDonut:', e.message); } }
// Detailed table
const tbody = document.getElementById('revTableBody');
@@ -1100,9 +1353,64 @@ document.querySelectorAll('.gran-btn').forEach(btn => {
});
});
// Init
document.addEventListener('DOMContentLoaded', () => { loadBI(); loadRevenue(); fetchLiveRate(); });
// === Console Nav: Scroll Spy (highlight active section) ===
(function() {
var links = document.querySelectorAll('.console-nav-btn');
var ids = [];
links.forEach(function(a) {
var h = a.getAttribute('href');
if (h && h.charAt(0) === '#') ids.push(h.substring(1));
});
var ticking = false;
function update() {
var active = ids[0];
for (var i = 0; i < ids.length; i++) {
var el = document.getElementById(ids[i]);
if (el && el.getBoundingClientRect().top <= 150) active = ids[i];
}
links.forEach(function(a) {
var h = a.getAttribute('href');
if (h === '#' + active) a.classList.add('active');
else a.classList.remove('active');
});
ticking = false;
}
window.addEventListener('scroll', function() {
if (!ticking) { requestAnimationFrame(update); ticking = true; }
}, { passive: true });
update();
})();
// Init - runs immediately (script is at bottom of body, DOM is ready)
// Also handles case where DOMContentLoaded already fired
function _startBI() {
try {
// Diagnostic: show Chart.js status
var chartStatus = typeof Chart !== 'undefined' ? 'Chart.js v' + (Chart.version || '?') : 'Chart.js FALHOU' + (window._chartError ? ': ' + window._chartError : '');
console.log(chartStatus);
// Chart.js Dark Terminal Defaults
if (typeof Chart !== 'undefined') {
Chart.defaults.color = '#94A3B8';
Chart.defaults.borderColor = 'rgba(0,255,136,0.06)';
Chart.defaults.plugins.legend.labels.color = '#94A3B8';
Chart.defaults.plugins.tooltip.backgroundColor = '#1A2332';
Chart.defaults.plugins.tooltip.titleColor = '#E2E8F0';
Chart.defaults.plugins.tooltip.bodyColor = '#94A3B8';
Chart.defaults.plugins.tooltip.borderColor = 'rgba(0,255,136,0.15)';
Chart.defaults.plugins.tooltip.borderWidth = 1;
}
loadBI(); loadRevenue(); fetchLiveRate();
setInterval(fetchLiveRate, 3000);
} catch(e) {
document.getElementById('periodInfo').textContent = 'Init erro: ' + e.message;
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', _startBI);
} else {
_startBI();
}
<\/script>
</body>
</html>`;

View File

@@ -2,12 +2,12 @@
* Admin Dashboard Corporate - KPIs, Tendências e Detalhes
* Filtros por período: Este Mês, Mês Anterior, Últimos 2 Meses, ou período customizado
*/
const { buildHeader, buildFooter, buildHead } = require('./ui-template');
const { buildHeader, buildFooter, buildHead, getChartJsScript } = require('./ui-template');
function buildAdminDashboardHTML(user) {
// Support both admin and corporate roles
const role = user.role || 'corporate';
const pageScripts = '<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"><\/script>';
const pageScripts = getChartJsScript();
// Calculate default dates (current month)
const now = new Date();
@@ -441,6 +441,15 @@ function buildAdminDashboardHTML(user) {
.chart-wrap { height: 200px; }
.details-table th, .details-table td { padding: 6px 4px; font-size: 10px; }
}
/* Dark Mode overrides */
[data-theme="dark"] .preset-btn { background: var(--card); color: var(--text-secondary); border-color: var(--border); }
[data-theme="dark"] .preset-btn:hover { border-color: var(--admin-accent); color: var(--green); }
[data-theme="dark"] .date-inputs input { background: var(--card); color: var(--text); border-color: var(--border); }
[data-theme="dark"] .details-table tr:hover { background: rgba(255,255,255,0.04); }
[data-theme="dark"] .details-table thead th { background: var(--bg); }
[data-theme="dark"] .details-table td { border-bottom-color: var(--border); }
[data-theme="dark"] .loading-overlay { background: rgba(13,17,23,0.85); }
`;
return `<!DOCTYPE html>

View File

@@ -2,7 +2,7 @@
* Admin Home Dashboard - Fast daily overview
* 3 flows: BRL→USD, USD→BRL, USD→USD (balance)
*/
const { buildHeader, buildFooter, buildHead } = require('./ui-template');
const { buildHeader, buildFooter, buildHead, getChartJsScript } = require('./ui-template');
function buildAdminHomeHTML(stats, user) {
const now = new Date().toLocaleString('pt-BR');
@@ -26,7 +26,7 @@ function buildAdminHomeHTML(stats, user) {
const totalOntem = stats.brlUsd.ontem.qtd + stats.usdBrl.ontem.qtd + stats.usdUsd.ontem.qtd;
const totalVar = calcVar(totalHoje, totalOntem);
const pageScripts = `<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>`;
const pageScripts = getChartJsScript();
const pageCSS = `
/* Trading Terminal - Live Rates */

View File

@@ -175,6 +175,24 @@ function buildAdminHTML(agentes, admin) {
tbody td { padding: 8px 6px; }
.status-badge { font-size: 10px; padding: 3px 8px; }
}
/* 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"] .form-group input,
[data-theme="dark"] .form-group select { background: var(--bg); color: var(--text); border-color: var(--border); }
[data-theme="dark"] .form-group input:disabled { background: var(--border); }
[data-theme="dark"] .btn-cancel { background: var(--bg); border-color: var(--border); color: var(--text); }
[data-theme="dark"] .btn-cancel:hover { background: var(--border); }
[data-theme="dark"] .modal { background: var(--card); }
[data-theme="dark"] .modal-overlay.active { background: rgba(0,0,0,0.7); }
[data-theme="dark"] .btn-emular:hover { background: rgba(63,185,80,0.15); }
[data-theme="dark"] .btn-edit:hover { background: rgba(88,166,255,0.15); }
[data-theme="dark"] .btn-toggle:hover { background: rgba(240,136,62,0.15); }
[data-theme="dark"] .btn-password:hover { background: rgba(188,140,255,0.15); }
</style>
`;

View File

@@ -2,7 +2,7 @@
* Gera HTML do dashboard — parametrizado por agente
* Updated: 2026-02-09 - 4 decimal places for spread
*/
const { buildHeader, buildFooter, buildHead } = require('./ui-template');
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');
@@ -13,7 +13,7 @@ function buildHTML(data, agente, isAgentDashboard = true, diasPeriodo = null, as
// Determine the back URL based on emulator's role
const backUrl = emulatorRole === 'corporate' ? '/corporate' : '/admin';
const pageScripts = `<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.7/dist/chart.umd.min.js"><\/script>`;
const pageScripts = getChartJsScript();
const dashboardCSS = `
/* Emulation Banner */
@@ -386,6 +386,22 @@ function buildHTML(data, agente, isAgentDashboard = true, diasPeriodo = null, as
.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>

View File

@@ -33,6 +33,49 @@ const cssVariables = `
--purple: #7B1FA2;
--purple-bg: #F3E5F5;
}
/* Dark Mode */
[data-theme="dark"] {
--bg: #0D1117;
--card: #161B22;
--text: #E6EDF3;
--text-secondary: #B1BAC4;
--text-muted: #6E7681;
--border: #30363D;
--primary-bg: #2D1B4E;
--admin-bg: #0D2818;
--corporate-bg: #2D1B4E;
--green: #3FB950;
--green-bg: #0D2818;
--blue: #58A6FF;
--blue-bg: #0D1D3A;
--orange: #F0883E;
--orange-bg: #2A1A0A;
--red: #F85149;
--red-bg: #3D1215;
--purple: #BC8CFF;
--purple-bg: #2D1B4E;
}
[data-theme="dark"] .app-header {
box-shadow: 0 2px 8px rgba(0,0,0,0.4);
}
[data-theme="dark"] .app-footer {
background: var(--card);
border-top-color: var(--border);
}
[data-theme="dark"] input,
[data-theme="dark"] select {
background: var(--bg);
color: var(--text);
border-color: var(--border);
}
[data-theme="dark"] input:focus,
[data-theme="dark"] select:focus {
border-color: var(--blue);
}
[data-theme="dark"] tbody tr:hover { background: rgba(255,255,255,0.03); }
[data-theme="dark"] tbody tr:nth-child(even) { background: rgba(255,255,255,0.02); }
[data-theme="dark"] thead th { background: var(--bg); }
`;
// CSS do Header unificado
@@ -186,6 +229,22 @@ const headerCSS = `
background: rgba(255,255,255,0.25);
}
/* Theme Toggle */
.btn-theme-toggle {
background: rgba(255,255,255,0.15);
border: 1px solid rgba(255,255,255,0.25);
color: white;
width: 36px; height: 36px;
border-radius: 50%;
display: flex; align-items: center; justify-content: center;
cursor: pointer; font-size: 16px; line-height: 1;
transition: all 0.2s;
}
.btn-theme-toggle:hover {
background: rgba(255,255,255,0.25);
transform: scale(1.1);
}
/* Footer */
.app-footer {
text-align: center;
@@ -357,6 +416,9 @@ function buildHeader(options = {}) {
<span>${userName}</span>
<span class="user-role">${roleLabel}</span>
</div>
<button class="btn-theme-toggle" onclick="toggleTheme()" title="Alternar tema claro/escuro" aria-label="Alternar tema">
<span id="themeIcon">\u263E</span>
</button>
<a href="/logout" class="btn-logout">Sair</a>
</div>
</div>
@@ -382,11 +444,35 @@ function buildFooter() {
* @param {string} additionalCSS - CSS adicional
* @param {string} scripts - Scripts externos (opcional)
*/
// Theme JS - runs in <head> to prevent FOUC
const themeScript = `
<script>
(function(){
var saved = localStorage.getItem('bi-theme');
var theme = saved || (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
document.documentElement.setAttribute('data-theme', theme);
})();
function toggleTheme() {
var current = document.documentElement.getAttribute('data-theme');
var next = current === 'dark' ? 'light' : 'dark';
document.documentElement.setAttribute('data-theme', next);
localStorage.setItem('bi-theme', next);
var icon = document.getElementById('themeIcon');
if (icon) icon.textContent = next === 'dark' ? '\\u2600' : '\\u263E';
}
document.addEventListener('DOMContentLoaded', function() {
var icon = document.getElementById('themeIcon');
if (icon) icon.textContent = document.documentElement.getAttribute('data-theme') === 'dark' ? '\\u2600' : '\\u263E';
});
<\/script>
`;
function buildHead(title, additionalCSS = '', scripts = '') {
return `
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${title} | BI - CCC</title>
${themeScript}
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
${scripts}
<style>
@@ -397,10 +483,26 @@ function buildHead(title, additionalCSS = '', scripts = '') {
`;
}
// Inline Chart.js to avoid CDN/network loading issues
const _chartJsPath = require('path').join(__dirname, '../public/chart.umd.min.js');
let _chartJsInline = '';
try {
_chartJsInline = require('fs').readFileSync(_chartJsPath, 'utf8');
} catch(e) { console.warn('Chart.js file not found at', _chartJsPath); }
function getChartJsScript() {
if (_chartJsInline) {
// Wrap in try/catch to prevent Chart.js errors from crashing the page
return '<script>try{' + _chartJsInline + '}catch(_e){window._chartError=_e.message;}<\/script>';
}
return '<script src="/public/chart.umd.min.js"><\/script>';
}
module.exports = {
buildHeader,
buildFooter,
buildHead,
cssVariables,
headerCSS
headerCSS,
getChartJsScript
};