build-container / build (push) Successful in 50s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
127 lines
12 KiB
HTML
127 lines
12 KiB
HTML
<!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="/budget" style="color:var(--accent);font-weight:600;text-decoration:none;margin-right:14px">Budget</a><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 & 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=>({"&":"&","<":"<",">":">",'"':"""}[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>
|