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;
|
max-height: 90vh;
|
||||||
border-radius: 8px;
|
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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -469,6 +493,7 @@
|
|||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
<button id="export-btn">Export Excel</button>
|
<button id="export-btn">Export Excel</button>
|
||||||
<div id="total-banner">Total: $0.00</div>
|
<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="settings-btn" title="Change Password">⚙</button>
|
||||||
<button class="header-icon-btn" id="logout-btn" title="Log Out">⏻</button>
|
<button class="header-icon-btn" id="logout-btn" title="Log Out">⏻</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -519,6 +544,13 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label>Project (optional)</label>
|
||||||
|
<select id="project-input">
|
||||||
|
<option value="">No project</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Note (optional)</label>
|
<label>Note (optional)</label>
|
||||||
<input type="text" id="note-input" placeholder="Short description">
|
<input type="text" id="note-input" placeholder="Short description">
|
||||||
@@ -547,6 +579,40 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- Login overlay -->
|
||||||
<div class="login-overlay" id="login-overlay">
|
<div class="login-overlay" id="login-overlay">
|
||||||
<div class="login-box">
|
<div class="login-box">
|
||||||
@@ -592,6 +658,7 @@
|
|||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
let receipts = [];
|
let receipts = [];
|
||||||
|
let settings = { customers: [], projects: [] };
|
||||||
let editingId = null;
|
let editingId = null;
|
||||||
let currentPhotoBlob = null; // Blob from resized image
|
let currentPhotoBlob = null; // Blob from resized image
|
||||||
let currentPhotoUrl = null; // Object URL or /api/photos/<id> URL for display
|
let currentPhotoUrl = null; // Object URL or /api/photos/<id> URL for display
|
||||||
@@ -607,6 +674,7 @@
|
|||||||
const amountInput = document.getElementById("amount-input");
|
const amountInput = document.getElementById("amount-input");
|
||||||
const categoryInput = document.getElementById("category-input");
|
const categoryInput = document.getElementById("category-input");
|
||||||
const noteInput = document.getElementById("note-input");
|
const noteInput = document.getElementById("note-input");
|
||||||
|
const projectInput = document.getElementById("project-input");
|
||||||
const btnSave = document.getElementById("btn-save");
|
const btnSave = document.getElementById("btn-save");
|
||||||
const totalBanner = document.getElementById("total-banner");
|
const totalBanner = document.getElementById("total-banner");
|
||||||
const photoPreview = document.getElementById("photo-preview");
|
const photoPreview = document.getElementById("photo-preview");
|
||||||
@@ -625,6 +693,29 @@
|
|||||||
totalBanner.textContent = "Total: " + formatMoney(total);
|
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) {
|
function photoSrc(r) {
|
||||||
// If the receipt has a photo filename, point to our proxy endpoint
|
// If the receipt has a photo filename, point to our proxy endpoint
|
||||||
if (r.photo) return "/api/photos/" + r.id;
|
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" });
|
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 = `
|
card.innerHTML = `
|
||||||
${thumbHtml}
|
${thumbHtml}
|
||||||
<div class="receipt-info">
|
<div class="receipt-info">
|
||||||
<span class="amount">${formatMoney(r.amount)}</span>
|
<span class="amount">${formatMoney(r.amount)}</span>
|
||||||
<span class="category-badge cat-${r.category}">${categoryLabel(r.category)}</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>
|
||||||
<div class="receipt-actions">
|
<div class="receipt-actions">
|
||||||
<button title="Edit" data-edit="${r.id}">✎</button>
|
<button title="Edit" data-edit="${r.id}">✎</button>
|
||||||
@@ -692,6 +792,7 @@
|
|||||||
amountInput.value = receipt.amount;
|
amountInput.value = receipt.amount;
|
||||||
categoryInput.value = receipt.category;
|
categoryInput.value = receipt.category;
|
||||||
noteInput.value = receipt.note || "";
|
noteInput.value = receipt.note || "";
|
||||||
|
renderProjectDropdown(receipt.projectId || "");
|
||||||
currentPhotoBlob = null; // no new blob yet
|
currentPhotoBlob = null; // no new blob yet
|
||||||
currentPhotoUrl = receipt.photo ? "/api/photos/" + receipt.id : null;
|
currentPhotoUrl = receipt.photo ? "/api/photos/" + receipt.id : null;
|
||||||
} else {
|
} else {
|
||||||
@@ -701,6 +802,7 @@
|
|||||||
amountInput.value = "";
|
amountInput.value = "";
|
||||||
categoryInput.value = "food";
|
categoryInput.value = "food";
|
||||||
noteInput.value = "";
|
noteInput.value = "";
|
||||||
|
renderProjectDropdown("");
|
||||||
currentPhotoBlob = null;
|
currentPhotoBlob = null;
|
||||||
currentPhotoUrl = null;
|
currentPhotoUrl = null;
|
||||||
}
|
}
|
||||||
@@ -1163,7 +1265,8 @@
|
|||||||
category: categoryInput.value,
|
category: categoryInput.value,
|
||||||
note: noteInput.value.trim(),
|
note: noteInput.value.trim(),
|
||||||
photo: (hasNewPhoto || currentPhotoUrl) ? id + ".jpg" : "",
|
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;
|
btnSave.disabled = true;
|
||||||
@@ -1234,12 +1337,154 @@
|
|||||||
window.location.href = "/api/export";
|
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
|
// Init — check session, then load
|
||||||
async function initApp() {
|
async function initApp() {
|
||||||
try {
|
try {
|
||||||
receipts = await apiLoadReceipts();
|
receipts = await apiLoadReceipts();
|
||||||
|
settings = await apiLoadSettings();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to load receipts:", e);
|
console.error("Failed to load data:", e);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
render();
|
render();
|
||||||
@@ -1252,6 +1497,7 @@
|
|||||||
} else if (r.ok) {
|
} else if (r.ok) {
|
||||||
hideLogin();
|
hideLogin();
|
||||||
receipts = await r.json();
|
receipts = await r.json();
|
||||||
|
try { settings = await apiLoadSettings(); } catch (e) {}
|
||||||
render();
|
render();
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|||||||
124
server.py
124
server.py
@@ -25,6 +25,7 @@ NC_DAV_ROOT = "" # set after login, e.g. /remote.php/dav/files/<user>/business-
|
|||||||
NC_AUTH = ()
|
NC_AUTH = ()
|
||||||
|
|
||||||
RECEIPTS_FILE = "receipts.json"
|
RECEIPTS_FILE = "receipts.json"
|
||||||
|
SETTINGS_FILE = "settings.json"
|
||||||
AUTH_FILE = "auth.json"
|
AUTH_FILE = "auth.json"
|
||||||
PHOTOS_DIR = "photos"
|
PHOTOS_DIR = "photos"
|
||||||
|
|
||||||
@@ -109,12 +110,33 @@ def save_receipts(receipts):
|
|||||||
r.raise_for_status()
|
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):
|
def build_excel(receipts):
|
||||||
"""Build an .xlsx file from the receipts list. Returns bytes."""
|
"""Build an .xlsx file from the receipts list. Returns bytes."""
|
||||||
wb = openpyxl.Workbook()
|
wb = openpyxl.Workbook()
|
||||||
ws = wb.active
|
ws = wb.active
|
||||||
ws.title = "Receipts"
|
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
|
# Styles
|
||||||
header_font = Font(bold=True, color="FFFFFF", size=11)
|
header_font = Font(bold=True, color="FFFFFF", size=11)
|
||||||
header_fill = PatternFill("solid", fgColor="1A1A2E")
|
header_fill = PatternFill("solid", fgColor="1A1A2E")
|
||||||
@@ -125,8 +147,8 @@ def build_excel(receipts):
|
|||||||
money_fmt = '#,##0.00'
|
money_fmt = '#,##0.00'
|
||||||
|
|
||||||
# Headers
|
# Headers
|
||||||
headers = ["Date", "Amount ($)", "Category", "Note"]
|
headers = ["Date", "Amount ($)", "Category", "Customer", "Project", "Note"]
|
||||||
col_widths = [14, 14, 14, 40]
|
col_widths = [14, 14, 14, 20, 20, 40]
|
||||||
for col_idx, (label, width) in enumerate(zip(headers, col_widths), 1):
|
for col_idx, (label, width) in enumerate(zip(headers, col_widths), 1):
|
||||||
cell = ws.cell(row=1, column=col_idx, value=label)
|
cell = ws.cell(row=1, column=col_idx, value=label)
|
||||||
cell.font = header_font
|
cell.font = header_font
|
||||||
@@ -143,7 +165,21 @@ def build_excel(receipts):
|
|||||||
amt_cell.border = thin_border
|
amt_cell.border = thin_border
|
||||||
ws.cell(row=row_idx, column=3,
|
ws.cell(row=row_idx, column=3,
|
||||||
value=r.get("category", "").capitalize()).border = thin_border
|
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
|
value=r.get("note", "")).border = thin_border
|
||||||
|
|
||||||
# Total row
|
# Total row
|
||||||
@@ -310,6 +346,17 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
self._send_error(500, str(e))
|
self._send_error(500, str(e))
|
||||||
return
|
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>
|
# GET /api/photos/<id>
|
||||||
m = re.fullmatch(r"/api/photos/([A-Za-z0-9_-]+)", self.path)
|
m = re.fullmatch(r"/api/photos/([A-Za-z0-9_-]+)", self.path)
|
||||||
if m:
|
if m:
|
||||||
@@ -385,6 +432,46 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
self._send_error(500, str(e))
|
self._send_error(500, str(e))
|
||||||
return
|
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
|
# POST /api/receipts — upsert a receipt
|
||||||
if self.path == "/api/receipts":
|
if self.path == "/api/receipts":
|
||||||
if not self._check_session():
|
if not self._check_session():
|
||||||
@@ -434,6 +521,37 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
|
|
||||||
# --- DELETE --------------------------------------------------------------
|
# --- DELETE --------------------------------------------------------------
|
||||||
def do_DELETE(self):
|
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)
|
m = re.fullmatch(r"/api/receipts/([A-Za-z0-9_-]+)", self.path)
|
||||||
if m:
|
if m:
|
||||||
if not self._check_session():
|
if not self._check_session():
|
||||||
|
|||||||
Reference in New Issue
Block a user