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

@@ -484,6 +484,22 @@
border-radius: 4px; border-radius: 4px;
} }
.manage-item button:hover { background: #fee; } .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;
}
</style> </style>
</head> </head>
<body> <body>
@@ -499,6 +515,12 @@
</div> </div>
</header> </header>
<div class="filter-bar" id="filter-bar" style="display:none;">
<select id="filter-project">
<option value="">All receipts</option>
</select>
</div>
<div class="container" id="receipt-list"> <div class="container" id="receipt-list">
<div class="empty-state" id="empty-state"> <div class="empty-state" id="empty-state">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M9 14l-4 4m0 0l4 4m-4-4h11a4 4 0 000-8h-1"/></svg> <svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M9 14l-4 4m0 0l4 4m-4-4h11a4 4 0 000-8h-1"/></svg>
@@ -662,9 +684,12 @@
let editingId = null; let editingId = null;
let currentPhotoBlob = null; // Blob from resized image let currentPhotoBlob = null; // Blob from resized image
let currentPhotoUrl = null; // Object URL or /api/photos/<id> URL for display let currentPhotoUrl = null; // Object URL or /api/photos/<id> URL for display
let activeFilter = ""; // "" = all, "none" = no project, or a project id
// DOM refs // DOM refs
const list = document.getElementById("receipt-list"); 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 emptyState = document.getElementById("empty-state");
const overlay = document.getElementById("modal-overlay"); const overlay = document.getElementById("modal-overlay");
const modalTitle = document.getElementById("modal-title"); const modalTitle = document.getElementById("modal-title");
@@ -689,7 +714,8 @@
} }
function updateTotal() { 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); totalBanner.textContent = "Total: " + formatMoney(total);
} }
@@ -701,6 +727,29 @@
return settings.customers.find(c => c.id === id) || null; return settings.customers.find(c => c.id === id) || null;
} }
function renderFilterDropdown() {
const prev = activeFilter;
let html = '<option value="">All receipts</option><option value="none">No project</option>';
settings.customers.forEach(c => {
const projects = settings.projects.filter(p => p.customerId === c.id);
if (projects.length === 0) return;
html += `<optgroup label="${escapeHtml(c.name)}">`;
projects.forEach(p => {
const sel = p.id === prev ? " selected" : "";
html += `<option value="${p.id}"${sel}>${escapeHtml(p.name)}</option>`;
});
html += '</optgroup>';
});
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) { function renderProjectDropdown(selectedId) {
let html = '<option value="">No project</option>'; let html = '<option value="">No project</option>';
settings.customers.forEach(c => { settings.customers.forEach(c => {
@@ -724,15 +773,17 @@
function render() { function render() {
list.querySelectorAll(".receipt-card").forEach(el => el.remove()); list.querySelectorAll(".receipt-card").forEach(el => el.remove());
renderFilterDropdown();
if (receipts.length === 0) { const visible = filteredReceipts();
if (visible.length === 0) {
emptyState.style.display = ""; emptyState.style.display = "";
updateTotal(); updateTotal();
return; return;
} }
emptyState.style.display = "none"; emptyState.style.display = "none";
[...receipts].reverse().forEach(r => { [...visible].reverse().forEach(r => {
const card = document.createElement("div"); const card = document.createElement("div");
card.className = "receipt-card"; card.className = "receipt-card";
@@ -1333,8 +1384,11 @@
// Export to Excel // Export to Excel
document.getElementById("export-btn").addEventListener("click", () => { document.getElementById("export-btn").addEventListener("click", () => {
if (receipts.length === 0) { alert("No receipts to export."); return; } const visible = filteredReceipts();
window.location.href = "/api/export"; 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 --- // --- Manage Customers & Projects ---
@@ -1392,6 +1446,11 @@
}); });
} }
filterProject.addEventListener("change", () => {
activeFilter = filterProject.value;
render();
});
document.getElementById("manage-btn").addEventListener("click", () => { document.getElementById("manage-btn").addEventListener("click", () => {
renderManageModal(); renderManageModal();
manageOverlay.classList.add("open"); manageOverlay.classList.add("open");

View File

@@ -8,10 +8,11 @@ import io
import json import json
import re import re
import secrets import secrets
import zipfile
import sys import sys
from http.server import HTTPServer, BaseHTTPRequestHandler from http.server import HTTPServer, BaseHTTPRequestHandler
from http.cookies import SimpleCookie from http.cookies import SimpleCookie
from urllib.parse import quote as urlquote from urllib.parse import quote as urlquote, urlparse, parse_qs
import openpyxl import openpyxl
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
@@ -327,18 +328,72 @@ class Handler(BaseHTTPRequestHandler):
self._send_error(502, str(e)) self._send_error(502, str(e))
return return
# GET /api/export — export receipts as Excel # GET /api/export — export receipts as Excel (optionally filtered by project)
if self.path == "/api/export": # ?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(): if not self._check_session():
return return
try: try:
parsed = urlparse(self.path)
qs = parse_qs(parsed.query)
project_filter = qs.get("project", [None])[0]
receipts = load_receipts() 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_response(200)
self.send_header("Content-Type", self.send_header("Content-Type", "application/zip")
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
self.send_header("Content-Disposition", self.send_header("Content-Disposition",
'attachment; filename="receipts.xlsx"') f'attachment; filename="{filename}.zip"')
self.send_header("Content-Length", str(len(body))) self.send_header("Content-Length", str(len(body)))
self.end_headers() self.end_headers()
self.wfile.write(body) self.wfile.write(body)