Initial commit: BI Agentes platform

Independent dashboard for CambioReal agents with local SQLite auth
and read-only RDS connection. Features login, per-agent transaction
filtering, KPIs, charts (Chart.js), and detailed transaction table.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-07 15:47:07 -05:00
commit 39900c3fe8
14 changed files with 3006 additions and 0 deletions

8
.env.example Normal file
View File

@@ -0,0 +1,8 @@
# RDS Read-Only
MYSQL_URL=seu-host-rds.region.rds.amazonaws.com
USER_MYSQL=usuario_readonly
PW_MYSQL=senha_aqui
# Sessão
SESSION_SECRET=trocar-por-chave-secreta-aleatoria
PORT=3080

5
.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
node_modules/
.env
data/*.db
data/*.db-shm
data/*.db-wal

109
README.md Normal file
View File

@@ -0,0 +1,109 @@
# BI Agentes — CambioReal
Dashboard de transações para agentes CambioReal com autenticação local e dados ao vivo do RDS.
## Arquitetura
```
┌─────────────────────────────────┐
│ BI Agentes (Express) │
│ │
│ SQLite (auth) Express/HTTP │
│ agentes.db :3080 │
│ email/senha /login │
│ agente_id /dashboard │
└───────────────────────┬─────────┘
│ READ-ONLY
┌──────▼──────┐
│ cambio_db │
│ (AWS RDS) │
└─────────────┘
```
- **SQLite local** — controle de agentes (email, senha bcrypt, agente_id). Sem dependência do RDS para autenticação.
- **MySQL RDS** — somente leitura. Consulta `br_transaction_to_usa` (BRL→USD) e `pagamento_br` (USD→BRL) filtradas pelo `agente_id` da sessão.
## Estrutura
```
bi-agentes/
├── server.js ← Entry point (Express + sessions)
├── src/
│ ├── auth.js ← Login/logout, bcrypt, middleware requireAuth
│ ├── db-local.js ← SQLite (better-sqlite3) — tabela agentes
│ ├── db-rds.js ← MySQL pool read-only (mysql2)
│ ├── queries.js ← SQL parametrizado por agente_id
│ └── dashboard.js ← Gerador HTML (KPIs, Chart.js, tabela)
├── public/
│ └── login.html ← Tela de login
├── scripts/
│ └── seed-agente.js ← CLI para cadastrar/listar agentes
├── data/
│ └── agentes.db ← SQLite database (gerado automaticamente)
├── .env ← Credenciais RDS + SESSION_SECRET
└── .gitignore
```
## Setup
```bash
npm install
cp .env.example .env # editar com credenciais do RDS
```
## Configuração (.env)
```
MYSQL_URL=<host-rds>
USER_MYSQL=<usuario-readonly>
PW_MYSQL=<senha>
SESSION_SECRET=<chave-secreta-sessao>
PORT=3080
```
## Gerenciar Agentes
Cadastrar:
```bash
node scripts/seed-agente.js --email agente@email.com --senha 123456 --agente 76 --nome "ValorFx"
```
Listar:
```bash
node scripts/seed-agente.js --list
```
## Rodar
```bash
node server.js
# BI Agentes rodando: http://localhost:3080
```
Para produção (intranet):
```bash
pm2 start server.js --name bi-agentes
```
## Funcionalidades do Dashboard
- **Autenticação** — cada agente loga com email/senha e vê apenas suas transações
- **KPIs** — transações, volume BRL/USD, taxa média, spread, IOF, ticket médio, clientes ativos
- **Gráficos** — volume por período (BRL+USD dual axis), volume por cliente (top 10), taxa cobrada vs PTAX
- **Tabela** — todas as transações com data/hora, cliente, valores, IOF, taxas, spread, status
- **Filtros** — período (de/até), granulação (dia/mês/ano), fluxo (BRL→USD / USD→BRL), cliente
- **Dados ao vivo** — cada refresh consulta o RDS em tempo real
- **Dois fluxos** — BRL→USD (`br_transaction_to_usa`) e USD→BRL (`pagamento_br`)
## Stack
- Node.js + Express
- express-session (sessões em memória)
- better-sqlite3 (auth local)
- mysql2 (RDS read-only)
- bcrypt (hash de senhas)
- Chart.js 4.x (gráficos client-side via CDN)
- Google Fonts Inter (tipografia via CDN)

0
data/.gitkeep Normal file
View File

1994
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

18
package.json Normal file
View File

@@ -0,0 +1,18 @@
{
"name": "bi-agentes",
"version": "1.0.0",
"description": "BI Dashboard para agentes CambioReal",
"main": "server.js",
"scripts": {
"start": "node server.js",
"seed": "node scripts/seed-agente.js"
},
"dependencies": {
"bcrypt": "^5.1.1",
"better-sqlite3": "^11.7.0",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express-session": "^1.18.1",
"mysql2": "^3.12.0"
}
}

166
public/login.html Normal file
View File

@@ -0,0 +1,166 @@
<!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 — 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;
}
* { 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(--primary) 0%, var(--primary-dark) 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 p {
font-size: 13px;
color: var(--text-secondary);
margin-top: 6px;
}
.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(--primary);
box-shadow: 0 0 0 3px rgba(108,63,160,0.12);
}
.btn-login {
width: 100%;
padding: 13px;
background: linear-gradient(135deg, var(--primary) 0%, var(--primary-dark) 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(108,63,160,0.3);
margin-top: 4px;
}
.btn-login:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(108,63,160,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);
}
</style>
</head>
<body>
<div class="login-container">
<div class="login-card">
<div class="login-header">
<div class="logo">&#x2194;</div>
<h1>BI Agentes</h1>
<p>CambioReal &mdash; Dashboard de Transacoes</p>
</div>
<div class="error-msg" id="errorMsg"></div>
<form id="loginForm" method="POST" action="/login">
<div class="form-group">
<label>E-mail</label>
<input type="email" name="email" id="email" required placeholder="agente@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</button>
</form>
</div>
<div class="footer">CambioReal &copy; 2026</div>
</div>
<script>
const params = new URLSearchParams(window.location.search);
if (params.get('error')) {
const el = document.getElementById('errorMsg');
el.textContent = 'E-mail ou senha incorretos.';
el.style.display = 'block';
}
document.getElementById('email').focus();
</script>
</body>
</html>

72
scripts/seed-agente.js Normal file
View File

@@ -0,0 +1,72 @@
/**
* CLI para cadastrar agentes no SQLite
*
* Uso:
* node scripts/seed-agente.js --email valorfx@cambioreal.com --senha 123456 --agente 76 --nome "ValorFx"
*
* Listar agentes:
* node scripts/seed-agente.js --list
*/
require('dotenv').config({ path: require('path').join(__dirname, '..', '.env') });
const db = require('../src/db-local');
const { createAgente } = require('../src/auth');
const args = process.argv.slice(2);
function getArg(name) {
const idx = args.indexOf('--' + name);
return idx !== -1 && args[idx + 1] ? args[idx + 1] : null;
}
async function main() {
// List mode
if (args.includes('--list')) {
const rows = db.prepare('SELECT id, email, agente_id, nome, ativo, created_at FROM agentes').all();
if (!rows.length) {
console.log('Nenhum agente cadastrado.');
} else {
console.log('\n ID | Agente ID | Nome | Email | Ativo | Criado em');
console.log(' ' + '-'.repeat(95));
rows.forEach(r => {
console.log(` ${String(r.id).padEnd(3)}| ${String(r.agente_id).padEnd(10)}| ${r.nome.padEnd(19)}| ${r.email.padEnd(29)}| ${r.ativo ? 'Sim' : 'Nao'} | ${r.created_at}`);
});
}
console.log();
process.exit(0);
}
// Create mode
const email = getArg('email');
const senha = getArg('senha');
const agenteId = getArg('agente');
const nome = getArg('nome');
if (!email || !senha || !agenteId || !nome) {
console.log(`
Uso: node scripts/seed-agente.js --email <email> --senha <senha> --agente <id> --nome "<nome>"
Exemplos:
node scripts/seed-agente.js --email valorfx@cambioreal.com --senha 123456 --agente 76 --nome "ValorFx"
node scripts/seed-agente.js --list
`);
process.exit(1);
}
try {
await createAgente(email, senha, parseInt(agenteId), nome);
console.log(`\n Agente cadastrado com sucesso!`);
console.log(` Nome: ${nome}`);
console.log(` Email: ${email}`);
console.log(` Agente ID: ${agenteId}\n`);
} catch (err) {
if (err.message.includes('UNIQUE')) {
console.error(`\n Erro: email "${email}" ja esta cadastrado.\n`);
} else {
console.error('Erro:', err.message);
}
process.exit(1);
}
}
main();

87
server.js Normal file
View File

@@ -0,0 +1,87 @@
/**
* BI Agentes — CambioReal
*
* 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 } = require('./src/auth');
const { fetchTransacoes, serialize } = require('./src/queries');
const { buildHTML } = require('./src/dashboard');
// Initialize SQLite (creates tables on first run)
require('./src/db-local');
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')));
// --- Routes ---
// Login page
app.get('/login', (req, res) => {
if (req.session && req.session.agente) return res.redirect('/dashboard');
res.sendFile(path.join(__dirname, 'public', 'login.html'));
});
// Login POST
app.post('/login', async (req, res) => {
const { email, senha } = req.body;
try {
const agente = await authenticate(email, senha);
if (!agente) return res.redirect('/login?error=1');
req.session.agente = {
id: agente.id,
email: agente.email,
agente_id: agente.agente_id,
nome: agente.nome,
};
res.redirect('/dashboard');
} catch (err) {
console.error('Login error:', err);
res.redirect('/login?error=1');
}
});
// Logout
app.get('/logout', (req, res) => {
req.session.destroy(() => res.redirect('/login'));
});
// Dashboard (protected)
app.get('/dashboard', requireAuth, async (req, res) => {
try {
const agente = req.session.agente;
const { rowsBrlUsd, rowsUsdBrl } = await fetchTransacoes(agente.agente_id);
const data = serialize(rowsBrlUsd, rowsUsdBrl);
const html = buildHTML(data, agente);
res.send(html);
} catch (err) {
console.error('Dashboard error:', err);
res.status(500).send('Erro ao carregar dashboard: ' + err.message);
}
});
// Root redirect
app.get('/', (req, res) => res.redirect('/dashboard'));
// Start
app.listen(PORT, () => {
console.log(`BI Agentes rodando: http://localhost:${PORT}`);
});

30
src/auth.js Normal file
View File

@@ -0,0 +1,30 @@
/**
* Autenticação — login/logout com bcrypt + express-session
*/
const bcrypt = require('bcrypt');
const db = require('./db-local');
const SALT_ROUNDS = 10;
async function createAgente(email, senha, agenteId, nome) {
const hash = await bcrypt.hash(senha, SALT_ROUNDS);
return db.prepare(
'INSERT INTO agentes (email, senha_hash, agente_id, nome) VALUES (?, ?, ?, ?)'
).run(email, hash, agenteId, nome);
}
async function authenticate(email, senha) {
const row = db.prepare(
'SELECT * FROM agentes WHERE email = ? AND ativo = 1'
).get(email);
if (!row) return null;
const match = await bcrypt.compare(senha, row.senha_hash);
return match ? row : null;
}
function requireAuth(req, res, next) {
if (req.session && req.session.agente) return next();
res.redirect('/login');
}
module.exports = { createAgente, authenticate, requireAuth };

379
src/dashboard.js Normal file
View File

@@ -0,0 +1,379 @@
/**
* Gera HTML do dashboard — parametrizado por agente
*/
function buildHTML(data, agente) {
const now = new Date().toLocaleString('pt-BR');
return `<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>BI — ${agente.nome}</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.7/dist/chart.umd.min.js"><\/script>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<style>
:root {
--primary: #6C3FA0;
--primary-light: #8B5FBF;
--primary-dark: #4A2570;
--primary-bg: #F3EEFA;
--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; padding: 24px 40px;
display: flex; justify-content: space-between; align-items: center;
box-shadow: 0 2px 8px rgba(74,37,112,0.3);
}
.header h1 { font-size: 24px; font-weight: 800; letter-spacing: -0.5px; }
.header .subtitle { font-size: 13px; opacity: 0.8; margin-top: 4px; font-weight: 400; }
.header-right { display: flex; align-items: center; gap: 16px; }
.header .badge {
background: rgba(255,255,255,0.15); backdrop-filter: blur(10px);
padding: 8px 16px; border-radius: 24px; font-size: 12px; 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;
}
.btn-logout {
background: rgba(255,255,255,0.15); color: white; border: 1px solid rgba(255,255,255,0.3);
padding: 8px 16px; border-radius: 8px; font-size: 12px; font-weight: 600;
cursor: pointer; text-decoration: none; font-family: inherit; transition: all 0.15s;
}
.btn-logout:hover { background: rgba(255,255,255,0.25); }
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.4; } }
.filters {
background: var(--card); border-bottom: 1px solid var(--border);
padding: 14px 40px; display: flex; gap: 24px; align-items: center; flex-wrap: wrap;
box-shadow: 0 1px 2px rgba(0,0,0,0.04);
}
.filter-group { display: flex; align-items: center; gap: 8px; }
.filter-group label {
font-size: 12px; font-weight: 600; color: var(--text-secondary);
text-transform: uppercase; letter-spacing: 0.3px;
}
.filter-group input, .filter-group select {
padding: 8px 14px; border: 1.5px solid var(--border); border-radius: 8px;
font-size: 13px; font-family: inherit; background: white; color: var(--text); transition: all 0.15s;
}
.filter-group input:focus, .filter-group select:focus {
outline: none; border-color: var(--primary); box-shadow: 0 0 0 3px rgba(108,63,160,0.12);
}
.btn-apply {
background: var(--primary); color: white; border: none; padding: 9px 24px;
border-radius: 8px; font-size: 13px; font-weight: 600; font-family: inherit;
cursor: pointer; transition: all 0.15s; box-shadow: 0 1px 3px rgba(108,63,160,0.3);
}
.btn-apply:hover { background: var(--primary-light); transform: translateY(-1px); }
.container { padding: 28px 40px; max-width: 1480px; margin: 0 auto; }
.kpi-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; margin-bottom: 28px; }
.kpi-card {
background: var(--card); border-radius: 12px; padding: 20px 22px;
border: 1px solid var(--border); box-shadow: 0 1px 3px rgba(0,0,0,0.06);
display: flex; align-items: flex-start; gap: 14px; transition: box-shadow 0.15s; overflow: hidden;
}
.kpi-card:hover { box-shadow: 0 4px 12px rgba(0,0,0,0.08); }
.kpi-icon {
width: 44px; height: 44px; border-radius: 10px;
display: flex; align-items: center; justify-content: center; font-size: 20px; flex-shrink: 0;
}
.kpi-icon.purple { background: var(--primary-bg); color: var(--primary); }
.kpi-icon.green { background: var(--green-bg); color: var(--green); }
.kpi-icon.blue { background: var(--blue-bg); color: var(--blue); }
.kpi-icon.orange { background: var(--orange-bg); color: var(--orange); }
.kpi-info { flex: 1; min-width: 0; }
.kpi-card .kpi-label { font-size: 11px; font-weight: 600; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 4px; }
.kpi-card .kpi-value { font-size: 20px; font-weight: 800; color: var(--text); line-height: 1.2; letter-spacing: -0.3px; word-break: break-word; font-variant-numeric: tabular-nums; }
.kpi-card .kpi-sub { font-size: 11px; color: var(--text-muted); margin-top: 3px; font-weight: 400; }
.charts-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-bottom: 28px; }
.chart-card { background: var(--card); border-radius: 12px; padding: 22px; border: 1px solid var(--border); box-shadow: 0 1px 3px rgba(0,0,0,0.06); }
.chart-card h3 { font-size: 14px; font-weight: 700; margin-bottom: 16px; color: var(--text); }
.chart-card canvas { width: 100% !important; }
.table-card { background: var(--card); border-radius: 12px; border: 1px solid var(--border); box-shadow: 0 1px 3px rgba(0,0,0,0.06); overflow: hidden; margin-bottom: 28px; }
.table-card h3 { font-size: 15px; font-weight: 700; padding: 18px 22px; border-bottom: 1px solid var(--border); }
.table-wrap { overflow-x: auto; }
table { width: 100%; border-collapse: collapse; font-size: 13px; }
thead th { background: #FAFBFC; padding: 11px 16px; text-align: left; font-weight: 600; color: var(--text-secondary); font-size: 11px; text-transform: uppercase; letter-spacing: 0.4px; border-bottom: 2px solid var(--border); white-space: nowrap; position: sticky; top: 0; }
tbody td { padding: 11px 16px; border-bottom: 1px solid #F3F4F6; white-space: nowrap; font-variant-numeric: tabular-nums; }
tbody tr:hover { background: #F8F5FF; }
tbody tr:nth-child(even) { background: #FAFBFC; }
tbody tr:nth-child(even):hover { background: #F8F5FF; }
.num { text-align: right; }
.footer { text-align: center; padding: 20px; font-size: 12px; color: var(--text-muted); }
@media (max-width: 1100px) { .kpi-grid { grid-template-columns: repeat(2, 1fr); } }
@media (max-width: 900px) { .charts-grid { grid-template-columns: 1fr; } .kpi-grid { grid-template-columns: repeat(2, 1fr); } .container { padding: 20px; } .filters { padding: 12px 20px; } .header { padding: 20px; } }
@media (max-width: 600px) { .kpi-grid { grid-template-columns: 1fr; } }
</style>
</head>
<body>
<div class="header">
<div>
<h1>${agente.nome} &mdash; Agente ${agente.agente_id}</h1>
<div class="subtitle">Dashboard de Transacoes BRL &harr; USD</div>
</div>
<div class="header-right">
<div class="badge"><span class="live-dot"></span>Ao vivo &mdash; ${now}</div>
<a href="/logout" class="btn-logout">Sair</a>
</div>
</div>
<div class="filters">
<div class="filter-group"><label>De:</label><input type="date" id="filterStart"></div>
<div class="filter-group"><label>Ate:</label><input type="date" id="filterEnd"></div>
<div class="filter-group">
<label>Granulacao:</label>
<select id="filterGran">
<option value="dia">Dia</option>
<option value="mes" selected>Mes</option>
<option value="ano">Ano</option>
</select>
</div>
<div class="filter-group">
<label>Fluxo:</label>
<select id="filterFluxo">
<option value="">Todos</option>
<option value="BRL \\u2192 USD">BRL &rarr; USD</option>
<option value="USD \\u2192 BRL">USD &rarr; BRL</option>
</select>
</div>
<div class="filter-group">
<label>Cliente:</label>
<select id="filterCliente"><option value="">Todos</option></select>
</div>
<button class="btn-apply" onclick="applyFilters()">Aplicar</button>
</div>
<div class="container">
<div class="kpi-grid" id="kpiGrid"></div>
<div class="charts-grid">
<div class="chart-card"><h3>Volume BRL / USD por Periodo</h3><canvas id="chartVolume"></canvas></div>
<div class="chart-card"><h3>Volume por Cliente (Top 10)</h3><canvas id="chartClientes"></canvas></div>
<div class="chart-card"><h3>Taxa Cobrada vs PTAX</h3><canvas id="chartTaxas"></canvas></div>
</div>
<div class="table-card">
<h3 id="tableTitle">Transacoes</h3>
<div class="table-wrap">
<table><thead id="tableHead"></thead><tbody id="tableBody"></tbody></table>
</div>
</div>
</div>
<div class="footer">CambioReal &mdash; ${agente.nome} BI Dashboard &mdash; Dados ao vivo</div>
<script>
const RAW_DATA = ${JSON.stringify(data)};
let filtered = [];
let charts = {};
const fmtBRL = v => v.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
const fmtUSD = v => v.toLocaleString('pt-BR', { style: 'currency', currency: 'USD' });
const fmtNum = (v, d=2) => v.toLocaleString('pt-BR', { minimumFractionDigits: d, maximumFractionDigits: d });
const fmtPct = v => fmtNum(v, 2) + '%';
(function init() {
const clientes = [...new Set(RAW_DATA.map(r => r.cliente))].sort();
const sel = document.getElementById('filterCliente');
clientes.forEach(c => { const o = document.createElement('option'); o.value = c; o.textContent = c; sel.appendChild(o); });
const dates = RAW_DATA.map(r => r.data_operacao ? r.data_operacao.slice(0, 10) : null).filter(Boolean).sort();
if (dates.length) {
document.getElementById('filterStart').value = dates[0];
document.getElementById('filterEnd').value = dates[dates.length - 1];
}
applyFilters();
})();
function applyFilters() {
const start = document.getElementById('filterStart').value;
const end = document.getElementById('filterEnd').value;
const gran = document.getElementById('filterGran').value;
const cliente = document.getElementById('filterCliente').value;
const fluxo = document.getElementById('filterFluxo').value;
filtered = RAW_DATA.filter(r => {
const dateOnly = r.data_operacao ? r.data_operacao.slice(0, 10) : null;
if (start && dateOnly && dateOnly < start) return false;
if (end && dateOnly && dateOnly > end) return false;
if (cliente && r.cliente !== cliente) return false;
if (fluxo && r.fluxo !== fluxo) return false;
return true;
});
renderKPIs();
try { renderCharts(gran); } catch(e) { console.error('Chart error:', e); }
renderTable();
}
function renderKPIs() {
const n = filtered.length;
const totalBRL = filtered.reduce((s, r) => s + r.valor_reais, 0);
const totalUSD = filtered.reduce((s, r) => s + r.valor_dolar, 0);
const taxaMedia = n && totalUSD ? filtered.reduce((s, r) => s + r.taxa_cobrada * r.valor_dolar, 0) / totalUSD : 0;
const spreadMedio = n ? filtered.reduce((s, r) => s + r.spread_pct, 0) / n : 0;
const iofTotal = filtered.reduce((s, r) => s + r.iof_valor_rs, 0);
const ticketMedio = n ? totalUSD / n : 0;
document.getElementById('kpiGrid').innerHTML = \`
<div class="kpi-card"><div class="kpi-icon purple">&#x2194;</div><div class="kpi-info"><div class="kpi-label">Transacoes</div><div class="kpi-value">\${n}</div><div class="kpi-sub">operacoes</div></div></div>
<div class="kpi-card"><div class="kpi-icon green">R$</div><div class="kpi-info"><div class="kpi-label">Volume BRL</div><div class="kpi-value">\${fmtBRL(totalBRL)}</div><div class="kpi-sub">total movimentado</div></div></div>
<div class="kpi-card"><div class="kpi-icon blue">US$</div><div class="kpi-info"><div class="kpi-label">Volume USD</div><div class="kpi-value">\${fmtUSD(totalUSD)}</div><div class="kpi-sub">total movimentado</div></div></div>
<div class="kpi-card"><div class="kpi-icon orange">&#x2195;</div><div class="kpi-info"><div class="kpi-label">Taxa Media</div><div class="kpi-value">\${fmtNum(taxaMedia, 4)}</div><div class="kpi-sub">ponderada BRL/USD</div></div></div>
<div class="kpi-card"><div class="kpi-icon purple">%</div><div class="kpi-info"><div class="kpi-label">Spread Medio</div><div class="kpi-value">\${fmtPct(spreadMedio)}</div><div class="kpi-sub">sobre taxa cobrada</div></div></div>
<div class="kpi-card"><div class="kpi-icon green">&#x00A7;</div><div class="kpi-info"><div class="kpi-label">IOF Total</div><div class="kpi-value">\${fmtBRL(iofTotal)}</div><div class="kpi-sub">recolhido no periodo</div></div></div>
<div class="kpi-card"><div class="kpi-icon blue">&#x00D8;</div><div class="kpi-info"><div class="kpi-label">Ticket Medio</div><div class="kpi-value">\${fmtUSD(ticketMedio)}</div><div class="kpi-sub">por operacao</div></div></div>
<div class="kpi-card"><div class="kpi-icon orange">&#x263A;</div><div class="kpi-info"><div class="kpi-label">Clientes Ativos</div><div class="kpi-value">\${new Set(filtered.map(r => r.cliente)).size}</div><div class="kpi-sub">no periodo</div></div></div>
\`;
}
function groupByPeriod(gran) {
const map = {};
filtered.forEach(r => {
if (!r.data_operacao) return;
const dateOnly = r.data_operacao.slice(0, 10);
let key;
if (gran === 'dia') key = dateOnly;
else if (gran === 'mes') key = dateOnly.slice(0, 7);
else key = dateOnly.slice(0, 4);
if (!map[key]) map[key] = { totalUSD: 0, totalBRL: 0, count: 0, sumWeightTaxa: 0, sumWeightPtax: 0 };
map[key].totalUSD += r.valor_dolar;
map[key].totalBRL += r.valor_reais;
map[key].count += 1;
map[key].sumWeightTaxa += r.taxa_cobrada * r.valor_dolar;
map[key].sumWeightPtax += r.taxa_ptax * r.valor_dolar;
});
const keys = Object.keys(map).sort();
return keys.map(k => ({
label: gran === 'dia' ? k.split('-').reverse().join('/') : gran === 'mes' ? k.split('-').reverse().join('/') : k,
...map[k],
taxaMedia: map[k].totalUSD ? map[k].sumWeightTaxa / map[k].totalUSD : 0,
ptaxMedia: map[k].totalUSD ? map[k].sumWeightPtax / map[k].totalUSD : 0,
}));
}
function destroyCharts() { Object.values(charts).forEach(c => c.destroy()); charts = {}; }
function renderCharts(gran) {
destroyCharts();
const periods = groupByPeriod(gran);
const labels = periods.map(p => p.label);
charts.volume = new Chart(document.getElementById('chartVolume'), {
type: 'bar',
data: {
labels,
datasets: [
{ label: 'Volume BRL', data: periods.map(p => p.totalBRL), backgroundColor: 'rgba(30,142,62,0.65)', borderRadius: 4, yAxisID: 'yBRL' },
{ label: 'Volume USD', data: periods.map(p => p.totalUSD), backgroundColor: 'rgba(26,115,232,0.7)', borderRadius: 4, yAxisID: 'yUSD' }
]
},
options: {
responsive: true,
plugins: { legend: { position: 'top', labels: { font: { size: 11 } } } },
scales: {
yBRL: { type: 'linear', position: 'left', ticks: { callback: v => 'R$ ' + (v >= 1000 ? (v/1000).toFixed(0) + 'k' : v.toFixed(0)), font: { size: 10 } }, title: { display: true, text: 'BRL', font: { size: 11 } }, grid: { display: true } },
yUSD: { type: 'linear', position: 'right', ticks: { callback: v => '$ ' + (v >= 1000 ? (v/1000).toFixed(0) + 'k' : v.toFixed(0)), font: { size: 10 } }, title: { display: true, text: 'USD', font: { size: 11 } }, grid: { display: false } }
}
}
});
const clientMap = {};
filtered.forEach(r => { clientMap[r.cliente] = (clientMap[r.cliente] || 0) + r.valor_dolar; });
const topClientes = Object.entries(clientMap).sort((a,b) => b[1] - a[1]).slice(0, 10);
const colors = ['#6C3FA0','#8B5FBF','#2980B9','#27AE60','#E67E22','#E74C3C','#3498DB','#1ABC9C','#9B59B6','#F39C12'];
charts.clientes = new Chart(document.getElementById('chartClientes'), {
type: 'bar',
data: {
labels: topClientes.map(c => c[0].length > 20 ? c[0].slice(0,20) + '...' : c[0]),
datasets: [{ label: 'Volume USD', data: topClientes.map(c => c[1]), backgroundColor: colors, borderRadius: 4 }]
},
options: { indexAxis: 'y', responsive: true, plugins: { legend: { display: false } }, scales: { x: { ticks: { callback: v => '$ ' + (v >= 1000 ? (v/1000).toFixed(0) + 'k' : v.toFixed(0)) } } } }
});
charts.taxas = new Chart(document.getElementById('chartTaxas'), {
type: 'line',
data: {
labels,
datasets: [
{ label: 'Taxa Cobrada', data: periods.map(p => p.taxaMedia), borderColor: '#6C3FA0', backgroundColor: 'rgba(108,63,160,0.1)', fill: true, tension: 0.3, pointRadius: 3 },
{ label: 'PTAX', data: periods.map(p => p.ptaxMedia), borderColor: '#2980B9', backgroundColor: 'rgba(41,128,185,0.1)', fill: true, tension: 0.3, pointRadius: 3 }
]
},
options: { responsive: true, scales: { y: { ticks: { callback: v => v.toFixed(4) } } } }
});
}
function fmtDate(d) {
if (!d) return '-';
const parts = d.split(' ');
const ymd = parts[0].split('-').reverse().join('/');
return parts[1] ? ymd + ' ' + parts[1] : ymd;
}
function renderTable() {
const brlUsd = filtered.filter(r => r.fluxo === 'BRL \\u2192 USD');
const usdBrl = filtered.filter(r => r.fluxo === 'USD \\u2192 BRL');
document.getElementById('tableTitle').textContent = 'Transacoes (' + filtered.length + ')';
let html = '';
if (brlUsd.length) {
html += '<tr><td colspan="11" style="background:var(--primary-bg);font-weight:700;padding:10px 16px;color:var(--primary);font-size:13px;">BRL \\u2192 USD (' + brlUsd.length + ')</td></tr>';
html += brlUsd.map(r => \`<tr>
<td>\${fmtDate(r.data_operacao)}</td><td>\${r.cliente}</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">\${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>\${r.status || '-'}</td>
</tr>\`).join('');
}
if (usdBrl.length) {
html += '<tr><td colspan="11" style="background:var(--blue-bg);font-weight:700;padding:10px 16px;color:var(--blue);font-size:13px;">USD \\u2192 BRL (' + usdBrl.length + ')</td></tr>';
html += usdBrl.map(r => \`<tr>
<td>\${fmtDate(r.data_operacao)}</td><td>\${r.cliente}</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">\${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>\${r.status || '-'}</td>
</tr>\`).join('');
}
document.getElementById('tableHead').innerHTML = '<tr><th>Data/Hora</th><th>Cliente</th><th>Valor BRL</th><th>Valor USD</th><th>IOF %</th><th>IOF R$</th><th>PTAX</th><th>Taxa Cobrada</th><th>Spread</th><th>Spread %</th><th>Status</th></tr>';
document.getElementById('tableBody').innerHTML = html;
}
<\/script>
</body>
</html>`;
}
module.exports = { buildHTML };

26
src/db-local.js Normal file
View File

@@ -0,0 +1,26 @@
/**
* SQLite local — controle de agentes (auth + config)
*/
const Database = require('better-sqlite3');
const path = require('path');
const DB_PATH = path.join(__dirname, '..', 'data', 'agentes.db');
const db = new Database(DB_PATH);
// WAL mode for better concurrency
db.pragma('journal_mode = WAL');
// Create tables
db.exec(`
CREATE TABLE IF NOT EXISTS agentes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT UNIQUE NOT NULL,
senha_hash TEXT NOT NULL,
agente_id INTEGER NOT NULL,
nome TEXT NOT NULL,
ativo INTEGER DEFAULT 1,
created_at TEXT DEFAULT (datetime('now'))
)
`);
module.exports = db;

15
src/db-rds.js Normal file
View File

@@ -0,0 +1,15 @@
/**
* MySQL pool — read-only no RDS (cambio_db)
*/
const mysql = require('mysql2/promise');
const pool = mysql.createPool({
host: process.env.MYSQL_URL,
user: process.env.USER_MYSQL,
password: process.env.PW_MYSQL,
database: 'cambio_db',
waitForConnections: true,
connectionLimit: 10,
});
module.exports = pool;

97
src/queries.js Normal file
View File

@@ -0,0 +1,97 @@
/**
* Queries de transações — parametrizadas por agente_id
*/
const pool = require('./db-rds');
async function fetchTransacoes(agenteId) {
const conn = await pool.getConnection();
try {
const [rowsBrlUsd] = await conn.execute(`
SELECT DISTINCT
c.nome AS cliente,
t.created_at AS data_operacao,
t.amount_brl AS valor_reais,
t.amount_usd AS valor_dolar,
t.iof AS iof_pct,
ROUND(t.iof / 100 * t.amount_usd * t.exchange_rate, 2) AS iof_valor_rs,
ROUND(t.ptax, 4) AS taxa_ptax,
ROUND(t.exchange_rate, 4) AS taxa_cobrada,
ROUND(t.exchange_rate - t.ptax, 4) AS spread_bruto,
ROUND((t.exchange_rate - t.ptax) / t.exchange_rate * 100, 2) AS spread_pct,
t.status
FROM br_transaction_to_usa t
INNER JOIN ag_contas ac ON ac.conta_id = t.id_conta AND ac.agente_id = ?
INNER JOIN conta c ON c.id_conta = t.id_conta
ORDER BY t.created_at
`, [agenteId]);
const [rowsUsdBrl] = await conn.execute(`
SELECT DISTINCT
c.nome AS cliente,
p.created_at AS data_operacao,
p.valor_sol AS valor_reais,
p.valor AS valor_dolar,
0 AS iof_pct,
0 AS iof_valor_rs,
ROUND(p.ptax, 4) AS taxa_ptax,
ROUND(p.cotacao, 4) AS taxa_cobrada,
ROUND(p.ptax - p.cotacao, 4) AS spread_bruto,
CASE WHEN p.cotacao > 0 THEN ROUND((p.ptax - p.cotacao) / p.ptax * 100, 2) ELSE 0 END AS spread_pct,
p.pgto AS status
FROM pagamento_br p
INNER JOIN conta c ON c.id_conta = p.id_conta
INNER JOIN ag_contas ac ON ac.conta_id = p.id_conta AND ac.agente_id = ?
ORDER BY p.created_at
`, [agenteId]);
return { rowsBrlUsd, rowsUsdBrl };
} finally {
conn.release();
}
}
function parseDate(d) {
try {
if (!d) return null;
const dt = d instanceof Date ? d : new Date(d);
return isNaN(dt.getTime()) ? null : dt.toISOString().slice(0, 16).replace('T', ' ');
} catch (e) { return null; }
}
function serialize(rowsBrlUsd, rowsUsdBrl) {
const dataBrlUsd = rowsBrlUsd.map(r => ({
fluxo: 'BRL → USD',
cliente: r.cliente,
data_operacao: parseDate(r.data_operacao),
data_sort: parseDate(r.data_operacao) || '',
valor_reais: Number(r.valor_reais),
valor_dolar: Number(r.valor_dolar),
iof_pct: Number(r.iof_pct),
iof_valor_rs: Number(r.iof_valor_rs),
taxa_ptax: Number(r.taxa_ptax),
taxa_cobrada: Number(r.taxa_cobrada),
spread_bruto: Number(r.spread_bruto),
spread_pct: Number(r.spread_pct),
status: r.status,
}));
const dataUsdBrl = rowsUsdBrl.map(r => ({
fluxo: 'USD → BRL',
cliente: r.cliente,
data_operacao: parseDate(r.data_operacao),
data_sort: parseDate(r.data_operacao) || '',
valor_reais: Number(r.valor_reais),
valor_dolar: Number(r.valor_dolar),
iof_pct: Number(r.iof_pct),
iof_valor_rs: Number(r.iof_valor_rs),
taxa_ptax: Number(r.taxa_ptax),
taxa_cobrada: Number(r.taxa_cobrada),
spread_bruto: Math.abs(Number(r.spread_bruto)),
spread_pct: Math.abs(Number(r.spread_pct)),
status: r.status,
}));
return [...dataBrlUsd, ...dataUsdBrl].sort((a, b) => a.data_sort.localeCompare(b.data_sort));
}
module.exports = { fetchTransacoes, serialize };