diff --git a/server.js b/server.js
index 350326a..a5e8dfc 100644
--- a/server.js
+++ b/server.js
@@ -11,7 +11,7 @@ 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, fetchBIData } = require('./src/queries');
+const { fetchTransacoes, fetchAllTransacoes, serialize, fetchDailyStats, fetchKPIs, fetchTrend30Days, fetchTopAgentes, fetchTrendByPeriod, fetchKPIsByPeriod, fetchBIData, fetchRevenueAnalytics } = require('./src/queries');
const { buildHTML } = require('./src/dashboard');
const { buildAdminHTML } = require('./src/admin-panel');
const { buildAdminHomeHTML } = require('./src/admin-home');
@@ -355,6 +355,18 @@ app.get('/admin/api/bi', requireRole('admin'), async (req, res) => {
}
});
+app.get('/admin/api/bi/revenue', requireRole('admin'), async (req, res) => {
+ try {
+ const { start, end, granularity } = req.query;
+ if (!start || !end) return res.status(400).json({ error: 'start and end required' });
+ const data = await fetchRevenueAnalytics(start, end, granularity || 'dia');
+ res.json(data);
+ } catch (err) {
+ console.error('Revenue 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;
diff --git a/src/admin-bi.js b/src/admin-bi.js
index 59309f9..f5f72dd 100644
--- a/src/admin-bi.js
+++ b/src/admin-bi.js
@@ -244,6 +244,29 @@ function buildAdminBIHTML(user) {
.netting-value.green { color: var(--green); }
.netting-value.red { color: var(--red); }
+ /* Revenue Controls */
+ .revenue-controls {
+ display: flex; align-items: center; justify-content: space-between;
+ margin-bottom: 16px; flex-wrap: wrap; gap: 12px;
+ }
+ .gran-selector { display: flex; align-items: center; gap: 8px; }
+ .gran-label {
+ font-size: 13px; font-weight: 600; color: var(--text-secondary);
+ }
+ .gran-btn {
+ padding: 8px 18px; 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;
+ }
+ .gran-btn:hover { border-color: #F9A825; color: #F9A825; }
+ .gran-btn.active { background: #F9A825; color: white; border-color: #F9A825; }
+ .revenue-status {
+ font-size: 12px; color: var(--text-muted); font-weight: 500;
+ background: var(--bg); padding: 6px 12px; border-radius: 6px;
+ }
+ .hero-grid-4 { grid-template-columns: repeat(4, 1fr); }
+ .hero-card[style*="--top-color"]::before { background: var(--top-color); }
+
/* Loading */
.loading-overlay {
position: absolute; inset: 0; background: rgba(255,255,255,0.8);
@@ -261,8 +284,10 @@ function buildAdminBIHTML(user) {
.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-grid-4 { grid-template-columns: repeat(2, 1fr); }
.hero-card:last-child { grid-column: span 2; }
.hero-value { font-size: 22px; }
+ .gran-selector { flex-wrap: wrap; }
.charts-row, .charts-row.equal, .charts-row.triple { grid-template-columns: 1fr; }
.filter-bar { padding: 14px 16px; gap: 10px; }
.filter-presets { flex-wrap: wrap; }
@@ -279,7 +304,7 @@ function buildAdminBIHTML(user) {
.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-grid, .hero-grid-4 { grid-template-columns: 1fr; }
.hero-card:last-child { grid-column: span 1; }
.hero-value { font-size: 20px; }
.chart-card { padding: 16px; }
@@ -460,6 +485,66 @@ ${buildHeader({ role: role, userName: user.nome, activePage: 'bi' })}
+
+
+ 💰
+ Revenue Analytics (P&L Real)
+
+
+
+
+ Granulacao:
+
+
+
+
+
Carregando...
+
+
+
+
+
Receita Total (P&L)
+
--
+
-- operacoes
+
+
+
BR → US
+
--
+
Checkout + CambioTransfer
+
+
+
US → BR
+
--
+
Spread + Fees
+
+
+
Receita / Operacao
+
--
+
ticket medio de receita
+
+
+
+
+
+
Receita por Produto ao Longo do Tempo
+
+
+
+
Composicao de Receita por Produto
+
+
+
+
+
+
Detalhamento por Periodo dia
+
+
+ | Periodo | Produto | Receita | Operacoes | Receita/Op |
+ | Carregando... |
+
+
+
+
${buildFooter()}
@@ -500,21 +585,23 @@ function setPreset(preset) {
document.querySelectorAll('.preset-btn').forEach(b => b.classList.remove('active'));
document.querySelector('[data-preset="'+preset+'"]').classList.add('active');
currentStart = start; currentEnd = today;
- loadBI();
+ loadAll();
}
+function loadAll() { loadBI(); if (typeof loadRevenue === 'function') loadRevenue(); }
+
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();
+ loadAll();
});
document.getElementById('dateEnd').addEventListener('change', () => {
currentEnd = document.getElementById('dateEnd').value;
document.querySelectorAll('.preset-btn').forEach(b => b.classList.remove('active'));
- loadBI();
+ loadAll();
});
// === Chart instances ===
@@ -766,8 +853,159 @@ async function fetchLiveRate() {
} catch (e) { /* retry next cycle */ }
}
+// === Revenue Analytics ===
+let currentGran = 'dia';
+let chartRevTimeline, chartRevDonut;
+
+function destroyRevCharts() {
+ [chartRevTimeline, chartRevDonut].forEach(c => { if (c) c.destroy(); });
+}
+
+// Color palette for products
+const productColors = {
+ 'BR\u2192US: Checkout': '#1A73E8',
+ 'BR\u2192US: CambioTransfer': '#42A5F5',
+ 'US\u2192BR: balance': '#1E8E3E',
+ 'US\u2192BR: desconhecido': '#66BB6A',
+ 'US\u2192BR: swift': '#00897B',
+ 'US\u2192BR: wire': '#26A69A',
+ 'US\u2192BR: pix': '#4CAF50',
+};
+function getProductColor(produto, i) {
+ return productColors[produto] || ['#7B1FA2','#AB47BC','#F9A825','#FF7043','#78909C','#EC407A','#5C6BC0','#8D6E63'][i % 8];
+}
+
+async function loadRevenue() {
+ document.getElementById('revenueStatus').textContent = 'Carregando...';
+ try {
+ const resp = await fetch('/admin/api/bi/revenue?start=' + currentStart + '&end=' + currentEnd + '&granularity=' + currentGran);
+ const d = await resp.json();
+
+ if (d.error) { document.getElementById('revenueStatus').textContent = 'Erro: ' + d.error; return; }
+
+ // KPI cards
+ document.getElementById('revTotal').textContent = fmtUSD(d.summary.total_receita);
+ document.getElementById('revTotalQtd').textContent = fmtNum(d.summary.total_qtd) + ' operacoes';
+ document.getElementById('revBrUs').textContent = fmtUSD(d.summary.receita_br_us);
+ document.getElementById('revUsBr').textContent = fmtUSD(d.summary.receita_us_br);
+ document.getElementById('revTicket').textContent = fmtUSD(d.summary.ticket_medio_receita);
+ document.getElementById('revGranBadge').textContent = currentGran;
+
+ // Get unique periods and products
+ const periods = [...new Set(d.timeline.map(r => r.periodo_label))].sort();
+ const products = [...new Set(d.timeline.map(r => r.produto))].sort();
+
+ // Build datasets for stacked bar
+ const datasets = products.map((prod, i) => ({
+ label: prod,
+ data: periods.map(p => {
+ const match = d.timeline.find(r => r.periodo_label === p && r.produto === prod);
+ return match ? match.receita : 0;
+ }),
+ backgroundColor: getProductColor(prod, i),
+ borderRadius: 3
+ }));
+
+ // Stacked bar chart
+ destroyRevCharts();
+ const labels = periods.map(p => {
+ if (currentGran === 'dia') { const parts = p.split('-'); return parts[2] + '/' + parts[1]; }
+ if (currentGran === 'mes') { const parts = p.split('-'); return parts[1] + '/' + parts[0]; }
+ return p;
+ });
+
+ chartRevTimeline = new Chart(document.getElementById('chartRevTimeline'), {
+ type: 'bar',
+ data: { labels, datasets },
+ options: {
+ responsive: true, maintainAspectRatio: false,
+ plugins: {
+ legend: { position: 'top', labels: { font: { size: 11, weight: 600 }, padding: 10, boxWidth: 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 } } }
+ }
+ }
+ });
+
+ // Donut chart by product
+ const donutData = d.totals.map(t => t.receita);
+ const donutLabels = d.totals.map(t => t.produto);
+ const donutColors = d.totals.map((t, i) => getProductColor(t.direcao + ': ' + t.tipo, i));
+
+ chartRevDonut = new Chart(document.getElementById('chartRevDonut'), {
+ type: 'doughnut',
+ data: {
+ labels: donutLabels,
+ datasets: [{ data: donutData, backgroundColor: donutColors, borderWidth: 0, hoverOffset: 8 }]
+ },
+ options: {
+ responsive: true, maintainAspectRatio: false,
+ plugins: {
+ legend: { position: 'bottom', labels: { padding: 12, font: { size: 11, weight: 600 }, boxWidth: 12 } },
+ tooltip: { callbacks: { label: ctx => ctx.label + ': ' + fmtUSD(ctx.raw) + ' (' + d.totals[ctx.dataIndex].qtd + ' ops)' } }
+ },
+ cutout: '60%'
+ }
+ });
+
+ // Detailed table
+ const tbody = document.getElementById('revTableBody');
+ if (d.timeline.length === 0) {
+ tbody.innerHTML = '| Sem dados no periodo |
';
+ } else {
+ // Group by period for subtotals
+ const byPeriod = {};
+ d.timeline.forEach(r => {
+ if (!byPeriod[r.periodo_label]) byPeriod[r.periodo_label] = [];
+ byPeriod[r.periodo_label].push(r);
+ });
+
+ let html = '';
+ for (const [periodo, rows] of Object.entries(byPeriod)) {
+ const periodTotal = rows.reduce((s, r) => s + r.receita, 0);
+ const periodQtd = rows.reduce((s, r) => s + r.qtd, 0);
+ rows.forEach(r => {
+ const ticketOp = r.qtd > 0 ? r.receita / r.qtd : 0;
+ html += '' +
+ '| ' + periodo + ' | ' +
+ '' + r.produto + ' | ' +
+ '' + fmtUSD(r.receita) + ' | ' +
+ '' + fmtNum(r.qtd) + ' | ' +
+ '' + fmtUSD(ticketOp) + ' |
';
+ });
+ if (rows.length > 1) {
+ html += '' +
+ '| ' + periodo + ' | TOTAL | ' +
+ '' + fmtUSD(periodTotal) + ' | ' +
+ '' + fmtNum(periodQtd) + ' | ' +
+ '' + fmtUSD(periodQtd > 0 ? periodTotal / periodQtd : 0) + ' |
';
+ }
+ }
+ tbody.innerHTML = html;
+ }
+
+ document.getElementById('revenueStatus').textContent = d.timeline.length + ' registros | ' + currentGran;
+ } catch (err) {
+ console.error('Revenue load error:', err);
+ document.getElementById('revenueStatus').textContent = 'Erro ao carregar';
+ }
+}
+
+// Granularity buttons
+document.querySelectorAll('.gran-btn').forEach(btn => {
+ btn.addEventListener('click', () => {
+ document.querySelectorAll('.gran-btn').forEach(b => b.classList.remove('active'));
+ btn.classList.add('active');
+ currentGran = btn.dataset.gran;
+ loadRevenue();
+ });
+});
+
// Init
-document.addEventListener('DOMContentLoaded', () => { loadBI(); fetchLiveRate(); });
+document.addEventListener('DOMContentLoaded', () => { loadBI(); loadRevenue(); fetchLiveRate(); });
setInterval(fetchLiveRate, 3000);
<\/script>