Files
receipt-manager/receipts.html
kamaji 9c35cc71e9 Add authentication and date picker to Receipt Manager
Add username/password login with session cookies, change-password modal,
and logout. All API endpoints require a valid session. Default credentials
(admin/admin) are auto-created on first run. Also add a date input field
to the receipt modal, defaulting to today but allowing the user to pick
any date.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 20:24:02 -06:00

958 lines
27 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Receipt Manager</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: #f0f2f5;
color: #1a1a2e;
min-height: 100vh;
}
header {
background: #1a1a2e;
color: #fff;
padding: 16px 20px;
display: flex;
justify-content: space-between;
align-items: center;
position: sticky;
top: 0;
z-index: 10;
}
header h1 { font-size: 1.2rem; font-weight: 600; }
.header-right { display: flex; align-items: center; gap: 10px; }
#total-banner {
font-size: 0.9rem;
background: rgba(255,255,255,0.12);
padding: 4px 12px;
border-radius: 20px;
}
#export-btn {
font-size: 0.8rem;
background: rgba(255,255,255,0.15);
color: #fff;
border: 1px solid rgba(255,255,255,0.25);
padding: 4px 12px;
border-radius: 20px;
cursor: pointer;
font-weight: 600;
}
#export-btn:hover { background: rgba(255,255,255,0.25); }
.container { max-width: 600px; margin: 0 auto; padding: 16px; }
/* Add receipt button */
#add-btn {
position: fixed;
bottom: 24px;
right: 24px;
width: 56px;
height: 56px;
border-radius: 50%;
background: #e94560;
color: #fff;
border: none;
font-size: 2rem;
line-height: 1;
cursor: pointer;
box-shadow: 0 4px 12px rgba(233,69,96,0.4);
z-index: 20;
display: flex;
align-items: center;
justify-content: center;
}
#add-btn:active { transform: scale(0.93); }
/* Modal overlay */
.modal-overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(0,0,0,0.5);
z-index: 100;
justify-content: center;
align-items: flex-end;
}
.modal-overlay.open { display: flex; }
.modal {
background: #fff;
width: 100%;
max-width: 600px;
border-radius: 16px 16px 0 0;
padding: 24px 20px 32px;
animation: slideUp 0.25s ease;
}
@keyframes slideUp {
from { transform: translateY(100%); }
to { transform: translateY(0); }
}
.modal h2 { font-size: 1.1rem; margin-bottom: 20px; }
.field { margin-bottom: 16px; }
.field label {
display: block;
font-size: 0.8rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #666;
margin-bottom: 6px;
}
.field input,
.field select {
width: 100%;
padding: 10px 12px;
border: 1.5px solid #ddd;
border-radius: 8px;
font-size: 1rem;
background: #fafafa;
appearance: none;
-webkit-appearance: none;
}
.field select {
background: #fafafa url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='7'%3E%3Cpath d='M1 1l5 5 5-5' stroke='%23666' fill='none' stroke-width='1.5'/%3E%3C/svg%3E") no-repeat right 12px center;
padding-right: 32px;
}
.field input:focus,
.field select:focus {
outline: none;
border-color: #e94560;
}
/* Photo area */
.photo-area {
border: 2px dashed #ccc;
border-radius: 8px;
padding: 20px;
text-align: center;
cursor: pointer;
color: #888;
position: relative;
overflow: hidden;
min-height: 120px;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 8px;
}
.photo-area.has-photo {
border-style: solid;
border-color: #4caf50;
padding: 0;
}
.photo-area img {
width: 100%;
height: auto;
display: block;
border-radius: 6px;
}
.photo-area svg { opacity: 0.4; }
.btn-row {
display: flex;
gap: 10px;
margin-top: 20px;
}
.btn-row button {
flex: 1;
padding: 12px;
border: none;
border-radius: 8px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
}
.btn-cancel { background: #eee; color: #333; }
.btn-save { background: #e94560; color: #fff; }
.btn-save:disabled { opacity: 0.5; cursor: not-allowed; }
/* Receipt cards */
.receipt-card {
background: #fff;
border-radius: 12px;
padding: 14px 16px;
margin-bottom: 12px;
box-shadow: 0 1px 4px rgba(0,0,0,0.06);
display: flex;
gap: 14px;
align-items: center;
}
.receipt-card .thumb {
width: 56px;
height: 56px;
border-radius: 8px;
object-fit: cover;
flex-shrink: 0;
background: #eee;
}
.receipt-card .thumb.no-photo {
display: flex;
align-items: center;
justify-content: center;
font-size: 1.4rem;
}
.receipt-info { flex: 1; min-width: 0; }
.receipt-info .amount { font-size: 1.1rem; font-weight: 700; }
.receipt-info .meta {
font-size: 0.8rem;
color: #888;
margin-top: 2px;
}
.receipt-actions { display: flex; gap: 6px; flex-shrink: 0; }
.receipt-actions button {
background: none;
border: none;
cursor: pointer;
font-size: 1.1rem;
padding: 4px;
border-radius: 4px;
color: #888;
}
.receipt-actions button:hover { background: #f0f0f0; }
.category-badge {
display: inline-block;
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
padding: 2px 8px;
border-radius: 4px;
margin-left: 8px;
vertical-align: middle;
}
.cat-food { background: #fff3e0; color: #e65100; }
.cat-hotel { background: #e8eaf6; color: #283593; }
.cat-fuel { background: #e0f2f1; color: #00695c; }
.cat-vehicle { background: #fce4ec; color: #b71c1c; }
.cat-other { background: #f3e5f5; color: #6a1b9a; }
.empty-state {
text-align: center;
padding: 60px 20px;
color: #aaa;
}
.empty-state svg { margin-bottom: 12px; opacity: 0.3; }
.empty-state p { font-size: 0.95rem; }
/* Login overlay */
.login-overlay {
position: fixed;
inset: 0;
background: #1a1a2e;
z-index: 500;
display: flex;
justify-content: center;
align-items: center;
}
.login-overlay.hidden { display: none; }
.login-box {
background: #fff;
border-radius: 16px;
padding: 36px 28px;
width: 90%;
max-width: 360px;
box-shadow: 0 8px 32px rgba(0,0,0,0.3);
}
.login-box h2 {
text-align: center;
margin-bottom: 24px;
font-size: 1.3rem;
}
.login-box .field { margin-bottom: 16px; }
.login-box .field label {
display: block;
font-size: 0.8rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #666;
margin-bottom: 6px;
}
.login-box .field input {
width: 100%;
padding: 10px 12px;
border: 1.5px solid #ddd;
border-radius: 8px;
font-size: 1rem;
background: #fafafa;
}
.login-box .field input:focus { outline: none; border-color: #e94560; }
.login-box .login-error {
color: #e94560;
font-size: 0.85rem;
text-align: center;
margin-bottom: 12px;
min-height: 1.2em;
}
.login-box button {
width: 100%;
padding: 12px;
border: none;
border-radius: 8px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
background: #e94560;
color: #fff;
}
.login-box button:disabled { opacity: 0.5; cursor: not-allowed; }
/* Change password modal */
.cp-overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(0,0,0,0.5);
z-index: 300;
justify-content: center;
align-items: center;
}
.cp-overlay.open { display: flex; }
.cp-box {
background: #fff;
border-radius: 16px;
padding: 28px 24px;
width: 90%;
max-width: 380px;
}
.cp-box h2 { margin-bottom: 20px; font-size: 1.1rem; }
.cp-box .field { margin-bottom: 16px; }
.cp-box .field label {
display: block;
font-size: 0.8rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #666;
margin-bottom: 6px;
}
.cp-box .field input {
width: 100%;
padding: 10px 12px;
border: 1.5px solid #ddd;
border-radius: 8px;
font-size: 1rem;
background: #fafafa;
}
.cp-box .field input:focus { outline: none; border-color: #e94560; }
.cp-box .cp-error {
color: #e94560;
font-size: 0.85rem;
text-align: center;
margin-bottom: 12px;
min-height: 1.2em;
}
.cp-box .cp-success {
color: #4caf50;
font-size: 0.85rem;
text-align: center;
margin-bottom: 12px;
min-height: 1.2em;
}
.cp-box .btn-row { display: flex; gap: 10px; margin-top: 20px; }
.cp-box .btn-row button {
flex: 1;
padding: 12px;
border: none;
border-radius: 8px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
}
/* Header icon buttons */
.header-icon-btn {
background: none;
border: none;
color: rgba(255,255,255,0.8);
cursor: pointer;
font-size: 1.2rem;
padding: 4px 6px;
border-radius: 4px;
}
.header-icon-btn:hover { color: #fff; background: rgba(255,255,255,0.15); }
/* Photo preview modal */
.photo-preview-overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(0,0,0,0.85);
z-index: 200;
justify-content: center;
align-items: center;
cursor: pointer;
}
.photo-preview-overlay.open { display: flex; }
.photo-preview-overlay img {
max-width: 95%;
max-height: 90vh;
border-radius: 8px;
}
</style>
</head>
<body>
<header>
<h1>Receipt Manager</h1>
<div class="header-right">
<button id="export-btn">Export Excel</button>
<div id="total-banner">Total: $0.00</div>
<button class="header-icon-btn" id="settings-btn" title="Change Password">&#9881;</button>
<button class="header-icon-btn" id="logout-btn" title="Log Out">&#9211;</button>
</div>
</header>
<div class="container" id="receipt-list">
<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>
<p>No receipts yet.<br>Tap <strong>+</strong> to add one.</p>
</div>
</div>
<button id="add-btn" aria-label="Add receipt">+</button>
<!-- Add/Edit modal -->
<div class="modal-overlay" id="modal-overlay">
<div class="modal">
<h2 id="modal-title">New Receipt</h2>
<div class="field">
<label>Photo</label>
<div class="photo-area" id="photo-area">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M23 19a2 2 0 01-2 2H3a2 2 0 01-2-2V8a2 2 0 012-2h4l2-3h6l2 3h4a2 2 0 012 2z"/><circle cx="12" cy="13" r="4"/></svg>
<span>Tap to take photo</span>
</div>
<input type="file" id="photo-input" accept="image/*" capture="environment" hidden>
</div>
<div class="field">
<label>Date</label>
<input type="date" id="date-input">
</div>
<div class="field">
<label>Amount ($)</label>
<input type="number" id="amount-input" placeholder="0.00" min="0" step="0.01" inputmode="decimal">
</div>
<div class="field">
<label>Category</label>
<select id="category-input">
<option value="food">Food</option>
<option value="hotel">Hotel</option>
<option value="fuel">Fuel</option>
<option value="vehicle">Vehicle</option>
<option value="other">Other</option>
</select>
</div>
<div class="field">
<label>Note (optional)</label>
<input type="text" id="note-input" placeholder="Short description">
</div>
<div class="btn-row">
<button class="btn-cancel" id="btn-cancel">Cancel</button>
<button class="btn-save" id="btn-save">Save</button>
</div>
</div>
</div>
<!-- Full-size photo viewer -->
<div class="photo-preview-overlay" id="photo-preview">
<img id="photo-preview-img" src="" alt="Receipt photo">
</div>
<!-- Login overlay -->
<div class="login-overlay" id="login-overlay">
<div class="login-box">
<h2>Receipt Manager</h2>
<div class="field">
<label>Username</label>
<input type="text" id="login-username" autocomplete="username">
</div>
<div class="field">
<label>Password</label>
<input type="password" id="login-password" autocomplete="current-password">
</div>
<div class="login-error" id="login-error"></div>
<button id="login-btn">Log In</button>
</div>
</div>
<!-- Change Password modal -->
<div class="cp-overlay" id="cp-overlay">
<div class="cp-box">
<h2>Change Password</h2>
<div class="field">
<label>Current Password</label>
<input type="password" id="cp-current" autocomplete="current-password">
</div>
<div class="field">
<label>New Password</label>
<input type="password" id="cp-new" autocomplete="new-password">
</div>
<div class="field">
<label>Confirm New Password</label>
<input type="password" id="cp-confirm" autocomplete="new-password">
</div>
<div class="cp-error" id="cp-error"></div>
<div class="cp-success" id="cp-success"></div>
<div class="btn-row">
<button class="btn-cancel" id="cp-cancel">Cancel</button>
<button class="btn-save" id="cp-save">Update</button>
</div>
</div>
</div>
<script>
(function () {
let receipts = [];
let editingId = null;
let currentPhotoBlob = null; // Blob from resized image
let currentPhotoUrl = null; // Object URL or /api/photos/<id> URL for display
// DOM refs
const list = document.getElementById("receipt-list");
const emptyState = document.getElementById("empty-state");
const overlay = document.getElementById("modal-overlay");
const modalTitle = document.getElementById("modal-title");
const photoArea = document.getElementById("photo-area");
const photoInput = document.getElementById("photo-input");
const dateInput = document.getElementById("date-input");
const amountInput = document.getElementById("amount-input");
const categoryInput = document.getElementById("category-input");
const noteInput = document.getElementById("note-input");
const btnSave = document.getElementById("btn-save");
const totalBanner = document.getElementById("total-banner");
const photoPreview = document.getElementById("photo-preview");
const photoPreviewImg = document.getElementById("photo-preview-img");
function formatMoney(n) {
return "$" + Number(n).toFixed(2);
}
function categoryLabel(cat) {
return cat.charAt(0).toUpperCase() + cat.slice(1);
}
function updateTotal() {
const total = receipts.reduce((s, r) => s + Number(r.amount), 0);
totalBanner.textContent = "Total: " + formatMoney(total);
}
function photoSrc(r) {
// If the receipt has a photo filename, point to our proxy endpoint
if (r.photo) return "/api/photos/" + r.id;
return null;
}
function render() {
list.querySelectorAll(".receipt-card").forEach(el => el.remove());
if (receipts.length === 0) {
emptyState.style.display = "";
updateTotal();
return;
}
emptyState.style.display = "none";
[...receipts].reverse().forEach(r => {
const card = document.createElement("div");
card.className = "receipt-card";
let thumbHtml;
const src = photoSrc(r);
if (src) {
thumbHtml = `<img class="thumb" src="${src}" alt="Receipt" data-id="${r.id}" style="cursor:pointer">`;
} else {
thumbHtml = `<div class="thumb no-photo">&#128206;</div>`;
}
const date = new Date(r.date).toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" });
card.innerHTML = `
${thumbHtml}
<div class="receipt-info">
<span class="amount">${formatMoney(r.amount)}</span>
<span class="category-badge cat-${r.category}">${categoryLabel(r.category)}</span>
<div class="meta">${date}${r.note ? " &mdash; " + escapeHtml(r.note) : ""}</div>
</div>
<div class="receipt-actions">
<button title="Edit" data-edit="${r.id}">&#9998;</button>
<button title="Delete" data-delete="${r.id}">&times;</button>
</div>
`;
list.appendChild(card);
});
updateTotal();
}
function escapeHtml(str) {
const d = document.createElement("div");
d.textContent = str;
return d.innerHTML;
}
// Open modal
function todayStr() {
return new Date().toISOString().slice(0, 10);
}
function openModal(receipt) {
if (receipt) {
editingId = receipt.id;
modalTitle.textContent = "Edit Receipt";
dateInput.value = (receipt.date || "").slice(0, 10);
amountInput.value = receipt.amount;
categoryInput.value = receipt.category;
noteInput.value = receipt.note || "";
currentPhotoBlob = null; // no new blob yet
currentPhotoUrl = receipt.photo ? "/api/photos/" + receipt.id : null;
} else {
editingId = null;
modalTitle.textContent = "New Receipt";
dateInput.value = todayStr();
amountInput.value = "";
categoryInput.value = "food";
noteInput.value = "";
currentPhotoBlob = null;
currentPhotoUrl = null;
}
updatePhotoArea();
overlay.classList.add("open");
amountInput.focus();
}
function closeModal() {
overlay.classList.remove("open");
photoInput.value = "";
}
function updatePhotoArea() {
if (currentPhotoUrl) {
photoArea.classList.add("has-photo");
photoArea.innerHTML = `<img src="${currentPhotoUrl}" alt="Receipt photo">`;
} else {
photoArea.classList.remove("has-photo");
photoArea.innerHTML = `
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M23 19a2 2 0 01-2 2H3a2 2 0 01-2-2V8a2 2 0 012-2h4l2-3h6l2 3h4a2 2 0 012 2z"/><circle cx="12" cy="13" r="4"/></svg>
<span>Tap to take photo</span>`;
}
}
// Resize image, returns a Promise<Blob>
function resizeImage(file, maxW) {
return new Promise((resolve) => {
const reader = new FileReader();
reader.onload = ev => {
const img = new Image();
img.onload = () => {
let w = img.width, h = img.height;
if (w > maxW) { h = h * (maxW / w); w = maxW; }
const c = document.createElement("canvas");
c.width = w; c.height = h;
c.getContext("2d").drawImage(img, 0, 0, w, h);
c.toBlob(blob => resolve(blob), "image/jpeg", 0.7);
};
img.src = ev.target.result;
};
reader.readAsDataURL(file);
});
}
// --- Auth helpers ---
const loginOverlay = document.getElementById("login-overlay");
const loginError = document.getElementById("login-error");
const loginUsername = document.getElementById("login-username");
const loginPassword = document.getElementById("login-password");
const loginBtn = document.getElementById("login-btn");
function showLogin() {
loginOverlay.classList.remove("hidden");
loginError.textContent = "";
loginUsername.value = "";
loginPassword.value = "";
loginUsername.focus();
}
function hideLogin() {
loginOverlay.classList.add("hidden");
}
function handleUnauthorized(r) {
if (r.status === 401) { showLogin(); return true; }
return false;
}
loginBtn.addEventListener("click", doLogin);
loginPassword.addEventListener("keydown", e => { if (e.key === "Enter") doLogin(); });
loginUsername.addEventListener("keydown", e => { if (e.key === "Enter") loginPassword.focus(); });
async function doLogin() {
loginError.textContent = "";
loginBtn.disabled = true;
loginBtn.textContent = "Logging in...";
try {
const r = await fetch("/api/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: loginUsername.value, password: loginPassword.value })
});
if (r.ok) {
hideLogin();
await initApp();
} else {
loginError.textContent = "Invalid username or password";
}
} catch (e) {
loginError.textContent = "Connection error";
} finally {
loginBtn.disabled = false;
loginBtn.textContent = "Log In";
}
}
document.getElementById("logout-btn").addEventListener("click", async () => {
await fetch("/api/logout", { method: "POST" });
receipts = [];
render();
showLogin();
});
// --- Change Password ---
const cpOverlay = document.getElementById("cp-overlay");
const cpError = document.getElementById("cp-error");
const cpSuccess = document.getElementById("cp-success");
const cpCurrent = document.getElementById("cp-current");
const cpNew = document.getElementById("cp-new");
const cpConfirm = document.getElementById("cp-confirm");
document.getElementById("settings-btn").addEventListener("click", () => {
cpCurrent.value = ""; cpNew.value = ""; cpConfirm.value = "";
cpError.textContent = ""; cpSuccess.textContent = "";
cpOverlay.classList.add("open");
cpCurrent.focus();
});
document.getElementById("cp-cancel").addEventListener("click", () => {
cpOverlay.classList.remove("open");
});
cpOverlay.addEventListener("click", e => {
if (e.target === cpOverlay) cpOverlay.classList.remove("open");
});
document.getElementById("cp-save").addEventListener("click", async () => {
cpError.textContent = ""; cpSuccess.textContent = "";
if (!cpCurrent.value) { cpError.textContent = "Enter current password"; return; }
if (!cpNew.value) { cpError.textContent = "Enter new password"; return; }
if (cpNew.value !== cpConfirm.value) { cpError.textContent = "New passwords do not match"; return; }
try {
const r = await fetch("/api/change-password", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ current: cpCurrent.value, new: cpNew.value })
});
if (handleUnauthorized(r)) return;
const data = await r.json();
if (r.ok) {
cpSuccess.textContent = "Password updated";
cpCurrent.value = ""; cpNew.value = ""; cpConfirm.value = "";
setTimeout(() => cpOverlay.classList.remove("open"), 1200);
} else {
cpError.textContent = data.error || "Failed to change password";
}
} catch (e) {
cpError.textContent = "Connection error";
}
});
// --- API helpers ---
async function apiLoadReceipts() {
const r = await fetch("/api/receipts");
if (handleUnauthorized(r)) throw new Error("Unauthorized");
if (!r.ok) throw new Error("Failed to load receipts");
return r.json();
}
async function apiSaveReceipt(entry) {
const r = await fetch("/api/receipts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(entry)
});
if (handleUnauthorized(r)) throw new Error("Unauthorized");
if (!r.ok) throw new Error("Failed to save receipt");
return r.json();
}
async function apiDeleteReceipt(id) {
const r = await fetch("/api/receipts/" + id, { method: "DELETE" });
if (handleUnauthorized(r)) throw new Error("Unauthorized");
if (!r.ok) throw new Error("Failed to delete receipt");
}
async function apiUploadPhoto(id, blob) {
const r = await fetch("/api/photos/" + id, {
method: "POST",
headers: { "Content-Type": "image/jpeg" },
body: blob
});
if (handleUnauthorized(r)) throw new Error("Unauthorized");
if (!r.ok) throw new Error("Failed to upload photo");
return r.json();
}
// Events
document.getElementById("add-btn").addEventListener("click", () => openModal(null));
document.getElementById("btn-cancel").addEventListener("click", closeModal);
overlay.addEventListener("click", e => {
if (e.target === overlay) closeModal();
});
photoArea.addEventListener("click", () => photoInput.click());
photoInput.addEventListener("change", async e => {
const file = e.target.files[0];
if (!file) return;
currentPhotoBlob = await resizeImage(file, 1024);
currentPhotoUrl = URL.createObjectURL(currentPhotoBlob);
updatePhotoArea();
});
btnSave.addEventListener("click", async () => {
const amount = parseFloat(amountInput.value);
if (isNaN(amount) || amount <= 0) {
amountInput.focus();
amountInput.style.borderColor = "#e94560";
return;
}
amountInput.style.borderColor = "";
const id = editingId || Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
const hasNewPhoto = !!currentPhotoBlob;
const hadExistingPhoto = editingId && receipts.find(r => r.id === editingId)?.photo;
const entry = {
id: id,
amount: amount,
category: categoryInput.value,
note: noteInput.value.trim(),
photo: (hasNewPhoto || currentPhotoUrl) ? id + ".jpg" : "",
date: dateInput.value ? new Date(dateInput.value + "T12:00:00").toISOString() : new Date().toISOString()
};
btnSave.disabled = true;
btnSave.textContent = "Saving...";
try {
// Upload photo first if we have a new one
if (hasNewPhoto) {
await apiUploadPhoto(id, currentPhotoBlob);
}
await apiSaveReceipt(entry);
// Update local state
if (editingId) {
const idx = receipts.findIndex(r => r.id === editingId);
if (idx !== -1) receipts[idx] = entry;
} else {
receipts.push(entry);
}
render();
closeModal();
} catch (err) {
alert("Error saving receipt: " + err.message);
} finally {
btnSave.disabled = false;
btnSave.textContent = "Save";
}
});
// Delegate clicks on list
list.addEventListener("click", async e => {
const delBtn = e.target.closest("[data-delete]");
if (delBtn) {
const id = delBtn.dataset.delete;
if (confirm("Delete this receipt?")) {
try {
await apiDeleteReceipt(id);
receipts = receipts.filter(r => r.id !== id);
render();
} catch (err) {
alert("Error deleting receipt: " + err.message);
}
}
return;
}
const editBtn = e.target.closest("[data-edit]");
if (editBtn) {
const r = receipts.find(r => r.id === editBtn.dataset.edit);
if (r) openModal(r);
return;
}
const thumb = e.target.closest("img.thumb");
if (thumb && thumb.dataset.id) {
const r = receipts.find(r => r.id === thumb.dataset.id);
if (r && r.photo) {
photoPreviewImg.src = "/api/photos/" + r.id;
photoPreview.classList.add("open");
}
}
});
photoPreview.addEventListener("click", () => {
photoPreview.classList.remove("open");
});
// Export to Excel
document.getElementById("export-btn").addEventListener("click", () => {
if (receipts.length === 0) { alert("No receipts to export."); return; }
window.location.href = "/api/export";
});
// Init — check session, then load
async function initApp() {
try {
receipts = await apiLoadReceipts();
} catch (e) {
console.error("Failed to load receipts:", e);
return;
}
render();
}
(async () => {
const r = await fetch("/api/receipts");
if (r.status === 401) {
showLogin();
} else if (r.ok) {
hideLogin();
receipts = await r.json();
render();
}
})();
})();
</script>
</body>
</html>