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:
8
.env.example
Normal file
8
.env.example
Normal 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
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules/
|
||||||
|
.env
|
||||||
|
data/*.db
|
||||||
|
data/*.db-shm
|
||||||
|
data/*.db-wal
|
||||||
109
README.md
Normal file
109
README.md
Normal 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
0
data/.gitkeep
Normal file
1994
package-lock.json
generated
Normal file
1994
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
18
package.json
Normal file
18
package.json
Normal 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
166
public/login.html
Normal 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">↔</div>
|
||||||
|
<h1>BI Agentes</h1>
|
||||||
|
<p>CambioReal — 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 © 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
72
scripts/seed-agente.js
Normal 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
87
server.js
Normal 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
30
src/auth.js
Normal 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
379
src/dashboard.js
Normal 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} — Agente ${agente.agente_id}</h1>
|
||||||
|
<div class="subtitle">Dashboard de Transacoes BRL ↔ USD</div>
|
||||||
|
</div>
|
||||||
|
<div class="header-right">
|
||||||
|
<div class="badge"><span class="live-dot"></span>Ao vivo — ${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 → USD</option>
|
||||||
|
<option value="USD \\u2192 BRL">USD → 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 — ${agente.nome} BI Dashboard — 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">↔</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">↕</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">§</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">Ø</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">☺</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
26
src/db-local.js
Normal 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
15
src/db-rds.js
Normal 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
97
src/queries.js
Normal 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 };
|
||||||
Reference in New Issue
Block a user