Files
money/app_page.html
T
2026-10-21 13:17:42 -04:00

127 lines
12 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="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Statement Loader</title>
<style>
:root{--bg:#f6f7f9;--card:#fff;--ink:#1c2230;--mute:#667085;--line:#e4e7ec;--accent:#1f4fd8;--ok:#1a7f4b;--okbg:#e6f4ec;--miss:#b42318;--missbg:#fdeceb;--warn:#9a6700;--warnbg:#fff4d6;--none:#eef0f3}
@media (prefers-color-scheme:dark){:root{--bg:#10131a;--card:#181c25;--ink:#e8ebf2;--mute:#9aa3b5;--line:#2a3040;--accent:#6c8cff;--ok:#5ad08f;--okbg:#14301f;--miss:#ff8a80;--missbg:#3a1a18;--warn:#f0c14b;--warnbg:#3a2f10;--none:#222838}}
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ink);font:14px/1.45 -apple-system,BlinkMacSystemFont,"Segoe UI",Arial,sans-serif}
main{max-width:1100px;margin:0 auto;padding:24px 16px 64px}
h1{font-size:22px;margin:0 0 2px}h2{font-size:15px;margin:0 0 10px}.sub{color:var(--mute);margin-bottom:20px}
.card{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:16px;margin-bottom:16px}
#drop{border:2px dashed var(--line);border-radius:10px;padding:26px;text-align:center;color:var(--mute);cursor:pointer}
#drop.over{border-color:var(--accent);background:var(--none);color:var(--ink)}
button{font:inherit;border-radius:8px;border:1px solid var(--line);background:var(--card);color:var(--ink);padding:8px 14px;cursor:pointer}
button.primary{background:var(--accent);border-color:var(--accent);color:#fff;font-weight:600}button:disabled{opacity:.45;cursor:default}
.grid{overflow-x:auto}table{border-collapse:collapse;width:100%}th,td{padding:6px 8px;text-align:left;border-bottom:1px solid var(--line);white-space:nowrap}
th{font-size:12px;color:var(--mute);font-weight:600}td.num{text-align:right;font-variant-numeric:tabular-nums}
.cell{display:inline-block;width:26px;height:22px;border-radius:5px;text-align:center;line-height:22px;font-size:12px;font-weight:700}
.have{background:var(--okbg);color:var(--ok)}.missing{background:var(--missbg);color:var(--miss)}.future,.none{background:var(--none);color:var(--mute)}
.pill{display:inline-block;padding:1px 8px;border-radius:99px;font-size:12px}.pill.new{background:var(--warnbg);color:var(--warn)}.pill.ex{background:var(--none);color:var(--mute)}
.alert{padding:10px 12px;border-radius:8px;margin-bottom:8px}.alert.bad{background:var(--missbg);color:var(--miss)}.alert.warn{background:var(--warnbg);color:var(--warn)}.alert.good{background:var(--okbg);color:var(--ok)}
.row{display:flex;gap:10px;align-items:center;flex-wrap:wrap}.spacer{flex:1}.mute{color:var(--mute)}code{background:var(--none);padding:1px 5px;border-radius:4px}
.legend span{margin-right:14px;font-size:12px;color:var(--mute)}
#help{display:none;margin-top:10px}
</style>
</head>
<body>
<main>
<div class="row"><h1 style="margin:0">Statement Loader</h1><div class="spacer"></div><a href="/dashboard" style="color:var(--accent);font-weight:600;text-decoration:none">Dashboard →</a></div>
<div class="sub">Runs only on this Mac. Drop in statements, check what’s covered, then add the new transactions to <b>Bills_Tracker.xlsx</b>. A backup is saved first, every time.</div>
<div id="alerts"></div>
<div class="card">
<h2>1 · Add statements</h2>
<div id="drop">Drop PDF or CSV statements here, or click to choose files<input id="file" type="file" multiple accept=".pdf,.csv,.xlsx,.json" hidden></div>
<div class="mute" style="margin-top:8px" id="uploadmsg">Supported: Chase Visa PDF · Ally Bank PDF · CVFCU PDF · Capital One CSV download.
<a href="#" id="helplink">Adding a different account?</a></div>
<div id="help" class="mute">Open <code>statement_loader.py</code>, add one line to <code>ACCOUNTS</code> (a name, a word printed on page 1 of its statement, and the day it closes), then ask Claude to write the parser for that bank’s layout. Any file the app can’t read is listed under “Needs attention”.</div>
</div>
<div class="card">
<h2>2 · Coverage</h2>
<div class="grid" id="coverage"></div>
<div class="legend" style="margin-top:8px"><span><b style="color:var(--ok)">✓</b> have it</span><span><b style="color:var(--miss)">!</b> missing</span><span>· not yet due / before first statement</span><span>Columns = month the statement closes.</span></div>
</div>
<div class="card">
<div class="row"><h2 style="margin:0">3 · Review &amp; import</h2><div class="spacer"></div>
<button id="open">Open workbook</button><button id="undo">Undo last import</button><button class="primary" id="import" disabled>Add to workbook</button></div>
<div id="summary" class="mute" style="margin:10px 0"></div>
<div class="grid"><table id="preview"></table></div>
</div>
<div class="card" id="catcard">
<div class="row"><h2 style="margin:0">4 · Categorize payees</h2><div class="spacer"></div>
<button id="auto">Auto-categorize</button><button class="primary" id="savecats" disabled>Save changes</button></div>
<div id="catsum" class="mute" style="margin:10px 0"></div>
<div class="grid" style="max-height:420px;overflow:auto"><table id="cattbl"></table></div>
</div>
</main>
<script>
const TOKEN="__TOKEN__";
const $=s=>document.querySelector(s);
const fmt=n=>(n<0?"-":"")+"$"+Math.abs(n).toLocaleString(undefined,{minimumFractionDigits:2,maximumFractionDigits:2});
const esc=s=>String(s??"").replace(/[&<>"]/g,c=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;"}[c]));
const mon=["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];
async function post(path,body,extra){const r=await fetch(path,{method:"POST",headers:{"X-Token":TOKEN,...(extra||{})},body});const j=await r.json();if(!r.ok)throw new Error(j.error||"Request failed");return j}
function alertBox(kind,html){$("#alerts").insertAdjacentHTML("beforeend",`<div class="alert ${kind}">${html}</div>`)}
async function refresh(msg){
if($("#catcard"))$("#catcard").style.display="";
$("#alerts").innerHTML=msg?`<div class="alert good">${msg}</div>`:"";
$("#coverage").innerHTML="Reading statements…";
const r=await fetch("/api/state");const s=await r.json();
if(s.error){alertBox("bad",esc(s.error));return}
if(s.setup){$("#catcard").style.display="none";$("#coverage").innerHTML="";$("#preview").innerHTML="";$("#import").disabled=true;
$("#summary").innerHTML="";alertBox("warn",`<b>First-time setup:</b> <code>Bills_Tracker.xlsx</code> isn’t in the data folder (<code>${esc(s.data_dir)}</code>). Drop your existing one into the box above, or <button id="blank" style="margin-left:6px">Create a blank workbook</button>`+(s.rules?"":" Also add <code>loader_rules.json</code> (see loader_rules.example.json: your name, so transfers between your own accounts aren’t counted as spending)."));
const b=$("#blank");if(b)b.onclick=async()=>{try{const j=await post("/api/create_blank","");await refresh(esc(j.message))}catch(e){alertBox("bad",esc(e.message))}};return}
s.bad.forEach(b=>alertBox("bad",`<b>${esc(b.file)}</b>: the transactions I read add up to ${fmt(b.parsed)} but the statement says ${fmt(b.stated)}. It will <b>not</b> be imported until that matches.`));
s.skipped.forEach(b=>alertBox("warn",`<b>${esc(b.file)}</b>: ${esc(b.why)}`));
s.duplicates.forEach(b=>alertBox("warn",`<b>${esc(b.file)}</b> is a duplicate of ${esc(b.same_as)}; counted once.`));
if(s.excel_open)alertBox("warn","Bills_Tracker.xlsx looks open in Excel. Close it before importing so changes aren’t overwritten.");
const c=s.coverage; let h="<table><tr><th>Account</th>"+c.months.map(m=>`<th>${mon[+m.slice(5)-1]}<br>${m.slice(2,4)}</th>`).join("")+"<th>Status</th></tr>";
c.accounts.forEach(a=>{h+=`<tr><td><b>${esc(a.account)}</b><div class="mute" style="font-size:12px">${a.count} ${a.kind=="csv"?"download(s)":"statements"}</div></td>`+
c.months.map(m=>{const v=a.cells[m];return `<td><span class="cell ${v}">${v=="have"?"✓":v=="missing"?"!":"·"}</span></td>`}).join("")+`<td class="mute">through ${esc(a.through)}<br>${esc(a.next)}</td></tr>`});
$("#coverage").innerHTML=h+"</table>";
const n=s.new; $("#import").disabled=!n.length;
$("#summary").innerHTML=n.length?`<b>${n.length}</b> new transactions found (${s.summary.log} go to the Bill Log, ${s.summary.excluded} are card payments/transfers kept off the spending totals). ${s.already} were already in the workbook.`+(s.summary.new_payees.length?` <span class="pill new">${s.summary.new_payees.length} new payee(s) to categorize</span>`:""):`Nothing new — all ${s.already} transactions in ${s.statements} files are already in the workbook.`;
$("#preview").innerHTML=n.length?"<tr><th>Date</th><th>Account</th><th>Statement description</th><th>Goes to</th><th>Payee / reason</th><th class='num'>Amount</th></tr>"+n.map(r=>`<tr><td>${r.date}</td><td>${esc(r.account)}</td><td>${esc(r.desc)}</td><td><span class="pill ${r.kind=="log"?"new":"ex"}">${r.kind=="log"?"Bill Log":"Excluded"}</span></td><td>${esc(r.kind=="log"?r.payee:r.reason)}${r.new_payee?' <span class="pill new">new</span>':""}</td><td class="num">${fmt(r.amount)}</td></tr>`).join(""):"";
}
async function upload(files){
let ok=0,errs=[];$("#uploadmsg").textContent="Uploading…";
for(const f of files){try{await post("/api/upload?name="+encodeURIComponent(f.name),f);ok++}catch(e){errs.push(f.name+": "+e.message)}}
await refresh(`${ok} file(s) added.`+(errs.length?" Problems: "+esc(errs.join("; ")):""));
$("#uploadmsg").innerHTML="Supported: Chase Visa PDF · Ally Bank PDF · CVFCU PDF · Capital One CSV download.";
}
const drop=$("#drop"),inp=$("#file");
drop.onclick=()=>inp.click();inp.onchange=()=>upload([...inp.files]);
["dragenter","dragover"].forEach(e=>drop.addEventListener(e,ev=>{ev.preventDefault();drop.classList.add("over")}));
["dragleave","drop"].forEach(e=>drop.addEventListener(e,ev=>{ev.preventDefault();drop.classList.remove("over")}));
drop.addEventListener("drop",ev=>upload([...ev.dataTransfer.files]));
$("#helplink").onclick=e=>{e.preventDefault();$("#help").style.display="block"};
$("#import").onclick=async()=>{
if(!confirm("Back up the workbook and add these transactions?"))return;
$("#import").disabled=true;
try{const j=await post("/api/import","");await refresh(esc(j.message)+" Backup: "+esc(j.backup||"")+(j.new_payees&&j.new_payees.length?". New payees were added to the Lists tab as “Other / Unsorted” — set their categories there.":""))}catch(e){alertBox("bad",esc(e.message))}
};
$("#undo").onclick=async()=>{if(!confirm("Restore the workbook from the most recent backup? This discards the last import."))return;try{const j=await post("/api/undo","");await refresh(esc(j.message))}catch(e){alertBox("bad",esc(e.message))}};
$("#open").onclick=async()=>{try{await post("/api/open","")}catch(e){alertBox("bad",esc(e.message))}};
let CATS=null,EDITS={};
async function loadPayees(){
const r=await fetch("/api/payees");const j=await r.json();if(j.error){$("#catsum").textContent=j.error;return}
CATS=j;EDITS={};$("#savecats").disabled=true;
$("#catsum").textContent=j.payees.length?`${j.payees.length} payee(s) are still “Other / Unsorted”. Pick a category (and Bill or Spending) for each, or try Auto-categorize first.`:"Every payee has a category.";
const opt=(list,sel)=>list.map(c=>`<option${c==sel?" selected":""}>${esc(c)}</option>`).join("");
$("#cattbl").innerHTML=j.payees.length?"<tr><th>Payee</th><th class='num'>Payments</th><th class='num'>Total</th><th>Category</th><th>Type</th></tr>"+j.payees.map((p,i)=>`<tr><td>${esc(p.payee)}</td><td class="num">${p.n}</td><td class="num">${fmt(p.total)}</td><td><select data-i="${i}" data-k="category">${opt(j.categories,p.category)}</select></td><td><select data-i="${i}" data-k="type">${opt(j.types,p.type)}</select></td></tr>`).join(""):"";
document.querySelectorAll("#cattbl select").forEach(sel=>sel.onchange=()=>{const p=j.payees[+sel.dataset.i];(EDITS[p.payee]=EDITS[p.payee]||{payee:p.payee,category:p.category,type:p.type})[sel.dataset.k]=sel.value;$("#savecats").disabled=false});
}
$("#auto").onclick=async()=>{try{const j=await post("/api/autocategorize","");await loadPayees();alertBox("good",esc(j.message))}catch(e){alertBox("bad",esc(e.message))}};
$("#savecats").onclick=async()=>{try{const j=await post("/api/set_categories",JSON.stringify(Object.values(EDITS)));await loadPayees();alertBox("good",esc(j.message))}catch(e){alertBox("bad",esc(e.message))}};
refresh();loadPayees();
</script>
</body>
</html>