Add green-bean inventory and cupping features
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.
This commit is contained in:
@@ -0,0 +1,151 @@
|
||||
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 };
|
||||
}
|
||||
Reference in New Issue
Block a user