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
+18
View File
@@ -4,6 +4,7 @@ import { asList, ifMatch, qs } from "./query";
import type {
AttachPagesBody,
BomLine,
BomPatch,
FieldCas,
Guarded,
LogRow,
@@ -12,7 +13,10 @@ import type {
PacketHeader,
PacketListQuery,
PacketRead,
ReceiptBody,
ShortBody,
TransitionBody,
WhStageBody,
WorkOrder,
} from "./types";
@@ -68,4 +72,18 @@ export const packetsApi = {
api.post<PacketRead>(`${base(id)}/change-orders/${no}/reject`, body),
coHold: (id: number, no: number, hold: boolean, extra: Guarded = {}) =>
api.post<PacketRead>(`${base(id)}/change-orders/${no}/hold`, { hold, ...extra }),
/* Phase 4: the warehouse. */
/** One BOM line. If-Match is the line's row_version. */
patchBomLine: (id: number, item: number, rowVersion: number, body: BomPatch, g: Guarded = {}) =>
request<PacketRead>(`${base(id)}/bom/${item}`, { method: "PATCH", body: { ...body, ...g }, headers: ifMatch(rowVersion) }),
/** Opens the short on a line, or updates the open one. expect_open says which the caller saw. */
saveShort: (id: number, item: number, body: ShortBody, g: Guarded = {}) =>
api.post<PacketRead>(`${base(id)}/bom/${item}/short`, { ...body, ...g }),
/** "Not short after all": closes the open short. */
clearShort: (id: number, item: number, body: { expect_open: boolean }, g: Guarded = {}) =>
api.delWithBody<PacketRead>(`${base(id)}/bom/${item}/short`, { ...body, ...g }),
receiveShort: (id: number, item: number, body: ReceiptBody, g: Guarded = {}) =>
api.post<PacketRead>(`${base(id)}/bom/${item}/short/receipts`, { ...body, ...g }),
setWhStage: (id: number, body: WhStageBody, g: Guarded = {}) => api.post<PacketRead>(`${base(id)}/wh`, { ...body, ...g }),
};
+10
View File
@@ -0,0 +1,10 @@
// Admin: the assets volume, the scanner and the open alerts (phase 4 contract, api/admin.py).
import { api } from "./client";
import { asList } from "./query";
import type { AlertRow, StorageInfo } from "./types";
export const storageApi = {
storage: (signal?: AbortSignal) => api.get<StorageInfo>("/api/admin/storage", signal),
alerts: async (signal?: AbortSignal): Promise<AlertRow[]> => asList<AlertRow>(await api.get<unknown>("/api/admin/alerts", signal), "alerts"),
ack: (id: number) => api.post<AlertRow | null>(`/api/admin/alerts/${encodeURIComponent(String(id))}/ack`, {}),
};
+152 -2
View File
@@ -175,6 +175,17 @@ export interface PacketCard {
co_pending?: boolean;
/** Phase 3: every leg is closed in VISUAL (observed). */
visual_closed?: boolean;
/** Phase 4: the current warehouse row, on every card. */
wh?: WhInfo | null;
wh_sent_at?: string | null;
visual_rel?: string | null;
/** Lines not N/A. */
bom_total?: number;
/** Lines not N/A that are pulled or have an open short. */
bom_pulled?: number;
bom_missing_heat?: number;
/** The released column only: the newest wh_release transition. */
released_at?: string | null;
}
export interface PacketList {
@@ -229,7 +240,16 @@ export interface BomShort {
eta: string | null;
note: string | null;
opened_at: string | null;
receipts?: unknown[];
receipts?: ShortReceipt[];
row_version?: number;
}
export interface ShortReceipt {
qty: number | string;
heat: string | null;
note: string | null;
at: string | null;
by?: WhoRef | null;
}
export interface BomLine {
@@ -249,6 +269,8 @@ export interface BomLine {
pulled?: boolean;
mtr_asset_id?: string | null;
src?: string | null;
/** "Y" on a sub-assembly line: its heats are recorded on its own packet. */
sub?: string | null;
short?: BomShort | null;
row_version?: number;
}
@@ -310,7 +332,7 @@ export interface PacketRead {
heat_table: unknown[];
stage: Record<string, unknown> | null;
stages: unknown[];
wh: Record<string, unknown> | null;
wh: WhInfo | null;
qc: Record<string, unknown> | null;
qc_history: unknown[];
change_orders: ChangeOrder[];
@@ -654,3 +676,131 @@ export interface ResourceMapRow {
rows_last_run: number | null;
updated_at: string | null;
}
/* ---------- Phase 4: warehouse ---------- */
export type WhStage = "incoming" | "pulling" | "hold" | "pulled";
/** The current packet_wh row. */
export interface WhInfo {
stage: WhStage;
by: WhoRef | null;
at: string | null;
reason: string | null;
note: string | null;
auto: boolean;
}
/** POST /api/packets/{id}/wh. */
export interface WhStageBody {
stage: WhStage;
expect_stage: WhStage;
reason?: string;
note?: string;
}
/** POST /api/packets/{id}/bom/{item}/short. */
export interface ShortBody {
qty: number;
reason: string;
ref: string | null;
eta: string | null;
note: string | null;
heat?: string;
expect_open: boolean;
}
/** POST /api/packets/{id}/bom/{item}/short/receipts. */
export interface ReceiptBody {
qty: number;
heat: string | null;
note: string | null;
}
/** PATCH /api/packets/{id}/bom/{item}. */
export interface BomPatch {
heat?: string | null;
na?: boolean;
issued?: number | null;
loc?: string | null;
pulled?: boolean;
mtr_asset_id?: string | null;
}
/** One open short on GET /api/warehouse/board. */
export interface ShortRow {
packet: {
id: number;
unit_number: string;
op: string | null;
drawing: string | null;
status: PacketStatus;
wh_stage: WhStage | null;
station: StationRef | null;
holder: string;
dept: DeptRef;
};
item: number;
bom_line_id: number;
description: string | null;
part_id: string | null;
material: string | null;
length: string | null;
qty: number | string;
qty_open: number | string;
reason: string | null;
ref: string | null;
eta: string | null;
note: string | null;
opened_at: string | null;
age_days: number;
late: boolean;
short_id: number;
row_version: number;
/** Not in the contract. A receipt needs no heat on an N/A line. */
na?: boolean;
}
export type BoardColumn = WhStage | "released";
export interface BoardResponse {
kpis: { waiting: number; pulling: number; hold: number; pulled: number; open_shorts: number; released_24h: number };
columns: Record<BoardColumn, PacketCard[]>;
shorts: ShortRow[];
}
export interface BoardQuery {
q?: string;
project_id?: number;
unit_id?: number;
dept?: string;
}
/* ---------- Phase 4: storage and alerts (Admin) ---------- */
export interface VolumeUsage {
total: number;
used: number;
free: number;
pct: number;
warn: boolean;
}
export interface AlertRow {
id: number;
kind: string;
severity: "info" | "warning" | "critical" | string;
subject: string | null;
detail: Record<string, unknown>;
created_at: string;
resolved_at: string | null;
acknowledged_at: string | null;
acknowledged_by: WhoRef | string | null;
}
export interface StorageInfo {
usage: VolumeUsage;
quota_warn_pct: number;
alert: AlertRow | null;
clamav: { configured: boolean; host: string | null; reachable: boolean | null };
}
+22
View File
@@ -0,0 +1,22 @@
// The warehouse board (phase 4 contract, api/warehouse.py).
import { api } from "./client";
import { qs } from "./query";
import type { BoardQuery, BoardResponse, PacketCard } from "./types";
const COLUMNS = ["incoming", "pulling", "hold", "pulled", "released"] as const;
/** Every column and number present, even when the server leaves one out. */
function normalise(raw: Partial<BoardResponse> | null): BoardResponse {
const cols = (raw?.columns ?? {}) as Partial<Record<(typeof COLUMNS)[number], PacketCard[]>>;
const columns = Object.fromEntries(COLUMNS.map((c) => [c, Array.isArray(cols[c]) ? cols[c] : []])) as BoardResponse["columns"];
return {
kpis: { waiting: 0, pulling: 0, hold: 0, pulled: 0, open_shorts: 0, released_24h: 0, ...(raw?.kpis ?? {}) },
columns,
shorts: Array.isArray(raw?.shorts) ? raw.shorts : [],
};
}
export const warehouseApi = {
board: async (q: BoardQuery = {}, signal?: AbortSignal): Promise<BoardResponse> =>
normalise(await api.get<Partial<BoardResponse> | null>(`/api/warehouse/board${qs({ ...q })}`, signal)),
};
+36
View File
@@ -0,0 +1,36 @@
import { render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router";
import { describe, expect, it } from "vitest";
import { makeCard } from "../test/packets";
import { Card } from "./Card";
const who = { id: "u1", display_name: "Wade Warehouse" };
describe("Card", () => {
it("shows the WH pill and line on a warehouse packet, for every role", () => {
render(
<MemoryRouter>
<Card
p={makeCard({
status: "warehouse",
wh_stage: "hold",
wh: { stage: "hold", by: who, at: new Date().toISOString(), reason: "Drawing / BOM problem", note: "Rev B on the floor", auto: false },
})}
/>
</MemoryRouter>,
);
expect(screen.getByText("WH · on hold")).toHaveClass("pill", "st-warehouse", "wh-hold");
expect(screen.getByText("On hold: Drawing / BOM problem · Rev B on the floor · <1 min")).toHaveClass("whline");
expect(screen.queryByText("With warehouse")).not.toBeInTheDocument();
});
it("keeps the status pill and no line outside the warehouse", () => {
render(
<MemoryRouter>
<Card p={makeCard({ status: "ready", wh_stage: null })} />
</MemoryRouter>,
);
expect(screen.getByText("Ready to assign")).toBeInTheDocument();
expect(screen.queryByText(/^WH ·/)).not.toBeInTheDocument();
});
});
+6 -1
View File
@@ -2,16 +2,20 @@ import type { ReactNode } from "react";
import { Link } from "react-router";
import type { PacketCard } from "../api/types";
import { flagClass, jobName, openCoCount, pillClass, statusLabel } from "../lib/packets";
import { pillClassFor, pillText, WH_STAGES, whLine, whStage } from "../warehouse/stages";
/** One packet on a board. The old card(): job name and pill, title, department, meta, flag line. */
export function Card({ p, extra }: { p: PacketCard; extra?: ReactNode }) {
const shorts = p.open_shorts || 0;
const cos = openCoCount(p);
// Every role sees where a warehouse packet really is: the stage pill and the line under it.
const atWh = p.status === "warehouse" && p.wh_stage !== undefined;
const stage = atWh ? whStage(p) : null;
return (
<Link className="card" to={`/packets/${p.id}`} aria-label={`${jobName(p)}, ${statusLabel(p.status)}`}>
<div className="r1">
<span className="job">{jobName(p)}</span>
<span className={pillClass(p.status)}>{statusLabel(p.status)}</span>
{stage ? <span className={pillClassFor(stage)}>{pillText(stage)}</span> : <span className={pillClass(p.status)}>{statusLabel(p.status)}</span>}
</div>
<div className="tt">{p.title || "Untitled"}</div>
<div className="small dept">{p.dept.name}</div>
@@ -39,6 +43,7 @@ export function Card({ p, extra }: { p: PacketCard; extra?: ReactNode }) {
{shorts ? <span className="pill st-review">{shorts} short</span> : null}
{cos ? <span className="pill st-review">CO {cos}</span> : null}
</div>
{stage ? <div className={`whline ${WH_STAGES[stage].cls}`}>{whLine(p)}</div> : null}
{p.flag ? <div className={`small ${flagClass(p.flag)}`}>⚑ {p.flag}</div> : null}
{p.co_pending ? <div className="small due soon">Change from VISUAL to approve</div> : null}
{p.visual_closed && p.status !== "filed" ? <div className="small muted">Closed in VISUAL</div> : null}
+15 -12
View File
@@ -8,8 +8,9 @@ interface FindBarProps {
onQ: (q: string) => void;
pick: UnitPick;
onPick: (v: UnitPick) => void;
view: BoardView;
onView: (v: BoardView) => void;
/** The Board | List toggle. Hidden when onView is not given. */
view?: BoardView;
onView?: (v: BoardView) => void;
/** "Showing n packets". */
count: number | null;
/** The flag kind filter. Shown when onFlag is given. */
@@ -18,7 +19,7 @@ interface FindBarProps {
}
/** Find a job: the search box, the project then unit picker, and the Board | List toggle. */
export function FindBar({ q, onQ, pick, onPick, view, onView, count, flag = "", onFlag }: FindBarProps) {
export function FindBar({ q, onQ, pick, onPick, view = "board", onView, count, flag = "", onFlag }: FindBarProps) {
return (
<section className="fltbar" aria-label="Find jobs">
<div className="fltrow">
@@ -55,16 +56,18 @@ export function FindBar({ q, onQ, pick, onPick, view, onView, count, flag = "",
</select>
</label>
) : null}
<div className="fltbtns">
<div className="seg" role="group" aria-label="View">
<button type="button" aria-pressed={view === "board"} onClick={() => onView("board")}>
Board
</button>
<button type="button" aria-pressed={view === "list"} onClick={() => onView("list")}>
List
</button>
{onView ? (
<div className="fltbtns">
<div className="seg" role="group" aria-label="View">
<button type="button" aria-pressed={view === "board"} onClick={() => onView("board")}>
Board
</button>
<button type="button" aria-pressed={view === "list"} onClick={() => onView("list")}>
List
</button>
</div>
</div>
</div>
) : null}
</div>
{count !== null ? (
<div className="fltrow2">
+13
View File
@@ -81,3 +81,16 @@ export function fmtDay(iso: string | null | undefined): string {
if (Number.isNaN(d.getTime())) return iso;
return d.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" });
}
/** "1.2 GB" from bytes. */
export function fmtBytes(n: number | null | undefined): string {
if (n === null || n === undefined || !Number.isFinite(n)) return "-";
const units = ["B", "KB", "MB", "GB", "TB"];
let v = n;
let i = 0;
while (v >= 1024 && i < units.length - 1) {
v /= 1024;
i++;
}
return `${i === 0 ? String(Math.round(v)) : v.toFixed(v < 10 ? 1 : 0)} ${units[i] ?? "B"}`;
}
+118 -5
View File
@@ -15,6 +15,9 @@ import { MoveSheet, SendToDeptSheet } from "./sheets";
import { DocumentsTab, HistoryTab, MaterialsTab, WorkOrdersTab, type Mutate } from "./tabs";
import { coSummary, coToReview } from "../lib/changeOrders";
import { ChangesTab, VisualBox } from "./visual";
import type { Write } from "../warehouse/PullTable";
import { packetMessage, pillClassFor, WH_STAGES, pillText, progress, whLine, whStage } from "../warehouse/stages";
import { useWhMove } from "../warehouse/useWhMove";
type Tab = "drawing" | "materials" | "workorders" | "changes" | "docs" | "history";
@@ -122,15 +125,18 @@ export function PacketPage() {
const versionRef = useRef<number | null>(null);
const fileRef = useRef<HTMLInputElement>(null);
// A read that was sent before a write committed can answer after the write's own answer: never go back.
const take = useCallback((p: PacketRead) => setPacket((cur) => (cur && cur.id === p.id && cur.version > p.version ? cur : p)), []);
const reload = useCallback(async () => {
try {
const p = await packetsApi.get(id);
setPacket(p);
take(p);
setLoadError(null);
} catch (err) {
setLoadError(errorText(err));
}
}, [id]);
}, [id, take]);
useEffect(() => {
setPacket(null);
@@ -150,6 +156,31 @@ export function PacketPage() {
const job = packet ? jobName(packet) : "";
// Line writes: the answer replaces the packet; the error goes back to the row that made it.
const write: Write = useCallback(
async (fn) => {
try {
const r = await run(fn, { sub: job });
if (r === undefined) return { ok: false };
if (r && typeof r === "object" && "id" in r) take(r as PacketRead);
else await reload();
return { ok: true };
} catch (err) {
return { ok: false, err };
}
},
[run, reload, job, take],
);
const wh = useWhMove({
run,
onDone: setPacket,
onError: (err) => {
if (err instanceof ApiError && err.status === 409) void reload();
setMsg({ ok: false, text: errorText(err) });
},
});
const mutate: Mutate = useCallback(
async (fn, opts = {}) => {
setBusy(true);
@@ -157,7 +188,7 @@ export function PacketPage() {
try {
const r = await run(fn, { sign: opts.sign, sub: job });
if (r === undefined) return false;
if (r && typeof r === "object" && "id" in r) setPacket(r);
if (r && typeof r === "object" && "id" in r) take(r as PacketRead);
else await reload();
if (opts.ok) setMsg({ ok: true, text: opts.ok });
return true;
@@ -170,7 +201,7 @@ export function PacketPage() {
setBusy(false);
}
},
[run, reload, job],
[run, reload, job, take],
);
if (!packet) {
@@ -272,6 +303,22 @@ export function PacketPage() {
const target = targetHours(p);
const lotQty = Math.max(0, ...p.work_orders.map((w) => Number(w.qty) || 0));
const backTo = me?.screens.includes("clerical") ? `/clerical/units/${p.unit.id}` : homePath(me?.screens ?? []);
const atWh = p.status === "warehouse";
const whCan = atWh && can("set_wh_stage");
const stage = whStage(p);
const whMsg = whCan ? packetMessage(p) : null;
const whTarget = { id: p.id, stage, job, progress: progress(p) };
const sendBack = async () => {
const reason = await askReason({
title: "Send back to Clerical",
sub: job,
note: "The packet goes back to Clerical as a draft. Say what needs fixing.",
min: 8,
submitLabel: "Send back to Clerical",
});
if (reason === null) return;
await transition("wh_send_back", { reason }, "Sent back to Clerical.");
};
const hasDraftControls =
draft && ["edit_header", "send_to_warehouse", "release_to_shop", "send_draft_to_qc", "edit_document_set", "move_unit", "remove"].some((a) => can(a) || p.blocked[a]);
@@ -473,6 +520,71 @@ export function PacketPage() {
</section>
) : null}
{atWh ? (
<section className={whCan ? "panel actbox" : "callout"} aria-label="Warehouse">
<div className="whstep">
<span className={pillClassFor(stage)}>{pillText(stage)}</span>
<div className={`whline ${WH_STAGES[stage].cls}`}>{whLine(p)}</div>
</div>
{whMsg ? (
<div>
<b>{whMsg.head}</b> {whMsg.rest}
</div>
) : null}
{whCan || can("wh_send_back") || can("wh_release") || p.blocked.wh_release ? (
<div className="acts">
<Act p={p} action="wh_send_back" busy={busy} className="btn ghost" onClick={() => void sendBack()}>
Send back to Clerical
</Act>
{whCan && stage === "incoming" ? (
<div className="act">
<button className="btn" type="button" disabled={busy || wh.busy} onClick={() => wh.move(whTarget, "pulling")}>
Start pulling
</button>
</div>
) : null}
{whCan && stage === "hold" ? (
<div className="act">
<button className="btn" type="button" disabled={busy || wh.busy} onClick={() => wh.move(whTarget, "pulling")}>
Resume pulling
</button>
</div>
) : null}
{whCan && stage === "pulling" ? (
<div className="act">
<button className="btn" type="button" disabled={busy || wh.busy} onClick={() => wh.move(whTarget, "pulled")}>
Mark pulled
</button>
</div>
) : null}
{whCan && stage === "pulled" ? (
<div className="act">
<button className="btn" type="button" disabled={busy || wh.busy} onClick={() => wh.move(whTarget, "pulling")}>
Back to pulling
</button>
</div>
) : null}
{whCan && (stage === "incoming" || stage === "pulling") ? (
<div className="act">
<button className="btn" type="button" disabled={busy || wh.busy} onClick={() => wh.move(whTarget, "hold")}>
Hold
</button>
</div>
) : null}
<Act
p={p}
action="wh_release"
busy={busy || wh.busy}
className="btn pri"
onClick={() => void transition("wh_release", {}, "Released to supervisor.")}
>
Release to supervisor{p.open_shorts ? ` (${p.open_shorts} short)` : ""}
</Act>
</div>
) : null}
</section>
) : null}
{!draft && (can("set_vclosed") || can("file") || p.blocked.file) ? (
<section className="panel actbox" aria-label="Close and file">
<div>
@@ -536,7 +648,7 @@ export function PacketPage() {
<DrawingViewer pages={drawingPages} />
</div>
) : null}
{shownTab === "materials" ? <MaterialsTab p={p} mutate={mutate} busy={busy} /> : null}
{shownTab === "materials" ? <MaterialsTab p={p} mutate={mutate} busy={busy} write={write} reload={reload} /> : null}
{shownTab === "workorders" ? <WorkOrdersTab p={p} /> : null}
{shownTab === "changes" ? <ChangesTab p={p} roles={me?.roles ?? []} sub={job} mutate={mutate} busy={busy} askReason={askReason} /> : null}
{shownTab === "docs" ? <DocumentsTab p={p} mutate={mutate} busy={busy} /> : null}
@@ -565,6 +677,7 @@ export function PacketPage() {
}}
/>
) : null}
{wh.sheet}
{guardSheet}
</>
);
+63 -6
View File
@@ -5,6 +5,9 @@ import { packetsApi } from "../api/packets";
import type { BomLine, Guarded, PacketRead, WorkOrder } from "../api/types";
import { errorText, fmtTime } from "../lib/format";
import { useLoad } from "../lib/useLoad";
import { MtrViewer } from "../warehouse/MtrViewer";
import { PullTable, ShortChip, type Write } from "../warehouse/PullTable";
import { ShortSheet } from "../warehouse/ShortSheet";
export type Mutate = (
fn: (g: Guarded) => Promise<PacketRead | null | undefined>,
@@ -35,10 +38,28 @@ const toDraft = (b: BomLine): LineDraft => ({
const EMPTY_LINE: LineDraft = { qty: "", part_id: "", description: "", material: "", length: "", dwg_ref: "" };
/** The BOM. Clerical may edit the lines of a hand draft (PUT /bom, full replace). */
export function MaterialsTab({ p, mutate, busy }: { p: PacketRead; mutate: Mutate; busy: boolean }) {
/**
* The BOM. Clerical may edit the lines of a hand draft (PUT /bom, full replace). The warehouse gets the pull table
* while the packet is with it. Anyone who may receive a short gets Receive on the line.
*/
export function MaterialsTab({
p,
mutate,
busy,
write,
reload,
}: {
p: PacketRead;
mutate: Mutate;
busy: boolean;
write?: Write | undefined;
reload?: (() => Promise<void>) | undefined;
}) {
const editable = p.status === "draft" && p.source_kind === "hand" && p.can.includes("edit_bom");
const [lines, setLines] = useState<LineDraft[] | null>(null);
const [receiveFor, setReceiveFor] = useState<number | null>(null);
const [viewFor, setViewFor] = useState<number | null>(null);
const canReceive = p.can.includes("wh_receive_short") || p.can.includes("edit_bom");
const set = (i: number, k: keyof LineDraft, v: string) => setLines((rows) => (rows ?? []).map((r, j) => (j === i ? { ...r, [k]: v } : r)));
const save = async (e: FormEvent) => {
@@ -124,6 +145,19 @@ export function MaterialsTab({ p, mutate, busy }: { p: PacketRead; mutate: Mutat
);
}
if (p.status === "warehouse" && p.can.includes("edit_bom") && write && reload) {
return <PullTable p={p} write={write} reload={reload} busy={busy} />;
}
const receiving = receiveFor === null ? undefined : p.bom.find((b) => b.item === receiveFor);
const viewed = viewFor === null ? undefined : p.bom.find((b) => b.item === viewFor);
const receive = async (item: number, body: Parameters<typeof packetsApi.receiveShort>[2]): Promise<string | null> => {
if (!write) return "Receiving is not available here.";
const r = await write((g) => packetsApi.receiveShort(p.id, item, body, g));
if (r.ok || r.err === undefined) return null;
return errorText(r.err);
};
return (
<div className="stack">
{editable ? (
@@ -164,12 +198,29 @@ export function MaterialsTab({ p, mutate, busy }: { p: PacketRead; mutate: Mutat
</td>
<td className="small">{show(b.material)}</td>
<td className="small">{show(b.length)}</td>
<td className="mono small">{show(b.heat)}</td>
<td className="mono small">
{show(b.heat)}
{b.mtr_asset_id ? (
<>
{" "}
<button type="button" className="lnk small" onClick={() => setViewFor(b.item)}>
MTR
</button>
</>
) : null}
</td>
<td>
{b.na ? (
{b.na && !b.short ? (
<span className="pill st-filed">N/A</span>
) : b.short ? (
<span className="pill st-review">{String(b.short.qty_open)} short</span>
<div className="stack tight">
<ShortChip s={b.short} />
{canReceive && write ? (
<button className="btn sm pri" type="button" disabled={busy} onClick={() => setReceiveFor(b.item)}>
Receive
</button>
) : null}
</div>
) : b.pulled ? (
<span className="pill st-ok">Pulled</span>
) : (
@@ -184,6 +235,12 @@ export function MaterialsTab({ p, mutate, busy }: { p: PacketRead; mutate: Mutat
) : (
<div className="empty">No BOM lines on this packet.</div>
)}
{receiving ? (
<ShortSheet mode="receive" line={receiving} onClose={() => setReceiveFor(null)} onReceive={(body) => receive(receiving.item, body)} />
) : null}
{viewed?.mtr_asset_id ? (
<MtrViewer assetId={viewed.mtr_asset_id} title={`MTR · item ${viewed.item}${viewed.heat ? ` · ${viewed.heat}` : ""}`} onClose={() => setViewFor(null)} />
) : null}
</div>
);
}
@@ -305,7 +362,7 @@ export function DocumentsTab({ p, mutate, busy }: { p: PacketRead; mutate: Mutat
}
setUploading(true);
try {
const asset = await assetsApi.upload(file, { packet_id: p.id, purpose: "doc" });
const asset = await assetsApi.upload(file, { packet_id: p.id, purpose: "document" });
const ok = await mutate((g) => packetsApi.addDoc(p.id, { asset_id: asset.id, kind, title: title.trim() || file.name, ...g }), { ok: "Document added" });
if (ok) {
setTitle("");
+2
View File
@@ -5,6 +5,7 @@ import { useAuth } from "../auth/useAuth";
import { AdminPage } from "./admin/AdminPage";
import { ClericalHome } from "./clerical/ClericalHome";
import { PlaceholderScreen } from "./PlaceholderScreen";
import { WarehouseHome } from "./warehouse/WarehouseHome";
/** A page under a screen, like /clerical/projects/7. Users without the screen go to their first screen. */
export function RequireScreen({ screen, children }: { screen: Screen; children: ReactNode }) {
@@ -23,5 +24,6 @@ export function ScreenRoute() {
if (!isScreen(screen) || !allowed.includes(screen)) return <Navigate to={homePath(me.screens)} replace />;
if (screen === "admin") return <AdminPage />;
if (screen === "clerical") return <ClericalHome />;
if (screen === "warehouse") return <WarehouseHome />;
return <PlaceholderScreen key={screen} screen={screen} />;
}
+4 -1
View File
@@ -6,8 +6,9 @@ import { useAuth } from "../../auth/useAuth";
import { errorText, fmtTime } from "../../lib/format";
import { useLoad, type Load } from "../../lib/useLoad";
import { FeedTab, ResourcesTab } from "./FeedTabs";
import { StorageTab } from "./StorageTab";
type Tab = "users" | "lookup" | "devices" | "groups" | "attempts" | "feed" | "resources";
type Tab = "users" | "lookup" | "devices" | "groups" | "attempts" | "feed" | "resources" | "storage";
const TABS: ReadonlyArray<{ id: Tab; label: string }> = [
{ id: "users", label: "Users" },
@@ -17,6 +18,7 @@ const TABS: ReadonlyArray<{ id: Tab; label: string }> = [
{ id: "attempts", label: "Sign-in attempts" },
{ id: "feed", label: "Feed" },
{ id: "resources", label: "VISUAL resources" },
{ id: "storage", label: "Storage" },
];
const ROLES_NOTE = "Roles come from AD groups. Ask IT to change them.";
@@ -701,6 +703,7 @@ export function AdminPage() {
{tab === "attempts" ? <AttemptsTab /> : null}
{tab === "feed" ? <FeedTab /> : null}
{tab === "resources" ? <ResourcesTab /> : null}
{tab === "storage" ? <StorageTab /> : null}
</div>
</>
);
@@ -0,0 +1,61 @@
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { DEPARTMENTS, makeMe, renderApp } from "../../test/auth";
import { callsTo, mockFetch } from "../../test/fetchMock";
const ADMIN = makeMe({
user: { display_name: "Ann Admin", sam_account: "admin.ann" },
roles: ["admin"],
screens: ["clerical", "warehouse", "supervisor", "stall", "qc", "pm", "admin"],
});
const GB = 1024 ** 3;
const ALERT = {
id: 3,
kind: "assets_quota",
severity: "warning",
subject: "assets",
detail: { total: 100 * GB, used: 90 * GB, free: 10 * GB, pct: 90, warn: true },
created_at: "2026-10-16T07:00:00Z",
resolved_at: null,
acknowledged_at: null,
acknowledged_by: null,
};
function base(clamav: { configured: boolean; host: string | null; reachable: boolean | null }) {
return {
"/api/auth/me": { body: ADMIN },
"/api/departments": { body: DEPARTMENTS },
"/api/admin/users": { body: [] },
"/api/admin/storage": {
body: { usage: { total: 100 * GB, used: 90 * GB, free: 10 * GB, pct: 90, warn: true }, quota_warn_pct: 85, alert: ALERT, clamav },
},
"/api/admin/alerts": { body: { alerts: [ALERT] } },
};
}
describe("Admin Storage tab", () => {
it("shows the usage, the warning threshold, the scanner and the open alert, and acknowledges it", async () => {
const fetch = mockFetch({ ...base({ configured: true, host: "clamav", reachable: true }), "POST /api/admin/alerts/3/ack": { body: { ...ALERT, acknowledged_at: "2026-10-16T08:00:00Z" } } });
renderApp("/admin");
fireEvent.click(await screen.findByRole("tab", { name: "Storage" }));
expect(await screen.findByText(/^Used/)).toHaveTextContent("Used 90 GB of 100 GB (90%).");
expect(screen.getByRole("meter", { name: "Assets volume used" })).toHaveAttribute("aria-valuenow", "90");
expect(screen.getByText("Warning at 85%.")).toBeInTheDocument();
expect(screen.getByText("clamav: reachable")).toBeInTheDocument();
const status = screen.getByRole("status");
expect(status).toHaveTextContent("The assets volume is 90% full.");
const table = await screen.findByRole("table", { name: "Open alerts" });
expect(within(table).getByText("warning")).toBeInTheDocument();
fireEvent.click(within(status).getByRole("button", { name: "Acknowledge" }));
await waitFor(() => expect(callsTo(fetch, "POST", "/api/admin/alerts/3/ack")).toEqual([{}]));
expect(await screen.findByText("Alert acknowledged.")).toBeInTheDocument();
});
it("says Not configured when no scanner is set", async () => {
mockFetch(base({ configured: false, host: null, reachable: null }));
renderApp("/admin");
fireEvent.click(await screen.findByRole("tab", { name: "Storage" }));
expect(await screen.findByText("Not configured")).toBeInTheDocument();
});
});
+141
View File
@@ -0,0 +1,141 @@
// Admin: the assets volume, the virus scanner and the open alerts (phase 4).
import { useState } from "react";
import { storageApi } from "../../api/storage";
import type { AlertRow } from "../../api/types";
import { errorText, fmtBytes, fmtTime } from "../../lib/format";
import { useLoad } from "../../lib/useLoad";
const whoName = (a: AlertRow["acknowledged_by"]) => (a && typeof a === "object" ? a.display_name : (a ?? ""));
function alertText(a: AlertRow): string {
if (a.kind === "assets_quota") {
const pct = Number((a.detail as { pct?: unknown }).pct);
return Number.isFinite(pct) ? `The assets volume is ${Math.round(pct)}% full.` : "The assets volume is nearly full.";
}
return a.subject ? `${a.kind.replace(/_/g, " ")}: ${a.subject}` : a.kind.replace(/_/g, " ");
}
export function StorageTab() {
const [load, reload] = useLoad((s) => storageApi.storage(s));
const [alerts, reloadAlerts] = useLoad((s) => storageApi.alerts(s));
const [busy, setBusy] = useState(false);
const [msg, setMsg] = useState<{ ok: boolean; text: string } | null>(null);
const ack = async (id: number) => {
setBusy(true);
setMsg(null);
try {
await storageApi.ack(id);
setMsg({ ok: true, text: "Alert acknowledged." });
reload();
reloadAlerts();
} catch (err) {
setMsg({ ok: false, text: errorText(err) });
} finally {
setBusy(false);
}
};
if (load.state === "loading") return <div className="empty slim">Loading</div>;
if (load.state === "error") return <div className="callout warn">{load.message}</div>;
const s = load.data;
const u = s.usage;
const pct = Math.max(0, Math.min(100, Number(u.pct) || 0));
const scan = s.clamav;
return (
<div className="stack">
{msg ? (
<div className={msg.ok ? "callout flush" : "callout warn flush"} role={msg.ok ? "status" : "alert"}>
{msg.text}
</div>
) : null}
<section className="panel stack" aria-labelledby="stor-h">
<h3 id="stor-h">Assets volume</h3>
<div>
Used <b>{fmtBytes(u.used)}</b> of <b>{fmtBytes(u.total)}</b> ({Math.round(pct)}%). <span className="muted">{fmtBytes(u.free)} free.</span>
</div>
<div
className="bar usage"
role="meter"
aria-label="Assets volume used"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(pct)}
>
<i className={u.warn ? "over" : undefined} style={{ width: `${pct}%` }} />
<span className="mark" style={{ left: `${s.quota_warn_pct}%` }} aria-hidden="true" />
</div>
<div className="small muted">Warning at {s.quota_warn_pct}%.</div>
{s.alert ? (
<div className="callout warn flush" role="status">
<b>{alertText(s.alert)}</b> Raised {fmtTime(s.alert.created_at)}.
{s.alert.acknowledged_at ? (
<> Acknowledged {fmtTime(s.alert.acknowledged_at)}{whoName(s.alert.acknowledged_by) ? ` by ${whoName(s.alert.acknowledged_by)}` : ""}.</>
) : (
<>
{" "}
<button className="btn sm" type="button" disabled={busy} onClick={() => void ack(s.alert?.id ?? 0)}>
Acknowledge
</button>
</>
)}
</div>
) : null}
</section>
<section className="panel" aria-label="Virus scanner">
<b>Virus scanner (ClamAV): </b>
{!scan.configured ? (
<span className="muted">Not configured</span>
) : (
<span className={scan.reachable ? "st-ok" : "st-bad"}>
{scan.host ?? "clamav"}: {scan.reachable ? "reachable" : "not reachable"}
</span>
)}
</section>
<section className="sec" aria-labelledby="alerts-h">
<div className="sec-h">
<h3 id="alerts-h">Open alerts</h3>
</div>
{alerts.state === "loading" ? <div className="empty slim">Loading</div> : null}
{alerts.state === "error" ? <div className="callout warn">{alerts.message}</div> : null}
{alerts.state === "ok" && !alerts.data.length ? <div className="empty slim">No open alerts.</div> : null}
{alerts.state === "ok" && alerts.data.length ? (
<div className="tw">
<table aria-label="Open alerts">
<thead>
<tr>
<th>Raised</th>
<th>Severity</th>
<th>What</th>
<th>Acknowledged</th>
<th />
</tr>
</thead>
<tbody>
{alerts.data.map((a) => (
<tr key={a.id}>
<td className="small mono nowrap">{fmtTime(a.created_at)}</td>
<td className="small">{a.severity}</td>
<td>{alertText(a)}</td>
<td className="small">{a.acknowledged_at ? `${fmtTime(a.acknowledged_at)} ${whoName(a.acknowledged_by)}` : "-"}</td>
<td>
{a.acknowledged_at ? null : (
<button className="btn sm" type="button" disabled={busy} onClick={() => void ack(a.id)}>
Acknowledge
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
) : null}
</section>
</div>
);
}
@@ -0,0 +1,88 @@
import { Link } from "react-router";
import type { ShortRow } from "../../api/types";
import { fmtDay } from "../../lib/format";
import { pillClass, statusLabel } from "../../lib/packets";
import { pillClassFor, pillText, whStage } from "../../warehouse/stages";
export const NO_SHORTS = "No open shorts. When a part isn't on the shelf, mark it Short on the pull and it lands here.";
const jobOf = (r: ShortRow) => (r.packet.op ? `${r.packet.unit_number} · ${r.packet.op}` : r.packet.unit_number);
/** Every open short on every live packet, soonest ETA first (the old Open shorts table). */
export function ShortsTable({ rows, busy, onReceive }: { rows: ShortRow[]; busy: boolean; onReceive: (r: ShortRow) => void }) {
return (
<section className="sec" aria-labelledby="shorts-h">
<div className="sec-h">
<h2 id="shorts-h">Open shorts</h2>
<span className="ct">parts that went on without the full pull. Receive them here when they come in.</span>
</div>
{rows.length ? (
<div className="tw">
<table aria-label="Open shorts">
<thead>
<tr>
<th>Job</th>
<th>Item</th>
<th className="num">Short</th>
<th>Reason</th>
<th>PO / req</th>
<th>ETA</th>
<th className="num">Age</th>
<th>Packet is at</th>
<th />
</tr>
</thead>
<tbody>
{rows.map((r) => {
const atWh = r.packet.status === "warehouse";
const stage = atWh ? whStage({ wh_stage: r.packet.wh_stage }) : null;
return (
<tr key={r.short_id}>
<td>
<Link className="lnk" to={`/packets/${r.packet.id}`}>
{jobOf(r)}
</Link>
<div className="sub">
{r.packet.op ?? ""} · {r.packet.drawing ?? ""}
</div>
</td>
<td>
<span className="balloon">{r.item}</span> {r.description ?? ""}
<div className="sub mono">{[r.part_id, r.material, r.length].filter(Boolean).join(" ")}</div>
</td>
<td className="num shortqty">{String(r.qty_open)}</td>
<td>
{r.reason ?? ""}
{r.note ? <div className="sub">{r.note}</div> : null}
</td>
<td className="mono">{r.ref || "-"}</td>
<td className={r.late ? "mono late" : "mono"}>{r.eta ? `${fmtDay(r.eta)}${r.late ? " · late" : ""}` : "-"}</td>
<td className="num">{r.age_days} d</td>
<td>
{stage ? (
<span className={pillClassFor(stage)}>{pillText(stage)}</span>
) : (
<span className={pillClass(r.packet.status)}>{statusLabel(r.packet.status)}</span>
)}
<div className="sub">
{r.packet.holder}
{r.packet.station ? ` · ${r.packet.station.name}` : ""}
</div>
</td>
<td>
<button className="btn sm pri" type="button" disabled={busy} onClick={() => onReceive(r)}>
Receive
</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
) : (
<div className="empty slim">{NO_SHORTS}</div>
)}
</section>
);
}
@@ -0,0 +1,249 @@
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import type { BoardResponse, PacketCard, ShortRow } from "../../api/types";
import { DEPARTMENTS, makeMe, renderApp } from "../../test/auth";
import { callsTo, mockFetch } from "../../test/fetchMock";
import { makeCard, 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 WELD = { code: "03", name: "Weld Parts" };
const ELEC = { code: "14", name: "Electrical" };
function whCard(id: number, over: Partial<PacketCard> = {}): PacketCard {
return makeCard({
id,
status: "warehouse",
holder: "Warehouse",
unit: { id: 100 + id, number: `L99900${id}` },
wh_stage: "incoming",
wh: null,
wh_sent_at: "2026-10-15T08:00:00Z",
visual_rel: null,
bom_total: 3,
bom_pulled: 0,
bom_missing_heat: 0,
...over,
});
}
const SHORT: ShortRow = {
packet: {
id: 9,
unit_number: "L999009",
op: "FIT-UP",
drawing: "D-9",
status: "ready",
wh_stage: null,
station: null,
holder: "Weld Parts",
dept: WELD,
},
item: 2,
bom_line_id: 92,
description: "Pipe 2in",
part_id: "P-2",
material: "A106",
length: "20 ft",
qty: 4,
qty_open: 1,
reason: "Not in stock",
ref: "PO 4471",
eta: "2026-10-01",
note: null,
opened_at: "2026-10-10T08:00:00Z",
age_days: 6,
late: true,
short_id: 5,
row_version: 1,
};
function board(over: Partial<BoardResponse> = {}): BoardResponse {
return {
kpis: { waiting: 3, pulling: 1, hold: 1, pulled: 1, open_shorts: 1, released_24h: 2 },
columns: {
incoming: [
whCard(1, { dept: WELD, wh_sent_at: "2026-10-15T10:00:00Z" }),
whCard(2, { dept: ELEC, wh_sent_at: "2026-10-12T10:00:00Z" }),
whCard(3, { dept: WELD, wh_sent_at: "2026-10-14T10:00:00Z" }),
],
pulling: [whCard(4, { wh_stage: "pulling", wh: { stage: "pulling", by: who, at: "2026-10-16T07:00:00Z", reason: null, note: null, auto: true }, bom_pulled: 1, bom_missing_heat: 1 })],
hold: [
whCard(5, {
wh_stage: "hold",
wh: { stage: "hold", by: who, at: "2026-10-16T07:00:00Z", reason: "Waiting on purchasing / PO", note: "PO 4471 due Fri", auto: false },
}),
],
pulled: [whCard(6, { wh_stage: "pulled", wh: { stage: "pulled", by: who, at: "2026-10-16T07:00:00Z", reason: null, note: null, auto: false }, bom_pulled: 3 })],
released: [makeCard({ id: 7, status: "ready", holder: "Weld Parts", unit: { id: 107, number: "L999007" }, released_at: "2026-10-16T06:00:00Z" })],
},
shorts: [SHORT],
...over,
};
}
function base(b: BoardResponse = board(), me = WADE) {
return {
"/api/auth/me": { body: me },
"/api/departments": { body: DEPARTMENTS },
"/api/warehouse/board": { body: b },
"/api/projects": { body: [] },
};
}
const column = (title: string) => screen.getByRole("heading", { name: new RegExp(`^${title.replace(/[()]/g, "\\$&")}`) }).closest("section") as HTMLElement;
const cardOf = (name: string) => screen.getByRole("group", { name: new RegExp(`^${name}`) });
describe("WarehouseHome", () => {
it("shows the numbers, the five columns, and groups cards by department oldest first", async () => {
mockFetch(base());
renderApp("/warehouse");
expect(await screen.findByRole("heading", { name: /^Waiting to pull/ })).toBeInTheDocument();
expect(screen.getByRole("heading", { name: "Warehouse" })).toBeInTheDocument();
expect(screen.getByText("Parts pull")).toBeInTheDocument();
for (const t of ["Waiting to pull", "Being pulled", "On hold", "Pulled, ready for production", "Released to shop (3 days)"]) {
expect(column(t)).toBeInTheDocument();
}
expect(screen.getByLabelText("Waiting to pull: 3")).toBeInTheDocument();
expect(screen.getByLabelText("On hold: 1")).toHaveClass("hot");
expect(screen.getByLabelText("Ready for production: 1")).toHaveClass("hot");
expect(screen.getByLabelText("Open shorts: 1")).toHaveClass("hot");
expect(screen.getByLabelText("Released last 24 h: 2")).toBeInTheDocument();
const waiting = column("Waiting to pull");
// Electrical has the oldest packet, so it comes first; Weld Parts cards go oldest first.
const heads = Array.from(waiting.querySelectorAll(".whgrp")).map((g) => g.textContent);
expect(heads).toEqual(["Electrical1", "Weld Parts2"]);
const jobs = Array.from(waiting.querySelectorAll(".whcard .job")).map((j) => j.textContent);
expect(jobs).toEqual(["L999002 · WELD-PART", "L999003 · WELD-PART", "L999001 · WELD-PART"]);
expect(within(column("Released to shop (3 days)")).getByRole("link", { name: /L999007/ })).toBeInTheDocument();
});
it("shows the pill, the line and the buttons for each stage", async () => {
mockFetch(base());
renderApp("/warehouse");
const inc = await screen.findByRole("group", { name: "L999002 · WELD-PART, Waiting to pull" });
expect(within(inc).getByText("WH · waiting")).toBeInTheDocument();
expect(within(inc).getByText(/^Waiting .*, not started yet$/)).toBeInTheDocument();
expect(within(inc).getByRole("button", { name: "Start pulling" })).toHaveClass("pri");
expect(within(inc).getByRole("button", { name: "Hold" })).toBeInTheDocument();
expect(within(inc).getByText((_, el) => el?.tagName === "SPAN" && el.textContent === "0/3 lines pulled")).toBeInTheDocument();
const pul = cardOf("L999004");
expect(within(pul).getByText("WH · being pulled")).toBeInTheDocument();
expect(within(pul).getByText(/^Being pulled by Wade Warehouse/)).toBeInTheDocument();
expect(within(pul).getByRole("button", { name: "Mark pulled" })).not.toHaveClass("pri");
expect(within(pul).getByText("1 heat missing")).toBeInTheDocument();
const hold = cardOf("L999005");
expect(within(hold).getByText("WH · on hold")).toBeInTheDocument();
expect(within(hold).getByText(/^On hold: Waiting on purchasing \/ PO · PO 4471 due Fri/)).toBeInTheDocument();
expect(within(hold).getByRole("button", { name: "Resume pulling" })).toHaveClass("pri");
expect(within(hold).queryByRole("button", { name: "Hold" })).not.toBeInTheDocument();
const done = cardOf("L999006");
expect(within(done).getByText("WH · pulled")).toBeInTheDocument();
expect(within(done).getByRole("button", { name: "Release…" })).toBeInTheDocument();
expect(within(done).getByRole("button", { name: "Back to pulling" })).toBeInTheDocument();
});
it("shows no stage buttons to a role outside the warehouse", async () => {
const wes = makeMe({ roles: ["shop:03:supervisor"], screens: ["warehouse", "supervisor"] });
mockFetch(base(board(), wes));
renderApp("/warehouse");
const inc = await screen.findByRole("group", { name: /^L999002/ });
expect(within(inc).getByText("WH · waiting")).toBeInTheDocument();
expect(within(inc).queryByRole("button")).not.toBeInTheDocument();
});
it("Start pulling posts the stage with the stage it saw", async () => {
const fetch = mockFetch({ ...base(), "POST /api/packets/2/wh": { body: makePacket({ id: 2, status: "warehouse" }) } });
renderApp("/warehouse");
const inc = await screen.findByRole("group", { name: /^L999002/ });
fireEvent.click(within(inc).getByRole("button", { name: "Start pulling" }));
await waitFor(() => expect(callsTo(fetch, "POST", "/api/packets/2/wh")).toEqual([{ stage: "pulling", expect_stage: "incoming" }]));
});
it("the hold sheet refuses Other without a note, then posts the reason and note", async () => {
const fetch = mockFetch({ ...base(), "POST /api/packets/2/wh": { body: makePacket({ id: 2, status: "warehouse" }) } });
renderApp("/warehouse");
const inc = await screen.findByRole("group", { name: /^L999002/ });
fireEvent.click(within(inc).getByRole("button", { name: "Hold" }));
const sheet = await screen.findByRole("dialog", { name: "Put on hold" });
fireEvent.click(within(sheet).getByLabelText("Other"));
fireEvent.click(within(sheet).getByRole("button", { name: "Put on hold" }));
expect(within(sheet).getByRole("alert")).toHaveTextContent("Say what it's waiting on.");
expect(callsTo(fetch, "POST", "/api/packets/2/wh")).toHaveLength(0);
fireEvent.click(within(sheet).getByLabelText("Waiting on purchasing / PO"));
fireEvent.change(within(sheet).getByLabelText(/^Note/), { target: { value: "PO 4471 due Fri" } });
fireEvent.click(within(sheet).getByRole("button", { name: "Put on hold" }));
await waitFor(() =>
expect(callsTo(fetch, "POST", "/api/packets/2/wh")).toEqual([
{ stage: "hold", expect_stage: "incoming", reason: "Waiting on purchasing / PO", note: "PO 4471 due Fri" },
]),
);
expect(screen.queryByRole("dialog", { name: "Put on hold" })).not.toBeInTheDocument();
});
it("Mark pulled asks first when lines remain", async () => {
const fetch = mockFetch({ ...base(), "POST /api/packets/4/wh": { body: makePacket({ id: 4, status: "warehouse" }) } });
renderApp("/warehouse");
const pul = await screen.findByRole("group", { name: /^L999004/ });
fireEvent.click(within(pul).getByRole("button", { name: "Mark pulled" }));
const sheet = await screen.findByRole("dialog", { name: "Not everything is pulled yet" });
expect(sheet).toHaveTextContent("2 lines not ticked Pulled and 1 heat number missing. Mark it pulled anyway?");
expect(callsTo(fetch, "POST", "/api/packets/4/wh")).toHaveLength(0);
fireEvent.click(within(sheet).getByRole("button", { name: "Mark pulled" }));
await waitFor(() => expect(callsTo(fetch, "POST", "/api/packets/4/wh")).toEqual([{ stage: "pulled", expect_stage: "pulling" }]));
});
it("a drop on the released column is refused with the toast; a drop on a stage moves the card", async () => {
const fetch = mockFetch({ ...base(), "POST /api/packets/6/wh": { body: makePacket({ id: 6, status: "warehouse" }) } });
renderApp("/warehouse");
const done = await screen.findByRole("group", { name: /^L999006/ });
const drag = (to: string) => {
const col = document.querySelector(`[data-whcol="${to}"]`) as HTMLElement;
const spy = vi.fn(() => [col]);
Object.defineProperty(document, "elementsFromPoint", { value: spy, configurable: true });
fireEvent.pointerDown(done, { clientX: 10, clientY: 10, button: 0, pointerType: "mouse" });
fireEvent.pointerMove(window, { clientX: 60, clientY: 40 });
fireEvent.pointerUp(window, { clientX: 60, clientY: 40 });
};
drag("released");
expect(await screen.findByRole("status")).toHaveTextContent("Open the packet and use Release to supervisor. It checks heats and shorts first.");
expect(callsTo(fetch, "POST", "/api/packets/6/wh")).toHaveLength(0);
// A drag never counts as a click: the packet does not open.
fireEvent.click(done);
expect(screen.getByRole("heading", { name: "Warehouse" })).toBeInTheDocument();
drag("pulling");
await waitFor(() => expect(callsTo(fetch, "POST", "/api/packets/6/wh")).toEqual([{ stage: "pulling", expect_stage: "pulled" }]));
});
it("lists the open shorts and receives one with a heat", async () => {
const fetch = mockFetch({
...base(),
"POST /api/packets/9/bom/2/short/receipts": { body: makePacket({ id: 9, status: "ready" }) },
});
renderApp("/warehouse");
const table = await screen.findByRole("table", { name: "Open shorts" });
expect(within(table).getByText("PO 4471")).toBeInTheDocument();
expect(within(table).getByText(/· late$/)).toBeInTheDocument();
fireEvent.click(within(table).getByRole("button", { name: "Receive" }));
const sheet = await screen.findByRole("dialog", { name: "Receive short part" });
fireEvent.click(within(sheet).getByRole("button", { name: "Receive" }));
expect(within(sheet).getByRole("alert")).toHaveTextContent("Scan or type the heat number of the part received.");
fireEvent.change(within(sheet).getByLabelText("Heat"), { target: { value: "HT-9" } });
fireEvent.click(within(sheet).getByRole("button", { name: "Receive" }));
await waitFor(() => expect(callsTo(fetch, "POST", "/api/packets/9/bom/2/short/receipts")).toEqual([{ qty: 1, heat: "HT-9", note: null }]));
await waitFor(() => expect(screen.queryByRole("dialog", { name: "Receive short part" })).not.toBeInTheDocument());
});
it("says so when there are no open shorts", async () => {
mockFetch(base(board({ shorts: [] })));
renderApp("/warehouse");
expect(await screen.findByText("No open shorts. When a part isn't on the shelf, mark it Short on the pull and it lands here.")).toBeInTheDocument();
});
});
@@ -0,0 +1,139 @@
import { useCallback, useEffect, useState } from "react";
import { ApiError } from "../../api/client";
import { packetsApi } from "../../api/packets";
import type { BoardColumn, PacketCard, ReceiptBody, ShortRow, WhStage } from "../../api/types";
import { warehouseApi } from "../../api/warehouse";
import { useAuth } from "../../auth/useAuth";
import { FindBar } from "../../components/FindBar";
import { Kpis } from "../../components/Kpis";
import type { UnitPick } from "../../components/PickUnit";
import { useGuard } from "../../components/useGuard";
import { useChanged } from "../../lib/changes";
import { errorText } from "../../lib/format";
import { jobName } from "../../lib/packets";
import { useLoad } from "../../lib/useLoad";
import { ShortSheet } from "../../warehouse/ShortSheet";
import { cardProgress, isWarehouseUser, whStage } from "../../warehouse/stages";
import { useWhMove } from "../../warehouse/useWhMove";
import { ShortsTable } from "./ShortsTable";
import { RELEASE_REFUSED, WhBoard } from "./WhBoard";
/** /warehouse (the old homeWarehouse): numbers, the open shorts, the find bar and the five-column board. */
export function WarehouseHome() {
const { me } = useAuth();
const { run, sheet: guardSheet } = useGuard();
const [q, setQ] = useState("");
const [query, setQuery] = useState("");
const [pick, setPick] = useState<UnitPick>({ project: null, unit: null });
const [toast, setToast] = useState<string | null>(null);
const [receiving, setReceiving] = useState<ShortRow | null>(null);
const projectId = pick.project?.id;
const unitId = pick.unit?.id;
useEffect(() => {
const t = window.setTimeout(() => setQuery(q.trim()), 250);
return () => window.clearTimeout(t);
}, [q]);
useEffect(() => {
if (!toast) return;
const t = window.setTimeout(() => setToast(null), 4000);
return () => window.clearTimeout(t);
}, [toast]);
const [load, reload] = useLoad(
(s) => warehouseApi.board({ ...(query ? { q: query } : {}), ...(unitId ? { unit_id: unitId } : projectId ? { project_id: projectId } : {}) }, s),
`${query}|${projectId ?? ""}|${unitId ?? ""}`,
);
useChanged(reload, { delayMs: 500 });
const onError = useCallback(
(err: unknown) => {
if (err instanceof ApiError && err.status === 409) reload();
setToast(errorText(err));
},
[reload],
);
const wh = useWhMove({ run, onDone: reload, onError });
const target = (p: PacketCard) => ({ id: p.id, stage: whStage(p), job: jobName(p), progress: cardProgress(p) });
const onMove = (p: PacketCard, to: WhStage) => wh.move(target(p), to);
const onDrop = (p: PacketCard, to: BoardColumn) => {
if (to === "released") {
setToast(RELEASE_REFUSED);
return;
}
wh.move(target(p), to);
};
const receive = async (r: ShortRow, body: ReceiptBody): Promise<string | null> => {
try {
const out = await run((g) => packetsApi.receiveShort(r.packet.id, r.item, body, g), { sub: r.packet.unit_number });
if (out !== undefined) reload();
return null;
} catch (err) {
if (err instanceof ApiError && err.status === 409) reload();
return errorText(err);
}
};
const data = load.state === "ok" ? load.data : null;
const k = data?.kpis;
const canMove = isWarehouseUser(me?.roles ?? []);
const count = data ? (["incoming", "pulling", "hold", "pulled"] as const).reduce((n, c) => n + data.columns[c].length, 0) : null;
return (
<>
<header className="phead">
<div>
<div className="eyebrow">Parts pull</div>
<h1>Warehouse</h1>
</div>
</header>
<Kpis
items={[
{ n: k?.waiting ?? 0, label: "Waiting to pull" },
{ n: k?.pulling ?? 0, label: "Being pulled" },
{ n: k?.hold ?? 0, label: "On hold", alert: true },
{ n: k?.pulled ?? 0, label: "Ready for production", alert: true },
{ n: k?.open_shorts ?? 0, label: "Open shorts", alert: true },
{ n: k?.released_24h ?? 0, label: "Released last 24 h" },
]}
/>
{load.state === "loading" ? <div className="empty slim">Loading</div> : null}
{load.state === "error" ? <div className="callout warn">{load.message}</div> : null}
{data ? <ShortsTable rows={data.shorts} busy={wh.busy} onReceive={setReceiving} /> : null}
<FindBar q={q} onQ={setQ} pick={pick} onPick={setPick} count={count} />
{data ? <WhBoard columns={data.columns} canMove={canMove} busy={wh.busy} onMove={onMove} onDrop={onDrop} /> : null}
{receiving ? (
<ShortSheet
mode="receive"
sub={receiving.packet.op ? `${receiving.packet.unit_number} · ${receiving.packet.op}` : receiving.packet.unit_number}
line={{
item: receiving.item,
description: receiving.description,
part_id: receiving.part_id,
qty: receiving.qty,
na: receiving.na,
short: { qty_open: receiving.qty_open, reason: receiving.reason, ref: receiving.ref, eta: receiving.eta, note: receiving.note },
}}
onClose={() => setReceiving(null)}
onReceive={(body) => receive(receiving, body)}
/>
) : null}
{wh.sheet}
{guardSheet}
{toast ? (
<div className="toast" role="status">
{toast}
</div>
) : null}
</>
);
}
+227
View File
@@ -0,0 +1,227 @@
import { useRef, type KeyboardEvent, type MouseEvent, type PointerEvent as ReactPointerEvent } from "react";
import { useNavigate } from "react-router";
import type { BoardColumn, BoardResponse, PacketCard, WhStage } from "../../api/types";
import { Card } from "../../components/Card";
import { fmtDay } from "../../lib/format";
import { jobName } from "../../lib/packets";
import { cardProgress, daysSince, pillClassFor, pillText, WH_STAGES, whLine, whStage } from "../../warehouse/stages";
import { groupByDept, WH_COLUMNS } from "./boards";
export const RELEASE_REFUSED = "Open the packet and use Release to supervisor. It checks heats and shorts first.";
/** Pixels the pointer must travel before a press becomes a drag. */
const DRAG_START = 10;
interface CardProps {
p: PacketCard;
canMove: boolean;
busy: boolean;
onMove: (p: PacketCard, to: WhStage) => void;
onDrop: (p: PacketCard, to: BoardColumn) => void;
}
/** A warehouse card (the old whCard): progress, dates, the line, the stage buttons, and drag to another column. */
export function WhCard({ p, canMove, busy, onMove, onDrop }: CardProps) {
const navigate = useNavigate();
const dragged = useRef(false);
const s = whStage(p);
const g = cardProgress(p);
const waited = daysSince(p.wh_sent_at);
const open = () => navigate(`/packets/${p.id}`);
const onPointerDown = (e: ReactPointerEvent<HTMLDivElement>) => {
if (!canMove) return;
if ((e.target as HTMLElement).closest("button, a, input, select, textarea, label")) return;
if (e.pointerType === "mouse" && e.button !== 0) return;
const card = e.currentTarget;
const sx = e.clientX;
const sy = e.clientY;
let ghost: HTMLElement | null = null;
let over: HTMLElement | null = null;
const move = (ev: PointerEvent) => {
if (!ghost && Math.hypot(ev.clientX - sx, ev.clientY - sy) < DRAG_START) return;
if (!ghost) {
ghost = card.cloneNode(true) as HTMLElement;
ghost.classList.add("whghost");
ghost.removeAttribute("id");
ghost.setAttribute("aria-hidden", "true");
ghost.style.width = `${card.offsetWidth}px`;
document.body.appendChild(ghost);
card.classList.add("whsrc");
}
ev.preventDefault();
ghost.style.left = `${ev.clientX}px`;
ghost.style.top = `${ev.clientY}px`;
const hits = typeof document.elementsFromPoint === "function" ? document.elementsFromPoint(ev.clientX, ev.clientY) : [];
const col = hits.map((x) => x.closest<HTMLElement>("[data-whcol]")).find((x): x is HTMLElement => !!x) ?? null;
if (col !== over) {
over?.classList.remove("whover");
over = col;
if (over && over.dataset.whcol !== "released") over.classList.add("whover");
}
};
const up = () => {
window.removeEventListener("pointermove", move);
window.removeEventListener("pointerup", up);
window.removeEventListener("pointercancel", up);
if (!ghost) return;
ghost.remove();
card.classList.remove("whsrc");
over?.classList.remove("whover");
// The click that follows a drag is not a click.
dragged.current = true;
window.setTimeout(() => {
dragged.current = false;
}, 60);
const to = over?.dataset.whcol as BoardColumn | undefined;
if (to) onDrop(p, to);
};
window.addEventListener("pointermove", move, { passive: false });
window.addEventListener("pointerup", up);
window.addEventListener("pointercancel", up);
};
const onClick = (e: MouseEvent<HTMLDivElement>) => {
if (dragged.current) {
dragged.current = false;
return;
}
if ((e.target as HTMLElement).closest("button, a, input, select, textarea, label")) return;
open();
};
const onKey = (e: KeyboardEvent<HTMLDivElement>) => {
if (e.key === "Enter" && e.target === e.currentTarget) open();
};
const btn = (to: WhStage, label: string, pri = false) => (
<button className={pri ? "btn sm pri" : "btn sm"} type="button" disabled={busy} onClick={() => onMove(p, to)}>
{label}
</button>
);
return (
<div
className={canMove ? "card whcard whdrag" : "card whcard"}
data-whpid={p.id}
tabIndex={0}
role="group"
aria-label={`${jobName(p)}, ${WH_STAGES[s].name}`}
onPointerDown={onPointerDown}
onClick={onClick}
onKeyDown={onKey}
>
<div className="r1">
<span className="job">{jobName(p)}</span>
<span className={pillClassFor(s)}>{pillText(s)}</span>
</div>
<div className="tt">
{p.op ?? ""} · {p.title ?? ""}
</div>
<div className="small whdates">
<span>
Sent <b>{p.wh_sent_at ? fmtDay(p.wh_sent_at) : "-"}</b>
</span>
{p.visual_rel ? (
<span>
Release <b>{fmtDay(p.visual_rel)}</b>
</span>
) : null}
{waited !== null && waited >= 1 ? <span className={waited >= 3 ? "due over" : "muted"}>{waited} d waiting</span> : null}
</div>
<div className="meta">
<span>
Dwg <b className="mono">{p.drawing || "-"}</b>
</span>
<span>
<b>{g.pulled}</b>/{g.total} lines pulled
</span>
{g.missingHeat ? (
<span className="due over">
{g.missingHeat} heat{g.missingHeat === 1 ? "" : "s"} missing
</span>
) : null}
{g.shorts ? <span className="pill st-review">{g.shorts} short</span> : null}
</div>
<div className="bar" title={`${g.pulled} of ${g.total} lines pulled`}>
<i style={{ width: `${g.total ? Math.min(100, (g.pulled / g.total) * 100) : 0}%` }} />
</div>
<div className={`whline ${WH_STAGES[s].cls}`}>{whLine(p)}</div>
{canMove ? (
<div className="whacts">
{s === "incoming" ? (
<>
{btn("pulling", "Start pulling", true)}
{btn("hold", "Hold")}
</>
) : s === "pulling" ? (
<>
{btn("pulled", "Mark pulled", g.done)}
{btn("hold", "Hold")}
</>
) : s === "hold" ? (
btn("pulling", "Resume pulling", true)
) : (
<>
<button className="btn sm pri" type="button" onClick={open}>
Release…
</button>
{btn("pulling", "Back to pulling")}
</>
)}
</div>
) : null}
</div>
);
}
/** The five columns of the warehouse board. The four stage columns group by department. */
export function WhBoard({
columns,
canMove,
busy,
onMove,
onDrop,
}: {
columns: BoardResponse["columns"];
canMove: boolean;
busy: boolean;
onMove: (p: PacketCard, to: WhStage) => void;
onDrop: (p: PacketCard, to: BoardColumn) => void;
}) {
return (
<div className="board whboard">
{WH_COLUMNS.map((c) => {
const list = columns[c.key];
const id = `whcol-${c.key}`;
return (
<section key={c.key} className="col whcol" data-whcol={c.key} aria-labelledby={id}>
<div className="col-h">
<h3 id={id}>{c.title}</h3>
<span className="c" aria-label={`${list.length} packet${list.length === 1 ? "" : "s"}`}>
{list.length}
</span>
</div>
{!list.length ? (
<div className="empty">{c.empty}</div>
) : c.key === "released" ? (
list.map((p) => <Card key={p.id} p={p} />)
) : (
groupByDept(list).map((grp) => (
<div key={grp.dept.code} className="whgrpbox">
<div className="whgrp">
<span>{grp.dept.name}</span>
<b>{grp.cards.length}</b>
</div>
{grp.cards.map((p) => (
<WhCard key={p.id} p={p} canMove={canMove} busy={busy} onMove={onMove} onDrop={onDrop} />
))}
</div>
))
)}
</section>
);
})}
</div>
);
}
+33
View File
@@ -0,0 +1,33 @@
// The warehouse board columns, grouped by department (the old whColumn).
import type { BoardColumn, DeptRef, PacketCard } from "../../api/types";
import { whOrder } from "../../warehouse/stages";
export interface WhColumnDef {
key: BoardColumn;
title: string;
empty: string;
}
export const WH_COLUMNS: readonly WhColumnDef[] = [
{ key: "incoming", title: "Waiting to pull", empty: "Nothing waiting. Clerical sends packets here when they're printed." },
{ key: "pulling", title: "Being pulled", empty: "Nobody is pulling right now. Drag a card here or tap Start pulling." },
{ key: "hold", title: "On hold", empty: "Nothing on hold." },
{ key: "pulled", title: "Pulled, ready for production", empty: "Nothing pulled and waiting on production." },
{ key: "released", title: "Released to shop (3 days)", empty: "Nothing released in the last 3 days." },
];
export interface DeptGroup {
dept: DeptRef;
cards: PacketCard[];
}
/** Cards by department, oldest first in each; the department with the oldest packet first. */
export function groupByDept(cards: readonly PacketCard[]): DeptGroup[] {
const groups = new Map<string, DeptGroup>();
for (const p of [...cards].sort(whOrder)) {
const g = groups.get(p.dept.code);
if (g) g.cards.push(p);
else groups.set(p.dept.code, { dept: p.dept, cards: [p] });
}
return [...groups.values()].sort((a, b) => whOrder(a.cards[0] as PacketCard, b.cards[0] as PacketCard));
}
+54
View File
@@ -350,3 +350,57 @@ label.btn input.sr { pointer-events: none; }
.pdfv-pages { flex-direction: row; flex-wrap: wrap; }
.pdfv-pages button { width: auto; }
}
/* ===== New in the rebuild (phase 4) ===== */
/* The warehouse board, the stage pill and line, the pull table. From the old .wh-*, .whline, .whacts, .whcard,
.whboard, .whcol, .whover, .whsrc, .whghost, .whstep, .whgrp, .whdates, .visbadge, .heat and the pullTable rows. */
.col-h h3 { font: inherit; letter-spacing: inherit; text-transform: inherit; color: inherit; }
.wh-in { color: #d9b25e; border-style: dashed; }
.wh-pull { color: var(--amber); }
.wh-hold { color: var(--pink); }
.wh-done { color: var(--green); }
.whline { font-size: 12.5px; font-weight: 600; padding: 4px 8px; border-radius: 6px; background: rgba(255, 255, 255, .04); border-left: 3px solid currentColor; }
.whline.wh-in { border-left-style: dashed; }
.whacts { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 2px; }
.whcard { cursor: pointer; }
.whcard:hover, .whcard:focus-visible { border-color: var(--cyan); }
.whcard.whdrag { touch-action: pan-y; user-select: none; }
.whboard { grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); }
.whcol { transition: border-color .12s, background .12s; }
.whcol.whover { border-color: var(--cyan); background: #12303a; }
.whsrc { opacity: .35; }
.whghost { position: fixed; z-index: 80; pointer-events: none; transform: translate(-50%, -30%) rotate(1.5deg); box-shadow: 0 10px 30px rgba(0, 0, 0, .5); opacity: .95; }
.whstep { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; margin-bottom: 6px; }
.whgrpbox { display: flex; flex-direction: column; gap: 10px; }
.whgrp { display: flex; justify-content: space-between; align-items: center; margin: 10px 2px 0; font-size: 12px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; color: var(--cyan); }
.whgrpbox:first-of-type .whgrp { margin-top: 2px; }
.whgrp b { color: var(--muted); }
.whdates { display: flex; gap: 10px; flex-wrap: wrap; color: var(--muted); }
.whdates b { color: var(--body); }
.visbadge { display: inline-block; font-size: 10.5px; font-weight: 700; letter-spacing: .08em; padding: 2px 7px; border-radius: 5px; border: 1px solid var(--cyan); color: var(--cyan); margin-right: 6px; }
.heat { font-family: var(--f-mono); font-weight: 600; color: var(--amber); }
.heat.none { color: var(--muted); font-weight: 400; }
input.in.heatIn { font-family: var(--f-mono); min-width: 170px; }
input.in.heatIn.need { border-color: var(--amber); box-shadow: 0 0 0 1px var(--amber) inset; }
input.in.w80 { width: 80px; }
input.in.w110 { width: 110px; }
table.pull td.need { font-size: 18px; font-weight: 600; }
tr.pullrow.pulled td { background: #13261d; }
tr.pullrow.short td { background: #2a1a24; }
tr.pullerr td { border-top: 0; padding-top: 0; }
.pullck { display: flex; gap: 8px; align-items: center; }
.pullck input { width: 26px; height: 26px; accent-color: var(--green); }
.nack { display: inline-flex; gap: 6px; align-items: center; white-space: nowrap; }
.nack input { width: 20px; height: 20px; }
td.heatcell { min-width: 190px; }
td.shortcell { min-width: 150px; }
.stack.tight { gap: 4px; align-items: flex-start; }
.shortchip { display: inline-flex; flex-wrap: wrap; gap: 4px; align-items: center; }
td.shortqty { font-size: 18px; font-weight: 700; color: var(--pink); }
td.late { color: var(--red); }
.btn.dim { opacity: .55; pointer-events: none; }
.btn.pinktxt { color: var(--pink); }
.mtrframe { width: 100%; height: min(75vh, 900px); border: 1px solid var(--line); border-radius: 10px; background: var(--white); }
.mtrimg { max-width: 100%; height: auto; border-radius: 10px; background: var(--white); align-self: center; }
.bar.usage { position: relative; height: 12px; border-radius: 6px; }
.bar.usage .mark { position: absolute; top: 0; bottom: 0; width: 2px; background: var(--amber); }
+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 };
}