From fde6fcb724c282cc892cb913d19353f8c24a6c89 Mon Sep 17 00:00:00 2001 From: kamaji Date: Sun, 1 Feb 2026 00:11:58 -0600 Subject: [PATCH] Add project filter and zip export with photos - Filter dropdown to display receipts by project - Total banner updates to reflect filtered view - Export produces a zip with Excel + receipt photos - Photo filenames use project_date_amount_category format Co-Authored-By: Claude Opus 4.5 --- receipts.html | 69 +++++++++++++++++++++++++++++++++++++++++++++++---- server.py | 69 +++++++++++++++++++++++++++++++++++++++++++++------ 2 files changed, 126 insertions(+), 12 deletions(-) diff --git a/receipts.html b/receipts.html index 7da3e15..f9ea5d8 100644 --- a/receipts.html +++ b/receipts.html @@ -484,6 +484,22 @@ border-radius: 4px; } .manage-item button:hover { background: #fee; } + + /* Filter bar */ + .filter-bar { + max-width: 600px; + margin: 0 auto; + padding: 8px 16px 0; + } + .filter-bar select { + width: 100%; + padding: 8px 12px; + border: 1.5px solid #ddd; + border-radius: 8px; + font-size: 0.9rem; + background: #fafafa; + color: #333; + } @@ -499,6 +515,12 @@ + +
@@ -662,9 +684,12 @@ let editingId = null; let currentPhotoBlob = null; // Blob from resized image let currentPhotoUrl = null; // Object URL or /api/photos/ URL for display + let activeFilter = ""; // "" = all, "none" = no project, or a project id // DOM refs const list = document.getElementById("receipt-list"); + const filterBar = document.getElementById("filter-bar"); + const filterProject = document.getElementById("filter-project"); const emptyState = document.getElementById("empty-state"); const overlay = document.getElementById("modal-overlay"); const modalTitle = document.getElementById("modal-title"); @@ -689,7 +714,8 @@ } function updateTotal() { - const total = receipts.reduce((s, r) => s + Number(r.amount), 0); + const visible = filteredReceipts(); + const total = visible.reduce((s, r) => s + Number(r.amount), 0); totalBanner.textContent = "Total: " + formatMoney(total); } @@ -701,6 +727,29 @@ return settings.customers.find(c => c.id === id) || null; } + function renderFilterDropdown() { + const prev = activeFilter; + let html = ''; + settings.customers.forEach(c => { + const projects = settings.projects.filter(p => p.customerId === c.id); + if (projects.length === 0) return; + html += ``; + projects.forEach(p => { + const sel = p.id === prev ? " selected" : ""; + html += ``; + }); + html += ''; + }); + filterProject.innerHTML = html; + filterBar.style.display = settings.projects.length > 0 ? "" : "none"; + } + + function filteredReceipts() { + if (!activeFilter) return receipts; + if (activeFilter === "none") return receipts.filter(r => !r.projectId); + return receipts.filter(r => r.projectId === activeFilter); + } + function renderProjectDropdown(selectedId) { let html = ''; settings.customers.forEach(c => { @@ -724,15 +773,17 @@ function render() { list.querySelectorAll(".receipt-card").forEach(el => el.remove()); + renderFilterDropdown(); - if (receipts.length === 0) { + const visible = filteredReceipts(); + if (visible.length === 0) { emptyState.style.display = ""; updateTotal(); return; } emptyState.style.display = "none"; - [...receipts].reverse().forEach(r => { + [...visible].reverse().forEach(r => { const card = document.createElement("div"); card.className = "receipt-card"; @@ -1333,8 +1384,11 @@ // Export to Excel document.getElementById("export-btn").addEventListener("click", () => { - if (receipts.length === 0) { alert("No receipts to export."); return; } - window.location.href = "/api/export"; + const visible = filteredReceipts(); + if (visible.length === 0) { alert("No receipts to export."); return; } + let url = "/api/export"; + if (activeFilter) url += "?project=" + encodeURIComponent(activeFilter); + window.location.href = url; }); // --- Manage Customers & Projects --- @@ -1392,6 +1446,11 @@ }); } + filterProject.addEventListener("change", () => { + activeFilter = filterProject.value; + render(); + }); + document.getElementById("manage-btn").addEventListener("click", () => { renderManageModal(); manageOverlay.classList.add("open"); diff --git a/server.py b/server.py index 46e21b2..24a9153 100644 --- a/server.py +++ b/server.py @@ -8,10 +8,11 @@ import io import json import re import secrets +import zipfile import sys from http.server import HTTPServer, BaseHTTPRequestHandler from http.cookies import SimpleCookie -from urllib.parse import quote as urlquote +from urllib.parse import quote as urlquote, urlparse, parse_qs import openpyxl from openpyxl.styles import Font, PatternFill, Alignment, Border, Side @@ -327,18 +328,72 @@ class Handler(BaseHTTPRequestHandler): self._send_error(502, str(e)) return - # GET /api/export — export receipts as Excel - if self.path == "/api/export": + # GET /api/export — export receipts as Excel (optionally filtered by project) + # ?project= → zip with Excel + photos for that project + # ?project=none → zip with Excel + photos for receipts with no project + # (no param) → zip with Excel + photos for all receipts + if self.path.startswith("/api/export"): if not self._check_session(): return try: + parsed = urlparse(self.path) + qs = parse_qs(parsed.query) + project_filter = qs.get("project", [None])[0] + receipts = load_receipts() - body = build_excel(receipts) + if project_filter == "none": + receipts = [r for r in receipts if not r.get("projectId")] + elif project_filter: + receipts = [r for r in receipts if r.get("projectId") == project_filter] + + # Load settings for name lookups + settings = load_settings() + project_map = {p["id"]: p for p in settings.get("projects", [])} + customer_map = {c["id"]: c for c in settings.get("customers", [])} + + def sanitize(s): + return re.sub(r'[^\w\s-]', '', s).strip().replace(" ", "_") + + # Determine filename prefix + filename = "receipts" + if project_filter and project_filter != "none": + proj = project_map.get(project_filter) + if proj: + cust = customer_map.get(proj.get("customerId", "")) + parts = [] + if cust: + parts.append(sanitize(cust["name"])) + parts.append(sanitize(proj["name"])) + filename = "-".join(parts) if parts else "receipts" + + excel_bytes = build_excel(receipts) + + # Build zip with Excel + photos + zip_buf = io.BytesIO() + with zipfile.ZipFile(zip_buf, "w", zipfile.ZIP_DEFLATED) as zf: + zf.writestr(f"{filename}.xlsx", excel_bytes) + for r in receipts: + if r.get("photo"): + try: + photo_resp = nc_get(f"{PHOTOS_DIR}/{r['id']}.jpg") + if photo_resp.status_code == 200: + # Name: project_date_amount_category + pid = r.get("projectId", "") + proj = project_map.get(pid) + proj_part = sanitize(proj["name"]) if proj else "no_project" + date_part = r.get("date", "")[:10] + amount_part = f"{r.get('amount', 0):.2f}" + cat_part = r.get("category", "other") + photo_name = f"{proj_part}_{date_part}_{amount_part}_{cat_part}.jpg" + zf.writestr(f"photos/{photo_name}", photo_resp.content) + except Exception: + pass # skip photos that fail to download + + body = zip_buf.getvalue() self.send_response(200) - self.send_header("Content-Type", - "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") + self.send_header("Content-Type", "application/zip") self.send_header("Content-Disposition", - 'attachment; filename="receipts.xlsx"') + f'attachment; filename="{filename}.zip"') self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body)