feat: revenue analytics (P&L real) no BI admin

Seção Revenue Analytics com cálculo real de receita por produto:
- Query completa com fees, pfee, bonus, taxa_cr por provider
- Granulação dinâmica (dia/mês/ano) com filtro de período
- KPIs: receita total, BR→US, US→BR, receita/operação
- Stacked bar por produto + donut composição + tabela detalhada
- Produtos: Checkout, CambioTransfer, balance, swift, etc.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
root
2026-02-14 12:49:03 -05:00
parent cadc4cd01e
commit 5ecf91a024
3 changed files with 449 additions and 7 deletions

View File

@@ -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;

View File

@@ -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' })}
</div>
</div>
<!-- Section: Revenue Analytics -->
<div class="section-title" style="margin-top:12px;">
<span class="icon" style="background:#FFF8E1;color:#F9A825;">&#x1F4B0;</span>
Revenue Analytics (P&L Real)
</div>
<div class="revenue-controls">
<div class="gran-selector">
<span class="gran-label">Granulacao:</span>
<button class="gran-btn active" data-gran="dia">Dia</button>
<button class="gran-btn" data-gran="mes">Mes</button>
<button class="gran-btn" data-gran="ano">Ano</button>
</div>
<span class="revenue-status" id="revenueStatus">Carregando...</span>
</div>
<div class="hero-grid hero-grid-4" id="revenueKpis">
<div class="hero-card" style="--top-color:linear-gradient(90deg,#F9A825,#FFD54F);">
<div class="hero-label">Receita Total (P&L)</div>
<div class="hero-value" id="revTotal">--</div>
<div class="hero-sub" id="revTotalQtd">-- operacoes</div>
</div>
<div class="hero-card" style="--top-color:linear-gradient(90deg,var(--blue),#42A5F5);">
<div class="hero-label">BR &#x2192; US</div>
<div class="hero-value" id="revBrUs">--</div>
<div class="hero-sub">Checkout + CambioTransfer</div>
</div>
<div class="hero-card" style="--top-color:linear-gradient(90deg,var(--green),#4CAF50);">
<div class="hero-label">US &#x2192; BR</div>
<div class="hero-value" id="revUsBr">--</div>
<div class="hero-sub">Spread + Fees</div>
</div>
<div class="hero-card" style="--top-color:linear-gradient(90deg,var(--purple),#AB47BC);">
<div class="hero-label">Receita / Operacao</div>
<div class="hero-value" id="revTicket">--</div>
<div class="hero-sub">ticket medio de receita</div>
</div>
</div>
<div class="charts-row equal">
<div class="chart-card">
<h3>Receita por Produto ao Longo do Tempo</h3>
<div class="chart-wrap"><canvas id="chartRevTimeline"></canvas></div>
</div>
<div class="chart-card">
<h3>Composicao de Receita por Produto</h3>
<div class="chart-wrap short"><canvas id="chartRevDonut"></canvas></div>
</div>
</div>
<div class="chart-card" style="margin-bottom:28px;">
<h3>Detalhamento por Periodo <span class="badge" id="revGranBadge">dia</span></h3>
<div style="overflow-x:auto;">
<table class="data-table" id="revTable">
<thead><tr><th>Periodo</th><th>Produto</th><th>Receita</th><th>Operacoes</th><th>Receita/Op</th></tr></thead>
<tbody id="revTableBody"><tr><td colspan="5" style="text-align:center;color:var(--text-muted);">Carregando...</td></tr></tbody>
</table>
</div>
</div>
</div>
${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 = '<tr><td colspan="5" style="text-align:center;color:var(--text-muted);">Sem dados no periodo</td></tr>';
} 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 += '<tr>' +
'<td style="font-weight:600;">' + periodo + '</td>' +
'<td>' + r.produto + '</td>' +
'<td style="color:' + (r.receita >= 0 ? 'var(--green)' : 'var(--red)') + ';font-weight:600;">' + fmtUSD(r.receita) + '</td>' +
'<td>' + fmtNum(r.qtd) + '</td>' +
'<td>' + fmtUSD(ticketOp) + '</td></tr>';
});
if (rows.length > 1) {
html += '<tr style="background:var(--bg);font-weight:700;">' +
'<td>' + periodo + '</td><td>TOTAL</td>' +
'<td style="color:' + (periodTotal >= 0 ? 'var(--green)' : 'var(--red)') + ';">' + fmtUSD(periodTotal) + '</td>' +
'<td>' + fmtNum(periodQtd) + '</td>' +
'<td>' + fmtUSD(periodQtd > 0 ? periodTotal / periodQtd : 0) + '</td></tr>';
}
}
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>
</body>

View File

@@ -761,6 +761,197 @@ async function fetchBIData(dataInicio, dataFim, getAgenteName = null) {
}
}
// Revenue Analytics - Real P&L by product with dynamic granularity
async function fetchRevenueAnalytics(dataInicio, dataFim, granularity = 'dia') {
const conn = await pool.getConnection();
try {
// Validate granularity
const validGran = ['dia', 'mes', 'ano'].includes(granularity) ? granularity : 'dia';
// Dynamic grouping expressions
let periodoInicio, periodoLabel;
switch (validGran) {
case 'ano':
periodoInicio = "MAKEDATE(YEAR(dia), 1)";
periodoLabel = "DATE_FORMAT(dia, '%Y')";
break;
case 'mes':
periodoInicio = "CAST(DATE_FORMAT(dia, '%Y-%m-01') AS DATE)";
periodoLabel = "DATE_FORMAT(dia, '%Y-%m')";
break;
default:
periodoInicio = "DATE(dia)";
periodoLabel = "DATE_FORMAT(dia, '%Y-%m-%d')";
}
const [rows] = await conn.execute(`
WITH limites AS (
SELECT
CAST(? AS DATE) AS inicio,
DATE_ADD(CAST(? AS DATE), INTERVAL 1 DAY) AS fim_exclusivo
),
q1 AS (
SELECT
CASE
WHEN pb.tipo_envio = 'balance' THEN DATE(pb.data_cp)
ELSE DATE(pb.created_at)
END AS dia,
CONCAT('US→BR: ', COALESCE(pb.tipo_envio, 'desconhecido')) AS produto,
CASE
WHEN pb.tipo_envio = 'balance' THEN COALESCE(pb.fee, 0)
ELSE COALESCE(
CASE
WHEN pb.ptax IS NOT NULL AND pb.ptax > 0
THEN ((pb.ptax - pb.cotacao) * pb.valor) / pb.ptax
ELSE 0
END, 0
) + COALESCE(pb.fee, 0)
END AS receita
FROM pagamento_br pb
JOIN limites l ON (
CASE WHEN pb.tipo_envio = 'balance' THEN pb.data_cp ELSE pb.created_at END
) >= l.inicio
AND (
CASE WHEN pb.tipo_envio = 'balance' THEN pb.data_cp ELSE pb.created_at END
) < l.fim_exclusivo
WHERE pb.valor > 0
AND pb.data_cp IS NOT NULL
AND pb.data_cp <> '0000-00-00'
),
q2 AS (
SELECT
DATE(t.created_at) AS dia,
CASE
WHEN t.cobranca_id IS NOT NULL THEN 'BR→US: Checkout'
ELSE 'BR→US: CambioTransfer'
END AS produto,
(
(
ROUND((t.amount_brl - IF(pm.provider IN ('ouribank','bs2'), 0, t.fee)) / t.ptax, 2)
- COALESCE(t.pfee, 0)
) - (
t.amount_usd + COALESCE(t.bonus_valor, 0) - COALESCE(t.taxa_cr, 0)
)
) AS receita
FROM br_transaction_to_usa t
JOIN br_payment_methods pm ON t.payment_method_id = pm.id
JOIN limites l ON t.created_at >= l.inicio AND t.created_at < l.fim_exclusivo
WHERE pm.provider IN ('dlocal','bexs','braza','bs2','ouribank','msb')
AND t.ptax IS NOT NULL AND t.ptax > 0
AND (
t.status IN ('boleto_pago','finalizado')
OR t.date_sent_usa <> '0000-00-00 00:00:00'
)
),
unioned AS (
SELECT dia, produto, receita FROM q1
UNION ALL
SELECT dia, produto, receita FROM q2
)
SELECT
${periodoInicio} AS periodo_inicio,
${periodoLabel} AS periodo_label,
produto,
ROUND(SUM(receita), 2) AS receita,
COUNT(*) AS qtd
FROM unioned
GROUP BY periodo_inicio, periodo_label, produto
ORDER BY periodo_inicio, produto
`, [dataInicio, dataFim]);
// Also get totals by product (for KPI cards)
const [totals] = await conn.execute(`
WITH limites AS (
SELECT
CAST(? AS DATE) AS inicio,
DATE_ADD(CAST(? AS DATE), INTERVAL 1 DAY) AS fim_exclusivo
),
q1 AS (
SELECT
CASE
WHEN pb.tipo_envio = 'balance' THEN COALESCE(pb.fee, 0)
ELSE COALESCE(
CASE WHEN pb.ptax IS NOT NULL AND pb.ptax > 0
THEN ((pb.ptax - pb.cotacao) * pb.valor) / pb.ptax ELSE 0
END, 0
) + COALESCE(pb.fee, 0)
END AS receita,
'US→BR' AS direcao,
COALESCE(pb.tipo_envio, 'desconhecido') AS tipo
FROM pagamento_br pb
JOIN limites l ON (
CASE WHEN pb.tipo_envio = 'balance' THEN pb.data_cp ELSE pb.created_at END
) >= l.inicio
AND (
CASE WHEN pb.tipo_envio = 'balance' THEN pb.data_cp ELSE pb.created_at END
) < l.fim_exclusivo
WHERE pb.valor > 0 AND pb.data_cp IS NOT NULL AND pb.data_cp <> '0000-00-00'
),
q2 AS (
SELECT
(
(ROUND((t.amount_brl - IF(pm.provider IN ('ouribank','bs2'), 0, t.fee)) / t.ptax, 2) - COALESCE(t.pfee, 0))
- (t.amount_usd + COALESCE(t.bonus_valor, 0) - COALESCE(t.taxa_cr, 0))
) AS receita,
'BR→US' AS direcao,
CASE WHEN t.cobranca_id IS NOT NULL THEN 'Checkout' ELSE 'CambioTransfer' END AS tipo
FROM br_transaction_to_usa t
JOIN br_payment_methods pm ON t.payment_method_id = pm.id
JOIN limites l ON t.created_at >= l.inicio AND t.created_at < l.fim_exclusivo
WHERE pm.provider IN ('dlocal','bexs','braza','bs2','ouribank','msb')
AND t.ptax IS NOT NULL AND t.ptax > 0
AND (t.status IN ('boleto_pago','finalizado') OR t.date_sent_usa <> '0000-00-00 00:00:00')
)
SELECT
direcao,
tipo,
ROUND(SUM(receita), 2) AS total_receita,
COUNT(*) AS total_qtd
FROM (SELECT receita, direcao, tipo FROM q1 UNION ALL SELECT receita, direcao, tipo FROM q2) all_data
GROUP BY direcao, tipo
ORDER BY direcao, tipo
`, [dataInicio, dataFim]);
// Format timeline data
const timeline = rows.map(r => ({
periodo_inicio: r.periodo_inicio instanceof Date ? r.periodo_inicio.toISOString().slice(0, 10) : String(r.periodo_inicio).slice(0, 10),
periodo_label: r.periodo_label,
produto: r.produto,
receita: Number(r.receita),
qtd: Number(r.qtd)
}));
// Format totals
const totalsByProduct = totals.map(r => ({
direcao: r.direcao,
tipo: r.tipo,
produto: r.direcao + ': ' + r.tipo,
receita: Number(r.total_receita),
qtd: Number(r.total_qtd)
}));
const grandTotal = totalsByProduct.reduce((s, r) => s + r.receita, 0);
const grandQtd = totalsByProduct.reduce((s, r) => s + r.qtd, 0);
const receitaBrUs = totalsByProduct.filter(r => r.direcao === 'BR→US').reduce((s, r) => s + r.receita, 0);
const receitaUsBr = totalsByProduct.filter(r => r.direcao === 'US→BR').reduce((s, r) => s + r.receita, 0);
return {
timeline,
totals: totalsByProduct,
summary: {
total_receita: Math.round(grandTotal * 100) / 100,
total_qtd: grandQtd,
receita_br_us: Math.round(receitaBrUs * 100) / 100,
receita_us_br: Math.round(receitaUsBr * 100) / 100,
ticket_medio_receita: grandQtd > 0 ? Math.round(grandTotal / grandQtd * 100) / 100 : 0
},
granularity: validGran
};
} finally {
conn.release();
}
}
module.exports = {
fetchTransacoes,
fetchAllTransacoes,
@@ -771,5 +962,6 @@ module.exports = {
fetchTopAgentes,
fetchTrendByPeriod,
fetchKPIsByPeriod,
fetchBIData
fetchBIData,
fetchRevenueAnalytics
};