Files
roast_command_center/public/js/inventory.js
T

266 lines
8.7 KiB
JavaScript

import { api, protectedFetch } from "./api.js?v=__ASSET_VERSION__";
import { initSideNav, loadNavUser } from "./nav.js?v=__ASSET_VERSION__";
import { showToast } from "./toast.js?v=__ASSET_VERSION__";
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 = `<tr><td colspan="5" class="empty-state">No lots yet.</td></tr>`;
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 =
`<tr><td colspan="5" class="empty-state">Could not load lots.</td></tr>`;
}
}
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();