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();
}
})();

124
server.py
View File

@@ -25,6 +25,7 @@ NC_DAV_ROOT = "" # set after login, e.g. /remote.php/dav/files/<user>/business-
NC_AUTH = ()
RECEIPTS_FILE = "receipts.json"
SETTINGS_FILE = "settings.json"
AUTH_FILE = "auth.json"
PHOTOS_DIR = "photos"
@@ -109,12 +110,33 @@ def save_receipts(receipts):
r.raise_for_status()
def load_settings():
"""Read settings.json from Nextcloud. Returns dict with customers and projects."""
r = nc_get(SETTINGS_FILE)
if r.status_code == 404:
return {"customers": [], "projects": []}
r.raise_for_status()
return r.json()
def save_settings(data):
"""Write settings.json to Nextcloud."""
payload = json.dumps(data, indent=2).encode()
r = nc_put(SETTINGS_FILE, payload, "application/json")
r.raise_for_status()
def build_excel(receipts):
"""Build an .xlsx file from the receipts list. Returns bytes."""
wb = openpyxl.Workbook()
ws = wb.active
ws.title = "Receipts"
# Load settings for project/customer name lookups
settings = load_settings()
project_map = {p["id"]: p for p in settings.get("projects", [])}
customer_map = {c["id"]: c for c in settings.get("customers", [])}
# Styles
header_font = Font(bold=True, color="FFFFFF", size=11)
header_fill = PatternFill("solid", fgColor="1A1A2E")
@@ -125,8 +147,8 @@ def build_excel(receipts):
money_fmt = '#,##0.00'
# Headers
headers = ["Date", "Amount ($)", "Category", "Note"]
col_widths = [14, 14, 14, 40]
headers = ["Date", "Amount ($)", "Category", "Customer", "Project", "Note"]
col_widths = [14, 14, 14, 20, 20, 40]
for col_idx, (label, width) in enumerate(zip(headers, col_widths), 1):
cell = ws.cell(row=1, column=col_idx, value=label)
cell.font = header_font
@@ -143,7 +165,21 @@ def build_excel(receipts):
amt_cell.border = thin_border
ws.cell(row=row_idx, column=3,
value=r.get("category", "").capitalize()).border = thin_border
ws.cell(row=row_idx, column=4,
# Customer and Project columns
project_name = ""
customer_name = ""
pid = r.get("projectId", "")
if pid and pid in project_map:
proj = project_map[pid]
project_name = proj.get("name", "")
cid = proj.get("customerId", "")
if cid and cid in customer_map:
customer_name = customer_map[cid].get("name", "")
ws.cell(row=row_idx, column=4, value=customer_name).border = thin_border
ws.cell(row=row_idx, column=5, value=project_name).border = thin_border
ws.cell(row=row_idx, column=6,
value=r.get("note", "")).border = thin_border
# Total row
@@ -310,6 +346,17 @@ class Handler(BaseHTTPRequestHandler):
self._send_error(500, str(e))
return
# GET /api/settings
if self.path == "/api/settings":
if not self._check_session():
return
try:
settings = load_settings()
self._send_json(settings)
except Exception as e:
self._send_error(502, str(e))
return
# GET /api/photos/<id>
m = re.fullmatch(r"/api/photos/([A-Za-z0-9_-]+)", self.path)
if m:
@@ -385,6 +432,46 @@ class Handler(BaseHTTPRequestHandler):
self._send_error(500, str(e))
return
# POST /api/customers — upsert a customer
if self.path == "/api/customers":
if not self._check_session():
return
try:
data = json.loads(self._read_body())
settings = load_settings()
customers = settings.get("customers", [])
idx = next((i for i, c in enumerate(customers) if c["id"] == data["id"]), None)
if idx is not None:
customers[idx] = data
else:
customers.append(data)
settings["customers"] = customers
save_settings(settings)
self._send_json(data, 200)
except Exception as e:
self._send_error(500, str(e))
return
# POST /api/projects — upsert a project
if self.path == "/api/projects":
if not self._check_session():
return
try:
data = json.loads(self._read_body())
settings = load_settings()
projects = settings.get("projects", [])
idx = next((i for i, p in enumerate(projects) if p["id"] == data["id"]), None)
if idx is not None:
projects[idx] = data
else:
projects.append(data)
settings["projects"] = projects
save_settings(settings)
self._send_json(data, 200)
except Exception as e:
self._send_error(500, str(e))
return
# POST /api/receipts — upsert a receipt
if self.path == "/api/receipts":
if not self._check_session():
@@ -434,6 +521,37 @@ class Handler(BaseHTTPRequestHandler):
# --- DELETE --------------------------------------------------------------
def do_DELETE(self):
# DELETE /api/customers/<id>
m = re.fullmatch(r"/api/customers/([A-Za-z0-9_-]+)", self.path)
if m:
if not self._check_session():
return
cid = m.group(1)
try:
settings = load_settings()
settings["customers"] = [c for c in settings.get("customers", []) if c["id"] != cid]
settings["projects"] = [p for p in settings.get("projects", []) if p.get("customerId") != cid]
save_settings(settings)
self._send_json({"ok": True})
except Exception as e:
self._send_error(500, str(e))
return
# DELETE /api/projects/<id>
m = re.fullmatch(r"/api/projects/([A-Za-z0-9_-]+)", self.path)
if m:
if not self._check_session():
return
pid = m.group(1)
try:
settings = load_settings()
settings["projects"] = [p for p in settings.get("projects", []) if p["id"] != pid]
save_settings(settings)
self._send_json({"ok": True})
except Exception as e:
self._send_error(500, str(e))
return
m = re.fullmatch(r"/api/receipts/([A-Za-z0-9_-]+)", self.path)
if m:
if not self._check_session():