import { api, protectedFetch } from "./api.js"; import { initSideNav, loadNavUser } from "./nav.js"; import { showToast } from "./toast.js"; let lots = []; let showArchived = false; let editingId = null; function fmtDate(value) { return value ? new Date(value).toLocaleDateString() : "—"; } /** Disables `button` for the duration of `run()` so a slow request can't be double-submitted. */ async function guarded(button, run) { if (!button || button.disabled) return; button.disabled = true; try { await run(); } finally { button.disabled = false; } } function renderLots() { const body = document.getElementById("lots-body"); const visible = showArchived ? lots : lots.filter((l) => !l.archived); if (!visible.length) { body.innerHTML = `No lots yet.`; return; } body.replaceChildren( ...visible.map((lot) => { const tr = document.createElement("tr"); if (lot.archived) tr.className = "archived"; const lotCell = document.createElement("td"); const strong = document.createElement("strong"); strong.textContent = lot.origin; const sub = document.createElement("div"); sub.className = "muted"; sub.style.fontSize = "11.5px"; sub.textContent = [lot.variety, lot.producer].filter(Boolean).join(" · ") || "—"; lotCell.append(strong, sub); const processCell = document.createElement("td"); processCell.textContent = lot.process || "—"; const remainingCell = document.createElement("td"); const pct = lot.initialWeightG > 0 ? Math.max(0, Math.min(100, (lot.remainingWeightG / lot.initialWeightG) * 100)) : 0; const wrap = document.createElement("div"); wrap.className = "lot-remaining"; const bar = document.createElement("div"); bar.className = "blend-total-bar"; const fill = document.createElement("div"); fill.className = "blend-total-fill"; if (lot.remainingWeightG < 0) fill.classList.add("over"); fill.style.width = `${lot.remainingWeightG < 0 ? 0 : pct}%`; bar.append(fill); const label = document.createElement("span"); label.className = "blend-total-label"; label.textContent = `${Math.round(lot.remainingWeightG)} g of ${Math.round(lot.initialWeightG)} g`; wrap.append(bar, label); remainingCell.append(wrap); const purchasedCell = document.createElement("td"); purchasedCell.textContent = fmtDate(lot.purchaseDate); const actions = document.createElement("td"); actions.className = "data-table-actions"; const editBtn = document.createElement("button"); editBtn.className = "ghost-btn small"; editBtn.type = "button"; editBtn.textContent = "Edit"; editBtn.addEventListener("click", () => startEdit(lot)); const archiveBtn = document.createElement("button"); archiveBtn.className = "ghost-btn small"; archiveBtn.type = "button"; archiveBtn.textContent = lot.archived ? "Unarchive" : "Archive"; archiveBtn.addEventListener("click", (event) => guarded(event.currentTarget, async () => { try { await api(`/api/inventory/${lot.id}`, { method: "PUT", body: JSON.stringify({ archived: !lot.archived }), }); showToast(lot.archived ? "Lot unarchived." : "Lot archived."); await loadLots(); } catch (error) { showToast(error.message, "fail"); } }), ); const logBtn = document.createElement("button"); logBtn.className = "ghost-btn small"; logBtn.type = "button"; logBtn.textContent = "Log"; logBtn.addEventListener("click", () => toggleLog(lot, tr, logBtn)); const deleteBtn = document.createElement("button"); deleteBtn.className = "ghost-btn small"; deleteBtn.type = "button"; deleteBtn.textContent = "Delete"; deleteBtn.addEventListener("click", (event) => guarded(event.currentTarget, async () => { if (!confirm(`Delete the ${lot.origin} lot? This cannot be undone.`)) return; try { await api(`/api/inventory/${lot.id}`, { method: "DELETE" }); showToast("Lot deleted."); if (editingId === lot.id) cancelEdit(); await loadLots(); } catch (error) { showToast(error.message, "fail"); } }), ); actions.append(editBtn, archiveBtn, logBtn, deleteBtn); tr.append(lotCell, processCell, remainingCell, purchasedCell, actions); return tr; }), ); } async function toggleLog(lot, row, button) { const existing = row.nextElementSibling; if (existing?.classList.contains("lot-log-row")) { existing.remove(); return; } guarded(button, async () => { try { const { log } = await api(`/api/inventory/${lot.id}`); const tr = document.createElement("tr"); tr.className = "lot-log-row"; const td = document.createElement("td"); td.colSpan = 5; if (!log.length) { td.className = "empty-state"; td.textContent = "No consumption recorded yet."; } else { const ul = document.createElement("ul"); ul.style.margin = "0"; ul.style.paddingLeft = "18px"; for (const entry of log) { const li = document.createElement("li"); li.style.fontSize = "12.5px"; li.textContent = `${Math.round(entry.weightG)} g — ${entry.planTitle || "manual"} — ${fmtDate(entry.createdAt)}`; ul.append(li); } td.append(ul); } tr.append(td); row.after(tr); } catch (error) { showToast(error.message, "fail"); } }); } async function loadLots() { try { const body = await api("/api/inventory"); lots = body.lots; renderLots(); } catch (error) { document.getElementById("lots-body").innerHTML = `Could not load lots.`; } } function startEdit(lot) { editingId = lot.id; const form = document.getElementById("lot-form"); form.id.value = lot.id; form.origin.value = lot.origin; form.variety.value = lot.variety; form.process.value = lot.process; form.producer.value = lot.producer; form.purchaseDate.value = lot.purchaseDate ? lot.purchaseDate.slice(0, 10) : ""; form.initialWeightG.value = lot.initialWeightG; form.costTotal.value = lot.costTotal ?? ""; form.moisturePct.value = lot.moisturePct ?? ""; form.densityGL.value = lot.densityGL ?? ""; form.notes.value = lot.notes; document.getElementById("lot-form-title").textContent = "Edit lot"; document.getElementById("lot-form-submit").textContent = "Save changes"; document.getElementById("lot-form-cancel").classList.remove("hidden"); const note = document.getElementById("lot-remaining-note"); note.classList.remove("hidden"); note.textContent = `Remaining: ${Math.round(lot.remainingWeightG)} g — remaining weight is only changed by roasts drawing from the lot. Correct the initial weight and the remaining shifts with it.`; document.getElementById("lot-form-card").scrollIntoView({ behavior: "smooth" }); } function cancelEdit() { editingId = null; const form = document.getElementById("lot-form"); form.reset(); form.id.value = ""; document.getElementById("lot-form-title").textContent = "Add a lot"; document.getElementById("lot-form-submit").textContent = "Add lot"; document.getElementById("lot-form-cancel").classList.add("hidden"); document.getElementById("lot-remaining-note").classList.add("hidden"); } document.getElementById("lot-form-cancel").addEventListener("click", cancelEdit); document.getElementById("show-archived").addEventListener("change", (event) => { showArchived = event.target.checked; renderLots(); }); document.getElementById("lot-form").addEventListener("submit", (event) => { event.preventDefault(); const form = event.target; const data = Object.fromEntries(new FormData(form)); const submitBtn = document.getElementById("lot-form-submit"); guarded(submitBtn, async () => { try { const payload = { origin: data.origin, variety: data.variety, process: data.process, producer: data.producer, purchaseDate: data.purchaseDate || null, initialWeightG: Number(data.initialWeightG), costTotal: data.costTotal === "" ? null : Number(data.costTotal), moisturePct: data.moisturePct === "" ? null : Number(data.moisturePct), densityGL: data.densityGL === "" ? null : Number(data.densityGL), notes: data.notes, }; if (editingId) { await api(`/api/inventory/${editingId}`, { method: "PUT", body: JSON.stringify(payload), }); showToast("Lot updated."); } else { await api("/api/inventory", { method: "POST", body: JSON.stringify(payload) }); showToast("Lot added."); } cancelEdit(); await loadLots(); } catch (error) { showToast(error.message, "fail"); } }); }); document.getElementById("btn-logout").addEventListener("click", async () => { await protectedFetch("/api/auth/logout", { method: "POST" }); location.assign("/"); }); async function init() { initSideNav(); const user = await loadNavUser(); if (!user) return; await loadLots(); } init();