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:
parent
cd5b02a4e3
commit
822a9b5907
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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‑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 => ({'&':'&','<':'<','>':'>','"':'"'}[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>
|
||||
Loading…
Reference in New Issue