Add customer and project management with receipt association

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-31 23:55:05 -06:00
parent 6e93b7f672
commit be2ac4eaf7
2 changed files with 370 additions and 6 deletions

View File

@@ -460,6 +460,30 @@
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>
@@ -469,6 +493,7 @@
<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>
@@ -519,6 +544,13 @@
</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">
@@ -547,6 +579,40 @@
</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">
@@ -592,6 +658,7 @@
<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
@@ -607,6 +674,7 @@
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");
@@ -625,6 +693,29 @@
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;
@@ -655,12 +746,21 @@
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}${r.note ? " &mdash; " + escapeHtml(r.note) : ""}</div>
<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>
@@ -692,6 +792,7 @@
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 {
@@ -701,6 +802,7 @@
amountInput.value = "";
categoryInput.value = "food";
noteInput.value = "";
renderProjectDropdown("");
currentPhotoBlob = null;
currentPhotoUrl = null;
}
@@ -1163,7 +1265,8 @@
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()
date: dateInput.value ? new Date(dateInput.value + "T12:00:00").toISOString() : new Date().toISOString(),
projectId: projectInput.value || ""
};
btnSave.disabled = true;
@@ -1234,12 +1337,154 @@
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 receipts:", e);
console.error("Failed to load data:", e);
return;
}
render();
@@ -1252,6 +1497,7 @@
} else if (r.ok) {
hideLogin();
receipts = await r.json();
try { settings = await apiLoadSettings(); } catch (e) {}
render();
}
})();