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:
20
public/chart.umd.min.js
vendored
Normal file
20
public/chart.umd.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
@@ -4,6 +4,13 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>BI - CCC | CambioReal</title>
|
<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">
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
||||||
<style>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
@@ -137,6 +144,24 @@
|
|||||||
color: var(--text-secondary);
|
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 */
|
/* Mobile responsive */
|
||||||
@media (max-width: 480px) {
|
@media (max-width: 480px) {
|
||||||
.login-container { padding: 16px; }
|
.login-container { padding: 16px; }
|
||||||
|
|||||||
@@ -328,6 +328,8 @@ app.get('/admin/dashboard', requireRole('admin'), (req, res) => {
|
|||||||
// --- Admin BI Dashboard (admin only) ---
|
// --- Admin BI Dashboard (admin only) ---
|
||||||
app.get('/admin/bi', requireRole('admin'), (req, res) => {
|
app.get('/admin/bi', requireRole('admin'), (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
res.set('Cache-Control', 'no-store, no-cache, must-revalidate');
|
||||||
|
res.set('Pragma', 'no-cache');
|
||||||
const html = buildAdminBIHTML(req.session.user);
|
const html = buildAdminBIHTML(req.session.user);
|
||||||
res.send(html);
|
res.send(html);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
388
src/admin-bi.js
388
src/admin-bi.js
@@ -2,11 +2,11 @@
|
|||||||
* Admin BI Dashboard - Business Intelligence Executive View
|
* Admin BI Dashboard - Business Intelligence Executive View
|
||||||
* Admin-only: comprehensive KPIs, revenue analysis, client intelligence, operational metrics
|
* 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) {
|
function buildAdminBIHTML(user) {
|
||||||
const role = user.role || 'admin';
|
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 now = new Date();
|
||||||
const firstDayOfMonth = new Date(now.getFullYear(), now.getMonth(), 1).toISOString().slice(0, 10);
|
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 thirtyDaysAgo = new Date(now.getTime() - 30 * 86400000).toISOString().slice(0, 10);
|
||||||
|
|
||||||
const pageCSS = `
|
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 - Live Rates */
|
||||||
.trading-terminal {
|
.trading-terminal {
|
||||||
background: linear-gradient(135deg, #0F1923 0%, #1A2332 50%, #0D1B2A 100%);
|
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 { font-size: 12px; letter-spacing: 0.5px; }
|
||||||
.section-title .icon { width: 24px; height: 24px; font-size: 12px; }
|
.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>
|
return `<!DOCTYPE html>
|
||||||
@@ -415,7 +606,7 @@ function buildAdminBIHTML(user) {
|
|||||||
<head>
|
<head>
|
||||||
${buildHead('BI Executive', pageCSS, pageScripts)}
|
${buildHead('BI Executive', pageCSS, pageScripts)}
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body class="trading-console">
|
||||||
|
|
||||||
${buildHeader({ role: role, userName: user.nome, activePage: 'bi' })}
|
${buildHeader({ role: role, userName: user.nome, activePage: 'bi' })}
|
||||||
|
|
||||||
@@ -443,7 +634,7 @@ ${buildHeader({ role: role, userName: user.nome, activePage: 'bi' })}
|
|||||||
<div class="app-container">
|
<div class="app-container">
|
||||||
|
|
||||||
<!-- Filter Bar -->
|
<!-- Filter Bar -->
|
||||||
<div class="filter-bar">
|
<div class="filter-bar" id="filterBar">
|
||||||
<span class="filter-bar-label">Periodo:</span>
|
<span class="filter-bar-label">Periodo:</span>
|
||||||
<div class="filter-presets">
|
<div class="filter-presets">
|
||||||
<button class="preset-btn" data-preset="7d">7 Dias</button>
|
<button class="preset-btn" data-preset="7d">7 Dias</button>
|
||||||
@@ -494,8 +685,8 @@ ${buildHeader({ role: role, userName: user.nome, activePage: 'bi' })}
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Section: Revenue & Spread -->
|
<!-- Section: Revenue & Spread -->
|
||||||
<div class="section-title">
|
<div class="section-title" id="sectionRevSpread">
|
||||||
<span class="icon" style="background:var(--green-bg);color:var(--green);">$</span>
|
<span class="icon">$</span>
|
||||||
Revenue & Spread
|
Revenue & Spread
|
||||||
</div>
|
</div>
|
||||||
<div class="charts-row">
|
<div class="charts-row">
|
||||||
@@ -510,8 +701,8 @@ ${buildHeader({ role: role, userName: user.nome, activePage: 'bi' })}
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Section: Client Intelligence -->
|
<!-- Section: Client Intelligence -->
|
||||||
<div class="section-title">
|
<div class="section-title" id="sectionClients">
|
||||||
<span class="icon" style="background:var(--orange-bg);color:var(--orange);">👥</span>
|
<span class="icon">👥</span>
|
||||||
Inteligencia de Clientes
|
Inteligencia de Clientes
|
||||||
</div>
|
</div>
|
||||||
<div class="charts-row equal">
|
<div class="charts-row equal">
|
||||||
@@ -539,8 +730,8 @@ ${buildHeader({ role: role, userName: user.nome, activePage: 'bi' })}
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Section: Operational -->
|
<!-- Section: Operational -->
|
||||||
<div class="section-title">
|
<div class="section-title" id="sectionOps">
|
||||||
<span class="icon" style="background:var(--blue-bg);color:var(--blue);">⚙</span>
|
<span class="icon">⚙</span>
|
||||||
Operacional
|
Operacional
|
||||||
</div>
|
</div>
|
||||||
<div class="charts-row triple">
|
<div class="charts-row triple">
|
||||||
@@ -582,8 +773,8 @@ ${buildHeader({ role: role, userName: user.nome, activePage: 'bi' })}
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Section: Revenue Analytics -->
|
<!-- Section: Revenue Analytics -->
|
||||||
<div class="section-title" style="margin-top:12px;">
|
<div class="section-title" id="sectionPnL" style="margin-top:12px;">
|
||||||
<span class="icon" style="background:#FFF8E1;color:#F9A825;">💰</span>
|
<span class="icon">💰</span>
|
||||||
Revenue Analytics (P&L Real)
|
Revenue Analytics (P&L Real)
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -645,6 +836,45 @@ ${buildHeader({ role: role, userName: user.nome, activePage: 'bi' })}
|
|||||||
|
|
||||||
${buildFooter()}
|
${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>
|
<script>
|
||||||
// === Formatters ===
|
// === Formatters ===
|
||||||
const fmtBRL = v => v.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
|
const fmtBRL = v => v.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
|
||||||
@@ -709,12 +939,14 @@ function destroyCharts() {
|
|||||||
|
|
||||||
// === Main Data Loader ===
|
// === Main Data Loader ===
|
||||||
async function loadBI() {
|
async function loadBI() {
|
||||||
|
document.getElementById('periodInfo').textContent = 'Carregando BI...';
|
||||||
const periodDays = Math.round((new Date(currentEnd) - new Date(currentStart)) / 86400000) + 1;
|
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 {
|
try {
|
||||||
const resp = await fetch('/admin/api/bi?start=' + currentStart + '&end=' + currentEnd);
|
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();
|
const d = await resp.json();
|
||||||
|
if (d.error) { document.getElementById('periodInfo').textContent = 'Erro: ' + d.error; return; }
|
||||||
|
|
||||||
// Hero KPIs
|
// Hero KPIs
|
||||||
document.getElementById('kpiSpreadRevenue').textContent = fmtBRL(d.kpis.total.spread_revenue);
|
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;
|
const avgSpread = d.kpis.brlUsd.avg_spread_pct || d.kpis.usdBrl.avg_spread_pct || 0;
|
||||||
document.getElementById('spreadBadge').textContent = 'Media: ' + avgSpread.toFixed(2) + '%';
|
document.getElementById('spreadBadge').textContent = 'Media: ' + avgSpread.toFixed(2) + '%';
|
||||||
|
|
||||||
// === Donut Chart: Revenue por Corredor ===
|
// === Charts (wrapped individually so data tables render even if Chart.js fails) ===
|
||||||
destroyCharts();
|
var _hasChart = typeof Chart !== 'undefined';
|
||||||
|
|
||||||
|
// Donut: Revenue por Corredor
|
||||||
|
if (_hasChart) { try { destroyCharts();
|
||||||
chartDonut = new Chart(document.getElementById('chartDonut'), {
|
chartDonut = new Chart(document.getElementById('chartDonut'), {
|
||||||
type: 'doughnut',
|
type: 'doughnut',
|
||||||
data: {
|
data: {
|
||||||
labels: ['BRL \u2192 USD', 'USD \u2192 BRL'],
|
labels: ['BRL \u2192 USD', 'USD \u2192 BRL'],
|
||||||
datasets: [{
|
datasets: [{
|
||||||
data: [d.kpis.brlUsd.spread_revenue, d.kpis.usdBrl.spread_revenue],
|
data: [d.kpis.brlUsd.spread_revenue, d.kpis.usdBrl.spread_revenue],
|
||||||
backgroundColor: ['#1A73E8', '#1E8E3E'],
|
backgroundColor: ['#58A6FF', '#00FF88'],
|
||||||
borderWidth: 0, hoverOffset: 8
|
borderWidth: 0, hoverOffset: 8
|
||||||
}]
|
}]
|
||||||
},
|
},
|
||||||
@@ -760,8 +995,9 @@ async function loadBI() {
|
|||||||
cutout: '65%'
|
cutout: '65%'
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
} catch(e) { console.warn('Chart donut:', e.message); } }
|
||||||
|
|
||||||
// === Spread Trend + Volume Line Chart ===
|
// Spread Trend + Volume Line Chart
|
||||||
const allDays = new Set();
|
const allDays = new Set();
|
||||||
d.trend.brlUsd.forEach(t => allDays.add(t.dia));
|
d.trend.brlUsd.forEach(t => allDays.add(t.dia));
|
||||||
d.trend.usdBrl.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]);
|
const volumes = days.map(day => volByDay[day]);
|
||||||
|
|
||||||
|
if (_hasChart) { try {
|
||||||
chartSpreadTrend = new Chart(document.getElementById('chartSpreadTrend'), {
|
chartSpreadTrend = new Chart(document.getElementById('chartSpreadTrend'), {
|
||||||
type: 'bar',
|
type: 'bar',
|
||||||
data: {
|
data: {
|
||||||
@@ -789,12 +1026,12 @@ async function loadBI() {
|
|||||||
datasets: [
|
datasets: [
|
||||||
{
|
{
|
||||||
label: 'Volume USD', data: volumes, type: 'bar',
|
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
|
borderWidth: 1, borderRadius: 4, yAxisID: 'y', order: 2
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Spread Medio %', data: avgSpreads, type: 'line',
|
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,
|
borderWidth: 2, pointRadius: 2, pointHoverRadius: 5, fill: true,
|
||||||
tension: 0.3, yAxisID: 'y1', order: 1, spanGaps: true
|
tension: 0.3, yAxisID: 'y1', order: 1, spanGaps: true
|
||||||
}
|
}
|
||||||
@@ -813,23 +1050,25 @@ async function loadBI() {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
scales: {
|
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 },
|
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 } }
|
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 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);
|
const clientVols = d.topClients.map(c => c.vol_usd);
|
||||||
|
if (_hasChart) { try {
|
||||||
chartTopClients = new Chart(document.getElementById('chartTopClients'), {
|
chartTopClients = new Chart(document.getElementById('chartTopClients'), {
|
||||||
type: 'bar',
|
type: 'bar',
|
||||||
data: {
|
data: {
|
||||||
labels: clientNames,
|
labels: clientNames,
|
||||||
datasets: [{
|
datasets: [{
|
||||||
label: 'Volume USD', data: clientVols,
|
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
|
borderWidth: 1, borderRadius: 4
|
||||||
}]
|
}]
|
||||||
},
|
},
|
||||||
@@ -840,11 +1079,12 @@ async function loadBI() {
|
|||||||
tooltip: { callbacks: { label: ctx => fmtUSD(ctx.raw) + ' (' + d.topClients[ctx.dataIndex].qtd + ' ops)' } }
|
tooltip: { callbacks: { label: ctx => fmtUSD(ctx.raw) + ' (' + d.topClients[ctx.dataIndex].qtd + ' ops)' } }
|
||||||
},
|
},
|
||||||
scales: {
|
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 } } }
|
y: { grid: { display: false }, ticks: { font: { size: 11, weight: 600 } } }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
} catch(e) { console.warn('Chart clients:', e.message); } }
|
||||||
|
|
||||||
// Retention
|
// Retention
|
||||||
document.getElementById('retentionValue').textContent = d.retention.rate + '%';
|
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 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; });
|
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'), {
|
chartVolFlow = new Chart(document.getElementById('chartVolFlow'), {
|
||||||
type: 'bar',
|
type: 'bar',
|
||||||
data: {
|
data: {
|
||||||
labels,
|
labels,
|
||||||
datasets: [
|
datasets: [
|
||||||
{ label: 'BRL\u2192USD', data: volBrlUsd, backgroundColor: '#1A73E8', borderRadius: 2 },
|
{ label: 'BRL\u2192USD', data: volBrlUsd, backgroundColor: '#58A6FF', borderRadius: 2 },
|
||||||
{ label: 'USD\u2192BRL', data: volUsdBrl, backgroundColor: '#1E8E3E', borderRadius: 2 },
|
{ label: 'USD\u2192BRL', data: volUsdBrl, backgroundColor: '#00FF88', borderRadius: 2 },
|
||||||
{ label: 'USD\u2192USD', data: volUsdUsd, backgroundColor: '#7B1FA2', borderRadius: 2 }
|
{ label: 'USD\u2192USD', data: volUsdUsd, backgroundColor: '#BC8CFF', borderRadius: 2 }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
options: {
|
options: {
|
||||||
@@ -889,10 +1130,11 @@ async function loadBI() {
|
|||||||
},
|
},
|
||||||
scales: {
|
scales: {
|
||||||
x: { stacked: true, grid: { display: false }, ticks: { font: { size: 10 }, maxRotation: 45 } },
|
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
|
// Netting
|
||||||
document.getElementById('nettingSaida').textContent = fmtUSD(d.netting.saida_usd);
|
document.getElementById('nettingSaida').textContent = fmtUSD(d.netting.saida_usd);
|
||||||
@@ -921,9 +1163,14 @@ async function loadBI() {
|
|||||||
}).join('');
|
}).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) {
|
} catch (err) {
|
||||||
console.error('BI load error:', 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
|
// Color palette for products
|
||||||
const productColors = {
|
const productColors = {
|
||||||
'BR\u2192US: Checkout': '#1A73E8',
|
'BR\u2192US: Checkout': '#58A6FF',
|
||||||
'BR\u2192US: CambioTransfer': '#42A5F5',
|
'BR\u2192US: CambioTransfer': '#82BFFF',
|
||||||
'US\u2192BR: balance': '#1E8E3E',
|
'US\u2192BR: balance': '#00FF88',
|
||||||
'US\u2192BR: desconhecido': '#66BB6A',
|
'US\u2192BR: desconhecido': '#66FFB2',
|
||||||
'US\u2192BR: swift': '#00897B',
|
'US\u2192BR: swift': '#00BFA5',
|
||||||
'US\u2192BR: wire': '#26A69A',
|
'US\u2192BR: wire': '#4DD0A0',
|
||||||
'US\u2192BR: pix': '#4CAF50',
|
'US\u2192BR: pix': '#33FF99',
|
||||||
};
|
};
|
||||||
function getProductColor(produto, i) {
|
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() {
|
async function loadRevenue() {
|
||||||
document.getElementById('revenueStatus').textContent = 'Carregando...';
|
document.getElementById('revenueStatus').textContent = 'Carregando...';
|
||||||
try {
|
try {
|
||||||
const resp = await fetch('/admin/api/bi/revenue?start=' + currentStart + '&end=' + currentEnd + '&granularity=' + currentGran);
|
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();
|
const d = await resp.json();
|
||||||
|
|
||||||
if (d.error) { document.getElementById('revenueStatus').textContent = 'Erro: ' + d.error; return; }
|
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
|
// KPI cards
|
||||||
document.getElementById('revTotal').textContent = fmtUSD(d.summary.total_receita);
|
document.getElementById('revTotal').textContent = fmtUSD(d.summary.total_receita);
|
||||||
@@ -1010,6 +1259,7 @@ async function loadRevenue() {
|
|||||||
return p;
|
return p;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (typeof Chart !== 'undefined') { try {
|
||||||
chartRevTimeline = new Chart(document.getElementById('chartRevTimeline'), {
|
chartRevTimeline = new Chart(document.getElementById('chartRevTimeline'), {
|
||||||
type: 'bar',
|
type: 'bar',
|
||||||
data: { labels, datasets },
|
data: { labels, datasets },
|
||||||
@@ -1021,16 +1271,18 @@ async function loadRevenue() {
|
|||||||
},
|
},
|
||||||
scales: {
|
scales: {
|
||||||
x: { stacked: true, grid: { display: false }, ticks: { font: { size: 10 }, maxRotation: 45 } },
|
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
|
// Donut chart by product
|
||||||
const donutData = d.totals.map(t => t.receita);
|
const donutData = d.totals.map(t => t.receita);
|
||||||
const donutLabels = d.totals.map(t => t.produto);
|
const donutLabels = d.totals.map(t => t.produto);
|
||||||
const donutColors = d.totals.map((t, i) => getProductColor(t.direcao + ': ' + t.tipo, i));
|
const donutColors = d.totals.map((t, i) => getProductColor(t.direcao + ': ' + t.tipo, i));
|
||||||
|
|
||||||
|
if (typeof Chart !== 'undefined') { try {
|
||||||
chartRevDonut = new Chart(document.getElementById('chartRevDonut'), {
|
chartRevDonut = new Chart(document.getElementById('chartRevDonut'), {
|
||||||
type: 'doughnut',
|
type: 'doughnut',
|
||||||
data: {
|
data: {
|
||||||
@@ -1046,6 +1298,7 @@ async function loadRevenue() {
|
|||||||
cutout: '60%'
|
cutout: '60%'
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
} catch(e) { console.warn('Chart revDonut:', e.message); } }
|
||||||
|
|
||||||
// Detailed table
|
// Detailed table
|
||||||
const tbody = document.getElementById('revTableBody');
|
const tbody = document.getElementById('revTableBody');
|
||||||
@@ -1100,9 +1353,64 @@ document.querySelectorAll('.gran-btn').forEach(btn => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Init
|
// === Console Nav: Scroll Spy (highlight active section) ===
|
||||||
document.addEventListener('DOMContentLoaded', () => { loadBI(); loadRevenue(); fetchLiveRate(); });
|
(function() {
|
||||||
setInterval(fetchLiveRate, 3000);
|
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>
|
<\/script>
|
||||||
</body>
|
</body>
|
||||||
</html>`;
|
</html>`;
|
||||||
|
|||||||
@@ -2,12 +2,12 @@
|
|||||||
* Admin Dashboard Corporate - KPIs, Tendências e Detalhes
|
* 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
|
* 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) {
|
function buildAdminDashboardHTML(user) {
|
||||||
// Support both admin and corporate roles
|
// Support both admin and corporate roles
|
||||||
const role = user.role || 'corporate';
|
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)
|
// Calculate default dates (current month)
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@@ -441,6 +441,15 @@ function buildAdminDashboardHTML(user) {
|
|||||||
.chart-wrap { height: 200px; }
|
.chart-wrap { height: 200px; }
|
||||||
.details-table th, .details-table td { padding: 6px 4px; font-size: 10px; }
|
.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>
|
return `<!DOCTYPE html>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
* Admin Home Dashboard - Fast daily overview
|
* Admin Home Dashboard - Fast daily overview
|
||||||
* 3 flows: BRL→USD, USD→BRL, USD→USD (balance)
|
* 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) {
|
function buildAdminHomeHTML(stats, user) {
|
||||||
const now = new Date().toLocaleString('pt-BR');
|
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 totalOntem = stats.brlUsd.ontem.qtd + stats.usdBrl.ontem.qtd + stats.usdUsd.ontem.qtd;
|
||||||
const totalVar = calcVar(totalHoje, totalOntem);
|
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 = `
|
const pageCSS = `
|
||||||
/* Trading Terminal - Live Rates */
|
/* Trading Terminal - Live Rates */
|
||||||
|
|||||||
@@ -175,6 +175,24 @@ function buildAdminHTML(agentes, admin) {
|
|||||||
tbody td { padding: 8px 6px; }
|
tbody td { padding: 8px 6px; }
|
||||||
.status-badge { font-size: 10px; padding: 3px 8px; }
|
.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>
|
</style>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
* Gera HTML do dashboard — parametrizado por agente
|
* Gera HTML do dashboard — parametrizado por agente
|
||||||
* Updated: 2026-02-09 - 4 decimal places for spread
|
* 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) {
|
function buildHTML(data, agente, isAgentDashboard = true, diasPeriodo = null, asyncLoad = false, isEmulating = false) {
|
||||||
const now = new Date().toLocaleString('pt-BR');
|
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
|
// Determine the back URL based on emulator's role
|
||||||
const backUrl = emulatorRole === 'corporate' ? '/corporate' : '/admin';
|
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 = `
|
const dashboardCSS = `
|
||||||
/* Emulation Banner */
|
/* Emulation Banner */
|
||||||
@@ -386,6 +386,22 @@ function buildHTML(data, agente, isAgentDashboard = true, diasPeriodo = null, as
|
|||||||
.portfolio-kpi-grid { grid-template-columns: 1fr; }
|
.portfolio-kpi-grid { grid-template-columns: 1fr; }
|
||||||
.netting-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>
|
return `<!DOCTYPE html>
|
||||||
|
|||||||
@@ -33,6 +33,49 @@ const cssVariables = `
|
|||||||
--purple: #7B1FA2;
|
--purple: #7B1FA2;
|
||||||
--purple-bg: #F3E5F5;
|
--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
|
// CSS do Header unificado
|
||||||
@@ -186,6 +229,22 @@ const headerCSS = `
|
|||||||
background: rgba(255,255,255,0.25);
|
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 */
|
/* Footer */
|
||||||
.app-footer {
|
.app-footer {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@@ -357,6 +416,9 @@ function buildHeader(options = {}) {
|
|||||||
<span>${userName}</span>
|
<span>${userName}</span>
|
||||||
<span class="user-role">${roleLabel}</span>
|
<span class="user-role">${roleLabel}</span>
|
||||||
</div>
|
</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>
|
<a href="/logout" class="btn-logout">Sair</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -382,11 +444,35 @@ function buildFooter() {
|
|||||||
* @param {string} additionalCSS - CSS adicional
|
* @param {string} additionalCSS - CSS adicional
|
||||||
* @param {string} scripts - Scripts externos (opcional)
|
* @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 = '') {
|
function buildHead(title, additionalCSS = '', scripts = '') {
|
||||||
return `
|
return `
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>${title} | BI - CCC</title>
|
<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">
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
||||||
${scripts}
|
${scripts}
|
||||||
<style>
|
<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 = {
|
module.exports = {
|
||||||
buildHeader,
|
buildHeader,
|
||||||
buildFooter,
|
buildFooter,
|
||||||
buildHead,
|
buildHead,
|
||||||
cssVariables,
|
cssVariables,
|
||||||
headerCSS
|
headerCSS,
|
||||||
|
getChartJsScript
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user