Deploy BudgetBuddy update
Browse files- app.py +27 -16
- frontend/assets/app.js +38 -9
- frontend/index.html +28 -14
app.py
CHANGED
|
@@ -126,9 +126,25 @@ def api_save(token: str, record_json: str) -> dict:
|
|
| 126 |
return {"ok": False, "message": f"Save failed: {e}"}
|
| 127 |
|
| 128 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 129 |
@app.api(name="dashboard")
|
| 130 |
-
def api_dashboard(token: str, start: str = "", end: str = "",
|
| 131 |
-
|
| 132 |
user = auth.verify_token(token)
|
| 133 |
if not user:
|
| 134 |
return {"ok": False, "error": "auth"}
|
|
@@ -138,18 +154,11 @@ def api_dashboard(token: str, start: str = "", end: str = "",
|
|
| 138 |
cat = None if category in ("", "All", None) else category
|
| 139 |
filt = analytics.filter_records(
|
| 140 |
records, analytics.parse_date(start), analytics.parse_date(end), cat)
|
| 141 |
-
txns = [{"date": r.get("date", ""), "vendor": r.get("vendor", ""),
|
| 142 |
-
"total": analytics._num(r.get("total")), "category": analytics._category(r),
|
| 143 |
-
"source": r.get("source", "receipt"),
|
| 144 |
-
"understanding": r.get("understanding", ""),
|
| 145 |
-
"currency": r.get("currency", ""),
|
| 146 |
-
"items": [{"name": str(it.get("name", "")), "amount": analytics._num(it.get("amount")),
|
| 147 |
-
"qty": analytics._num(it.get("qty", 1)) or 1,
|
| 148 |
-
"category": str(it.get("category") or analytics._category(r))}
|
| 149 |
-
for it in (r.get("line_items") or [])]}
|
| 150 |
-
for r in sorted(filt, key=lambda r: (analytics.parse_date(r.get("date")) or date.min,
|
| 151 |
-
str(r.get("saved_at", ""))), reverse=True)]
|
| 152 |
today = date.today()
|
|
|
|
|
|
|
|
|
|
|
|
|
| 153 |
return {
|
| 154 |
"ok": True,
|
| 155 |
"summary": summ,
|
|
@@ -157,9 +166,11 @@ def api_dashboard(token: str, start: str = "", end: str = "",
|
|
| 157 |
"by_category": analytics.spend_by_category(filt),
|
| 158 |
"over_time": analytics.spend_over_time(filt, granularity or "Monthly"),
|
| 159 |
"comparison": analytics.category_comparison(records),
|
| 160 |
-
"calendar": analytics.calendar_data(records,
|
| 161 |
-
"calendar_month": f"{
|
| 162 |
-
"transactions":
|
|
|
|
|
|
|
| 163 |
}
|
| 164 |
except Exception as e:
|
| 165 |
import traceback
|
|
|
|
| 126 |
return {"ok": False, "message": f"Save failed: {e}"}
|
| 127 |
|
| 128 |
|
| 129 |
+
def _txn_dict(r: dict) -> dict:
|
| 130 |
+
return {"date": r.get("date", ""), "vendor": r.get("vendor", ""),
|
| 131 |
+
"total": analytics._num(r.get("total")), "category": analytics._category(r),
|
| 132 |
+
"source": r.get("source", "receipt"), "understanding": r.get("understanding", ""),
|
| 133 |
+
"currency": r.get("currency", ""),
|
| 134 |
+
"items": [{"name": str(it.get("name", "")), "amount": analytics._num(it.get("amount")),
|
| 135 |
+
"qty": analytics._num(it.get("qty", 1)) or 1,
|
| 136 |
+
"category": str(it.get("category") or analytics._category(r))}
|
| 137 |
+
for it in (r.get("line_items") or [])]}
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
def _sort_recent(records):
|
| 141 |
+
return sorted(records, key=lambda r: (analytics.parse_date(r.get("date")) or date.min,
|
| 142 |
+
str(r.get("saved_at", ""))), reverse=True)
|
| 143 |
+
|
| 144 |
+
|
| 145 |
@app.api(name="dashboard")
|
| 146 |
+
def api_dashboard(token: str, start: str = "", end: str = "", category: str = "All",
|
| 147 |
+
granularity: str = "Monthly", cal_month: str = "") -> dict:
|
| 148 |
user = auth.verify_token(token)
|
| 149 |
if not user:
|
| 150 |
return {"ok": False, "error": "auth"}
|
|
|
|
| 154 |
cat = None if category in ("", "All", None) else category
|
| 155 |
filt = analytics.filter_records(
|
| 156 |
records, analytics.parse_date(start), analytics.parse_date(end), cat)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 157 |
today = date.today()
|
| 158 |
+
try:
|
| 159 |
+
cy, cm = (int(x) for x in cal_month.split("-")) if cal_month else (today.year, today.month)
|
| 160 |
+
except Exception:
|
| 161 |
+
cy, cm = today.year, today.month
|
| 162 |
return {
|
| 163 |
"ok": True,
|
| 164 |
"summary": summ,
|
|
|
|
| 166 |
"by_category": analytics.spend_by_category(filt),
|
| 167 |
"over_time": analytics.spend_over_time(filt, granularity or "Monthly"),
|
| 168 |
"comparison": analytics.category_comparison(records),
|
| 169 |
+
"calendar": analytics.calendar_data(records, cy, cm),
|
| 170 |
+
"calendar_month": f"{cy}-{cm:02d}",
|
| 171 |
+
"transactions": [_txn_dict(r) for r in _sort_recent(filt)],
|
| 172 |
+
"recent": [_txn_dict(r) for r in _sort_recent(records)[:8]],
|
| 173 |
+
"has_any": len(records) > 0,
|
| 174 |
}
|
| 175 |
except Exception as e:
|
| 176 |
import traceback
|
frontend/assets/app.js
CHANGED
|
@@ -72,6 +72,9 @@ async function loadDashboard() {
|
|
| 72 |
budgetRing(s.this_month_total, d.budget, cur);
|
| 73 |
$("#budget-sub").textContent = d.budget > 0 ? `${money(s.this_month_total, cur)} of ${money(d.budget, cur)} used` : "No budget set yet";
|
| 74 |
donut(d.by_category, cur); $("#ot-sub").textContent = S.gran; timeChart(d.over_time); comparison(d.comparison, cur);
|
|
|
|
|
|
|
|
|
|
| 75 |
}
|
| 76 |
function budgetRing(spent, budget, cur) {
|
| 77 |
const ctx = $("#ring-budget"); if (S.charts.b) S.charts.b.destroy();
|
|
@@ -81,14 +84,14 @@ function budgetRing(spent, budget, cur) {
|
|
| 81 |
}
|
| 82 |
function donut(rows, cur) {
|
| 83 |
const ctx = $("#chart-cat"); if (S.charts.c) S.charts.c.destroy();
|
| 84 |
-
|
| 85 |
-
S.charts.c = new Chart(ctx, { type: "doughnut", data: { labels: rows.map(r => r.category), datasets: [{ data: rows.map(r => r.amount), backgroundColor: rows.map(r => catColor(r.category)), borderWidth: 0, hoverOffset: 6 }] }, options: { responsive: true, maintainAspectRatio: false, cutout: "64%", plugins: { legend: { position: "right", labels: { color: "#cfd6e4", boxWidth: 10, font: { size: 11 }, padding: 9, usePointStyle: true } }, tooltip: { callbacks: { label: c => ` ${c.label}: ${money(c.parsed, cur)}` } } } } });
|
| 86 |
}
|
| 87 |
function timeChart(rows) {
|
| 88 |
const ctx = $("#chart-time"); if (S.charts.t) S.charts.t.destroy();
|
| 89 |
-
|
| 90 |
const g = ctx.getContext("2d").createLinearGradient(0, 0, 0, 230); g.addColorStop(0, "rgba(215,242,90,.32)"); g.addColorStop(1, "rgba(215,242,90,0)");
|
| 91 |
-
S.charts.t = new Chart(ctx, { type: "line", data: { labels: rows.map(r => r.period), datasets: [{ data: rows.map(r => r.amount), borderColor: "#d7f25a", backgroundColor: g, fill: true, tension: .4, pointRadius: 3, pointBackgroundColor: "#d7f25a", borderWidth: 2.5 }] }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false } }, scales: { x: { ticks: { color: "#8b94a6", font: { size: 10 } }, grid: { display: false } }, y: { ticks: { color: "#8b94a6", font: { size: 10 } }, grid: { color: "#ffffff0c" } } } } });
|
| 92 |
}
|
| 93 |
function comparison(rows, cur) {
|
| 94 |
if (!rows || !rows.length) { $("#comparison").innerHTML = `<div class="muted" style="font-size:.84rem">No data yet.</div>`; return; }
|
|
@@ -101,9 +104,15 @@ async function saveBudget() { const v = num($("#budget-in").value); if (v <= 0)
|
|
| 101 |
/* calendar */
|
| 102 |
async function loadCalendar() {
|
| 103 |
if (!S.token) return;
|
| 104 |
-
const t = new Date()
|
| 105 |
-
|
| 106 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 107 |
}
|
| 108 |
function renderCalendar() {
|
| 109 |
const d = S.cal; if (!d) return;
|
|
@@ -190,7 +199,20 @@ async function understandReceipt() {
|
|
| 190 |
try { const r = await api("understand", { record_json: JSON.stringify(readReceipt()) }); fillReceipt(r.record); const u = $("#rc-understand"); u.classList.remove("hidden"); u.textContent = r.understanding || "Categorized."; const unc = r.uncertain || []; $("#rc-savemsg").textContent = unc.length ? "Double-check: " + unc.join(", ") : "Looks good — Save when ready."; $("#rc-savemsg").className = "msg " + (unc.length ? "warn" : "good"); $("#rc-save").disabled = false; }
|
| 191 |
catch (e) { $("#rc-savemsg").textContent = "Error: " + e; } busy($("#rc-confirm"), false);
|
| 192 |
}
|
| 193 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 194 |
|
| 195 |
/* payment */
|
| 196 |
async function extractPayment() {
|
|
@@ -201,7 +223,12 @@ async function extractPayment() {
|
|
| 201 |
}
|
| 202 |
async function savePayment() {
|
| 203 |
const rec = { vendor: $("#pm-vendor").value.trim(), date: $("#pm-date").value.trim(), currency: $("#pm-currency").value.trim(), line_items: [], charges: [], total: num($("#pm-amount").value), category: $("#pm-cat").value, note: $("#pm-note").value.trim(), source: "payment" };
|
| 204 |
-
busy($("#pm-save"), true, "Saving…");
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 205 |
}
|
| 206 |
|
| 207 |
/* manual (item-wise) */
|
|
@@ -260,6 +287,8 @@ async function boot() {
|
|
| 260 |
wireFile("#pm-file", "#pm-cam", "#pm-prev", f => S.pmFile = f);
|
| 261 |
$("#mn-additem").onclick = () => $("#mn-items tbody").appendChild(mnItemRow());
|
| 262 |
$("#mn-save").onclick = saveManual;
|
|
|
|
|
|
|
| 263 |
["#tx-q", "#tx-cat", "#tx-from", "#tx-to"].forEach(s => $(s).addEventListener("input", () => { if (s === "#tx-q") renderTxnList(); else loadTxns(); }));
|
| 264 |
$("#chat-send").onclick = chat; $("#chat-in").addEventListener("keydown", e => { if (e.key === "Enter") chat(); });
|
| 265 |
$("#chat-chips").innerHTML = ["How much did I spend this month?", "What's my top category?", "How much on Dining?"].map(q => `<button class="chip">${q}</button>`).join("");
|
|
|
|
| 72 |
budgetRing(s.this_month_total, d.budget, cur);
|
| 73 |
$("#budget-sub").textContent = d.budget > 0 ? `${money(s.this_month_total, cur)} of ${money(d.budget, cur)} used` : "No budget set yet";
|
| 74 |
donut(d.by_category, cur); $("#ot-sub").textContent = S.gran; timeChart(d.over_time); comparison(d.comparison, cur);
|
| 75 |
+
const rec = d.recent || [];
|
| 76 |
+
$("#recent").innerHTML = rec.length ? rec.map(t => txnCard(t, t.currency || cur)).join("") : `<div class="muted" style="font-size:.85rem">No transactions yet — add your first spend.</div>`;
|
| 77 |
+
wireTxnExpand();
|
| 78 |
}
|
| 79 |
function budgetRing(spent, budget, cur) {
|
| 80 |
const ctx = $("#ring-budget"); if (S.charts.b) S.charts.b.destroy();
|
|
|
|
| 84 |
}
|
| 85 |
function donut(rows, cur) {
|
| 86 |
const ctx = $("#chart-cat"); if (S.charts.c) S.charts.c.destroy();
|
| 87 |
+
const empty = !rows.length;
|
| 88 |
+
S.charts.c = new Chart(ctx, { type: "doughnut", data: { labels: empty ? ["No spending yet"] : rows.map(r => r.category), datasets: [{ data: empty ? [1] : rows.map(r => r.amount), backgroundColor: empty ? ["#242936"] : rows.map(r => catColor(r.category)), borderWidth: 0, hoverOffset: empty ? 0 : 6 }] }, options: { responsive: true, maintainAspectRatio: false, cutout: "64%", plugins: { legend: { display: !empty, position: "right", labels: { color: "#cfd6e4", boxWidth: 10, font: { size: 11 }, padding: 9, usePointStyle: true } }, tooltip: { enabled: !empty, callbacks: { label: c => ` ${c.label}: ${money(c.parsed, cur)}` } } } } });
|
| 89 |
}
|
| 90 |
function timeChart(rows) {
|
| 91 |
const ctx = $("#chart-time"); if (S.charts.t) S.charts.t.destroy();
|
| 92 |
+
const empty = !rows.length;
|
| 93 |
const g = ctx.getContext("2d").createLinearGradient(0, 0, 0, 230); g.addColorStop(0, "rgba(215,242,90,.32)"); g.addColorStop(1, "rgba(215,242,90,0)");
|
| 94 |
+
S.charts.t = new Chart(ctx, { type: "line", data: { labels: empty ? ["", ""] : rows.map(r => r.period), datasets: [{ data: empty ? [0, 0] : rows.map(r => r.amount), borderColor: "#d7f25a", backgroundColor: g, fill: true, tension: .4, pointRadius: empty ? 0 : 3, pointBackgroundColor: "#d7f25a", borderWidth: 2.5 }] }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false } }, scales: { x: { ticks: { color: "#8b94a6", font: { size: 10 } }, grid: { display: false } }, y: { beginAtZero: true, ticks: { color: "#8b94a6", font: { size: 10 } }, grid: { color: "#ffffff0c" } } } } });
|
| 95 |
}
|
| 96 |
function comparison(rows, cur) {
|
| 97 |
if (!rows || !rows.length) { $("#comparison").innerHTML = `<div class="muted" style="font-size:.84rem">No data yet.</div>`; return; }
|
|
|
|
| 104 |
/* calendar */
|
| 105 |
async function loadCalendar() {
|
| 106 |
if (!S.token) return;
|
| 107 |
+
if (!S.calYM) { const t = new Date(); S.calYM = [t.getFullYear(), t.getMonth() + 1]; }
|
| 108 |
+
const [y, m] = S.calYM, iso = d => d.toISOString().slice(0, 10);
|
| 109 |
+
let d; try { d = await api("dashboard", { token: S.token, start: iso(new Date(y, m - 1, 1)), end: iso(new Date(y, m, 0)), category: "All", granularity: "Daily", cal_month: `${y}-${String(m).padStart(2, "0")}` }); } catch (e) { return; }
|
| 110 |
+
if (!d.ok) return; S.cal = d; S.selDay = null; renderCalendar();
|
| 111 |
+
$("#cal-day-title").textContent = "Pick a day"; $("#cal-day").innerHTML = "";
|
| 112 |
+
}
|
| 113 |
+
function calNav(delta) {
|
| 114 |
+
if (!S.calYM) { const t = new Date(); S.calYM = [t.getFullYear(), t.getMonth() + 1]; }
|
| 115 |
+
let [y, m] = S.calYM; m += delta; if (m < 1) { m = 12; y--; } if (m > 12) { m = 1; y++; } S.calYM = [y, m]; loadCalendar();
|
| 116 |
}
|
| 117 |
function renderCalendar() {
|
| 118 |
const d = S.cal; if (!d) return;
|
|
|
|
| 199 |
try { const r = await api("understand", { record_json: JSON.stringify(readReceipt()) }); fillReceipt(r.record); const u = $("#rc-understand"); u.classList.remove("hidden"); u.textContent = r.understanding || "Categorized."; const unc = r.uncertain || []; $("#rc-savemsg").textContent = unc.length ? "Double-check: " + unc.join(", ") : "Looks good — Save when ready."; $("#rc-savemsg").className = "msg " + (unc.length ? "warn" : "good"); $("#rc-save").disabled = false; }
|
| 200 |
catch (e) { $("#rc-savemsg").textContent = "Error: " + e; } busy($("#rc-confirm"), false);
|
| 201 |
}
|
| 202 |
+
function resetReceipt() {
|
| 203 |
+
fillReceipt({ line_items: [], charges: [] });
|
| 204 |
+
$("#rc-prev").innerHTML = ""; $("#rc-status").textContent = ""; $("#rc-understand").classList.add("hidden");
|
| 205 |
+
$("#rc-save").disabled = true; S.rcFile = null; $("#rc-file").value = ""; $("#rc-cam").value = "";
|
| 206 |
+
}
|
| 207 |
+
async function saveReceipt() {
|
| 208 |
+
busy($("#rc-save"), true, "Saving…");
|
| 209 |
+
try {
|
| 210 |
+
const r = await api("save", { token: S.token, record_json: JSON.stringify(readReceipt()) });
|
| 211 |
+
if (r.ok) { resetReceipt(); $("#rc-savemsg").textContent = "Saved ✓ — see it on your Dashboard."; $("#rc-savemsg").className = "msg good"; loadDashboard(); }
|
| 212 |
+
else { $("#rc-savemsg").textContent = r.message; $("#rc-savemsg").className = "msg warn"; }
|
| 213 |
+
} catch (e) { $("#rc-savemsg").textContent = "Error: " + e; }
|
| 214 |
+
busy($("#rc-save"), false);
|
| 215 |
+
}
|
| 216 |
|
| 217 |
/* payment */
|
| 218 |
async function extractPayment() {
|
|
|
|
| 223 |
}
|
| 224 |
async function savePayment() {
|
| 225 |
const rec = { vendor: $("#pm-vendor").value.trim(), date: $("#pm-date").value.trim(), currency: $("#pm-currency").value.trim(), line_items: [], charges: [], total: num($("#pm-amount").value), category: $("#pm-cat").value, note: $("#pm-note").value.trim(), source: "payment" };
|
| 226 |
+
busy($("#pm-save"), true, "Saving…");
|
| 227 |
+
try {
|
| 228 |
+
const r = await api("save", { token: S.token, record_json: JSON.stringify(rec) });
|
| 229 |
+
if (r.ok) { ["pm-vendor", "pm-amount", "pm-date", "pm-currency", "pm-note"].forEach(i => $("#" + i).value = ""); $("#pm-prev").innerHTML = ""; $("#pm-status").textContent = ""; $("#pm-save").disabled = true; S.pmFile = null; $("#pm-file").value = ""; $("#pm-cam").value = ""; $("#pm-savemsg").textContent = "Saved ✓ — see it on your Dashboard."; $("#pm-savemsg").className = "msg good"; loadDashboard(); }
|
| 230 |
+
else { $("#pm-savemsg").textContent = r.message; $("#pm-savemsg").className = "msg warn"; }
|
| 231 |
+
} catch (e) { $("#pm-savemsg").textContent = "Error: " + e; } busy($("#pm-save"), false);
|
| 232 |
}
|
| 233 |
|
| 234 |
/* manual (item-wise) */
|
|
|
|
| 287 |
wireFile("#pm-file", "#pm-cam", "#pm-prev", f => S.pmFile = f);
|
| 288 |
$("#mn-additem").onclick = () => $("#mn-items tbody").appendChild(mnItemRow());
|
| 289 |
$("#mn-save").onclick = saveManual;
|
| 290 |
+
$("#cal-prev").onclick = () => calNav(-1); $("#cal-next").onclick = () => calNav(1);
|
| 291 |
+
$("#see-all").onclick = () => view("txns");
|
| 292 |
["#tx-q", "#tx-cat", "#tx-from", "#tx-to"].forEach(s => $(s).addEventListener("input", () => { if (s === "#tx-q") renderTxnList(); else loadTxns(); }));
|
| 293 |
$("#chat-send").onclick = chat; $("#chat-in").addEventListener("keydown", e => { if (e.key === "Enter") chat(); });
|
| 294 |
$("#chat-chips").innerHTML = ["How much did I spend this month?", "What's my top category?", "How much on Dining?"].map(q => `<button class="chip">${q}</button>`).join("");
|
frontend/index.html
CHANGED
|
@@ -27,6 +27,9 @@ svg{stroke-width:1.9;fill:none;stroke:currentColor;stroke-linecap:round;stroke-l
|
|
| 27 |
input,select,textarea{width:100%;background:var(--surface2);border:1px solid var(--line);color:var(--ink);border-radius:12px;padding:11px 13px;font-size:.9rem;outline:none;transition:border-color .15s}
|
| 28 |
input:focus,select:focus,textarea:focus{border-color:var(--lav)}
|
| 29 |
input[type=date]::-webkit-calendar-picker-indicator{filter:invert(.7)}
|
|
|
|
|
|
|
|
|
|
| 30 |
label{display:block;font-size:.76rem;color:var(--muted);margin-bottom:6px;font-weight:600}
|
| 31 |
.field{margin:13px 0} .row{display:flex;gap:11px}.row>*{flex:1} .msg{margin-top:11px;font-size:.84rem;min-height:1em}
|
| 32 |
.btn{background:var(--lime);color:var(--lime-ink);font-weight:800;border-radius:13px;padding:11px 17px;font-size:.88rem;transition:filter .12s,transform .05s}
|
|
@@ -104,6 +107,9 @@ td input,td select{padding:8px 9px;border-radius:9px}
|
|
| 104 |
.drop{border:1.5px dashed var(--line);border-radius:15px;padding:18px;text-align:center;color:var(--muted);font-size:.85rem;display:block;cursor:pointer}
|
| 105 |
.drop:hover{border-color:var(--lav);color:var(--ink)} .drop img{max-height:280px;max-width:100%;border-radius:12px;margin-top:10px}
|
| 106 |
.cap-row{display:flex;gap:9px;margin-top:10px}
|
|
|
|
|
|
|
|
|
|
| 107 |
.banner{border-radius:12px;padding:11px 14px;font-size:.84rem;margin:12px 0;border:1px solid transparent}
|
| 108 |
.banner.ok{background:#10271d;color:var(--good);border-color:#1d3a2c} .banner.warn{background:#2c2611;color:var(--warn);border-color:#3c331a}
|
| 109 |
.banner.lav{background:#211a37;color:var(--lav);border-color:#352c57}
|
|
@@ -203,6 +209,7 @@ td input,td select{padding:8px 9px;border-radius:9px}
|
|
| 203 |
<div class="panel"><h3>Spend over time</h3><div class="ph-sub" id="ot-sub">Monthly</div><div class="chart-box"><canvas id="chart-time"></canvas></div></div>
|
| 204 |
<div class="panel"><h3>This month vs last</h3><div class="ph-sub">By category</div><div class="cmp" id="comparison"></div></div>
|
| 205 |
</div>
|
|
|
|
| 206 |
</section>
|
| 207 |
|
| 208 |
<!-- Add -->
|
|
@@ -214,28 +221,33 @@ td input,td select{padding:8px 9px;border-radius:9px}
|
|
| 214 |
<button class="mode" data-mode="manual"><svg viewBox="0 0 24 24"><path d="M12 20h9M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4z"/></svg>Manual</button>
|
| 215 |
</div>
|
| 216 |
|
| 217 |
-
<div id="mode-receipt"
|
| 218 |
-
<div class="panel">
|
| 219 |
<h3>Bill photo</h3><div class="ph-sub">Snap or upload — items, taxes, round-off and all</div>
|
| 220 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 221 |
<input id="rc-file" type="file" accept="image/*" class="vh"><input id="rc-cam" type="file" accept="image/*" capture="environment" class="vh">
|
| 222 |
-
<div class="
|
| 223 |
-
<button class="btn" id="rc-extract" style="margin-top:11px;width:100%">Extract</button><div class="msg" id="rc-status"></div>
|
| 224 |
</div>
|
| 225 |
<div class="panel">
|
| 226 |
-
<h3>Review & understand</h3>
|
| 227 |
-
<div class="row"><div class="field"><label>Vendor</label><input id="rc-vendor"></div><div class="field"><label>Currency</label><input id="rc-currency"></div></div>
|
| 228 |
-
<div class="field"><label>Date</label><input id="rc-date" type="date"></div>
|
| 229 |
<label>Line items</label>
|
| 230 |
<table id="rc-items"><thead><tr><th>Item</th><th class="col-qty">Qty</th><th class="col-amt">Amount</th><th class="col-cat">Category</th><th class="col-x"></th></tr></thead><tbody></tbody></table>
|
| 231 |
<button class="btn sm alt" id="rc-additem" style="margin-top:8px">+ Add item</button>
|
| 232 |
-
<
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
<
|
| 236 |
<div id="rc-recon" class="banner ok">Totals reconcile.</div>
|
| 237 |
<div id="rc-understand" class="banner lav hidden"></div>
|
| 238 |
-
<div class="row"><button class="btn lav" id="rc-confirm">Understand & categorize</button><button class="btn" id="rc-save" disabled>Save</button></div>
|
| 239 |
<div class="msg" id="rc-savemsg"></div>
|
| 240 |
</div>
|
| 241 |
</div>
|
|
@@ -268,7 +280,9 @@ td input,td select{padding:8px 9px;border-radius:9px}
|
|
| 268 |
|
| 269 |
<!-- Calendar -->
|
| 270 |
<section id="view-calendar" class="hidden">
|
| 271 |
-
<div class="topbar"><div class="title">Calendar</div>
|
|
|
|
|
|
|
| 272 |
<div class="grid2b">
|
| 273 |
<div class="panel"><div class="cal-grid" id="cal"></div></div>
|
| 274 |
<div class="panel"><h3 id="cal-day-title">Pick a day</h3><div class="ph-sub">Spending on the selected day</div><div id="cal-day"></div></div>
|
|
|
|
| 27 |
input,select,textarea{width:100%;background:var(--surface2);border:1px solid var(--line);color:var(--ink);border-radius:12px;padding:11px 13px;font-size:.9rem;outline:none;transition:border-color .15s}
|
| 28 |
input:focus,select:focus,textarea:focus{border-color:var(--lav)}
|
| 29 |
input[type=date]::-webkit-calendar-picker-indicator{filter:invert(.7)}
|
| 30 |
+
input[type=number]::-webkit-inner-spin-button,input[type=number]::-webkit-outer-spin-button{-webkit-appearance:none;margin:0}
|
| 31 |
+
input[type=number]{-moz-appearance:textfield;appearance:textfield}
|
| 32 |
+
.empty-chart{height:230px;display:flex;align-items:center;justify-content:center;color:var(--faint);font-size:.86rem;border:1px dashed var(--line);border-radius:14px}
|
| 33 |
label{display:block;font-size:.76rem;color:var(--muted);margin-bottom:6px;font-weight:600}
|
| 34 |
.field{margin:13px 0} .row{display:flex;gap:11px}.row>*{flex:1} .msg{margin-top:11px;font-size:.84rem;min-height:1em}
|
| 35 |
.btn{background:var(--lime);color:var(--lime-ink);font-weight:800;border-radius:13px;padding:11px 17px;font-size:.88rem;transition:filter .12s,transform .05s}
|
|
|
|
| 107 |
.drop{border:1.5px dashed var(--line);border-radius:15px;padding:18px;text-align:center;color:var(--muted);font-size:.85rem;display:block;cursor:pointer}
|
| 108 |
.drop:hover{border-color:var(--lav);color:var(--ink)} .drop img{max-height:280px;max-width:100%;border-radius:12px;margin-top:10px}
|
| 109 |
.cap-row{display:flex;gap:9px;margin-top:10px}
|
| 110 |
+
.upload-row{display:flex;gap:14px;align-items:stretch}
|
| 111 |
+
.upload-actions{display:flex;flex-direction:column;gap:9px;justify-content:center;min-width:160px}
|
| 112 |
+
@media(max-width:720px){.upload-row{flex-direction:column}.upload-actions{min-width:0}}
|
| 113 |
.banner{border-radius:12px;padding:11px 14px;font-size:.84rem;margin:12px 0;border:1px solid transparent}
|
| 114 |
.banner.ok{background:#10271d;color:var(--good);border-color:#1d3a2c} .banner.warn{background:#2c2611;color:var(--warn);border-color:#3c331a}
|
| 115 |
.banner.lav{background:#211a37;color:var(--lav);border-color:#352c57}
|
|
|
|
| 209 |
<div class="panel"><h3>Spend over time</h3><div class="ph-sub" id="ot-sub">Monthly</div><div class="chart-box"><canvas id="chart-time"></canvas></div></div>
|
| 210 |
<div class="panel"><h3>This month vs last</h3><div class="ph-sub">By category</div><div class="cmp" id="comparison"></div></div>
|
| 211 |
</div>
|
| 212 |
+
<div class="panel"><div style="display:flex;justify-content:space-between;align-items:center"><h3>Recent transactions</h3><button class="btn sm alt" id="see-all">See all</button></div><div class="ph-sub">Your latest spends</div><div id="recent"></div></div>
|
| 213 |
</section>
|
| 214 |
|
| 215 |
<!-- Add -->
|
|
|
|
| 221 |
<button class="mode" data-mode="manual"><svg viewBox="0 0 24 24"><path d="M12 20h9M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4z"/></svg>Manual</button>
|
| 222 |
</div>
|
| 223 |
|
| 224 |
+
<div id="mode-receipt">
|
| 225 |
+
<div class="panel" style="margin-bottom:16px">
|
| 226 |
<h3>Bill photo</h3><div class="ph-sub">Snap or upload — items, taxes, round-off and all</div>
|
| 227 |
+
<div class="upload-row">
|
| 228 |
+
<label class="drop" for="rc-file" style="flex:1;margin:0">Tap to choose a photo<div id="rc-prev"></div></label>
|
| 229 |
+
<div class="upload-actions">
|
| 230 |
+
<label class="btn alt sm" for="rc-file" style="text-align:center">Choose file</label>
|
| 231 |
+
<label class="btn alt sm" for="rc-cam" style="text-align:center">Take photo</label>
|
| 232 |
+
<button class="btn" id="rc-extract">Extract</button>
|
| 233 |
+
</div>
|
| 234 |
+
</div>
|
| 235 |
<input id="rc-file" type="file" accept="image/*" class="vh"><input id="rc-cam" type="file" accept="image/*" capture="environment" class="vh">
|
| 236 |
+
<div class="msg" id="rc-status"></div>
|
|
|
|
| 237 |
</div>
|
| 238 |
<div class="panel">
|
| 239 |
+
<h3>Review & understand</h3><div class="ph-sub">Fix anything wrong, then let the AI categorize it</div>
|
| 240 |
+
<div class="row"><div class="field"><label>Vendor</label><input id="rc-vendor"></div><div class="field"><label>Date</label><input id="rc-date" type="date"></div><div class="field"><label>Currency</label><input id="rc-currency"></div></div>
|
|
|
|
| 241 |
<label>Line items</label>
|
| 242 |
<table id="rc-items"><thead><tr><th>Item</th><th class="col-qty">Qty</th><th class="col-amt">Amount</th><th class="col-cat">Category</th><th class="col-x"></th></tr></thead><tbody></tbody></table>
|
| 243 |
<button class="btn sm alt" id="rc-additem" style="margin-top:8px">+ Add item</button>
|
| 244 |
+
<div class="row" style="margin-top:16px;align-items:flex-start">
|
| 245 |
+
<div style="flex:1"><label>Charges & taxes</label><table id="rc-charges"><thead><tr><th>Label</th><th class="col-amt">Amount</th><th class="col-x"></th></tr></thead><tbody></tbody></table><button class="btn sm alt" id="rc-addcharge" style="margin-top:8px">+ Add charge</button></div>
|
| 246 |
+
<div style="flex:1"><div class="field" style="margin:0 0 13px"><label>Total</label><input id="rc-total" type="number" inputmode="decimal"></div><div class="field" style="margin:0"><label>Overall category</label><select id="rc-cat"></select></div></div>
|
| 247 |
+
</div>
|
| 248 |
<div id="rc-recon" class="banner ok">Totals reconcile.</div>
|
| 249 |
<div id="rc-understand" class="banner lav hidden"></div>
|
| 250 |
+
<div class="row" style="margin-top:4px"><button class="btn lav" id="rc-confirm">Understand & categorize</button><button class="btn" id="rc-save" disabled>Save</button></div>
|
| 251 |
<div class="msg" id="rc-savemsg"></div>
|
| 252 |
</div>
|
| 253 |
</div>
|
|
|
|
| 280 |
|
| 281 |
<!-- Calendar -->
|
| 282 |
<section id="view-calendar" class="hidden">
|
| 283 |
+
<div class="topbar"><div class="title">Calendar</div>
|
| 284 |
+
<div class="controls"><button class="btn sm alt" id="cal-prev">‹ Prev</button><div class="sub" id="cal-month" style="min-width:120px;text-align:center"></div><button class="btn sm alt" id="cal-next">Next ›</button></div>
|
| 285 |
+
</div>
|
| 286 |
<div class="grid2b">
|
| 287 |
<div class="panel"><div class="cal-grid" id="cal"></div></div>
|
| 288 |
<div class="panel"><h3 id="cal-day-title">Pick a day</h3><div class="ph-sub">Spending on the selected day</div><div id="cal-day"></div></div>
|