Test and deploy / test-and-deploy (push) Successful in 59s
Co-Authored-By: Claude Fable 5 <[email protected]>
301 lines
10 KiB
JavaScript
301 lines
10 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.name || lot.origin;
|
|
const sub = document.createElement("div");
|
|
sub.className = "muted";
|
|
sub.style.fontSize = "11.5px";
|
|
const subParts = lot.name ? [lot.origin, lot.variety, lot.producer] : [lot.variety, lot.producer];
|
|
sub.textContent = subParts.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.name || 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.name.value = lot.name || "";
|
|
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 = {
|
|
name: data.name,
|
|
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");
|
|
}
|
|
});
|
|
});
|
|
|
|
// The LLM prefill endpoint returns raw `extracted` page facts — map the green-relevant ones
|
|
// into the lot form (only overwriting what the page actually stated).
|
|
document.getElementById("lot-prefill-btn").addEventListener("click", (event) =>
|
|
guarded(event.currentTarget, async () => {
|
|
const url = document.getElementById("lot-prefill-url").value.trim();
|
|
const note = document.getElementById("lot-prefill-note");
|
|
if (!url) return;
|
|
note.classList.remove("hidden");
|
|
note.textContent = "Reading the page with the LLM…";
|
|
try {
|
|
const body = await api("/api/prefill", {
|
|
method: "POST",
|
|
body: JSON.stringify({ url }),
|
|
});
|
|
const x = body.extracted ?? {};
|
|
const form = document.getElementById("lot-form");
|
|
if (x.origin) form.origin.value = x.origin;
|
|
if (x.cultivar) form.variety.value = x.cultivar;
|
|
if (x.process) form.process.value = x.process;
|
|
if (x.producer) form.producer.value = x.producer;
|
|
if (x.moisturePct) form.moisturePct.value = x.moisturePct;
|
|
const filled = ["origin", "cultivar", "process", "producer", "moisturePct"]
|
|
.filter((k) => x[k]).length;
|
|
note.textContent = filled
|
|
? `Prefilled ${filled} field${filled === 1 ? "" : "s"} — check them, then save.`
|
|
: "The page didn't state anything usable — fill the form by hand.";
|
|
} catch (error) {
|
|
note.textContent = `Prefill failed: ${error.message}`;
|
|
}
|
|
}),
|
|
);
|
|
|
|
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();
|