Statement loader web app with Docker image and Gitea Actions build
build-container / build (push) Successful in 31s
build-container / build (push) Successful in 31s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
3dba054dca
12 files changed
+1081
No files matched your search
+104
@@ -0,0 +1,104 @@
|
||||
<!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" 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>
|
||||
</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){
|
||||
$("#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}
|
||||
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))}};
|
||||
refresh();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in new issue
Block a user