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 <noreply@anthropic.com>
This commit is contained in:
2026-02-01 00:11:58 -06:00
parent be2ac4eaf7
commit fde6fcb724
2 changed files with 126 additions and 12 deletions

View File

@@ -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=<id> → 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)