feat: integra dados de merchant/checkout no Client 360
Merchants (via br_cb_empresas) agora mostram dados de CambioCheckout: - fetchMerchantProfile detecta merchant e retorna lifetime checkout stats - fetchMerchantData retorna KPIs, monthly, top payers e transacoes por periodo - fetchTopClients inclui checkout volume (merchants sobem no ranking) - fetchClientSearch inclui merchants nos resultados de busca - Profile/data endpoints fazem merge automatico dos dados checkout - UI: badge MERCHANT roxo, 6 hero cards checkout, chart mensal, top 10 payers Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
65
server.js
65
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, fetchRevenueAnalytics, fetchBIStrategic, fetchTopClients, fetchClientSearch, fetchClientProfile, fetchClientData } = require('./src/queries');
|
||||
const { fetchTransacoes, fetchAllTransacoes, serialize, fetchDailyStats, fetchKPIs, fetchTrend30Days, fetchTopAgentes, fetchTrendByPeriod, fetchKPIsByPeriod, fetchBIData, fetchRevenueAnalytics, fetchBIStrategic, fetchTopClients, fetchClientSearch, fetchClientProfile, fetchClientData, fetchMerchantProfile, fetchMerchantData } = require('./src/queries');
|
||||
const { buildHTML } = require('./src/dashboard');
|
||||
const { buildAdminHTML } = require('./src/admin-panel');
|
||||
const { buildAdminHomeHTML } = require('./src/admin-home');
|
||||
@@ -421,8 +421,32 @@ app.get('/admin/api/cliente/:id/profile', requireRole('admin'), async (req, res)
|
||||
try {
|
||||
const clienteId = parseInt(req.params.id);
|
||||
if (!clienteId) return res.status(400).json({ error: 'Invalid client ID' });
|
||||
const data = await fetchClientProfile(clienteId);
|
||||
res.json(data);
|
||||
const [profile, merchant] = await Promise.all([
|
||||
fetchClientProfile(clienteId),
|
||||
fetchMerchantProfile(clienteId)
|
||||
]);
|
||||
if (merchant.is_merchant) {
|
||||
const ck = merchant.checkout;
|
||||
profile.merchant = { empresa_id: merchant.empresa_id, nome_empresa: merchant.nome_empresa };
|
||||
profile.total_ops += ck.tx_count;
|
||||
profile.total_vol_usd += ck.vol_usd;
|
||||
profile.total_spread_revenue += ck.revenue;
|
||||
profile.ltv = profile.total_spread_revenue;
|
||||
// Extend date ranges
|
||||
const dates = [profile.first_op, ck.first_op].filter(Boolean);
|
||||
const lastDates = [profile.last_op, ck.last_op].filter(Boolean);
|
||||
if (dates.length) profile.first_op = dates.sort()[0];
|
||||
if (lastDates.length) {
|
||||
profile.last_op = lastDates.sort().pop();
|
||||
profile.days_inactive = Math.round((Date.now() - new Date(profile.last_op).getTime()) / 86400000);
|
||||
}
|
||||
profile.months_active = Math.max(profile.months_active, ck.months_active);
|
||||
profile.avg_monthly_vol = profile.months_active > 0 ? Math.round(profile.total_vol_usd / profile.months_active) : 0;
|
||||
profile.avg_monthly_ops = profile.months_active > 0 ? Math.round(profile.total_ops / profile.months_active * 10) / 10 : 0;
|
||||
profile.avg_monthly_revenue = profile.months_active > 0 ? Math.round(profile.total_spread_revenue / profile.months_active * 100) / 100 : 0;
|
||||
profile.checkout = ck;
|
||||
}
|
||||
res.json(profile);
|
||||
} catch (err) {
|
||||
console.error('Client profile API error:', err);
|
||||
res.status(500).json({ error: err.message });
|
||||
@@ -434,8 +458,39 @@ app.get('/admin/api/cliente/:id/data', requireRole('admin'), async (req, res) =>
|
||||
const clienteId = parseInt(req.params.id);
|
||||
const { start, end } = req.query;
|
||||
if (!clienteId || !start || !end) return res.status(400).json({ error: 'client ID, start and end required' });
|
||||
const data = await fetchClientData(clienteId, start, end);
|
||||
res.json(data);
|
||||
|
||||
const merchant = await fetchMerchantProfile(clienteId);
|
||||
if (merchant.is_merchant) {
|
||||
const [data, mData] = await Promise.all([
|
||||
fetchClientData(clienteId, start, end),
|
||||
fetchMerchantData(merchant.empresa_id, start, end)
|
||||
]);
|
||||
// Add checkout KPIs
|
||||
data.kpis.checkout = mData.kpis;
|
||||
// Merge totals
|
||||
data.kpis.total.qtd += mData.kpis.qtd;
|
||||
data.kpis.total.vol_usd += mData.kpis.vol_usd;
|
||||
data.kpis.total.spread_revenue += mData.kpis.revenue;
|
||||
const totalQtd = data.kpis.total.qtd;
|
||||
data.kpis.total.ticket_medio = totalQtd > 0 ? Math.round(data.kpis.total.vol_usd / totalQtd) : 0;
|
||||
// Merge comparison
|
||||
data.comparison.prev_qtd += mData.comparison.prev_qtd;
|
||||
data.comparison.prev_vol_usd += mData.comparison.prev_vol_usd;
|
||||
data.comparison.prev_spread += mData.comparison.prev_revenue;
|
||||
// Merchant-specific data
|
||||
data.merchant = {
|
||||
monthly: mData.monthly,
|
||||
topPayers: mData.topPayers,
|
||||
comparison: mData.comparison
|
||||
};
|
||||
// Merge transactions (checkout txs get flow="Checkout")
|
||||
data.transactions = data.transactions.concat(mData.transactions)
|
||||
.sort((a, b) => b.date.localeCompare(a.date));
|
||||
res.json(data);
|
||||
} else {
|
||||
const data = await fetchClientData(clienteId, start, end);
|
||||
res.json(data);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Client data API error:', err);
|
||||
res.status(500).json({ error: err.message });
|
||||
|
||||
@@ -327,6 +327,29 @@ function buildAdminClienteHTML(user) {
|
||||
[data-theme="dark"] .date-inputs input[type="date"] { background: var(--card); color: var(--text); border-color: var(--border); }
|
||||
[data-theme="dark"] .data-table tr:hover td { background: rgba(255,255,255,0.03); }
|
||||
|
||||
/* === Merchant / Checkout === */
|
||||
.merchant-badge {
|
||||
display: none; padding: 4px 12px; border-radius: 8px; font-size: 11px; font-weight: 800;
|
||||
letter-spacing: 0.5px; background: var(--purple-bg); color: var(--purple, #7B1FA2);
|
||||
text-transform: uppercase; margin-top: 4px;
|
||||
}
|
||||
.merchant-badge.visible { display: inline-block; }
|
||||
.hero-card.checkout::before { background: linear-gradient(90deg, var(--purple, #7B1FA2), #AB47BC); }
|
||||
.checkout-section { display: none; }
|
||||
.checkout-section.visible { display: block; }
|
||||
.flow-tag.checkout { background: var(--purple-bg); color: var(--purple, #7B1FA2); }
|
||||
.top-payer-row { display: flex; align-items: center; padding: 10px 0; border-bottom: 1px solid var(--border); cursor: pointer; transition: background 0.1s; }
|
||||
.top-payer-row:last-child { border-bottom: none; }
|
||||
.top-payer-row:hover { background: var(--bg); }
|
||||
[data-theme="dark"] .top-payer-row:hover { background: rgba(0,255,136,0.04); }
|
||||
.top-payer-rank { width: 28px; font-size: 11px; font-weight: 800; color: var(--text-muted); text-align: center; }
|
||||
.top-payer-info { flex: 1; min-width: 0; }
|
||||
.top-payer-name { font-size: 13px; font-weight: 700; color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.top-payer-stats { font-size: 11px; color: var(--text-muted); }
|
||||
.top-payer-vol { font-size: 14px; font-weight: 700; color: var(--purple, #7B1FA2); font-variant-numeric: tabular-nums; }
|
||||
[data-theme="dark"] .top-payer-vol { color: #BC8CFF; }
|
||||
[data-theme="dark"] .top-payer-name { font-family: 'SF Mono','Fira Code','Consolas',monospace; }
|
||||
|
||||
/* === Responsive === */
|
||||
@media (max-width: 1200px) { .hero-grid { grid-template-columns: repeat(3, 1fr); } .intel-grid { grid-template-columns: 1fr 1fr; } }
|
||||
@media (max-width: 900px) { .charts-row, .charts-row.wide-left { grid-template-columns: 1fr; } .charts-row.triple { grid-template-columns: 1fr 1fr; } .charts-row.triple > :last-child { grid-column: span 2; } .intel-grid { grid-template-columns: 1fr; } .filter-divider { display: none; } .profile-card { flex-direction: column; text-align: center; } .profile-left { flex-direction: column; } .profile-stats { justify-content: center; } .health-gauge-wrap { flex-direction: column; align-items: center; } }
|
||||
@@ -375,6 +398,7 @@ ${buildHeader({ role: role, userName: user.nome, activePage: 'cliente' })}
|
||||
<div>
|
||||
<div class="profile-name" id="profileName">--</div>
|
||||
<div class="profile-id" id="profileId">--</div>
|
||||
<div class="merchant-badge" id="merchantBadge">MERCHANT</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="profile-stats">
|
||||
@@ -419,6 +443,36 @@ ${buildHeader({ role: role, userName: user.nome, activePage: 'cliente' })}
|
||||
<div class="hero-card avgspread"><div class="hero-label">Spread Medio</div><div class="hero-value" id="kpiAvgSpread">--</div><div class="hero-sub">% ponderado</div></div>
|
||||
</div>
|
||||
|
||||
<!-- Checkout KPIs (merchant only) -->
|
||||
<div class="checkout-section" id="checkoutKpiSection">
|
||||
<div class="section-title" id="sectionCheckout">
|
||||
<span class="icon">🛒</span>
|
||||
CambioCheckout (Merchant)
|
||||
</div>
|
||||
<div class="hero-grid" id="checkoutHeroGrid">
|
||||
<div class="hero-card checkout"><div class="hero-label">Checkout Volume</div><div class="hero-value" id="ckVolume">--</div><span class="hero-badge neutral" id="ckVolBadge">--</span></div>
|
||||
<div class="hero-card checkout"><div class="hero-label">Payers Unicos</div><div class="hero-value" id="ckPayers">--</div><div class="hero-sub">pagadores distintos</div></div>
|
||||
<div class="hero-card checkout"><div class="hero-label">Receita Checkout</div><div class="hero-value" id="ckRevenue">--</div><span class="hero-badge neutral" id="ckRevBadge">--</span></div>
|
||||
<div class="hero-card checkout"><div class="hero-label">Checkout Ops</div><div class="hero-value" id="ckOps">--</div><span class="hero-badge neutral" id="ckOpsBadge">--</span></div>
|
||||
<div class="hero-card checkout"><div class="hero-label">Ticket Checkout</div><div class="hero-value" id="ckTicket">--</div><div class="hero-sub">USD / operacao</div></div>
|
||||
<div class="hero-card checkout"><div class="hero-label">Spread Checkout</div><div class="hero-value" id="ckSpread">--</div><div class="hero-sub">% medio</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Checkout Analytics (merchant only) -->
|
||||
<div class="checkout-section" id="checkoutAnalyticsSection">
|
||||
<div class="charts-row wide-left">
|
||||
<div class="chart-card">
|
||||
<h3>Checkout Mensal: Volume + Payers</h3>
|
||||
<div class="chart-wrap"><canvas id="chartCheckoutMonthly"></canvas></div>
|
||||
</div>
|
||||
<div class="chart-card">
|
||||
<h3>Top 10 Pagadores</h3>
|
||||
<div id="topPayersList" style="max-height:310px;overflow-y:auto;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Section: Saude & Risco -->
|
||||
<div class="section-title" id="sectionHealth">
|
||||
<span class="icon">🎯</span>
|
||||
@@ -566,6 +620,7 @@ ${buildHeader({ role: role, userName: user.nome, activePage: 'cliente' })}
|
||||
<nav class="console-nav" id="consoleNav" style="display:none;">
|
||||
<a href="#searchWrap" class="console-nav-btn" data-section="searchWrap"><span class="nav-icon">🔍</span><span class="nav-label">Busca</span></a>
|
||||
<a href="#heroGrid" class="console-nav-btn" data-section="heroGrid"><span class="nav-icon">📊</span><span class="nav-label">KPIs</span></a>
|
||||
<a href="#sectionCheckout" class="console-nav-btn checkout-section" data-section="sectionCheckout"><span class="nav-icon">🛒</span><span class="nav-label">Checkout</span></a>
|
||||
<a href="#sectionHealth" class="console-nav-btn" data-section="sectionHealth"><span class="nav-icon">🎯</span><span class="nav-label">Saude</span></a>
|
||||
<a href="#sectionRevenue" class="console-nav-btn" data-section="sectionRevenue"><span class="nav-icon">💰</span><span class="nav-label">Revenue</span></a>
|
||||
<a href="#sectionTimeline" class="console-nav-btn" data-section="sectionTimeline"><span class="nav-icon">📈</span><span class="nav-label">Timeline</span></a>
|
||||
@@ -627,10 +682,10 @@ function applyChartDefaults(t) {
|
||||
}
|
||||
|
||||
// === Charts ===
|
||||
var chartTimeline, chartFlowDonut, chartSpreadTrend, chartDow, chartAvgSize, chartProvider, chartMonthlyRev, chartMoM;
|
||||
var chartTimeline, chartFlowDonut, chartSpreadTrend, chartDow, chartAvgSize, chartProvider, chartMonthlyRev, chartMoM, chartCheckoutMonthly;
|
||||
function destroyAllCharts() {
|
||||
[chartTimeline, chartFlowDonut, chartSpreadTrend, chartDow, chartAvgSize, chartProvider, chartMonthlyRev, chartMoM].forEach(function(c){if(c)c.destroy();});
|
||||
chartTimeline = chartFlowDonut = chartSpreadTrend = chartDow = chartAvgSize = chartProvider = chartMonthlyRev = chartMoM = null;
|
||||
[chartTimeline, chartFlowDonut, chartSpreadTrend, chartDow, chartAvgSize, chartProvider, chartMonthlyRev, chartMoM, chartCheckoutMonthly].forEach(function(c){if(c)c.destroy();});
|
||||
chartTimeline = chartFlowDonut = chartSpreadTrend = chartDow = chartAvgSize = chartProvider = chartMonthlyRev = chartMoM = chartCheckoutMonthly = null;
|
||||
}
|
||||
|
||||
// === Health Score ===
|
||||
@@ -839,6 +894,8 @@ function clearClient() {
|
||||
document.getElementById('emptyState').style.display = '';
|
||||
document.getElementById('contentArea').classList.remove('visible');
|
||||
document.getElementById('consoleNav').style.display = 'none';
|
||||
document.getElementById('merchantBadge').classList.remove('visible');
|
||||
document.querySelectorAll('.checkout-section').forEach(function(el){ el.classList.remove('visible'); });
|
||||
destroyAllCharts();
|
||||
var url = new URL(window.location); url.searchParams.delete('id'); history.replaceState(null, '', url);
|
||||
}
|
||||
@@ -860,6 +917,19 @@ function renderProfile(p) {
|
||||
document.getElementById('profileLTV').textContent = fmtUSD(p.ltv || p.total_spread_revenue);
|
||||
document.getElementById('profileVolume').textContent = fmtUSD(p.total_vol_usd);
|
||||
document.getElementById('profileOps').textContent = p.total_ops;
|
||||
// Merchant badge
|
||||
var isMerchant = !!(p.merchant && p.merchant.nome_empresa);
|
||||
var badge = document.getElementById('merchantBadge');
|
||||
if (isMerchant) {
|
||||
badge.textContent = 'MERCHANT: ' + p.merchant.nome_empresa;
|
||||
badge.classList.add('visible');
|
||||
} else {
|
||||
badge.classList.remove('visible');
|
||||
}
|
||||
// Show/hide checkout sections
|
||||
document.querySelectorAll('.checkout-section').forEach(function(el) {
|
||||
el.classList.toggle('visible', isMerchant);
|
||||
});
|
||||
}
|
||||
|
||||
// === Data Loading ===
|
||||
@@ -873,6 +943,11 @@ function loadData() {
|
||||
function renderAll(d) {
|
||||
var t = getChartTheme(); applyChartDefaults(t);
|
||||
renderKPIs(d); renderIntelligence(d, t); renderCharts(d, t); renderTable(d); updatePeriodInfo();
|
||||
if (profileData && profileData.merchant) {
|
||||
renderCheckoutKPIs(d, t);
|
||||
renderCheckoutMonthly(d, t);
|
||||
renderTopPayers(d);
|
||||
}
|
||||
}
|
||||
function updatePeriodInfo() {
|
||||
var s = new Date(currentStart), e = new Date(currentEnd);
|
||||
@@ -1077,6 +1152,55 @@ function renderProviderChart(d, t) {
|
||||
}); } catch(e) {}
|
||||
}
|
||||
|
||||
// === Checkout Renders (Merchant) ===
|
||||
function renderCheckoutKPIs(d) {
|
||||
var ck = d.kpis.checkout; if (!ck) return;
|
||||
var cmp = (d.merchant && d.merchant.comparison) || {};
|
||||
document.getElementById('ckVolume').textContent = fmtUSD(ck.vol_usd);
|
||||
document.getElementById('ckPayers').textContent = ck.unique_payers;
|
||||
document.getElementById('ckRevenue').textContent = fmtUSD(ck.revenue);
|
||||
document.getElementById('ckOps').textContent = ck.qtd;
|
||||
document.getElementById('ckTicket').textContent = fmtUSD(ck.ticket_medio);
|
||||
document.getElementById('ckSpread').textContent = fmtPct(ck.avg_spread_pct);
|
||||
var bv = badge(ck.vol_usd, cmp.prev_vol_usd);
|
||||
document.getElementById('ckVolBadge').className = 'hero-badge ' + bv.cls; document.getElementById('ckVolBadge').textContent = bv.text;
|
||||
var br = badge(ck.revenue, cmp.prev_revenue);
|
||||
document.getElementById('ckRevBadge').className = 'hero-badge ' + br.cls; document.getElementById('ckRevBadge').textContent = br.text;
|
||||
var bo = badge(ck.qtd, cmp.prev_qtd);
|
||||
document.getElementById('ckOpsBadge').className = 'hero-badge ' + bo.cls; document.getElementById('ckOpsBadge').textContent = bo.text;
|
||||
}
|
||||
function renderCheckoutMonthly(d, t) {
|
||||
if (!d.merchant || !d.merchant.monthly || !d.merchant.monthly.length) return;
|
||||
var m = d.merchant.monthly;
|
||||
if (chartCheckoutMonthly) chartCheckoutMonthly.destroy();
|
||||
try {
|
||||
chartCheckoutMonthly = new Chart(document.getElementById('chartCheckoutMonthly'), {
|
||||
type: 'bar', data: {
|
||||
labels: m.map(function(x){return x.mes;}),
|
||||
datasets: [
|
||||
{ label: 'Volume USD', data: m.map(function(x){return x.vol_usd;}), backgroundColor: 'rgba(188,140,255,0.2)', borderColor: 'rgba(188,140,255,0.6)', borderWidth: 1, borderRadius: 4, yAxisID: 'y', order: 2 },
|
||||
{ label: 'Payers', data: m.map(function(x){return x.unique_payers;}), type: 'line', borderColor: t.lineQtd, backgroundColor: 'transparent', borderWidth: 2, pointRadius: 3, tension: 0.3, yAxisID: 'y1', order: 1 }
|
||||
]
|
||||
}, 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: function(ctx) { return ctx.dataset.label === 'Volume USD' ? 'Volume: ' + fmtUSD(ctx.raw) : 'Payers: ' + ctx.raw; }}}},
|
||||
scales: {
|
||||
y: { position:'left', grid:{color:t.grid}, ticks:{callback:function(v){return fmtUSD(v);}, font:{size:10}} },
|
||||
y1: { position:'right', grid:{display:false}, ticks:{font:{size:10}, stepSize:1}, min:0 },
|
||||
x: { grid:{display:false}, ticks:{font:{size:10}} }
|
||||
}
|
||||
}
|
||||
}); } catch(e) { console.warn('chartCheckoutMonthly:', e.message); }
|
||||
}
|
||||
function renderTopPayers(d) {
|
||||
var el = document.getElementById('topPayersList');
|
||||
if (!d.merchant || !d.merchant.topPayers || !d.merchant.topPayers.length) { el.innerHTML = '<div style="text-align:center;color:var(--text-muted);padding:20px;">Sem dados</div>'; return; }
|
||||
el.innerHTML = d.merchant.topPayers.map(function(p, i) {
|
||||
return '<div class="top-payer-row" onclick="selectClient('+p.id_conta+',\\''+esc(p.nome).replace(/'/g,"\\\\'")+'\\')"><span class="top-payer-rank">#'+(i+1)+'</span><div class="top-payer-info"><div class="top-payer-name" title="'+esc(p.nome)+'">'+esc(p.nome)+'</div><div class="top-payer-stats">'+p.tx_count+' ops</div></div><span class="top-payer-vol">'+fmtUSD(p.vol_usd)+'</span></div>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// === Transaction Table ===
|
||||
function renderTable(d) { if (!d.transactions) return; currentPage = 1; _renderTablePage(); }
|
||||
function _getSortedTx() {
|
||||
@@ -1095,8 +1219,9 @@ function _renderTablePage() {
|
||||
if (!page.length) { tbody.innerHTML = '<tr><td colspan="10" style="text-align:center;color:var(--text-muted)">Nenhuma transacao</td></tr>'; }
|
||||
else {
|
||||
tbody.innerHTML = page.map(function(r) {
|
||||
var fc = r.flow === 'BRL\\u2192USD' ? 'brl-usd' : 'usd-brl';
|
||||
return '<tr><td>'+r.date.slice(0,16)+'</td><td><span class="flow-tag '+fc+'">'+r.flow+'</span></td><td>'+fmtUSD(r.usd)+'</td><td>'+fmtBRL(r.brl)+'</td><td>'+Number(r.rate).toFixed(4)+'</td><td>'+Number(r.ptax).toFixed(4)+'</td><td>'+fmtPct(r.spread_pct)+'</td><td>'+(r.iof?fmtPct(r.iof):'--')+'</td><td>'+(r.status||'--')+'</td><td>'+(r.provider||'--')+'</td></tr>';
|
||||
var fc = r.flow === 'Checkout' ? 'checkout' : (r.flow === 'BRL\\u2192USD' ? 'brl-usd' : 'usd-brl');
|
||||
var provCol = r.flow === 'Checkout' && r.payer_name ? esc(r.payer_name) : (r.provider||'--');
|
||||
return '<tr><td>'+r.date.slice(0,16)+'</td><td><span class="flow-tag '+fc+'">'+r.flow+'</span></td><td>'+fmtUSD(r.usd)+'</td><td>'+fmtBRL(r.brl)+'</td><td>'+Number(r.rate).toFixed(4)+'</td><td>'+Number(r.ptax).toFixed(4)+'</td><td>'+fmtPct(r.spread_pct)+'</td><td>'+(r.iof?fmtPct(r.iof):'--')+'</td><td>'+(r.status||'--')+'</td><td>'+provCol+'</td></tr>';
|
||||
}).join('');
|
||||
}
|
||||
var foot = document.getElementById('txTableFoot');
|
||||
@@ -1163,7 +1288,7 @@ document.querySelectorAll('[data-tl-gran]').forEach(function(btn) {
|
||||
});
|
||||
|
||||
// Console Nav
|
||||
var _navSections = ['searchWrap','heroGrid','sectionHealth','sectionRevenue','sectionTimeline','sectionFlows','sectionTable','sectionInsights'];
|
||||
var _navSections = ['searchWrap','heroGrid','sectionCheckout','sectionHealth','sectionRevenue','sectionTimeline','sectionFlows','sectionTable','sectionInsights'];
|
||||
function updateConsoleNav() {
|
||||
var sy = window.scrollY + 120, active = _navSections[0];
|
||||
_navSections.forEach(function(id){var el=document.getElementById(id); if(el&&el.offsetTop<=sy) active=id;});
|
||||
|
||||
253
src/queries.js
253
src/queries.js
@@ -955,16 +955,16 @@ async function fetchRevenueAnalytics(dataInicio, dataFim, granularity = 'dia') {
|
||||
}
|
||||
}
|
||||
|
||||
// Top 20 clients by total USD volume
|
||||
// Top 20 clients by total USD volume (including checkout volume for merchants)
|
||||
async function fetchTopClients() {
|
||||
const conn = await pool.getConnection();
|
||||
try {
|
||||
const [rows] = await conn.execute(`
|
||||
SELECT c.id_conta, c.nome,
|
||||
COALESCE(t1.vol_usd, 0) + COALESCE(t2.vol_usd, 0) AS total_vol_usd,
|
||||
COALESCE(t1.cnt, 0) + COALESCE(t2.cnt, 0) AS total_ops,
|
||||
GREATEST(COALESCE(t1.months_active, 0), COALESCE(t2.months_active, 0)) AS months_active,
|
||||
COALESCE(t1.last_op, t2.last_op) AS last_op
|
||||
COALESCE(t1.vol_usd, 0) + COALESCE(t2.vol_usd, 0) + COALESCE(t3.vol_usd, 0) AS total_vol_usd,
|
||||
COALESCE(t1.cnt, 0) + COALESCE(t2.cnt, 0) + COALESCE(t3.cnt, 0) AS total_ops,
|
||||
GREATEST(COALESCE(t1.months_active, 0), COALESCE(t2.months_active, 0), COALESCE(t3.months_active, 0)) AS months_active,
|
||||
GREATEST(COALESCE(t1.last_op, '1970-01-01'), COALESCE(t2.last_op, '1970-01-01'), COALESCE(t3.last_op, '1970-01-01')) AS last_op
|
||||
FROM conta c
|
||||
LEFT JOIN (
|
||||
SELECT id_conta,
|
||||
@@ -982,7 +982,19 @@ async function fetchTopClients() {
|
||||
MAX(created_at) AS last_op
|
||||
FROM pagamento_br GROUP BY id_conta
|
||||
) t2 ON t2.id_conta = c.id_conta
|
||||
WHERE COALESCE(t1.cnt, 0) + COALESCE(t2.cnt, 0) > 0
|
||||
LEFT JOIN (
|
||||
SELECT e.id_conta,
|
||||
SUM(t.amount_usd) AS vol_usd,
|
||||
COUNT(*) AS cnt,
|
||||
COUNT(DISTINCT DATE_FORMAT(t.created_at, '%Y-%m')) AS months_active,
|
||||
MAX(t.created_at) AS last_op
|
||||
FROM br_cb_empresas e
|
||||
INNER JOIN br_cb_cobranca cb ON cb.empresa_id = e.id
|
||||
INNER JOIN br_transaction_to_usa t ON t.cobranca_id = cb.id
|
||||
WHERE e.active = 1
|
||||
GROUP BY e.id_conta
|
||||
) t3 ON t3.id_conta = c.id_conta
|
||||
WHERE COALESCE(t1.cnt, 0) + COALESCE(t2.cnt, 0) + COALESCE(t3.cnt, 0) > 0
|
||||
ORDER BY total_vol_usd DESC
|
||||
LIMIT 20
|
||||
`);
|
||||
@@ -992,14 +1004,14 @@ async function fetchTopClients() {
|
||||
vol: Math.round(r.total_vol_usd || 0),
|
||||
ops: r.total_ops || 0,
|
||||
months: r.months_active || 0,
|
||||
lastOp: r.last_op ? r.last_op.toISOString().slice(0, 10) : null
|
||||
lastOp: r.last_op && String(r.last_op) !== '1970-01-01' ? (r.last_op instanceof Date ? r.last_op.toISOString().slice(0, 10) : String(r.last_op).slice(0, 10)) : null
|
||||
}));
|
||||
} finally {
|
||||
conn.release();
|
||||
}
|
||||
}
|
||||
|
||||
// Search clients by name (server-side, max 15 results)
|
||||
// Search clients by name (server-side, max 15 results) — includes merchants
|
||||
async function fetchClientSearch(query) {
|
||||
const conn = await pool.getConnection();
|
||||
try {
|
||||
@@ -1008,6 +1020,7 @@ async function fetchClientSearch(query) {
|
||||
WHERE c.id_conta IN (
|
||||
SELECT DISTINCT id_conta FROM br_transaction_to_usa
|
||||
UNION SELECT DISTINCT id_conta FROM pagamento_br
|
||||
UNION SELECT DISTINCT id_conta FROM br_cb_empresas WHERE active = 1
|
||||
) AND c.nome LIKE CONCAT('%', ?, '%')
|
||||
ORDER BY c.nome LIMIT 15
|
||||
`, [query]);
|
||||
@@ -1531,6 +1544,226 @@ async function fetchBIStrategic(dataInicio, dataFim) {
|
||||
}
|
||||
}
|
||||
|
||||
// Detect if client is a merchant (has active empresa in br_cb_empresas)
|
||||
async function fetchMerchantProfile(clienteId) {
|
||||
const conn = await pool.getConnection();
|
||||
try {
|
||||
// Check if this id_conta is a merchant
|
||||
const [empresa] = await conn.execute(
|
||||
'SELECT id, nome_empresa FROM br_cb_empresas WHERE id_conta = ? AND active = 1 LIMIT 1',
|
||||
[clienteId]
|
||||
);
|
||||
if (!empresa.length) return { is_merchant: false };
|
||||
|
||||
const empresaId = empresa[0].id;
|
||||
const nomeEmpresa = empresa[0].nome_empresa;
|
||||
|
||||
// Lifetime checkout stats via cobranca -> transactions
|
||||
const [stats] = await conn.execute(`
|
||||
SELECT
|
||||
COUNT(*) as tx_count,
|
||||
ROUND(COALESCE(SUM(t.amount_usd), 0), 2) as vol_usd,
|
||||
COUNT(DISTINCT t.id_conta) as unique_payers,
|
||||
MIN(t.created_at) as first_op,
|
||||
MAX(t.created_at) as last_op,
|
||||
COUNT(DISTINCT DATE_FORMAT(t.created_at, '%Y-%m')) as months_active
|
||||
FROM br_cb_cobranca cb
|
||||
INNER JOIN br_transaction_to_usa t ON t.cobranca_id = cb.id
|
||||
WHERE cb.empresa_id = ?
|
||||
`, [empresaId]);
|
||||
|
||||
// Revenue: same formula as fetchRevenueAnalytics for BR→US Checkout
|
||||
const [rev] = await conn.execute(`
|
||||
SELECT ROUND(COALESCE(SUM(
|
||||
(
|
||||
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)
|
||||
)
|
||||
), 0), 2) as revenue
|
||||
FROM br_cb_cobranca cb
|
||||
INNER JOIN br_transaction_to_usa t ON t.cobranca_id = cb.id
|
||||
INNER JOIN br_payment_methods pm ON t.payment_method_id = pm.id
|
||||
WHERE cb.empresa_id = ?
|
||||
AND 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')
|
||||
`, [empresaId]);
|
||||
|
||||
const s = stats[0] || {};
|
||||
return {
|
||||
is_merchant: true,
|
||||
empresa_id: empresaId,
|
||||
nome_empresa: nomeEmpresa,
|
||||
checkout: {
|
||||
tx_count: Number(s.tx_count) || 0,
|
||||
vol_usd: Number(s.vol_usd) || 0,
|
||||
unique_payers: Number(s.unique_payers) || 0,
|
||||
revenue: Number(rev[0]?.revenue) || 0,
|
||||
first_op: s.first_op ? (s.first_op instanceof Date ? s.first_op.toISOString().slice(0, 10) : String(s.first_op).slice(0, 10)) : null,
|
||||
last_op: s.last_op ? (s.last_op instanceof Date ? s.last_op.toISOString().slice(0, 10) : String(s.last_op).slice(0, 10)) : null,
|
||||
months_active: Number(s.months_active) || 0
|
||||
}
|
||||
};
|
||||
} finally {
|
||||
conn.release();
|
||||
}
|
||||
}
|
||||
|
||||
// Merchant checkout data for a period
|
||||
async function fetchMerchantData(empresaId, dataInicio, dataFim) {
|
||||
const conn = await pool.getConnection();
|
||||
try {
|
||||
// Previous period for comparison
|
||||
const start = new Date(dataInicio);
|
||||
const end = new Date(dataFim);
|
||||
const periodDays = Math.round((end - start) / 86400000) + 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);
|
||||
|
||||
// KPIs current period
|
||||
const [kpi] = await conn.execute(`
|
||||
SELECT
|
||||
COUNT(*) as qtd,
|
||||
ROUND(COALESCE(SUM(t.amount_usd), 0), 2) as vol_usd,
|
||||
COUNT(DISTINCT t.id_conta) as unique_payers,
|
||||
ROUND(COALESCE(AVG((t.exchange_rate - t.ptax) / t.exchange_rate * 100), 0), 2) as avg_spread_pct
|
||||
FROM br_cb_cobranca cb
|
||||
INNER JOIN br_transaction_to_usa t ON t.cobranca_id = cb.id
|
||||
WHERE cb.empresa_id = ? AND DATE(t.created_at) >= ? AND DATE(t.created_at) <= ?
|
||||
`, [empresaId, dataInicio, dataFim]);
|
||||
|
||||
// Revenue current period
|
||||
const [rev] = await conn.execute(`
|
||||
SELECT ROUND(COALESCE(SUM(
|
||||
(
|
||||
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)
|
||||
)
|
||||
), 0), 2) as revenue
|
||||
FROM br_cb_cobranca cb
|
||||
INNER JOIN br_transaction_to_usa t ON t.cobranca_id = cb.id
|
||||
INNER JOIN br_payment_methods pm ON t.payment_method_id = pm.id
|
||||
WHERE cb.empresa_id = ? AND DATE(t.created_at) >= ? AND DATE(t.created_at) <= ?
|
||||
AND 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')
|
||||
`, [empresaId, dataInicio, dataFim]);
|
||||
|
||||
// KPIs previous period
|
||||
const [prevKpi] = await conn.execute(`
|
||||
SELECT COUNT(*) as qtd, ROUND(COALESCE(SUM(t.amount_usd), 0), 2) as vol_usd
|
||||
FROM br_cb_cobranca cb
|
||||
INNER JOIN br_transaction_to_usa t ON t.cobranca_id = cb.id
|
||||
WHERE cb.empresa_id = ? AND DATE(t.created_at) >= ? AND DATE(t.created_at) <= ?
|
||||
`, [empresaId, prevStartStr, prevEndStr]);
|
||||
|
||||
const [prevRev] = await conn.execute(`
|
||||
SELECT ROUND(COALESCE(SUM(
|
||||
(
|
||||
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)
|
||||
)
|
||||
), 0), 2) as revenue
|
||||
FROM br_cb_cobranca cb
|
||||
INNER JOIN br_transaction_to_usa t ON t.cobranca_id = cb.id
|
||||
INNER JOIN br_payment_methods pm ON t.payment_method_id = pm.id
|
||||
WHERE cb.empresa_id = ? AND DATE(t.created_at) >= ? AND DATE(t.created_at) <= ?
|
||||
AND 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')
|
||||
`, [empresaId, prevStartStr, prevEndStr]);
|
||||
|
||||
// Monthly breakdown
|
||||
const [monthly] = await conn.execute(`
|
||||
SELECT DATE_FORMAT(t.created_at, '%Y-%m') as mes,
|
||||
COUNT(*) as qtd,
|
||||
ROUND(SUM(t.amount_usd), 2) as vol_usd,
|
||||
COUNT(DISTINCT t.id_conta) as unique_payers
|
||||
FROM br_cb_cobranca cb
|
||||
INNER JOIN br_transaction_to_usa t ON t.cobranca_id = cb.id
|
||||
WHERE cb.empresa_id = ? AND DATE(t.created_at) >= ? AND DATE(t.created_at) <= ?
|
||||
GROUP BY DATE_FORMAT(t.created_at, '%Y-%m') ORDER BY mes
|
||||
`, [empresaId, dataInicio, dataFim]);
|
||||
|
||||
// Top 10 payers
|
||||
const [topPayers] = await conn.execute(`
|
||||
SELECT c.nome, t.id_conta, COUNT(*) as tx_count, ROUND(SUM(t.amount_usd), 2) as vol_usd
|
||||
FROM br_cb_cobranca cb
|
||||
INNER JOIN br_transaction_to_usa t ON t.cobranca_id = cb.id
|
||||
INNER JOIN conta c ON c.id_conta = t.id_conta
|
||||
WHERE cb.empresa_id = ? AND DATE(t.created_at) >= ? AND DATE(t.created_at) <= ?
|
||||
GROUP BY t.id_conta, c.nome
|
||||
ORDER BY vol_usd DESC LIMIT 10
|
||||
`, [empresaId, dataInicio, dataFim]);
|
||||
|
||||
// Transactions (latest 500)
|
||||
const [txRows] = await conn.execute(`
|
||||
SELECT t.created_at as date, t.amount_usd as usd, t.amount_brl as brl,
|
||||
ROUND(t.exchange_rate, 4) as rate, ROUND(t.ptax, 4) as ptax,
|
||||
ROUND((t.exchange_rate - t.ptax) / t.exchange_rate * 100, 2) as spread_pct,
|
||||
t.iof, t.status, COALESCE(pm.provider, '') as provider,
|
||||
c.nome as payer_name, t.id_conta as payer_id
|
||||
FROM br_cb_cobranca cb
|
||||
INNER JOIN br_transaction_to_usa t ON t.cobranca_id = cb.id
|
||||
LEFT JOIN br_payment_methods pm ON t.payment_method_id = pm.id
|
||||
LEFT JOIN conta c ON c.id_conta = t.id_conta
|
||||
WHERE cb.empresa_id = ? AND DATE(t.created_at) >= ? AND DATE(t.created_at) <= ?
|
||||
ORDER BY t.created_at DESC LIMIT 500
|
||||
`, [empresaId, dataInicio, dataFim]);
|
||||
|
||||
const fmtDT = (d) => { try { const dt = d instanceof Date ? d : new Date(d); return dt.toISOString().slice(0, 16).replace('T', ' '); } catch(e) { return String(d); } };
|
||||
|
||||
const k = kpi[0] || {};
|
||||
const qtd = Number(k.qtd) || 0;
|
||||
const volUsd = Number(k.vol_usd) || 0;
|
||||
const revenue = Number(rev[0]?.revenue) || 0;
|
||||
|
||||
return {
|
||||
kpis: {
|
||||
qtd,
|
||||
vol_usd: volUsd,
|
||||
unique_payers: Number(k.unique_payers) || 0,
|
||||
avg_spread_pct: Number(k.avg_spread_pct) || 0,
|
||||
revenue,
|
||||
ticket_medio: qtd > 0 ? Math.round(volUsd / qtd) : 0
|
||||
},
|
||||
comparison: {
|
||||
prev_qtd: Number(prevKpi[0]?.qtd) || 0,
|
||||
prev_vol_usd: Number(prevKpi[0]?.vol_usd) || 0,
|
||||
prev_revenue: Number(prevRev[0]?.revenue) || 0
|
||||
},
|
||||
monthly: monthly.map(r => ({
|
||||
mes: r.mes,
|
||||
qtd: Number(r.qtd),
|
||||
vol_usd: Number(r.vol_usd),
|
||||
unique_payers: Number(r.unique_payers)
|
||||
})),
|
||||
topPayers: topPayers.map(r => ({
|
||||
nome: r.nome,
|
||||
id_conta: r.id_conta,
|
||||
tx_count: Number(r.tx_count),
|
||||
vol_usd: Number(r.vol_usd)
|
||||
})),
|
||||
transactions: txRows.map(r => ({
|
||||
date: fmtDT(r.date), flow: 'Checkout', usd: Number(r.usd), brl: Number(r.brl),
|
||||
rate: Number(r.rate), ptax: Number(r.ptax), spread_pct: Number(r.spread_pct),
|
||||
iof: Number(r.iof), status: r.status, provider: r.provider,
|
||||
payer_name: r.payer_name || '', payer_id: r.payer_id
|
||||
}))
|
||||
};
|
||||
} finally {
|
||||
conn.release();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
fetchTransacoes,
|
||||
fetchAllTransacoes,
|
||||
@@ -1547,5 +1780,7 @@ module.exports = {
|
||||
fetchTopClients,
|
||||
fetchClientSearch,
|
||||
fetchClientProfile,
|
||||
fetchClientData
|
||||
fetchClientData,
|
||||
fetchMerchantProfile,
|
||||
fetchMerchantData
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user