Phase 4: Warehouse screen, pull table, shorts, MTRs and the upload pipeline
ci / frontend (push) Successful in 30s
ci / server (push) Successful in 4m30s
ci / image (push) Successful in 35s
ci / e2e (push) Successful in 1m40s

The Warehouse home with the numbers, the Open shorts table, the find bar and the five-column task board
(waiting, being pulled, on hold, pulled, released in the last 3 days) with buttons, hold reasons and drag.
Every role sees the warehouse stage on a packet as the WH pill and the line under it. The pull table on the
Materials tab: Pulled with auto-start, issued, location, heat with Enter to the next line, shorts with
receipts, N/A, MTR per line with a viewer. Send back to Clerical with a reason. Release to supervisor stays
blocked until every line is pulled or short and every pulled line has a heat.

Server: the heats_recorded precondition on WH_RELEASE, receive by custody, server-validated hold and short
reasons, the old packet history texts, GET /api/warehouse/board, cards with the warehouse stage and pull
counts. Uploads typed by content per kind (drawing, mtr, document, bom with XLSX and CSV), limits per kind,
optional ClamAV behind the clamav compose profile, a storage quota alert on the new notification_events
table (migration 0005) shown on the Admin Storage tab.

Tests: every warehouse action as every role, the release gate, the short-receive exception, the heat
pattern, upload sniffing and limits, the ClamAV path with a fake scanner, the quota alert; frontend board
and pull table tests; Playwright scenarios for the board and the pull flow. Verified on docker-1 in the CI
stack: 3557 server tests, 106 frontend tests, 17 Playwright scenarios. Docs updated; D5 closed.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
2026-10-16 17:13:31 -06:00
co-authored by Claude Fable 5.1
parent 591fe90076
commit 39a4eb5213
65 changed files with 6213 additions and 130 deletions
+91
View File
@@ -0,0 +1,91 @@
import { useState, type FormEvent } from "react";
import { Sheet } from "../components/Sheet";
import { HOLD_NOTE_MAX, WH_REASONS } from "./stages";
/** Put a packet on hold: one of the hold reasons and a note. "Other" needs the note. */
export function HoldSheet({
sub,
onHold,
onCancel,
}: {
sub?: string | undefined;
onHold: (reason: string, note: string) => void;
onCancel: () => void;
}) {
const [reason, setReason] = useState<string>(WH_REASONS[0]);
const [note, setNote] = useState("");
const [error, setError] = useState<string | null>(null);
const submit = (e: FormEvent) => {
e.preventDefault();
const n = note.trim().slice(0, HOLD_NOTE_MAX);
if (reason === "Other" && !n) {
setError("Say what it's waiting on.");
return;
}
onHold(reason, n);
};
return (
<Sheet title="Put on hold" sub={sub} onClose={onCancel}>
<div className="small">Everyone who follows this packet sees it's on hold and why.</div>
<form className="stack" onSubmit={submit} noValidate>
<div className="chips" role="radiogroup" aria-label="Reason">
{WH_REASONS.map((r) => (
<label key={r} className="chip">
<input type="radio" name="wh_reason" value={r} checked={reason === r} onChange={() => setReason(r)} /> {r}
</label>
))}
</div>
<label className="f">
Note (what you're waiting on, PO no., who you asked)
<input className="in" value={note} maxLength={HOLD_NOTE_MAX} onChange={(e) => setNote(e.target.value)} />
</label>
{error ? (
<div className="callout warn flush" role="alert">
{error}
</div>
) : null}
<div className="sheet-acts start">
<button className="btn pink" type="submit">
Put on hold
</button>
<button className="btn ghost" type="button" onClick={onCancel}>
Cancel
</button>
</div>
</form>
</Sheet>
);
}
/** A yes or no question in a sheet. */
export function ConfirmSheet({
title,
sub,
text,
okLabel,
onOk,
onCancel,
}: {
title: string;
sub?: string | undefined;
text: string;
okLabel: string;
onOk: () => void;
onCancel: () => void;
}) {
return (
<Sheet title={title} sub={sub} onClose={onCancel}>
<div>{text}</div>
<div className="sheet-acts start">
<button className="btn pri" type="button" onClick={onOk}>
{okLabel}
</button>
<button className="btn ghost" type="button" onClick={onCancel}>
Cancel
</button>
</div>
</Sheet>
);
}
+29
View File
@@ -0,0 +1,29 @@
import { assetsApi, blobUrl } from "../api/assets";
import { Sheet } from "../components/Sheet";
import { useLoad } from "../lib/useLoad";
/** Shows an MTR file: a PDF in a frame, an image as itself, and a link to open it in a new tab. */
export function MtrViewer({ assetId, title, onClose }: { assetId: string; title: string; onClose: () => void }) {
const [meta] = useLoad((s) => assetsApi.get(assetId, s), assetId);
const url = blobUrl(assetId);
const mime = meta.state === "ok" ? meta.data.mime : null;
return (
<Sheet title={title} onClose={onClose} wide>
<div className="sheet-acts start">
<a className="btn sm" href={url} target="_blank" rel="noopener noreferrer">
Open in new tab
</a>
<span className="grow" />
<button className="btn sm ghost" type="button" onClick={onClose}>
Close
</button>
</div>
{meta.state === "loading" ? <div className="empty slim">Loading</div> : null}
{mime?.startsWith("image/") ? (
<img className="mtrimg" src={url} alt={title} />
) : meta.state !== "loading" ? (
<iframe className="mtrframe" src={url} title={title} />
) : null}
</Sheet>
);
}
+278
View File
@@ -0,0 +1,278 @@
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import type { BomLine, PacketRead } from "../api/types";
import { DEPARTMENTS, makeMe, renderApp } from "../test/auth";
import { callsTo, mockFetch } from "../test/fetchMock";
import { makePacket } from "../test/packets";
const WADE = makeMe({ roles: ["warehouse"], screens: ["warehouse"], user: { sam_account: "wh.wade", display_name: "Wade Warehouse" } });
const who = { id: "u-wade", display_name: "Wade Warehouse" };
const PULL_BLOCK = "Every line must be pulled or marked short before release.";
function line(item: number, over: Partial<BomLine> = {}): BomLine {
return {
id: 100 + item,
item,
qty: 2,
part_id: `P-${item}`,
description: `Part ${item}`,
dwg_ref: null,
length: "10 ft",
material: "A36",
heat: null,
na: false,
issued: null,
loc: null,
pulled: false,
mtr_asset_id: null,
short: null,
row_version: 4,
...over,
};
}
function whPacket(over: Partial<PacketRead> = {}): PacketRead {
return makePacket({
status: "warehouse",
holder: "Warehouse",
heat_map: true,
wh: { stage: "incoming", by: null, at: "2026-10-16T08:00:00Z", reason: null, note: null, auto: false },
bom: [line(1), line(2), line(3)],
can: ["set_wh_stage", "edit_bom", "edit_heats", "wh_send_back", "wh_receive_short"],
blocked: { wh_release: [PULL_BLOCK] },
...over,
});
}
function base(packet: PacketRead, me = WADE) {
return {
"/api/auth/me": { body: me },
"/api/departments": { body: DEPARTMENTS },
"/api/packets/1": { body: packet },
"/api/packets/1/log": { body: [] },
};
}
function headerOf(fetch: ReturnType<typeof mockFetch>, method: string, path: string, name: string): Array<string | undefined> {
return fetch.mock.calls
.filter(([input, init]) => String(input).split("?")[0] === path && (init?.method ?? "GET") === method)
.map(([, init]) => (init?.headers as Record<string, string> | undefined)?.[name]);
}
describe("Pull table and the warehouse packet page", () => {
it("the first Pulled tick sends PATCH with If-Match and pulled true, and the stage follows the answer", async () => {
const after = whPacket({
version: 4,
bom: [line(1, { pulled: true, issued: 2, row_version: 5 }), line(2), line(3)],
wh: { stage: "pulling", by: who, at: "2026-10-16T08:01:00Z", reason: null, note: null, auto: true },
});
const fetch = mockFetch({ ...base(whPacket()), "PATCH /api/packets/1/bom/1": { body: after } });
renderApp("/packets/1");
const box = await screen.findByRole("region", { name: "Warehouse" });
expect(within(box).getByText("WH · waiting")).toBeInTheDocument();
fireEvent.click(screen.getByLabelText("Line 1 pulled"));
await waitFor(() => expect(within(box).getByText("WH · being pulled")).toBeInTheDocument());
expect(callsTo(fetch, "PATCH", "/api/packets/1/bom/1")).toEqual([{ pulled: true }]);
expect(headerOf(fetch, "PATCH", "/api/packets/1/bom/1", "If-Match")).toEqual(["4"]);
expect(screen.getByLabelText("Line 1 pulled")).toBeChecked();
expect(within(box).getByText(/^Being pulled by Wade Warehouse/)).toBeInTheDocument();
});
it("Enter in a heat input saves it and moves to the next line's heat", async () => {
const start = whPacket({ bom: [line(1, { pulled: true, issued: 2 }), line(2), line(3)] });
const after = whPacket({ version: 4, bom: [line(1, { pulled: true, issued: 2, heat: "HT-1234/7A", row_version: 5 }), line(2), line(3)] });
const fetch = mockFetch({ ...base(start), "PATCH /api/packets/1/bom/1": { body: after } });
renderApp("/packets/1");
const heat1 = await screen.findByLabelText("Line 1 heat");
expect(heat1).toHaveClass("need");
heat1.focus();
fireEvent.change(heat1, { target: { value: "HT-1234/7A" } });
fireEvent.keyDown(heat1, { key: "Enter" });
expect(screen.getByLabelText("Line 2 heat")).toHaveFocus();
await waitFor(() => expect(screen.getByLabelText("Line 1 heat")).not.toHaveClass("need"));
expect(callsTo(fetch, "PATCH", "/api/packets/1/bom/1")).toEqual([{ heat: "HT-1234/7A" }]);
expect(headerOf(fetch, "PATCH", "/api/packets/1/bom/1", "If-Match")).toEqual(["4"]);
});
it("a bad heat shows the server's text under the row and keeps the value", async () => {
const msg = 'Heat number "<img src=x>" isn\'t valid: letters, numbers, space . / # _ - only, up to 40 characters.';
mockFetch({ ...base(whPacket()), "PATCH /api/packets/1/bom/2": { status: 422, body: { detail: msg, code: "invalid_heat" } } });
renderApp("/packets/1");
const heat2 = await screen.findByLabelText("Line 2 heat");
heat2.focus();
fireEvent.change(heat2, { target: { value: "<img src=x>" } });
fireEvent.keyDown(heat2, { key: "Enter" });
expect(await screen.findByText(`Item 2: ${msg}`)).toBeInTheDocument();
expect(screen.getByLabelText("Line 2 heat")).toHaveValue("<img src=x>");
});
it("a row conflict reads the line again and retries once; a second conflict reloads and says so", async () => {
let gets = 0;
const fetch = mockFetch({
...base(whPacket()),
"/api/packets/1": () => {
gets++;
return { body: gets === 1 ? whPacket() : whPacket({ version: 5, bom: [line(1, { loc: "R-9", row_version: 6 }), line(2), line(3)] }) };
},
"PATCH /api/packets/1/bom/1": { status: 409, body: { detail: "Changed.", code: "row_conflict" } },
});
renderApp("/packets/1");
const loc = await screen.findByLabelText("Line 1 location");
fireEvent.change(loc, { target: { value: "A-1" } });
fireEvent.blur(loc);
expect(await screen.findByText("Item 1: Someone else changed this line. Reloaded.")).toBeInTheDocument();
// The first try, then once more with the row version read back (6)
expect(callsTo(fetch, "PATCH", "/api/packets/1/bom/1")).toEqual([{ loc: "A-1" }, { loc: "A-1" }]);
const ifMatch = fetch.mock.calls
.filter((c) => String(c[0]).endsWith("/api/packets/1/bom/1"))
.map((c) => new Headers((c[1] as RequestInit).headers).get("If-Match"));
expect(ifMatch).toEqual(["4", "6"]);
await waitFor(() => expect(screen.getByLabelText("Line 1 location")).toHaveValue("R-9"));
});
it("records a short through the short sheet", async () => {
const fetch = mockFetch({ ...base(whPacket()), "POST /api/packets/1/bom/2/short": { body: whPacket({ version: 4 }) } });
renderApp("/packets/1");
const row = await screen.findByRole("row", { name: /^Line 2/ });
fireEvent.click(within(row).getByRole("button", { name: "Short" }));
const sheet = await screen.findByRole("dialog", { name: "Record short" });
fireEvent.change(within(sheet).getByLabelText("Qty short"), { target: { value: "1" } });
fireEvent.change(within(sheet).getByLabelText("PO / requisition"), { target: { value: "PO 4471" } });
fireEvent.click(within(sheet).getByRole("button", { name: "Save short" }));
await waitFor(() =>
expect(callsTo(fetch, "POST", "/api/packets/1/bom/2/short")).toEqual([
{ qty: 1, reason: "Not in stock", ref: "PO 4471", eta: null, note: null, expect_open: false },
]),
);
await waitFor(() => expect(screen.queryByRole("dialog", { name: "Record short" })).not.toBeInTheDocument());
});
it("Release is disabled with the blocked text, and enabled when can has it", async () => {
mockFetch(base(whPacket()));
renderApp("/packets/1");
const rel = await screen.findByRole("button", { name: "Release to supervisor" });
expect(rel).toBeDisabled();
expect(screen.getByText(PULL_BLOCK)).toHaveClass("why");
expect(screen.getByText("3 lines not pulled.")).toBeInTheDocument();
});
it("Release posts wh_release when can has it, with the short count on the label", async () => {
const ready = whPacket({
open_shorts: 1,
wh: { stage: "pulled", by: who, at: "2026-10-16T09:00:00Z", reason: null, note: null, auto: false },
bom: [
line(1, { pulled: true, issued: 2, heat: "H1" }),
line(2, { pulled: true, issued: 1, heat: "H2", short: { id: 7, qty: 1, qty_open: 1, reason: "Not in stock", ref: null, eta: null, note: null, opened_at: null } }),
line(3, { pulled: true, issued: 2, heat: "H3" }),
],
can: ["set_wh_stage", "edit_bom", "wh_send_back", "wh_release"],
blocked: {},
});
const fetch = mockFetch({
...base(ready),
"POST /api/packets/1/transitions": { body: makePacket({ status: "ready", holder: "Weld Parts", can: [], blocked: {} }) },
});
renderApp("/packets/1");
expect(await screen.findByText("Ready to release with 1 short line.")).toBeInTheDocument();
const rel = screen.getByRole("button", { name: "Release to supervisor (1 short)" });
expect(rel).toBeEnabled();
fireEvent.click(rel);
await waitFor(() => expect(screen.getByTestId("status-pill")).toHaveTextContent("Ready to assign"));
expect(callsTo(fetch, "POST", "/api/packets/1/transitions")).toEqual([{ action: "wh_release", expect: { status: "warehouse", dept: "03" } }]);
});
it("Send back to Clerical asks for a reason", async () => {
const fetch = mockFetch({
...base(whPacket()),
"POST /api/packets/1/transitions": { body: makePacket({ status: "draft", return_note: "Wrong BOM revision on line 2" }) },
});
renderApp("/packets/1");
fireEvent.click(await screen.findByRole("button", { name: "Send back to Clerical" }));
const sheet = await screen.findByRole("dialog", { name: "Send back to Clerical" });
fireEvent.change(within(sheet).getByLabelText("Reason"), { target: { value: "Wrong BOM revision on line 2" } });
fireEvent.click(within(sheet).getByRole("button", { name: "Send back to Clerical" }));
await waitFor(() =>
expect(callsTo(fetch, "POST", "/api/packets/1/transitions")).toEqual([
{ action: "wh_send_back", expect: { status: "warehouse", dept: "03" }, reason: "Wrong BOM revision on line 2" },
]),
);
});
it("another role sees the pill and the line but no warehouse buttons or pull table", async () => {
const wes = makeMe({ roles: ["shop:03:supervisor"], screens: ["supervisor"] });
const held = whPacket({
wh: { stage: "hold", by: who, at: "2026-10-16T08:00:00Z", reason: "Waiting on purchasing / PO", note: "PO 4471 due Fri", auto: false },
can: [],
blocked: {},
});
mockFetch(base(held, wes));
renderApp("/packets/1");
const box = await screen.findByRole("region", { name: "Warehouse" });
expect(within(box).getByText("WH · on hold")).toBeInTheDocument();
expect(within(box).getByText(/^On hold: Waiting on purchasing \/ PO · PO 4471 due Fri/)).toBeInTheDocument();
expect(within(box).queryByRole("button")).not.toBeInTheDocument();
expect(screen.queryByLabelText("Line 1 pulled")).not.toBeInTheDocument();
});
it("Receive appears for the holder of a packet with a short and posts the receipt", async () => {
const lou = makeMe({ roles: ["shop:03:lead"], screens: ["supervisor"] });
const ready = makePacket({
status: "ready",
holder: "Weld Parts",
open_shorts: 1,
bom: [
line(1, { pulled: true, issued: 2, heat: "H1", mtr_asset_id: "mtr-1" }),
line(2, { pulled: true, issued: 1, heat: "H2", short: { id: 7, qty: 1, qty_open: 1, reason: "Not in stock", ref: "PO 4471", eta: null, note: null, opened_at: null } }),
],
can: ["edit_bom", "edit_heats"],
blocked: {},
});
const fetch = mockFetch({ ...base(ready, lou), "POST /api/packets/1/bom/2/short/receipts": { body: ready } });
renderApp("/packets/1");
const recv = await screen.findByRole("button", { name: "Receive" });
expect(screen.getByRole("button", { name: "MTR" })).toBeInTheDocument();
expect(screen.queryByLabelText("Line 1 pulled")).not.toBeInTheDocument();
fireEvent.click(recv);
const sheet = await screen.findByRole("dialog", { name: "Receive short part" });
fireEvent.change(within(sheet).getByLabelText("Heat"), { target: { value: "HT-77" } });
fireEvent.click(within(sheet).getByRole("button", { name: "Receive" }));
await waitFor(() => expect(callsTo(fetch, "POST", "/api/packets/1/bom/2/short/receipts")).toEqual([{ qty: 1, heat: "HT-77", note: null }]));
});
it("no Receive for a role that cannot receive", async () => {
const quinn = makeMe({ roles: ["qc"], screens: ["qc"] });
const ready = makePacket({
status: "ready",
holder: "Weld Parts",
bom: [line(2, { pulled: true, issued: 1, short: { id: 7, qty: 1, qty_open: 1, reason: "Not in stock", ref: null, eta: null, note: null, opened_at: null } })],
can: [],
blocked: {},
});
mockFetch(base(ready, quinn));
renderApp("/packets/1");
expect(await screen.findByText("Short 1")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Receive" })).not.toBeInTheDocument();
});
it("uploads an MTR with purpose mtr and patches the line", async () => {
const after = whPacket({ version: 4, bom: [line(1, { mtr_asset_id: "a-mtr", row_version: 5 }), line(2), line(3)] });
const fetch = mockFetch({
...base(whPacket()),
"POST /api/assets": { status: 201, body: { id: "a-mtr", mime: "image/png", size: 9, sha256: "00", pages: null, original_name: "m.png", url: "/_blob/a-mtr" } },
"PATCH /api/packets/1/bom/1": { body: after },
"/api/assets/a-mtr": { body: { id: "a-mtr", mime: "image/png", size: 9, sha256: "00", pages: null, original_name: "m.png", url: "/_blob/a-mtr" } },
});
renderApp("/packets/1");
const input = await screen.findByLabelText("Line 1 MTR file");
fireEvent.change(input, { target: { files: [new File(["\x89PNG"], "m.png", { type: "image/png" })] } });
const view = await screen.findByRole("button", { name: "MTR" });
const up = fetch.mock.calls.find(([u, init]) => u === "/api/assets" && init?.method === "POST");
expect((up?.[1]?.body as FormData).get("purpose")).toBe("mtr");
expect(callsTo(fetch, "PATCH", "/api/packets/1/bom/1")).toEqual([{ mtr_asset_id: "a-mtr" }]);
expect(screen.getByText("Replace")).toBeInTheDocument();
fireEvent.click(view);
const sheet = await screen.findByRole("dialog", { name: /^MTR · item 1/ });
expect(within(sheet).getByRole("link", { name: "Open in new tab" })).toHaveAttribute("href", "/_blob/a-mtr");
expect(await within(sheet).findByRole("img")).toHaveAttribute("src", "/_blob/a-mtr");
});
});
+376
View File
@@ -0,0 +1,376 @@
import { useEffect, useRef, useState, type KeyboardEvent } from "react";
import { assetsApi } from "../api/assets";
import { errorCode } from "../api/client";
import { packetsApi } from "../api/packets";
import type { BomLine, BomPatch, Guarded, PacketRead } from "../api/types";
import { errorText, fmtDay } from "../lib/format";
import { MtrViewer } from "./MtrViewer";
import { ShortSheet } from "./ShortSheet";
import { isSub, issuedLess, needsHeat, num } from "./stages";
export type WriteResult = { ok: true } | { ok: false; err?: unknown };
/** Runs a packet call through the guard and takes the packet answer. Errors come back, not thrown. */
export type Write = (fn: (g: Guarded) => Promise<PacketRead | null | undefined>) => Promise<WriteResult>;
export const ROW_CONFLICT = "Someone else changed this line. Reloaded.";
const show = (v: unknown) => (v === null || v === undefined || v === "" ? "" : String(v));
/** The text for a failed line write. A row conflict reloads the packet first. */
async function failText(err: unknown, reload: () => Promise<void>): Promise<string> {
const code = errorCode(err);
if (code === "row_conflict") {
await reload();
return ROW_CONFLICT;
}
if (code === "short_conflict" || code === "stale_step") await reload();
return errorText(err);
}
/** The old shortChip: the open quantity, the reason, the PO and the ETA. */
export function ShortChip({ s }: { s: NonNullable<BomLine["short"]> }) {
const late = !!s.eta && new Date(`${s.eta.slice(0, 10)}T23:59:59`).getTime() < Date.now();
return (
<span className="shortchip">
<span className="pill st-review">Short {String(s.qty_open)}</span>{" "}
<span className="small">
{s.reason ?? ""}
{s.ref ? (
<>
{" "}
· <span className="mono">{s.ref}</span>
</>
) : null}
{s.eta ? (
<>
{" "}
· ETA <b className={late ? "mono due over" : "mono"}>{fmtDay(s.eta)}</b>
</>
) : null}
</span>
</span>
);
}
function heatPlaceholder(b: BomLine): string {
if (isSub(b)) return "Sub-assy (heat on its own packet)";
if (b.na) return "N/A";
if (b.short) return num(b.issued) ? `Heat for the ${show(b.issued)} pulled` : "Nothing issued";
return "Scan / type heat";
}
interface RowProps {
b: BomLine;
busy: boolean;
patch: (b: BomLine, body: BomPatch) => Promise<string | null>;
upload: (b: BomLine, file: File) => Promise<string | null>;
onShort: (b: BomLine) => void;
onView: (b: BomLine) => void;
}
/** One line of the pull. Text fields save on blur or Enter when changed; Enter in Heat moves to the next heat. */
function PullRow({ b, busy, patch, upload, onShort, onView }: RowProps) {
const n = b.item;
const [issued, setIssued] = useState(show(b.issued));
const [loc, setLoc] = useState(show(b.loc));
const [heat, setHeat] = useState(show(b.heat));
// The boxes flip at once and the server's answer settles them (a tick that fails flips back).
const [pulled, setPulled] = useState(!!b.pulled);
const [na, setNa] = useState(!!b.na);
const [error, setError] = useState<string | null>(null);
const sent = useRef({ issued: show(b.issued), loc: show(b.loc), heat: show(b.heat) });
const fileRef = useRef<HTMLInputElement>(null);
// A new row version is the server's word on the line: take its values, but keep a field the person is still
// typing in (its text differs from what was last synced), so a tick's answer never wipes a half-typed heat.
useEffect(() => {
const prev = sent.current;
const next = { issued: show(b.issued), loc: show(b.loc), heat: show(b.heat) };
sent.current = next;
setIssued((cur) => (cur === prev.issued ? next.issued : cur));
setLoc((cur) => (cur === prev.loc ? next.loc : cur));
setHeat((cur) => (cur === prev.heat ? next.heat : cur));
setPulled(!!b.pulled);
setNa(!!b.na);
}, [b.row_version, b.issued, b.loc, b.heat, b.pulled, b.na]);
// A commit while another write of this packet is in flight waits for it, so it carries the fresh row version.
const pending = useRef<Set<"issued" | "loc" | "heat">>(new Set());
useEffect(() => {
if (busy || !pending.current.size) return;
const fields = [...pending.current];
pending.current.clear();
for (const f of fields) commit(f);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [busy]);
const run = async (body: BomPatch) => {
setError(null);
const err = await patch(b, body);
if (err) {
setError(err);
setPulled(!!b.pulled);
setNa(!!b.na);
}
};
const tick = (field: "pulled" | "na", value: boolean) => {
(field === "pulled" ? setPulled : setNa)(value);
void run({ [field]: value });
};
const commit = (field: "issued" | "loc" | "heat") => {
if (busy) {
pending.current.add(field);
return;
}
const value = field === "issued" ? issued.trim() : field === "loc" ? loc.trim() : heat.trim();
if (value === sent.current[field]) return;
sent.current = { ...sent.current, [field]: value };
if (field === "issued") {
const v = value === "" ? null : Number(value);
if (v !== null && (!Number.isFinite(v) || v < 0)) {
setError("Issued is a number of pieces.");
return;
}
void run({ issued: v });
} else if (field === "loc") {
void run({ loc: value || null });
} else {
void run({ heat: value || null });
}
};
const onEnter = (field: "issued" | "loc" | "heat") => (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key !== "Enter") return;
e.preventDefault();
commit(field);
if (field !== "heat") return;
// A scanner sends Enter after the number: go on to the next line's heat.
const all = Array.from(e.currentTarget.closest("table")?.querySelectorAll<HTMLInputElement>("input[data-heat]") ?? []);
const i = all.indexOf(e.currentTarget);
const next = all.slice(i + 1).find((x) => !x.disabled);
next?.focus();
};
const cls = b.short ? "pullrow short" : b.pulled ? "pullrow pulled" : "pullrow";
return (
<>
<tr className={cls} aria-label={`Line ${n}`}>
<td>
<label className="pullck">
<input type="checkbox" aria-label={`Line ${n} pulled`} checked={pulled} disabled={busy} onChange={(e) => tick("pulled", e.target.checked)} />
<span className="balloon">{n}</span>
</label>
</td>
<td>
{show(b.description)}
{isSub(b) ? (
<>
{" "}
<span className="visbadge" title="Sub-assembly made on its own packet. Its heats are recorded there.">
SUB-ASSY
</span>
</>
) : null}
{b.part_id ? <div className="sub mono">{b.part_id}</div> : null}
{b.material || b.length ? (
<div className="sub mono">
{show(b.material)}
{b.length ? ` · ${b.length}` : ""}
</div>
) : null}
</td>
<td className="num need">{show(b.qty)}</td>
<td>
<input
className="in sm num w80"
inputMode="decimal"
aria-label={`Line ${n} issued`}
value={issued}
placeholder={show(b.qty)}
onChange={(e) => setIssued(e.target.value)}
onBlur={() => commit("issued")}
onKeyDown={onEnter("issued")}
/>
</td>
<td>
<input
className="in sm w110"
aria-label={`Line ${n} location`}
value={loc}
maxLength={120}
placeholder="Bin / rack"
onChange={(e) => setLoc(e.target.value)}
onBlur={() => commit("loc")}
onKeyDown={onEnter("loc")}
/>
</td>
<td className="heatcell">
<input
className={needsHeat(b) ? "in sm heatIn need" : "in sm heatIn"}
data-heat={n}
aria-label={`Line ${n} heat`}
value={heat}
maxLength={40}
autoComplete="off"
spellCheck={false}
placeholder={heatPlaceholder(b)}
disabled={!!b.na}
onChange={(e) => setHeat(e.target.value)}
onBlur={() => commit("heat")}
onKeyDown={onEnter("heat")}
/>
</td>
<td className="shortcell">
{b.short ? (
<div className="stack tight">
<ShortChip s={b.short} />
<button className="btn sm ghost" type="button" disabled={busy} onClick={() => onShort(b)}>
Edit short
</button>
</div>
) : issuedLess(b) ? (
<button className="btn sm pink" type="button" disabled={busy} onClick={() => onShort(b)}>
Record short
</button>
) : (
<button className="btn sm ghost" type="button" disabled={busy} onClick={() => onShort(b)}>
Short
</button>
)}
</td>
<td className="nowrap">
<label className="small nack">
<input type="checkbox" aria-label={`Line ${n} N/A`} checked={na} disabled={busy} onChange={(e) => tick("na", e.target.checked)} /> N/A
</label>{" "}
{b.mtr_asset_id ? (
<>
<button type="button" className="lnk small" onClick={() => onView(b)}>
MTR
</button>{" "}
</>
) : null}
<label className={busy ? "btn sm ghost dim" : "btn sm ghost"}>
{b.mtr_asset_id ? "Replace" : "MTR"}
<input
ref={fileRef}
className="sr"
type="file"
accept=".pdf,.png,.jpg,.jpeg"
aria-label={`Line ${n} MTR file`}
disabled={busy}
onChange={(e) => {
const f = e.target.files?.[0];
if (!f) return;
setError(null);
void upload(b, f).then((err) => {
if (err) setError(err);
if (fileRef.current) fileRef.current.value = "";
});
}}
/>
</label>
</td>
</tr>
{error ? (
<tr className="pullerr">
<td colSpan={8}>
<div className="callout warn flush" role="alert">
Item {n}: {error}
</div>
</td>
</tr>
) : null}
</>
);
}
/** The warehouse pull (the old pullTable): tick Pulled, issued, location, heat, shorts, N/A and the MTR. */
export function PullTable({ p, write, reload, busy }: { p: PacketRead; write: Write; reload: () => Promise<void>; busy: boolean }) {
const [shortFor, setShortFor] = useState<number | null>(null);
const [viewFor, setViewFor] = useState<number | null>(null);
const result = async (r: WriteResult): Promise<string | null> => (r.ok || r.err === undefined ? null : failText(r.err, reload));
// Writes run one after another, each with the line's row version from the latest packet, so a heat typed while
// the Pulled tick is still in flight does not race it into a row conflict.
// The newest packet this table has seen: the prop, or a write's own answer before React has re-rendered with it.
const latest = useRef(p);
if (latest.current.id !== p.id || p.version >= latest.current.version) latest.current = p;
const chain = useRef<Promise<unknown>>(Promise.resolve());
const patch = (b: BomLine, body: BomPatch): Promise<string | null> => {
const next = chain.current.then(async () => {
const send = (rv: number) =>
write((g) =>
packetsApi.patchBomLine(p.id, b.item, rv, body, g).then((r) => {
if (r.version >= latest.current.version) latest.current = r;
return r;
}),
);
const rv = () => (latest.current.bom.find((x) => x.item === b.item) ?? b).row_version ?? 1;
let r = await send(rv());
if (!r.ok && r.err !== undefined && errorCode(r.err) === "row_conflict") {
// Someone else (or an earlier write of ours) moved the row on: read it again and try once more.
const fresh = await packetsApi.get(p.id);
if (fresh.version >= latest.current.version) latest.current = fresh;
r = await send(rv());
}
return result(r);
});
chain.current = next.catch(() => undefined);
return next;
};
const upload = async (b: BomLine, file: File): Promise<string | null> => {
let asset;
try {
asset = await assetsApi.upload(file, { packet_id: p.id, purpose: "mtr" });
} catch (err) {
return errorText(err);
}
return patch(b, { mtr_asset_id: asset.id });
};
const line = shortFor === null ? undefined : p.bom.find((b) => b.item === shortFor);
const viewed = viewFor === null ? undefined : p.bom.find((b) => b.item === viewFor);
if (!p.bom.length) return <div className="empty">No BOM lines on this packet.</div>;
return (
<>
<div className="tw">
<table className="pull" aria-label="Pull">
<thead>
<tr>
<th>Pulled</th>
<th>Part</th>
<th className="num">Need</th>
<th>Issued</th>
<th>Location</th>
<th>Heat no.</th>
<th>Short</th>
<th>Non-trace / MTR</th>
</tr>
</thead>
<tbody>
{p.bom.map((b) => (
<PullRow key={b.item} b={b} busy={busy} patch={patch} upload={upload} onShort={(x) => setShortFor(x.item)} onView={(x) => setViewFor(x.item)} />
))}
</tbody>
</table>
</div>
{line ? (
<ShortSheet
mode="record"
line={line}
onClose={() => setShortFor(null)}
onSave={async (body) => result(await write((g) => packetsApi.saveShort(p.id, line.item, body, g)))}
onClear={async () => result(await write((g) => packetsApi.clearShort(p.id, line.item, { expect_open: true }, g)))}
/>
) : null}
{viewed?.mtr_asset_id ? (
<MtrViewer assetId={viewed.mtr_asset_id} title={`MTR · item ${viewed.item}${viewed.heat ? ` · ${viewed.heat}` : ""}`} onClose={() => setViewFor(null)} />
) : null}
</>
);
}
+208
View File
@@ -0,0 +1,208 @@
import { useState, type FormEvent } from "react";
import type { ReceiptBody, ShortBody } from "../api/types";
import { Sheet } from "../components/Sheet";
import { num, SHORT_REASONS } from "./stages";
/** The line the sheet is about. A receipt from the Open shorts table has no line, only the short's numbers. */
export interface ShortLine {
item: number;
description: string | null;
part_id: string | null;
qty: number | string | null;
issued?: number | string | null | undefined;
heat?: string | null | undefined;
na?: boolean | undefined;
short?: { qty_open: number | string; reason: string | null; ref: string | null; eta: string | null; note: string | null } | null | undefined;
}
/** Each handler answers null when it worked, or the text to show. */
interface ShortSheetProps {
mode: "record" | "receive";
line: ShortLine;
sub?: string | undefined;
onSave?: (body: ShortBody) => Promise<string | null>;
onClear?: () => Promise<string | null>;
onReceive?: (body: ReceiptBody) => Promise<string | null>;
onClose: () => void;
}
const LIMIT_REF = 60;
const LIMIT_NOTE = 500;
const LIMIT_HEAT = 40;
/** Record or edit a short on a line, or receive the missing pieces (the old short sheet). */
export function ShortSheet({ mode, line, sub, onSave, onClear, onReceive, onClose }: ShortSheetProps) {
const need = num(line.qty) ?? 0;
const os = line.short;
const open = num(os?.qty_open) ?? 0;
const issued = num(line.issued);
const defQty = os ? open : Math.max(1, need - Math.min(issued ?? 0, need));
const [qty, setQty] = useState(String(mode === "receive" ? open : defQty));
const [reason, setReason] = useState<string>(os?.reason && (SHORT_REASONS as readonly string[]).includes(os.reason) ? os.reason : SHORT_REASONS[0]);
const [ref, setRef] = useState(os?.ref ?? "");
const [eta, setEta] = useState(os?.eta ?? "");
const [note, setNote] = useState(mode === "receive" ? "" : (os?.note ?? ""));
const [heat, setHeat] = useState(mode === "receive" ? "" : (line.heat ?? ""));
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const done = async (p: Promise<string | null>) => {
setBusy(true);
setError(null);
try {
const err = await p;
if (err === null) onClose();
else setError(err);
} finally {
setBusy(false);
}
};
const submit = (e: FormEvent) => {
e.preventDefault();
const q = Number(qty);
if (mode === "receive") {
if (!Number.isInteger(q) || q < 1 || q > open) {
setError(`Received qty must be 1 to ${open}.`);
return;
}
const h = heat.trim();
if (!line.na && !h) {
setError("Scan or type the heat number of the part received.");
return;
}
if (onReceive) void done(onReceive({ qty: q, heat: h || null, note: note.trim() || null }));
return;
}
if (!Number.isInteger(q) || q < 1 || q > need) {
setError(`Short qty must be 1 to ${need}.`);
return;
}
const body: ShortBody = {
qty: q,
reason,
ref: ref.trim() || null,
eta: eta || null,
note: note.trim() || null,
expect_open: !!os,
};
const h = heat.trim();
if (!line.na && h) body.heat = h;
if (onSave) void done(onSave(body));
};
const title = mode === "receive" ? "Receive short part" : os ? "Edit short" : "Record short";
return (
<Sheet title={title} sub={sub ? `${sub} · item ${line.item}` : `item ${line.item}`} onClose={onClose}>
<form className="stack" onSubmit={submit} noValidate>
{mode === "receive" ? (
<>
<div className="small">
{line.description ?? ""} · <b className="mono">{open}</b> still short{os?.ref ? <> · {os.ref}</> : null}
</div>
<div className="frow">
<label className="f">
Qty received
<input className="in" type="number" min={1} max={open} value={qty} onChange={(e) => setQty(e.target.value)} />
</label>
<label className="f">
Heat
<input
className="in"
value={heat}
maxLength={LIMIT_HEAT}
autoComplete="off"
placeholder={line.na ? "N/A line, no heat needed" : "Scan / type heat"}
onChange={(e) => setHeat(e.target.value)}
/>
</label>
</div>
<label className="f">
Note
<input className="in" value={note} maxLength={LIMIT_NOTE} placeholder="Optional" onChange={(e) => setNote(e.target.value)} />
</label>
</>
) : (
<>
<div className="small">
{line.description ?? ""} · need <b className="mono">{need}</b>
{line.part_id ? (
<>
{" "}
· <span className="mono">{line.part_id}</span>
</>
) : null}
</div>
<div className="frow">
<label className="f">
Qty short
<input className="in" type="number" min={1} max={need} value={qty} onChange={(e) => setQty(e.target.value)} />
</label>
<label className="f">
Reason
<select className="in" aria-label="Reason" value={reason} onChange={(e) => setReason(e.target.value)}>
{SHORT_REASONS.map((r) => (
<option key={r} value={r}>
{r}
</option>
))}
</select>
</label>
<label className="f">
PO / requisition
<input className="in" value={ref} maxLength={LIMIT_REF} placeholder="Optional" onChange={(e) => setRef(e.target.value)} />
</label>
<label className="f">
Expected
<input className="in" type="date" value={eta} onChange={(e) => setEta(e.target.value)} />
</label>
</div>
<label className="f">
Note
<input
className="in"
value={note}
maxLength={LIMIT_NOTE}
placeholder="For example: purchasing notified, cut from next stick"
onChange={(e) => setNote(e.target.value)}
/>
</label>
{line.na ? null : (
<label className="f">
Heat no. for the pulled pieces
<input
className="in"
value={heat}
maxLength={LIMIT_HEAT}
autoComplete="off"
placeholder="Scan / type. Leave blank if none pulled."
onChange={(e) => setHeat(e.target.value)}
/>
</label>
)}
</>
)}
{error ? (
<div className="callout warn flush" role="alert">
{error}
</div>
) : null}
<div className="sheet-acts start">
<button className="btn pri" type="submit" disabled={busy}>
{mode === "receive" ? "Receive" : "Save short"}
</button>
<button className="btn ghost" type="button" onClick={onClose}>
Cancel
</button>
<span className="grow" />
{mode === "record" && os && onClear ? (
<button className="btn ghost pinktxt" type="button" disabled={busy} onClick={() => void done(onClear())}>
Not short after all
</button>
) : null}
</div>
</form>
</Sheet>
);
}
+70
View File
@@ -0,0 +1,70 @@
import { describe, expect, it } from "vitest";
import { makeCard } from "../test/packets";
import { markPulledWarning, needsHeat, packetMessage, pillText, whLine, whOrder, whStage } from "./stages";
const NOW = new Date("2026-10-16T12:00:00Z");
const who = { id: "u1", display_name: "Wade Warehouse" };
describe("warehouse stages", () => {
it("gives the pill and the line for each stage", () => {
const at = "2026-10-16T11:48:00Z";
expect(pillText(whStage({ wh_stage: null }))).toBe("WH · waiting");
expect(whLine({ wh_stage: "incoming", wh: null, wh_sent_at: "2026-10-16T10:00:00Z" }, NOW)).toBe("Waiting 2 h, not started yet");
const pulling = { wh: { stage: "pulling" as const, by: who, at, reason: null, note: null, auto: false } };
expect(pillText(whStage(pulling))).toBe("WH · being pulled");
expect(whLine(pulling, NOW)).toBe("Being pulled by Wade Warehouse · 12 min");
const hold = { wh: { stage: "hold" as const, by: who, at, reason: "Waiting on purchasing / PO", note: "PO 4471 due Fri", auto: false } };
expect(pillText(whStage(hold))).toBe("WH · on hold");
expect(whLine(hold, NOW)).toBe("On hold: Waiting on purchasing / PO · PO 4471 due Fri · 12 min");
const pulled = { wh: { stage: "pulled" as const, by: who, at: "2026-10-14T12:00:00Z", reason: null, note: null, auto: false } };
expect(pillText(whStage(pulled))).toBe("WH · pulled");
expect(whLine(pulled, NOW)).toBe("Pulled by Wade Warehouse · 2 d ago, ready for production");
});
it("orders by the day sent, then the VISUAL release date, then the time", () => {
const a = makeCard({ id: 1, wh_sent_at: "2026-10-15T15:00:00", visual_rel: "2026-10-20" });
const b = makeCard({ id: 2, wh_sent_at: "2026-10-15T09:00:00", visual_rel: null });
const c = makeCard({ id: 3, wh_sent_at: "2026-10-14T16:00:00", visual_rel: "2026-11-01" });
const d = makeCard({ id: 4, wh_sent_at: "2026-10-15T08:00:00", visual_rel: "2026-10-20" });
expect([a, b, c, d].sort(whOrder).map((x) => x.id)).toEqual([3, 4, 1, 2]);
});
it("needs a heat only on a pulled, issued, traceable line that is not a sub-assembly", () => {
const base = { na: false, sub: null, pulled: true, issued: 2, heat: null };
expect(needsHeat(base)).toBe(true);
expect(needsHeat({ ...base, heat: "H1" })).toBe(false);
expect(needsHeat({ ...base, na: true })).toBe(false);
expect(needsHeat({ ...base, sub: "Y" })).toBe(false);
expect(needsHeat({ ...base, pulled: false })).toBe(false);
expect(needsHeat({ ...base, issued: 0 })).toBe(false);
});
it("warns before Mark pulled when lines remain", () => {
expect(markPulledWarning({ total: 3, pulled: 1, missingHeat: 1 })).toBe(
"2 lines not ticked Pulled and 1 heat number missing. Mark it pulled anyway? You'll still need to finish those lines before it can be released.",
);
expect(markPulledWarning({ total: 3, pulled: 3, missingHeat: 0 })).toBeNull();
});
it("says what is open on the packet, or that it is ready", () => {
const line = { qty: 2, part_id: null, description: null, dwg_ref: null, length: null, material: null };
expect(
packetMessage({
bom: [
{ ...line, item: 10, pulled: true, issued: 2, heat: null },
{ ...line, item: 20, pulled: false },
{ ...line, item: 30, pulled: true, issued: 1, heat: "H" },
],
}).head,
).toBe("1 line not pulled · 1 issued less than needed, record the short · heat number needed for item 10 (2 pulled).");
expect(
packetMessage({
bom: [{ ...line, item: 1, pulled: true, issued: 1, heat: "H", short: { id: 1, qty: 1, qty_open: 1, reason: "On order", ref: null, eta: null, note: null, opened_at: null } }],
}).head,
).toBe("Ready to release with 1 short line.");
expect(packetMessage({ bom: [{ ...line, item: 1, pulled: true, issued: 2, heat: "H" }] }).head).toBe("All parts pulled and heats recorded.");
});
});
+208
View File
@@ -0,0 +1,208 @@
// Warehouse stages, reasons, the pill and line everyone sees, the board order and the pull progress.
// Ported from the old build's warehouse.js and app.js (pullTable, needsHeat, shortChip).
import type { BomLine, PacketCard, PacketRead, WhInfo, WhStage } from "../api/types";
export const WH_STAGES: Record<WhStage, { name: string; pill: string; cls: string }> = {
incoming: { name: "Waiting to pull", pill: "WH · waiting", cls: "wh-in" },
pulling: { name: "Being pulled", pill: "WH · being pulled", cls: "wh-pull" },
hold: { name: "On hold", pill: "WH · on hold", cls: "wh-hold" },
pulled: { name: "Pulled, ready for production", pill: "WH · pulled", cls: "wh-done" },
};
export const WH_STAGE_KEYS: readonly WhStage[] = ["incoming", "pulling", "hold", "pulled"];
/** The hold reasons. The server holds the same closed set. "Other" needs a note. */
export const WH_REASONS = [
"Waiting on material (not in stock)",
"Waiting on purchasing / PO",
"Question for engineering or clerical",
"Drawing / BOM problem",
"Other",
] as const;
/** The short reasons. The server holds the same closed set. */
export const SHORT_REASONS = [
"Not in stock",
"On order",
"Back-ordered",
"Damaged / rejected",
"Wrong part received",
"Needs cutting / prep",
"Other",
] as const;
export const HOLD_NOTE_MAX = 200;
type WhCarrier = { wh_stage?: string | null; wh?: WhInfo | null };
const isStage = (s: unknown): s is WhStage => typeof s === "string" && s in WH_STAGES;
/** The stage of a warehouse packet. A packet with no warehouse row is waiting. */
export function whStage(p: WhCarrier): WhStage {
if (isStage(p.wh?.stage)) return p.wh.stage;
if (isStage(p.wh_stage)) return p.wh_stage;
return "incoming";
}
export function pillText(stage: WhStage): string {
return WH_STAGES[stage].pill;
}
export function pillClassFor(stage: WhStage): string {
return `pill st-warehouse ${WH_STAGES[stage].cls}`;
}
/** The old ago(): "<1 min", "12 min", "3 h", "2 d". Empty without a time. */
export function span(iso: string | null | undefined, now: Date = new Date()): string {
if (!iso) return "";
const t = new Date(iso).getTime();
if (Number.isNaN(t)) return "";
const m = Math.max(0, Math.round((now.getTime() - t) / 60_000));
if (m < 1) return "<1 min";
if (m < 60) return `${m} min`;
if (m < 1440) return `${Math.round(m / 60)} h`;
return `${Math.round(m / 1440)} d`;
}
/** The line under the pill (the old whLine): who, how long, why. */
export function whLine(p: WhCarrier & { wh_sent_at?: string | null }, now: Date = new Date()): string {
const s = whStage(p);
const w: Partial<WhInfo> = p.wh ?? {};
const ago = span(w.at ?? p.wh_sent_at ?? null, now);
const who = w.by?.display_name || "warehouse";
switch (s) {
case "incoming":
return `Waiting${ago ? ` ${ago}` : ""}, not started yet`;
case "pulling":
return `Being pulled by ${who}${ago ? ` · ${ago}` : ""}`;
case "hold":
return `On hold: ${w.reason || "no reason given"}${w.note ? ` · ${w.note}` : ""}${ago ? ` · ${ago}` : ""}`;
default:
return `Pulled by ${who}${ago ? ` · ${ago} ago` : ""}, ready for production`;
}
}
/** Local calendar day of a time, for the board order. */
function day(iso: string | null | undefined): string {
if (!iso) return "9999";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "9999";
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
}
type Ordered = Pick<PacketCard, "wh_sent_at" | "visual_rel">;
const timeOf = (x: Ordered) => {
const t = x.wh_sent_at ? new Date(x.wh_sent_at).getTime() : NaN;
return Number.isNaN(t) ? Number.MAX_SAFE_INTEGER : t;
};
/** Oldest first: the day it was sent, then VISUAL's release date, then the exact time (the old whOrder). */
export function whOrder(a: Ordered, b: Ordered): number {
return (
day(a.wh_sent_at).localeCompare(day(b.wh_sent_at)) ||
String(a.visual_rel || "9999").localeCompare(String(b.visual_rel || "9999")) ||
timeOf(a) - timeOf(b)
);
}
export const num = (v: unknown): number | null => {
if (v === null || v === undefined || v === "") return null;
const n = Number(v);
return Number.isFinite(n) ? n : null;
};
export function isSub(b: Pick<BomLine, "sub">): boolean {
return String(b.sub ?? "").trim().toUpperCase() === "Y";
}
/** The old needsHeat for a pulled line: not N/A, not a sub-assembly, pieces issued and no heat. */
export function needsHeat(b: Pick<BomLine, "na" | "sub" | "pulled" | "issued" | "heat">): boolean {
return !b.na && !isSub(b) && !!b.pulled && (num(b.issued) ?? 0) > 0 && !(b.heat ?? "").trim();
}
/** Pulled with fewer pieces issued than needed and no short recorded yet. */
export function issuedLess(b: Pick<BomLine, "pulled" | "issued" | "qty" | "short">): boolean {
const iss = num(b.issued);
return !!b.pulled && iss !== null && iss < (num(b.qty) ?? 0) && !b.short;
}
export interface Progress {
/** Lines not N/A. */
total: number;
/** Lines not N/A that are pulled or have an open short. */
pulled: number;
missingHeat: number;
shorts: number;
done: boolean;
}
/** Pull progress from a card's counts. */
export function cardProgress(p: PacketCard): Progress {
const total = p.bom_total ?? 0;
const pulled = p.bom_pulled ?? 0;
const missingHeat = p.bom_missing_heat ?? 0;
return { total, pulled, missingHeat, shorts: p.open_shorts || 0, done: total > 0 && pulled === total && missingHeat === 0 };
}
/** The same from a packet's BOM. */
export function progress(p: Pick<PacketRead, "bom">): Progress {
const bom = p.bom.filter((b) => !b.na);
const total = bom.length;
const pulled = bom.filter((b) => b.pulled || b.short).length;
const missingHeat = bom.filter(needsHeat).length;
const shorts = bom.filter((b) => b.short).length;
return { total, pulled, missingHeat, shorts, done: total > 0 && pulled === total && missingHeat === 0 };
}
const plural = (n: number, one: string, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
/** The confirm text for Mark pulled when lines remain. Null when nothing remains. */
export function markPulledWarning(g: Pick<Progress, "total" | "pulled" | "missingHeat">): string | null {
const left = Math.max(0, g.total - g.pulled);
if (!left && !g.missingHeat) return null;
const parts = [left ? `${plural(left, "line")} not ticked Pulled` : "", g.missingHeat ? `${plural(g.missingHeat, "heat number")} missing` : ""].filter(Boolean);
return `${parts.join(" and ")}. Mark it pulled anyway? You'll still need to finish those lines before it can be released.`;
}
/** The warehouse message on the packet page: what is still open, or that it is ready. */
export function packetMessage(p: Pick<PacketRead, "bom">): { open: boolean; head: string; rest: string } {
const bom = p.bom.filter((b) => !b.na);
const unp = bom.filter((b) => !b.pulled && !b.short).length;
const less = bom.filter(issuedLess).length;
const noh = bom.filter(needsHeat);
const sh = bom.filter((b) => b.short).length;
const parts = [
unp ? `${plural(unp, "line")} not pulled` : "",
less ? `${less} issued less than needed, record the short` : "",
noh.length ? `heat number needed for ${noh.map((b) => `item ${b.item} (${String(b.issued ?? 0)} pulled)`).join(", ")}` : "",
].filter(Boolean);
if (parts.length) {
return {
open: true,
head: `${parts.join(" · ")}.`,
rest: `${noh.length ? "Parts that were pulled still need their heat, even when the rest of the line is short. " : ""}Tick Pulled as parts come off the rack. If a part isn't here, use Short so it's tracked and the packet can still move.`,
};
}
if (sh) {
return {
open: false,
head: `Ready to release with ${plural(sh, "short line")}.`,
rest: "The shorts stay open on the Shorts list until you receive them, and the supervisor and stall can see them.",
};
}
return { open: false, head: "All parts pulled and heats recorded.", rest: "Release the packet to the supervisor." };
}
/** Whole days since a time, or null. */
export function daysSince(iso: string | null | undefined, now: Date = new Date()): number | null {
if (!iso) return null;
const t = new Date(iso).getTime();
if (Number.isNaN(t)) return null;
return Math.floor((now.getTime() - t) / 86_400_000);
}
/** The roles that move cards on the board. The server checks again on every move. */
export function isWarehouseUser(roles: readonly string[]): boolean {
return roles.some((r) => r === "warehouse" || r === "warehouse_supervisor" || r === "admin");
}
+99
View File
@@ -0,0 +1,99 @@
import { useCallback, useState, type ReactNode } from "react";
import { packetsApi } from "../api/packets";
import type { PacketRead, WhStage } from "../api/types";
import type { Guard } from "../components/useGuard";
import { ConfirmSheet, HoldSheet } from "./HoldSheet";
import { markPulledWarning, type Progress } from "./stages";
/** What a stage move needs to know about the packet. */
export interface WhTarget {
id: number;
stage: WhStage;
job: string;
progress: Pick<Progress, "total" | "pulled" | "missingHeat" | "done">;
}
type Pending = { kind: "hold"; t: WhTarget } | { kind: "confirm"; t: WhTarget; text: string };
/**
* Moves a packet on the warehouse board. Hold asks for a reason first; Mark pulled asks when lines remain.
* `onDone` gets the packet answer; `onError` gets the failure. Render `sheet` once in the screen.
*/
export function useWhMove({
run,
onDone,
onError,
}: {
run: Guard["run"];
onDone: (p: PacketRead) => void;
onError: (err: unknown) => void;
}): { move: (t: WhTarget, to: WhStage) => void; busy: boolean; sheet: ReactNode } {
const [pending, setPending] = useState<Pending | null>(null);
const [busy, setBusy] = useState(false);
const send = useCallback(
async (t: WhTarget, to: WhStage, extra: { reason?: string; note?: string } = {}) => {
setBusy(true);
try {
const r = await run((g) => packetsApi.setWhStage(t.id, { stage: to, expect_stage: t.stage, ...extra }, g), { sub: t.job });
if (r) onDone(r);
} catch (err) {
onError(err);
} finally {
setBusy(false);
}
},
[run, onDone, onError],
);
const move = useCallback(
(t: WhTarget, to: WhStage) => {
if (t.stage === to) return;
if (to === "hold") {
setPending({ kind: "hold", t });
return;
}
if (to === "pulled" && !t.progress.done) {
const text = markPulledWarning(t.progress);
if (text) {
setPending({ kind: "confirm", t, text });
return;
}
}
void send(t, to);
},
[send],
);
let sheet: ReactNode = null;
if (pending?.kind === "hold") {
const t = pending.t;
sheet = (
<HoldSheet
sub={t.job}
onCancel={() => setPending(null)}
onHold={(reason, note) => {
setPending(null);
void send(t, "hold", note ? { reason, note } : { reason });
}}
/>
);
} else if (pending?.kind === "confirm") {
const t = pending.t;
sheet = (
<ConfirmSheet
title="Not everything is pulled yet"
sub={t.job}
text={pending.text}
okLabel="Mark pulled"
onCancel={() => setPending(null)}
onOk={() => {
setPending(null);
void send(t, "pulled");
}}
/>
);
}
return { move, busy, sheet };
}