kua-money-trace/web/dashboard.html

554 lines
28 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!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>