192 lines
9.8 KiB
HTML
192 lines
9.8 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 · 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‑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=>({"&":"&","<":"<",">":">"}[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>
|