kua-money-trace/web/dashboard.html

638 lines
33 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: #0d0d12;
--surface: #14141c;
--raised: #1c1c28;
--border: #242433;
--border2: #2e2e44;
--txt: #e2e2f0;
--sub: #7070a0;
--dim: #4a4a70;
--in: #4ade9a;
--in-dim: #1a3d2a;
--out: #f06a6a;
--out-dim: #3d1a1a;
--acc: #a78bfa;
--acc-dim: #2a1f4a;
--gold: #f0b84a;
--gold-dim:#3d2e0f;
--blue: #60b0f0;
--sidebar: 56px;
--topbar: 52px;
}
*, *::before, *::after { 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; height: 100vh; display: flex; overflow: hidden }
/* ── sidebar ──────────────────────────────── */
nav {
width: var(--sidebar); height: 100vh; background: var(--surface);
border-right: 1px solid var(--border); display: flex; flex-direction: column;
align-items: center; padding: 12px 0; gap: 4px; flex-shrink: 0; z-index: 10;
}
.logo { width: 32px; height: 32px; border-radius: 9px; background: var(--acc-dim);
display: flex; align-items: center; justify-content: center; margin-bottom: 12px;
font-size: 15px; font-weight: 800; color: var(--acc); letter-spacing: -1px }
.nav-btn { width: 36px; height: 36px; border-radius: 8px; border: none; background: transparent;
color: var(--dim); cursor: pointer; display: flex; align-items: center; justify-content: center;
font-size: 16px; transition: background .15s, color .15s }
.nav-btn:hover { background: var(--raised); color: var(--txt) }
.nav-btn.active { background: var(--acc-dim); color: var(--acc) }
.nav-sep { width: 24px; height: 1px; background: var(--border); margin: 4px 0 }
.nav-bot { margin-top: auto }
/* ── main frame ───────────────────────────── */
.frame { flex: 1; display: flex; flex-direction: column; overflow: hidden; min-width: 0 }
/* ── topbar ───────────────────────────────── */
.topbar {
height: var(--topbar); background: var(--surface); border-bottom: 1px solid var(--border);
display: flex; align-items: center; padding: 0 20px; gap: 14px; flex-shrink: 0
}
.topbar h1 { font-size: 13px; font-weight: 600; color: var(--txt); letter-spacing: .2px }
.topbar h1 span { color: var(--sub); font-weight: 400 }
.status-pill { display: flex; align-items: center; gap: 5px; background: var(--raised);
border: 1px solid var(--border); border-radius: 20px; padding: 3px 10px 3px 6px; font-size: 11px; color: var(--sub) }
.dot { width: 6px; height: 6px; border-radius: 50%; background: var(--dim) }
.dot.live { background: var(--in); box-shadow: 0 0 6px var(--in) }
.dot.err { background: var(--out) }
.topbar-right { margin-left: auto; display: flex; align-items: center; gap: 8px }
.api-input { background: var(--raised); border: 1px solid var(--border); color: var(--txt);
padding: 5px 10px; border-radius: 8px; font-size: 12px; outline: none; width: 190px }
.api-input:focus { border-color: var(--acc) }
.icon-btn { background: var(--raised); border: 1px solid var(--border); color: var(--sub);
padding: 5px 10px; border-radius: 8px; font-size: 12px; cursor: pointer }
.icon-btn:hover { color: var(--txt); border-color: var(--border2) }
/* ── scroll area ──────────────────────────── */
.scroll { flex: 1; overflow-y: auto; overflow-x: hidden; padding: 20px }
.scroll::-webkit-scrollbar { width: 5px }
.scroll::-webkit-scrollbar-track { background: transparent }
.scroll::-webkit-scrollbar-thumb { background: var(--border2); border-radius: 3px }
/* ── error bar ────────────────────────────── */
#err { display: none; background: var(--out-dim); border: 1px solid #5a2020; color: #f09090;
border-radius: 8px; padding: 9px 14px; margin-bottom: 16px; font-size: 12px }
/* ── KPI row ──────────────────────────────── */
.kpi-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 10px; margin-bottom: 18px }
.kpi { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 16px 18px; position: relative; overflow: hidden }
.kpi::before { content: ''; position: absolute; inset: 0; opacity: .06; border-radius: 12px }
.kpi.green::before { background: var(--in) } .kpi.green .kpi-val { color: var(--in) }
.kpi.red::before { background: var(--out) } .kpi.red .kpi-val { color: var(--out) }
.kpi.purple::before{ background: var(--acc) } .kpi.purple .kpi-val { color: var(--acc) }
.kpi.gold::before { background: var(--gold) } .kpi.gold .kpi-val { color: var(--gold) }
.kpi-label { font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: .7px; color: var(--sub); margin-bottom: 6px }
.kpi-val { font-size: 26px; font-weight: 700; font-variant-numeric: tabular-nums; line-height: 1 }
.kpi-sub { font-size: 11px; color: var(--dim); margin-top: 4px }
/* ── two-col body ─────────────────────────── */
.body-grid { display: grid; grid-template-columns: 1fr 340px; gap: 14px; margin-bottom: 14px }
@media (max-width: 960px) { .body-grid { grid-template-columns: 1fr } }
/* ── panel ────────────────────────────────── */
.panel { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; overflow: hidden }
.panel-head { padding: 13px 16px; border-bottom: 1px solid var(--border); display: flex; align-items: center; justify-content: space-between }
.panel-title { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .7px; color: var(--sub) }
.panel-count { font-size: 11px; color: var(--dim) }
/* ── type chart ───────────────────────────── */
.chart-wrap { padding: 16px; display: flex; gap: 20px; align-items: center }
.donut-wrap { flex-shrink: 0; position: relative; width: 120px; height: 120px }
.donut-wrap svg { overflow: visible }
.donut-center { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; pointer-events: none }
.donut-center .dc-val { font-size: 20px; font-weight: 700; color: var(--txt) }
.donut-center .dc-sub { font-size: 10px; color: var(--sub) }
.donut-legend { flex: 1; display: flex; flex-direction: column; gap: 7px; min-width: 0 }
.legend-row { display: flex; align-items: center; gap: 8px }
.legend-dot { width: 8px; height: 8px; border-radius: 2px; flex-shrink: 0 }
.legend-label { font-size: 12px; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap }
.legend-amt { font-size: 12px; font-weight: 600; font-variant-numeric: tabular-nums; color: var(--sub); white-space: nowrap }
.legend-pct { font-size: 10px; color: var(--dim); width: 32px; text-align: right; flex-shrink: 0 }
/* ── entity cards ─────────────────────────── */
.entity-list { padding: 8px 0 }
.entity-row { padding: 10px 16px; display: flex; align-items: center; gap: 11px; border-bottom: 1px solid var(--border) }
.entity-row:last-child { border-bottom: 0 }
.avatar { width: 32px; height: 32px; border-radius: 9px; background: var(--acc-dim);
display: flex; align-items: center; justify-content: center; font-size: 11px; font-weight: 700; color: var(--acc); flex-shrink: 0 }
.avatar.company { background: var(--gold-dim); color: var(--gold) }
.avatar.vendor { background: var(--raised); color: var(--sub) }
.entity-info { flex: 1; min-width: 0 }
.entity-name { font-size: 13px; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap }
.entity-meta { font-size: 11px; color: var(--sub); margin-top: 1px }
.entity-accts { margin-top: 5px; display: flex; flex-direction: column; gap: 3px }
.acct-chip { font-size: 11px; color: var(--dim); display: flex; align-items: center; gap: 5px }
.acct-chip::before { content: ''; width: 3px; height: 3px; border-radius: 50%; background: var(--border2); flex-shrink: 0 }
.entity-net { font-size: 13px; font-weight: 700; font-variant-numeric: tabular-nums; flex-shrink: 0 }
/* ── movements ────────────────────────────── */
.mov-controls { padding: 12px 16px; border-bottom: 1px solid var(--border); display: flex; gap: 8px; flex-wrap: wrap; align-items: center }
.ctrl-input { background: var(--raised); border: 1px solid var(--border); color: var(--txt);
padding: 6px 10px; border-radius: 8px; font-size: 12px; outline: none }
.ctrl-input:focus { border-color: var(--acc) }
.ctrl-input::placeholder { color: var(--dim) }
.seg { display: flex; border: 1px solid var(--border); border-radius: 8px; overflow: hidden }
.seg button { background: transparent; border: 0; color: var(--sub); padding: 6px 11px; cursor: pointer; font-size: 12px }
.seg button.on { background: var(--raised); color: var(--txt) }
/* ── movement feed ────────────────────────── */
.mov-feed { display: flex; flex-direction: column }
.mov-item { display: flex; align-items: flex-start; gap: 12px; padding: 12px 16px;
border-bottom: 1px solid var(--border); cursor: pointer; transition: background .1s }
.mov-item:last-child { border-bottom: 0 }
.mov-item:hover { background: var(--raised) }
.mov-item.selected { background: #1e1e35 }
.mov-icon { width: 34px; height: 34px; border-radius: 9px; display: flex; align-items: center;
justify-content: center; font-size: 14px; flex-shrink: 0; margin-top: 1px }
.mov-icon.in { background: var(--in-dim); color: var(--in) }
.mov-icon.out { background: var(--out-dim); color: var(--out) }
.mov-body { flex: 1; min-width: 0 }
.mov-top { display: flex; align-items: baseline; gap: 8px }
.mov-desc { font-size: 13px; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; min-width: 0 }
.mov-amt { font-size: 13px; font-weight: 700; font-variant-numeric: tabular-nums; flex-shrink: 0 }
.mov-amt.in { color: var(--in) }
.mov-amt.out { color: var(--out) }
.mov-bot { display: flex; align-items: center; gap: 8px; margin-top: 3px }
.mov-date { font-size: 11px; color: var(--dim) }
.mov-cpty { font-size: 11px; color: var(--sub); overflow: hidden; text-overflow: ellipsis; white-space: nowrap }
.etype-chip { font-size: 10px; padding: 1px 6px; border-radius: 4px; white-space: nowrap; flex-shrink: 0 }
.tree-trigger { flex-shrink: 0; background: var(--raised); border: 1px solid var(--border);
color: var(--dim); padding: 4px 9px; border-radius: 6px; font-size: 11px; cursor: pointer;
margin-top: 1px; transition: border-color .15s, color .15s }
.tree-trigger:hover { border-color: var(--acc); color: var(--acc) }
/* ── empty / loading ──────────────────────── */
.empty { color: var(--sub); padding: 28px; text-align: center; font-size: 12px }
.spin { display: inline-block; width: 11px; height: 11px; border: 2px solid var(--border2);
border-top-color: var(--acc); border-radius: 50%; animation: sp .6s linear infinite; vertical-align: middle; margin-right: 5px }
@keyframes sp { to { transform: rotate(360deg) } }
/* ── tree drawer ──────────────────────────── */
.drawer { position: fixed; top: 0; right: 0; bottom: 0; width: 400px; max-width: 90vw;
background: var(--surface); border-left: 1px solid var(--border);
display: flex; flex-direction: column; z-index: 100;
transform: translateX(100%); transition: transform .22s cubic-bezier(.4,0,.2,1) }
.drawer.open { transform: translateX(0) }
.drawer-head { padding: 14px 16px; border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 10px; flex-shrink: 0 }
.drawer-head h3 { font-size: 13px; font-weight: 600; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap }
.close-btn { background: var(--raised); border: 1px solid var(--border); color: var(--sub);
width: 28px; height: 28px; border-radius: 7px; cursor: pointer; font-size: 13px; display: flex; align-items: center; justify-content: center }
.close-btn:hover { color: var(--txt) }
.drawer-tabs { display: flex; border-bottom: 1px solid var(--border); flex-shrink: 0 }
.drawer-tabs button { flex: 1; background: transparent; border: 0; border-bottom: 2px solid transparent;
color: var(--sub); padding: 9px; font-size: 12px; cursor: pointer }
.drawer-tabs button.on { color: var(--txt); border-bottom-color: var(--acc) }
.drawer-body { flex: 1; overflow-y: auto; padding: 14px; font: 12px/1.6 ui-monospace, monospace }
/* ── tree nodes ───────────────────────────── */
.tn { margin: 3px 0 }
.tn-label { display: flex; align-items: flex-start; gap: 6px; padding: 3px 6px; border-radius: 6px }
.tn-label:hover { background: var(--raised) }
.tn-kind { font-size: 9px; background: var(--raised); border: 1px solid var(--border); padding: 1px 5px; border-radius: 4px; color: var(--dim); flex-shrink: 0; margin-top: 2px }
.tn-id { color: var(--dim); font-size: 10px }
.tn-desc { color: var(--txt) }
.tn-amt { color: var(--acc); font-size: 11px; white-space: nowrap; flex-shrink: 0 }
.tn-date { color: var(--dim); font-size: 10px; flex-shrink: 0 }
.tl-label { color: var(--sub); font-size: 10px; padding: 1px 6px 1px 20px }
.tl-label .tl-type { color: var(--blue) }
.tl-label .tl-meth { color: var(--dim) }
.tc { border-left: 1px solid var(--border); margin-left: 12px; padding-left: 10px }
.t-cycle { color: var(--gold); font-size: 11px; font-style: italic }
</style>
</head>
<body>
<!-- sidebar -->
<nav>
<div class="logo"></div>
<button class="nav-btn active" title="Dashboard"></button>
<a href="index.html" style="text-decoration:none"><button class="nav-btn" title="Cartolas"></button></a>
<div class="nav-sep"></div>
<div class="nav-bot">
<div class="dot" id="nav-dot" title="Estado del servidor"></div>
</div>
</nav>
<!-- main -->
<div class="frame">
<!-- topbar -->
<div class="topbar">
<h1>money&#8209;trace <span id="sub-label">· cargando…</span></h1>
<div class="status-pill">
<div class="dot" id="status-dot"></div>
<span id="status-txt">conectando</span>
</div>
<div class="topbar-right">
<input class="api-input" id="api-base" value="http://localhost:3910" placeholder="http://localhost:3910">
<button class="icon-btn" id="reload-btn">↺ Recargar</button>
</div>
</div>
<!-- scroll area -->
<div class="scroll">
<div id="err"></div>
<!-- KPIs -->
<div class="kpi-row" id="kpi-row">
<div class="kpi green"><div class="kpi-label">Ingresos reales</div><div class="kpi-val" id="k-income"></div><div class="kpi-sub" id="k-income-c"> movimientos</div></div>
<div class="kpi red"> <div class="kpi-label">Egresos reales</div> <div class="kpi-val" id="k-expense"></div><div class="kpi-sub" id="k-expense-c"> movimientos</div></div>
<div class="kpi purple"><div class="kpi-label">Neto</div> <div class="kpi-val" id="k-net"></div> <div class="kpi-sub">ingresos egresos</div></div>
<div class="kpi"> <div class="kpi-label">Movimientos</div> <div class="kpi-val" id="k-mov"></div> <div class="kpi-sub" id="k-mov-sub"> cuentas</div></div>
<div class="kpi"> <div class="kpi-label">Links de trazab.</div><div class="kpi-val" id="k-links"></div><div class="kpi-sub" id="k-links-sub"> documentos</div></div>
</div>
<!-- body grid -->
<div class="body-grid">
<!-- left: type donut + movements -->
<div style="display:flex;flex-direction:column;gap:14px">
<div class="panel">
<div class="panel-head">
<div class="panel-title">Flujo por tipo económico</div>
<div class="panel-count" id="type-cur">CLP</div>
</div>
<div class="chart-wrap" id="chart-wrap">
<div class="empty"><span class="spin"></span>cargando…</div>
</div>
</div>
<div class="panel">
<div class="panel-head">
<div class="panel-title">Movimientos</div>
<div style="display:flex;gap:6px">
<div class="seg" id="dir-seg">
<button data-d="all" class="on">Todo</button>
<button data-d="in"></button>
<button data-d="out"></button>
</div>
<select id="etype-sel" class="ctrl-input" style="padding:5px 8px">
<option value="">Todos los tipos</option>
</select>
<input class="ctrl-input" id="mov-q" placeholder="Buscar…" style="width:150px">
</div>
</div>
<div class="mov-feed" id="mov-feed">
<div class="empty"><span class="spin"></span>cargando…</div>
</div>
<div id="mov-empty" class="empty" hidden>Sin movimientos para este filtro.</div>
</div>
</div>
<!-- right: entities -->
<div class="panel" style="align-self:start">
<div class="panel-head">
<div class="panel-title">Entidades</div>
<div class="panel-count" id="ent-count"></div>
</div>
<div class="entity-list" id="entity-list">
<div class="empty"><span class="spin"></span>cargando…</div>
</div>
</div>
</div><!-- /body-grid -->
</div><!-- /scroll -->
</div><!-- /frame -->
<!-- tree drawer -->
<div class="drawer" id="drawer">
<div class="drawer-head">
<h3 id="drawer-title">Árbol de trazabilidad</h3>
<button class="close-btn" id="drawer-close"></button>
</div>
<div class="drawer-tabs">
<button class="on" id="tab-orig" onclick="switchTab('orig')">← Origen del dinero</button>
<button id="tab-dest" onclick="switchTab('dest')">Destino →</button>
</div>
<div class="drawer-body" id="drawer-body">
<div class="empty">Selecciona un movimiento.</div>
</div>
</div>
<script>
// ── state ─────────────────────────────────────────────────────
let MOVS=[], ENTS=[], ACCS=[], SUM=null;
let byId={}, entById={}, accById={};
let selId=null, activeTab='orig';
// ── helpers ────────────────────────────────────────────────────
const api = () => (document.getElementById('api-base').value||'http://localhost:3910').replace(/\/$/,'');
const fmt = (n, cur='CLP') => n==null ? '' : (cur!=='CLP'?cur+' ':'')+Math.abs(n).toLocaleString('es-CL');
const esc = s => String(s||'').replace(/[&<>"]/g, c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
// economic type metadata: [label, color, isIncome, isExpense]
const ET = {
pure_income: ['Ingreso puro', '#4ade9a', true, false],
operating_income: ['Ingreso oper.', '#4ade9a', true, false],
real_expense: ['Gasto real', '#f06a6a', false, true ],
card_charge: ['Cargo tarjeta', '#f06a6a', false, true ],
card_payment: ['Pago tarjeta', '#7070a0', false, false],
wallet_funding: ['Carga wallet', '#7070a0', false, false],
foreign_account_funding: ['Fondeo ext.', '#7070a0', false, false],
internal_transfer: ['Transf. interna', '#7070a0', false, false],
reimbursement: ['Reembolso', '#60d0a0', true, false],
refund: ['Refund', '#60d0a0', true, false],
partner_loan: ['Préstamo socio', '#a78bfa', false, false],
partner_withdrawal: ['Retiro socio', '#a78bfa', false, false],
adjustment: ['Ajuste', '#4a4a70', false, false],
non_accounting: ['No contable', '#2a2a44', false, false],
unknown: ['Desconocido', '#2a2a44', false, false],
};
const etLabel = t => ET[t]?.[0] || t;
const etColor = t => ET[t]?.[1] || '#4a4a70';
const isInc = t => ET[t]?.[2] || false;
const isExp = t => ET[t]?.[3] || false;
function setStatus(ok) {
const dot = document.getElementById('status-dot');
const ndot = document.getElementById('nav-dot');
const txt = document.getElementById('status-txt');
dot.className = ndot.className = 'dot ' + (ok ? 'live' : 'err');
txt.textContent = ok ? 'conectado' : 'error';
}
function showErr(msg) {
const el = document.getElementById('err');
el.textContent = msg; el.style.display = 'block';
}
function clearErr() { document.getElementById('err').style.display = 'none'; }
// ── load ────────────────────────────────────────────────────────
async function load() {
clearErr();
try {
const [sum, er, ar, mr] = await Promise.all([
fetch(api()+'/summary').then(r=>{ if(!r.ok) throw new Error(r.status); return r.json() }),
fetch(api()+'/entities').then(r=>r.json()),
fetch(api()+'/accounts').then(r=>r.json()),
fetch(api()+'/movements').then(r=>r.json()),
]);
setStatus(true);
SUM = sum;
ENTS = er.entities || [];
ACCS = ar.accounts || [];
MOVS = (mr.movements || []).slice().sort((a,b) => (b.date||'').localeCompare(a.date||''));
accById = Object.fromEntries(ACCS.map(a=>[a.id,a]));
entById = Object.fromEntries(ENTS.map(e=>[e.id,e]));
renderAll();
} catch(e) {
setStatus(false);
showErr('No se pudo conectar: '+e.message+' — ¿está corriendo npm run serve?');
}
}
function renderAll() {
renderKPIs();
renderChart();
renderEntities();
renderMovements();
}
// ── KPIs ────────────────────────────────────────────────────────
function renderKPIs() {
const c = SUM.counts;
const bt = SUM.movementAmountByType || {};
let inc=0, incN=0, exp=0, expN=0;
for (const m of MOVS) {
if (isInc(m.economicType)) { inc += m.amount?.value||0; incN++ }
if (isExp(m.economicType)) { exp += m.amount?.value||0; expN++ }
}
const net = inc - exp;
document.getElementById('k-income').textContent = fmt(inc);
document.getElementById('k-income-c').textContent = incN + ' movimientos';
document.getElementById('k-expense').textContent = fmt(exp);
document.getElementById('k-expense-c').textContent = expN + ' movimientos';
const nel = document.getElementById('k-net');
nel.textContent = (net >= 0 ? '+' : '-') + fmt(Math.abs(net));
nel.style.color = net >= 0 ? 'var(--in)' : 'var(--out)';
document.getElementById('k-mov').textContent = c.movements;
document.getElementById('k-mov-sub').textContent = c.accounts + ' cuentas · ' + c.entities + ' entidades';
document.getElementById('k-links').textContent = c.links;
document.getElementById('k-links-sub').textContent = c.documents + ' documentos · ' + c.events + ' eventos';
document.getElementById('sub-label').textContent = '· ' + c.entities + ' entidades · ' + c.movements + ' movimientos';
}
// ── donut chart ─────────────────────────────────────────────────
function renderChart() {
const bt = SUM.movementAmountByType || {};
const entries = Object.entries(bt).filter(([,v])=>v>0).sort((a,b)=>b[1]-a[1]);
const total = entries.reduce((s,[,v])=>s+v, 0);
if (!total) { document.getElementById('chart-wrap').innerHTML='<div class="empty">Sin datos.</div>'; return; }
// populate filter
const sel = document.getElementById('etype-sel');
while (sel.options.length > 1) sel.remove(1);
entries.forEach(([t]) => sel.add(new Option(etLabel(t), t)));
// SVG donut
const R=52, r=34, cx=60, cy=60, TAU=2*Math.PI;
let angle = -Math.PI/2;
const segments = entries.map(([t,v]) => {
const pct = v/total, sweep = pct*TAU;
const x1=cx+R*Math.cos(angle), y1=cy+R*Math.sin(angle);
angle += sweep;
const x2=cx+R*Math.cos(angle), y2=cy+R*Math.sin(angle);
const lf = sweep > Math.PI ? 1 : 0;
const path = `M${cx},${cy} L${x1},${y1} A${R},${R},0,${lf},1,${x2},${y2} Z`;
return { t, v, pct, path, color: etColor(t) };
});
// inner hole mask
const svgH = `<svg width="120" height="120" viewBox="0 0 120 120">
${segments.map(s=>`<path d="${esc(s.path)}" fill="${s.color}" opacity=".85"/>`).join('')}
<circle cx="60" cy="60" r="${r}" fill="var(--surface)"/>
</svg>`;
const legend = segments.slice(0,6).map(s => {
const pct = Math.round(s.pct*100);
return `<div class="legend-row">
<div class="legend-dot" style="background:${s.color}"></div>
<div class="legend-label">${esc(etLabel(s.t))}</div>
<div class="legend-amt">${fmt(s.v)}</div>
<div class="legend-pct">${pct}%</div>
</div>`;
}).join('');
document.getElementById('chart-wrap').innerHTML = `
<div class="donut-wrap">
${svgH}
<div class="donut-center">
<div class="dc-val">${entries.length}</div>
<div class="dc-sub">tipos</div>
</div>
</div>
<div class="donut-legend">${legend}</div>`;
}
// ── entities ────────────────────────────────────────────────────
function renderEntities() {
const acctsByEnt = {};
for (const a of ACCS) {
if (!acctsByEnt[a.ownerEntityId]) acctsByEnt[a.ownerEntityId] = [];
acctsByEnt[a.ownerEntityId].push(a);
}
const hints = SUM.movementAmountByEntityHint || {};
document.getElementById('ent-count').textContent = ENTS.length + ' entidades';
const html = ENTS.map(e => {
const init = (e.name||e.id).split(/\s+/).slice(0,2).map(w=>w[0]).join('').toUpperCase();
const cls = e.kind==='company' ? 'company' : e.kind==='vendor' ? 'vendor' : '';
const accts = acctsByEnt[e.id] || [];
const net = hints[e.id];
const netHtml = net!=null
? `<div class="entity-net ${net>=0?'':'out'}" style="color:${net>=0?'var(--in)':'var(--out)'}">${net>=0?'+':''}${fmt(net)}</div>` : '';
const acctHtml = accts.map(a=>`<div class="acct-chip">${esc(a.label||a.id)}</div>`).join('');
return `<div class="entity-row">
<div class="avatar ${cls}">${esc(init)}</div>
<div class="entity-info">
<div class="entity-name">${esc(e.name||e.id)}</div>
<div class="entity-meta">${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>';
}
// ── movements ────────────────────────────────────────────────────
function currentMovs() {
const q = document.getElementById('mov-q').value.toLowerCase().trim();
const dir = document.querySelector('#dir-seg .on').dataset.d;
const et = document.getElementById('etype-sel').value;
return MOVS.filter(m => {
if (dir!=='all' && m.direction!==dir) return false;
if (et && m.economicType!==et) return false;
if (q) {
const hay = ((m.description||'')+' '+(m.counterparty||'')+' '+m.id).toLowerCase();
if (!hay.includes(q)) return false;
}
return true;
});
}
function renderMovements() {
const movs = currentMovs();
const feed = document.getElementById('mov-feed');
document.getElementById('mov-empty').hidden = movs.length > 0;
feed.innerHTML = movs.map(m => {
const cls = m.direction==='in'?'in':'out';
const icon = m.direction==='in' ? '↑' : '↓';
const color = etColor(m.economicType);
const sel = m.id===selId ? ' selected' : '';
const acct = accById[m.accountId];
return `<div class="mov-item${sel}" data-id="${esc(m.id)}">
<div class="mov-icon ${cls}">${icon}</div>
<div class="mov-body">
<div class="mov-top">
<div class="mov-desc">${esc(m.description||m.id)}</div>
<div class="mov-amt ${cls}">${cls==='in'?'+':'-'}${fmt(m.amount?.value, m.amount?.currency)}</div>
</div>
<div class="mov-bot">
<span class="mov-date">${m.date||''}</span>
${m.counterparty?`<span class="mov-cpty">→ ${esc(m.counterparty)}</span>`:''}
<span class="etype-chip" style="background:${color}22;color:${color}">${esc(etLabel(m.economicType))}</span>
${acct?`<span class="mov-date">${esc(acct.label||acct.id)}</span>`:''}
</div>
</div>
<button class="tree-trigger" data-id="${esc(m.id)}">árbol ↗</button>
</div>`;
}).join('');
feed.querySelectorAll('.mov-item').forEach(el => el.addEventListener('click', e => {
if (e.target.classList.contains('tree-trigger')) return;
openDrawer(el.dataset.id);
}));
feed.querySelectorAll('.tree-trigger').forEach(b => b.addEventListener('click', e => {
e.stopPropagation(); openDrawer(b.dataset.id);
}));
}
document.getElementById('mov-q').addEventListener('input', renderMovements);
document.getElementById('etype-sel').addEventListener('change', renderMovements);
document.querySelectorAll('#dir-seg button').forEach(b => b.addEventListener('click', () => {
document.querySelectorAll('#dir-seg button').forEach(x=>x.classList.remove('on'));
b.classList.add('on'); renderMovements();
}));
// ── drawer ───────────────────────────────────────────────────────
async function openDrawer(movId) {
selId = movId;
document.querySelectorAll('.mov-item').forEach(el => el.classList.toggle('selected', el.dataset.id===movId));
const m = MOVS.find(x=>x.id===movId);
document.getElementById('drawer-title').textContent = m?.description || movId;
document.getElementById('drawer').classList.add('open');
await loadTree(movId, activeTab);
}
async function loadTree(movId, tab) {
const body = document.getElementById('drawer-body');
body.innerHTML = '<div class="empty"><span class="spin"></span>cargando árbol…</div>';
try {
const path = tab==='orig'
? `/nodes/${encodeURIComponent(movId)}/origin-tree`
: `/nodes/${encodeURIComponent(movId)}/destination-tree`;
const tree = await fetch(api()+path).then(r=>r.json());
body.innerHTML = renderTree(tree, tab==='orig');
} catch(e) {
body.innerHTML = `<div class="empty">Error: ${esc(e.message)}</div>`;
}
}
function switchTab(tab) {
activeTab = tab;
document.getElementById('tab-orig').classList.toggle('on', tab==='orig');
document.getElementById('tab-dest').classList.toggle('on', tab==='dest');
if (selId) loadTree(selId, tab);
}
document.getElementById('drawer-close').addEventListener('click', () => {
document.getElementById('drawer').classList.remove('open');
selId = null;
document.querySelectorAll('.mov-item.selected').forEach(el=>el.classList.remove('selected'));
});
// ── tree renderer ────────────────────────────────────────────────
function renderTree(branch, isOrig) {
const lines = [];
function visit(b) {
const n = b.node || {};
const label = n.description || n.subject || n.label || n.name || n.issuerName || n.id || '?';
const amtH = n.amount ? ` <span class="tn-amt">${fmt(n.amount.value,n.amount.currency)}</span>` : '';
const dtH = (n.date||n.documentDate) ? ` <span class="tn-date">${n.date||n.documentDate}</span>` : '';
const kindH = n.kind ? `<span class="tn-kind">${esc(n.kind)}</span>` : '';
if (b.cycle) { lines.push(`<div class="t-cycle">↩ ciclo: ${esc(label)}</div>`); return }
if (b.truncated) { lines.push(`<div class="t-cycle">… truncado</div>`); return }
lines.push(`<div class="tn"><div class="tn-label">${kindH}<span class="tn-id">${esc(n.id||'')}</span>${dtH} <span class="tn-desc">${esc(label)}</span>${amtH}</div>`);
const edges = isOrig ? (b.incoming||[]) : (b.outgoing||[]);
if (edges.length) {
lines.push('<div class="tc">');
for (const e of edges) {
const lk = e.link || {};
const la = lk.amount ? ' '+fmt(lk.amount.value,lk.amount.currency) : '';
const lm = lk.method ? ` <span class="tl-meth">(${esc(lk.method)})</span>` : '';
lines.push(`<div class="tl-label"><span class="tl-type">${esc(lk.type||'')}</span>${la}${lm}</div>`);
visit(isOrig ? e.source : e.target);
}
lines.push('</div>');
}
lines.push('</div>');
}
visit(branch);
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>