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:
@@ -11,6 +11,7 @@ services:
|
|||||||
# Netbird VPN Setup Key
|
# Netbird VPN Setup Key
|
||||||
- NETBIRD_SETUP_KEY=${NETBIRD_SETUP_KEY:-14A782C8-24D2-46A9-B427-A422854E9B50}
|
- NETBIRD_SETUP_KEY=${NETBIRD_SETUP_KEY:-14A782C8-24D2-46A9-B427-A422854E9B50}
|
||||||
- NETBIRD_MANAGEMENT_URL=${NETBIRD_MANAGEMENT_URL:-https://netbird.cambioreal.com}
|
- NETBIRD_MANAGEMENT_URL=${NETBIRD_MANAGEMENT_URL:-https://netbird.cambioreal.com}
|
||||||
|
- NETBIRD_HOSTNAME=${NETBIRD_HOSTNAME:-bi-ccc}
|
||||||
|
|
||||||
# MySQL RDS Connection (via Netbird)
|
# MySQL RDS Connection (via Netbird)
|
||||||
- MYSQL_URL=${MYSQL_URL}
|
- MYSQL_URL=${MYSQL_URL}
|
||||||
@@ -20,9 +21,12 @@ services:
|
|||||||
# App Config
|
# App Config
|
||||||
- SESSION_SECRET=${SESSION_SECRET:-bi-agentes-secret-key-change-me}
|
- SESSION_SECRET=${SESSION_SECRET:-bi-agentes-secret-key-change-me}
|
||||||
- PORT=3080
|
- PORT=3080
|
||||||
|
- AWESOME_API_TOKEN=${AWESOME_API_TOKEN:-2dbcf6a26f9bd9016859a2d31f99fbd8fc9ac4e9e8e440d94002ac3b436a747a}
|
||||||
volumes:
|
volumes:
|
||||||
# Persist SQLite database
|
# Persist SQLite database
|
||||||
- ./data:/app/data
|
- ./data:/app/data
|
||||||
|
# Persist NetBird config (keeps same peer/IP between restarts)
|
||||||
|
- ./netbird:/var/lib/netbird
|
||||||
cap_add:
|
cap_add:
|
||||||
# Required for Netbird VPN
|
# Required for Netbird VPN
|
||||||
- NET_ADMIN
|
- NET_ADMIN
|
||||||
|
|||||||
@@ -16,7 +16,9 @@ if [ -n "$NETBIRD_SETUP_KEY" ]; then
|
|||||||
sleep 3
|
sleep 3
|
||||||
|
|
||||||
# Connect using setup key and management URL
|
# 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
|
# Wait for connection
|
||||||
echo "Waiting for Netbird connection..."
|
echo "Waiting for Netbird connection..."
|
||||||
@@ -24,6 +26,11 @@ if [ -n "$NETBIRD_SETUP_KEY" ]; then
|
|||||||
|
|
||||||
# Check connection status
|
# Check connection status
|
||||||
netbird status || echo "Netbird connection pending..."
|
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
|
else
|
||||||
echo "WARNING: NETBIRD_SETUP_KEY not set. Database connection may fail."
|
echo "WARNING: NETBIRD_SETUP_KEY not set. Database connection may fail."
|
||||||
fi
|
fi
|
||||||
|
|||||||
192
public/admin-login.html
Normal file
192
public/admin-login.html
Normal 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">⚙</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 © 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>
|
||||||
@@ -136,6 +136,22 @@
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--text-secondary);
|
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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
195
server.js
195
server.js
@@ -11,7 +11,7 @@ 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 } = require('./src/queries');
|
const { fetchTransacoes, fetchAllTransacoes, serialize, fetchDailyStats, fetchKPIs, fetchTrend30Days, fetchTopAgentes, fetchTrendByPeriod, fetchKPIsByPeriod } = 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');
|
||||||
@@ -38,10 +38,17 @@ app.use('/public', express.static(path.join(__dirname, 'public')));
|
|||||||
|
|
||||||
// --- Unified Login Routes ---
|
// --- 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)
|
// Root -> login page (or redirect if logged in)
|
||||||
app.get('/', (req, res) => {
|
app.get('/', (req, res) => {
|
||||||
if (req.session?.user) {
|
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');
|
res.redirect('/login');
|
||||||
});
|
});
|
||||||
@@ -49,7 +56,7 @@ app.get('/', (req, res) => {
|
|||||||
// Login page
|
// Login page
|
||||||
app.get('/login', (req, res) => {
|
app.get('/login', (req, res) => {
|
||||||
if (req.session?.user) {
|
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'));
|
res.sendFile(path.join(__dirname, 'public', 'login.html'));
|
||||||
});
|
});
|
||||||
@@ -72,11 +79,7 @@ app.post('/login', async (req, res) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Redirect based on role
|
// Redirect based on role
|
||||||
if (user.role === 'admin') {
|
res.redirect(getRedirectByRole(user.role));
|
||||||
res.redirect('/admin');
|
|
||||||
} else {
|
|
||||||
res.redirect('/dashboard');
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Login error:', err);
|
console.error('Login error:', err);
|
||||||
res.redirect(`/login?error=1&email=${emailParam}`);
|
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
|
// Admin home - User management panel (admin only)
|
||||||
app.get('/admin', requireRole('admin'), async (req, res) => {
|
app.get('/admin', requireRole('admin'), (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) => {
|
|
||||||
try {
|
try {
|
||||||
const agentes = db.prepare('SELECT * FROM agentes ORDER BY id DESC').all();
|
const agentes = db.prepare('SELECT * FROM agentes ORDER BY id DESC').all();
|
||||||
const html = buildAdminHTML(agentes, req.session.user);
|
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)
|
// Alias: /admin/usuarios -> /admin
|
||||||
app.get('/admin/dashboard', requireRole('admin'), async (req, res) => {
|
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 {
|
try {
|
||||||
const user = req.session.user;
|
const user = req.session.user;
|
||||||
const html = buildAdminDashboardHTML({ nome: user.nome, email: user.email });
|
const html = buildAdminDashboardHTML(user);
|
||||||
res.send(html);
|
res.send(html);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Admin dashboard error:', err);
|
console.error('Corporate dashboard error:', err);
|
||||||
res.status(500).send('Erro ao carregar dashboard admin: ' + err.message);
|
res.status(500).send('Erro ao carregar dashboard corporate: ' + err.message);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// API endpoint for admin dashboard data (admin only)
|
// Legacy route - redirect to /corporate
|
||||||
app.get('/admin/api/data', requireRole('admin'), async (req, res) => {
|
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 {
|
try {
|
||||||
const dias = parseInt(req.query.dias) || 90;
|
const dias = parseInt(req.query.dias) || 90;
|
||||||
const { rowsBrlUsd, rowsUsdBrl } = await fetchAllTransacoes(dias);
|
const { rowsBrlUsd, rowsUsdBrl } = await fetchAllTransacoes(dias);
|
||||||
const data = serialize(rowsBrlUsd, rowsUsdBrl);
|
const data = serialize(rowsBrlUsd, rowsUsdBrl);
|
||||||
res.json({ success: true, data, count: data.length });
|
res.json({ success: true, data, count: data.length });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Admin API error:', err);
|
console.error('Corporate API error:', err);
|
||||||
res.status(500).json({ success: false, error: err.message });
|
res.status(500).json({ success: false, error: err.message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// API: KPIs (hoje vs média 30 dias) - com cache
|
// 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 {
|
try {
|
||||||
const data = await cache.getOrFetch('kpis', fetchKPIs, 5 * 60 * 1000);
|
const data = await cache.getOrFetch('kpis', fetchKPIs, 5 * 60 * 1000);
|
||||||
res.json({ success: true, data });
|
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
|
// 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 {
|
try {
|
||||||
const data = await cache.getOrFetch('trend30', fetchTrend30Days, 10 * 60 * 1000);
|
const data = await cache.getOrFetch('trend30', fetchTrend30Days, 10 * 60 * 1000);
|
||||||
res.json({ success: true, data });
|
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
|
// 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 {
|
try {
|
||||||
const dias = parseInt(req.query.dias) || 30;
|
const dias = parseInt(req.query.dias) || 30;
|
||||||
const cacheKey = `top-agentes-${dias}`;
|
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)
|
// API: Corporate Dashboard - KPIs por período
|
||||||
app.get('/admin/emular/:agente_id', requireRole('admin'), async (req, res) => {
|
app.get('/corporate/api/kpis-period', requireRole('corporate', 'admin'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const agenteId = parseInt(req.params.agente_id);
|
const { inicio, fim } = req.query;
|
||||||
const agente = db.prepare('SELECT * FROM agentes WHERE agente_id = ?').get(agenteId);
|
if (!inicio || !fim) {
|
||||||
|
return res.status(400).json({ success: false, error: 'Parametros inicio e fim sao obrigatorios' });
|
||||||
if (!agente) {
|
|
||||||
return res.status(404).send('Agente nao encontrado');
|
|
||||||
}
|
}
|
||||||
|
const data = await fetchKPIsByPeriod(inicio, fim);
|
||||||
const { rowsBrlUsd, rowsUsdBrl } = await fetchTransacoes(agenteId);
|
res.json({ success: true, data });
|
||||||
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);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Admin emulate error:', err);
|
console.error('Corporate KPIs API error:', err);
|
||||||
res.status(500).send('Erro ao emular agente: ' + err.message);
|
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)
|
// 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;
|
||||||
@@ -245,7 +333,8 @@ app.post('/admin/agentes', requireRole('admin'), async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const userRole = role || 'agente';
|
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) {
|
if (userRole === 'agente' && !agente_id) {
|
||||||
return res.status(400).json({ error: 'Agente ID e obrigatorio para agentes' });
|
return res.status(400).json({ error: 'Agente ID e obrigatorio para agentes' });
|
||||||
|
|||||||
@@ -1,13 +1,110 @@
|
|||||||
/**
|
/**
|
||||||
* Admin Dashboard - KPIs, Tendências e Ranking
|
* Admin Dashboard Corporate - KPIs, Tendências e Detalhes
|
||||||
* Lazy loading para performance
|
* Filtros por período: Este Mês, Mês Anterior, Últimos 2 Meses, ou período customizado
|
||||||
*/
|
*/
|
||||||
const { buildHeader, buildFooter, buildHead } = require('./ui-template');
|
const { buildHeader, buildFooter, buildHead } = require('./ui-template');
|
||||||
|
|
||||||
function buildAdminDashboardHTML(admin) {
|
function buildAdminDashboardHTML(user) {
|
||||||
const pageScripts = `<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>`;
|
// 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 = `
|
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 {
|
.dashboard-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 24px;
|
gap: 24px;
|
||||||
@@ -41,7 +138,7 @@ function buildAdminDashboardHTML(admin) {
|
|||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
}
|
}
|
||||||
.kpi-value {
|
.kpi-value {
|
||||||
font-size: 36px;
|
font-size: 32px;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
margin-bottom: 4px;
|
margin-bottom: 4px;
|
||||||
@@ -50,18 +147,14 @@ function buildAdminDashboardHTML(admin) {
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
.kpi-badge {
|
.kpi-detail {
|
||||||
position: absolute;
|
margin-top: 8px;
|
||||||
top: 16px;
|
padding-top: 8px;
|
||||||
right: 16px;
|
border-top: 1px solid var(--border);
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
font-weight: 700;
|
color: var(--text-secondary);
|
||||||
padding: 4px 10px;
|
|
||||||
border-radius: 12px;
|
|
||||||
}
|
}
|
||||||
.kpi-badge.up { background: var(--green-bg); color: var(--green); }
|
.kpi-detail span { font-weight: 600; color: var(--text); }
|
||||||
.kpi-badge.down { background: var(--red-bg); color: var(--red); }
|
|
||||||
.kpi-badge.neutral { background: var(--blue-bg); color: var(--blue); }
|
|
||||||
|
|
||||||
/* Chart Cards */
|
/* Chart Cards */
|
||||||
.charts-row {
|
.charts-row {
|
||||||
@@ -78,6 +171,9 @@ function buildAdminDashboardHTML(admin) {
|
|||||||
min-height: 380px;
|
min-height: 380px;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
.chart-card.full-width {
|
||||||
|
grid-column: span 2;
|
||||||
|
}
|
||||||
.chart-card h3 {
|
.chart-card h3 {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
@@ -89,6 +185,41 @@ function buildAdminDashboardHTML(admin) {
|
|||||||
position: relative;
|
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 */
|
||||||
.ranking-card {
|
.ranking-card {
|
||||||
background: var(--card);
|
background: var(--card);
|
||||||
@@ -97,7 +228,6 @@ function buildAdminDashboardHTML(admin) {
|
|||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
|
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
|
||||||
min-height: 300px;
|
min-height: 300px;
|
||||||
position: relative;
|
|
||||||
}
|
}
|
||||||
.ranking-header {
|
.ranking-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -110,15 +240,6 @@ function buildAdminDashboardHTML(admin) {
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--text);
|
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 {
|
.ranking-table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
@@ -138,11 +259,7 @@ function buildAdminDashboardHTML(admin) {
|
|||||||
border-bottom: 1px solid #F3F4F6;
|
border-bottom: 1px solid #F3F4F6;
|
||||||
}
|
}
|
||||||
.ranking-table tr:last-child td { border-bottom: none; }
|
.ranking-table tr:last-child td { border-bottom: none; }
|
||||||
.rank-num {
|
.rank-num { width: 40px; font-weight: 800; color: var(--primary); }
|
||||||
width: 40px;
|
|
||||||
font-weight: 800;
|
|
||||||
color: var(--primary);
|
|
||||||
}
|
|
||||||
.rank-1 { color: #FFD700; }
|
.rank-1 { color: #FFD700; }
|
||||||
.rank-2 { color: #C0C0C0; }
|
.rank-2 { color: #C0C0C0; }
|
||||||
.rank-3 { color: #CD7F32; }
|
.rank-3 { color: #CD7F32; }
|
||||||
@@ -163,9 +280,7 @@ function buildAdminDashboardHTML(admin) {
|
|||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
animation: spin 0.8s linear infinite;
|
animation: spin 0.8s linear infinite;
|
||||||
}
|
}
|
||||||
@keyframes spin {
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
to { transform: rotate(360deg); }
|
|
||||||
}
|
|
||||||
.loading-text {
|
.loading-text {
|
||||||
margin-left: 12px;
|
margin-left: 12px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
@@ -175,72 +290,117 @@ function buildAdminDashboardHTML(admin) {
|
|||||||
/* Responsive */
|
/* Responsive */
|
||||||
@media (max-width: 1200px) {
|
@media (max-width: 1200px) {
|
||||||
.kpi-row { grid-template-columns: repeat(2, 1fr); }
|
.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) {
|
@media (max-width: 768px) {
|
||||||
.kpi-row { grid-template-columns: 1fr; }
|
.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>
|
return `<!DOCTYPE html>
|
||||||
<html lang="pt-BR">
|
<html lang="pt-BR">
|
||||||
<head>
|
<head>
|
||||||
${buildHead('Dashboard', pageCSS, pageScripts)}
|
${buildHead('Dashboard Corporate', pageCSS, pageScripts)}
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
${buildHeader({ role: 'admin', userName: admin.nome, activePage: 'dashboard' })}
|
${buildHeader({ role: role, userName: user.nome, activePage: 'dashboard' })}
|
||||||
|
|
||||||
<div class="app-container">
|
<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">
|
<div class="dashboard-grid">
|
||||||
|
|
||||||
<!-- KPIs Row -->
|
|
||||||
<div class="kpi-row" id="kpiRow">
|
<div class="kpi-row" id="kpiRow">
|
||||||
<div class="kpi-card total">
|
<div class="kpi-card total"><div class="loading"><div class="spinner"></div><span class="loading-text">Carregando...</span></div></div>
|
||||||
<div class="loading"><div class="spinner"></div><span class="loading-text">Carregando...</span></div>
|
<div class="kpi-card brl-usd"><div class="loading"><div class="spinner"></div></div></div>
|
||||||
</div>
|
<div class="kpi-card usd-brl"><div class="loading"><div class="spinner"></div></div></div>
|
||||||
<div class="kpi-card brl-usd">
|
<div class="kpi-card usd-usd"><div class="loading"><div class="spinner"></div></div></div>
|
||||||
<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>
|
</div>
|
||||||
|
|
||||||
<!-- Charts Row -->
|
|
||||||
<div class="charts-row">
|
<div class="charts-row">
|
||||||
<div class="chart-card" id="chartConsolidado">
|
<div class="chart-card" id="chartVolume">
|
||||||
<h3>Tendencia 30 dias - Total Consolidado</h3>
|
<h3>Volume Diario (USD)</h3>
|
||||||
<div class="chart-wrap">
|
<div class="chart-wrap"><div class="loading"><div class="spinner"></div><span class="loading-text">Carregando grafico...</span></div></div>
|
||||||
<div class="loading"><div class="spinner"></div><span class="loading-text">Carregando grafico...</span></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="chart-card" id="chartFluxos">
|
<div class="chart-card" id="chartOrdens">
|
||||||
<h3>Tendencia 30 dias - Por Fluxo</h3>
|
<h3>Quantidade de Ordens por Dia</h3>
|
||||||
<div class="chart-wrap">
|
<div class="chart-wrap"><div class="loading"><div class="spinner"></div><span class="loading-text">Carregando grafico...</span></div></div>
|
||||||
<div class="loading"><div class="spinner"></div><span class="loading-text">Carregando grafico...</span></div>
|
|
||||||
</div>
|
|
||||||
</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-card" id="rankingCard">
|
||||||
<div class="ranking-header">
|
<div class="ranking-header"><h3>Top 5 Agentes no Periodo</h3></div>
|
||||||
<h3>Top 5 Agentes</h3>
|
<div id="rankingContent"><div class="loading"><div class="spinner"></div><span class="loading-text">Carregando ranking...</span></div></div>
|
||||||
<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>
|
|
||||||
<div id="rankingContent">
|
|
||||||
<div class="loading"><div class="spinner"></div><span class="loading-text">Carregando ranking...</span></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -249,213 +409,175 @@ ${buildFooter()}
|
|||||||
<script>
|
<script>
|
||||||
const formatUSD = (v) => '$' + Number(v).toLocaleString('en-US', { minimumFractionDigits: 0, maximumFractionDigits: 0 });
|
const formatUSD = (v) => '$' + Number(v).toLocaleString('en-US', { minimumFractionDigits: 0, maximumFractionDigits: 0 });
|
||||||
const formatNum = (v) => Number(v).toLocaleString('pt-BR');
|
const formatNum = (v) => Number(v).toLocaleString('pt-BR');
|
||||||
|
const formatDate = (d) => d.split('-').reverse().join('/');
|
||||||
|
|
||||||
// Load KPIs
|
let currentPeriod = { inicio: '${firstDayOfMonth}', fim: '${today}' };
|
||||||
async function loadKPIs() {
|
let trendData = null;
|
||||||
try {
|
let charts = {};
|
||||||
const res = await fetch('/admin/api/kpis');
|
|
||||||
const json = await res.json();
|
|
||||||
if (!json.success) throw new Error(json.error);
|
|
||||||
|
|
||||||
const d = json.data;
|
function getPresetDates(preset) {
|
||||||
const calcVar = (hoje, media) => media > 0 ? ((hoje - media) / media * 100).toFixed(0) : 0;
|
const now = new Date();
|
||||||
|
let inicio, fim;
|
||||||
const cards = document.querySelectorAll('#kpiRow .kpi-card');
|
if (preset === 'thisMonth') {
|
||||||
|
inicio = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||||
// Total
|
fim = now;
|
||||||
const totalVar = calcVar(d.total.hoje_qtd, d.total.media_qtd);
|
} else if (preset === 'lastMonth') {
|
||||||
cards[0].innerHTML = \`
|
inicio = new Date(now.getFullYear(), now.getMonth() - 1, 1);
|
||||||
<div class="kpi-badge \${totalVar >= 0 ? 'up' : 'down'}">\${totalVar >= 0 ? '+' : ''}\${totalVar}%</div>
|
fim = new Date(now.getFullYear(), now.getMonth(), 0);
|
||||||
<div class="kpi-label">Total Ordens Hoje</div>
|
} else if (preset === 'last2Months') {
|
||||||
<div class="kpi-value">\${d.total.hoje_qtd}</div>
|
inicio = new Date(now.getFullYear(), now.getMonth() - 1, 1);
|
||||||
<div class="kpi-sub">Media 30d: \${d.total.media_qtd} ordens</div>
|
fim = now;
|
||||||
\`;
|
|
||||||
|
|
||||||
// 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 → 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 → 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 → 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);
|
|
||||||
}
|
}
|
||||||
|
return { inicio: inicio.toISOString().slice(0, 10), fim: fim.toISOString().slice(0, 10) };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load Trend Charts
|
document.querySelectorAll('.preset-btn').forEach(btn => {
|
||||||
async function loadTrend() {
|
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 {
|
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();
|
const json = await res.json();
|
||||||
if (!json.success) throw new Error(json.error);
|
if (!json.success) throw new Error(json.error);
|
||||||
|
|
||||||
const d = json.data;
|
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 → 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 → 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 → 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();
|
const allDates = new Set();
|
||||||
d.brlUsd.forEach(r => allDates.add(r.dia));
|
trendData.brlUsd.forEach(r => allDates.add(r.dia));
|
||||||
d.usdBrl.forEach(r => allDates.add(r.dia));
|
trendData.usdBrl.forEach(r => allDates.add(r.dia));
|
||||||
d.usdUsd.forEach(r => allDates.add(r.dia));
|
trendData.usdUsd.forEach(r => allDates.add(r.dia));
|
||||||
const dates = Array.from(allDates).sort();
|
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 =>
|
if (charts.volume) charts.volume.destroy();
|
||||||
getQtd(d.brlUsd, dia) + getQtd(d.usdBrl, dia) + getQtd(d.usdUsd, dia)
|
if (charts.ordens) charts.ordens.destroy();
|
||||||
);
|
if (charts.fluxos) charts.fluxos.destroy();
|
||||||
|
|
||||||
// Chart 1: Consolidado
|
document.querySelector('#chartVolume .chart-wrap').innerHTML = '<canvas id="canvasVolume"></canvas>';
|
||||||
document.querySelector('#chartConsolidado .chart-wrap').innerHTML = '<canvas id="canvasConsolidado"></canvas>';
|
charts.volume = new Chart(document.getElementById('canvasVolume'), {
|
||||||
new Chart(document.getElementById('canvasConsolidado'), {
|
type: 'bar',
|
||||||
type: 'line',
|
|
||||||
data: {
|
data: {
|
||||||
labels: dates.map(d => d.slice(5)),
|
labels: dates.map(d => d.slice(5)),
|
||||||
datasets: [{
|
datasets: [
|
||||||
label: 'Total Ordens',
|
{ label: 'BRL→USD', data: dates.map(dia => getVal(trendData.brlUsd, dia, 'vol_usd')), backgroundColor: '#1A73E8', borderRadius: 4 },
|
||||||
data: consolidado,
|
{ label: 'USD→BRL', data: dates.map(dia => getVal(trendData.usdBrl, dia, 'vol_usd')), backgroundColor: '#1E8E3E', borderRadius: 4 },
|
||||||
borderColor: '#7600be',
|
{ label: 'USD→USD', data: dates.map(dia => getVal(trendData.usdUsd, dia, 'vol_usd')), backgroundColor: '#7B1FA2', borderRadius: 4 }
|
||||||
backgroundColor: 'rgba(118,0,190,0.1)',
|
]
|
||||||
fill: true,
|
|
||||||
tension: 0.3,
|
|
||||||
pointRadius: 2
|
|
||||||
}]
|
|
||||||
},
|
},
|
||||||
options: {
|
options: {
|
||||||
responsive: true,
|
responsive: true, maintainAspectRatio: false,
|
||||||
maintainAspectRatio: false,
|
plugins: { legend: { position: 'top', labels: { usePointStyle: true, font: { size: 11 } } }, tooltip: { callbacks: { label: ctx => ctx.dataset.label + ': ' + formatUSD(ctx.raw) } } },
|
||||||
plugins: { legend: { display: false } },
|
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 } } }
|
||||||
scales: {
|
|
||||||
y: { beginAtZero: true, grid: { color: '#F3F4F6' } },
|
|
||||||
x: { grid: { display: false }, ticks: { maxRotation: 0, autoSkip: true, maxTicksLimit: 10 } }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 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>';
|
document.querySelector('#chartFluxos .chart-wrap').innerHTML = '<canvas id="canvasFluxos"></canvas>';
|
||||||
new Chart(document.getElementById('canvasFluxos'), {
|
charts.fluxos = new Chart(document.getElementById('canvasFluxos'), {
|
||||||
type: 'line',
|
type: 'line',
|
||||||
data: {
|
data: {
|
||||||
labels: dates.map(d => d.slice(5)),
|
labels: dates.map(d => d.slice(5)),
|
||||||
datasets: [
|
datasets: [
|
||||||
{
|
{ 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: 'BRL→USD',
|
{ 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 },
|
||||||
data: dates.map(dia => getQtd(d.brlUsd, dia)),
|
{ 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 }
|
||||||
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
|
|
||||||
}
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
options: {
|
options: {
|
||||||
responsive: true,
|
responsive: true, maintainAspectRatio: false,
|
||||||
maintainAspectRatio: false,
|
plugins: { legend: { position: 'top', labels: { usePointStyle: true, font: { size: 11 } } }, tooltip: { callbacks: { label: ctx => ctx.dataset.label + ': ' + formatUSD(ctx.raw) } } },
|
||||||
plugins: {
|
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 } } }
|
||||||
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 } }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} 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() {
|
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');
|
const content = document.getElementById('rankingContent');
|
||||||
content.innerHTML = '<div class="loading"><div class="spinner"></div><span class="loading-text">Carregando...</span></div>';
|
content.innerHTML = '<div class="loading"><div class="spinner"></div><span class="loading-text">Carregando...</span></div>';
|
||||||
|
|
||||||
try {
|
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();
|
const json = await res.json();
|
||||||
if (!json.success) throw new Error(json.error);
|
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) {
|
let html = '<table class="ranking-table"><thead><tr><th>#</th><th>Agente</th><th>Qtd Ordens</th><th>Volume USD</th></tr></thead><tbody>';
|
||||||
content.innerHTML = '<p style="text-align:center;color:var(--text-muted);padding:40px;">Nenhum dado encontrado para o periodo.</p>';
|
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>'; });
|
||||||
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>
|
|
||||||
\`;
|
|
||||||
});
|
|
||||||
|
|
||||||
html += '</tbody></table>';
|
html += '</tbody></table>';
|
||||||
content.innerHTML = html;
|
content.innerHTML = html;
|
||||||
} catch (err) {
|
} catch (err) { console.error('Ranking error:', err); content.innerHTML = '<p style="color:var(--red);padding:20px;">Erro ao carregar ranking</p>'; }
|
||||||
console.error('Ranking error:', err);
|
|
||||||
content.innerHTML = '<p style="color:var(--red);padding:20px;">Erro ao carregar ranking</p>';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load all sections
|
function loadAllData() { updatePeriodInfo(); loadKPIs(); loadTrend(); loadRanking(); }
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', loadAllData);
|
||||||
loadKPIs();
|
<\/script>
|
||||||
loadTrend();
|
|
||||||
loadRanking();
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>`;
|
</html>`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,9 +4,11 @@
|
|||||||
*/
|
*/
|
||||||
const { buildHeader, buildFooter, buildHead } = require('./ui-template');
|
const { buildHeader, buildFooter, buildHead } = require('./ui-template');
|
||||||
|
|
||||||
function buildAdminHomeHTML(stats, admin) {
|
function buildAdminHomeHTML(stats, user) {
|
||||||
const now = new Date().toLocaleString('pt-BR');
|
const now = new Date().toLocaleString('pt-BR');
|
||||||
const hoje = new Date().toLocaleDateString('pt-BR', { weekday: 'long', day: 'numeric', month: 'long' });
|
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 formatBRL = (v) => v.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
|
||||||
const formatUSD = (v) => v.toLocaleString('pt-BR', { style: 'currency', currency: 'USD' });
|
const formatUSD = (v) => v.toLocaleString('pt-BR', { style: 'currency', currency: 'USD' });
|
||||||
@@ -104,6 +106,31 @@ function buildAdminHomeHTML(stats, admin) {
|
|||||||
}
|
}
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.kpi-grid, .charts-grid { grid-template-columns: 1fr; }
|
.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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
${buildHeader({ role: 'admin', userName: admin.nome, activePage: 'home' })}
|
${buildHeader({ role: role, userName: user.nome, activePage: 'home' })}
|
||||||
|
|
||||||
<div class="app-container">
|
<div class="app-container">
|
||||||
<div class="date-banner">
|
<div class="date-banner">
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ function buildAdminHTML(agentes, admin) {
|
|||||||
.status-badge.active { background: var(--green-bg); color: var(--green); }
|
.status-badge.active { background: var(--green-bg); color: var(--green); }
|
||||||
.status-badge.inactive { background: var(--red-bg); color: var(--red); }
|
.status-badge.inactive { background: var(--red-bg); color: var(--red); }
|
||||||
.status-badge.admin { background: var(--admin-bg); color: var(--admin-accent); }
|
.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); }
|
.status-badge.agent { background: var(--blue-bg); color: var(--blue); }
|
||||||
|
|
||||||
.actions { display: flex; gap: 6px; }
|
.actions { display: flex; gap: 6px; }
|
||||||
@@ -130,7 +131,49 @@ function buildAdminHTML(agentes, admin) {
|
|||||||
.alert.show { display: block; }
|
.alert.show { display: block; }
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@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>
|
</style>
|
||||||
`;
|
`;
|
||||||
@@ -173,12 +216,12 @@ ${buildHeader({ role: 'admin', userName: admin.nome, activePage: 'users' })}
|
|||||||
<td>${a.id}</td>
|
<td>${a.id}</td>
|
||||||
<td>${a.nome}</td>
|
<td>${a.nome}</td>
|
||||||
<td>${a.email}</td>
|
<td>${a.email}</td>
|
||||||
<td><span class="status-badge ${a.role === 'admin' ? 'admin' : 'agent'}">${a.role === 'admin' ? 'Admin' : 'Agente'}</span></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.agente_id}</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><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>${a.created_at ? new Date(a.created_at).toLocaleDateString('pt-BR') : '-'}</td>
|
||||||
<td class="actions">
|
<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-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-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>
|
<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>
|
<label>Tipo de Usuario</label>
|
||||||
<select id="agentRole" name="role" onchange="toggleAgenteIdField()">
|
<select id="agentRole" name="role" onchange="toggleAgenteIdField()">
|
||||||
<option value="agente">Agente</option>
|
<option value="agente">Agente</option>
|
||||||
|
<option value="corporate">Corporate</option>
|
||||||
<option value="admin">Administrador</option>
|
<option value="admin">Administrador</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
@@ -276,7 +320,8 @@ function toggleAgenteIdField() {
|
|||||||
const role = document.getElementById('agentRole').value;
|
const role = document.getElementById('agentRole').value;
|
||||||
const agenteIdGroup = document.getElementById('agenteIdGroup');
|
const agenteIdGroup = document.getElementById('agenteIdGroup');
|
||||||
const agenteIdInput = document.getElementById('agentAgenteId');
|
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';
|
agenteIdGroup.style.display = 'none';
|
||||||
agenteIdInput.required = false;
|
agenteIdInput.required = false;
|
||||||
agenteIdInput.value = '';
|
agenteIdInput.value = '';
|
||||||
@@ -344,6 +389,7 @@ async function submitAgentForm(e) {
|
|||||||
role: role,
|
role: role,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Only agents need agente_id
|
||||||
if (role === 'agente') {
|
if (role === 'agente') {
|
||||||
const agenteId = document.getElementById('agentAgenteId').value;
|
const agenteId = document.getElementById('agentAgenteId').value;
|
||||||
if (!isEditing && !agenteId) {
|
if (!isEditing && !agenteId) {
|
||||||
@@ -351,6 +397,9 @@ async function submitAgentForm(e) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
data.agente_id = parseInt(agenteId) || 0;
|
data.agente_id = parseInt(agenteId) || 0;
|
||||||
|
} else {
|
||||||
|
// Admin and Corporate don't have agente_id
|
||||||
|
data.agente_id = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isEditing) {
|
if (!isEditing) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* Autenticacao Unificada — login/logout com bcrypt + express-session
|
* Autenticacao Unificada — login/logout com bcrypt + express-session
|
||||||
* Suporta roles: 'agente' | 'admin'
|
* Suporta roles: 'agente' | 'corporate' | 'admin'
|
||||||
*/
|
*/
|
||||||
const bcrypt = require('bcrypt');
|
const bcrypt = require('bcrypt');
|
||||||
const db = require('./db-local');
|
const db = require('./db-local');
|
||||||
@@ -8,7 +8,7 @@ const db = require('./db-local');
|
|||||||
const SALT_ROUNDS = 10;
|
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) {
|
async function createUser(email, senha, nome, role = 'agente', agenteId = 0) {
|
||||||
const hash = await bcrypt.hash(senha, SALT_ROUNDS);
|
const hash = await bcrypt.hash(senha, SALT_ROUNDS);
|
||||||
@@ -41,7 +41,8 @@ async function authenticate(email, senha) {
|
|||||||
* Middleware que verifica autenticacao e roles
|
* Middleware que verifica autenticacao e roles
|
||||||
* Uso: requireRole() - qualquer usuario logado
|
* Uso: requireRole() - qualquer usuario logado
|
||||||
* requireRole('admin') - apenas admins
|
* requireRole('admin') - apenas admins
|
||||||
* requireRole('agente', 'admin') - ambos
|
* requireRole('corporate', 'admin') - corporate e admins
|
||||||
|
* requireRole('agente') - apenas agentes
|
||||||
*/
|
*/
|
||||||
function requireRole(...roles) {
|
function requireRole(...roles) {
|
||||||
return (req, res, next) => {
|
return (req, res, next) => {
|
||||||
|
|||||||
348
src/dashboard.js
348
src/dashboard.js
@@ -1,92 +1,72 @@
|
|||||||
/**
|
/**
|
||||||
* Gera HTML do dashboard — parametrizado por agente
|
* 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) {
|
function buildHTML(data, agente, isAgentDashboard = true, diasPeriodo = null, asyncLoad = false, isEmulating = false) {
|
||||||
const now = new Date().toLocaleString('pt-BR');
|
const now = new Date().toLocaleString('pt-BR');
|
||||||
const isAdminDash = diasPeriodo !== null;
|
const isAdminDash = diasPeriodo !== null;
|
||||||
return `<!DOCTYPE html>
|
// When emulating, use the emulator's role if provided, otherwise default to admin
|
||||||
<html lang="pt-BR">
|
const emulatorRole = agente.emulatorRole || 'admin';
|
||||||
<head>
|
const role = isEmulating ? emulatorRole : (isAdminDash ? 'admin' : 'agente');
|
||||||
<meta charset="UTF-8">
|
// Determine the back URL based on emulator's role
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
const backUrl = emulatorRole === 'corporate' ? '/corporate' : '/admin';
|
||||||
<title>BI - CCC — ${agente.nome}</title>
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.7/dist/chart.umd.min.js"><\/script>
|
const pageScripts = `<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>
|
const dashboardCSS = `
|
||||||
:root {
|
/* Emulation Banner */
|
||||||
--primary: #7600be;
|
.emulation-banner {
|
||||||
--primary-light: #9B2DE5;
|
background: linear-gradient(90deg, #FF6B35, #F7931E);
|
||||||
--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%);
|
|
||||||
color: white;
|
color: white;
|
||||||
box-shadow: 0 2px 8px rgba(74,37,112,0.3);
|
padding: 10px 24px;
|
||||||
}
|
text-align: center;
|
||||||
.header-inner {
|
font-size: 13px;
|
||||||
max-width: 1600px;
|
font-weight: 600;
|
||||||
margin: 0 auto;
|
|
||||||
padding: 16px 40px;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
}
|
}
|
||||||
.header-logo {
|
.emulation-banner a {
|
||||||
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 {
|
|
||||||
background: rgba(255,255,255,0.2);
|
background: rgba(255,255,255,0.2);
|
||||||
padding: 6px 14px;
|
color: white;
|
||||||
|
padding: 6px 16px;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
font-size: 14px;
|
text-decoration: none;
|
||||||
font-weight: 800;
|
font-size: 12px;
|
||||||
|
border: 1px solid rgba(255,255,255,0.3);
|
||||||
}
|
}
|
||||||
.btn-logout {
|
.emulation-banner a:hover {
|
||||||
background: rgba(255,255,255,0.15); color: white; border: 1px solid rgba(255,255,255,0.3);
|
background: 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;
|
|
||||||
|
/* 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; } }
|
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.4; } }
|
||||||
|
|
||||||
|
/* Filters */
|
||||||
.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);
|
box-shadow: 0 1px 2px rgba(0,0,0,0.04);
|
||||||
}
|
}
|
||||||
.filters-inner {
|
.filters-inner {
|
||||||
@@ -124,6 +104,94 @@ function buildHTML(data, agente, isAgentDashboard = true, diasPeriodo = null, as
|
|||||||
}
|
}
|
||||||
.btn-export:hover { background: #25a244; transform: translateY(-1px); }
|
.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; }
|
.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; }
|
.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; }
|
tbody tr:nth-child(even):hover { background: #F8F5FF; }
|
||||||
.num { text-align: right; }
|
.num { text-align: right; }
|
||||||
|
|
||||||
.footer { text-align: center; padding: 20px; font-size: 12px; color: var(--text-muted); }
|
|
||||||
|
|
||||||
/* Portfolio Analysis Styles */
|
/* Portfolio Analysis Styles */
|
||||||
.section-title {
|
.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-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-medium { background: var(--orange-bg) !important; color: var(--orange); font-weight: 600; }
|
||||||
.spread-low { background: #FEE2E2 !important; color: #DC2626; 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 Chart Styles */
|
||||||
.netting-section { margin-top: 32px; }
|
.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; }
|
.portfolio-kpi-grid { grid-template-columns: 1fr; }
|
||||||
.netting-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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
${isEmulating ? `
|
${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>
|
<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>
|
||||||
` : ''}
|
` : ''}
|
||||||
<div class="header">
|
${buildHeader({ role, userName: agente.nome, activePage: 'dashboard', showNav: !isEmulating })}
|
||||||
<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 ? ` — #${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 — ${now}</div>
|
|
||||||
<a href="/logout" class="btn-logout">Sair</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="filters">
|
<div class="filters">
|
||||||
<div class="filters-inner">
|
<div class="filters-inner">
|
||||||
@@ -371,6 +417,45 @@ ${isEmulating ? `
|
|||||||
</div>
|
</div>
|
||||||
</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="container">
|
||||||
<div class="kpi-grid" id="kpiGrid"></div>
|
<div class="kpi-grid" id="kpiGrid"></div>
|
||||||
|
|
||||||
@@ -451,7 +536,7 @@ ${isEmulating ? `
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="footer">BI - CCC — CambioReal Central Command — ${agente.nome}</div>
|
${buildFooter()}
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
let RAW_DATA = ${asyncLoad ? '[]' : JSON.stringify(data)};
|
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 fmtUSD = v => v.toLocaleString('pt-BR', { style: 'currency', currency: 'USD' });
|
||||||
const fmtNum = (v, d=2) => v.toLocaleString('pt-BR', { minimumFractionDigits: d, maximumFractionDigits: d });
|
const fmtNum = (v, d=2) => v.toLocaleString('pt-BR', { minimumFractionDigits: d, maximumFractionDigits: d });
|
||||||
const fmtPct = v => fmtNum(v, 2) + '%';
|
const fmtPct = v => fmtNum(v, 2) + '%';
|
||||||
|
const fmtPct4 = v => fmtNum(v, 4) + '%'; // 4 decimal places for spread
|
||||||
|
|
||||||
// Loading overlay
|
// Loading overlay
|
||||||
function showLoading(msg) {
|
function showLoading(msg) {
|
||||||
@@ -508,6 +594,36 @@ async function loadDataAsync() {
|
|||||||
hideLoading();
|
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() {
|
function initDashboard() {
|
||||||
const clientes = [...new Set(RAW_DATA.map(r => r.cliente))].sort();
|
const clientes = [...new Set(RAW_DATA.map(r => r.cliente))].sort();
|
||||||
const sel = document.getElementById('filterCliente');
|
const sel = document.getElementById('filterCliente');
|
||||||
@@ -520,6 +636,7 @@ function initDashboard() {
|
|||||||
document.getElementById('filterEnd').value = dates[dates.length - 1];
|
document.getElementById('filterEnd').value = dates[dates.length - 1];
|
||||||
}
|
}
|
||||||
applyFilters();
|
applyFilters();
|
||||||
|
startLiveRate();
|
||||||
}
|
}
|
||||||
|
|
||||||
(function init() {
|
(function init() {
|
||||||
@@ -537,6 +654,7 @@ function initDashboard() {
|
|||||||
document.getElementById('filterEnd').value = dates[dates.length - 1];
|
document.getElementById('filterEnd').value = dates[dates.length - 1];
|
||||||
}
|
}
|
||||||
applyFilters();
|
applyFilters();
|
||||||
|
startLiveRate();
|
||||||
})();
|
})();
|
||||||
|
|
||||||
function applyFilters() {
|
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 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 rows = filtered.map(r => {
|
||||||
const spreadLiq = Math.max(0, r.spread_pct - 0.20);
|
const spreadLiq = r.spread_pct - 0.20;
|
||||||
return [
|
return [
|
||||||
r.fluxo,
|
r.fluxo,
|
||||||
r.data_operacao || '',
|
r.data_operacao || '',
|
||||||
@@ -758,8 +876,8 @@ function exportCSV() {
|
|||||||
r.taxa_ptax.toFixed(4),
|
r.taxa_ptax.toFixed(4),
|
||||||
r.taxa_cobrada.toFixed(4),
|
r.taxa_cobrada.toFixed(4),
|
||||||
r.spread_bruto.toFixed(4),
|
r.spread_bruto.toFixed(4),
|
||||||
r.spread_pct.toFixed(2),
|
r.spread_pct.toFixed(4),
|
||||||
spreadLiq.toFixed(2),
|
spreadLiq.toFixed(4),
|
||||||
r.status || ''
|
r.status || ''
|
||||||
].join(';');
|
].join(';');
|
||||||
});
|
});
|
||||||
@@ -781,18 +899,20 @@ function exportCSV() {
|
|||||||
function calcSpreadLiquido(spreadPct) {
|
function calcSpreadLiquido(spreadPct) {
|
||||||
// Spread Líquido = Spread Cobrado - 0.20% (custo operacional)
|
// Spread Líquido = Spread Cobrado - 0.20% (custo operacional)
|
||||||
const CUSTO_OPERACIONAL = 0.20;
|
const CUSTO_OPERACIONAL = 0.20;
|
||||||
return Math.max(0, spreadPct - CUSTO_OPERACIONAL);
|
return spreadPct - CUSTO_OPERACIONAL;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getSpreadLiqClass(spreadLiq) {
|
function getSpreadLiqClass(spreadLiq) {
|
||||||
|
if (spreadLiq < 0) return 'spread-negative';
|
||||||
if (spreadLiq >= 0.5) return 'spread-high';
|
if (spreadLiq >= 0.5) return 'spread-high';
|
||||||
if (spreadLiq >= 0.2) return 'spread-medium';
|
if (spreadLiq >= 0.2) return 'spread-medium';
|
||||||
return 'spread-low';
|
return 'spread-low';
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderTable() {
|
function renderTable() {
|
||||||
const brlUsd = filtered.filter(r => r.fluxo === 'BRL \\u2192 USD');
|
// Filtrar apenas transações finalizadas
|
||||||
const usdBrl = filtered.filter(r => r.fluxo === 'USD \\u2192 BRL');
|
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 + ')';
|
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">\${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">\${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.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">\${fmtNum(r.spread_bruto, 4)}</td><td class="num">\${fmtPct4(r.spread_pct)}</td>
|
||||||
<td class="num \${getSpreadLiqClass(spreadLiq)}">\${fmtPct(spreadLiq)}</td>
|
<td class="num \${getSpreadLiqClass(spreadLiq)}">\${fmtPct4(spreadLiq)}</td>
|
||||||
<td>\${r.status || '-'}</td>
|
<td>\${r.status || '-'}</td>
|
||||||
</tr>\`;
|
</tr>\`;
|
||||||
}).join('');
|
}).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">\${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">\${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.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">\${fmtNum(r.spread_bruto, 4)}</td><td class="num">\${fmtPct4(r.spread_pct)}</td>
|
||||||
<td class="num \${getSpreadLiqClass(spreadLiq)}">\${fmtPct(spreadLiq)}</td>
|
<td class="num \${getSpreadLiqClass(spreadLiq)}">\${fmtPct4(spreadLiq)}</td>
|
||||||
<td>\${r.status || '-'}</td>
|
<td>\${r.status || '-'}</td>
|
||||||
</tr>\`;
|
</tr>\`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|||||||
144
src/queries.js
144
src/queries.js
@@ -29,7 +29,7 @@ async function fetchTransacoes(agenteId) {
|
|||||||
SELECT DISTINCT
|
SELECT DISTINCT
|
||||||
c.nome AS cliente,
|
c.nome AS cliente,
|
||||||
p.created_at AS data_operacao,
|
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,
|
p.valor AS valor_dolar,
|
||||||
0 AS iof_pct,
|
0 AS iof_pct,
|
||||||
0 AS iof_valor_rs,
|
0 AS iof_valor_rs,
|
||||||
@@ -86,8 +86,8 @@ function serialize(rowsBrlUsd, rowsUsdBrl) {
|
|||||||
iof_valor_rs: Number(r.iof_valor_rs),
|
iof_valor_rs: Number(r.iof_valor_rs),
|
||||||
taxa_ptax: Number(r.taxa_ptax),
|
taxa_ptax: Number(r.taxa_ptax),
|
||||||
taxa_cobrada: Number(r.taxa_cobrada),
|
taxa_cobrada: Number(r.taxa_cobrada),
|
||||||
spread_bruto: Math.abs(Number(r.spread_bruto)),
|
spread_bruto: Number(r.spread_bruto),
|
||||||
spread_pct: Math.abs(Number(r.spread_pct)),
|
spread_pct: Number(r.spread_pct),
|
||||||
status: r.status,
|
status: r.status,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -121,7 +121,7 @@ async function fetchAllTransacoes(diasAtras = 90) {
|
|||||||
SELECT
|
SELECT
|
||||||
c.nome AS cliente,
|
c.nome AS cliente,
|
||||||
p.created_at AS data_operacao,
|
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,
|
p.valor AS valor_dolar,
|
||||||
0 AS iof_pct,
|
0 AS iof_pct,
|
||||||
0 AS iof_valor_rs,
|
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)
|
// Top 5 agentes por período (IDs do RDS, nomes do callback)
|
||||||
async function fetchTopAgentes(dias = 30, getAgenteName = null) {
|
async function fetchTopAgentes(dias = 30, getAgenteName = null) {
|
||||||
const conn = await pool.getConnection();
|
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
|
||||||
|
};
|
||||||
|
|||||||
@@ -10,9 +10,12 @@ const cssVariables = `
|
|||||||
--primary-light: #9B2DE5;
|
--primary-light: #9B2DE5;
|
||||||
--primary-dark: #5A0091;
|
--primary-dark: #5A0091;
|
||||||
--primary-bg: #F5EAFA;
|
--primary-bg: #F5EAFA;
|
||||||
--admin-accent: #5A0091;
|
--admin-accent: #2E7D32;
|
||||||
--admin-dark: #3D0066;
|
--admin-dark: #1B5E20;
|
||||||
--admin-bg: #F5EAFA;
|
--admin-bg: #E8F5E9;
|
||||||
|
--corporate-accent: #7600be;
|
||||||
|
--corporate-dark: #5A0091;
|
||||||
|
--corporate-bg: #F5EAFA;
|
||||||
--bg: #F0F2F5;
|
--bg: #F0F2F5;
|
||||||
--card: #FFFFFF;
|
--card: #FFFFFF;
|
||||||
--text: #1A1D23;
|
--text: #1A1D23;
|
||||||
@@ -51,6 +54,10 @@ const headerCSS = `
|
|||||||
--header-color: var(--admin-accent);
|
--header-color: var(--admin-accent);
|
||||||
--header-dark: var(--admin-dark);
|
--header-dark: var(--admin-dark);
|
||||||
}
|
}
|
||||||
|
.app-header.corporate {
|
||||||
|
--header-color: var(--corporate-accent);
|
||||||
|
--header-dark: var(--corporate-dark);
|
||||||
|
}
|
||||||
.app-header.agent {
|
.app-header.agent {
|
||||||
--header-color: var(--primary);
|
--header-color: var(--primary);
|
||||||
--header-dark: var(--primary-dark);
|
--header-dark: var(--primary-dark);
|
||||||
@@ -69,6 +76,11 @@ const headerCSS = `
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
|
text-decoration: none;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.header-brand:hover {
|
||||||
|
opacity: 0.95;
|
||||||
}
|
}
|
||||||
.header-brand .logo {
|
.header-brand .logo {
|
||||||
height: 36px;
|
height: 36px;
|
||||||
@@ -195,32 +207,80 @@ const headerCSS = `
|
|||||||
/* Responsive */
|
/* Responsive */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.header-inner {
|
.header-inner {
|
||||||
padding: 16px 20px;
|
padding: 12px 16px;
|
||||||
height: auto;
|
height: auto;
|
||||||
flex-direction: column;
|
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 {
|
.header-nav {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.header-nav a {
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 8px 12px;
|
||||||
}
|
}
|
||||||
.header-user {
|
.header-user {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
justify-content: center;
|
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 {
|
.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;
|
display: none;
|
||||||
}
|
}
|
||||||
|
.app-container {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gera o header HTML
|
* Gera o header HTML
|
||||||
* @param {Object} options
|
* @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.userName - Nome do usuário
|
||||||
* @param {string} options.activePage - Página ativa para nav
|
* @param {string} options.activePage - Página ativa para nav
|
||||||
* @param {boolean} options.showNav - Mostrar navegação
|
* @param {boolean} options.showNav - Mostrar navegação
|
||||||
@@ -228,7 +288,12 @@ const headerCSS = `
|
|||||||
function buildHeader(options = {}) {
|
function buildHeader(options = {}) {
|
||||||
const { role = 'agente', userName = '', activePage = '', showNav = true } = options;
|
const { role = 'agente', userName = '', activePage = '', showNav = true } = options;
|
||||||
const isAdmin = role === 'admin';
|
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
|
const initials = userName
|
||||||
.split(' ')
|
.split(' ')
|
||||||
@@ -237,36 +302,59 @@ function buildHeader(options = {}) {
|
|||||||
.join('')
|
.join('')
|
||||||
.toUpperCase();
|
.toUpperCase();
|
||||||
|
|
||||||
|
// Admin navigation: Corporate Dashboard + Users (admin on the right)
|
||||||
const adminNav = `
|
const adminNav = `
|
||||||
<nav class="header-nav">
|
<nav class="header-nav">
|
||||||
<a href="/admin" class="${activePage === 'home' ? 'active' : ''}">Home</a>
|
<a href="/corporate" class="${activePage === 'dashboard' ? 'active' : ''}">Corporate</a>
|
||||||
<a href="/admin/agentes" class="${activePage === 'users' ? 'active' : ''}">Usuarios</a>
|
<a href="/admin" class="${activePage === 'users' ? 'active' : ''}">Usuarios</a>
|
||||||
<a href="/admin/dashboard" class="${activePage === 'dashboard' ? 'active' : ''}">Dashboard</a>
|
|
||||||
</nav>
|
</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 = `
|
const agentNav = `
|
||||||
<nav class="header-nav">
|
<nav class="header-nav">
|
||||||
<a href="/dashboard" class="active">Meu Dashboard</a>
|
<a href="/dashboard" class="active">Meu Dashboard</a>
|
||||||
</nav>
|
</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 `
|
return `
|
||||||
<header class="app-header ${headerClass}">
|
<header class="app-header ${headerClass}">
|
||||||
<div class="header-inner">
|
<div class="header-inner">
|
||||||
<div class="header-brand">
|
<a href="${homeUrl}" class="header-brand">
|
||||||
<img src="/public/logo.png" alt="CambioReal" class="logo">
|
<img src="/public/logo.png" alt="CambioReal" class="logo">
|
||||||
<div class="app-name">
|
<div class="app-name">
|
||||||
<span class="app-name-badge">BI - CCC</span>
|
<span class="app-name-badge">BI - CCC</span>
|
||||||
<span class="app-subtitle">Central Command Center</span>
|
<span class="app-subtitle">Central Command Center</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</a>
|
||||||
${showNav ? (isAdmin ? adminNav : agentNav) : ''}
|
${showNav ? nav : ''}
|
||||||
<div class="header-user">
|
<div class="header-user">
|
||||||
<div class="user-info">
|
<div class="user-info">
|
||||||
<span class="user-avatar">${initials}</span>
|
<span class="user-avatar">${initials}</span>
|
||||||
<span>${userName}</span>
|
<span>${userName}</span>
|
||||||
<span class="user-role">${isAdmin ? 'Admin' : 'Agente'}</span>
|
<span class="user-role">${roleLabel}</span>
|
||||||
</div>
|
</div>
|
||||||
<a href="/logout" class="btn-logout">Sair</a>
|
<a href="/logout" class="btn-logout">Sair</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user