feat: trading terminal live rates + fix spread negativo + fix USD→BRL

- Adiciona widget de cotações ao vivo (USD/BRL e EUR/BRL) com design
  estilo terminal de trading (dark theme, tipografia mono, glow effects)
- Proxy server-side /api/cotacao com cache 3s e token AwesomeAPI
- Auto-refresh a cada 3 segundos apenas quando a página está aberta
- Corrige cálculo de spread negativo: remove Math.abs() em USD→BRL
  e Math.max(0,...) no spread líquido
- Corrige seção USD→BRL que não aparecia (filtro status !== 'finalizado')
- Corrige valor_reais no fluxo USD→BRL: agora calcula valor * cotação
- Adiciona classe CSS spread-negative para destacar spreads negativos
- Bandeiras de fluxo (BR/US/EU) nos botões de compra e venda

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
root
2026-02-10 22:30:43 -05:00
parent 1ad28f54dd
commit 7ee15ad5e5
12 changed files with 1285 additions and 436 deletions

View File

@@ -11,6 +11,7 @@ services:
# Netbird VPN Setup Key
- NETBIRD_SETUP_KEY=${NETBIRD_SETUP_KEY:-14A782C8-24D2-46A9-B427-A422854E9B50}
- NETBIRD_MANAGEMENT_URL=${NETBIRD_MANAGEMENT_URL:-https://netbird.cambioreal.com}
- NETBIRD_HOSTNAME=${NETBIRD_HOSTNAME:-bi-ccc}
# MySQL RDS Connection (via Netbird)
- MYSQL_URL=${MYSQL_URL}
@@ -20,9 +21,12 @@ services:
# App Config
- SESSION_SECRET=${SESSION_SECRET:-bi-agentes-secret-key-change-me}
- PORT=3080
- AWESOME_API_TOKEN=${AWESOME_API_TOKEN:-2dbcf6a26f9bd9016859a2d31f99fbd8fc9ac4e9e8e440d94002ac3b436a747a}
volumes:
# Persist SQLite database
- ./data:/app/data
# Persist NetBird config (keeps same peer/IP between restarts)
- ./netbird:/var/lib/netbird
cap_add:
# Required for Netbird VPN
- NET_ADMIN

View File

@@ -16,7 +16,9 @@ if [ -n "$NETBIRD_SETUP_KEY" ]; then
sleep 3
# Connect using setup key and management URL
netbird up --setup-key "$NETBIRD_SETUP_KEY" --management-url "$MGMT_URL" &
# Use custom hostname for stable DNS name (default: bi-ccc)
NETBIRD_HOSTNAME="${NETBIRD_HOSTNAME:-bi-ccc}"
netbird up --setup-key "$NETBIRD_SETUP_KEY" --management-url "$MGMT_URL" --hostname "$NETBIRD_HOSTNAME" &
# Wait for connection
echo "Waiting for Netbird connection..."
@@ -24,6 +26,11 @@ if [ -n "$NETBIRD_SETUP_KEY" ]; then
# Check connection status
netbird status || echo "Netbird connection pending..."
# Add route for local network to bypass VPN tunnel
# This ensures responses to LAN clients go via Docker bridge, not VPN
echo "Adding local network route..."
ip route add 192.168.88.0/24 via 172.23.0.1 dev eth0 2>/dev/null || true
else
echo "WARNING: NETBIRD_SETUP_KEY not set. Database connection may fail."
fi

192
public/admin-login.html Normal file
View File

@@ -0,0 +1,192 @@
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>BI Agentes - Admin Login</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<style>
:root {
--primary: #6C3FA0;
--primary-light: #8B5FBF;
--primary-dark: #4A2570;
--bg: #F0F2F5;
--card: #FFFFFF;
--text: #1A1D23;
--text-secondary: #5F6368;
--border: #E8EAED;
--error: #D93025;
--error-bg: #FDE7E7;
--admin-accent: #2E7D32;
--admin-bg: #E8F5E9;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
background: var(--bg);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
-webkit-font-smoothing: antialiased;
}
.login-container {
width: 100%;
max-width: 400px;
padding: 20px;
}
.login-card {
background: var(--card);
border-radius: 16px;
padding: 40px 36px;
box-shadow: 0 4px 24px rgba(0,0,0,0.08);
border: 1px solid var(--border);
}
.login-header {
text-align: center;
margin-bottom: 32px;
}
.login-header .logo {
width: 56px; height: 56px;
background: linear-gradient(135deg, var(--admin-accent) 0%, #1B5E20 100%);
border-radius: 14px;
display: flex; align-items: center; justify-content: center;
margin: 0 auto 16px;
font-size: 28px;
color: white;
}
.login-header h1 {
font-size: 22px;
font-weight: 800;
color: var(--text);
letter-spacing: -0.5px;
}
.login-header .admin-badge {
display: inline-block;
background: var(--admin-bg);
color: var(--admin-accent);
padding: 4px 12px;
border-radius: 12px;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-top: 8px;
}
.login-header p {
font-size: 13px;
color: var(--text-secondary);
margin-top: 10px;
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.3px;
margin-bottom: 6px;
}
.form-group input {
width: 100%;
padding: 12px 16px;
border: 1.5px solid var(--border);
border-radius: 10px;
font-size: 14px;
font-family: inherit;
color: var(--text);
transition: all 0.15s;
background: white;
}
.form-group input:focus {
outline: none;
border-color: var(--admin-accent);
box-shadow: 0 0 0 3px rgba(46,125,50,0.12);
}
.btn-login {
width: 100%;
padding: 13px;
background: linear-gradient(135deg, var(--admin-accent) 0%, #1B5E20 100%);
color: white;
border: none;
border-radius: 10px;
font-size: 14px;
font-weight: 700;
font-family: inherit;
cursor: pointer;
transition: all 0.15s;
box-shadow: 0 2px 8px rgba(46,125,50,0.3);
margin-top: 4px;
}
.btn-login:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(46,125,50,0.4);
}
.btn-login:active { transform: translateY(0); }
.error-msg {
background: var(--error-bg);
color: var(--error);
padding: 10px 14px;
border-radius: 8px;
font-size: 13px;
font-weight: 500;
margin-bottom: 20px;
display: none;
}
.footer {
text-align: center;
margin-top: 24px;
font-size: 12px;
color: var(--text-secondary);
}
.footer a {
color: var(--primary);
text-decoration: none;
}
.footer a:hover {
text-decoration: underline;
}
</style>
</head>
<body>
<div class="login-container">
<div class="login-card">
<div class="login-header">
<div class="logo">&#x2699;</div>
<h1>BI Agentes</h1>
<span class="admin-badge">Administrador</span>
<p>Painel de Gerenciamento de Agentes</p>
</div>
<div class="error-msg" id="errorMsg"></div>
<form id="loginForm" method="POST" action="/admin/login">
<div class="form-group">
<label>E-mail</label>
<input type="email" name="email" id="email" required placeholder="admin@email.com" autocomplete="email">
</div>
<div class="form-group">
<label>Senha</label>
<input type="password" name="senha" id="senha" required placeholder="Digite sua senha" autocomplete="current-password">
</div>
<button type="submit" class="btn-login">Entrar como Admin</button>
</form>
</div>
<div class="footer">
<a href="/login">Voltar para login de agentes</a>
<br><br>
CambioReal &copy; 2026
</div>
</div>
<script>
const params = new URLSearchParams(window.location.search);
if (params.get('error')) {
const el = document.getElementById('errorMsg');
el.textContent = 'E-mail ou senha incorretos.';
el.style.display = 'block';
}
document.getElementById('email').focus();
</script>
</body>
</html>

View File

@@ -136,6 +136,22 @@
font-size: 12px;
color: var(--text-secondary);
}
/* Mobile responsive */
@media (max-width: 480px) {
.login-container { padding: 16px; }
.login-card { padding: 28px 24px; border-radius: 12px; }
.login-header { margin-bottom: 24px; }
.login-header .logo { width: 140px; margin-bottom: 16px; }
.login-header .app-name { font-size: 16px; padding: 6px 16px; }
.login-header .subtitle { font-size: 10px; letter-spacing: 1.5px; }
.form-group { margin-bottom: 16px; }
.form-group label { font-size: 11px; }
.form-group input { padding: 11px 14px; font-size: 14px; border-radius: 8px; }
.btn-login { padding: 12px; font-size: 14px; border-radius: 8px; }
.error-msg { font-size: 12px; padding: 10px 12px; }
.footer { margin-top: 20px; font-size: 11px; }
}
</style>
</head>
<body>

195
server.js
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 } = require('./src/queries');
const { fetchTransacoes, fetchAllTransacoes, serialize, fetchDailyStats, fetchKPIs, fetchTrend30Days, fetchTopAgentes, fetchTrendByPeriod, fetchKPIsByPeriod } = require('./src/queries');
const { buildHTML } = require('./src/dashboard');
const { buildAdminHTML } = require('./src/admin-panel');
const { buildAdminHomeHTML } = require('./src/admin-home');
@@ -38,10 +38,17 @@ app.use('/public', express.static(path.join(__dirname, 'public')));
// --- Unified Login Routes ---
// Helper function to get redirect URL based on role
function getRedirectByRole(role) {
if (role === 'admin') return '/corporate'; // Admin vai direto pro Corporate Dashboard
if (role === 'corporate') return '/corporate';
return '/dashboard';
}
// Root -> login page (or redirect if logged in)
app.get('/', (req, res) => {
if (req.session?.user) {
return res.redirect(req.session.user.role === 'admin' ? '/admin' : '/dashboard');
return res.redirect(getRedirectByRole(req.session.user.role));
}
res.redirect('/login');
});
@@ -49,7 +56,7 @@ app.get('/', (req, res) => {
// Login page
app.get('/login', (req, res) => {
if (req.session?.user) {
return res.redirect(req.session.user.role === 'admin' ? '/admin' : '/dashboard');
return res.redirect(getRedirectByRole(req.session.user.role));
}
res.sendFile(path.join(__dirname, 'public', 'login.html'));
});
@@ -72,11 +79,7 @@ app.post('/login', async (req, res) => {
};
// Redirect based on role
if (user.role === 'admin') {
res.redirect('/admin');
} else {
res.redirect('/dashboard');
}
res.redirect(getRedirectByRole(user.role));
} catch (err) {
console.error('Login error:', err);
res.redirect(`/login?error=1&email=${emailParam}`);
@@ -114,22 +117,10 @@ app.get('/dashboard', requireRole('agente'), async (req, res) => {
}
});
// --- Admin Routes ---
// --- Admin Routes (User Management - admin only) ---
// Admin home (admin only) - Fast daily overview
app.get('/admin', requireRole('admin'), async (req, res) => {
try {
const stats = await fetchDailyStats();
const html = buildAdminHomeHTML(stats, req.session.user);
res.send(html);
} catch (err) {
console.error('Admin home error:', err);
res.status(500).send('Erro ao carregar home admin: ' + err.message);
}
});
// Admin agents management (admin only)
app.get('/admin/agentes', requireRole('admin'), (req, res) => {
// Admin home - User management panel (admin only)
app.get('/admin', requireRole('admin'), (req, res) => {
try {
const agentes = db.prepare('SELECT * FROM agentes ORDER BY id DESC').all();
const html = buildAdminHTML(agentes, req.session.user);
@@ -140,33 +131,101 @@ app.get('/admin/agentes', requireRole('admin'), (req, res) => {
}
});
// Admin Dashboard - KPIs, Tendências e Ranking (com lazy load)
app.get('/admin/dashboard', requireRole('admin'), async (req, res) => {
// Alias: /admin/usuarios -> /admin
app.get('/admin/usuarios', requireRole('admin'), (req, res) => {
res.redirect('/admin');
});
// Legacy route - redirect to /admin
app.get('/admin/agentes', requireRole('admin'), (req, res) => {
res.redirect('/admin');
});
// --- Corporate Routes (Dashboard + Emulation - corporate and admin) ---
// Corporate Dashboard - Full KPIs, Trends and Ranking
app.get('/corporate', requireRole('corporate', 'admin'), async (req, res) => {
try {
const user = req.session.user;
const html = buildAdminDashboardHTML({ nome: user.nome, email: user.email });
const html = buildAdminDashboardHTML(user);
res.send(html);
} catch (err) {
console.error('Admin dashboard error:', err);
res.status(500).send('Erro ao carregar dashboard admin: ' + err.message);
console.error('Corporate dashboard error:', err);
res.status(500).send('Erro ao carregar dashboard corporate: ' + err.message);
}
});
// API endpoint for admin dashboard data (admin only)
app.get('/admin/api/data', requireRole('admin'), async (req, res) => {
// Legacy route - redirect to /corporate
app.get('/corporate/dashboard', requireRole('corporate', 'admin'), (req, res) => {
res.redirect('/corporate');
});
// Corporate emulate agent - view dashboard as specific agent
app.get('/corporate/emular/:agente_id', requireRole('corporate', 'admin'), async (req, res) => {
try {
const agenteId = parseInt(req.params.agente_id);
const agente = db.prepare('SELECT * FROM agentes WHERE agente_id = ?').get(agenteId);
if (!agente) {
return res.status(404).send('Agente nao encontrado');
}
const { rowsBrlUsd, rowsUsdBrl } = await fetchTransacoes(agenteId);
const data = serialize(rowsBrlUsd, rowsUsdBrl);
const html = buildHTML(data, {
nome: agente.nome + ' (Emulando)',
agente_id: agenteId,
email: agente.email,
emulatorRole: req.session.user.role // Pass the emulator's role
}, true, null, false, true); // isEmulating = true
res.send(html);
} catch (err) {
console.error('Corporate emulate error:', err);
res.status(500).send('Erro ao emular agente: ' + err.message);
}
});
// Legacy route - redirect to /corporate/emular
app.get('/admin/emular/:agente_id', requireRole('admin'), (req, res) => {
res.redirect(`/corporate/emular/${req.params.agente_id}`);
});
// --- Live Rate Proxy (caches for 3s to avoid rate limiting) ---
let _rateCache = { data: null, ts: 0 };
app.get('/api/cotacao', async (req, res) => {
try {
const now = Date.now();
if (_rateCache.data && now - _rateCache.ts < 3000) {
return res.json(_rateCache.data);
}
const token = process.env.AWESOME_API_TOKEN || '';
const url = 'https://economia.awesomeapi.com.br/json/last/USD-BRL,EUR-BRL' + (token ? '?token=' + token : '');
const resp = await fetch(url);
const json = await resp.json();
_rateCache = { data: json, ts: now };
res.json(json);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// --- Corporate API Routes (dashboard data - corporate and admin) ---
// API endpoint for corporate dashboard data
app.get('/corporate/api/data', requireRole('corporate', 'admin'), async (req, res) => {
try {
const dias = parseInt(req.query.dias) || 90;
const { rowsBrlUsd, rowsUsdBrl } = await fetchAllTransacoes(dias);
const data = serialize(rowsBrlUsd, rowsUsdBrl);
res.json({ success: true, data, count: data.length });
} catch (err) {
console.error('Admin API error:', err);
console.error('Corporate API error:', err);
res.status(500).json({ success: false, error: err.message });
}
});
// API: KPIs (hoje vs média 30 dias) - com cache
app.get('/admin/api/kpis', requireRole('admin'), async (req, res) => {
app.get('/corporate/api/kpis', requireRole('corporate', 'admin'), async (req, res) => {
try {
const data = await cache.getOrFetch('kpis', fetchKPIs, 5 * 60 * 1000);
res.json({ success: true, data });
@@ -177,7 +236,7 @@ app.get('/admin/api/kpis', requireRole('admin'), async (req, res) => {
});
// API: Tendência 30 dias - com cache
app.get('/admin/api/trend', requireRole('admin'), async (req, res) => {
app.get('/corporate/api/trend', requireRole('corporate', 'admin'), async (req, res) => {
try {
const data = await cache.getOrFetch('trend30', fetchTrend30Days, 10 * 60 * 1000);
res.json({ success: true, data });
@@ -188,7 +247,7 @@ app.get('/admin/api/trend', requireRole('admin'), async (req, res) => {
});
// API: Top 5 agentes - com cache por período
app.get('/admin/api/top-agentes', requireRole('admin'), async (req, res) => {
app.get('/corporate/api/top-agentes', requireRole('corporate', 'admin'), async (req, res) => {
try {
const dias = parseInt(req.query.dias) || 30;
const cacheKey = `top-agentes-${dias}`;
@@ -212,30 +271,59 @@ app.get('/admin/api/top-agentes', requireRole('admin'), async (req, res) => {
}
});
// Admin emulate agent - view dashboard as specific agent (admin only)
app.get('/admin/emular/:agente_id', requireRole('admin'), async (req, res) => {
// API: Corporate Dashboard - KPIs por período
app.get('/corporate/api/kpis-period', requireRole('corporate', 'admin'), async (req, res) => {
try {
const agenteId = parseInt(req.params.agente_id);
const agente = db.prepare('SELECT * FROM agentes WHERE agente_id = ?').get(agenteId);
if (!agente) {
return res.status(404).send('Agente nao encontrado');
const { inicio, fim } = req.query;
if (!inicio || !fim) {
return res.status(400).json({ success: false, error: 'Parametros inicio e fim sao obrigatorios' });
}
const { rowsBrlUsd, rowsUsdBrl } = await fetchTransacoes(agenteId);
const data = serialize(rowsBrlUsd, rowsUsdBrl);
const html = buildHTML(data, {
nome: agente.nome + ' (Emulando)',
agente_id: agenteId,
email: agente.email
}, true, null, false, true); // isEmulating = true
res.send(html);
const data = await fetchKPIsByPeriod(inicio, fim);
res.json({ success: true, data });
} catch (err) {
console.error('Admin emulate error:', err);
res.status(500).send('Erro ao emular agente: ' + err.message);
console.error('Corporate KPIs API error:', err);
res.status(500).json({ success: false, error: err.message });
}
});
// API: Corporate Dashboard - Tendência por período
app.get('/corporate/api/trend-period', requireRole('corporate', 'admin'), async (req, res) => {
try {
const { inicio, fim } = req.query;
if (!inicio || !fim) {
return res.status(400).json({ success: false, error: 'Parametros inicio e fim sao obrigatorios' });
}
const data = await fetchTrendByPeriod(inicio, fim);
res.json({ success: true, data });
} catch (err) {
console.error('Corporate Trend API error:', err);
res.status(500).json({ success: false, error: err.message });
}
});
// Legacy API routes - redirect to /corporate/api/*
app.get('/admin/api/data', requireRole('admin'), (req, res) => {
res.redirect(`/corporate/api/data?${new URLSearchParams(req.query)}`);
});
app.get('/admin/api/kpis', requireRole('admin'), (req, res) => {
res.redirect('/corporate/api/kpis');
});
app.get('/admin/api/trend', requireRole('admin'), (req, res) => {
res.redirect('/corporate/api/trend');
});
app.get('/admin/api/top-agentes', requireRole('admin'), (req, res) => {
res.redirect(`/corporate/api/top-agentes?${new URLSearchParams(req.query)}`);
});
app.get('/admin/api/corporate/kpis', requireRole('admin'), (req, res) => {
res.redirect(`/corporate/api/kpis-period?${new URLSearchParams(req.query)}`);
});
app.get('/admin/api/corporate/trend', requireRole('admin'), (req, res) => {
res.redirect(`/corporate/api/trend-period?${new URLSearchParams(req.query)}`);
});
app.get('/admin/dashboard', requireRole('admin'), (req, res) => {
res.redirect('/corporate/dashboard');
});
// Create user (admin only)
app.post('/admin/agentes', requireRole('admin'), async (req, res) => {
const { nome, email, agente_id, senha, role } = req.body;
@@ -245,7 +333,8 @@ app.post('/admin/agentes', requireRole('admin'), async (req, res) => {
}
const userRole = role || 'agente';
const agenteId = userRole === 'admin' ? 0 : (agente_id || 0);
// Admin and Corporate don't need agente_id
const agenteId = (userRole === 'admin' || userRole === 'corporate') ? 0 : (agente_id || 0);
if (userRole === 'agente' && !agente_id) {
return res.status(400).json({ error: 'Agente ID e obrigatorio para agentes' });

View File

@@ -1,13 +1,110 @@
/**
* Admin Dashboard - KPIs, Tendências e Ranking
* Lazy loading para performance
* Admin Dashboard Corporate - KPIs, Tendências e Detalhes
* Filtros por período: Este Mês, Mês Anterior, Últimos 2 Meses, ou período customizado
*/
const { buildHeader, buildFooter, buildHead } = require('./ui-template');
function buildAdminDashboardHTML(admin) {
const pageScripts = `<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>`;
function buildAdminDashboardHTML(user) {
// Support both admin and corporate roles
const role = user.role || 'corporate';
const pageScripts = '<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"><\/script>';
// Calculate default dates (current month)
const now = new Date();
const firstDayOfMonth = new Date(now.getFullYear(), now.getMonth(), 1).toISOString().slice(0, 10);
const today = now.toISOString().slice(0, 10);
const pageCSS = `
/* Filter Bar */
.filter-bar {
background: var(--card);
border-radius: 16px;
padding: 20px 24px;
border: 1px solid var(--border);
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
margin-bottom: 24px;
display: flex;
align-items: center;
gap: 16px;
flex-wrap: wrap;
}
.filter-bar-label {
font-size: 13px;
font-weight: 600;
color: var(--text-secondary);
}
.filter-presets {
display: flex;
gap: 8px;
}
.preset-btn {
padding: 8px 16px;
border: 1px solid var(--border);
border-radius: 8px;
font-size: 13px;
font-weight: 500;
font-family: inherit;
background: white;
color: var(--text);
cursor: pointer;
transition: all 0.15s;
}
.preset-btn:hover {
border-color: var(--primary);
color: var(--primary);
}
.preset-btn.active {
background: var(--primary);
border-color: var(--primary);
color: white;
}
.filter-divider {
width: 1px;
height: 32px;
background: var(--border);
margin: 0 8px;
}
.date-inputs {
display: flex;
align-items: center;
gap: 8px;
}
.date-inputs label {
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
}
.date-inputs input {
padding: 8px 12px;
border: 1px solid var(--border);
border-radius: 8px;
font-size: 13px;
font-family: inherit;
}
.btn-apply {
padding: 8px 20px;
background: var(--primary);
color: white;
border: none;
border-radius: 8px;
font-size: 13px;
font-weight: 600;
font-family: inherit;
cursor: pointer;
transition: all 0.15s;
}
.btn-apply:hover {
background: var(--primary-light);
}
.period-info {
margin-left: auto;
font-size: 12px;
color: var(--text-muted);
background: var(--bg);
padding: 6px 12px;
border-radius: 6px;
}
.dashboard-grid {
display: grid;
gap: 24px;
@@ -41,7 +138,7 @@ function buildAdminDashboardHTML(admin) {
margin-bottom: 12px;
}
.kpi-value {
font-size: 36px;
font-size: 32px;
font-weight: 800;
color: var(--text);
margin-bottom: 4px;
@@ -50,18 +147,14 @@ function buildAdminDashboardHTML(admin) {
font-size: 13px;
color: var(--text-muted);
}
.kpi-badge {
position: absolute;
top: 16px;
right: 16px;
font-size: 11px;
font-weight: 700;
padding: 4px 10px;
border-radius: 12px;
.kpi-detail {
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid var(--border);
font-size: 12px;
color: var(--text-secondary);
}
.kpi-badge.up { background: var(--green-bg); color: var(--green); }
.kpi-badge.down { background: var(--red-bg); color: var(--red); }
.kpi-badge.neutral { background: var(--blue-bg); color: var(--blue); }
.kpi-detail span { font-weight: 600; color: var(--text); }
/* Chart Cards */
.charts-row {
@@ -78,6 +171,9 @@ function buildAdminDashboardHTML(admin) {
min-height: 380px;
position: relative;
}
.chart-card.full-width {
grid-column: span 2;
}
.chart-card h3 {
font-size: 14px;
font-weight: 700;
@@ -89,6 +185,41 @@ function buildAdminDashboardHTML(admin) {
position: relative;
}
/* Details Table */
.details-card {
background: var(--card);
border-radius: 16px;
padding: 24px;
border: 1px solid var(--border);
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
}
.details-card h3 {
font-size: 14px;
font-weight: 700;
margin-bottom: 16px;
color: var(--text);
}
.details-table {
width: 100%;
border-collapse: collapse;
}
.details-table th {
text-align: left;
font-size: 11px;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
padding: 12px 8px;
border-bottom: 2px solid var(--border);
}
.details-table td {
padding: 12px 8px;
font-size: 13px;
border-bottom: 1px solid #F3F4F6;
}
.details-table tr:hover { background: #FAFBFC; }
.details-table .num { text-align: right; font-variant-numeric: tabular-nums; }
/* Ranking Card */
.ranking-card {
background: var(--card);
@@ -97,7 +228,6 @@ function buildAdminDashboardHTML(admin) {
border: 1px solid var(--border);
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
min-height: 300px;
position: relative;
}
.ranking-header {
display: flex;
@@ -110,15 +240,6 @@ function buildAdminDashboardHTML(admin) {
font-weight: 700;
color: var(--text);
}
.ranking-header select {
padding: 8px 12px;
border: 1px solid var(--border);
border-radius: 8px;
font-size: 13px;
font-family: inherit;
background: white;
cursor: pointer;
}
.ranking-table {
width: 100%;
border-collapse: collapse;
@@ -138,11 +259,7 @@ function buildAdminDashboardHTML(admin) {
border-bottom: 1px solid #F3F4F6;
}
.ranking-table tr:last-child td { border-bottom: none; }
.rank-num {
width: 40px;
font-weight: 800;
color: var(--primary);
}
.rank-num { width: 40px; font-weight: 800; color: var(--primary); }
.rank-1 { color: #FFD700; }
.rank-2 { color: #C0C0C0; }
.rank-3 { color: #CD7F32; }
@@ -163,9 +280,7 @@ function buildAdminDashboardHTML(admin) {
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
@keyframes spin { to { transform: rotate(360deg); } }
.loading-text {
margin-left: 12px;
font-size: 13px;
@@ -175,72 +290,117 @@ function buildAdminDashboardHTML(admin) {
/* Responsive */
@media (max-width: 1200px) {
.kpi-row { grid-template-columns: repeat(2, 1fr); }
.charts-row { grid-template-columns: 1fr; }
.chart-card.full-width { grid-column: span 1; }
}
@media (max-width: 768px) {
.kpi-row { grid-template-columns: 1fr; }
.charts-row { grid-template-columns: 1fr; }
.filter-bar {
flex-direction: column;
align-items: stretch;
padding: 16px;
gap: 12px;
}
.filter-presets { flex-wrap: wrap; justify-content: center; }
.preset-btn { flex: 1; min-width: 90px; text-align: center; }
.filter-divider { display: none; }
.date-inputs {
flex-direction: column;
align-items: stretch;
gap: 8px;
width: 100%;
}
.date-inputs input { width: 100%; }
.btn-apply { width: 100%; }
.period-info { margin-left: 0; text-align: center; }
.kpi-card { padding: 16px; min-height: 120px; }
.kpi-value { font-size: 24px; }
.kpi-label { font-size: 11px; }
.kpi-sub { font-size: 12px; }
.kpi-detail { font-size: 11px; }
.chart-card { padding: 16px; min-height: 300px; }
.chart-card h3 { font-size: 13px; margin-bottom: 12px; }
.chart-wrap { height: 240px; }
.details-card, .ranking-card { padding: 16px; }
.details-table th, .details-table td { padding: 8px 6px; font-size: 11px; }
.ranking-table th, .ranking-table td { padding: 10px 6px; font-size: 12px; }
}
@media (max-width: 480px) {
.filter-bar { padding: 12px; }
.preset-btn { font-size: 11px; padding: 6px 10px; }
.date-inputs input { font-size: 12px; padding: 8px 10px; }
.kpi-value { font-size: 20px; }
.chart-wrap { height: 200px; }
.details-table th, .details-table td { padding: 6px 4px; font-size: 10px; }
}
`;
return `<!DOCTYPE html>
<html lang="pt-BR">
<head>
${buildHead('Dashboard', pageCSS, pageScripts)}
${buildHead('Dashboard Corporate', pageCSS, pageScripts)}
</head>
<body>
${buildHeader({ role: 'admin', userName: admin.nome, activePage: 'dashboard' })}
${buildHeader({ role: role, userName: user.nome, activePage: 'dashboard' })}
<div class="app-container">
<div class="filter-bar">
<span class="filter-bar-label">Periodo:</span>
<div class="filter-presets">
<button class="preset-btn active" data-preset="thisMonth">Este Mes</button>
<button class="preset-btn" data-preset="lastMonth">Mes Anterior</button>
<button class="preset-btn" data-preset="last2Months">Ultimos 2 Meses</button>
</div>
<div class="filter-divider"></div>
<div class="date-inputs">
<label>De:</label>
<input type="date" id="dateStart" value="${firstDayOfMonth}">
<label>Ate:</label>
<input type="date" id="dateEnd" value="${today}">
<button class="btn-apply" onclick="applyCustomDates()">Aplicar</button>
</div>
<div class="period-info" id="periodInfo">Carregando...</div>
</div>
<div class="dashboard-grid">
<!-- KPIs Row -->
<div class="kpi-row" id="kpiRow">
<div class="kpi-card total">
<div class="loading"><div class="spinner"></div><span class="loading-text">Carregando...</span></div>
</div>
<div class="kpi-card brl-usd">
<div class="loading"><div class="spinner"></div></div>
</div>
<div class="kpi-card usd-brl">
<div class="loading"><div class="spinner"></div></div>
</div>
<div class="kpi-card usd-usd">
<div class="loading"><div class="spinner"></div></div>
</div>
<div class="kpi-card total"><div class="loading"><div class="spinner"></div><span class="loading-text">Carregando...</span></div></div>
<div class="kpi-card brl-usd"><div class="loading"><div class="spinner"></div></div></div>
<div class="kpi-card usd-brl"><div class="loading"><div class="spinner"></div></div></div>
<div class="kpi-card usd-usd"><div class="loading"><div class="spinner"></div></div></div>
</div>
<!-- Charts Row -->
<div class="charts-row">
<div class="chart-card" id="chartConsolidado">
<h3>Tendencia 30 dias - Total Consolidado</h3>
<div class="chart-wrap">
<div class="loading"><div class="spinner"></div><span class="loading-text">Carregando grafico...</span></div>
</div>
</div>
<div class="chart-card" id="chartFluxos">
<h3>Tendencia 30 dias - Por Fluxo</h3>
<div class="chart-wrap">
<div class="loading"><div class="spinner"></div><span class="loading-text">Carregando grafico...</span></div>
<div class="chart-card" id="chartVolume">
<h3>Volume Diario (USD)</h3>
<div class="chart-wrap"><div class="loading"><div class="spinner"></div><span class="loading-text">Carregando grafico...</span></div></div>
</div>
<div class="chart-card" id="chartOrdens">
<h3>Quantidade de Ordens por Dia</h3>
<div class="chart-wrap"><div class="loading"><div class="spinner"></div><span class="loading-text">Carregando grafico...</span></div></div>
</div>
</div>
<!-- Ranking -->
<div class="charts-row">
<div class="chart-card full-width" id="chartFluxos">
<h3>Volume por Fluxo (Comparativo)</h3>
<div class="chart-wrap"><div class="loading"><div class="spinner"></div><span class="loading-text">Carregando grafico...</span></div></div>
</div>
</div>
<div class="details-card" id="detailsCard">
<h3>Resumo Diario do Periodo</h3>
<div id="detailsContent"><div class="loading"><div class="spinner"></div><span class="loading-text">Carregando detalhes...</span></div></div>
</div>
<div class="ranking-card" id="rankingCard">
<div class="ranking-header">
<h3>Top 5 Agentes</h3>
<select id="rankingPeriodo" onchange="loadRanking()">
<option value="30" selected>Ultimo Mes</option>
<option value="7">Ultima Semana</option>
<option value="90">Ultimos 3 Meses</option>
</select>
<div class="ranking-header"><h3>Top 5 Agentes no Periodo</h3></div>
<div id="rankingContent"><div class="loading"><div class="spinner"></div><span class="loading-text">Carregando ranking...</span></div></div>
</div>
<div id="rankingContent">
<div class="loading"><div class="spinner"></div><span class="loading-text">Carregando ranking...</span></div>
</div>
</div>
</div>
</div>
@@ -249,213 +409,175 @@ ${buildFooter()}
<script>
const formatUSD = (v) => '$' + Number(v).toLocaleString('en-US', { minimumFractionDigits: 0, maximumFractionDigits: 0 });
const formatNum = (v) => Number(v).toLocaleString('pt-BR');
const formatDate = (d) => d.split('-').reverse().join('/');
// Load KPIs
async function loadKPIs() {
try {
const res = await fetch('/admin/api/kpis');
const json = await res.json();
if (!json.success) throw new Error(json.error);
let currentPeriod = { inicio: '${firstDayOfMonth}', fim: '${today}' };
let trendData = null;
let charts = {};
const d = json.data;
const calcVar = (hoje, media) => media > 0 ? ((hoje - media) / media * 100).toFixed(0) : 0;
const cards = document.querySelectorAll('#kpiRow .kpi-card');
// Total
const totalVar = calcVar(d.total.hoje_qtd, d.total.media_qtd);
cards[0].innerHTML = \`
<div class="kpi-badge \${totalVar >= 0 ? 'up' : 'down'}">\${totalVar >= 0 ? '+' : ''}\${totalVar}%</div>
<div class="kpi-label">Total Ordens Hoje</div>
<div class="kpi-value">\${d.total.hoje_qtd}</div>
<div class="kpi-sub">Media 30d: \${d.total.media_qtd} ordens</div>
\`;
// BRL->USD
const brlVar = calcVar(d.brlUsd.hoje_qtd, d.brlUsd.media_qtd);
cards[1].innerHTML = \`
<div class="kpi-badge \${brlVar >= 0 ? 'up' : 'down'}">\${brlVar >= 0 ? '+' : ''}\${brlVar}%</div>
<div class="kpi-label">BRL &rarr; USD</div>
<div class="kpi-value">\${d.brlUsd.hoje_qtd}</div>
<div class="kpi-sub">Media 30d: \${d.brlUsd.media_qtd}</div>
\`;
// USD->BRL
const usdBrlVar = calcVar(d.usdBrl.hoje_qtd, d.usdBrl.media_qtd);
cards[2].innerHTML = \`
<div class="kpi-badge \${usdBrlVar >= 0 ? 'up' : 'down'}">\${usdBrlVar >= 0 ? '+' : ''}\${usdBrlVar}%</div>
<div class="kpi-label">USD &rarr; BRL</div>
<div class="kpi-value">\${d.usdBrl.hoje_qtd}</div>
<div class="kpi-sub">Media 30d: \${d.usdBrl.media_qtd}</div>
\`;
// USD->USD
const usdUsdVar = calcVar(d.usdUsd.hoje_qtd, d.usdUsd.media_qtd);
cards[3].innerHTML = \`
<div class="kpi-badge \${usdUsdVar >= 0 ? 'up' : 'down'}">\${usdUsdVar >= 0 ? '+' : ''}\${usdUsdVar}%</div>
<div class="kpi-label">USD &rarr; USD</div>
<div class="kpi-value">\${d.usdUsd.hoje_qtd}</div>
<div class="kpi-sub">Media 30d: \${d.usdUsd.media_qtd}</div>
\`;
} catch (err) {
console.error('KPIs error:', err);
function getPresetDates(preset) {
const now = new Date();
let inicio, fim;
if (preset === 'thisMonth') {
inicio = new Date(now.getFullYear(), now.getMonth(), 1);
fim = now;
} else if (preset === 'lastMonth') {
inicio = new Date(now.getFullYear(), now.getMonth() - 1, 1);
fim = new Date(now.getFullYear(), now.getMonth(), 0);
} else if (preset === 'last2Months') {
inicio = new Date(now.getFullYear(), now.getMonth() - 1, 1);
fim = now;
}
return { inicio: inicio.toISOString().slice(0, 10), fim: fim.toISOString().slice(0, 10) };
}
// Load Trend Charts
async function loadTrend() {
document.querySelectorAll('.preset-btn').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.preset-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
const dates = getPresetDates(btn.dataset.preset);
document.getElementById('dateStart').value = dates.inicio;
document.getElementById('dateEnd').value = dates.fim;
currentPeriod = dates;
loadAllData();
});
});
function applyCustomDates() {
document.querySelectorAll('.preset-btn').forEach(b => b.classList.remove('active'));
currentPeriod = { inicio: document.getElementById('dateStart').value, fim: document.getElementById('dateEnd').value };
loadAllData();
}
function updatePeriodInfo() {
const dias = Math.ceil((new Date(currentPeriod.fim) - new Date(currentPeriod.inicio)) / (1000 * 60 * 60 * 24)) + 1;
document.getElementById('periodInfo').textContent = formatDate(currentPeriod.inicio) + ' - ' + formatDate(currentPeriod.fim) + ' (' + dias + ' dias)';
}
async function loadKPIs() {
try {
const res = await fetch('/admin/api/trend');
const res = await fetch('/corporate/api/kpis-period?inicio=' + currentPeriod.inicio + '&fim=' + currentPeriod.fim);
const json = await res.json();
if (!json.success) throw new Error(json.error);
const d = json.data;
const cards = document.querySelectorAll('#kpiRow .kpi-card');
cards[0].innerHTML = '<div class="kpi-label">Total no Periodo</div><div class="kpi-value">' + formatNum(d.total.qtd) + '</div><div class="kpi-sub">ordens realizadas</div><div class="kpi-detail">Volume: <span>' + formatUSD(d.total.vol_usd) + '</span> | Ticket Medio: <span>' + formatUSD(d.total.ticket_medio) + '</span></div>';
cards[1].innerHTML = '<div class="kpi-label">BRL &rarr; USD</div><div class="kpi-value">' + formatNum(d.brlUsd.qtd) + '</div><div class="kpi-sub">' + formatUSD(d.brlUsd.vol_usd) + '</div><div class="kpi-detail">Ticket Medio: <span>' + formatUSD(d.brlUsd.ticket_medio) + '</span></div>';
cards[2].innerHTML = '<div class="kpi-label">USD &rarr; BRL</div><div class="kpi-value">' + formatNum(d.usdBrl.qtd) + '</div><div class="kpi-sub">' + formatUSD(d.usdBrl.vol_usd) + '</div><div class="kpi-detail">Ticket Medio: <span>' + formatUSD(d.usdBrl.ticket_medio) + '</span></div>';
cards[3].innerHTML = '<div class="kpi-label">USD &rarr; USD</div><div class="kpi-value">' + formatNum(d.usdUsd.qtd) + '</div><div class="kpi-sub">' + formatUSD(d.usdUsd.vol_usd) + '</div><div class="kpi-detail">Ticket Medio: <span>' + formatUSD(d.usdUsd.ticket_medio) + '</span></div>';
} catch (err) { console.error('KPIs error:', err); }
}
async function loadTrend() {
try {
const res = await fetch('/corporate/api/trend-period?inicio=' + currentPeriod.inicio + '&fim=' + currentPeriod.fim);
const json = await res.json();
if (!json.success) throw new Error(json.error);
trendData = json.data;
// Build consolidated data
const allDates = new Set();
d.brlUsd.forEach(r => allDates.add(r.dia));
d.usdBrl.forEach(r => allDates.add(r.dia));
d.usdUsd.forEach(r => allDates.add(r.dia));
trendData.brlUsd.forEach(r => allDates.add(r.dia));
trendData.usdBrl.forEach(r => allDates.add(r.dia));
trendData.usdUsd.forEach(r => allDates.add(r.dia));
const dates = Array.from(allDates).sort();
const getQtd = (arr, dia) => arr.find(r => r.dia === dia)?.qtd || 0;
const getVal = (arr, dia, key) => arr.find(r => r.dia === dia)?.[key] || 0;
const consolidado = dates.map(dia =>
getQtd(d.brlUsd, dia) + getQtd(d.usdBrl, dia) + getQtd(d.usdUsd, dia)
);
if (charts.volume) charts.volume.destroy();
if (charts.ordens) charts.ordens.destroy();
if (charts.fluxos) charts.fluxos.destroy();
// Chart 1: Consolidado
document.querySelector('#chartConsolidado .chart-wrap').innerHTML = '<canvas id="canvasConsolidado"></canvas>';
new Chart(document.getElementById('canvasConsolidado'), {
type: 'line',
document.querySelector('#chartVolume .chart-wrap').innerHTML = '<canvas id="canvasVolume"></canvas>';
charts.volume = new Chart(document.getElementById('canvasVolume'), {
type: 'bar',
data: {
labels: dates.map(d => d.slice(5)),
datasets: [{
label: 'Total Ordens',
data: consolidado,
borderColor: '#7600be',
backgroundColor: 'rgba(118,0,190,0.1)',
fill: true,
tension: 0.3,
pointRadius: 2
}]
datasets: [
{ label: 'BRL→USD', data: dates.map(dia => getVal(trendData.brlUsd, dia, 'vol_usd')), backgroundColor: '#1A73E8', borderRadius: 4 },
{ label: 'USD→BRL', data: dates.map(dia => getVal(trendData.usdBrl, dia, 'vol_usd')), backgroundColor: '#1E8E3E', borderRadius: 4 },
{ label: 'USD→USD', data: dates.map(dia => getVal(trendData.usdUsd, dia, 'vol_usd')), backgroundColor: '#7B1FA2', borderRadius: 4 }
]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: {
y: { beginAtZero: true, grid: { color: '#F3F4F6' } },
x: { grid: { display: false }, ticks: { maxRotation: 0, autoSkip: true, maxTicksLimit: 10 } }
}
responsive: true, maintainAspectRatio: false,
plugins: { legend: { position: 'top', labels: { usePointStyle: true, font: { size: 11 } } }, tooltip: { callbacks: { label: ctx => ctx.dataset.label + ': ' + formatUSD(ctx.raw) } } },
scales: { y: { beginAtZero: true, stacked: true, grid: { color: '#F3F4F6' }, ticks: { callback: v => '$' + (v >= 1e6 ? (v/1e6).toFixed(1)+'M' : v >= 1e3 ? (v/1e3).toFixed(0)+'k' : v) } }, x: { stacked: true, grid: { display: false }, ticks: { maxRotation: 0, autoSkip: true, maxTicksLimit: 15 } } }
}
});
// Chart 2: Por Fluxo
const consolidadoQtd = dates.map(dia => getVal(trendData.brlUsd, dia, 'qtd') + getVal(trendData.usdBrl, dia, 'qtd') + getVal(trendData.usdUsd, dia, 'qtd'));
document.querySelector('#chartOrdens .chart-wrap').innerHTML = '<canvas id="canvasOrdens"></canvas>';
charts.ordens = new Chart(document.getElementById('canvasOrdens'), {
type: 'line',
data: { labels: dates.map(d => d.slice(5)), datasets: [{ label: 'Total Ordens', data: consolidadoQtd, borderColor: '#7600be', backgroundColor: 'rgba(118,0,190,0.1)', fill: true, tension: 0.3, pointRadius: 3 }] },
options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false } }, scales: { y: { beginAtZero: true, grid: { color: '#F3F4F6' } }, x: { grid: { display: false }, ticks: { maxRotation: 0, autoSkip: true, maxTicksLimit: 15 } } } }
});
document.querySelector('#chartFluxos .chart-wrap').innerHTML = '<canvas id="canvasFluxos"></canvas>';
new Chart(document.getElementById('canvasFluxos'), {
charts.fluxos = new Chart(document.getElementById('canvasFluxos'), {
type: 'line',
data: {
labels: dates.map(d => d.slice(5)),
datasets: [
{
label: 'BRL→USD',
data: dates.map(dia => getQtd(d.brlUsd, dia)),
borderColor: '#1A73E8',
backgroundColor: 'transparent',
tension: 0.3,
pointRadius: 2
},
{
label: 'USD→BRL',
data: dates.map(dia => getQtd(d.usdBrl, dia)),
borderColor: '#1E8E3E',
backgroundColor: 'transparent',
tension: 0.3,
pointRadius: 2
},
{
label: 'USD→USD',
data: dates.map(dia => getQtd(d.usdUsd, dia)),
borderColor: '#7B1FA2',
backgroundColor: 'transparent',
tension: 0.3,
pointRadius: 2
}
{ label: 'BRL→USD', data: dates.map(dia => getVal(trendData.brlUsd, dia, 'vol_usd')), borderColor: '#1A73E8', backgroundColor: 'rgba(26,115,232,0.1)', fill: true, tension: 0.3, pointRadius: 2 },
{ label: 'USD→BRL', data: dates.map(dia => getVal(trendData.usdBrl, dia, 'vol_usd')), borderColor: '#1E8E3E', backgroundColor: 'rgba(30,142,62,0.1)', fill: true, tension: 0.3, pointRadius: 2 },
{ label: 'USD→USD', data: dates.map(dia => getVal(trendData.usdUsd, dia, 'vol_usd')), borderColor: '#7B1FA2', backgroundColor: 'rgba(123,31,162,0.1)', fill: true, tension: 0.3, pointRadius: 2 }
]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { position: 'top', labels: { usePointStyle: true, font: { size: 11 } } }
},
scales: {
y: { beginAtZero: true, grid: { color: '#F3F4F6' } },
x: { grid: { display: false }, ticks: { maxRotation: 0, autoSkip: true, maxTicksLimit: 10 } }
}
responsive: true, maintainAspectRatio: false,
plugins: { legend: { position: 'top', labels: { usePointStyle: true, font: { size: 11 } } }, tooltip: { callbacks: { label: ctx => ctx.dataset.label + ': ' + formatUSD(ctx.raw) } } },
scales: { y: { beginAtZero: true, grid: { color: '#F3F4F6' }, ticks: { callback: v => '$' + (v >= 1e6 ? (v/1e6).toFixed(1)+'M' : v >= 1e3 ? (v/1e3).toFixed(0)+'k' : v) } }, x: { grid: { display: false }, ticks: { maxRotation: 0, autoSkip: true, maxTicksLimit: 20 } } }
}
});
} catch (err) {
console.error('Trend error:', err);
}
renderDetailsTable(dates);
} catch (err) { console.error('Trend error:', err); }
}
function renderDetailsTable(dates) {
if (!trendData || !dates.length) { document.getElementById('detailsContent').innerHTML = '<p style="color:var(--text-muted);text-align:center;padding:20px;">Sem dados para o periodo.</p>'; return; }
const getVal = (arr, dia, key) => arr.find(r => r.dia === dia)?.[key] || 0;
const recentDates = dates.slice(-10).reverse();
let html = '<table class="details-table"><thead><tr><th>Data</th><th class="num">BRL→USD</th><th class="num">USD→BRL</th><th class="num">USD→USD</th><th class="num">Total Qtd</th><th class="num">Volume Total</th></tr></thead><tbody>';
recentDates.forEach(dia => {
const brlUsdQtd = getVal(trendData.brlUsd, dia, 'qtd');
const usdBrlQtd = getVal(trendData.usdBrl, dia, 'qtd');
const usdUsdQtd = getVal(trendData.usdUsd, dia, 'qtd');
const brlUsdVol = getVal(trendData.brlUsd, dia, 'vol_usd');
const usdBrlVol = getVal(trendData.usdBrl, dia, 'vol_usd');
const usdUsdVol = getVal(trendData.usdUsd, dia, 'vol_usd');
const totalQtd = brlUsdQtd + usdBrlQtd + usdUsdQtd;
const totalVol = brlUsdVol + usdBrlVol + usdUsdVol;
html += '<tr><td>' + formatDate(dia) + '</td><td class="num">' + brlUsdQtd + '</td><td class="num">' + usdBrlQtd + '</td><td class="num">' + usdUsdQtd + '</td><td class="num"><strong>' + totalQtd + '</strong></td><td class="num"><strong>' + formatUSD(totalVol) + '</strong></td></tr>';
});
html += '</tbody></table>';
if (dates.length > 10) html += '<p style="font-size:12px;color:var(--text-muted);margin-top:12px;text-align:center;">Mostrando os 10 dias mais recentes de ' + dates.length + ' dias no periodo.</p>';
document.getElementById('detailsContent').innerHTML = html;
}
// Load Ranking
async function loadRanking() {
const dias = document.getElementById('rankingPeriodo').value;
const dias = Math.ceil((new Date(currentPeriod.fim) - new Date(currentPeriod.inicio)) / (1000 * 60 * 60 * 24)) + 1;
const content = document.getElementById('rankingContent');
content.innerHTML = '<div class="loading"><div class="spinner"></div><span class="loading-text">Carregando...</span></div>';
try {
const res = await fetch('/admin/api/top-agentes?dias=' + dias);
const res = await fetch('/corporate/api/top-agentes?dias=' + dias);
const json = await res.json();
if (!json.success) throw new Error(json.error);
if (json.data.length === 0) { content.innerHTML = '<p style="text-align:center;color:var(--text-muted);padding:40px;">Nenhum dado encontrado.</p>'; return; }
if (json.data.length === 0) {
content.innerHTML = '<p style="text-align:center;color:var(--text-muted);padding:40px;">Nenhum dado encontrado para o periodo.</p>';
return;
}
let html = \`
<table class="ranking-table">
<thead>
<tr>
<th>#</th>
<th>Agente</th>
<th>Qtd Ordens</th>
<th>Volume USD</th>
</tr>
</thead>
<tbody>
\`;
json.data.forEach(r => {
html += \`
<tr>
<td class="rank-num rank-\${r.rank}">\${r.rank}</td>
<td>\${r.agente}</td>
<td>\${formatNum(r.qtd)}</td>
<td>\${formatUSD(r.vol_usd)}</td>
</tr>
\`;
});
let html = '<table class="ranking-table"><thead><tr><th>#</th><th>Agente</th><th>Qtd Ordens</th><th>Volume USD</th></tr></thead><tbody>';
json.data.forEach(r => { html += '<tr><td class="rank-num rank-' + r.rank + '">' + r.rank + '</td><td>' + r.agente + '</td><td>' + formatNum(r.qtd) + '</td><td>' + formatUSD(r.vol_usd) + '</td></tr>'; });
html += '</tbody></table>';
content.innerHTML = html;
} catch (err) {
console.error('Ranking error:', err);
content.innerHTML = '<p style="color:var(--red);padding:20px;">Erro ao carregar ranking</p>';
}
} catch (err) { console.error('Ranking error:', err); content.innerHTML = '<p style="color:var(--red);padding:20px;">Erro ao carregar ranking</p>'; }
}
// Load all sections
document.addEventListener('DOMContentLoaded', () => {
loadKPIs();
loadTrend();
loadRanking();
});
</script>
function loadAllData() { updatePeriodInfo(); loadKPIs(); loadTrend(); loadRanking(); }
document.addEventListener('DOMContentLoaded', loadAllData);
<\/script>
</body>
</html>`;
}

View File

@@ -4,9 +4,11 @@
*/
const { buildHeader, buildFooter, buildHead } = require('./ui-template');
function buildAdminHomeHTML(stats, admin) {
function buildAdminHomeHTML(stats, user) {
const now = new Date().toLocaleString('pt-BR');
const hoje = new Date().toLocaleDateString('pt-BR', { weekday: 'long', day: 'numeric', month: 'long' });
// Support both admin and corporate roles
const role = user.role || 'corporate';
const formatBRL = (v) => v.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
const formatUSD = (v) => v.toLocaleString('pt-BR', { style: 'currency', currency: 'USD' });
@@ -104,6 +106,31 @@ function buildAdminHomeHTML(stats, admin) {
}
@media (max-width: 768px) {
.kpi-grid, .charts-grid { grid-template-columns: 1fr; }
.date-banner {
flex-direction: column;
gap: 8px;
text-align: center;
padding: 14px 16px;
}
.date-banner h2 { font-size: 16px; }
.date-banner .time { font-size: 12px; }
.kpi-card { padding: 16px; }
.kpi-value { font-size: 28px; }
.kpi-label { font-size: 11px; }
.kpi-sub { font-size: 12px; }
.kpi-badge { font-size: 10px; padding: 3px 8px; }
.chart-card { padding: 16px; }
.chart-card h3 { font-size: 13px; margin-bottom: 14px; }
.chart-wrap { height: 220px; }
.detail-card { padding: 16px; }
.detail-card h3 { font-size: 13px; }
.detail-row { font-size: 12px; padding: 8px 0; }
}
@media (max-width: 480px) {
.kpi-value { font-size: 24px; }
.chart-wrap { height: 180px; }
.detail-card h3 .icon { width: 24px; height: 24px; font-size: 12px; }
.detail-row { font-size: 11px; }
}
`;
@@ -114,7 +141,7 @@ ${buildHead('Home', pageCSS, pageScripts)}
</head>
<body>
${buildHeader({ role: 'admin', userName: admin.nome, activePage: 'home' })}
${buildHeader({ role: role, userName: user.nome, activePage: 'home' })}
<div class="app-container">
<div class="date-banner">

View File

@@ -48,6 +48,7 @@ function buildAdminHTML(agentes, admin) {
.status-badge.active { background: var(--green-bg); color: var(--green); }
.status-badge.inactive { background: var(--red-bg); color: var(--red); }
.status-badge.admin { background: var(--admin-bg); color: var(--admin-accent); }
.status-badge.corporate { background: var(--corporate-bg); color: var(--corporate-accent); }
.status-badge.agent { background: var(--blue-bg); color: var(--blue); }
.actions { display: flex; gap: 6px; }
@@ -130,7 +131,49 @@ function buildAdminHTML(agentes, admin) {
.alert.show { display: block; }
@media (max-width: 768px) {
.toolbar { flex-direction: column; gap: 12px; align-items: flex-start; }
.toolbar { flex-direction: column; gap: 12px; align-items: stretch; }
.toolbar h2 { font-size: 16px; }
.btn-create { text-align: center; }
/* Mobile table improvements */
table { font-size: 12px; }
thead th { padding: 10px 8px; font-size: 10px; }
tbody td { padding: 10px 8px; }
/* Stack action buttons on mobile */
.actions {
flex-direction: column;
gap: 4px;
}
.btn-action {
padding: 8px 10px;
font-size: 11px;
text-align: center;
width: 100%;
}
/* Modal improvements for mobile */
.modal {
margin: 10px;
max-height: calc(100vh - 20px);
border-radius: 12px;
}
.modal-header { padding: 16px 20px; }
.modal-body { padding: 16px 20px; }
.modal-footer {
padding: 12px 20px;
flex-direction: column;
gap: 8px;
}
.modal-footer button { width: 100%; }
}
@media (max-width: 480px) {
.table-card { border-radius: 8px; }
table { font-size: 11px; }
thead th { padding: 8px 6px; }
tbody td { padding: 8px 6px; }
.status-badge { font-size: 10px; padding: 3px 8px; }
}
</style>
`;
@@ -173,12 +216,12 @@ ${buildHeader({ role: 'admin', userName: admin.nome, activePage: 'users' })}
<td>${a.id}</td>
<td>${a.nome}</td>
<td>${a.email}</td>
<td><span class="status-badge ${a.role === 'admin' ? 'admin' : 'agent'}">${a.role === 'admin' ? 'Admin' : 'Agente'}</span></td>
<td>${a.role === 'admin' ? '-' : a.agente_id}</td>
<td><span class="status-badge ${a.role === 'admin' ? 'admin' : a.role === 'corporate' ? 'corporate' : 'agent'}">${a.role === 'admin' ? 'Admin' : a.role === 'corporate' ? 'Corporate' : 'Agente'}</span></td>
<td>${(a.role === 'admin' || a.role === 'corporate') ? '-' : a.agente_id}</td>
<td><span class="status-badge ${a.ativo ? 'active' : 'inactive'}">${a.ativo ? 'Ativo' : 'Inativo'}</span></td>
<td>${a.created_at ? new Date(a.created_at).toLocaleDateString('pt-BR') : '-'}</td>
<td class="actions">
${a.role === 'agente' ? `<a href="/admin/emular/${a.agente_id}" class="btn-action btn-emular" title="Ver como este agente">Emular</a>` : ''}
${a.role === 'agente' ? `<a href="/corporate/emular/${a.agente_id}" class="btn-action btn-emular" title="Ver como este agente">Emular</a>` : ''}
<button class="btn-action btn-edit" onclick="openEditModal(${a.id}, '${a.nome.replace(/'/g, "\\'")}', '${a.email.replace(/'/g, "\\'")}', ${a.agente_id}, '${a.role || 'agente'}', event)">Editar</button>
<button class="btn-action btn-toggle" onclick="toggleAgente(${a.id}, ${a.ativo})">${a.ativo ? 'Desativar' : 'Ativar'}</button>
<button class="btn-action btn-password" onclick="openPasswordModal(${a.id}, '${a.nome.replace(/'/g, "\\'")}')">Senha</button>
@@ -215,6 +258,7 @@ ${buildFooter()}
<label>Tipo de Usuario</label>
<select id="agentRole" name="role" onchange="toggleAgenteIdField()">
<option value="agente">Agente</option>
<option value="corporate">Corporate</option>
<option value="admin">Administrador</option>
</select>
</div>
@@ -276,7 +320,8 @@ function toggleAgenteIdField() {
const role = document.getElementById('agentRole').value;
const agenteIdGroup = document.getElementById('agenteIdGroup');
const agenteIdInput = document.getElementById('agentAgenteId');
if (role === 'admin') {
// Admin and Corporate don't need agente_id
if (role === 'admin' || role === 'corporate') {
agenteIdGroup.style.display = 'none';
agenteIdInput.required = false;
agenteIdInput.value = '';
@@ -344,6 +389,7 @@ async function submitAgentForm(e) {
role: role,
};
// Only agents need agente_id
if (role === 'agente') {
const agenteId = document.getElementById('agentAgenteId').value;
if (!isEditing && !agenteId) {
@@ -351,6 +397,9 @@ async function submitAgentForm(e) {
return;
}
data.agente_id = parseInt(agenteId) || 0;
} else {
// Admin and Corporate don't have agente_id
data.agente_id = 0;
}
if (!isEditing) {

View File

@@ -1,6 +1,6 @@
/**
* Autenticacao Unificada — login/logout com bcrypt + express-session
* Suporta roles: 'agente' | 'admin'
* Suporta roles: 'agente' | 'corporate' | 'admin'
*/
const bcrypt = require('bcrypt');
const db = require('./db-local');
@@ -8,7 +8,7 @@ const db = require('./db-local');
const SALT_ROUNDS = 10;
/**
* Cria um novo usuario (agente ou admin)
* Cria um novo usuario (agente, corporate ou admin)
*/
async function createUser(email, senha, nome, role = 'agente', agenteId = 0) {
const hash = await bcrypt.hash(senha, SALT_ROUNDS);
@@ -41,7 +41,8 @@ async function authenticate(email, senha) {
* Middleware que verifica autenticacao e roles
* Uso: requireRole() - qualquer usuario logado
* requireRole('admin') - apenas admins
* requireRole('agente', 'admin') - ambos
* requireRole('corporate', 'admin') - corporate e admins
* requireRole('agente') - apenas agentes
*/
function requireRole(...roles) {
return (req, res, next) => {

View File

@@ -1,92 +1,72 @@
/**
* Gera HTML do dashboard — parametrizado por agente
* Updated: 2026-02-09 - 4 decimal places for spread
*/
const { buildHeader, buildFooter, buildHead } = require('./ui-template');
function buildHTML(data, agente, isAgentDashboard = true, diasPeriodo = null, asyncLoad = false, isEmulating = false) {
const now = new Date().toLocaleString('pt-BR');
const isAdminDash = diasPeriodo !== null;
return `<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>BI - CCC — ${agente.nome}</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.7/dist/chart.umd.min.js"><\/script>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<style>
:root {
--primary: #7600be;
--primary-light: #9B2DE5;
--primary-dark: #5A0091;
--primary-bg: #F5EAFA;
--bg: #F0F2F5;
--card: #FFFFFF;
--text: #1A1D23;
--text-secondary: #5F6368;
--text-muted: #9AA0A6;
--border: #E8EAED;
--green: #1E8E3E;
--green-bg: #E6F4EA;
--blue: #1A73E8;
--blue-bg: #E8F0FE;
--orange: #E8710A;
--orange-bg: #FEF3E8;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: var(--bg); color: var(--text); line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
.header {
background: linear-gradient(135deg, var(--primary) 0%, var(--primary-dark) 100%);
// When emulating, use the emulator's role if provided, otherwise default to admin
const emulatorRole = agente.emulatorRole || 'admin';
const role = isEmulating ? emulatorRole : (isAdminDash ? 'admin' : 'agente');
// Determine the back URL based on emulator's role
const backUrl = emulatorRole === 'corporate' ? '/corporate' : '/admin';
const pageScripts = `<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.7/dist/chart.umd.min.js"><\/script>`;
const dashboardCSS = `
/* Emulation Banner */
.emulation-banner {
background: linear-gradient(90deg, #FF6B35, #F7931E);
color: white;
box-shadow: 0 2px 8px rgba(74,37,112,0.3);
}
.header-inner {
max-width: 1600px;
margin: 0 auto;
padding: 16px 40px;
padding: 10px 24px;
text-align: center;
font-size: 13px;
font-weight: 600;
display: flex;
justify-content: space-between;
justify-content: center;
align-items: center;
gap: 16px;
}
.header-logo {
height: 36px;
width: auto;
background: white;
padding: 6px 12px;
border-radius: 8px;
}
.header h1 { font-size: 20px; font-weight: 800; letter-spacing: -0.5px; }
.header .subtitle { font-size: 12px; opacity: 0.8; margin-top: 2px; font-weight: 400; }
.header-right { display: flex; align-items: center; gap: 12px; }
.header .badge {
background: rgba(255,255,255,0.15); backdrop-filter: blur(10px);
padding: 6px 14px; border-radius: 20px; font-size: 11px; font-weight: 600;
border: 1px solid rgba(255,255,255,0.2);
}
.header .live-dot {
display: inline-block; width: 8px; height: 8px; background: #4ADE80;
border-radius: 50%; margin-right: 6px; animation: pulse 2s infinite;
}
.header .app-name-badge {
.emulation-banner a {
background: rgba(255,255,255,0.2);
padding: 6px 14px;
color: white;
padding: 6px 16px;
border-radius: 6px;
font-size: 14px;
font-weight: 800;
text-decoration: none;
font-size: 12px;
border: 1px solid rgba(255,255,255,0.3);
}
.btn-logout {
background: rgba(255,255,255,0.15); color: white; border: 1px solid rgba(255,255,255,0.3);
padding: 8px 16px; border-radius: 6px; font-size: 12px; font-weight: 600;
cursor: pointer; text-decoration: none; font-family: inherit; transition: all 0.15s;
.emulation-banner a:hover {
background: rgba(255,255,255,0.3);
}
/* Live Badge */
.live-badge {
background: rgba(255,255,255,0.15);
padding: 6px 14px;
border-radius: 20px;
font-size: 11px;
font-weight: 600;
border: 1px solid rgba(255,255,255,0.2);
margin-right: 12px;
}
.live-dot {
display: inline-block;
width: 8px;
height: 8px;
background: #4ADE80;
border-radius: 50%;
margin-right: 6px;
animation: pulse 2s infinite;
}
.btn-logout:hover { background: rgba(255,255,255,0.25); }
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.4; } }
/* Filters */
.filters {
background: var(--card); border-bottom: 1px solid var(--border);
background: var(--card);
border-bottom: 1px solid var(--border);
box-shadow: 0 1px 2px rgba(0,0,0,0.04);
}
.filters-inner {
@@ -124,6 +104,94 @@ function buildHTML(data, agente, isAgentDashboard = true, diasPeriodo = null, as
}
.btn-export:hover { background: #25a244; transform: translateY(-1px); }
/* Trading Terminal - Live Rates */
.trading-terminal {
background: linear-gradient(135deg, #0F1923 0%, #1A2332 50%, #0D1B2A 100%);
border-top: 1px solid rgba(0,255,136,0.15);
border-bottom: 1px solid rgba(0,255,136,0.15);
padding: 16px 40px;
position: relative;
overflow: hidden;
}
.trading-terminal::before {
content: '';
position: absolute; top: 0; left: 0; right: 0; height: 1px;
background: linear-gradient(90deg, transparent, rgba(0,255,136,0.3), transparent);
}
.trading-terminal::after {
content: '';
position: absolute; bottom: 0; left: 0; right: 0; height: 1px;
background: linear-gradient(90deg, transparent, rgba(0,255,136,0.3), transparent);
}
.live-rate-bar {
display: flex; align-items: center; justify-content: center; gap: 20px;
max-width: 1600px; margin: 0 auto;
}
.terminal-title {
font-size: 10px; font-weight: 700; color: rgba(0,255,136,0.6);
text-transform: uppercase; letter-spacing: 2px; margin-right: 8px;
font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace;
}
.live-rate-dot {
width: 7px; height: 7px; border-radius: 50%; background: #00FF88;
display: inline-block; animation: blink 1.5s infinite;
box-shadow: 0 0 6px rgba(0,255,136,0.6);
}
@keyframes blink { 0%, 100% { opacity: 1; } 50% { opacity: 0.2; } }
.rate-pair-group {
display: flex; align-items: center; gap: 8px;
background: rgba(255,255,255,0.03);
border: 1px solid rgba(255,255,255,0.06);
border-radius: 8px; padding: 8px 14px;
}
.rate-pair-label {
font-size: 11px; font-weight: 800; color: rgba(255,255,255,0.35);
text-transform: uppercase; letter-spacing: 1px; writing-mode: vertical-rl;
text-orientation: mixed; font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace;
}
.live-rate-btn {
display: flex; flex-direction: column; align-items: center; gap: 1px;
padding: 8px 18px; border-radius: 6px; border: 1px solid transparent;
font-family: inherit; font-size: 14px; font-weight: 700; cursor: default;
transition: all 0.25s; min-width: 150px; justify-content: center;
position: relative;
}
.rate-flags { font-size: 9px; opacity: 0.5; letter-spacing: 1px; line-height: 1; }
.live-rate-btn.compra {
background: rgba(0,255,136,0.08); color: #00FF88;
border-color: rgba(0,255,136,0.2);
}
.live-rate-btn.compra:hover { background: rgba(0,255,136,0.14); }
.live-rate-btn.venda {
background: rgba(255,68,68,0.08); color: #FF4444;
border-color: rgba(255,68,68,0.2);
}
.live-rate-btn.venda:hover { background: rgba(255,68,68,0.14); }
.live-rate-btn .rate-value {
font-size: 22px; font-weight: 800; letter-spacing: -0.5px;
font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace;
font-variant-numeric: tabular-nums;
text-shadow: 0 0 12px currentColor;
}
.live-rate-btn .rate-type {
font-size: 9px; text-transform: uppercase; letter-spacing: 1.5px;
opacity: 0.5; font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace;
}
.live-rate-btn.pulse .rate-value { animation: ratePulse 0.4s ease; }
@keyframes ratePulse {
0% { transform: scale(1); text-shadow: 0 0 12px currentColor; }
50% { transform: scale(1.06); text-shadow: 0 0 24px currentColor, 0 0 48px currentColor; }
100% { transform: scale(1); text-shadow: 0 0 12px currentColor; }
}
.rate-separator {
width: 1px; height: 40px; background: rgba(255,255,255,0.08);
}
.live-rate-time {
font-size: 10px; color: rgba(255,255,255,0.25);
font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace;
letter-spacing: 0.5px;
}
.container { padding: 28px 40px; max-width: 1600px; margin: 0 auto; }
.kpi-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; margin-bottom: 28px; }
@@ -206,7 +274,6 @@ function buildHTML(data, agente, isAgentDashboard = true, diasPeriodo = null, as
tbody tr:nth-child(even):hover { background: #F8F5FF; }
.num { text-align: right; }
.footer { text-align: center; padding: 20px; font-size: 12px; color: var(--text-muted); }
/* Portfolio Analysis Styles */
.section-title {
@@ -229,6 +296,7 @@ function buildHTML(data, agente, isAgentDashboard = true, diasPeriodo = null, as
.spread-high { background: var(--green-bg) !important; color: var(--green); font-weight: 600; }
.spread-medium { background: var(--orange-bg) !important; color: var(--orange); font-weight: 600; }
.spread-low { background: #FEE2E2 !important; color: #DC2626; font-weight: 600; }
.spread-negative { background: #DC2626 !important; color: #FFFFFF; font-weight: 700; }
/* Netting Chart Styles */
.netting-section { margin-top: 32px; }
@@ -303,44 +371,22 @@ function buildHTML(data, agente, isAgentDashboard = true, diasPeriodo = null, as
.portfolio-kpi-grid { grid-template-columns: 1fr; }
.netting-kpi-grid { grid-template-columns: 1fr; }
}
</style>
`;
return `<!DOCTYPE html>
<html lang="pt-BR">
<head>
${buildHead('Dashboard', dashboardCSS, pageScripts)}
</head>
<body>
${isEmulating ? `
<div style="background:linear-gradient(90deg,#FF6B35,#F7931E);color:white;padding:12px 24px;text-align:center;font-size:13px;font-weight:600;display:flex;justify-content:center;align-items:center;gap:16px;">
<div class="emulation-banner">
<span>Modo Emulacao - Visualizando como: ${agente.nome}</span>
<a href="/admin" style="background:rgba(255,255,255,0.2);color:white;padding:6px 16px;border-radius:6px;text-decoration:none;font-size:12px;border:1px solid rgba(255,255,255,0.3);">Voltar ao BI - CCC</a>
<a href="${backUrl}">Voltar ao BI - CCC</a>
</div>
` : ''}
<div class="header">
<div class="header-inner">
<div style="display:flex;align-items:center;gap:16px;">
<img src="/public/logo.png" alt="CambioReal" class="header-logo">
<div>
<h1 style="display:flex;align-items:center;gap:10px;">
<span class="app-name-badge">BI - CCC</span>
${agente.nome}${agente.agente_id ? ` &mdash; #${agente.agente_id}` : ''}
</h1>
<div class="subtitle">Dashboard de Transacoes ${isAdminDash ? `(${data.length.toLocaleString('pt-BR')} registros)` : ''}</div>
</div>
</div>
<div class="header-right">
${isAdminDash ? `
<select onchange="window.location.href='/admin/dashboard?dias='+this.value" style="padding:8px 12px;border-radius:8px;border:1px solid rgba(255,255,255,0.3);background:rgba(255,255,255,0.1);color:white;font-size:13px;cursor:pointer;">
<option value="30" ${diasPeriodo === 30 ? 'selected' : ''}>30 dias</option>
<option value="60" ${diasPeriodo === 60 ? 'selected' : ''}>60 dias</option>
<option value="90" ${diasPeriodo === 90 ? 'selected' : ''}>90 dias</option>
<option value="180" ${diasPeriodo === 180 ? 'selected' : ''}>180 dias</option>
<option value="365" ${diasPeriodo === 365 ? 'selected' : ''}>1 ano</option>
</select>
<a href="/admin" class="btn-logout">Voltar</a>
` : ''}
<div class="badge"><span class="live-dot"></span>Ao vivo &mdash; ${now}</div>
<a href="/logout" class="btn-logout">Sair</a>
</div>
</div>
</div>
${buildHeader({ role, userName: agente.nome, activePage: 'dashboard', showNav: !isEmulating })}
<div class="filters">
<div class="filters-inner">
@@ -371,6 +417,45 @@ ${isEmulating ? `
</div>
</div>
<div class="trading-terminal">
<div class="live-rate-bar">
<span class="live-rate-dot"></span>
<span class="terminal-title">Live Rates</span>
<div class="rate-pair-group">
<span class="rate-pair-label">USD</span>
<button class="live-rate-btn compra">
<span class="rate-flags">\uD83C\uDDFA\uD83C\uDDF8 \u2192 \uD83C\uDDE7\uD83C\uDDF7</span>
<span class="rate-type">Compra</span>
<span class="rate-value" id="rateUsdCompra">--</span>
</button>
<button class="live-rate-btn venda">
<span class="rate-flags">\uD83C\uDDE7\uD83C\uDDF7 \u2192 \uD83C\uDDFA\uD83C\uDDF8</span>
<span class="rate-type">Venda</span>
<span class="rate-value" id="rateUsdVenda">--</span>
</button>
</div>
<div class="rate-separator"></div>
<div class="rate-pair-group">
<span class="rate-pair-label">EUR</span>
<button class="live-rate-btn compra">
<span class="rate-flags">\uD83C\uDDEA\uD83C\uDDFA \u2192 \uD83C\uDDE7\uD83C\uDDF7</span>
<span class="rate-type">Compra</span>
<span class="rate-value" id="rateEurCompra">--</span>
</button>
<button class="live-rate-btn venda">
<span class="rate-flags">\uD83C\uDDE7\uD83C\uDDF7 \u2192 \uD83C\uDDEA\uD83C\uDDFA</span>
<span class="rate-type">Venda</span>
<span class="rate-value" id="rateEurVenda">--</span>
</button>
</div>
<span class="live-rate-time" id="rateTime">--</span>
</div>
</div>
<div class="container">
<div class="kpi-grid" id="kpiGrid"></div>
@@ -451,7 +536,7 @@ ${isEmulating ? `
</div>
</div>
<div class="footer">BI - CCC &mdash; CambioReal Central Command &mdash; ${agente.nome}</div>
${buildFooter()}
<script>
let RAW_DATA = ${asyncLoad ? '[]' : JSON.stringify(data)};
@@ -470,6 +555,7 @@ const fmtBRL = v => v.toLocaleString('pt-BR', { style: 'currency', currency: 'BR
const fmtUSD = v => v.toLocaleString('pt-BR', { style: 'currency', currency: 'USD' });
const fmtNum = (v, d=2) => v.toLocaleString('pt-BR', { minimumFractionDigits: d, maximumFractionDigits: d });
const fmtPct = v => fmtNum(v, 2) + '%';
const fmtPct4 = v => fmtNum(v, 4) + '%'; // 4 decimal places for spread
// Loading overlay
function showLoading(msg) {
@@ -508,6 +594,36 @@ async function loadDataAsync() {
hideLoading();
}
// Live USD/BRL + EUR/BRL Rate
let _lastUsdBid = null, _lastUsdAsk = null, _lastEurBid = null, _lastEurAsk = null;
function pulseEl(el) { el.closest('.live-rate-btn').classList.add('pulse'); setTimeout(() => el.closest('.live-rate-btn').classList.remove('pulse'), 500); }
async function fetchLiveRate() {
try {
const resp = await fetch('/api/cotacao');
const json = await resp.json();
const usd = json.USDBRL;
const eur = json.EURBRL;
if (usd) {
const bid = parseFloat(usd.bid), ask = parseFloat(usd.ask);
const elC = document.getElementById('rateUsdCompra'), elV = document.getElementById('rateUsdVenda');
elC.textContent = bid.toFixed(4); elV.textContent = ask.toFixed(4);
if (_lastUsdBid !== null && bid !== _lastUsdBid) pulseEl(elC);
if (_lastUsdAsk !== null && ask !== _lastUsdAsk) pulseEl(elV);
_lastUsdBid = bid; _lastUsdAsk = ask;
}
if (eur) {
const bid = parseFloat(eur.bid), ask = parseFloat(eur.ask);
const elC = document.getElementById('rateEurCompra'), elV = document.getElementById('rateEurVenda');
elC.textContent = bid.toFixed(4); elV.textContent = ask.toFixed(4);
if (_lastEurBid !== null && bid !== _lastEurBid) pulseEl(elC);
if (_lastEurAsk !== null && ask !== _lastEurAsk) pulseEl(elV);
_lastEurBid = bid; _lastEurAsk = ask;
}
document.getElementById('rateTime').textContent = new Date().toLocaleTimeString('pt-BR');
} catch (e) { /* silently retry next cycle */ }
}
function startLiveRate() { fetchLiveRate(); setInterval(fetchLiveRate, 3000); }
function initDashboard() {
const clientes = [...new Set(RAW_DATA.map(r => r.cliente))].sort();
const sel = document.getElementById('filterCliente');
@@ -520,6 +636,7 @@ function initDashboard() {
document.getElementById('filterEnd').value = dates[dates.length - 1];
}
applyFilters();
startLiveRate();
}
(function init() {
@@ -537,6 +654,7 @@ function initDashboard() {
document.getElementById('filterEnd').value = dates[dates.length - 1];
}
applyFilters();
startLiveRate();
})();
function applyFilters() {
@@ -746,7 +864,7 @@ function exportCSV() {
const headers = ['Fluxo','Data/Hora','Cliente','Valor BRL','Valor USD','IOF %','IOF R$','PTAX','Taxa Cobrada','Spread','Spread %','Spread Liq %','Status'];
const rows = filtered.map(r => {
const spreadLiq = Math.max(0, r.spread_pct - 0.20);
const spreadLiq = r.spread_pct - 0.20;
return [
r.fluxo,
r.data_operacao || '',
@@ -758,8 +876,8 @@ function exportCSV() {
r.taxa_ptax.toFixed(4),
r.taxa_cobrada.toFixed(4),
r.spread_bruto.toFixed(4),
r.spread_pct.toFixed(2),
spreadLiq.toFixed(2),
r.spread_pct.toFixed(4),
spreadLiq.toFixed(4),
r.status || ''
].join(';');
});
@@ -781,18 +899,20 @@ function exportCSV() {
function calcSpreadLiquido(spreadPct) {
// Spread Líquido = Spread Cobrado - 0.20% (custo operacional)
const CUSTO_OPERACIONAL = 0.20;
return Math.max(0, spreadPct - CUSTO_OPERACIONAL);
return spreadPct - CUSTO_OPERACIONAL;
}
function getSpreadLiqClass(spreadLiq) {
if (spreadLiq < 0) return 'spread-negative';
if (spreadLiq >= 0.5) return 'spread-high';
if (spreadLiq >= 0.2) return 'spread-medium';
return 'spread-low';
}
function renderTable() {
const brlUsd = filtered.filter(r => r.fluxo === 'BRL \\u2192 USD');
const usdBrl = filtered.filter(r => r.fluxo === 'USD \\u2192 BRL');
// Filtrar apenas transações finalizadas
const brlUsd = filtered.filter(r => r.fluxo === 'BRL \\u2192 USD' && r.status === 'finalizado');
const usdBrl = filtered.filter(r => r.fluxo === 'USD \\u2192 BRL' && r.status && r.status !== '0000-00-00');
document.getElementById('tableTitle').textContent = 'Transacoes (' + filtered.length + ')';
@@ -806,8 +926,8 @@ function renderTable() {
<td class="num">\${fmtBRL(r.valor_reais)}</td><td class="num">\${fmtUSD(r.valor_dolar)}</td>
<td class="num">\${fmtPct(r.iof_pct)}</td><td class="num">\${fmtBRL(r.iof_valor_rs)}</td>
<td class="num">\${fmtNum(r.taxa_ptax, 4)}</td><td class="num">\${fmtNum(r.taxa_cobrada, 4)}</td>
<td class="num">\${fmtNum(r.spread_bruto, 4)}</td><td class="num">\${fmtPct(r.spread_pct)}</td>
<td class="num \${getSpreadLiqClass(spreadLiq)}">\${fmtPct(spreadLiq)}</td>
<td class="num">\${fmtNum(r.spread_bruto, 4)}</td><td class="num">\${fmtPct4(r.spread_pct)}</td>
<td class="num \${getSpreadLiqClass(spreadLiq)}">\${fmtPct4(spreadLiq)}</td>
<td>\${r.status || '-'}</td>
</tr>\`;
}).join('');
@@ -821,8 +941,8 @@ function renderTable() {
<td class="num">\${fmtBRL(r.valor_reais)}</td><td class="num">\${fmtUSD(r.valor_dolar)}</td>
<td class="num">\${fmtPct(r.iof_pct)}</td><td class="num">\${fmtBRL(r.iof_valor_rs)}</td>
<td class="num">\${fmtNum(r.taxa_ptax, 4)}</td><td class="num">\${fmtNum(r.taxa_cobrada, 4)}</td>
<td class="num">\${fmtNum(r.spread_bruto, 4)}</td><td class="num">\${fmtPct(r.spread_pct)}</td>
<td class="num \${getSpreadLiqClass(spreadLiq)}">\${fmtPct(spreadLiq)}</td>
<td class="num">\${fmtNum(r.spread_bruto, 4)}</td><td class="num">\${fmtPct4(r.spread_pct)}</td>
<td class="num \${getSpreadLiqClass(spreadLiq)}">\${fmtPct4(spreadLiq)}</td>
<td>\${r.status || '-'}</td>
</tr>\`;
}).join('');

View File

@@ -29,7 +29,7 @@ async function fetchTransacoes(agenteId) {
SELECT DISTINCT
c.nome AS cliente,
p.created_at AS data_operacao,
p.valor_sol AS valor_reais,
ROUND(p.valor * p.cotacao, 2) AS valor_reais,
p.valor AS valor_dolar,
0 AS iof_pct,
0 AS iof_valor_rs,
@@ -86,8 +86,8 @@ function serialize(rowsBrlUsd, rowsUsdBrl) {
iof_valor_rs: Number(r.iof_valor_rs),
taxa_ptax: Number(r.taxa_ptax),
taxa_cobrada: Number(r.taxa_cobrada),
spread_bruto: Math.abs(Number(r.spread_bruto)),
spread_pct: Math.abs(Number(r.spread_pct)),
spread_bruto: Number(r.spread_bruto),
spread_pct: Number(r.spread_pct),
status: r.status,
}));
@@ -121,7 +121,7 @@ async function fetchAllTransacoes(diasAtras = 90) {
SELECT
c.nome AS cliente,
p.created_at AS data_operacao,
p.valor_sol AS valor_reais,
ROUND(p.valor * p.cotacao, 2) AS valor_reais,
p.valor AS valor_dolar,
0 AS iof_pct,
0 AS iof_valor_rs,
@@ -340,6 +340,130 @@ async function fetchTrend30Days() {
}
}
// Tendência por período customizado - dados diários para gráfico
async function fetchTrendByPeriod(dataInicio, dataFim) {
const conn = await pool.getConnection();
try {
// BRL -> USD por dia
const [brlUsd] = await conn.execute(`
SELECT DATE(created_at) as dia, COUNT(*) as qtd,
ROUND(SUM(amount_usd), 2) as vol_usd,
ROUND(SUM(amount_brl), 2) as vol_brl
FROM br_transaction_to_usa
WHERE DATE(created_at) >= ? AND DATE(created_at) <= ?
GROUP BY DATE(created_at)
ORDER BY dia
`, [dataInicio, dataFim]);
// USD -> BRL por dia
const [usdBrl] = await conn.execute(`
SELECT DATE(created_at) as dia, COUNT(*) as qtd,
ROUND(SUM(valor), 2) as vol_usd,
ROUND(SUM(valor_sol), 2) as vol_brl
FROM pagamento_br
WHERE 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
`, [dataInicio, dataFim]);
// USD -> USD por dia
const [usdUsd] = await conn.execute(`
SELECT DATE(created_at) as dia, COUNT(*) as qtd,
ROUND(SUM(valor), 2) as vol_usd
FROM pagamento_br
WHERE DATE(created_at) >= ? AND DATE(created_at) <= ?
AND (cotacao IS NULL OR cotacao = 0 OR pgto = 'balance')
GROUP BY DATE(created_at)
ORDER BY dia
`, [dataInicio, dataFim]);
const formatRows = (rows) => rows.map(r => ({
dia: r.dia instanceof Date ? r.dia.toISOString().slice(0, 10) : String(r.dia).slice(0, 10),
qtd: Number(r.qtd),
vol_usd: Number(r.vol_usd),
vol_brl: Number(r.vol_brl) || 0
}));
return {
brlUsd: formatRows(brlUsd),
usdBrl: formatRows(usdBrl),
usdUsd: formatRows(usdUsd)
};
} finally {
conn.release();
}
}
// KPIs por período customizado
async function fetchKPIsByPeriod(dataInicio, dataFim) {
const conn = await pool.getConnection();
try {
// BRL -> USD
const [brlUsd] = await conn.execute(`
SELECT
COUNT(*) as qtd,
ROUND(SUM(amount_usd), 2) as vol_usd,
ROUND(SUM(amount_brl), 2) as vol_brl,
ROUND(AVG(amount_usd), 2) as ticket_medio
FROM br_transaction_to_usa
WHERE DATE(created_at) >= ? AND DATE(created_at) <= ?
`, [dataInicio, dataFim]);
// USD -> BRL
const [usdBrl] = await conn.execute(`
SELECT
COUNT(*) as qtd,
ROUND(SUM(valor), 2) as vol_usd,
ROUND(SUM(valor_sol), 2) as vol_brl,
ROUND(AVG(valor), 2) as ticket_medio
FROM pagamento_br
WHERE DATE(created_at) >= ? AND DATE(created_at) <= ?
AND cotacao IS NOT NULL AND cotacao > 0
AND (pgto IS NULL OR pgto != 'balance')
`, [dataInicio, dataFim]);
// USD -> USD
const [usdUsd] = await conn.execute(`
SELECT
COUNT(*) as qtd,
ROUND(SUM(valor), 2) as vol_usd,
ROUND(AVG(valor), 2) as ticket_medio
FROM pagamento_br
WHERE DATE(created_at) >= ? AND DATE(created_at) <= ?
AND (cotacao IS NULL OR cotacao = 0 OR pgto = 'balance')
`, [dataInicio, dataFim]);
const format = (row) => ({
qtd: Number(row[0]?.qtd) || 0,
vol_usd: Number(row[0]?.vol_usd) || 0,
vol_brl: Number(row[0]?.vol_brl) || 0,
ticket_medio: Number(row[0]?.ticket_medio) || 0
});
const brlUsdData = format(brlUsd);
const usdBrlData = format(usdBrl);
const usdUsdData = format(usdUsd);
return {
brlUsd: brlUsdData,
usdBrl: usdBrlData,
usdUsd: usdUsdData,
total: {
qtd: brlUsdData.qtd + usdBrlData.qtd + usdUsdData.qtd,
vol_usd: brlUsdData.vol_usd + usdBrlData.vol_usd + usdUsdData.vol_usd,
vol_brl: brlUsdData.vol_brl + usdBrlData.vol_brl,
ticket_medio: Math.round((brlUsdData.vol_usd + usdBrlData.vol_usd + usdUsdData.vol_usd) /
(brlUsdData.qtd + usdBrlData.qtd + usdUsdData.qtd) || 0)
},
periodo: { inicio: dataInicio, fim: dataFim }
};
} finally {
conn.release();
}
}
// Top 5 agentes por período (IDs do RDS, nomes do callback)
async function fetchTopAgentes(dias = 30, getAgenteName = null) {
const conn = await pool.getConnection();
@@ -379,4 +503,14 @@ async function fetchTopAgentes(dias = 30, getAgenteName = null) {
}
}
module.exports = { fetchTransacoes, fetchAllTransacoes, serialize, fetchDailyStats, fetchKPIs, fetchTrend30Days, fetchTopAgentes };
module.exports = {
fetchTransacoes,
fetchAllTransacoes,
serialize,
fetchDailyStats,
fetchKPIs,
fetchTrend30Days,
fetchTopAgentes,
fetchTrendByPeriod,
fetchKPIsByPeriod
};

View File

@@ -10,9 +10,12 @@ const cssVariables = `
--primary-light: #9B2DE5;
--primary-dark: #5A0091;
--primary-bg: #F5EAFA;
--admin-accent: #5A0091;
--admin-dark: #3D0066;
--admin-bg: #F5EAFA;
--admin-accent: #2E7D32;
--admin-dark: #1B5E20;
--admin-bg: #E8F5E9;
--corporate-accent: #7600be;
--corporate-dark: #5A0091;
--corporate-bg: #F5EAFA;
--bg: #F0F2F5;
--card: #FFFFFF;
--text: #1A1D23;
@@ -51,6 +54,10 @@ const headerCSS = `
--header-color: var(--admin-accent);
--header-dark: var(--admin-dark);
}
.app-header.corporate {
--header-color: var(--corporate-accent);
--header-dark: var(--corporate-dark);
}
.app-header.agent {
--header-color: var(--primary);
--header-dark: var(--primary-dark);
@@ -69,6 +76,11 @@ const headerCSS = `
display: flex;
align-items: center;
gap: 16px;
text-decoration: none;
color: white;
}
.header-brand:hover {
opacity: 0.95;
}
.header-brand .logo {
height: 36px;
@@ -195,32 +207,80 @@ const headerCSS = `
/* Responsive */
@media (max-width: 768px) {
.header-inner {
padding: 16px 20px;
padding: 12px 16px;
height: auto;
flex-direction: column;
gap: 12px;
gap: 10px;
}
.header-brand {
gap: 10px;
}
.header-brand .logo {
height: 28px;
padding: 4px 8px;
}
.header-brand .app-name-badge {
font-size: 12px;
padding: 5px 10px;
}
.header-brand .app-subtitle {
display: none;
}
.header-nav {
width: 100%;
justify-content: center;
gap: 6px;
}
.header-nav a {
font-size: 12px;
padding: 8px 12px;
}
.header-user {
width: 100%;
justify-content: center;
gap: 10px;
}
.header-user .user-info {
font-size: 12px;
}
.header-user .avatar {
width: 32px;
height: 32px;
font-size: 11px;
}
.header-user .btn-logout {
font-size: 11px;
padding: 6px 12px;
}
.app-container {
padding: 20px;
padding: 16px;
}
.header-brand .app-subtitle {
}
@media (max-width: 480px) {
.header-inner {
padding: 10px 12px;
}
.header-nav {
flex-wrap: wrap;
}
.header-nav a {
font-size: 11px;
padding: 6px 10px;
}
.header-user .user-info {
display: none;
}
.app-container {
padding: 12px;
}
}
`;
/**
* Gera o header HTML
* @param {Object} options
* @param {string} options.role - 'admin' ou 'agente'
* @param {string} options.role - 'admin', 'corporate' ou 'agente'
* @param {string} options.userName - Nome do usuário
* @param {string} options.activePage - Página ativa para nav
* @param {boolean} options.showNav - Mostrar navegação
@@ -228,7 +288,12 @@ const headerCSS = `
function buildHeader(options = {}) {
const { role = 'agente', userName = '', activePage = '', showNav = true } = options;
const isAdmin = role === 'admin';
const headerClass = isAdmin ? 'admin' : 'agent';
const isCorporate = role === 'corporate';
// Determine header class: admin (green), corporate (purple), agent (purple)
let headerClass = 'agent';
if (isAdmin) headerClass = 'admin';
else if (isCorporate) headerClass = 'corporate';
const initials = userName
.split(' ')
@@ -237,36 +302,59 @@ function buildHeader(options = {}) {
.join('')
.toUpperCase();
// Admin navigation: Corporate Dashboard + Users (admin on the right)
const adminNav = `
<nav class="header-nav">
<a href="/admin" class="${activePage === 'home' ? 'active' : ''}">Home</a>
<a href="/admin/agentes" class="${activePage === 'users' ? 'active' : ''}">Usuarios</a>
<a href="/admin/dashboard" class="${activePage === 'dashboard' ? 'active' : ''}">Dashboard</a>
<a href="/corporate" class="${activePage === 'dashboard' ? 'active' : ''}">Corporate</a>
<a href="/admin" class="${activePage === 'users' ? 'active' : ''}">Usuarios</a>
</nav>
`;
// Corporate navigation: Dashboard only
const corporateNav = `
<nav class="header-nav">
<a href="/corporate" class="${activePage === 'dashboard' ? 'active' : ''}">Dashboard</a>
</nav>
`;
// Agent navigation: Just their dashboard
const agentNav = `
<nav class="header-nav">
<a href="/dashboard" class="active">Meu Dashboard</a>
</nav>
`;
// Select navigation based on role
let nav = agentNav;
if (isAdmin) nav = adminNav;
else if (isCorporate) nav = corporateNav;
// Home URL based on role
let homeUrl = '/dashboard';
if (isAdmin) homeUrl = '/corporate'; // Admin home is Corporate Dashboard
else if (isCorporate) homeUrl = '/corporate';
// Role label for display
let roleLabel = 'Agente';
if (isAdmin) roleLabel = 'Admin';
else if (isCorporate) roleLabel = 'Corporate';
return `
<header class="app-header ${headerClass}">
<div class="header-inner">
<div class="header-brand">
<a href="${homeUrl}" class="header-brand">
<img src="/public/logo.png" alt="CambioReal" class="logo">
<div class="app-name">
<span class="app-name-badge">BI - CCC</span>
<span class="app-subtitle">Central Command Center</span>
</div>
</div>
${showNav ? (isAdmin ? adminNav : agentNav) : ''}
</a>
${showNav ? nav : ''}
<div class="header-user">
<div class="user-info">
<span class="user-avatar">${initials}</span>
<span>${userName}</span>
<span class="user-role">${isAdmin ? 'Admin' : 'Agente'}</span>
<span class="user-role">${roleLabel}</span>
</div>
<a href="/logout" class="btn-logout">Sair</a>
</div>