- Dark/light mode toggle across all pages (login, dashboard, corporate, admin, BI) - BI Executive redesigned as permanent dark trading console (Bloomberg-style) - Floating vertical nav with anchor scroll for mobile navigation - Chart.js bundled locally (eliminates CDN dependency) - Chart.js inlined in HTML for guaranteed loading - Fix: themeScript </script> tag had literal backslash breaking HTML parser - Fix: each chart wrapped in individual try/catch for graceful degradation - No-cache headers on BI page to prevent stale HTML - Robust init that handles DOMContentLoaded already fired Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
467 lines
16 KiB
JavaScript
467 lines
16 KiB
JavaScript
/**
|
|
* BI - CCC (Central Command Center) — CambioReal
|
|
* Login Unificado: todos os usuarios acessam via /login
|
|
*
|
|
* Uso: node server.js
|
|
* Abre: http://localhost:3080
|
|
*/
|
|
require('dotenv').config();
|
|
|
|
const express = require('express');
|
|
const session = require('express-session');
|
|
const path = require('path');
|
|
const { authenticate, requireAuth, requireRole, createAgente, createUser } = require('./src/auth');
|
|
const { fetchTransacoes, fetchAllTransacoes, serialize, fetchDailyStats, fetchKPIs, fetchTrend30Days, fetchTopAgentes, fetchTrendByPeriod, fetchKPIsByPeriod, fetchBIData, fetchRevenueAnalytics } = require('./src/queries');
|
|
const { buildHTML } = require('./src/dashboard');
|
|
const { buildAdminHTML } = require('./src/admin-panel');
|
|
const { buildAdminHomeHTML } = require('./src/admin-home');
|
|
const { buildAdminDashboardHTML } = require('./src/admin-dashboard');
|
|
const { buildAdminBIHTML } = require('./src/admin-bi');
|
|
const bcrypt = require('bcrypt');
|
|
const db = require('./src/db-local');
|
|
const cache = require('./src/cache');
|
|
|
|
const app = express();
|
|
const PORT = process.env.PORT || 3080;
|
|
|
|
// Middleware
|
|
app.use(express.urlencoded({ extended: false }));
|
|
app.use(express.json());
|
|
app.use(session({
|
|
secret: process.env.SESSION_SECRET || 'bi-agentes-default-secret',
|
|
resave: false,
|
|
saveUninitialized: false,
|
|
cookie: { maxAge: 8 * 60 * 60 * 1000 }, // 8 horas
|
|
}));
|
|
|
|
// Static files
|
|
app.use('/public', express.static(path.join(__dirname, 'public')));
|
|
|
|
// --- Unified Login Routes ---
|
|
|
|
// Helper function to get redirect URL based on role
|
|
function getRedirectByRole(role) {
|
|
if (role === 'admin') return '/corporate'; // Admin vai direto pro Corporate Dashboard
|
|
if (role === 'corporate') return '/corporate';
|
|
return '/dashboard';
|
|
}
|
|
|
|
// Root -> login page (or redirect if logged in)
|
|
app.get('/', (req, res) => {
|
|
if (req.session?.user) {
|
|
return res.redirect(getRedirectByRole(req.session.user.role));
|
|
}
|
|
res.redirect('/login');
|
|
});
|
|
|
|
// Login page
|
|
app.get('/login', (req, res) => {
|
|
if (req.session?.user) {
|
|
return res.redirect(getRedirectByRole(req.session.user.role));
|
|
}
|
|
res.sendFile(path.join(__dirname, 'public', 'login.html'));
|
|
});
|
|
|
|
// Unified Login POST - detects role and redirects accordingly
|
|
app.post('/login', async (req, res) => {
|
|
const { email, senha } = req.body;
|
|
const emailParam = encodeURIComponent(email || '');
|
|
try {
|
|
const user = await authenticate(email, senha);
|
|
if (!user) return res.redirect(`/login?error=1&email=${emailParam}`);
|
|
|
|
// Unified session
|
|
req.session.user = {
|
|
id: user.id,
|
|
email: user.email,
|
|
nome: user.nome,
|
|
role: user.role || 'agente',
|
|
agente_id: user.agente_id
|
|
};
|
|
|
|
// Redirect based on role
|
|
res.redirect(getRedirectByRole(user.role));
|
|
} catch (err) {
|
|
console.error('Login error:', err);
|
|
res.redirect(`/login?error=1&email=${emailParam}`);
|
|
}
|
|
});
|
|
|
|
// Unified Logout
|
|
app.get('/logout', (req, res) => {
|
|
req.session.destroy(() => res.redirect('/login'));
|
|
});
|
|
|
|
// Legacy admin login - redirect to unified login
|
|
app.get('/admin/login', (req, res) => {
|
|
res.redirect('/login');
|
|
});
|
|
|
|
// Legacy admin logout - redirect to unified logout
|
|
app.get('/admin/logout', (req, res) => {
|
|
res.redirect('/logout');
|
|
});
|
|
|
|
// --- Agent Routes ---
|
|
|
|
// Dashboard (agente only)
|
|
app.get('/dashboard', requireRole('agente'), async (req, res) => {
|
|
try {
|
|
const user = req.session.user;
|
|
const { rowsBrlUsd, rowsUsdBrl } = await fetchTransacoes(user.agente_id);
|
|
const data = serialize(rowsBrlUsd, rowsUsdBrl);
|
|
const html = buildHTML(data, user);
|
|
res.send(html);
|
|
} catch (err) {
|
|
console.error('Dashboard error:', err);
|
|
res.status(500).send('Erro ao carregar dashboard: ' + err.message);
|
|
}
|
|
});
|
|
|
|
// --- Admin Routes (User Management - admin only) ---
|
|
|
|
// Admin home - User management panel (admin only)
|
|
app.get('/admin', requireRole('admin'), (req, res) => {
|
|
try {
|
|
const agentes = db.prepare('SELECT * FROM agentes ORDER BY id DESC').all();
|
|
const html = buildAdminHTML(agentes, req.session.user);
|
|
res.send(html);
|
|
} catch (err) {
|
|
console.error('Admin panel error:', err);
|
|
res.status(500).send('Erro ao carregar painel admin: ' + err.message);
|
|
}
|
|
});
|
|
|
|
// Alias: /admin/usuarios -> /admin
|
|
app.get('/admin/usuarios', requireRole('admin'), (req, res) => {
|
|
res.redirect('/admin');
|
|
});
|
|
|
|
// Legacy route - redirect to /admin
|
|
app.get('/admin/agentes', requireRole('admin'), (req, res) => {
|
|
res.redirect('/admin');
|
|
});
|
|
|
|
// --- Corporate Routes (Dashboard + Emulation - corporate and admin) ---
|
|
|
|
// Corporate Dashboard - Full KPIs, Trends and Ranking
|
|
app.get('/corporate', requireRole('corporate', 'admin'), async (req, res) => {
|
|
try {
|
|
const user = req.session.user;
|
|
const html = buildAdminDashboardHTML(user);
|
|
res.send(html);
|
|
} catch (err) {
|
|
console.error('Corporate dashboard error:', err);
|
|
res.status(500).send('Erro ao carregar dashboard corporate: ' + err.message);
|
|
}
|
|
});
|
|
|
|
// Legacy route - redirect to /corporate
|
|
app.get('/corporate/dashboard', requireRole('corporate', 'admin'), (req, res) => {
|
|
res.redirect('/corporate');
|
|
});
|
|
|
|
// Corporate emulate agent - view dashboard as specific agent
|
|
app.get('/corporate/emular/:agente_id', requireRole('corporate', 'admin'), async (req, res) => {
|
|
try {
|
|
const agenteId = parseInt(req.params.agente_id);
|
|
const agente = db.prepare('SELECT * FROM agentes WHERE agente_id = ?').get(agenteId);
|
|
|
|
if (!agente) {
|
|
return res.status(404).send('Agente nao encontrado');
|
|
}
|
|
|
|
const { rowsBrlUsd, rowsUsdBrl } = await fetchTransacoes(agenteId);
|
|
const data = serialize(rowsBrlUsd, rowsUsdBrl);
|
|
const html = buildHTML(data, {
|
|
nome: agente.nome + ' (Emulando)',
|
|
agente_id: agenteId,
|
|
email: agente.email,
|
|
emulatorRole: req.session.user.role // Pass the emulator's role
|
|
}, true, null, false, true); // isEmulating = true
|
|
res.send(html);
|
|
} catch (err) {
|
|
console.error('Corporate emulate error:', err);
|
|
res.status(500).send('Erro ao emular agente: ' + err.message);
|
|
}
|
|
});
|
|
|
|
// Legacy route - redirect to /corporate/emular
|
|
app.get('/admin/emular/:agente_id', requireRole('admin'), (req, res) => {
|
|
res.redirect(`/corporate/emular/${req.params.agente_id}`);
|
|
});
|
|
|
|
// --- Live Rate Proxy (caches for 3s to avoid rate limiting) ---
|
|
let _rateCache = { data: null, ts: 0 };
|
|
app.get('/api/cotacao', async (req, res) => {
|
|
try {
|
|
const now = Date.now();
|
|
if (_rateCache.data && now - _rateCache.ts < 3000) {
|
|
return res.json(_rateCache.data);
|
|
}
|
|
const token = process.env.AWESOME_API_TOKEN || '';
|
|
const url = 'https://economia.awesomeapi.com.br/json/last/USD-BRL,EUR-BRL' + (token ? '?token=' + token : '');
|
|
const resp = await fetch(url);
|
|
const json = await resp.json();
|
|
_rateCache = { data: json, ts: now };
|
|
res.json(json);
|
|
} catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// --- Corporate API Routes (dashboard data - corporate and admin) ---
|
|
|
|
// API endpoint for corporate dashboard data
|
|
app.get('/corporate/api/data', requireRole('corporate', 'admin'), async (req, res) => {
|
|
try {
|
|
const dias = parseInt(req.query.dias) || 90;
|
|
const { rowsBrlUsd, rowsUsdBrl } = await fetchAllTransacoes(dias);
|
|
const data = serialize(rowsBrlUsd, rowsUsdBrl);
|
|
res.json({ success: true, data, count: data.length });
|
|
} catch (err) {
|
|
console.error('Corporate API error:', err);
|
|
res.status(500).json({ success: false, error: err.message });
|
|
}
|
|
});
|
|
|
|
// API: KPIs (hoje vs média 30 dias) - com cache
|
|
app.get('/corporate/api/kpis', requireRole('corporate', 'admin'), async (req, res) => {
|
|
try {
|
|
const data = await cache.getOrFetch('kpis', fetchKPIs, 5 * 60 * 1000);
|
|
res.json({ success: true, data });
|
|
} catch (err) {
|
|
console.error('KPIs API error:', err);
|
|
res.status(500).json({ success: false, error: err.message });
|
|
}
|
|
});
|
|
|
|
// API: Tendência 30 dias - com cache
|
|
app.get('/corporate/api/trend', requireRole('corporate', 'admin'), async (req, res) => {
|
|
try {
|
|
const data = await cache.getOrFetch('trend30', fetchTrend30Days, 10 * 60 * 1000);
|
|
res.json({ success: true, data });
|
|
} catch (err) {
|
|
console.error('Trend API error:', err);
|
|
res.status(500).json({ success: false, error: err.message });
|
|
}
|
|
});
|
|
|
|
// API: Top 5 agentes - com cache por período
|
|
app.get('/corporate/api/top-agentes', requireRole('corporate', 'admin'), async (req, res) => {
|
|
try {
|
|
const dias = parseInt(req.query.dias) || 30;
|
|
const cacheKey = `top-agentes-${dias}`;
|
|
|
|
// Busca dados do RDS (com cache)
|
|
const rawData = await cache.getOrFetch(cacheKey, () => fetchTopAgentes(dias), 10 * 60 * 1000);
|
|
|
|
// Adiciona nomes dos agentes do SQLite local
|
|
const data = rawData.map(r => {
|
|
const agente = db.prepare('SELECT nome FROM agentes WHERE agente_id = ?').get(r.agente_id);
|
|
return {
|
|
...r,
|
|
agente: agente?.nome || `Agente #${r.agente_id}`
|
|
};
|
|
});
|
|
|
|
res.json({ success: true, data });
|
|
} catch (err) {
|
|
console.error('Top Agentes API error:', err);
|
|
res.status(500).json({ success: false, error: err.message });
|
|
}
|
|
});
|
|
|
|
// API: Corporate Dashboard - KPIs por período
|
|
app.get('/corporate/api/kpis-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 fetchKPIsByPeriod(inicio, fim);
|
|
res.json({ success: true, data });
|
|
} catch (err) {
|
|
console.error('Corporate KPIs API error:', err);
|
|
res.status(500).json({ success: false, error: err.message });
|
|
}
|
|
});
|
|
|
|
// API: Corporate Dashboard - Tendência por período
|
|
app.get('/corporate/api/trend-period', requireRole('corporate', 'admin'), async (req, res) => {
|
|
try {
|
|
const { inicio, fim } = req.query;
|
|
if (!inicio || !fim) {
|
|
return res.status(400).json({ success: false, error: 'Parametros inicio e fim sao obrigatorios' });
|
|
}
|
|
const data = await fetchTrendByPeriod(inicio, fim);
|
|
res.json({ success: true, data });
|
|
} catch (err) {
|
|
console.error('Corporate Trend API error:', err);
|
|
res.status(500).json({ success: false, error: err.message });
|
|
}
|
|
});
|
|
|
|
// Legacy API routes - redirect to /corporate/api/*
|
|
app.get('/admin/api/data', requireRole('admin'), (req, res) => {
|
|
res.redirect(`/corporate/api/data?${new URLSearchParams(req.query)}`);
|
|
});
|
|
app.get('/admin/api/kpis', requireRole('admin'), (req, res) => {
|
|
res.redirect('/corporate/api/kpis');
|
|
});
|
|
app.get('/admin/api/trend', requireRole('admin'), (req, res) => {
|
|
res.redirect('/corporate/api/trend');
|
|
});
|
|
app.get('/admin/api/top-agentes', requireRole('admin'), (req, res) => {
|
|
res.redirect(`/corporate/api/top-agentes?${new URLSearchParams(req.query)}`);
|
|
});
|
|
app.get('/admin/api/corporate/kpis', requireRole('admin'), (req, res) => {
|
|
res.redirect(`/corporate/api/kpis-period?${new URLSearchParams(req.query)}`);
|
|
});
|
|
app.get('/admin/api/corporate/trend', requireRole('admin'), (req, res) => {
|
|
res.redirect(`/corporate/api/trend-period?${new URLSearchParams(req.query)}`);
|
|
});
|
|
app.get('/admin/dashboard', requireRole('admin'), (req, res) => {
|
|
res.redirect('/corporate/dashboard');
|
|
});
|
|
|
|
// --- Admin BI Dashboard (admin only) ---
|
|
app.get('/admin/bi', requireRole('admin'), (req, res) => {
|
|
try {
|
|
res.set('Cache-Control', 'no-store, no-cache, must-revalidate');
|
|
res.set('Pragma', 'no-cache');
|
|
const html = buildAdminBIHTML(req.session.user);
|
|
res.send(html);
|
|
} catch (err) {
|
|
console.error('Admin BI error:', err);
|
|
res.status(500).send('Erro ao carregar BI: ' + err.message);
|
|
}
|
|
});
|
|
|
|
app.get('/admin/api/bi', requireRole('admin'), async (req, res) => {
|
|
try {
|
|
const start = req.query.start;
|
|
const end = req.query.end;
|
|
if (!start || !end) return res.status(400).json({ error: 'start and end required' });
|
|
|
|
const getAgenteName = (agenteId) => {
|
|
const row = db.prepare('SELECT nome FROM agentes WHERE agente_id = ?').get(agenteId);
|
|
return row ? row.nome : null;
|
|
};
|
|
|
|
const data = await fetchBIData(start, end, getAgenteName);
|
|
res.json(data);
|
|
} catch (err) {
|
|
console.error('Admin BI API error:', err);
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
app.get('/admin/api/bi/revenue', requireRole('admin'), async (req, res) => {
|
|
try {
|
|
const { start, end, granularity } = req.query;
|
|
if (!start || !end) return res.status(400).json({ error: 'start and end required' });
|
|
const data = await fetchRevenueAnalytics(start, end, granularity || 'dia');
|
|
res.json(data);
|
|
} catch (err) {
|
|
console.error('Revenue API error:', err);
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// Create user (admin only)
|
|
app.post('/admin/agentes', requireRole('admin'), async (req, res) => {
|
|
const { nome, email, agente_id, senha, role } = req.body;
|
|
try {
|
|
if (!nome || !email || !senha) {
|
|
return res.status(400).json({ error: 'Nome, email e senha sao obrigatorios' });
|
|
}
|
|
|
|
const userRole = role || 'agente';
|
|
// Admin and Corporate don't need agente_id
|
|
const agenteId = (userRole === 'admin' || userRole === 'corporate') ? 0 : (agente_id || 0);
|
|
|
|
if (userRole === 'agente' && !agente_id) {
|
|
return res.status(400).json({ error: 'Agente ID e obrigatorio para agentes' });
|
|
}
|
|
|
|
const result = await createUser(email, senha, nome, userRole, agenteId);
|
|
res.json({ success: true, id: result.lastInsertRowid });
|
|
} catch (err) {
|
|
console.error('Create user error:', err);
|
|
if (err.message && err.message.includes('UNIQUE')) {
|
|
return res.status(400).json({ error: 'E-mail ja cadastrado' });
|
|
}
|
|
res.status(500).json({ error: 'Erro ao criar usuario' });
|
|
}
|
|
});
|
|
|
|
// Update user (admin only)
|
|
app.put('/admin/agentes/:id', requireRole('admin'), async (req, res) => {
|
|
const { id } = req.params;
|
|
const { nome, email, agente_id, ativo, senha, role } = req.body;
|
|
try {
|
|
const agent = db.prepare('SELECT * FROM agentes WHERE id = ?').get(id);
|
|
if (!agent) {
|
|
return res.status(404).json({ error: 'Usuario nao encontrado' });
|
|
}
|
|
|
|
if (senha) {
|
|
const hash = await bcrypt.hash(senha, 10);
|
|
db.prepare('UPDATE agentes SET senha_hash = ? WHERE id = ?').run(hash, id);
|
|
}
|
|
|
|
if (nome !== undefined) {
|
|
db.prepare('UPDATE agentes SET nome = ? WHERE id = ?').run(nome, id);
|
|
}
|
|
if (email !== undefined) {
|
|
db.prepare('UPDATE agentes SET email = ? WHERE id = ?').run(email, id);
|
|
}
|
|
if (agente_id !== undefined) {
|
|
db.prepare('UPDATE agentes SET agente_id = ? WHERE id = ?').run(agente_id, id);
|
|
}
|
|
if (ativo !== undefined) {
|
|
db.prepare('UPDATE agentes SET ativo = ? WHERE id = ?').run(ativo, id);
|
|
}
|
|
if (role !== undefined) {
|
|
db.prepare('UPDATE agentes SET role = ? WHERE id = ?').run(role, id);
|
|
}
|
|
|
|
res.json({ success: true });
|
|
} catch (err) {
|
|
console.error('Update user error:', err);
|
|
if (err.message && err.message.includes('UNIQUE')) {
|
|
return res.status(400).json({ error: 'E-mail ja cadastrado' });
|
|
}
|
|
res.status(500).json({ error: 'Erro ao atualizar usuario' });
|
|
}
|
|
});
|
|
|
|
// Delete/deactivate user (admin only)
|
|
app.delete('/admin/agentes/:id', requireRole('admin'), (req, res) => {
|
|
const { id } = req.params;
|
|
try {
|
|
const result = db.prepare('UPDATE agentes SET ativo = 0 WHERE id = ?').run(id);
|
|
if (result.changes === 0) {
|
|
return res.status(404).json({ error: 'Usuario nao encontrado' });
|
|
}
|
|
res.json({ success: true });
|
|
} catch (err) {
|
|
console.error('Delete user error:', err);
|
|
res.status(500).json({ error: 'Erro ao desativar usuario' });
|
|
}
|
|
});
|
|
|
|
// Start
|
|
app.listen(PORT, () => {
|
|
console.log(`BI - CCC rodando: http://localhost:${PORT}`);
|
|
|
|
// Inicializa cache com auto-refresh (atualiza a cada 5 minutos)
|
|
console.log('[Cache] Inicializando cache com auto-refresh...');
|
|
cache.registerAutoRefresh('kpis', fetchKPIs, 5 * 60 * 1000);
|
|
cache.registerAutoRefresh('trend30', fetchTrend30Days, 10 * 60 * 1000);
|
|
cache.registerAutoRefresh('top-agentes-30', () => fetchTopAgentes(30), 10 * 60 * 1000);
|
|
cache.registerAutoRefresh('top-agentes-7', () => fetchTopAgentes(7), 10 * 60 * 1000);
|
|
cache.registerAutoRefresh('top-agentes-90', () => fetchTopAgentes(90), 10 * 60 * 1000);
|
|
});
|