diff --git a/app.py b/app.py index ab00382..1416e8b 100644 --- a/app.py +++ b/app.py @@ -172,6 +172,12 @@ class H(BaseHTTPRequestHandler): req = json.loads(body or b"{}") with LOCK: out = L.set_budget(req["month"], req["budgets"]) return self._send(200, {"message": f"Saved the {req['month']} budget ({out['rows']} envelopes). Backup: {out['backup']}."}) + if u.path == "/api/add_category": + try: + with LOCK: out = L.add_category(json.loads(body or b"{}").get("name")) + except ValueError as e: return self._send(400, {"error": str(e)}) + note = " It will show on the web pages; the Excel summary tabs only have 20 category slots." if out["beyond_excel"] else "" + return self._send(200, {"message": f"Added category '{out['name']}'.{note} Backup: {out['backup']}.", "name": out["name"]}) if u.path == "/api/set_categories": with LOCK: out = L.set_categories(json.loads(body or b"[]")) return self._send(200, {"message": f"Updated {out['updated']} payee(s). Backup: {out['backup']}."}) diff --git a/app_budget.html b/app_budget.html index 68cacb2..248d91e 100644 --- a/app_budget.html +++ b/app_budget.html @@ -36,6 +36,7 @@ input.amt{width:110px;text-align:right}
+ @@ -105,6 +106,11 @@ $("#showall").onchange=render; $("#copy").onclick=async()=>{const p=await (await fetch("/api/budget?month="+add(month,-1))).json();p.envelopes.forEach(e=>{const r=rows.find(x=>x.envelope==e.envelope);if(r)r.edit=e.budgeted});dirty=true;render()}; $("#suggest").onclick=()=>{rows.forEach(r=>{if(r.suggest)r.edit=r.suggest});dirty=true;render()}; $("#movebtn").onclick=()=>{const o=rows.map(r=>``).join("");$("#mfrom").innerHTML=o;$("#mto").innerHTML=o;$("#movepanel").style.display="block"}; +$("#addenv").onclick=async()=>{ + const name=(prompt("Name for the new envelope (category):")||"").trim();if(!name)return; + try{const r=await fetch("/api/add_category",{method:"POST",headers:{"X-Token":TOKEN},body:JSON.stringify({name})});const j=await r.json();if(!r.ok)throw new Error(j.error); + rows.push({envelope:j.name,budgeted:0,spent:0,prior:0,available:0,suggest:0,edit:0});$("#showall").checked=true;render();$("#msg").innerHTML=`
${esc(j.message)}
`} + catch(e){$("#msg").innerHTML=`
${esc(e.message)}
`}}; $("#mcancel").onclick=()=>$("#movepanel").style.display="none"; $("#mgo").onclick=()=>{const f=rows.find(r=>r.envelope==$("#mfrom").value),t=rows.find(r=>r.envelope==$("#mto").value),a=+$("#mamt").value; if(!f||!t||f===t||!(a>0))return;f.edit=Math.max(0,(+f.edit||0)-a);t.edit=(+t.edit||0)+a;dirty=true;$("#movepanel").style.display="none";$("#mamt").value="";$("#showall").checked||(0);render()}; diff --git a/app_page.html b/app_page.html index c6fb3ca..6d8ab6a 100644 --- a/app_page.html +++ b/app_page.html @@ -55,7 +55,7 @@ th{font-size:12px;color:var(--mute);font-weight:600}td.num{text-align:right;font

4 ยท Categorize payees

-
+
@@ -118,6 +118,7 @@ async function loadPayees(){ $("#cattbl").innerHTML=j.payees.length?"PayeePaymentsTotalCategoryType"+j.payees.map((p,i)=>`${esc(p.payee)}${p.n}${fmt(p.total)}`).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}); } +$("#addcat").onclick=async()=>{const name=(prompt("Name for the new category:")||"").trim();if(!name)return;try{const j=await post("/api/add_category",JSON.stringify({name}));await loadPayees();alertBox("good",esc(j.message))}catch(e){alertBox("bad",esc(e.message))}}; $("#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(); diff --git a/statement_loader.py b/statement_loader.py index 55ce06b..32cef4d 100644 --- a/statement_loader.py +++ b/statement_loader.py @@ -487,6 +487,25 @@ def set_categories(changes, workbook=WORKBOOK): _save_workbook(wb, workbook) return dict(updated=n, backup=b.name) +def add_category(name, workbook=WORKBOOK): + """Add a category (envelope) to the Lists tab: first free slot in E4:E23, else E25:E40 (the web pages show them all; + the Excel summary tabs only have 20 slots).""" + from openpyxl import load_workbook + from copy import copy + name = re.sub(r"\s+", " ", (name or "")).strip() + if not name or len(name) > 40: raise ValueError("Give the category a name (40 characters or fewer).") + b = _backup(workbook); wb = load_workbook(workbook); ls = wb["Lists"] + if name.lower() in {str(c).lower() for c in _categories(wb)}: raise ValueError(f"'{name}' already exists.") + row = next((r for r in list(range(4, 24)) + list(range(25, 41)) if ls.cell(r, 5).value is None), None) + if row is None: raise ValueError("The Lists tab has no free category slots left.") + cell = ls.cell(row, 5); cell.value = name + src = ls.cell(4, 5); cell.font, cell.border, cell.alignment, cell.fill = copy(src.font), copy(src.border), copy(src.alignment), copy(src.fill) + if row > 23: + for dv in ls.data_validations.dataValidation: + if dv.formula1 == "$E$4:$E$23": dv.formula1 = "$E$4:$E$40" + _save_workbook(wb, workbook) + return dict(name=name, backup=b.name, beyond_excel=row > 23) + def autocategorize(workbook=WORKBOOK): """Apply the built-in merchant rules to payees still set to Other / Unsorted.""" from openpyxl import load_workbook