638 lines
33 KiB
HTML
638 lines
33 KiB
HTML
<!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‑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=>({'&':'&','<':'<','>':'>','"':'"'}[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>
|