Ports inventory management and SCA-style cupping scoring from hope_roaster: lot tracking with audit-logged consumption, cupping sessions with server-authoritative scoring and a live radar chart, and links from the planner (draw-from-lot, open-cupping-session). Also fixes the Blend/Single-origin toggle layout, replaces tooltips with an in-context "why" teaching layer, and stages the planner UI into pre-roast vs. post-roast phases.
152 lines
4.9 KiB
JavaScript
152 lines
4.9 KiB
JavaScript
import { api, protectedFetch } from "./api.js";
|
|
import { showToast } from "./toast.js";
|
|
|
|
let lots = [];
|
|
|
|
function findLot(id) {
|
|
return lots.find((l) => l.id === id);
|
|
}
|
|
|
|
/** Wires the "From inventory lot" picker (in The Coffee) and the "Draw from lot" action
|
|
* (in Roast Log — Plan vs. Actual). `getRemotePlanId` is a function since the planner's
|
|
* remotePlanId is module-local and can change after a sync. */
|
|
export function initLotPicker({ state, recompute, flushCurrentPlan, getRemotePlanId }) {
|
|
const select = document.getElementById("lot-select");
|
|
const note = document.getElementById("lot-picker-note");
|
|
const consumeRow = document.getElementById("inventory-consume");
|
|
const consumeLabel = document.getElementById("inventory-consume-label");
|
|
const consumeBtn = document.getElementById("btn-consume");
|
|
|
|
function renderOptions() {
|
|
const current = state.plan.inventory.lotId;
|
|
select.replaceChildren(
|
|
Object.assign(document.createElement("option"), { value: "", textContent: "— none —" }),
|
|
...lots
|
|
.filter((l) => !l.archived)
|
|
.map((l) =>
|
|
Object.assign(document.createElement("option"), {
|
|
value: l.id,
|
|
textContent: `${l.origin}${l.variety ? ` — ${l.variety}` : ""} (${Math.round(l.remainingWeightG)} g remaining)`,
|
|
}),
|
|
),
|
|
);
|
|
if (current && !findLot(current)) {
|
|
// The plan references a lot that's now archived/deleted — keep it selectable so the
|
|
// stored value still displays instead of silently reverting to "— none —".
|
|
select.append(
|
|
Object.assign(document.createElement("option"), {
|
|
value: current,
|
|
textContent: state.plan.inventory.lotLabel || "Unavailable lot",
|
|
disabled: true,
|
|
}),
|
|
);
|
|
}
|
|
select.value = current || "";
|
|
}
|
|
|
|
async function loadLots() {
|
|
try {
|
|
const body = await api("/api/inventory");
|
|
lots = body.lots;
|
|
} catch {
|
|
lots = [];
|
|
}
|
|
renderOptions();
|
|
updateConsumeRow();
|
|
}
|
|
|
|
function updateConsumeRow() {
|
|
const lotId = state.plan.inventory.lotId;
|
|
if (!lotId) {
|
|
consumeRow.classList.add("hidden");
|
|
return;
|
|
}
|
|
consumeRow.classList.remove("hidden");
|
|
const consumed = state.plan.inventory.consumed;
|
|
if (consumed) {
|
|
consumeLabel.textContent = `✓ ${Math.round(consumed.weightG)} g drawn from ${consumed.lotLabel} · ${new Date(consumed.atIso).toLocaleDateString()}`;
|
|
consumeBtn.classList.add("hidden");
|
|
return;
|
|
}
|
|
consumeBtn.classList.remove("hidden");
|
|
const weightG = Number(state.plan.fields["0.4"]);
|
|
const lot = findLot(lotId);
|
|
const lotLabel = lot ? lot.origin : state.plan.inventory.lotLabel || "this lot";
|
|
if (Number.isFinite(weightG) && weightG > 0) {
|
|
consumeLabel.textContent = `Charging will draw ${weightG} g from ${lotLabel}.`;
|
|
consumeBtn.disabled = false;
|
|
consumeBtn.removeAttribute("title");
|
|
} else {
|
|
consumeLabel.textContent = `Set "Green in" (field 0.4) to draw weight from ${lotLabel}.`;
|
|
consumeBtn.disabled = true;
|
|
consumeBtn.title = "Enter a Green in weight on The Coffee first";
|
|
}
|
|
}
|
|
|
|
select.addEventListener("change", () => {
|
|
const lotId = select.value;
|
|
state.plan.inventory.lotId = lotId;
|
|
const lot = findLot(lotId);
|
|
state.plan.inventory.lotLabel = lot
|
|
? `${lot.origin}${lot.variety ? ` — ${lot.variety}` : ""}`
|
|
: "";
|
|
if (lot) {
|
|
note.textContent = `${Math.round(lot.remainingWeightG)} g remaining in this lot.`;
|
|
const nameInput = document.querySelector('[name="0.1"]');
|
|
if (nameInput && !nameInput.value) {
|
|
nameInput.value = lot.origin;
|
|
state.plan.fields["0.1"] = lot.origin;
|
|
}
|
|
} else {
|
|
note.textContent =
|
|
"Optional — link a lot and the Roast Log can draw the green weight down when you charge.";
|
|
}
|
|
updateConsumeRow();
|
|
recompute();
|
|
});
|
|
|
|
consumeBtn.addEventListener("click", async () => {
|
|
consumeBtn.disabled = true;
|
|
try {
|
|
if (!(await flushCurrentPlan())) {
|
|
showToast("Could not sync the plan. Try again.", "fail");
|
|
return;
|
|
}
|
|
const roastPlanId = getRemotePlanId();
|
|
if (!roastPlanId) {
|
|
showToast("Sync the plan first, then draw from the lot.", "fail");
|
|
return;
|
|
}
|
|
const weightG = Number(state.plan.fields["0.4"]);
|
|
const response = await protectedFetch(
|
|
`/api/inventory/${state.plan.inventory.lotId}/consume`,
|
|
{
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({ weightG, roastPlanId }),
|
|
},
|
|
);
|
|
const body = await response.json().catch(() => ({}));
|
|
if (!response.ok && body.code !== "already_consumed") {
|
|
showToast(body.error || "Could not draw from the lot.", "fail");
|
|
return;
|
|
}
|
|
state.plan.inventory.consumed = {
|
|
lotId: state.plan.inventory.lotId,
|
|
lotLabel: state.plan.inventory.lotLabel,
|
|
weightG,
|
|
atIso: new Date().toISOString(),
|
|
};
|
|
updateConsumeRow();
|
|
recompute();
|
|
showToast(`${weightG} g drawn from lot.`);
|
|
await loadLots();
|
|
} finally {
|
|
consumeBtn.disabled = false;
|
|
}
|
|
});
|
|
|
|
loadLots();
|
|
return { updateConsumeRow, renderOptions };
|
|
}
|