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

+
+ + + +
PeriodoProdutoReceitaOperacoesReceita/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> diff --git a/src/queries.js b/src/queries.js index 8af415d..b8d07d8 100644 --- a/src/queries.js +++ b/src/queries.js @@ -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 };