Add dashboard.html and static file serving to server

- web/dashboard.html: overview dashboard with KPI cards, type breakdown
  bars, entity list with accounts, movements table with filters, and a
  slide-in origin/destination tree panel for any movement
- src/server.js: serve web/ as static files (GET /), add CORS headers
  so the dashboard works from file:// too; default route is dashboard.html
This commit is contained in:
Kavi 2026-06-01 22:38:51 -04:00
parent cd5b02a4e3
commit 822a9b5907
2 changed files with 589 additions and 0 deletions

View File

@ -1,5 +1,7 @@
#!/usr/bin/env node
import http from 'node:http';
import fs from 'node:fs/promises';
import path from 'node:path';
import { URL } from 'node:url';
import { buildMoneyGraph, destinationTree, originTree, summarizeLedger } from './moneyGraph.js';
import { loadLedger, resolveAccountOwnerHints } from './ledgerStore.js';
@ -8,14 +10,28 @@ const args = parseArgs(process.argv.slice(2));
const ledgerPath = args.ledger || 'data/example-ledger.json';
const port = Number(args.port || 3910);
const host = args.host || '127.0.0.1';
const webDir = path.resolve(args.web || 'web');
let ledger = resolveAccountOwnerHints(await loadLedger(ledgerPath));
let graph = buildMoneyGraph(ledger);
const MIME = {
'.html': 'text/html; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.ico': 'image/x-icon',
};
const server = http.createServer(async (req, res) => {
try {
const url = new URL(req.url, `http://${req.headers.host}`);
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; }
if (req.method === 'GET' && url.pathname === '/health') {
return sendJson(res, { status: 'ok', service: 'kua-money-trace', ledger: ledgerPath });
}
@ -52,6 +68,24 @@ const server = http.createServer(async (req, res) => {
return sendJson(res, destinationTree(graph, decodeURIComponent(destinationMatch[1])));
}
// static files from web/
if (req.method === 'GET') {
const filePath = path.join(webDir, url.pathname === '/' ? 'dashboard.html' : url.pathname);
const realPath = path.resolve(filePath);
if (!realPath.startsWith(webDir + path.sep) && realPath !== webDir) {
return sendJson(res, { error: 'forbidden' }, 403);
}
try {
const data = await fs.readFile(realPath);
const ext = path.extname(realPath);
res.writeHead(200, { 'content-type': MIME[ext] || 'application/octet-stream' });
res.end(data);
return;
} catch {
// fall through to 404
}
}
sendJson(res, { error: 'not found' }, 404);
} catch (error) {
sendJson(res, { error: error.message }, 500);
@ -60,6 +94,8 @@ const server = http.createServer(async (req, res) => {
server.listen(port, host, () => {
console.log(`kua-money-trace listening on http://${host}:${port}`);
console.log(` dashboard: http://${host}:${port}/dashboard.html`);
console.log(` ledger: ${ledgerPath}`);
});
function sendJson(res, body, status = 200) {

553
web/dashboard.html Normal file
View File

@ -0,0 +1,553 @@
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>money-trace · dashboard</title>
<style>
:root{
--bg:#13131a; --panel:#1b1b24; --line:#2a2a37; --txt:#e7e7ee; --mut:#8a8a9a;
--in:#5ec48a; --out:#d98a8a; --accent:#c8b5d1; --chip:#23232e;
--warn:#c8a85e; --link-funds:#7ab3d9; --link-fin:#9bc8a4;
}
*{box-sizing:border-box;margin:0;padding:0}
body{background:var(--bg);color:var(--txt);font:13px/1.5 ui-sans-serif,-apple-system,Segoe UI,sans-serif;min-height:100vh;display:flex;flex-direction:column}
a{color:inherit;text-decoration:none}
/* ─── header ─────────────────────────────── */
header{position:sticky;top:0;z-index:20;background:var(--bg);border-bottom:1px solid var(--line);
padding:12px 20px;display:flex;align-items:center;gap:14px;flex-wrap:wrap}
header h1{font-size:14px;font-weight:600;letter-spacing:.3px;white-space:nowrap}
header h1 small{color:var(--mut);font-weight:400}
.api-bar{display:flex;align-items:center;gap:6px;margin-left:auto}
.api-bar label{color:var(--mut);font-size:11px;white-space:nowrap}
.api-bar input{background:var(--panel);border:1px solid var(--line);color:var(--txt);
padding:4px 9px;border-radius:6px;font-size:12px;outline:none;width:180px}
.api-bar input:focus{border-color:var(--accent)}
.btn{background:var(--panel);border:1px solid var(--line);color:var(--txt);
padding:5px 12px;border-radius:6px;font-size:12px;cursor:pointer;white-space:nowrap}
.btn:hover{border-color:var(--accent);color:var(--accent)}
.dot{width:7px;height:7px;border-radius:50%;background:var(--mut);flex-shrink:0}
.dot.ok{background:var(--in)} .dot.err{background:var(--out)}
.nav-links{display:flex;gap:2px}
.nav-links a{color:var(--mut);font-size:12px;padding:4px 10px;border-radius:6px;border:1px solid transparent}
.nav-links a:hover{color:var(--txt);border-color:var(--line)}
.nav-links a.active{color:var(--accent);border-color:var(--line)}
/* ─── layout ─────────────────────────────── */
.main{flex:1;display:flex;overflow:hidden}
.content{flex:1;overflow-y:auto;padding:20px}
.tree-panel{width:0;overflow:hidden;transition:width .2s ease;border-left:1px solid transparent;background:var(--panel);display:flex;flex-direction:column}
.tree-panel.open{width:420px;border-left-color:var(--line)}
.tree-header{padding:14px 16px;border-bottom:1px solid var(--line);display:flex;align-items:center;gap:10px;flex-shrink:0}
.tree-header h3{font-size:13px;font-weight:600;flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.tree-body{flex:1;overflow-y:auto;padding:16px;font:12px/1.6 ui-monospace,monospace}
.tree-tabs{display:flex;gap:0;flex-shrink:0;border-bottom:1px solid var(--line)}
.tree-tabs button{flex:1;background:transparent;border:0;border-bottom:2px solid transparent;color:var(--mut);
padding:8px;font-size:12px;cursor:pointer}
.tree-tabs button.on{color:var(--txt);border-bottom-color:var(--accent)}
/* ─── KPI strip ───────────────────────────── */
.kpis{display:flex;gap:12px;flex-wrap:wrap;margin-bottom:20px}
.kpi{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:14px 18px;min-width:110px}
.kpi b{display:block;font-size:10px;color:var(--mut);font-weight:500;text-transform:uppercase;letter-spacing:.5px;margin-bottom:4px}
.kpi span{font-size:22px;font-weight:700;font-variant-numeric:tabular-nums}
.kpi.accent span{color:var(--accent)}
.kpi.green span{color:var(--in)}
.kpi.red span{color:var(--out)}
/* ─── two-col layout ──────────────────────── */
.cols{display:grid;grid-template-columns:280px 1fr;gap:16px;margin-bottom:20px}
@media(max-width:900px){.cols{grid-template-columns:1fr}}
/* ─── cards ───────────────────────────────── */
.card{background:var(--panel);border:1px solid var(--line);border-radius:10px;overflow:hidden}
.card-head{padding:12px 16px;border-bottom:1px solid var(--line);display:flex;align-items:center;justify-content:space-between}
.card-head h2{font-size:12px;font-weight:600;text-transform:uppercase;letter-spacing:.5px;color:var(--mut)}
.card-body{padding:12px 0}
/* ─── entity list ─────────────────────────── */
.entity-item{padding:10px 16px;display:flex;align-items:flex-start;gap:10px;cursor:pointer;border-bottom:1px solid var(--line)}
.entity-item:last-child{border-bottom:0}
.entity-item:hover{background:#1e1e28}
.entity-badge{flex-shrink:0;width:28px;height:28px;border-radius:8px;background:var(--chip);display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:700;color:var(--accent)}
.entity-info{flex:1;min-width:0}
.entity-name{font-size:13px;font-weight:500}
.entity-sub{font-size:11px;color:var(--mut);margin-top:1px}
.entity-accts{margin-top:6px;display:flex;flex-direction:column;gap:3px}
.acct-row{font-size:11px;color:var(--mut);display:flex;align-items:center;gap:6px}
.acct-row::before{content:"";display:inline-block;width:4px;height:4px;border-radius:50%;background:var(--line);flex-shrink:0}
.entity-net{font-size:12px;font-weight:600;font-variant-numeric:tabular-nums;white-space:nowrap;padding-top:2px}
/* ─── type breakdown ──────────────────────── */
.type-rows{padding:8px 0}
.type-row{padding:7px 16px;display:flex;align-items:center;gap:10px}
.type-label{font-size:12px;color:var(--txt);min-width:160px;flex-shrink:0}
.type-bar-wrap{flex:1;height:6px;background:var(--chip);border-radius:3px;overflow:hidden}
.type-bar{height:100%;border-radius:3px;transition:width .4s ease}
.type-amt{font-size:12px;font-weight:600;font-variant-numeric:tabular-nums;color:var(--mut);white-space:nowrap;min-width:90px;text-align:right}
.type-count{font-size:11px;color:var(--mut);width:28px;text-align:right;flex-shrink:0}
/* ─── movements table ─────────────────────── */
.mov-table{width:100%;border-collapse:collapse;font-variant-numeric:tabular-nums}
.mov-table thead th{position:sticky;top:0;background:var(--panel);text-align:left;color:var(--mut);
font-size:10px;font-weight:500;text-transform:uppercase;letter-spacing:.5px;
padding:10px 12px;border-bottom:1px solid var(--line);white-space:nowrap}
.mov-table tbody td{padding:9px 12px;border-bottom:1px solid var(--line);font-size:12px;vertical-align:middle}
.mov-table tbody tr{cursor:pointer;transition:background .1s}
.mov-table tbody tr:hover{background:#1e1e28}
.mov-table tbody tr.selected{background:#202030}
.mov-table .amt{text-align:right;font-weight:600}
.in{color:var(--in)} .out{color:var(--out)}
.dir{font-size:10px;font-weight:700;letter-spacing:.4px}
.etype{display:inline-block;background:var(--chip);padding:2px 7px;border-radius:6px;font-size:10px;white-space:nowrap}
.desc-cell{max-width:300px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.date-cell{color:var(--mut);white-space:nowrap}
.cpty{color:var(--mut);font-size:11px;margin-top:1px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:220px}
.acct-cell{color:var(--mut);white-space:nowrap;font-size:11px}
.tree-btn{background:var(--chip);border:1px solid var(--line);color:var(--mut);padding:3px 8px;border-radius:5px;font-size:11px;cursor:pointer;white-space:nowrap}
.tree-btn:hover{color:var(--txt);border-color:var(--accent)}
/* ─── tree view ───────────────────────────── */
.tree-node{margin:2px 0}
.tree-node-label{display:flex;align-items:flex-start;gap:6px;padding:2px 0;line-height:1.4}
.tree-node-label .nid{color:var(--mut);font-size:10px}
.tree-node-label .ndesc{color:var(--txt)}
.tree-node-label .namt{color:var(--accent);font-size:11px;white-space:nowrap}
.tree-node-label .ndate{color:var(--mut);font-size:10px}
.tree-node-label .nkind{font-size:10px;background:var(--chip);padding:1px 5px;border-radius:4px;color:var(--mut);white-space:nowrap}
.tree-link-label{color:var(--mut);font-size:10px;padding:1px 0 1px 12px}
.tree-children{border-left:1px solid var(--line);margin-left:8px;padding-left:10px}
.tree-cycle{color:var(--warn);font-size:10px;font-style:italic}
.tree-truncated{color:var(--mut);font-size:10px;font-style:italic}
/* ─── empty / loading ─────────────────────── */
.empty{color:var(--mut);padding:32px;text-align:center;font-size:13px}
.spinner{display:inline-block;width:12px;height:12px;border:2px solid var(--line);border-top-color:var(--accent);border-radius:50%;animation:spin .6s linear infinite;vertical-align:middle;margin-right:6px}
@keyframes spin{to{transform:rotate(360deg)}}
/* ─── error banner ────────────────────────── */
#error-banner{display:none;background:#2a1a1a;border:1px solid #5a2a2a;color:#e09090;border-radius:8px;padding:10px 14px;margin-bottom:16px;font-size:12px}
</style>
</head>
<body>
<header>
<h1>money&#8209;trace <small id="sub">· dashboard</small></h1>
<div class="nav-links">
<a href="index.html">Cartolas</a>
<a href="dashboard.html" class="active">Dashboard</a>
</div>
<div class="api-bar">
<span class="dot" id="api-dot"></span>
<label>API</label>
<input type="text" id="api-base" value="http://localhost:3910" placeholder="http://localhost:3910">
<button class="btn" id="reload-btn">↺ Recargar</button>
</div>
</header>
<div class="main">
<div class="content" id="content">
<div id="error-banner"></div>
<!-- KPI strip -->
<div class="kpis" id="kpis">
<div class="kpi"><b>Entidades</b><span id="k-entities"></span></div>
<div class="kpi"><b>Cuentas</b><span id="k-accounts"></span></div>
<div class="kpi"><b>Movimientos</b><span id="k-movements"></span></div>
<div class="kpi"><b>Documentos</b><span id="k-documents"></span></div>
<div class="kpi"><b>Eventos</b><span id="k-events"></span></div>
<div class="kpi"><b>Links</b><span id="k-links"></span></div>
<div class="kpi green"><b>Ingresos reales</b><span id="k-income"></span></div>
<div class="kpi red"><b>Egresos reales</b><span id="k-expense"></span></div>
</div>
<!-- Two-col: entities + type breakdown -->
<div class="cols" id="cols">
<div class="card" id="entity-card">
<div class="card-head"><h2>Entidades</h2></div>
<div class="card-body" id="entity-list"><div class="empty"><span class="spinner"></span>Cargando…</div></div>
</div>
<div class="card" id="type-card">
<div class="card-head"><h2>Movimientos por tipo económico</h2></div>
<div class="type-rows" id="type-rows"><div class="empty"><span class="spinner"></span>Cargando…</div></div>
</div>
</div>
<!-- Movements -->
<div class="card">
<div class="card-head">
<h2>Movimientos</h2>
<div style="display:flex;gap:8px;align-items:center">
<input type="search" id="mov-search" placeholder="Buscar…" style="background:var(--chip);border:1px solid var(--line);color:var(--txt);padding:4px 9px;border-radius:6px;font-size:12px;outline:none;width:180px">
<select id="mov-dir" style="background:var(--chip);border:1px solid var(--line);color:var(--txt);padding:4px 9px;border-radius:6px;font-size:12px;outline:none">
<option value="">Todos</option>
<option value="in">▲ Entradas</option>
<option value="out">▼ Salidas</option>
</select>
<select id="mov-etype" style="background:var(--chip);border:1px solid var(--line);color:var(--txt);padding:4px 9px;border-radius:6px;font-size:12px;outline:none">
<option value="">Todos los tipos</option>
</select>
</div>
</div>
<div style="overflow-x:auto">
<table class="mov-table">
<thead><tr>
<th>Fecha</th>
<th>Cuenta</th>
<th>Dir</th>
<th style="text-align:right">Monto</th>
<th>Tipo económico</th>
<th>Descripción / Contraparte</th>
<th>Árbol</th>
</tr></thead>
<tbody id="mov-rows"></tbody>
</table>
<div id="mov-empty" class="empty" hidden>Sin movimientos para este filtro.</div>
</div>
</div>
</div>
<!-- Tree panel -->
<div class="tree-panel" id="tree-panel">
<div class="tree-header">
<h3 id="tree-title">Árbol de trazabilidad</h3>
<button class="btn" id="tree-close"></button>
</div>
<div class="tree-tabs">
<button class="on" id="tab-origin" onclick="switchTab('origin')">← Origen</button>
<button id="tab-dest" onclick="switchTab('dest')">Destino →</button>
</div>
<div class="tree-body" id="tree-body"><div class="empty">Selecciona un movimiento.</div></div>
</div>
</div>
<script>
// ─── state ────────────────────────────────────────────────────
let MOVEMENTS = [], ENTITIES = [], ACCOUNTS = [], SUMMARY = null;
let accountById = {}, entityById = {};
let selectedMovId = null, activeTab = 'origin';
// ─── config ───────────────────────────────────────────────────
function apiBase() { return (document.getElementById('api-base').value || 'http://localhost:3910').replace(/\/$/, ''); }
function setDot(ok) {
const d = document.getElementById('api-dot');
d.className = 'dot ' + (ok ? 'ok' : 'err');
}
// ─── economic type meta ───────────────────────────────────────
const ETYPE = {
pure_income: ['Ingreso puro', '#5ec48a', true, false],
operating_income: ['Ingreso operacional', '#5ec48a', true, false],
real_expense: ['Gasto real', '#d98a8a', false, true ],
card_charge: ['Cargo tarjeta', '#d98a8a', false, true ],
card_payment: ['Pago tarjeta', '#8a8a9a', false, false],
wallet_funding: ['Carga wallet', '#8a8a9a', false, false],
foreign_account_funding: ['Fondeo ext.', '#8a8a9a', false, false],
internal_transfer: ['Transferencia interna', '#8a8a9a', false, false],
reimbursement: ['Reembolso', '#9bc8a4', true, false],
refund: ['Refund', '#9bc8a4', true, false],
partner_loan: ['Préstamo socio', '#c8b5d1', false, false],
partner_withdrawal: ['Retiro socio', '#c8b5d1', false, false],
adjustment: ['Ajuste', '#8a8a9a', false, false],
non_accounting: ['No contable', '#555566', false, false],
unknown: ['Desconocido', '#555566', false, false],
};
function etypeLabel(t) { return ETYPE[t] ? ETYPE[t][0] : t; }
function etypeColor(t) { return ETYPE[t] ? ETYPE[t][1] : '#8a8a9a'; }
function isIncome(t) { return ETYPE[t] ? ETYPE[t][2] : false; }
function isExpense(t) { return ETYPE[t] ? ETYPE[t][3] : false; }
const fmt = (n, cur = 'CLP') => {
if (n == null) return '';
const f = Math.abs(n).toLocaleString('es-CL');
return (n < 0 ? '-' : '') + (cur !== 'CLP' ? cur + ' ' : '') + f;
};
const esc = s => String(s || '').replace(/[&<>"]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
// ─── fetch helpers ────────────────────────────────────────────
async function apiFetch(path) {
const r = await fetch(apiBase() + path);
if (!r.ok) throw new Error(`${r.status} ${r.statusText} ${path}`);
return r.json();
}
function showError(msg) {
const b = document.getElementById('error-banner');
b.textContent = msg; b.style.display = 'block';
}
function clearError() {
document.getElementById('error-banner').style.display = 'none';
}
// ─── load ─────────────────────────────────────────────────────
async function load() {
clearError();
try {
const [summary, entRes, accRes, movRes] = await Promise.all([
apiFetch('/summary'),
apiFetch('/entities'),
apiFetch('/accounts'),
apiFetch('/movements'),
]);
setDot(true);
SUMMARY = summary;
ENTITIES = entRes.entities || [];
ACCOUNTS = accRes.accounts || [];
MOVEMENTS = (movRes.movements || []).slice().sort((a, b) => b.date.localeCompare(a.date));
accountById = Object.fromEntries(ACCOUNTS.map(a => [a.id, a]));
entityById = Object.fromEntries(ENTITIES.map(e => [e.id, e]));
renderAll();
} catch (err) {
setDot(false);
showError('No se pudo conectar con el servidor: ' + err.message + '. ¿Está corriendo npm run serve?');
renderAllEmpty();
}
}
function renderAll() {
renderKPIs();
renderEntities();
renderTypes();
renderMovements();
}
function renderAllEmpty() {
document.getElementById('k-entities').textContent = '';
document.getElementById('entity-list').innerHTML = '<div class="empty">Sin datos.</div>';
document.getElementById('type-rows').innerHTML = '<div class="empty">Sin datos.</div>';
document.getElementById('mov-rows').innerHTML = '';
document.getElementById('mov-empty').hidden = false;
}
// ─── KPIs ─────────────────────────────────────────────────────
function renderKPIs() {
const c = SUMMARY.counts;
document.getElementById('k-entities').textContent = c.entities;
document.getElementById('k-accounts').textContent = c.accounts;
document.getElementById('k-movements').textContent = c.movements;
document.getElementById('k-documents').textContent = c.documents;
document.getElementById('k-events').textContent = c.events;
document.getElementById('k-links').textContent = c.links;
const byType = SUMMARY.movementAmountByType || {};
let income = 0, expense = 0;
for (const [t, v] of Object.entries(byType)) {
if (isIncome(t)) income += v;
if (isExpense(t)) expense += v;
}
document.getElementById('k-income').textContent = fmt(income);
document.getElementById('k-expense').textContent = fmt(expense);
document.getElementById('sub').textContent =
'· ' + c.entities + ' entid. · ' + c.movements + ' mov.';
}
// ─── entities ─────────────────────────────────────────────────
function renderEntities() {
const acctsByEntity = {};
for (const a of ACCOUNTS) {
if (!acctsByEntity[a.ownerEntityId]) acctsByEntity[a.ownerEntityId] = [];
acctsByEntity[a.ownerEntityId].push(a);
}
const entityHints = SUMMARY.movementAmountByEntityHint || {};
const html = ENTITIES.map(e => {
const initials = (e.name || e.id).split(/\s+/).slice(0,2).map(w=>w[0]).join('').toUpperCase();
const accts = acctsByEntity[e.id] || [];
const net = entityHints[e.id];
const netHtml = net != null
? `<div class="entity-net ${net >= 0 ? 'in' : 'out'}">${fmt(net)}</div>` : '';
const acctHtml = accts.map(a =>
`<div class="acct-row">${esc(a.label || a.id)}</div>`
).join('');
return `<div class="entity-item">
<div class="entity-badge">${esc(initials)}</div>
<div class="entity-info">
<div class="entity-name">${esc(e.name || e.id)}</div>
<div class="entity-sub">${esc(e.kind || '')}${e.rut ? ' · ' + e.rut : ''}</div>
${accts.length ? '<div class="entity-accts">' + acctHtml + '</div>' : ''}
</div>
${netHtml}
</div>`;
}).join('');
document.getElementById('entity-list').innerHTML = html || '<div class="empty">Sin entidades.</div>';
}
// ─── type breakdown ────────────────────────────────────────────
function renderTypes() {
const byType = SUMMARY.movementAmountByType || {};
const entries = Object.entries(byType).filter(([,v]) => v > 0).sort((a,b) => b[1]-a[1]);
if (!entries.length) { document.getElementById('type-rows').innerHTML = '<div class="empty">Sin datos.</div>'; return; }
const max = entries[0][1];
// count per type from movements
const countByType = {};
for (const m of MOVEMENTS) countByType[m.economicType] = (countByType[m.economicType] || 0) + 1;
// populate filter dropdown
const sel = document.getElementById('mov-etype');
while (sel.options.length > 1) sel.remove(1);
entries.forEach(([t]) => {
const o = new Option(etypeLabel(t), t);
sel.add(o);
});
const rows = entries.map(([t, v]) => {
const pct = Math.round((v / max) * 100);
const color = etypeColor(t);
const cnt = countByType[t] || 0;
return `<div class="type-row">
<div class="type-label" style="color:${color}">${etypeLabel(t)}</div>
<div class="type-bar-wrap"><div class="type-bar" style="width:${pct}%;background:${color}"></div></div>
<div class="type-amt">${fmt(v)}</div>
<div class="type-count">${cnt}</div>
</div>`;
}).join('');
document.getElementById('type-rows').innerHTML = rows;
}
// ─── movements ────────────────────────────────────────────────
function currentMovements() {
const q = document.getElementById('mov-search').value.toLowerCase().trim();
const dir = document.getElementById('mov-dir').value;
const etype = document.getElementById('mov-etype').value;
return MOVEMENTS.filter(m => {
if (dir && m.direction !== dir) return false;
if (etype && m.economicType !== etype) return false;
if (q) {
const hay = ((m.description || '') + ' ' + (m.counterparty || '') + ' ' + m.id).toLowerCase();
if (!hay.includes(q)) return false;
}
return true;
});
}
function renderMovements() {
const filtered = currentMovements();
const tb = document.getElementById('mov-rows');
document.getElementById('mov-empty').hidden = filtered.length > 0;
tb.innerHTML = filtered.map(m => {
const cls = m.direction === 'in' ? 'in' : 'out';
const arr = m.direction === 'in' ? '▲' : '▼';
const acct = accountById[m.accountId];
const acctLabel = acct ? esc(acct.label || acct.id) : esc(m.accountId);
const color = etypeColor(m.economicType);
const sel = m.id === selectedMovId ? ' selected' : '';
return `<tr data-id="${esc(m.id)}"${sel}>
<td class="date-cell">${m.date || ''}</td>
<td class="acct-cell">${acctLabel}</td>
<td class="dir ${cls}">${arr}</td>
<td class="amt ${cls}">${fmt(m.amount?.value, m.amount?.currency)}</td>
<td><span class="etype" style="color:${color}">${etypeLabel(m.economicType)}</span></td>
<td>
<div class="desc-cell">${esc(m.description || '')}</div>
${m.counterparty ? `<div class="cpty">→ ${esc(m.counterparty)}</div>` : ''}
</td>
<td><button class="tree-btn" data-id="${esc(m.id)}">árbol</button></td>
</tr>`;
}).join('');
tb.querySelectorAll('tr').forEach(tr => tr.addEventListener('click', (e) => {
if (e.target.tagName === 'BUTTON') return;
openTree(tr.dataset.id);
}));
tb.querySelectorAll('.tree-btn').forEach(b => b.addEventListener('click', (e) => {
e.stopPropagation();
openTree(b.dataset.id);
}));
}
document.getElementById('mov-search').addEventListener('input', renderMovements);
document.getElementById('mov-dir').addEventListener('change', renderMovements);
document.getElementById('mov-etype').addEventListener('change', renderMovements);
// ─── tree panel ───────────────────────────────────────────────
async function openTree(movId) {
selectedMovId = movId;
document.querySelectorAll('.mov-table tbody tr').forEach(r => {
r.classList.toggle('selected', r.dataset.id === movId);
});
const panel = document.getElementById('tree-panel');
panel.classList.add('open');
const m = MOVEMENTS.find(x => x.id === movId);
document.getElementById('tree-title').textContent = m ? (m.description || m.id) : movId;
await loadTreeTab(movId, activeTab);
}
async function loadTreeTab(movId, tab) {
const body = document.getElementById('tree-body');
body.innerHTML = '<div class="empty"><span class="spinner"></span>Cargando árbol…</div>';
try {
const path = tab === 'origin'
? `/nodes/${encodeURIComponent(movId)}/origin-tree`
: `/nodes/${encodeURIComponent(movId)}/destination-tree`;
const tree = await apiFetch(path);
body.innerHTML = renderTree(tree, tab === 'origin');
} catch (err) {
body.innerHTML = `<div class="empty">Error: ${esc(err.message)}</div>`;
}
}
function switchTab(tab) {
activeTab = tab;
document.getElementById('tab-origin').classList.toggle('on', tab === 'origin');
document.getElementById('tab-dest').classList.toggle('on', tab === 'dest');
if (selectedMovId) loadTreeTab(selectedMovId, tab);
}
document.getElementById('tree-close').addEventListener('click', () => {
document.getElementById('tree-panel').classList.remove('open');
selectedMovId = null;
document.querySelectorAll('.mov-table tbody tr.selected').forEach(r => r.classList.remove('selected'));
});
// ─── tree renderer ─────────────────────────────────────────────
function renderTree(branch, isOrigin) {
const lines = [];
function visit(b, depth) {
const n = b.node || {};
const label = n.description || n.subject || n.label || n.name || n.issuerName || n.id || '?';
const amtHtml = n.amount ? ` <span class="namt">${fmt(n.amount.value, n.amount.currency)}</span>` : '';
const dateHtml = (n.date || n.documentDate) ? ` <span class="ndate">${n.date || n.documentDate}</span>` : '';
const kindHtml = n.kind ? ` <span class="nkind">${esc(n.kind)}</span>` : '';
const idHtml = `<span class="nid">${esc(n.id || '')}</span>`;
if (b.cycle) {
lines.push(`<div class="tree-node-label"><span class="tree-cycle">↩ ciclo: ${esc(label)}</span></div>`);
return;
}
if (b.truncated) {
lines.push(`<div class="tree-node-label"><span class="tree-truncated">… truncado</span></div>`);
return;
}
lines.push(`<div class="tree-node"><div class="tree-node-label">${idHtml}${dateHtml} <span class="ndesc">${esc(label)}</span>${amtHtml}${kindHtml}</div>`);
const children = isOrigin ? (b.incoming || []) : (b.outgoing || []);
if (children.length) {
lines.push('<div class="tree-children">');
for (const edge of children) {
const lnk = edge.link || {};
const lamt = lnk.amount ? ` ${fmt(lnk.amount.value, lnk.amount.currency)}` : '';
const ltype = lnk.type ? `<span style="color:var(--link-funds)">${esc(lnk.type)}</span>` : '';
const lmeth = lnk.method ? ` <span style="color:var(--mut)">(${esc(lnk.method)})</span>` : '';
lines.push(`<div class="tree-link-label">↖ ${ltype}${lamt}${lmeth}</div>`);
const child = isOrigin ? edge.source : edge.target;
visit(child, depth + 1);
}
lines.push('</div>');
}
lines.push('</div>');
}
visit(branch, 0);
return lines.join('');
}
// ─── boot ─────────────────────────────────────────────────────
document.getElementById('reload-btn').addEventListener('click', load);
document.getElementById('api-base').addEventListener('keydown', e => { if (e.key === 'Enter') load(); });
load();
</script>
</body>
</html>