Add customer and project management with receipt association
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
252
receipts.html
252
receipts.html
@@ -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 & 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>
|
||||
@@ -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 & 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) + " › " + 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 ? " — " + escapeHtml(r.note) : ""}</div>
|
||||
<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>
|
||||
@@ -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">×</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>`;
|
||||
});
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user