Files
receipt-manager/receipts.html
2026-01-31 23:55:05 -06:00

1508 lines
46 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;
overflow-y: auto;
}
.modal-overlay.open { display: flex; }
.modal {
background: #fff;
width: 100%;
max-width: 600px;
max-height: 90vh;
overflow-y: auto;
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; }
.extract-status {
font-size: 0.8rem;
color: #888;
text-align: center;
padding: 6px 0 0;
min-height: 1.4em;
}
.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; }
/* Manual crop overlay */
.crop-overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(0,0,0,0.92);
z-index: 150;
flex-direction: column;
align-items: center;
justify-content: center;
}
.crop-overlay.open { display: flex; }
.crop-canvas-wrap {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
overflow: hidden;
padding: 12px;
}
.crop-canvas-wrap canvas {
max-width: 100%;
max-height: 100%;
touch-action: none;
}
.crop-toolbar {
display: flex;
gap: 12px;
padding: 12px 20px 24px;
width: 100%;
max-width: 500px;
}
.crop-toolbar button {
flex: 1;
padding: 12px;
border: none;
border-radius: 8px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
}
.crop-btn-full { background: rgba(255,255,255,0.15); color: #fff; }
.crop-btn-crop { background: #e94560; color: #fff; }
/* 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;
}
/* Manage modal items */
.manage-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 10px;
background: #f8f8f8;
border-radius: 6px;
margin-bottom: 6px;
font-size: 0.9rem;
}
.manage-item .item-label { flex: 1; }
.manage-item .item-sub { color: #888; font-size: 0.8rem; margin-left: 8px; }
.manage-item button {
background: none;
border: none;
color: #e94560;
cursor: pointer;
font-size: 1.1rem;
padding: 2px 6px;
border-radius: 4px;
}
.manage-item button:hover { background: #fee; }
</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="manage-btn" title="Manage Customers &amp; Projects">&#9776;</button>
<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 class="extract-status" id="extract-status"></div>
</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>Project (optional)</label>
<select id="project-input">
<option value="">No project</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>
<!-- Manual crop overlay -->
<div class="crop-overlay" id="crop-overlay">
<div class="crop-canvas-wrap">
<canvas id="crop-canvas"></canvas>
</div>
<div class="crop-toolbar">
<button class="crop-btn-full" id="crop-full">Use Full Image</button>
<button class="crop-btn-crop" id="crop-use">Crop &amp; Use</button>
</div>
</div>
<!-- Manage Customers & Projects modal -->
<div class="modal-overlay" id="manage-overlay">
<div class="modal">
<h2>Manage Customers &amp; Projects</h2>
<div class="manage-section">
<h3 style="font-size:0.95rem;margin-bottom:10px;">Customers</h3>
<div id="customer-list"></div>
<div style="display:flex;gap:8px;margin-top:8px;">
<input type="text" id="new-customer-name" placeholder="Customer name" style="flex:1;padding:8px 10px;border:1.5px solid #ddd;border-radius:8px;font-size:0.9rem;">
<button id="add-customer-btn" style="padding:8px 16px;border:none;border-radius:8px;background:#1a1a2e;color:#fff;font-weight:600;cursor:pointer;font-size:0.9rem;">Add</button>
</div>
</div>
<hr style="margin:20px 0;border:none;border-top:1px solid #eee;">
<div class="manage-section">
<h3 style="font-size:0.95rem;margin-bottom:10px;">Projects</h3>
<div id="project-list"></div>
<div style="display:flex;gap:8px;margin-top:8px;flex-wrap:wrap;">
<select id="new-project-customer" style="flex:1;min-width:120px;padding:8px 10px;border:1.5px solid #ddd;border-radius:8px;font-size:0.9rem;background:#fafafa;">
<option value="">Select customer</option>
</select>
<input type="text" id="new-project-name" placeholder="Project name" style="flex:1;min-width:120px;padding:8px 10px;border:1.5px solid #ddd;border-radius:8px;font-size:0.9rem;">
<button id="add-project-btn" style="padding:8px 16px;border:none;border-radius:8px;background:#1a1a2e;color:#fff;font-weight:600;cursor:pointer;font-size:0.9rem;">Add</button>
</div>
</div>
<div class="btn-row">
<button class="btn-cancel" id="manage-close">Close</button>
</div>
</div>
</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 settings = { customers: [], projects: [] };
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 projectInput = document.getElementById("project-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 getProjectById(id) {
return settings.projects.find(p => p.id === id) || null;
}
function getCustomerById(id) {
return settings.customers.find(c => c.id === id) || null;
}
function renderProjectDropdown(selectedId) {
let html = '<option value="">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 === selectedId ? " selected" : "";
html += `<option value="${p.id}"${sel}>${escapeHtml(p.name)}</option>`;
});
html += '</optgroup>';
});
projectInput.innerHTML = html;
}
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" });
let projectInfo = "";
if (r.projectId) {
const proj = getProjectById(r.projectId);
if (proj) {
const cust = getCustomerById(proj.customerId);
projectInfo = cust ? escapeHtml(cust.name) + " &rsaquo; " + escapeHtml(proj.name) : escapeHtml(proj.name);
}
}
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}${projectInfo ? " &mdash; " + projectInfo : ""}${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 || "";
renderProjectDropdown(receipt.projectId || "");
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 = "";
renderProjectDropdown("");
currentPhotoBlob = null;
currentPhotoUrl = null;
}
updatePhotoArea();
extractStatus.textContent = "";
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"><span style="position:absolute;bottom:0;left:0;right:0;background:rgba(0,0,0,0.5);color:#fff;font-size:0.75rem;text-align:center;padding:4px 0;">Tap to change photo</span>`;
} 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);
photoInput.click();
});
document.getElementById("btn-cancel").addEventListener("click", closeModal);
overlay.addEventListener("click", e => {
if (e.target === overlay) closeModal();
});
photoArea.addEventListener("click", () => photoInput.click());
// --- Receipt extraction via Claude ---
const extractStatus = document.getElementById("extract-status");
async function extractReceiptInfo(blob) {
extractStatus.textContent = "Reading receipt...";
try {
const r = await fetch("/api/extract-receipt", {
method: "POST",
headers: { "Content-Type": "image/jpeg" },
body: blob
});
if (r.ok) {
const data = await r.json();
if (data.amount != null) {
amountInput.value = data.amount;
}
if (data.date != null) {
dateInput.value = data.date;
}
}
} catch (e) {
console.error("Receipt extraction failed:", e);
} finally {
extractStatus.textContent = "";
}
}
// --- Manual crop tool ---
const cropOverlay = document.getElementById("crop-overlay");
const cropCanvas = document.getElementById("crop-canvas");
const cropCtx = cropCanvas.getContext("2d");
let cropImg = null; // Image element for the selected photo
let cropOriginalBlob = null;
let cropScale = 1; // scale factor: canvas pixels → display pixels
let cropImgX = 0, cropImgY = 0; // image offset on canvas
let cropImgW = 0, cropImgH = 0; // drawn image size on canvas
// Crop rectangle in canvas coordinates
let cropRect = { x: 0, y: 0, w: 0, h: 0 };
const HANDLE_SIZE = 18; // touch-friendly handle radius
let cropDrag = null; // { type: 'move'|'tl'|'tr'|'bl'|'br'|'t'|'b'|'l'|'r', sx, sy, origRect }
function drawCrop() {
const cw = cropCanvas.width, ch = cropCanvas.height;
cropCtx.clearRect(0, 0, cw, ch);
// Draw image
cropCtx.drawImage(cropImg, cropImgX, cropImgY, cropImgW, cropImgH);
// Dark mask outside crop rect
cropCtx.fillStyle = "rgba(0,0,0,0.55)";
// Top
cropCtx.fillRect(0, 0, cw, cropRect.y);
// Bottom
cropCtx.fillRect(0, cropRect.y + cropRect.h, cw, ch - cropRect.y - cropRect.h);
// Left
cropCtx.fillRect(0, cropRect.y, cropRect.x, cropRect.h);
// Right
cropCtx.fillRect(cropRect.x + cropRect.w, cropRect.y, cw - cropRect.x - cropRect.w, cropRect.h);
// Crop border
cropCtx.strokeStyle = "#e94560";
cropCtx.lineWidth = 2;
cropCtx.strokeRect(cropRect.x, cropRect.y, cropRect.w, cropRect.h);
// Corner handles
const hs = HANDLE_SIZE / 2;
cropCtx.fillStyle = "#e94560";
const corners = [
[cropRect.x, cropRect.y],
[cropRect.x + cropRect.w, cropRect.y],
[cropRect.x, cropRect.y + cropRect.h],
[cropRect.x + cropRect.w, cropRect.y + cropRect.h],
];
corners.forEach(([cx, cy]) => {
cropCtx.beginPath();
cropCtx.arc(cx, cy, hs, 0, Math.PI * 2);
cropCtx.fill();
});
}
function getCropPos(e) {
const rect = cropCanvas.getBoundingClientRect();
const scX = cropCanvas.width / rect.width;
const scY = cropCanvas.height / rect.height;
const touch = e.touches ? e.touches[0] : e;
return { x: (touch.clientX - rect.left) * scX, y: (touch.clientY - rect.top) * scY };
}
function hitTest(pos) {
const r = cropRect;
const hs = HANDLE_SIZE;
// Check corners first
if (Math.hypot(pos.x - r.x, pos.y - r.y) < hs) return "tl";
if (Math.hypot(pos.x - (r.x + r.w), pos.y - r.y) < hs) return "tr";
if (Math.hypot(pos.x - r.x, pos.y - (r.y + r.h)) < hs) return "bl";
if (Math.hypot(pos.x - (r.x + r.w), pos.y - (r.y + r.h)) < hs) return "br";
// Check edges (within hs distance)
if (pos.x > r.x + hs && pos.x < r.x + r.w - hs) {
if (Math.abs(pos.y - r.y) < hs) return "t";
if (Math.abs(pos.y - (r.y + r.h)) < hs) return "b";
}
if (pos.y > r.y + hs && pos.y < r.y + r.h - hs) {
if (Math.abs(pos.x - r.x) < hs) return "l";
if (Math.abs(pos.x - (r.x + r.w)) < hs) return "r";
}
// Inside rect = move
if (pos.x >= r.x && pos.x <= r.x + r.w && pos.y >= r.y && pos.y <= r.y + r.h) return "move";
return null;
}
function clampRect(r) {
const minS = 30;
if (r.w < minS) r.w = minS;
if (r.h < minS) r.h = minS;
if (r.x < cropImgX) r.x = cropImgX;
if (r.y < cropImgY) r.y = cropImgY;
if (r.x + r.w > cropImgX + cropImgW) r.x = cropImgX + cropImgW - r.w;
if (r.y + r.h > cropImgY + cropImgH) r.y = cropImgY + cropImgH - r.h;
// Ensure still within image after clamping position
if (r.x < cropImgX) { r.x = cropImgX; r.w = cropImgW; }
if (r.y < cropImgY) { r.y = cropImgY; r.h = cropImgH; }
}
function onCropPointerDown(e) {
e.preventDefault();
const pos = getCropPos(e);
const type = hitTest(pos);
if (!type) return;
cropDrag = { type, sx: pos.x, sy: pos.y, origRect: { ...cropRect } };
}
function onCropPointerMove(e) {
if (!cropDrag) return;
e.preventDefault();
const pos = getCropPos(e);
const dx = pos.x - cropDrag.sx;
const dy = pos.y - cropDrag.sy;
const o = cropDrag.origRect;
const minS = 30;
if (cropDrag.type === "move") {
cropRect.x = o.x + dx;
cropRect.y = o.y + dy;
cropRect.w = o.w;
cropRect.h = o.h;
} else {
let nx = o.x, ny = o.y, nw = o.w, nh = o.h;
if (cropDrag.type.includes("l")) { nx = o.x + dx; nw = o.w - dx; }
if (cropDrag.type.includes("r")) { nw = o.w + dx; }
if (cropDrag.type.includes("t")) { ny = o.y + dy; nh = o.h - dy; }
if (cropDrag.type.includes("b")) { nh = o.h + dy; }
// Prevent inversion
if (nw < minS) { if (cropDrag.type.includes("l")) { nx = o.x + o.w - minS; } nw = minS; }
if (nh < minS) { if (cropDrag.type.includes("t")) { ny = o.y + o.h - minS; } nh = minS; }
cropRect.x = nx; cropRect.y = ny; cropRect.w = nw; cropRect.h = nh;
}
clampRect(cropRect);
drawCrop();
}
function onCropPointerUp(e) {
cropDrag = null;
}
// Attach mouse events
cropCanvas.addEventListener("mousedown", onCropPointerDown);
window.addEventListener("mousemove", onCropPointerMove);
window.addEventListener("mouseup", onCropPointerUp);
// Attach touch events
cropCanvas.addEventListener("touchstart", onCropPointerDown, { passive: false });
window.addEventListener("touchmove", onCropPointerMove, { passive: false });
window.addEventListener("touchend", onCropPointerUp);
function openCropOverlay(blob) {
cropOriginalBlob = blob;
cropImg = new Image();
cropImg.onload = () => {
// Show overlay first so the wrapper has layout dimensions
cropOverlay.classList.add("open");
requestAnimationFrame(() => {
const wrap = cropCanvas.parentElement;
const maxW = wrap.clientWidth - 24;
const maxH = wrap.clientHeight - 24;
let dw = cropImg.width, dh = cropImg.height;
const ratio = Math.min(maxW / dw, maxH / dh, 1);
dw = Math.round(dw * ratio);
dh = Math.round(dh * ratio);
cropCanvas.width = dw;
cropCanvas.height = dh;
cropImgX = 0; cropImgY = 0;
cropImgW = dw; cropImgH = dh;
// Default crop rect: inset 10% from each edge
const inset = 0.10;
cropRect = {
x: Math.round(dw * inset),
y: Math.round(dh * inset),
w: Math.round(dw * (1 - 2 * inset)),
h: Math.round(dh * (1 - 2 * inset))
};
drawCrop();
});
};
cropImg.src = URL.createObjectURL(blob);
}
document.getElementById("crop-full").addEventListener("click", () => {
currentPhotoBlob = cropOriginalBlob;
currentPhotoUrl = URL.createObjectURL(currentPhotoBlob);
updatePhotoArea();
cropOverlay.classList.remove("open");
extractReceiptInfo(currentPhotoBlob);
});
document.getElementById("crop-use").addEventListener("click", () => {
// Extract crop region from the original image at full resolution
const scaleX = cropImg.width / cropImgW;
const scaleY = cropImg.height / cropImgH;
const sx = Math.round((cropRect.x - cropImgX) * scaleX);
const sy = Math.round((cropRect.y - cropImgY) * scaleY);
const sw = Math.round(cropRect.w * scaleX);
const sh = Math.round(cropRect.h * scaleY);
const outCanvas = document.createElement("canvas");
outCanvas.width = sw;
outCanvas.height = sh;
outCanvas.getContext("2d").drawImage(cropImg, sx, sy, sw, sh, 0, 0, sw, sh);
outCanvas.toBlob(blob => {
currentPhotoBlob = blob;
currentPhotoUrl = URL.createObjectURL(blob);
updatePhotoArea();
cropOverlay.classList.remove("open");
extractReceiptInfo(currentPhotoBlob);
}, "image/jpeg", 0.85);
});
photoInput.addEventListener("change", async e => {
const file = e.target.files[0];
if (!file) return;
const resized = await resizeImage(file, 1024);
openCropOverlay(resized);
});
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(),
projectId: projectInput.value || ""
};
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";
});
// --- Manage Customers & Projects ---
const manageOverlay = document.getElementById("manage-overlay");
const customerListEl = document.getElementById("customer-list");
const projectListEl = document.getElementById("project-list");
const newCustomerName = document.getElementById("new-customer-name");
const newProjectName = document.getElementById("new-project-name");
const newProjectCustomer = document.getElementById("new-project-customer");
async function apiLoadSettings() {
const r = await fetch("/api/settings");
if (handleUnauthorized(r)) throw new Error("Unauthorized");
if (!r.ok) throw new Error("Failed to load settings");
return r.json();
}
function renderManageModal() {
// Customer list
customerListEl.innerHTML = "";
settings.customers.forEach(c => {
const div = document.createElement("div");
div.className = "manage-item";
div.innerHTML = `<span class="item-label">${escapeHtml(c.name)}</span><button data-del-customer="${c.id}" title="Delete">&times;</button>`;
customerListEl.appendChild(div);
});
if (settings.customers.length === 0) {
customerListEl.innerHTML = '<div style="color:#aaa;font-size:0.85rem;padding:4px 0;">No customers yet</div>';
}
// Project list grouped by customer
projectListEl.innerHTML = "";
settings.customers.forEach(c => {
const projects = settings.projects.filter(p => p.customerId === c.id);
if (projects.length === 0) return;
const header = document.createElement("div");
header.style.cssText = "font-size:0.8rem;font-weight:600;color:#888;text-transform:uppercase;margin-top:8px;margin-bottom:4px;";
header.textContent = c.name;
projectListEl.appendChild(header);
projects.forEach(p => {
const div = document.createElement("div");
div.className = "manage-item";
div.innerHTML = `<span class="item-label">${escapeHtml(p.name)}</span><button data-del-project="${p.id}" title="Delete">&times;</button>`;
projectListEl.appendChild(div);
});
});
if (settings.projects.length === 0) {
projectListEl.innerHTML = '<div style="color:#aaa;font-size:0.85rem;padding:4px 0;">No projects yet</div>';
}
// Update customer selector in project add form
newProjectCustomer.innerHTML = '<option value="">Select customer</option>';
settings.customers.forEach(c => {
newProjectCustomer.innerHTML += `<option value="${c.id}">${escapeHtml(c.name)}</option>`;
});
}
document.getElementById("manage-btn").addEventListener("click", () => {
renderManageModal();
manageOverlay.classList.add("open");
});
document.getElementById("manage-close").addEventListener("click", () => {
manageOverlay.classList.remove("open");
});
manageOverlay.addEventListener("click", e => {
if (e.target === manageOverlay) manageOverlay.classList.remove("open");
});
// Add customer
document.getElementById("add-customer-btn").addEventListener("click", async () => {
const name = newCustomerName.value.trim();
if (!name) return;
const id = "c_" + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
try {
await fetch("/api/customers", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id, name })
});
settings = await apiLoadSettings();
renderManageModal();
render();
newCustomerName.value = "";
} catch (e) {
alert("Error adding customer: " + e.message);
}
});
// Add project
document.getElementById("add-project-btn").addEventListener("click", async () => {
const name = newProjectName.value.trim();
const customerId = newProjectCustomer.value;
if (!name || !customerId) return;
const id = "p_" + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
try {
await fetch("/api/projects", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id, name, customerId })
});
settings = await apiLoadSettings();
renderManageModal();
render();
newProjectName.value = "";
} catch (e) {
alert("Error adding project: " + e.message);
}
});
// Delete customer or project via delegation
manageOverlay.addEventListener("click", async e => {
const delCust = e.target.closest("[data-del-customer]");
if (delCust) {
const id = delCust.dataset.delCustomer;
if (!confirm("Delete this customer and all its projects?")) return;
try {
await fetch("/api/customers/" + id, { method: "DELETE" });
settings = await apiLoadSettings();
renderManageModal();
render();
} catch (e) {
alert("Error deleting customer: " + e.message);
}
return;
}
const delProj = e.target.closest("[data-del-project]");
if (delProj) {
const id = delProj.dataset.delProject;
if (!confirm("Delete this project?")) return;
try {
await fetch("/api/projects/" + id, { method: "DELETE" });
settings = await apiLoadSettings();
renderManageModal();
render();
} catch (e) {
alert("Error deleting project: " + e.message);
}
return;
}
});
// Init — check session, then load
async function initApp() {
try {
receipts = await apiLoadReceipts();
settings = await apiLoadSettings();
} catch (e) {
console.error("Failed to load data:", e);
return;
}
render();
}
(async () => {
const r = await fetch("/api/receipts");
if (r.status === 401) {
showLogin();
} else if (r.ok) {
hideLogin();
receipts = await r.json();
try { settings = await apiLoadSettings(); } catch (e) {}
render();
}
})();
})();
</script>
</body>
</html>