- 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>
1567 lines
48 KiB
HTML
1567 lines
48 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; }
|
|
|
|
/* 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>
|
|
</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 & Projects">☰</button>
|
|
<button class="header-icon-btn" id="settings-btn" title="Change Password">⚙</button>
|
|
<button class="header-icon-btn" id="logout-btn" title="Log Out">⏻</button>
|
|
</div>
|
|
</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="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 & Use</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Manage Customers & Projects modal -->
|
|
<div class="modal-overlay" id="manage-overlay">
|
|
<div class="modal">
|
|
<h2>Manage Customers & 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
|
|
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");
|
|
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 visible = filteredReceipts();
|
|
const total = visible.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 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) {
|
|
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());
|
|
renderFilterDropdown();
|
|
|
|
const visible = filteredReceipts();
|
|
if (visible.length === 0) {
|
|
emptyState.style.display = "";
|
|
updateTotal();
|
|
return;
|
|
}
|
|
emptyState.style.display = "none";
|
|
|
|
[...visible].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">📎</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) + " › " + 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 ? " — " + projectInfo : ""}${r.note ? " — " + escapeHtml(r.note) : ""}</div>
|
|
</div>
|
|
<div class="receipt-actions">
|
|
<button title="Edit" data-edit="${r.id}">✎</button>
|
|
<button title="Delete" data-delete="${r.id}">×</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", () => {
|
|
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 ---
|
|
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">×</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">×</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>`;
|
|
});
|
|
}
|
|
|
|
filterProject.addEventListener("change", () => {
|
|
activeFilter = filterProject.value;
|
|
render();
|
|
});
|
|
|
|
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>
|