feat: admin BI executive dashboard - visão estratégica completa
Novo dashboard admin-only (/admin/bi) com BI profissional: - 5 hero KPIs: receita spread, volume USD, transações, clientes ativos, ticket médio - Revenue por corredor (donut) + spread médio + volume diário (dual-axis chart) - Top 10 clientes por volume + taxa retenção + clientes em risco (30+ dias) - Volume stacked por corredor + netting entrada/saída + ranking agentes - Filtros: 7d, 30d, 90d, este mês, ou período custom - Comparativo automático vs período anterior em todos os KPIs - Responsivo mobile (5→2→1 cols) + trading terminal USD/BRL Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
33
server.js
33
server.js
@@ -11,11 +11,12 @@ const express = require('express');
|
||||
const session = require('express-session');
|
||||
const path = require('path');
|
||||
const { authenticate, requireAuth, requireRole, createAgente, createUser } = require('./src/auth');
|
||||
const { fetchTransacoes, fetchAllTransacoes, serialize, fetchDailyStats, fetchKPIs, fetchTrend30Days, fetchTopAgentes, fetchTrendByPeriod, fetchKPIsByPeriod } = require('./src/queries');
|
||||
const { fetchTransacoes, fetchAllTransacoes, serialize, fetchDailyStats, fetchKPIs, fetchTrend30Days, fetchTopAgentes, fetchTrendByPeriod, fetchKPIsByPeriod, fetchBIData } = require('./src/queries');
|
||||
const { buildHTML } = require('./src/dashboard');
|
||||
const { buildAdminHTML } = require('./src/admin-panel');
|
||||
const { buildAdminHomeHTML } = require('./src/admin-home');
|
||||
const { buildAdminDashboardHTML } = require('./src/admin-dashboard');
|
||||
const { buildAdminBIHTML } = require('./src/admin-bi');
|
||||
const bcrypt = require('bcrypt');
|
||||
const db = require('./src/db-local');
|
||||
const cache = require('./src/cache');
|
||||
@@ -324,6 +325,36 @@ app.get('/admin/dashboard', requireRole('admin'), (req, res) => {
|
||||
res.redirect('/corporate/dashboard');
|
||||
});
|
||||
|
||||
// --- Admin BI Dashboard (admin only) ---
|
||||
app.get('/admin/bi', requireRole('admin'), (req, res) => {
|
||||
try {
|
||||
const html = buildAdminBIHTML(req.session.user);
|
||||
res.send(html);
|
||||
} catch (err) {
|
||||
console.error('Admin BI error:', err);
|
||||
res.status(500).send('Erro ao carregar BI: ' + err.message);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/admin/api/bi', requireRole('admin'), async (req, res) => {
|
||||
try {
|
||||
const start = req.query.start;
|
||||
const end = req.query.end;
|
||||
if (!start || !end) return res.status(400).json({ error: 'start and end required' });
|
||||
|
||||
const getAgenteName = (agenteId) => {
|
||||
const row = db.prepare('SELECT nome FROM agentes WHERE agente_id = ?').get(agenteId);
|
||||
return row ? row.nome : null;
|
||||
};
|
||||
|
||||
const data = await fetchBIData(start, end, getAgenteName);
|
||||
res.json(data);
|
||||
} catch (err) {
|
||||
console.error('Admin BI API error:', err);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Create user (admin only)
|
||||
app.post('/admin/agentes', requireRole('admin'), async (req, res) => {
|
||||
const { nome, email, agente_id, senha, role } = req.body;
|
||||
|
||||
777
src/admin-bi.js
Normal file
777
src/admin-bi.js
Normal file
@@ -0,0 +1,777 @@
|
||||
/**
|
||||
* Admin BI Dashboard - Business Intelligence Executive View
|
||||
* Admin-only: comprehensive KPIs, revenue analysis, client intelligence, operational metrics
|
||||
*/
|
||||
const { buildHeader, buildFooter, buildHead } = require('./ui-template');
|
||||
|
||||
function buildAdminBIHTML(user) {
|
||||
const role = user.role || 'admin';
|
||||
const pageScripts = '<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"><\/script>';
|
||||
|
||||
const now = new Date();
|
||||
const firstDayOfMonth = new Date(now.getFullYear(), now.getMonth(), 1).toISOString().slice(0, 10);
|
||||
const today = now.toISOString().slice(0, 10);
|
||||
const thirtyDaysAgo = new Date(now.getTime() - 30 * 86400000).toISOString().slice(0, 10);
|
||||
|
||||
const pageCSS = `
|
||||
/* Trading Terminal - Live Rates */
|
||||
.trading-terminal {
|
||||
background: linear-gradient(135deg, #0F1923 0%, #1A2332 50%, #0D1B2A 100%);
|
||||
border-top: 1px solid rgba(0,255,136,0.15);
|
||||
border-bottom: 1px solid rgba(0,255,136,0.15);
|
||||
padding: 16px 40px;
|
||||
position: relative; overflow: hidden;
|
||||
}
|
||||
.trading-terminal::before {
|
||||
content: ''; position: absolute; top: 0; left: 0; right: 0; height: 1px;
|
||||
background: linear-gradient(90deg, transparent, rgba(0,255,136,0.3), transparent);
|
||||
}
|
||||
.trading-terminal::after {
|
||||
content: ''; position: absolute; bottom: 0; left: 0; right: 0; height: 1px;
|
||||
background: linear-gradient(90deg, transparent, rgba(0,255,136,0.3), transparent);
|
||||
}
|
||||
.live-rate-bar {
|
||||
display: flex; align-items: center; justify-content: center; gap: 20px;
|
||||
max-width: 1600px; margin: 0 auto;
|
||||
}
|
||||
.terminal-title {
|
||||
font-size: 10px; font-weight: 700; color: rgba(0,255,136,0.6);
|
||||
text-transform: uppercase; letter-spacing: 2px; margin-right: 8px;
|
||||
font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace;
|
||||
}
|
||||
.live-rate-dot {
|
||||
width: 7px; height: 7px; border-radius: 50%; background: #00FF88;
|
||||
display: inline-block; animation: blink 1.5s infinite;
|
||||
box-shadow: 0 0 6px rgba(0,255,136,0.6);
|
||||
}
|
||||
@keyframes blink { 0%, 100% { opacity: 1; } 50% { opacity: 0.2; } }
|
||||
.rate-pair-group {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
background: rgba(255,255,255,0.03);
|
||||
border: 1px solid rgba(255,255,255,0.06);
|
||||
border-radius: 8px; padding: 8px 14px;
|
||||
}
|
||||
.rate-pair-label {
|
||||
font-size: 11px; font-weight: 800; color: rgba(255,255,255,0.35);
|
||||
text-transform: uppercase; letter-spacing: 1px; writing-mode: vertical-rl;
|
||||
text-orientation: mixed; font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace;
|
||||
}
|
||||
.live-rate-btn {
|
||||
display: flex; flex-direction: column; align-items: center; gap: 1px;
|
||||
padding: 8px 18px; border-radius: 6px; border: 1px solid transparent;
|
||||
font-family: inherit; font-size: 14px; font-weight: 700; cursor: default;
|
||||
transition: all 0.25s; min-width: 150px; justify-content: center; position: relative;
|
||||
}
|
||||
.rate-flags { font-size: 9px; opacity: 0.5; letter-spacing: 1px; line-height: 1; }
|
||||
.live-rate-btn.compra { background: rgba(0,255,136,0.08); color: #00FF88; border-color: rgba(0,255,136,0.2); }
|
||||
.live-rate-btn.compra:hover { background: rgba(0,255,136,0.14); }
|
||||
.live-rate-btn.venda { background: rgba(255,68,68,0.08); color: #FF4444; border-color: rgba(255,68,68,0.2); }
|
||||
.live-rate-btn.venda:hover { background: rgba(255,68,68,0.14); }
|
||||
.live-rate-btn .rate-value {
|
||||
font-size: 22px; font-weight: 800; letter-spacing: -0.5px;
|
||||
font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace;
|
||||
font-variant-numeric: tabular-nums; text-shadow: 0 0 12px currentColor;
|
||||
}
|
||||
.live-rate-btn .rate-type {
|
||||
font-size: 9px; text-transform: uppercase; letter-spacing: 1.5px;
|
||||
opacity: 0.5; font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace;
|
||||
}
|
||||
.live-rate-btn.pulse .rate-value { animation: ratePulse 0.4s ease; }
|
||||
@keyframes ratePulse {
|
||||
0% { transform: scale(1); text-shadow: 0 0 12px currentColor; }
|
||||
50% { transform: scale(1.06); text-shadow: 0 0 24px currentColor, 0 0 48px currentColor; }
|
||||
100% { transform: scale(1); text-shadow: 0 0 12px currentColor; }
|
||||
}
|
||||
.live-rate-time {
|
||||
font-size: 10px; color: rgba(255,255,255,0.25);
|
||||
font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace; letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
/* Filter Bar */
|
||||
.filter-bar {
|
||||
background: var(--card); border-radius: 16px; padding: 20px 24px;
|
||||
border: 1px solid var(--border); box-shadow: 0 2px 8px rgba(0,0,0,0.04);
|
||||
margin-bottom: 24px; display: flex; align-items: center; gap: 16px; flex-wrap: wrap;
|
||||
}
|
||||
.filter-bar-label { font-size: 13px; font-weight: 600; color: var(--text-secondary); }
|
||||
.filter-presets { display: flex; gap: 8px; }
|
||||
.preset-btn {
|
||||
padding: 8px 16px; border: 1px solid var(--border); border-radius: 8px;
|
||||
background: var(--bg); font-size: 13px; font-weight: 600; cursor: pointer;
|
||||
color: var(--text-secondary); transition: all 0.15s; font-family: inherit;
|
||||
}
|
||||
.preset-btn:hover { border-color: var(--admin-accent); color: var(--admin-accent); }
|
||||
.preset-btn.active {
|
||||
background: var(--admin-accent); color: white; border-color: var(--admin-accent);
|
||||
}
|
||||
.filter-divider { width: 1px; height: 32px; background: var(--border); }
|
||||
.date-inputs { display: flex; align-items: center; gap: 8px; }
|
||||
.date-inputs label { font-size: 12px; font-weight: 600; color: var(--text-muted); }
|
||||
.date-inputs input[type="date"] {
|
||||
padding: 8px 12px; border: 1px solid var(--border); border-radius: 8px;
|
||||
font-size: 13px; font-family: inherit; background: var(--bg); color: var(--text);
|
||||
}
|
||||
.period-info {
|
||||
margin-left: auto; font-size: 12px; color: var(--text-muted);
|
||||
font-weight: 500; background: var(--bg); padding: 6px 12px; border-radius: 6px;
|
||||
}
|
||||
|
||||
/* Hero KPI Cards */
|
||||
.hero-grid {
|
||||
display: grid; grid-template-columns: repeat(5, 1fr); gap: 16px; margin-bottom: 28px;
|
||||
}
|
||||
.hero-card {
|
||||
background: var(--card); border-radius: 16px; padding: 20px 24px;
|
||||
border: 1px solid var(--border); box-shadow: 0 2px 8px rgba(0,0,0,0.04);
|
||||
position: relative; overflow: hidden;
|
||||
}
|
||||
.hero-card::before {
|
||||
content: ''; position: absolute; top: 0; left: 0; right: 0; height: 3px;
|
||||
}
|
||||
.hero-card.spread::before { background: linear-gradient(90deg, var(--green), #4CAF50); }
|
||||
.hero-card.volume::before { background: linear-gradient(90deg, var(--blue), #42A5F5); }
|
||||
.hero-card.transactions::before { background: linear-gradient(90deg, var(--purple), #AB47BC); }
|
||||
.hero-card.clients::before { background: linear-gradient(90deg, var(--orange), #FFA726); }
|
||||
.hero-card.ticket::before { background: linear-gradient(90deg, #00897B, #26A69A); }
|
||||
.hero-label {
|
||||
font-size: 11px; font-weight: 700; color: var(--text-muted);
|
||||
text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 8px;
|
||||
}
|
||||
.hero-value {
|
||||
font-size: 28px; font-weight: 800; color: var(--text); margin-bottom: 4px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.hero-badge {
|
||||
display: inline-block; font-size: 11px; font-weight: 700; padding: 3px 10px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
.hero-badge.up { background: var(--green-bg); color: var(--green); }
|
||||
.hero-badge.down { background: var(--red-bg); color: var(--red); }
|
||||
.hero-badge.neutral { background: var(--blue-bg); color: var(--blue); }
|
||||
.hero-sub { font-size: 12px; color: var(--text-muted); margin-top: 6px; }
|
||||
|
||||
/* Section Headers */
|
||||
.section-title {
|
||||
font-size: 14px; font-weight: 700; color: var(--text-secondary);
|
||||
text-transform: uppercase; letter-spacing: 1px; margin-bottom: 16px;
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
}
|
||||
.section-title .icon {
|
||||
width: 28px; height: 28px; border-radius: 8px; display: flex;
|
||||
align-items: center; justify-content: center; font-size: 14px;
|
||||
}
|
||||
|
||||
/* Charts Grid */
|
||||
.charts-row {
|
||||
display: grid; grid-template-columns: 1fr 2fr; gap: 20px; margin-bottom: 28px;
|
||||
}
|
||||
.charts-row.equal { grid-template-columns: 1fr 1fr; }
|
||||
.charts-row.triple { grid-template-columns: 1fr 1fr 1fr; }
|
||||
.chart-card {
|
||||
background: var(--card); border-radius: 16px; padding: 24px;
|
||||
border: 1px solid var(--border); box-shadow: 0 2px 8px rgba(0,0,0,0.04);
|
||||
}
|
||||
.chart-card h3 {
|
||||
font-size: 14px; font-weight: 700; color: var(--text); margin-bottom: 16px;
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
}
|
||||
.chart-card h3 .badge {
|
||||
font-size: 10px; padding: 3px 8px; border-radius: 10px;
|
||||
font-weight: 700; background: var(--bg); color: var(--text-muted);
|
||||
}
|
||||
.chart-wrap { position: relative; height: 280px; }
|
||||
.chart-wrap.short { height: 220px; }
|
||||
|
||||
/* Data Tables */
|
||||
.data-table {
|
||||
width: 100%; border-collapse: collapse; font-size: 13px;
|
||||
}
|
||||
.data-table th {
|
||||
text-align: left; padding: 10px 12px; font-weight: 700; font-size: 11px;
|
||||
text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-muted);
|
||||
border-bottom: 2px solid var(--border);
|
||||
}
|
||||
.data-table td {
|
||||
padding: 10px 12px; border-bottom: 1px solid var(--border);
|
||||
color: var(--text); font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.data-table tr:last-child td { border-bottom: none; }
|
||||
.data-table tr:hover td { background: var(--bg); }
|
||||
.data-table .rank {
|
||||
width: 32px; height: 32px; border-radius: 50%; display: inline-flex;
|
||||
align-items: center; justify-content: center; font-weight: 800; font-size: 12px;
|
||||
}
|
||||
.rank-1 { background: #FFF8E1; color: #F9A825; }
|
||||
.rank-2 { background: #F5F5F5; color: #757575; }
|
||||
.rank-3 { background: #FBE9E7; color: #D84315; }
|
||||
.rank-default { background: var(--bg); color: var(--text-muted); }
|
||||
|
||||
/* Gauge / Metric Cards */
|
||||
.metric-card {
|
||||
background: var(--card); border-radius: 16px; padding: 24px;
|
||||
border: 1px solid var(--border); box-shadow: 0 2px 8px rgba(0,0,0,0.04);
|
||||
text-align: center;
|
||||
}
|
||||
.metric-card h3 {
|
||||
font-size: 14px; font-weight: 700; color: var(--text); margin-bottom: 16px;
|
||||
}
|
||||
.gauge-value {
|
||||
font-size: 48px; font-weight: 800; margin: 12px 0;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.gauge-value.green { color: var(--green); }
|
||||
.gauge-value.red { color: var(--red); }
|
||||
.gauge-value.blue { color: var(--blue); }
|
||||
.gauge-bar {
|
||||
height: 8px; border-radius: 4px; background: var(--bg); overflow: hidden; margin: 12px 0;
|
||||
}
|
||||
.gauge-bar-fill {
|
||||
height: 100%; border-radius: 4px; transition: width 0.6s ease;
|
||||
}
|
||||
.gauge-bar-fill.green { background: linear-gradient(90deg, var(--green), #4CAF50); }
|
||||
.gauge-bar-fill.red { background: linear-gradient(90deg, var(--red), #EF5350); }
|
||||
.gauge-bar-fill.blue { background: linear-gradient(90deg, var(--blue), #42A5F5); }
|
||||
.gauge-label { font-size: 12px; color: var(--text-muted); }
|
||||
|
||||
/* Netting Card */
|
||||
.netting-row {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
padding: 10px 0; border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.netting-row:last-child { border-bottom: none; }
|
||||
.netting-label { font-size: 13px; color: var(--text-secondary); font-weight: 600; }
|
||||
.netting-value { font-size: 15px; font-weight: 700; font-variant-numeric: tabular-nums; }
|
||||
.netting-value.green { color: var(--green); }
|
||||
.netting-value.red { color: var(--red); }
|
||||
|
||||
/* Loading */
|
||||
.loading-overlay {
|
||||
position: absolute; inset: 0; background: rgba(255,255,255,0.8);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
border-radius: 16px; z-index: 10; font-size: 13px; color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.trading-terminal { padding: 12px 16px; }
|
||||
.live-rate-bar { flex-wrap: wrap; gap: 10px; justify-content: center; }
|
||||
.terminal-title { display: none; }
|
||||
.rate-pair-group { padding: 8px 12px; width: 100%; justify-content: center; }
|
||||
.live-rate-btn { min-width: 120px; padding: 8px 14px; flex: 1; }
|
||||
.live-rate-btn .rate-value { font-size: 18px; }
|
||||
.live-rate-time { width: 100%; text-align: center; }
|
||||
.hero-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
.hero-card:last-child { grid-column: span 2; }
|
||||
.hero-value { font-size: 22px; }
|
||||
.charts-row, .charts-row.equal, .charts-row.triple { grid-template-columns: 1fr; }
|
||||
.filter-bar { padding: 14px 16px; gap: 10px; }
|
||||
.filter-presets { flex-wrap: wrap; }
|
||||
.period-info { margin-left: 0; width: 100%; text-align: center; }
|
||||
.filter-divider { display: none; }
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
.trading-terminal { padding: 10px 12px; }
|
||||
.live-rate-bar { gap: 8px; flex-direction: column; align-items: center; }
|
||||
.rate-pair-group { padding: 10px; gap: 6px; }
|
||||
.rate-pair-label { writing-mode: horizontal-tb; font-size: 10px; }
|
||||
.live-rate-btn { min-width: 110px; padding: 8px 12px; }
|
||||
.live-rate-btn .rate-value { font-size: 16px; }
|
||||
.live-rate-btn .rate-type { font-size: 8px; }
|
||||
.rate-flags { font-size: 9px; }
|
||||
.live-rate-time { font-size: 9px; }
|
||||
.hero-grid { grid-template-columns: 1fr; }
|
||||
.hero-card:last-child { grid-column: span 1; }
|
||||
.hero-value { font-size: 20px; }
|
||||
.chart-card { padding: 16px; }
|
||||
.chart-wrap { height: 220px; }
|
||||
.chart-wrap.short { height: 180px; }
|
||||
.data-table { font-size: 11px; }
|
||||
.data-table th, .data-table td { padding: 8px 6px; }
|
||||
.gauge-value { font-size: 36px; }
|
||||
}
|
||||
`;
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="pt-BR">
|
||||
<head>
|
||||
${buildHead('BI Executive', pageCSS, pageScripts)}
|
||||
</head>
|
||||
<body>
|
||||
|
||||
${buildHeader({ role: role, userName: user.nome, activePage: 'bi' })}
|
||||
|
||||
<div class="trading-terminal">
|
||||
<div class="live-rate-bar">
|
||||
<span class="live-rate-dot"></span>
|
||||
<span class="terminal-title">Live Rates</span>
|
||||
<div class="rate-pair-group">
|
||||
<span class="rate-pair-label">USD</span>
|
||||
<button class="live-rate-btn compra">
|
||||
<span class="rate-flags">\\uD83C\\uDDFA\\uD83C\\uDDF8 \\u2192 \\uD83C\\uDDE7\\uD83C\\uDDF7</span>
|
||||
<span class="rate-type">Compra</span>
|
||||
<span class="rate-value" id="rateUsdCompra">--</span>
|
||||
</button>
|
||||
<button class="live-rate-btn venda">
|
||||
<span class="rate-flags">\\uD83C\\uDDE7\\uD83C\\uDDF7 \\u2192 \\uD83C\\uDDFA\\uD83C\\uDDF8</span>
|
||||
<span class="rate-type">Venda</span>
|
||||
<span class="rate-value" id="rateUsdVenda">--</span>
|
||||
</button>
|
||||
</div>
|
||||
<span class="live-rate-time" id="rateTime">--</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="app-container">
|
||||
|
||||
<!-- Filter Bar -->
|
||||
<div class="filter-bar">
|
||||
<span class="filter-bar-label">Periodo:</span>
|
||||
<div class="filter-presets">
|
||||
<button class="preset-btn" data-preset="7d">7 Dias</button>
|
||||
<button class="preset-btn active" data-preset="30d">30 Dias</button>
|
||||
<button class="preset-btn" data-preset="90d">90 Dias</button>
|
||||
<button class="preset-btn" data-preset="thisMonth">Este Mes</button>
|
||||
</div>
|
||||
<div class="filter-divider"></div>
|
||||
<div class="date-inputs">
|
||||
<label>De:</label>
|
||||
<input type="date" id="dateStart" value="${thirtyDaysAgo}">
|
||||
<label>Ate:</label>
|
||||
<input type="date" id="dateEnd" value="${today}">
|
||||
</div>
|
||||
<span class="period-info" id="periodInfo">Carregando...</span>
|
||||
</div>
|
||||
|
||||
<!-- Hero KPI Cards -->
|
||||
<div class="hero-grid" id="heroGrid">
|
||||
<div class="hero-card spread">
|
||||
<div class="hero-label">Receita de Spread</div>
|
||||
<div class="hero-value" id="kpiSpreadRevenue">--</div>
|
||||
<span class="hero-badge neutral" id="kpiSpreadBadge">--</span>
|
||||
<div class="hero-sub" id="kpiSpreadSub">vs periodo anterior</div>
|
||||
</div>
|
||||
<div class="hero-card volume">
|
||||
<div class="hero-label">Volume Processado</div>
|
||||
<div class="hero-value" id="kpiVolume">--</div>
|
||||
<span class="hero-badge neutral" id="kpiVolumeBadge">--</span>
|
||||
<div class="hero-sub" id="kpiVolumeSub">USD total movimentado</div>
|
||||
</div>
|
||||
<div class="hero-card transactions">
|
||||
<div class="hero-label">Transacoes</div>
|
||||
<div class="hero-value" id="kpiTransactions">--</div>
|
||||
<span class="hero-badge neutral" id="kpiTransBadge">--</span>
|
||||
<div class="hero-sub" id="kpiTransSub">operacoes no periodo</div>
|
||||
</div>
|
||||
<div class="hero-card clients">
|
||||
<div class="hero-label">Clientes Ativos</div>
|
||||
<div class="hero-value" id="kpiClients">--</div>
|
||||
<div class="hero-sub">clientes unicos no periodo</div>
|
||||
</div>
|
||||
<div class="hero-card ticket">
|
||||
<div class="hero-label">Ticket Medio</div>
|
||||
<div class="hero-value" id="kpiTicket">--</div>
|
||||
<div class="hero-sub">USD por operacao</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Section: Revenue & Spread -->
|
||||
<div class="section-title">
|
||||
<span class="icon" style="background:var(--green-bg);color:var(--green);">$</span>
|
||||
Revenue & Spread
|
||||
</div>
|
||||
<div class="charts-row">
|
||||
<div class="chart-card">
|
||||
<h3>Revenue por Corredor</h3>
|
||||
<div class="chart-wrap short"><canvas id="chartDonut"></canvas></div>
|
||||
</div>
|
||||
<div class="chart-card">
|
||||
<h3>Spread Medio + Volume Diario <span class="badge" id="spreadBadge">--</span></h3>
|
||||
<div class="chart-wrap"><canvas id="chartSpreadTrend"></canvas></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Section: Client Intelligence -->
|
||||
<div class="section-title">
|
||||
<span class="icon" style="background:var(--orange-bg);color:var(--orange);">👥</span>
|
||||
Inteligencia de Clientes
|
||||
</div>
|
||||
<div class="charts-row equal">
|
||||
<div class="chart-card">
|
||||
<h3>Top 10 Clientes por Volume</h3>
|
||||
<div class="chart-wrap"><canvas id="chartTopClients"></canvas></div>
|
||||
</div>
|
||||
<div class="chart-card" style="display:flex;flex-direction:column;gap:20px;">
|
||||
<div class="metric-card" style="box-shadow:none;border:1px solid var(--border);padding:16px;">
|
||||
<h3 style="margin-bottom:8px;">Taxa de Retencao</h3>
|
||||
<div class="gauge-value green" id="retentionValue">--%</div>
|
||||
<div class="gauge-bar"><div class="gauge-bar-fill green" id="retentionBar" style="width:0%"></div></div>
|
||||
<div class="gauge-label" id="retentionLabel">-- de -- clientes retidos</div>
|
||||
</div>
|
||||
<div style="flex:1;overflow:auto;">
|
||||
<h3 style="font-size:14px;font-weight:700;margin-bottom:12px;color:var(--text);">
|
||||
Clientes em Risco <span style="font-size:11px;color:var(--red);font-weight:600;">(30+ dias sem operar)</span>
|
||||
</h3>
|
||||
<table class="data-table" id="riskTable">
|
||||
<thead><tr><th>Cliente</th><th>Ultima Op</th><th>Volume USD</th></tr></thead>
|
||||
<tbody id="riskTableBody"><tr><td colspan="3" style="text-align:center;color:var(--text-muted);">Carregando...</td></tr></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Section: Operational -->
|
||||
<div class="section-title">
|
||||
<span class="icon" style="background:var(--blue-bg);color:var(--blue);">⚙</span>
|
||||
Operacional
|
||||
</div>
|
||||
<div class="charts-row triple">
|
||||
<div class="chart-card">
|
||||
<h3>Volume por Corredor</h3>
|
||||
<div class="chart-wrap"><canvas id="chartVolFlow"></canvas></div>
|
||||
</div>
|
||||
<div class="chart-card" style="display:flex;flex-direction:column;gap:16px;">
|
||||
<h3 style="font-size:14px;font-weight:700;color:var(--text);margin:0;">Netting & Balanco</h3>
|
||||
<div id="nettingContent">
|
||||
<div class="netting-row">
|
||||
<span class="netting-label">Saida (BRL→USD)</span>
|
||||
<span class="netting-value red" id="nettingSaida">--</span>
|
||||
</div>
|
||||
<div class="netting-row">
|
||||
<span class="netting-label">Entrada (USD→BRL)</span>
|
||||
<span class="netting-value green" id="nettingEntrada">--</span>
|
||||
</div>
|
||||
<div class="netting-row" style="border-top:2px solid var(--border);padding-top:12px;">
|
||||
<span class="netting-label" style="font-weight:700;">Posicao Liquida</span>
|
||||
<span class="netting-value" id="nettingPosicao">--</span>
|
||||
</div>
|
||||
<div style="margin-top:16px;">
|
||||
<div style="font-size:12px;color:var(--text-muted);margin-bottom:4px;">Eficiencia Netting</div>
|
||||
<div class="gauge-value blue" id="nettingEficiencia" style="font-size:36px;margin:4px 0;">--%</div>
|
||||
<div class="gauge-bar"><div class="gauge-bar-fill blue" id="nettingBar" style="width:0%"></div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chart-card" style="overflow:auto;">
|
||||
<h3>Ranking Agentes</h3>
|
||||
<table class="data-table">
|
||||
<thead><tr><th>#</th><th>Agente</th><th>Volume</th><th>Ops</th><th>Spread R$</th><th>Clientes</th></tr></thead>
|
||||
<tbody id="agentTableBody"><tr><td colspan="6" style="text-align:center;color:var(--text-muted);">Carregando...</td></tr></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
${buildFooter()}
|
||||
|
||||
<script>
|
||||
// === Formatters ===
|
||||
const fmtBRL = v => v.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
|
||||
const fmtUSD = v => v.toLocaleString('pt-BR', { style: 'currency', currency: 'USD' });
|
||||
const fmtNum = v => v.toLocaleString('pt-BR');
|
||||
const fmtPct = (curr, prev) => {
|
||||
if (!prev || prev === 0) return { text: 'N/A', cls: 'neutral' };
|
||||
const pct = ((curr - prev) / prev * 100).toFixed(1);
|
||||
return { text: (pct > 0 ? '+' : '') + pct + '%', cls: pct >= 0 ? 'up' : 'down' };
|
||||
};
|
||||
|
||||
// === Date & Filter Logic ===
|
||||
let currentStart = '${thirtyDaysAgo}';
|
||||
let currentEnd = '${today}';
|
||||
|
||||
function setPreset(preset) {
|
||||
const now = new Date();
|
||||
const today = now.toISOString().slice(0, 10);
|
||||
let start;
|
||||
switch(preset) {
|
||||
case '7d':
|
||||
start = new Date(now.getTime() - 7 * 86400000).toISOString().slice(0, 10); break;
|
||||
case '30d':
|
||||
start = new Date(now.getTime() - 30 * 86400000).toISOString().slice(0, 10); break;
|
||||
case '90d':
|
||||
start = new Date(now.getTime() - 90 * 86400000).toISOString().slice(0, 10); break;
|
||||
case 'thisMonth':
|
||||
start = new Date(now.getFullYear(), now.getMonth(), 1).toISOString().slice(0, 10); break;
|
||||
default:
|
||||
start = new Date(now.getTime() - 30 * 86400000).toISOString().slice(0, 10);
|
||||
}
|
||||
document.getElementById('dateStart').value = start;
|
||||
document.getElementById('dateEnd').value = today;
|
||||
document.querySelectorAll('.preset-btn').forEach(b => b.classList.remove('active'));
|
||||
document.querySelector('[data-preset="'+preset+'"]').classList.add('active');
|
||||
currentStart = start; currentEnd = today;
|
||||
loadBI();
|
||||
}
|
||||
|
||||
document.querySelectorAll('.preset-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => setPreset(btn.dataset.preset));
|
||||
});
|
||||
document.getElementById('dateStart').addEventListener('change', () => {
|
||||
currentStart = document.getElementById('dateStart').value;
|
||||
document.querySelectorAll('.preset-btn').forEach(b => b.classList.remove('active'));
|
||||
loadBI();
|
||||
});
|
||||
document.getElementById('dateEnd').addEventListener('change', () => {
|
||||
currentEnd = document.getElementById('dateEnd').value;
|
||||
document.querySelectorAll('.preset-btn').forEach(b => b.classList.remove('active'));
|
||||
loadBI();
|
||||
});
|
||||
|
||||
// === Chart instances ===
|
||||
let chartDonut, chartSpreadTrend, chartTopClients, chartVolFlow;
|
||||
|
||||
function destroyCharts() {
|
||||
[chartDonut, chartSpreadTrend, chartTopClients, chartVolFlow].forEach(c => { if (c) c.destroy(); });
|
||||
}
|
||||
|
||||
// === Main Data Loader ===
|
||||
async function loadBI() {
|
||||
const periodDays = Math.round((new Date(currentEnd) - new Date(currentStart)) / 86400000) + 1;
|
||||
document.getElementById('periodInfo').textContent = periodDays + ' dias | ' + currentStart.split('-').reverse().join('/') + ' a ' + currentEnd.split('-').reverse().join('/');
|
||||
|
||||
try {
|
||||
const resp = await fetch('/admin/api/bi?start=' + currentStart + '&end=' + currentEnd);
|
||||
const d = await resp.json();
|
||||
|
||||
// Hero KPIs
|
||||
document.getElementById('kpiSpreadRevenue').textContent = fmtBRL(d.kpis.total.spread_revenue);
|
||||
const sBadge = fmtPct(d.kpis.total.spread_revenue, d.comparison.prev_spread);
|
||||
document.getElementById('kpiSpreadBadge').textContent = sBadge.text;
|
||||
document.getElementById('kpiSpreadBadge').className = 'hero-badge ' + sBadge.cls;
|
||||
|
||||
document.getElementById('kpiVolume').textContent = fmtUSD(d.kpis.total.vol_usd);
|
||||
const vBadge = fmtPct(d.kpis.total.vol_usd, d.comparison.prev_vol_usd);
|
||||
document.getElementById('kpiVolumeBadge').textContent = vBadge.text;
|
||||
document.getElementById('kpiVolumeBadge').className = 'hero-badge ' + vBadge.cls;
|
||||
|
||||
document.getElementById('kpiTransactions').textContent = fmtNum(d.kpis.total.qtd);
|
||||
const tBadge = fmtPct(d.kpis.total.qtd, d.comparison.prev_qtd);
|
||||
document.getElementById('kpiTransBadge').textContent = tBadge.text;
|
||||
document.getElementById('kpiTransBadge').className = 'hero-badge ' + tBadge.cls;
|
||||
|
||||
document.getElementById('kpiClients').textContent = fmtNum(d.kpis.total.clientes);
|
||||
document.getElementById('kpiTicket').textContent = fmtUSD(d.kpis.total.ticket_medio);
|
||||
|
||||
// Spread badge
|
||||
const avgSpread = d.kpis.brlUsd.avg_spread_pct || d.kpis.usdBrl.avg_spread_pct || 0;
|
||||
document.getElementById('spreadBadge').textContent = 'Media: ' + avgSpread.toFixed(2) + '%';
|
||||
|
||||
// === Donut Chart: Revenue por Corredor ===
|
||||
destroyCharts();
|
||||
chartDonut = new Chart(document.getElementById('chartDonut'), {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: ['BRL \\u2192 USD', 'USD \\u2192 BRL'],
|
||||
datasets: [{
|
||||
data: [d.kpis.brlUsd.spread_revenue, d.kpis.usdBrl.spread_revenue],
|
||||
backgroundColor: ['#1A73E8', '#1E8E3E'],
|
||||
borderWidth: 0, hoverOffset: 8
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true, maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { position: 'bottom', labels: { padding: 16, font: { size: 12, weight: 600 } } },
|
||||
tooltip: { callbacks: { label: ctx => ctx.label + ': ' + fmtBRL(ctx.raw) } }
|
||||
},
|
||||
cutout: '65%'
|
||||
}
|
||||
});
|
||||
|
||||
// === Spread Trend + Volume Line Chart ===
|
||||
const allDays = new Set();
|
||||
d.trend.brlUsd.forEach(t => allDays.add(t.dia));
|
||||
d.trend.usdBrl.forEach(t => allDays.add(t.dia));
|
||||
d.trend.usdUsd.forEach(t => allDays.add(t.dia));
|
||||
const days = [...allDays].sort();
|
||||
const labels = days.map(d => { const p = d.split('-'); return p[2] + '/' + p[1]; });
|
||||
|
||||
const spreadByDay = {};
|
||||
const volByDay = {};
|
||||
days.forEach(day => { spreadByDay[day] = []; volByDay[day] = 0; });
|
||||
d.trend.brlUsd.forEach(t => { spreadByDay[t.dia].push(t.avg_spread); volByDay[t.dia] += t.vol_usd; });
|
||||
d.trend.usdBrl.forEach(t => { spreadByDay[t.dia].push(t.avg_spread); volByDay[t.dia] += t.vol_usd; });
|
||||
d.trend.usdUsd.forEach(t => { volByDay[t.dia] += t.vol_usd; });
|
||||
|
||||
const avgSpreads = days.map(day => {
|
||||
const vals = spreadByDay[day];
|
||||
return vals.length > 0 ? vals.reduce((a,b) => a+b, 0) / vals.length : null;
|
||||
});
|
||||
const volumes = days.map(day => volByDay[day]);
|
||||
|
||||
chartSpreadTrend = new Chart(document.getElementById('chartSpreadTrend'), {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels,
|
||||
datasets: [
|
||||
{
|
||||
label: 'Volume USD', data: volumes, type: 'bar',
|
||||
backgroundColor: 'rgba(26,115,232,0.15)', borderColor: 'rgba(26,115,232,0.4)',
|
||||
borderWidth: 1, borderRadius: 4, yAxisID: 'y', order: 2
|
||||
},
|
||||
{
|
||||
label: 'Spread Medio %', data: avgSpreads, type: 'line',
|
||||
borderColor: '#1E8E3E', backgroundColor: 'rgba(30,142,62,0.1)',
|
||||
borderWidth: 2, pointRadius: 2, pointHoverRadius: 5, fill: true,
|
||||
tension: 0.3, yAxisID: 'y1', order: 1, spanGaps: true
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true, maintainAspectRatio: false, interaction: { mode: 'index', intersect: false },
|
||||
plugins: {
|
||||
legend: { position: 'top', labels: { font: { size: 11, weight: 600 }, padding: 12 } },
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: ctx => ctx.dataset.label === 'Volume USD'
|
||||
? 'Volume: ' + fmtUSD(ctx.raw)
|
||||
: 'Spread: ' + (ctx.raw !== null ? ctx.raw.toFixed(2) + '%' : 'N/A')
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
y: { position: 'left', grid: { color: 'rgba(0,0,0,0.04)' }, ticks: { callback: v => fmtUSD(v), font: { size: 10 } } },
|
||||
y1: { position: 'right', grid: { display: false }, ticks: { callback: v => v.toFixed(1) + '%', font: { size: 10 } }, min: 0 },
|
||||
x: { grid: { display: false }, ticks: { font: { size: 10 }, maxRotation: 45 } }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// === Top 10 Clients Horizontal Bar ===
|
||||
const clientNames = d.topClients.map(c => c.nome.length > 25 ? c.nome.slice(0, 22) + '...' : c.nome);
|
||||
const clientVols = d.topClients.map(c => c.vol_usd);
|
||||
chartTopClients = new Chart(document.getElementById('chartTopClients'), {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: clientNames,
|
||||
datasets: [{
|
||||
label: 'Volume USD', data: clientVols,
|
||||
backgroundColor: 'rgba(118,0,190,0.12)', borderColor: 'rgba(118,0,190,0.5)',
|
||||
borderWidth: 1, borderRadius: 4
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true, maintainAspectRatio: false, indexAxis: 'y',
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: { callbacks: { label: ctx => fmtUSD(ctx.raw) + ' (' + d.topClients[ctx.dataIndex].qtd + ' ops)' } }
|
||||
},
|
||||
scales: {
|
||||
x: { grid: { color: 'rgba(0,0,0,0.04)' }, ticks: { callback: v => fmtUSD(v), font: { size: 10 } } },
|
||||
y: { grid: { display: false }, ticks: { font: { size: 11, weight: 600 } } }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Retention
|
||||
document.getElementById('retentionValue').textContent = d.retention.rate + '%';
|
||||
document.getElementById('retentionBar').style.width = d.retention.rate + '%';
|
||||
document.getElementById('retentionLabel').textContent = d.retention.retained + ' de ' + d.retention.prev_clients + ' clientes retidos';
|
||||
const retEl = document.getElementById('retentionValue');
|
||||
retEl.className = 'gauge-value ' + (d.retention.rate >= 70 ? 'green' : d.retention.rate >= 40 ? 'blue' : 'red');
|
||||
document.getElementById('retentionBar').className = 'gauge-bar-fill ' + (d.retention.rate >= 70 ? 'green' : d.retention.rate >= 40 ? 'blue' : 'red');
|
||||
|
||||
// Clients at risk table
|
||||
const riskBody = document.getElementById('riskTableBody');
|
||||
if (d.clientsAtRisk.length === 0) {
|
||||
riskBody.innerHTML = '<tr><td colspan="3" style="text-align:center;color:var(--green);font-weight:600;">Nenhum cliente em risco</td></tr>';
|
||||
} else {
|
||||
riskBody.innerHTML = d.clientsAtRisk.map(c =>
|
||||
'<tr><td style="font-weight:600;">' + (c.nome.length > 30 ? c.nome.slice(0,27)+'...' : c.nome) + '</td>' +
|
||||
'<td style="color:var(--red);">' + c.last_op.slice(0,10) + '</td>' +
|
||||
'<td>' + fmtUSD(c.vol_usd) + '</td></tr>'
|
||||
).join('');
|
||||
}
|
||||
|
||||
// === Volume by Flow Stacked Chart ===
|
||||
const volBrlUsd = days.map(day => { const t = d.trend.brlUsd.find(x => x.dia === day); return t ? t.vol_usd : 0; });
|
||||
const volUsdBrl = days.map(day => { const t = d.trend.usdBrl.find(x => x.dia === day); return t ? t.vol_usd : 0; });
|
||||
const volUsdUsd = days.map(day => { const t = d.trend.usdUsd.find(x => x.dia === day); return t ? t.vol_usd : 0; });
|
||||
|
||||
chartVolFlow = new Chart(document.getElementById('chartVolFlow'), {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels,
|
||||
datasets: [
|
||||
{ label: 'BRL\\u2192USD', data: volBrlUsd, backgroundColor: '#1A73E8', borderRadius: 2 },
|
||||
{ label: 'USD\\u2192BRL', data: volUsdBrl, backgroundColor: '#1E8E3E', borderRadius: 2 },
|
||||
{ label: 'USD\\u2192USD', data: volUsdUsd, backgroundColor: '#7B1FA2', borderRadius: 2 }
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true, maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { position: 'top', labels: { font: { size: 11, weight: 600 }, padding: 12 } },
|
||||
tooltip: { callbacks: { label: ctx => ctx.dataset.label + ': ' + fmtUSD(ctx.raw) } }
|
||||
},
|
||||
scales: {
|
||||
x: { stacked: true, grid: { display: false }, ticks: { font: { size: 10 }, maxRotation: 45 } },
|
||||
y: { stacked: true, grid: { color: 'rgba(0,0,0,0.04)' }, ticks: { callback: v => fmtUSD(v), font: { size: 10 } } }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Netting
|
||||
document.getElementById('nettingSaida').textContent = fmtUSD(d.netting.saida_usd);
|
||||
document.getElementById('nettingEntrada').textContent = fmtUSD(d.netting.entrada_usd);
|
||||
const posEl = document.getElementById('nettingPosicao');
|
||||
posEl.textContent = fmtUSD(Math.abs(d.netting.posicao_liquida));
|
||||
posEl.className = 'netting-value ' + (d.netting.posicao_liquida >= 0 ? 'green' : 'red');
|
||||
posEl.textContent = (d.netting.posicao_liquida >= 0 ? '+' : '-') + fmtUSD(Math.abs(d.netting.posicao_liquida));
|
||||
document.getElementById('nettingEficiencia').textContent = d.netting.eficiencia + '%';
|
||||
document.getElementById('nettingBar').style.width = d.netting.eficiencia + '%';
|
||||
|
||||
// Agent ranking table
|
||||
const agentBody = document.getElementById('agentTableBody');
|
||||
if (d.agentRanking.length === 0) {
|
||||
agentBody.innerHTML = '<tr><td colspan="6" style="text-align:center;color:var(--text-muted);">Sem dados</td></tr>';
|
||||
} else {
|
||||
agentBody.innerHTML = d.agentRanking.map(a => {
|
||||
const rankCls = a.rank <= 3 ? 'rank-' + a.rank : 'rank-default';
|
||||
return '<tr>' +
|
||||
'<td><span class="rank ' + rankCls + '">' + a.rank + '</span></td>' +
|
||||
'<td style="font-weight:600;">' + a.nome + '</td>' +
|
||||
'<td>' + fmtUSD(a.vol_usd) + '</td>' +
|
||||
'<td>' + fmtNum(a.qtd) + '</td>' +
|
||||
'<td style="color:var(--green);font-weight:600;">' + fmtBRL(a.spread_revenue) + '</td>' +
|
||||
'<td>' + a.clientes + '</td></tr>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
console.error('BI load error:', err);
|
||||
document.getElementById('periodInfo').textContent = 'Erro ao carregar dados';
|
||||
}
|
||||
}
|
||||
|
||||
// === Live Rates ===
|
||||
let _lastUsdBid = null, _lastUsdAsk = null;
|
||||
function pulseEl(el) { el.closest('.live-rate-btn').classList.add('pulse'); setTimeout(() => el.closest('.live-rate-btn').classList.remove('pulse'), 500); }
|
||||
async function fetchLiveRate() {
|
||||
try {
|
||||
const resp = await fetch('/api/cotacao');
|
||||
const json = await resp.json();
|
||||
const usd = json.USDBRL;
|
||||
if (usd) {
|
||||
const bidRaw = parseFloat(usd.bid), askRaw = parseFloat(usd.ask);
|
||||
const bid = bidRaw * (1 - 0.0043);
|
||||
const ask = askRaw * (1 + 0.0005);
|
||||
const elC = document.getElementById('rateUsdCompra'), elV = document.getElementById('rateUsdVenda');
|
||||
elC.textContent = bid.toFixed(4); elV.textContent = ask.toFixed(4);
|
||||
if (_lastUsdBid !== null && bidRaw !== _lastUsdBid) pulseEl(elC);
|
||||
if (_lastUsdAsk !== null && askRaw !== _lastUsdAsk) pulseEl(elV);
|
||||
_lastUsdBid = bidRaw; _lastUsdAsk = askRaw;
|
||||
}
|
||||
document.getElementById('rateTime').textContent = new Date().toLocaleTimeString('pt-BR');
|
||||
} catch (e) { /* retry next cycle */ }
|
||||
}
|
||||
|
||||
// Init
|
||||
document.addEventListener('DOMContentLoaded', () => { loadBI(); fetchLiveRate(); });
|
||||
setInterval(fetchLiveRate, 3000);
|
||||
<\/script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
module.exports = { buildAdminBIHTML };
|
||||
261
src/queries.js
261
src/queries.js
@@ -503,6 +503,264 @@ async function fetchTopAgentes(dias = 30, getAgenteName = null) {
|
||||
}
|
||||
}
|
||||
|
||||
// BI Analytics - Comprehensive data for admin BI dashboard
|
||||
async function fetchBIData(dataInicio, dataFim, getAgenteName = null) {
|
||||
const conn = await pool.getConnection();
|
||||
try {
|
||||
// Calculate previous period for comparison
|
||||
const start = new Date(dataInicio);
|
||||
const end = new Date(dataFim);
|
||||
const periodDays = Math.round((end - start) / (1000 * 60 * 60 * 24)) + 1;
|
||||
const prevEnd = new Date(start);
|
||||
prevEnd.setDate(prevEnd.getDate() - 1);
|
||||
const prevStart = new Date(prevEnd);
|
||||
prevStart.setDate(prevStart.getDate() - periodDays + 1);
|
||||
const prevStartStr = prevStart.toISOString().slice(0, 10);
|
||||
const prevEndStr = prevEnd.toISOString().slice(0, 10);
|
||||
|
||||
// 1. BRL→USD KPIs
|
||||
const [kpiBrlUsd] = await conn.execute(`
|
||||
SELECT
|
||||
COUNT(*) as qtd,
|
||||
ROUND(COALESCE(SUM(amount_usd), 0), 2) as vol_usd,
|
||||
ROUND(COALESCE(SUM(amount_brl), 0), 2) as vol_brl,
|
||||
ROUND(COALESCE(SUM((exchange_rate - ptax) * amount_usd), 0), 2) as spread_revenue,
|
||||
ROUND(COALESCE(AVG((exchange_rate - ptax) / exchange_rate * 100), 0), 2) as avg_spread_pct,
|
||||
COUNT(DISTINCT id_conta) as clientes
|
||||
FROM br_transaction_to_usa
|
||||
WHERE DATE(created_at) >= ? AND DATE(created_at) <= ?
|
||||
`, [dataInicio, dataFim]);
|
||||
|
||||
// 2. USD→BRL KPIs
|
||||
const [kpiUsdBrl] = await conn.execute(`
|
||||
SELECT
|
||||
COUNT(*) as qtd,
|
||||
ROUND(COALESCE(SUM(valor), 0), 2) as vol_usd,
|
||||
ROUND(COALESCE(SUM(valor_sol), 0), 2) as vol_brl,
|
||||
ROUND(COALESCE(SUM((ptax - cotacao) * valor), 0), 2) as spread_revenue,
|
||||
ROUND(COALESCE(AVG(CASE WHEN cotacao > 0 THEN (ptax - cotacao) / ptax * 100 ELSE 0 END), 0), 2) as avg_spread_pct,
|
||||
COUNT(DISTINCT id_conta) as clientes
|
||||
FROM pagamento_br
|
||||
WHERE DATE(created_at) >= ? AND DATE(created_at) <= ?
|
||||
AND cotacao IS NOT NULL AND cotacao > 0
|
||||
AND (pgto IS NULL OR pgto != 'balance')
|
||||
`, [dataInicio, dataFim]);
|
||||
|
||||
// 3. USD→USD KPIs
|
||||
const [kpiUsdUsd] = await conn.execute(`
|
||||
SELECT
|
||||
COUNT(*) as qtd,
|
||||
ROUND(COALESCE(SUM(valor), 0), 2) as vol_usd,
|
||||
COUNT(DISTINCT id_conta) as clientes
|
||||
FROM pagamento_br
|
||||
WHERE DATE(created_at) >= ? AND DATE(created_at) <= ?
|
||||
AND (cotacao IS NULL OR cotacao = 0 OR pgto = 'balance')
|
||||
`, [dataInicio, dataFim]);
|
||||
|
||||
// 4. Unique active clients across all flows
|
||||
const [uniqueClients] = await conn.execute(`
|
||||
SELECT COUNT(DISTINCT id_conta) as total FROM (
|
||||
SELECT id_conta FROM br_transaction_to_usa
|
||||
WHERE DATE(created_at) >= ? AND DATE(created_at) <= ?
|
||||
UNION
|
||||
SELECT id_conta FROM pagamento_br
|
||||
WHERE DATE(created_at) >= ? AND DATE(created_at) <= ?
|
||||
) all_clients
|
||||
`, [dataInicio, dataFim, dataInicio, dataFim]);
|
||||
|
||||
// 5. Previous period totals for comparison
|
||||
const [prevBrlUsd] = await conn.execute(`
|
||||
SELECT COUNT(*) as qtd, ROUND(COALESCE(SUM(amount_usd),0),2) as vol_usd,
|
||||
ROUND(COALESCE(SUM((exchange_rate - ptax) * amount_usd),0),2) as spread_revenue
|
||||
FROM br_transaction_to_usa WHERE DATE(created_at) >= ? AND DATE(created_at) <= ?
|
||||
`, [prevStartStr, prevEndStr]);
|
||||
const [prevUsdBrl] = await conn.execute(`
|
||||
SELECT COUNT(*) as qtd, ROUND(COALESCE(SUM(valor),0),2) as vol_usd,
|
||||
ROUND(COALESCE(SUM((ptax - cotacao) * valor),0),2) as spread_revenue
|
||||
FROM pagamento_br WHERE DATE(created_at) >= ? AND DATE(created_at) <= ?
|
||||
AND cotacao IS NOT NULL AND cotacao > 0 AND (pgto IS NULL OR pgto != 'balance')
|
||||
`, [prevStartStr, prevEndStr]);
|
||||
const [prevUsdUsd] = await conn.execute(`
|
||||
SELECT COUNT(*) as qtd, ROUND(COALESCE(SUM(valor),0),2) as vol_usd
|
||||
FROM pagamento_br WHERE DATE(created_at) >= ? AND DATE(created_at) <= ?
|
||||
AND (cotacao IS NULL OR cotacao = 0 OR pgto = 'balance')
|
||||
`, [prevStartStr, prevEndStr]);
|
||||
|
||||
// 6. BRL→USD daily trend with spread
|
||||
const [trendBrlUsd] = await conn.execute(`
|
||||
SELECT DATE(created_at) as dia, COUNT(*) as qtd,
|
||||
ROUND(SUM(amount_usd), 2) as vol_usd,
|
||||
ROUND(AVG((exchange_rate - ptax) / exchange_rate * 100), 2) as avg_spread
|
||||
FROM br_transaction_to_usa
|
||||
WHERE DATE(created_at) >= ? AND DATE(created_at) <= ?
|
||||
GROUP BY DATE(created_at) ORDER BY dia
|
||||
`, [dataInicio, dataFim]);
|
||||
|
||||
// 7. USD→BRL daily trend with spread
|
||||
const [trendUsdBrl] = await conn.execute(`
|
||||
SELECT DATE(created_at) as dia, COUNT(*) as qtd,
|
||||
ROUND(SUM(valor), 2) as vol_usd,
|
||||
ROUND(AVG(CASE WHEN cotacao > 0 THEN (ptax - cotacao) / ptax * 100 ELSE 0 END), 2) as avg_spread
|
||||
FROM pagamento_br
|
||||
WHERE DATE(created_at) >= ? AND DATE(created_at) <= ?
|
||||
AND cotacao IS NOT NULL AND cotacao > 0
|
||||
AND (pgto IS NULL OR pgto != 'balance')
|
||||
GROUP BY DATE(created_at) ORDER BY dia
|
||||
`, [dataInicio, dataFim]);
|
||||
|
||||
// 8. USD→USD daily trend
|
||||
const [trendUsdUsd] = await conn.execute(`
|
||||
SELECT DATE(created_at) as dia, COUNT(*) as qtd,
|
||||
ROUND(SUM(valor), 2) as vol_usd
|
||||
FROM pagamento_br
|
||||
WHERE DATE(created_at) >= ? AND DATE(created_at) <= ?
|
||||
AND (cotacao IS NULL OR cotacao = 0 OR pgto = 'balance')
|
||||
GROUP BY DATE(created_at) ORDER BY dia
|
||||
`, [dataInicio, dataFim]);
|
||||
|
||||
// 9. Top 10 clients by volume
|
||||
const [topClients] = await conn.execute(`
|
||||
SELECT nome, SUM(vol) as total_usd, SUM(qtd) as total_qtd FROM (
|
||||
SELECT c.nome, SUM(t.amount_usd) as vol, COUNT(*) as qtd
|
||||
FROM br_transaction_to_usa t
|
||||
INNER JOIN conta c ON c.id_conta = t.id_conta
|
||||
WHERE DATE(t.created_at) >= ? AND DATE(t.created_at) <= ?
|
||||
GROUP BY c.nome
|
||||
UNION ALL
|
||||
SELECT c.nome, SUM(p.valor) as vol, COUNT(*) as qtd
|
||||
FROM pagamento_br p
|
||||
INNER JOIN conta c ON c.id_conta = p.id_conta
|
||||
WHERE DATE(p.created_at) >= ? AND DATE(p.created_at) <= ?
|
||||
GROUP BY c.nome
|
||||
) combined
|
||||
GROUP BY nome ORDER BY total_usd DESC LIMIT 10
|
||||
`, [dataInicio, dataFim, dataInicio, dataFim]);
|
||||
|
||||
// 10. Client retention: clients in previous period who also appear in current
|
||||
const [retention] = await conn.execute(`
|
||||
SELECT
|
||||
COUNT(DISTINCT prev.id_conta) as prev_clients,
|
||||
COUNT(DISTINCT CASE WHEN curr.id_conta IS NOT NULL THEN prev.id_conta END) as retained
|
||||
FROM (
|
||||
SELECT DISTINCT id_conta FROM br_transaction_to_usa WHERE DATE(created_at) >= ? AND DATE(created_at) <= ?
|
||||
UNION
|
||||
SELECT DISTINCT id_conta FROM pagamento_br WHERE DATE(created_at) >= ? AND DATE(created_at) <= ?
|
||||
) prev
|
||||
LEFT JOIN (
|
||||
SELECT DISTINCT id_conta FROM br_transaction_to_usa WHERE DATE(created_at) >= ? AND DATE(created_at) <= ?
|
||||
UNION
|
||||
SELECT DISTINCT id_conta FROM pagamento_br WHERE DATE(created_at) >= ? AND DATE(created_at) <= ?
|
||||
) curr ON prev.id_conta = curr.id_conta
|
||||
`, [prevStartStr, prevEndStr, prevStartStr, prevEndStr, dataInicio, dataFim, dataInicio, dataFim]);
|
||||
|
||||
// 11. Clients at risk (last transaction > 30 days ago, had meaningful volume)
|
||||
const [clientsAtRisk] = await conn.execute(`
|
||||
SELECT nome, MAX(last_op) as last_op, SUM(vol) as total_usd, SUM(qtd) as total_qtd FROM (
|
||||
SELECT c.nome, MAX(t.created_at) as last_op, SUM(t.amount_usd) as vol, COUNT(*) as qtd
|
||||
FROM br_transaction_to_usa t
|
||||
INNER JOIN conta c ON c.id_conta = t.id_conta
|
||||
GROUP BY c.nome
|
||||
UNION ALL
|
||||
SELECT c.nome, MAX(p.created_at) as last_op, SUM(p.valor) as vol, COUNT(*) as qtd
|
||||
FROM pagamento_br p
|
||||
INNER JOIN conta c ON c.id_conta = p.id_conta
|
||||
GROUP BY c.nome
|
||||
) combined
|
||||
GROUP BY nome
|
||||
HAVING MAX(last_op) < DATE_SUB(CURDATE(), INTERVAL 30 DAY)
|
||||
ORDER BY total_usd DESC LIMIT 10
|
||||
`);
|
||||
|
||||
// 12. Agent ranking with spread revenue
|
||||
const [agentRanking] = await conn.execute(`
|
||||
SELECT agente_id, SUM(vol) as total_usd, SUM(qtd) as total_qtd,
|
||||
ROUND(SUM(spread_rev), 2) as total_spread, COUNT(DISTINCT client_id) as clientes
|
||||
FROM (
|
||||
SELECT ac.agente_id, t.id_conta as client_id, SUM(t.amount_usd) as vol, COUNT(*) as qtd,
|
||||
SUM((t.exchange_rate - t.ptax) * t.amount_usd) as spread_rev
|
||||
FROM br_transaction_to_usa t
|
||||
INNER JOIN ag_contas ac ON ac.conta_id = t.id_conta
|
||||
WHERE DATE(t.created_at) >= ? AND DATE(t.created_at) <= ?
|
||||
GROUP BY ac.agente_id, t.id_conta
|
||||
UNION ALL
|
||||
SELECT ac.agente_id, p.id_conta as client_id, SUM(p.valor) as vol, COUNT(*) as qtd,
|
||||
SUM((p.ptax - p.cotacao) * p.valor) as spread_rev
|
||||
FROM pagamento_br p
|
||||
INNER JOIN ag_contas ac ON ac.conta_id = p.id_conta
|
||||
WHERE DATE(p.created_at) >= ? AND DATE(p.created_at) <= ?
|
||||
AND p.cotacao IS NOT NULL AND p.cotacao > 0
|
||||
AND (p.pgto IS NULL OR p.pgto != 'balance')
|
||||
GROUP BY ac.agente_id, p.id_conta
|
||||
) combined
|
||||
GROUP BY agente_id ORDER BY total_usd DESC LIMIT 10
|
||||
`, [dataInicio, dataFim, dataInicio, dataFim]);
|
||||
|
||||
// Resolve agent names
|
||||
const agents = agentRanking.map((r, i) => {
|
||||
const nome = getAgenteName ? (getAgenteName(r.agente_id) || `Agente ${r.agente_id}`) : `Agente ${r.agente_id}`;
|
||||
return { rank: i + 1, agente_id: r.agente_id, nome,
|
||||
vol_usd: Number(r.total_usd), qtd: Number(r.total_qtd),
|
||||
spread_revenue: Number(r.total_spread), clientes: Number(r.clientes)
|
||||
};
|
||||
});
|
||||
|
||||
// Format results
|
||||
const fmtKpi = (r) => ({
|
||||
qtd: Number(r?.qtd) || 0, vol_usd: Number(r?.vol_usd) || 0,
|
||||
vol_brl: Number(r?.vol_brl) || 0, spread_revenue: Number(r?.spread_revenue) || 0,
|
||||
avg_spread_pct: Number(r?.avg_spread_pct) || 0, clientes: Number(r?.clientes) || 0
|
||||
});
|
||||
const fmtTrend = (rows) => rows.map(r => ({
|
||||
dia: r.dia instanceof Date ? r.dia.toISOString().slice(0, 10) : String(r.dia).slice(0, 10),
|
||||
qtd: Number(r.qtd), vol_usd: Number(r.vol_usd), avg_spread: Number(r.avg_spread) || 0
|
||||
}));
|
||||
|
||||
const brl = fmtKpi(kpiBrlUsd[0]);
|
||||
const usd = fmtKpi(kpiUsdBrl[0]);
|
||||
const uu = { qtd: Number(kpiUsdUsd[0]?.qtd) || 0, vol_usd: Number(kpiUsdUsd[0]?.vol_usd) || 0, clientes: Number(kpiUsdUsd[0]?.clientes) || 0 };
|
||||
const totalQtd = brl.qtd + usd.qtd + uu.qtd;
|
||||
const totalVolUsd = brl.vol_usd + usd.vol_usd + uu.vol_usd;
|
||||
|
||||
const pBrl = Number(prevBrlUsd[0]?.qtd) || 0;
|
||||
const pUsd = Number(prevUsdBrl[0]?.qtd) || 0;
|
||||
const pUu = Number(prevUsdUsd[0]?.qtd) || 0;
|
||||
const prevQtd = pBrl + pUsd + pUu;
|
||||
const prevVolUsd = (Number(prevBrlUsd[0]?.vol_usd) || 0) + (Number(prevUsdBrl[0]?.vol_usd) || 0) + (Number(prevUsdUsd[0]?.vol_usd) || 0);
|
||||
const prevSpread = (Number(prevBrlUsd[0]?.spread_revenue) || 0) + (Number(prevUsdBrl[0]?.spread_revenue) || 0);
|
||||
|
||||
const retPrev = Number(retention[0]?.prev_clients) || 0;
|
||||
const retCurr = Number(retention[0]?.retained) || 0;
|
||||
|
||||
return {
|
||||
kpis: {
|
||||
brlUsd: brl, usdBrl: usd, usdUsd: uu,
|
||||
total: {
|
||||
qtd: totalQtd, vol_usd: totalVolUsd,
|
||||
spread_revenue: brl.spread_revenue + usd.spread_revenue,
|
||||
clientes: Number(uniqueClients[0]?.total) || 0,
|
||||
ticket_medio: totalQtd > 0 ? Math.round(totalVolUsd / totalQtd) : 0
|
||||
}
|
||||
},
|
||||
comparison: { prev_qtd: prevQtd, prev_vol_usd: prevVolUsd, prev_spread: prevSpread },
|
||||
trend: { brlUsd: fmtTrend(trendBrlUsd), usdBrl: fmtTrend(trendUsdBrl), usdUsd: fmtTrend(trendUsdUsd) },
|
||||
topClients: topClients.map(r => ({ nome: r.nome, vol_usd: Number(r.total_usd), qtd: Number(r.total_qtd) })),
|
||||
retention: { prev_clients: retPrev, retained: retCurr, rate: retPrev > 0 ? Math.round(retCurr / retPrev * 100) : 0 },
|
||||
clientsAtRisk: clientsAtRisk.map(r => ({
|
||||
nome: r.nome, vol_usd: Number(r.total_usd), qtd: Number(r.total_qtd),
|
||||
last_op: r.last_op instanceof Date ? r.last_op.toISOString().slice(0, 10) : String(r.last_op).slice(0, 16)
|
||||
})),
|
||||
agentRanking: agents,
|
||||
netting: {
|
||||
saida_usd: brl.vol_usd, entrada_usd: usd.vol_usd,
|
||||
posicao_liquida: usd.vol_usd - brl.vol_usd,
|
||||
eficiencia: brl.vol_usd > 0 ? Math.min(100, Math.round(usd.vol_usd / brl.vol_usd * 100)) : 0
|
||||
}
|
||||
};
|
||||
} finally {
|
||||
conn.release();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
fetchTransacoes,
|
||||
fetchAllTransacoes,
|
||||
@@ -512,5 +770,6 @@ module.exports = {
|
||||
fetchTrend30Days,
|
||||
fetchTopAgentes,
|
||||
fetchTrendByPeriod,
|
||||
fetchKPIsByPeriod
|
||||
fetchKPIsByPeriod,
|
||||
fetchBIData
|
||||
};
|
||||
|
||||
@@ -302,10 +302,11 @@ function buildHeader(options = {}) {
|
||||
.join('')
|
||||
.toUpperCase();
|
||||
|
||||
// Admin navigation: Corporate Dashboard + Users (admin on the right)
|
||||
// Admin navigation: Corporate Dashboard + BI + Users
|
||||
const adminNav = `
|
||||
<nav class="header-nav">
|
||||
<a href="/corporate" class="${activePage === 'dashboard' ? 'active' : ''}">Corporate</a>
|
||||
<a href="/admin/bi" class="${activePage === 'bi' ? 'active' : ''}">BI Executive</a>
|
||||
<a href="/admin" class="${activePage === 'users' ? 'active' : ''}">Usuarios</a>
|
||||
</nav>
|
||||
`;
|
||||
|
||||
Reference in New Issue
Block a user