feat: dashboard cliente 360 — visao completa por cliente com KPIs, timeline, fluxos e transacoes
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
51
server.js
51
server.js
@@ -11,12 +11,13 @@ const express = require('express');
|
|||||||
const session = require('express-session');
|
const session = require('express-session');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { authenticate, requireAuth, requireRole, createAgente, createUser } = require('./src/auth');
|
const { authenticate, requireAuth, requireRole, createAgente, createUser } = require('./src/auth');
|
||||||
const { fetchTransacoes, fetchAllTransacoes, serialize, fetchDailyStats, fetchKPIs, fetchTrend30Days, fetchTopAgentes, fetchTrendByPeriod, fetchKPIsByPeriod, fetchBIData, fetchRevenueAnalytics } = require('./src/queries');
|
const { fetchTransacoes, fetchAllTransacoes, serialize, fetchDailyStats, fetchKPIs, fetchTrend30Days, fetchTopAgentes, fetchTrendByPeriod, fetchKPIsByPeriod, fetchBIData, fetchRevenueAnalytics, fetchClientList, fetchClientProfile, fetchClientData } = require('./src/queries');
|
||||||
const { buildHTML } = require('./src/dashboard');
|
const { buildHTML } = require('./src/dashboard');
|
||||||
const { buildAdminHTML } = require('./src/admin-panel');
|
const { buildAdminHTML } = require('./src/admin-panel');
|
||||||
const { buildAdminHomeHTML } = require('./src/admin-home');
|
const { buildAdminHomeHTML } = require('./src/admin-home');
|
||||||
const { buildAdminDashboardHTML } = require('./src/admin-dashboard');
|
const { buildAdminDashboardHTML } = require('./src/admin-dashboard');
|
||||||
const { buildAdminBIHTML } = require('./src/admin-bi');
|
const { buildAdminBIHTML } = require('./src/admin-bi');
|
||||||
|
const { buildAdminClienteHTML } = require('./src/admin-cliente');
|
||||||
const bcrypt = require('bcrypt');
|
const bcrypt = require('bcrypt');
|
||||||
const db = require('./src/db-local');
|
const db = require('./src/db-local');
|
||||||
const cache = require('./src/cache');
|
const cache = require('./src/cache');
|
||||||
@@ -369,6 +370,54 @@ app.get('/admin/api/bi/revenue', requireRole('admin'), async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- Admin Cliente Dashboard (admin only) ---
|
||||||
|
app.get('/admin/cliente', requireRole('admin'), (req, res) => {
|
||||||
|
try {
|
||||||
|
res.set('Cache-Control', 'no-store, no-cache, must-revalidate');
|
||||||
|
res.set('Pragma', 'no-cache');
|
||||||
|
const html = buildAdminClienteHTML(req.session.user);
|
||||||
|
res.send(html);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Admin Cliente error:', err);
|
||||||
|
res.status(500).send('Erro ao carregar pagina de cliente: ' + err.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/admin/api/clientes', requireRole('admin'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const data = await cache.getOrFetch('client-list', fetchClientList, 15 * 60 * 1000);
|
||||||
|
res.json(data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Client list API error:', err);
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Client profile API error:', err);
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/admin/api/cliente/:id/data', requireRole('admin'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
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);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Client data API error:', err);
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Create user (admin only)
|
// Create user (admin only)
|
||||||
app.post('/admin/agentes', requireRole('admin'), async (req, res) => {
|
app.post('/admin/agentes', requireRole('admin'), async (req, res) => {
|
||||||
const { nome, email, agente_id, senha, role } = req.body;
|
const { nome, email, agente_id, senha, role } = req.body;
|
||||||
|
|||||||
1276
src/admin-cliente.js
Normal file
1276
src/admin-cliente.js
Normal file
File diff suppressed because it is too large
Load Diff
304
src/queries.js
304
src/queries.js
@@ -653,9 +653,11 @@ async function fetchBIData(dataInicio, dataFim, getAgenteName = null) {
|
|||||||
) curr ON prev.id_conta = curr.id_conta
|
) curr ON prev.id_conta = curr.id_conta
|
||||||
`, [prevStartStr, prevEndStr, prevStartStr, prevEndStr, dataInicio, dataFim, dataInicio, dataFim]);
|
`, [prevStartStr, prevEndStr, prevStartStr, prevEndStr, dataInicio, dataFim, dataInicio, dataFim]);
|
||||||
|
|
||||||
// 11. Clients at risk (last transaction > 30 days ago, had meaningful volume)
|
// 11. Clients at risk (inactive clients with meaningful volume)
|
||||||
const [clientsAtRisk] = await conn.execute(`
|
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 nome, MAX(last_op) as last_op, SUM(vol) as total_usd, SUM(qtd) as total_qtd,
|
||||||
|
DATEDIFF(CURDATE(), MAX(last_op)) as days_inactive
|
||||||
|
FROM (
|
||||||
SELECT c.nome, MAX(t.created_at) as last_op, SUM(t.amount_usd) as vol, COUNT(*) as qtd
|
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
|
FROM br_transaction_to_usa t
|
||||||
INNER JOIN conta c ON c.id_conta = t.id_conta
|
INNER JOIN conta c ON c.id_conta = t.id_conta
|
||||||
@@ -667,8 +669,8 @@ async function fetchBIData(dataInicio, dataFim, getAgenteName = null) {
|
|||||||
GROUP BY c.nome
|
GROUP BY c.nome
|
||||||
) combined
|
) combined
|
||||||
GROUP BY nome
|
GROUP BY nome
|
||||||
HAVING MAX(last_op) < DATE_SUB(CURDATE(), INTERVAL 30 DAY)
|
HAVING MAX(last_op) < CURDATE()
|
||||||
ORDER BY total_usd DESC LIMIT 10
|
ORDER BY total_usd DESC LIMIT 20
|
||||||
`);
|
`);
|
||||||
|
|
||||||
// 12. Agent ranking with spread revenue
|
// 12. Agent ranking with spread revenue
|
||||||
@@ -747,7 +749,8 @@ async function fetchBIData(dataInicio, dataFim, getAgenteName = null) {
|
|||||||
retention: { prev_clients: retPrev, retained: retCurr, rate: retPrev > 0 ? Math.round(retCurr / retPrev * 100) : 0 },
|
retention: { prev_clients: retPrev, retained: retCurr, rate: retPrev > 0 ? Math.round(retCurr / retPrev * 100) : 0 },
|
||||||
clientsAtRisk: clientsAtRisk.map(r => ({
|
clientsAtRisk: clientsAtRisk.map(r => ({
|
||||||
nome: r.nome, vol_usd: Number(r.total_usd), qtd: Number(r.total_qtd),
|
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)
|
last_op: r.last_op instanceof Date ? r.last_op.toISOString().slice(0, 10) : String(r.last_op).slice(0, 16),
|
||||||
|
days_inactive: Number(r.days_inactive) || 0
|
||||||
})),
|
})),
|
||||||
agentRanking: agents,
|
agentRanking: agents,
|
||||||
netting: {
|
netting: {
|
||||||
@@ -952,6 +955,292 @@ async function fetchRevenueAnalytics(dataInicio, dataFim, granularity = 'dia') {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// List clients with at least 1 transaction
|
||||||
|
async function fetchClientList() {
|
||||||
|
const conn = await pool.getConnection();
|
||||||
|
try {
|
||||||
|
const [rows] = await conn.execute(`
|
||||||
|
SELECT c.id_conta, c.nome FROM conta c
|
||||||
|
WHERE c.id_conta IN (
|
||||||
|
SELECT DISTINCT id_conta FROM br_transaction_to_usa
|
||||||
|
UNION SELECT DISTINCT id_conta FROM pagamento_br
|
||||||
|
) ORDER BY c.nome
|
||||||
|
`);
|
||||||
|
return rows.map(r => ({ id: r.id_conta, nome: r.nome }));
|
||||||
|
} finally {
|
||||||
|
conn.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client lifetime profile (no date filter)
|
||||||
|
async function fetchClientProfile(clienteId) {
|
||||||
|
const conn = await pool.getConnection();
|
||||||
|
try {
|
||||||
|
// Name
|
||||||
|
const [conta] = await conn.execute('SELECT nome FROM conta WHERE id_conta = ?', [clienteId]);
|
||||||
|
const nome = conta[0]?.nome || 'Cliente #' + clienteId;
|
||||||
|
|
||||||
|
// BRL->USD lifetime
|
||||||
|
const [brl] = 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,
|
||||||
|
MIN(created_at) as first_op, MAX(created_at) as last_op
|
||||||
|
FROM br_transaction_to_usa WHERE id_conta = ?
|
||||||
|
`, [clienteId]);
|
||||||
|
|
||||||
|
// USD->BRL lifetime
|
||||||
|
const [usd] = 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,
|
||||||
|
MIN(created_at) as first_op, MAX(created_at) as last_op
|
||||||
|
FROM pagamento_br WHERE id_conta = ?
|
||||||
|
AND cotacao IS NOT NULL AND cotacao > 0
|
||||||
|
AND (pgto IS NULL OR pgto != 'balance')
|
||||||
|
`, [clienteId]);
|
||||||
|
|
||||||
|
const brlData = brl[0] || {};
|
||||||
|
const usdData = usd[0] || {};
|
||||||
|
|
||||||
|
const dates = [brlData.first_op, usdData.first_op].filter(Boolean);
|
||||||
|
const lastDates = [brlData.last_op, usdData.last_op].filter(Boolean);
|
||||||
|
const firstOp = dates.length > 0 ? new Date(Math.min(...dates.map(d => new Date(d).getTime()))) : null;
|
||||||
|
const lastOp = lastDates.length > 0 ? new Date(Math.max(...lastDates.map(d => new Date(d).getTime()))) : null;
|
||||||
|
const daysInactive = lastOp ? Math.round((Date.now() - lastOp.getTime()) / 86400000) : null;
|
||||||
|
|
||||||
|
const brlQtd = Number(brlData.qtd) || 0;
|
||||||
|
const usdQtd = Number(usdData.qtd) || 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: clienteId,
|
||||||
|
nome,
|
||||||
|
first_op: firstOp ? firstOp.toISOString().slice(0, 10) : null,
|
||||||
|
last_op: lastOp ? lastOp.toISOString().slice(0, 10) : null,
|
||||||
|
days_inactive: daysInactive,
|
||||||
|
total_ops: brlQtd + usdQtd,
|
||||||
|
total_vol_usd: (Number(brlData.vol_usd) || 0) + (Number(usdData.vol_usd) || 0),
|
||||||
|
total_vol_brl: (Number(brlData.vol_brl) || 0) + (Number(usdData.vol_brl) || 0),
|
||||||
|
total_spread_revenue: (Number(brlData.spread_revenue) || 0) + (Number(usdData.spread_revenue) || 0),
|
||||||
|
brlUsd: { qtd: brlQtd, vol_usd: Number(brlData.vol_usd) || 0 },
|
||||||
|
usdBrl: { qtd: usdQtd, vol_usd: Number(usdData.vol_usd) || 0 }
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
conn.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client data for a period — full analytics
|
||||||
|
async function fetchClientData(clienteId, 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 BRL->USD
|
||||||
|
const [kpiBrl] = 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
|
||||||
|
FROM br_transaction_to_usa WHERE id_conta = ? AND DATE(created_at) >= ? AND DATE(created_at) <= ?
|
||||||
|
`, [clienteId, dataInicio, dataFim]);
|
||||||
|
|
||||||
|
// KPIs USD->BRL
|
||||||
|
const [kpiUsd] = 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
|
||||||
|
FROM pagamento_br WHERE id_conta = ? AND DATE(created_at) >= ? AND DATE(created_at) <= ?
|
||||||
|
AND cotacao IS NOT NULL AND cotacao > 0 AND (pgto IS NULL OR pgto != 'balance')
|
||||||
|
`, [clienteId, dataInicio, dataFim]);
|
||||||
|
|
||||||
|
// Previous period
|
||||||
|
const [prevBrl] = 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 id_conta = ? AND DATE(created_at) >= ? AND DATE(created_at) <= ?
|
||||||
|
`, [clienteId, prevStartStr, prevEndStr]);
|
||||||
|
const [prevUsd] = 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 id_conta = ? AND DATE(created_at) >= ? AND DATE(created_at) <= ?
|
||||||
|
AND cotacao IS NOT NULL AND cotacao > 0 AND (pgto IS NULL OR pgto != 'balance')
|
||||||
|
`, [clienteId, prevStartStr, prevEndStr]);
|
||||||
|
|
||||||
|
// Trend BRL->USD
|
||||||
|
const [trendBrl] = 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 id_conta = ? AND DATE(created_at) >= ? AND DATE(created_at) <= ?
|
||||||
|
GROUP BY DATE(created_at) ORDER BY dia
|
||||||
|
`, [clienteId, dataInicio, dataFim]);
|
||||||
|
|
||||||
|
// Trend USD->BRL
|
||||||
|
const [trendUsd] = 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 id_conta = ? AND 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
|
||||||
|
`, [clienteId, dataInicio, dataFim]);
|
||||||
|
|
||||||
|
// Individual transactions BRL->USD
|
||||||
|
const [txBrl] = await conn.execute(`
|
||||||
|
SELECT t.created_at as date, 'BRL→USD' as flow,
|
||||||
|
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
|
||||||
|
FROM br_transaction_to_usa t
|
||||||
|
LEFT JOIN br_payment_methods pm ON t.payment_method_id = pm.id
|
||||||
|
WHERE t.id_conta = ? AND DATE(t.created_at) >= ? AND DATE(t.created_at) <= ?
|
||||||
|
ORDER BY t.created_at DESC
|
||||||
|
`, [clienteId, dataInicio, dataFim]);
|
||||||
|
|
||||||
|
// Individual transactions USD->BRL
|
||||||
|
const [txUsd] = await conn.execute(`
|
||||||
|
SELECT p.created_at as date, 'USD→BRL' as flow,
|
||||||
|
p.valor as usd, ROUND(p.valor * p.cotacao, 2) as brl,
|
||||||
|
ROUND(p.cotacao,4) as rate, ROUND(p.ptax,4) as ptax,
|
||||||
|
CASE WHEN p.cotacao > 0 THEN ROUND((p.ptax - p.cotacao) / p.ptax * 100, 2) ELSE 0 END as spread_pct,
|
||||||
|
0 as iof, COALESCE(p.pgto, '') as status,
|
||||||
|
COALESCE(p.tipo_envio, '') as provider
|
||||||
|
FROM pagamento_br p
|
||||||
|
WHERE p.id_conta = ? AND 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')
|
||||||
|
ORDER BY p.created_at DESC
|
||||||
|
`, [clienteId, dataInicio, dataFim]);
|
||||||
|
|
||||||
|
// Day of week
|
||||||
|
const [dowBrl] = await conn.execute(`
|
||||||
|
SELECT DAYOFWEEK(created_at) as dow, COUNT(*) as qtd, ROUND(SUM(amount_usd),2) as vol_usd
|
||||||
|
FROM br_transaction_to_usa WHERE id_conta = ? AND DATE(created_at) >= ? AND DATE(created_at) <= ?
|
||||||
|
GROUP BY DAYOFWEEK(created_at)
|
||||||
|
`, [clienteId, dataInicio, dataFim]);
|
||||||
|
const [dowUsd] = await conn.execute(`
|
||||||
|
SELECT DAYOFWEEK(created_at) as dow, COUNT(*) as qtd, ROUND(SUM(valor),2) as vol_usd
|
||||||
|
FROM pagamento_br WHERE id_conta = ? AND DATE(created_at) >= ? AND DATE(created_at) <= ?
|
||||||
|
AND cotacao IS NOT NULL AND cotacao > 0 AND (pgto IS NULL OR pgto != 'balance')
|
||||||
|
GROUP BY DAYOFWEEK(created_at)
|
||||||
|
`, [clienteId, dataInicio, dataFim]);
|
||||||
|
|
||||||
|
// Providers
|
||||||
|
const [provBrl] = await conn.execute(`
|
||||||
|
SELECT COALESCE(pm.provider, 'N/A') as name, COUNT(*) as qtd, ROUND(SUM(t.amount_usd),2) as vol_usd
|
||||||
|
FROM br_transaction_to_usa t
|
||||||
|
LEFT JOIN br_payment_methods pm ON t.payment_method_id = pm.id
|
||||||
|
WHERE t.id_conta = ? AND DATE(t.created_at) >= ? AND DATE(t.created_at) <= ?
|
||||||
|
GROUP BY pm.provider
|
||||||
|
`, [clienteId, dataInicio, dataFim]);
|
||||||
|
const [provUsd] = await conn.execute(`
|
||||||
|
SELECT COALESCE(p.tipo_envio, 'N/A') as name, COUNT(*) as qtd, ROUND(SUM(p.valor),2) as vol_usd
|
||||||
|
FROM pagamento_br p
|
||||||
|
WHERE p.id_conta = ? AND 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 p.tipo_envio
|
||||||
|
`, [clienteId, dataInicio, dataFim]);
|
||||||
|
|
||||||
|
// Monthly average
|
||||||
|
const [monthlyBrl] = await conn.execute(`
|
||||||
|
SELECT DATE_FORMAT(created_at, '%Y-%m') as mes, ROUND(AVG(amount_usd),2) as avg_usd, COUNT(*) as qtd
|
||||||
|
FROM br_transaction_to_usa WHERE id_conta = ? AND DATE(created_at) >= ? AND DATE(created_at) <= ?
|
||||||
|
GROUP BY DATE_FORMAT(created_at, '%Y-%m') ORDER BY mes
|
||||||
|
`, [clienteId, dataInicio, dataFim]);
|
||||||
|
const [monthlyUsd] = await conn.execute(`
|
||||||
|
SELECT DATE_FORMAT(created_at, '%Y-%m') as mes, ROUND(AVG(valor),2) as avg_usd, COUNT(*) as qtd
|
||||||
|
FROM pagamento_br WHERE id_conta = ? AND 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_FORMAT(created_at, '%Y-%m') ORDER BY mes
|
||||||
|
`, [clienteId, dataInicio, dataFim]);
|
||||||
|
|
||||||
|
// Format helpers
|
||||||
|
const fmtD = (d) => d instanceof Date ? d.toISOString().slice(0, 10) : String(d).slice(0, 10);
|
||||||
|
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 fmtTrend = (rows) => rows.map(r => ({
|
||||||
|
dia: fmtD(r.dia), qtd: Number(r.qtd), vol_usd: Number(r.vol_usd), avg_spread: Number(r.avg_spread) || 0
|
||||||
|
}));
|
||||||
|
|
||||||
|
const b = kpiBrl[0] || {};
|
||||||
|
const u = kpiUsd[0] || {};
|
||||||
|
const bQtd = Number(b.qtd) || 0;
|
||||||
|
const uQtd = Number(u.qtd) || 0;
|
||||||
|
const totalQtd = bQtd + uQtd;
|
||||||
|
const totalVolUsd = (Number(b.vol_usd) || 0) + (Number(u.vol_usd) || 0);
|
||||||
|
|
||||||
|
// Merge day of week
|
||||||
|
const dowMap = {};
|
||||||
|
for (let i = 1; i <= 7; i++) dowMap[i] = { qtd: 0, vol_usd: 0 };
|
||||||
|
dowBrl.forEach(r => { dowMap[r.dow].qtd += Number(r.qtd); dowMap[r.dow].vol_usd += Number(r.vol_usd); });
|
||||||
|
dowUsd.forEach(r => { dowMap[r.dow].qtd += Number(r.qtd); dowMap[r.dow].vol_usd += Number(r.vol_usd); });
|
||||||
|
|
||||||
|
// Merge providers
|
||||||
|
const provMap = {};
|
||||||
|
[...provBrl, ...provUsd].forEach(r => {
|
||||||
|
const n = r.name || 'N/A';
|
||||||
|
if (!provMap[n]) provMap[n] = { name: n, qtd: 0, vol_usd: 0 };
|
||||||
|
provMap[n].qtd += Number(r.qtd);
|
||||||
|
provMap[n].vol_usd += Number(r.vol_usd);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Merge monthly avg
|
||||||
|
const monthMap = {};
|
||||||
|
[...monthlyBrl, ...monthlyUsd].forEach(r => {
|
||||||
|
if (!monthMap[r.mes]) monthMap[r.mes] = { mes: r.mes, total_usd: 0, total_qtd: 0 };
|
||||||
|
monthMap[r.mes].total_usd += Number(r.avg_usd) * Number(r.qtd);
|
||||||
|
monthMap[r.mes].total_qtd += Number(r.qtd);
|
||||||
|
});
|
||||||
|
|
||||||
|
const transactions = [
|
||||||
|
...txBrl.map(r => ({
|
||||||
|
date: fmtDT(r.date), flow: r.flow, 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
|
||||||
|
})),
|
||||||
|
...txUsd.map(r => ({
|
||||||
|
date: fmtDT(r.date), flow: r.flow, 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
|
||||||
|
}))
|
||||||
|
].sort((a, b) => b.date.localeCompare(a.date));
|
||||||
|
|
||||||
|
return {
|
||||||
|
kpis: {
|
||||||
|
brlUsd: { qtd: bQtd, vol_usd: Number(b.vol_usd)||0, vol_brl: Number(b.vol_brl)||0, spread_revenue: Number(b.spread_revenue)||0, avg_spread_pct: Number(b.avg_spread_pct)||0, ticket_medio: bQtd > 0 ? Math.round((Number(b.vol_usd)||0) / bQtd) : 0 },
|
||||||
|
usdBrl: { qtd: uQtd, vol_usd: Number(u.vol_usd)||0, vol_brl: Number(u.vol_brl)||0, spread_revenue: Number(u.spread_revenue)||0, avg_spread_pct: Number(u.avg_spread_pct)||0, ticket_medio: uQtd > 0 ? Math.round((Number(u.vol_usd)||0) / uQtd) : 0 },
|
||||||
|
total: {
|
||||||
|
qtd: totalQtd, vol_usd: totalVolUsd, vol_brl: (Number(b.vol_brl)||0) + (Number(u.vol_brl)||0),
|
||||||
|
spread_revenue: (Number(b.spread_revenue)||0) + (Number(u.spread_revenue)||0),
|
||||||
|
avg_spread_pct: totalQtd > 0 ? ((Number(b.avg_spread_pct)||0) * bQtd + (Number(u.avg_spread_pct)||0) * uQtd) / totalQtd : 0,
|
||||||
|
ticket_medio: totalQtd > 0 ? Math.round(totalVolUsd / totalQtd) : 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
comparison: {
|
||||||
|
prev_qtd: (Number(prevBrl[0]?.qtd)||0) + (Number(prevUsd[0]?.qtd)||0),
|
||||||
|
prev_vol_usd: (Number(prevBrl[0]?.vol_usd)||0) + (Number(prevUsd[0]?.vol_usd)||0),
|
||||||
|
prev_spread: (Number(prevBrl[0]?.spread_revenue)||0) + (Number(prevUsd[0]?.spread_revenue)||0)
|
||||||
|
},
|
||||||
|
trend: { brlUsd: fmtTrend(trendBrl), usdBrl: fmtTrend(trendUsd) },
|
||||||
|
transactions,
|
||||||
|
dayOfWeek: dowMap,
|
||||||
|
providers: Object.values(provMap).sort((a, b) => b.vol_usd - a.vol_usd),
|
||||||
|
monthlyAvg: Object.values(monthMap).map(m => ({ mes: m.mes, avg_usd: m.total_qtd > 0 ? Math.round(m.total_usd / m.total_qtd) : 0, qtd: m.total_qtd })).sort((a, b) => a.mes.localeCompare(b.mes))
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
conn.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
fetchTransacoes,
|
fetchTransacoes,
|
||||||
fetchAllTransacoes,
|
fetchAllTransacoes,
|
||||||
@@ -963,5 +1252,8 @@ module.exports = {
|
|||||||
fetchTrendByPeriod,
|
fetchTrendByPeriod,
|
||||||
fetchKPIsByPeriod,
|
fetchKPIsByPeriod,
|
||||||
fetchBIData,
|
fetchBIData,
|
||||||
fetchRevenueAnalytics
|
fetchRevenueAnalytics,
|
||||||
|
fetchClientList,
|
||||||
|
fetchClientProfile,
|
||||||
|
fetchClientData
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -366,6 +366,7 @@ function buildHeader(options = {}) {
|
|||||||
<nav class="header-nav">
|
<nav class="header-nav">
|
||||||
<a href="/corporate" class="${activePage === 'dashboard' ? 'active' : ''}">Corporate</a>
|
<a href="/corporate" class="${activePage === 'dashboard' ? 'active' : ''}">Corporate</a>
|
||||||
<a href="/admin/bi" class="${activePage === 'bi' ? 'active' : ''}">BI Executive</a>
|
<a href="/admin/bi" class="${activePage === 'bi' ? 'active' : ''}">BI Executive</a>
|
||||||
|
<a href="/admin/cliente" class="${activePage === 'cliente' ? 'active' : ''}">Clientes</a>
|
||||||
<a href="/admin" class="${activePage === 'users' ? 'active' : ''}">Usuarios</a>
|
<a href="/admin" class="${activePage === 'users' ? 'active' : ''}">Usuarios</a>
|
||||||
</nav>
|
</nav>
|
||||||
`;
|
`;
|
||||||
@@ -459,6 +460,7 @@ const themeScript = `
|
|||||||
localStorage.setItem('bi-theme', next);
|
localStorage.setItem('bi-theme', next);
|
||||||
var icon = document.getElementById('themeIcon');
|
var icon = document.getElementById('themeIcon');
|
||||||
if (icon) icon.textContent = next === 'dark' ? '\\u2600' : '\\u263E';
|
if (icon) icon.textContent = next === 'dark' ? '\\u2600' : '\\u263E';
|
||||||
|
window.dispatchEvent(new CustomEvent('themechange', {detail:{theme:next}}));
|
||||||
}
|
}
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
var icon = document.getElementById('themeIcon');
|
var icon = document.getElementById('themeIcon');
|
||||||
|
|||||||
Reference in New Issue
Block a user