kua-money-trace/web/index.html

192 lines
9.8 KiB
HTML
Raw Permalink 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 · ledger</title>
<style>
:root{
--bg:#13131a; --panel:#1b1b24; --line:#2a2a37; --txt:#e7e7ee; --mut:#8a8a9a;
--in:#5ec48a; --out:#d98a8a; --accent:#c8b5d1; --chip:#23232e;
}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--txt);font:14px/1.45 ui-sans-serif,-apple-system,Segoe UI,Roboto,sans-serif}
header{position:sticky;top:0;z-index:5;background:var(--bg);border-bottom:1px solid var(--line);
padding:14px 20px;display:flex;gap:16px;align-items:center;flex-wrap:wrap}
h1{font-size:15px;font-weight:600;margin:0;letter-spacing:.3px}
h1 small{color:var(--mut);font-weight:400}
.stats{display:flex;gap:18px;margin-left:auto;font-variant-numeric:tabular-nums}
.stat b{display:block;font-size:11px;color:var(--mut);font-weight:500;text-transform:uppercase;letter-spacing:.5px}
.stat span{font-size:15px;font-weight:600}
.in{color:var(--in)} .out{color:var(--out)}
.controls{padding:12px 20px;display:flex;gap:10px;flex-wrap:wrap;align-items:center;border-bottom:1px solid var(--line)}
input[type=search],select{background:var(--panel);border:1px solid var(--line);color:var(--txt);
padding:7px 11px;border-radius:8px;font-size:13px;outline:none}
input[type=search]{min-width:240px}
input:focus,select:focus{border-color:var(--accent)}
.seg{display:flex;border:1px solid var(--line);border-radius:8px;overflow:hidden}
.seg button{background:var(--panel);border:0;color:var(--mut);padding:7px 13px;cursor:pointer;font-size:13px}
.seg button.on{background:var(--chip);color:var(--txt)}
.wrap{padding:0 20px 60px}
table{width:100%;border-collapse:collapse;font-variant-numeric:tabular-nums}
thead th{position:sticky;top:0;background:var(--bg);text-align:left;color:var(--mut);font-weight:500;
font-size:11px;text-transform:uppercase;letter-spacing:.5px;padding:10px 10px;border-bottom:1px solid var(--line);cursor:pointer;white-space:nowrap}
tbody td{padding:9px 10px;border-bottom:1px solid var(--line)}
tbody tr:hover{background:#1e1e28}
.amt{text-align:right;font-weight:600}
.bal{text-align:right;color:var(--mut)}
.dir{font-size:11px;font-weight:600;letter-spacing:.4px}
.desc{max-width:520px}
.tag{display:inline-block;background:var(--chip);color:var(--mut);font-size:11px;padding:2px 7px;border-radius:6px;margin-right:6px}
a.pdf{color:var(--accent);text-decoration:none;border:1px solid var(--line);padding:3px 8px;border-radius:6px;font-size:12px;white-space:nowrap}
a.pdf:hover{border-color:var(--accent)}
.platform{color:var(--accent);font-size:11px;margin-left:6px}
.empty{color:var(--mut);padding:40px;text-align:center}
</style>
</head>
<body>
<header>
<h1>money&#8209;trace <small id="sub">· Santander</small></h1>
<div class="stats">
<div class="stat"><b>Movimientos</b><span id="s-count"></span></div>
<div class="stat"><b>Entradas reales</b><span class="in" id="s-in"></span></div>
<div class="stat"><b>Salidas reales</b><span class="out" id="s-out"></span></div>
<div class="stat"><b>Internos</b><span id="s-int" style="color:var(--mut)"></span></div>
<div class="stat"><b>Neto real</b><span id="s-net"></span></div>
</div>
</header>
<div class="controls">
<input type="search" id="q" placeholder="Buscar descripción, cuenta, RUT…">
<div class="seg" id="dir">
<button data-d="all" class="on">Todo</button>
<button data-d="credit">▲ Entradas</button>
<button data-d="debit">▼ Salidas</button>
</div>
<select id="bank"><option value="">Todos los bancos</option></select>
<select id="flow"><option value="">Todos los flujos</option></select>
<select id="type"><option value="">Todos los tipos</option></select>
<select id="acct"><option value="">Todas las cuentas</option></select>
<label style="display:flex;align-items:center;gap:6px;color:var(--mut);font-size:13px;cursor:pointer">
<input type="checkbox" id="hideInternal"> Ocultar internos</label>
</div>
<div class="wrap">
<table>
<thead><tr>
<th data-k="date">Fecha</th>
<th data-k="bank">Banco</th>
<th data-k="flow_type">Flujo</th>
<th data-k="account_last4">Cuenta</th>
<th data-k="direction">Dir</th>
<th data-k="amount" class="amt">Monto</th>
<th data-k="balance" class="bal">Saldo</th>
<th data-k="description">Descripción</th>
<th>PDF</th>
</tr></thead>
<tbody id="rows"></tbody>
</table>
<div id="empty" class="empty" hidden>Sin movimientos para este filtro.</div>
</div>
<script>
const fmt = n => n==null ? "" : n.toLocaleString("es-CL");
let TXNS=[], sortK="date", sortDir=1;
fetch("ledger.json").then(r=>r.json()).then(data=>{
const banks=new Set(), flows=new Set(), types=new Set(), accts=new Set();
data.statements.forEach((s,si)=>{
banks.add(s.bank); types.add(s.doc_type); if(s.account_last4) accts.add(s.account_last4);
s.transactions.forEach(t=>{
if(t.flow_type) flows.add(t.flow_type);
TXNS.push({...t, bank:s.bank, doc_type:s.doc_type, account_last4:s.account_last4||"—",
pdf_url:s.pdf_url, period:s.period_start});
});
});
document.getElementById("sub").textContent = "· "+banks.size+" bancos · "+TXNS.length+" movimientos";
const bSel=document.getElementById("bank"), fSel=document.getElementById("flow"),
tSel=document.getElementById("type"), aSel=document.getElementById("acct");
[...banks].sort().forEach(b=>bSel.add(new Option(b,b)));
[...flows].sort().forEach(f=>fSel.add(new Option(flowLabel(f),f)));
[...types].sort().forEach(t=>tSel.add(new Option(label(t),t)));
[...accts].sort().forEach(a=>aSel.add(new Option("…"+a,a)));
render();
});
const label=t=>({cuenta_corriente:"Cta Corriente",cuenta_vista:"Cta Vista",linea_credito:"Línea Crédito",tarjeta_credito:"Tarjeta"}[t]||t);
const FLOW={self_transfer:["Auto-transf.","#8a8a9a"],credit_line:["Línea créd.","#8a8a9a"],card_payment:["Pago tarjeta","#8a8a9a"],fee:["Comisión","#b59a6a"],income:["Ingreso","#5ec48a"],inter_person:["A persona","#c8b5d1"],expense:["Gasto","#d98a8a"],other:["Otro","#8a8a9a"]};
const flowLabel=f=>(FLOW[f]?FLOW[f][0]:f);
const INTERNAL=new Set(["self_transfer","credit_line","card_payment"]);
function current(){
const q=document.getElementById("q").value.toLowerCase().trim();
const d=document.querySelector("#dir .on").dataset.d;
const bk=document.getElementById("bank").value;
const fl=document.getElementById("flow").value;
const ac=document.getElementById("acct").value;
const hideInt=document.getElementById("hideInternal").checked;
let r=TXNS.filter(t=>{
if(d!=="all" && t.direction!==d) return false;
if(bk && t.bank!==bk) return false;
if(fl && t.flow_type!==fl) return false;
if(hideInt && INTERNAL.has(t.flow_type)) return false;
const ty=document.getElementById("type").value;
if(ty && t.doc_type!==ty) return false;
if(ac && t.account_last4!==ac) return false;
if(q && !(t.description||"").toLowerCase().includes(q) && !(t.account_last4||"").includes(q)
&& !(t.counterparty||"").toLowerCase().includes(q)) return false;
return true;
});
r.sort((a,b)=>{
let x=a[sortK], y=b[sortK];
if(sortK==="amount"||sortK==="balance"){x=x??-1e18;y=y??-1e18;}
return (x>y?1:x<y?-1:0)*sortDir;
});
return r;
}
function render(){
const r=current(), tb=document.getElementById("rows");
document.getElementById("empty").hidden = r.length>0;
tb.innerHTML = r.map(t=>{
const cls=t.direction==="credit"?"in":"out", arr=t.direction==="credit"?"▲":"▼";
const plat=/MERCADOPAGO|MERPAGO|PAYSCAN|SUMUP|TUU/i.test(t.description||"")?'<span class="platform">plataforma</span>':"";
const cp = t.counterparty ? `<span class="platform">→ ${esc(t.counterparty)}</span>` : "";
const fdef = FLOW[t.flow_type]||["",""];
const fchip = t.flow_type ? `<span class="tag" style="color:${fdef[1]}">${fdef[0]}</span>` : "";
const rowcls = INTERNAL.has(t.flow_type) ? ' style="opacity:.6"' : "";
return `<tr${rowcls}>
<td>${t.date||""}</td>
<td>${esc(t.bank||"")}</td>
<td>${fchip}</td>
<td>…${t.account_last4}</td>
<td class="dir ${cls}">${arr}</td>
<td class="amt ${cls}">${fmt(t.amount)}</td>
<td class="bal">${fmt(t.balance)}</td>
<td class="desc">${esc(t.description||"")}${plat}${cp}</td>
<td><a class="pdf" href="${t.pdf_url}" target="_blank">📄 abrir</a></td>
</tr>`;}).join("");
let cr=0,db=0,intern=0;
for(const t of r){
if(INTERNAL.has(t.flow_type)){ intern+=t.amount; }
else if(t.direction==="credit") cr+=t.amount;
else db+=t.amount;
}
document.getElementById("s-count").textContent=r.length;
document.getElementById("s-in").textContent=fmt(cr);
document.getElementById("s-out").textContent=fmt(db);
document.getElementById("s-int").textContent=fmt(intern);
const net=cr-db; const ne=document.getElementById("s-net");
ne.textContent=(net<0?"-":"")+fmt(Math.abs(net)); ne.className=net<0?"out":"in";
}
const esc=s=>s.replace(/[&<>]/g,c=>({"&":"&amp;","<":"&lt;",">":"&gt;"}[c]));
document.getElementById("q").addEventListener("input",render);
document.getElementById("bank").addEventListener("change",render);
document.getElementById("flow").addEventListener("change",render);
document.getElementById("type").addEventListener("change",render);
document.getElementById("acct").addEventListener("change",render);
document.getElementById("hideInternal").addEventListener("change",render);
document.querySelectorAll("#dir button").forEach(b=>b.onclick=()=>{
document.querySelectorAll("#dir button").forEach(x=>x.classList.remove("on"));
b.classList.add("on"); render();});
document.querySelectorAll("thead th[data-k]").forEach(th=>th.onclick=()=>{
const k=th.dataset.k; if(sortK===k) sortDir*=-1; else {sortK=k; sortDir=1;} render();});
</script>
</body>
</html>