Build Seth's changes C1 to C11 (Gitea issues 1, 8, 18, 23, 24, 25, 27, 31, 32, 33, 36)
ci / server (pull_request) Successful in 12m48s
ci / frontend (pull_request) Successful in 54s
ci / image (pull_request) Skipped
ci / e2e (pull_request) Successful in 4m38s

- C1 (issue 1): over target follows VISUAL ticket hours, not the timer.
- C2 (issue 24): a Q.C. reject always needs a reason; accept needs an NCR number after a reject without one.
- C3 (issue 25): an incomplete unit ITP blocks filing.
- C4 (issue 27): prints open the print dialog on the page, no new tab.
- C5 (issue 31): filing is blocked while VISUAL shows a work order open; the feed sets vclosed on VISUAL packets.
- C6 (issue 8): the data book leaves out the history pages.
- C7 (issue 18): the supervisor approves each received short; accept and filing wait for it.
- C8 (issue 23): per-department checklists, edited by the department supervisor or Admin, frozen at START.
- C9 (issue 32): Q.C. and Clerical may run the stamp check.
- C10 (issue 33): the PM screen and reports are for PS-ProjectMgmt and Admin only.
- C11 (issue 36): the Stall screen lists the packets assigned to the welder, at the supervisor's stall.

Migration 0011. Docs: PLAN (also issue 9 confirmed, issue 4 asked), POLICY, SECURITY, SCHEMA, FEED, README, CHANGELOG.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-30 16:36:49 -06:00
co-authored by Claude Opus 5.5
parent 150c507a31
commit 70a97398f1
101 changed files with 3871 additions and 1098 deletions
+31 -59
View File
@@ -1,10 +1,7 @@
import { useState } from "react";
import { errorCode } from "../api/client";
import { packetsApi } from "../api/packets";
import type { PacketRead } from "../api/types";
import type { ReasonOptions } from "../components/ReasonSheet";
import { Sheet } from "../components/Sheet";
import { errorText, fmtTime } from "../lib/format";
import { fmtTime } from "../lib/format";
import { useLoad } from "../lib/useLoad";
import { Act } from "./Act";
import { openTab, showsCloseFile, SIGN_REOPEN, visualLine } from "./closeUtil";
@@ -39,6 +36,14 @@ function SealLine({ p }: { p: PacketRead }) {
);
}
/** " Job closed in VISUAL <when> by <who>." Clerical's mark on a hand packet, the feed's on a VISUAL one. */
function closedText(p: PacketRead): string {
if (p.vclosed?.by) return ` Job closed in VISUAL ${fmtTime(p.vclosed.at)} by ${p.vclosed.by.display_name}.`;
const at = p.vclosed?.at ?? p.vclose_check?.closed_at ?? null;
if (!at) return "";
return p.source_kind === "hand" ? ` Job closed in VISUAL ${fmtTime(at)} by Clerical.` : ` VISUAL shows the job closed since ${fmtTime(at)}.`;
}
/**
* The Clerical close and file panel, from status complete on. Ready to close (databook route, not confirmed closed),
* ready for the data book, and filed (read-only for everyone but the IT reopen).
@@ -58,43 +63,26 @@ export function CloseFile({
sub: string;
onPrint: () => void;
}) {
const [confirm, setConfirm] = useState<string | null>(null);
if (!showsCloseFile(p)) return null;
const filed = p.status === "filed";
const databookRoute = p.routed_to === "databook";
const readyToClose = !filed && databookRoute && !p.vclosed;
const hand = p.source_kind === "hand";
const check = p.vclose_check ?? null;
// Change C5: a VISUAL packet is closed when the feed sees every leg closed; a hand packet when Clerical says so.
const feedClosed = !hand && !!check?.closed_at;
const readyToClose = !filed && databookRoute && !p.vclosed && !feedClosed;
const vline = visualLine(check);
const seal = p.seal;
const book = filed && seal?.databook ? seal.databook : null;
const viewUrl = book?.url ?? packetsApi.pdfUrl(p.id, "databook");
const downloadUrl = book?.download_url ?? packetsApi.pdfUrl(p.id, "databook", true);
/** FILE. A 409 open_work_orders opens the confirm; File anyway sends it again with confirm_open_wos. */
const fileIt = async (confirmed: boolean) => {
const hit: { message?: string } = {};
await mutate(
async (g) => {
try {
return await packetsApi.transition(p.id, {
action: "file",
expect: { status: p.status, dept: p.dept.code },
...(confirmed ? { confirm_open_wos: true } : {}),
...g,
});
} catch (err) {
if (!confirmed && errorCode(err) === "open_work_orders") {
hit.message = errorText(err);
return undefined;
}
throw err;
}
},
{ ok: "Filed in the data book." },
);
if (hit.message !== undefined) setConfirm(hit.message);
};
/** FILE. The server refuses it while VISUAL shows a work order open (change C5); `blocked.file` says why. */
const fileIt = () =>
mutate((g) => packetsApi.transition(p.id, { action: "file", expect: { status: p.status, dept: p.dept.code }, ...g }), {
ok: "Filed in the data book.",
});
const reopen = async () => {
const reason = await askReason({
@@ -162,7 +150,10 @@ export function CloseFile({
) : readyToClose ? (
<>
<div>
<b>Back from the shop, ready to close.</b> Close the job in VISUAL, then click below. The packet moves on to the data book.
<b>Back from the shop, ready to close.</b>{" "}
{hand
? "Close the job in VISUAL, then click below. The packet moves on to the data book."
: "Close the job in VISUAL. The packet moves on to the data book when the feed sees it closed."}
</div>
{vline ? (
<div className="small" data-testid="visual-line">
@@ -171,10 +162,12 @@ export function CloseFile({
) : null}
{reopened}
<div className="acts">
<Act p={p} action="set_vclosed" busy={busy} className="btn pri" onClick={() => void mutate((g) => packetsApi.setVclosed(p.id, true, g), { ok: "Marked closed in VISUAL." })}>
Job closed in VISUAL
</Act>
<Act p={p} action="file" busy={busy} onClick={() => void fileIt(false)}>
{hand ? (
<Act p={p} action="set_vclosed" busy={busy} className="btn pri" onClick={() => void mutate((g) => packetsApi.setVclosed(p.id, true, g), { ok: "Marked closed in VISUAL." })}>
Job closed in VISUAL
</Act>
) : null}
<Act p={p} action="file" busy={busy} onClick={() => void fileIt()}>
File in the data book
</Act>
{exports}
@@ -184,7 +177,7 @@ export function CloseFile({
<>
<div>
<b>Ready for the data book.</b>
{databookRoute && p.vclosed ? ` Job closed in VISUAL ${fmtTime(p.vclosed.at)} by ${p.vclosed.by?.display_name ?? "Clerical"}.` : ""} Check the data book, then file it.
{databookRoute ? closedText(p) : ""} Check the data book, then file it.
</div>
{(check?.open_wos ?? []).length > 0 && vline ? (
<div className="callout warn flush" data-testid="visual-line">
@@ -193,11 +186,11 @@ export function CloseFile({
) : null}
{reopened}
<div className="acts">
<Act p={p} action="file" busy={busy} className="btn pri" onClick={() => void fileIt(false)}>
<Act p={p} action="file" busy={busy} className="btn pri" onClick={() => void fileIt()}>
File in the data book
</Act>
{exports}
{databookRoute ? (
{databookRoute && hand ? (
<Act p={p} action="set_vclosed" busy={busy} className="btn ghost" onClick={() => void mutate((g) => packetsApi.setVclosed(p.id, false, g))}>
Not closed in VISUAL, move back
</Act>
@@ -205,27 +198,6 @@ export function CloseFile({
</div>
</>
)}
{confirm !== null ? (
<Sheet title="File with open work orders?" sub={sub} onClose={() => setConfirm(null)}>
<p>{confirm}</p>
<div className="sheet-acts">
<button className="btn ghost" type="button" onClick={() => setConfirm(null)}>
Cancel
</button>
<button
className="btn pri"
type="button"
disabled={busy}
onClick={() => {
setConfirm(null);
void fileIt(true);
}}
>
File anyway
</button>
</div>
</Sheet>
) : null}
</section>
);
}
+7 -3
View File
@@ -14,7 +14,7 @@ import { dueInfo, flagClass, jobName, pillClass, statusLabel, targetHours } from
import { Act } from "./Act";
import { CloseFile } from "./CloseFile";
import { DrawingViewer } from "./DrawingViewer";
import { openPrint } from "./closeUtil";
import { onPrintError, openPrint } from "./closeUtil";
import { PrintMenu } from "./PrintMenu";
import { MoveSheet, SendToDeptSheet } from "./sheets";
import { DocumentsTab, HistoryTab, MaterialsTab, WorkOrdersTab, type Mutate } from "./tabs";
@@ -146,6 +146,9 @@ export function PacketPage() {
versionRef.current = packet?.version ?? null;
}, [packet]);
// A print that could not be made (change C4) says so where the page's errors show.
useEffect(() => onPrintError((text) => setMsg({ ok: false, text })), []);
useEffect(() => {
if (!toast) return;
const t = window.setTimeout(() => setToast(null), TOAST_MS);
@@ -300,8 +303,9 @@ export function PacketPage() {
const hasQc = !!p.qc || p.qc_history.length > 0;
const hidden = (tab === "changes" && !(p.change_orders ?? []).length) || (tab === "qc" && !hasQc);
const shownTab: Tab = hidden ? "materials" : (tab ?? (drawingPages.length ? "drawing" : "materials"));
// The warehouse short-parts exception and the data book date are not edits of the packet itself.
const passive = new Set(["wh_receive_short", "set_databook_at"]);
// The warehouse short-parts exception, the supervisor's short approval and the data book date are not edits of
// the packet itself.
const passive = new Set(["wh_receive_short", "set_databook_at", "short_approve"]);
const viewOnly =
p.can.every((a) => passive.has(a)) && Object.keys(p.blocked).every((a) => passive.has(a));
const project = p.unit.project;
+35 -10
View File
@@ -4,7 +4,12 @@ import type { PacketPage, PacketRead } from "../api/types";
import { DEPARTMENTS, makeMe, renderApp } from "../test/auth";
import { mockFetch, type Route } from "../test/fetchMock";
import { makePacket } from "../test/packets";
import { kioskCode, printRows } from "./closeUtil";
import { printPdf } from "../lib/printPdf";
import { kioskCode, PRINT_FAILED, printRows } from "./closeUtil";
// Change C4: every print goes to the browser's print dialog through printPdf; jsdom has no pdf.js or print.
vi.mock("../lib/printPdf", () => ({ printPdf: vi.fn(() => Promise.resolve()) }));
const printed = vi.mocked(printPdf);
function page(n: number, over: Partial<PacketPage> = {}): PacketPage {
return { n, type: "drawing", asset_id: `as-${n}`, pn: n, dwg: "CI-DWG-1", rev: "0", void: null, url: `/_blob/as-${n}`, ...over };
@@ -29,6 +34,8 @@ const rowNames = () =>
afterEach(() => {
vi.restoreAllMocks();
printed.mockReset();
printed.mockImplementation(() => Promise.resolve());
});
describe("printRows", () => {
@@ -52,7 +59,7 @@ describe("printRows", () => {
});
describe("Print menu", () => {
it("opens from the header, lists the rows, and a row opens its print in a new tab", async () => {
it("opens from the header, lists the rows, and a row prints straight from the page", async () => {
const open = vi.spyOn(window, "open").mockImplementation(() => null);
mockFetch(base(makePacket({ pages: [page(1)] })));
renderApp("/packets/1");
@@ -65,7 +72,8 @@ describe("Print menu", () => {
);
expect(sheet).toHaveTextContent('Every printed page is stamped with who printed it and when, plus "verify revision before use". Printing is recorded.');
fireEvent.click(within(sheet).getByRole("button", { name: "Drawing with weld map" }));
expect(open).toHaveBeenCalledWith("/api/packets/1/print/map-w", "_blank", "noopener");
expect(printed).toHaveBeenCalledWith("/api/packets/1/print/map-w");
expect(open).not.toHaveBeenCalled();
await waitFor(() => expect(screen.queryByRole("dialog", { name: "Print" })).not.toBeInTheDocument());
});
@@ -77,7 +85,8 @@ describe("Print menu", () => {
await screen.findByRole("dialog", { name: "Print" });
expect(rowNames()).toEqual(["Job traveler", "Drawing with heat map", "Drawing with weld map", "Drawing (no markup)", "Superseded drawings (VOID)", "Data book", "Cancel"]);
fireEvent.click(screen.getByRole("button", { name: "Data book" }));
expect(open).toHaveBeenCalledWith("/api/packets/1/print/databook", "_blank", "noopener");
expect(printed).toHaveBeenCalledWith("/api/packets/1/print/databook");
expect(open).not.toHaveBeenCalled();
});
it("Cancel closes it without opening anything", async () => {
@@ -88,6 +97,16 @@ describe("Print menu", () => {
fireEvent.click(within(await screen.findByRole("dialog", { name: "Print" })).getByRole("button", { name: "Cancel" }));
await waitFor(() => expect(screen.queryByRole("dialog", { name: "Print" })).not.toBeInTheDocument());
expect(open).not.toHaveBeenCalled();
expect(printed).not.toHaveBeenCalled();
});
it("says so when the print cannot be made", async () => {
printed.mockImplementation(() => Promise.reject(new Error("Unexpected server response (500)")));
mockFetch(base(makePacket({ pages: [page(1)] })));
renderApp("/packets/1");
fireEvent.click(await screen.findByRole("button", { name: "Print…" }));
fireEvent.click(within(await screen.findByRole("dialog", { name: "Print" })).getByRole("button", { name: "Job traveler" }));
expect(await screen.findByRole("alert")).toHaveTextContent(PRINT_FAILED);
});
it("is not offered on a removed packet", async () => {
@@ -113,7 +132,8 @@ describe("Print entry points on the tabs", () => {
);
expect(screen.getByTestId("kiosk-code")).toHaveTextContent("*%2L999001$7A$0$0$10%*");
fireEvent.click(screen.getByRole("button", { name: "Print traveler" }));
expect(open).toHaveBeenCalledWith("/api/packets/1/print/traveler", "_blank", "noopener");
expect(printed).toHaveBeenCalledWith("/api/packets/1/print/traveler");
expect(open).not.toHaveBeenCalled();
const pdf = screen.getByRole("link", { name: "Traveler (PDF)" });
expect(pdf).toHaveAttribute("href", "/api/packets/1/pdf/traveler");
expect(pdf).toHaveAttribute("target", "_blank");
@@ -126,7 +146,8 @@ describe("Print entry points on the tabs", () => {
renderApp("/packets/1");
fireEvent.click(await screen.findByRole("tab", { name: /^Materials/ }));
fireEvent.click(screen.getByRole("button", { name: "Print" }));
expect(open).toHaveBeenCalledWith("/api/packets/1/print/doc?asset=mtr-3", "_blank", "noopener");
expect(printed).toHaveBeenCalledWith("/api/packets/1/print/doc?asset=mtr-3");
expect(open).not.toHaveBeenCalled();
});
it("the Documents tab prints a packet document and a folder document", async () => {
@@ -139,9 +160,11 @@ describe("Print entry points on the tabs", () => {
renderApp("/packets/1");
fireEvent.click(await screen.findByRole("tab", { name: /^Documents/ }));
fireEvent.click(within(screen.getByRole("region", { name: "Documents for the data book" })).getByRole("button", { name: "Print" }));
expect(open).toHaveBeenLastCalledWith("/api/packets/1/print/doc?asset=a4", "_blank", "noopener");
expect(printed).toHaveBeenLastCalledWith("/api/packets/1/print/doc?asset=a4");
expect(open).not.toHaveBeenCalled();
fireEvent.click(within(screen.getByRole("region", { name: "Also in the data book from the Q.C. folder" })).getByRole("button", { name: "Print" }));
expect(open).toHaveBeenLastCalledWith("/api/packets/1/print/doc?asset=f1", "_blank", "noopener");
expect(printed).toHaveBeenLastCalledWith("/api/packets/1/print/doc?asset=f1");
expect(open).not.toHaveBeenCalled();
});
it("the Drawing tab prints the shown sheet", async () => {
@@ -151,7 +174,8 @@ describe("Print entry points on the tabs", () => {
await screen.findByRole("tab", { name: /^Drawing/ });
fireEvent.click(screen.getByRole("button", { name: /^p\.2/ }));
fireEvent.click(screen.getByRole("button", { name: "Print this sheet" }));
expect(open).toHaveBeenCalledWith("/api/packets/1/print/clean?sheet=2", "_blank", "noopener");
expect(printed).toHaveBeenCalledWith("/api/packets/1/print/clean?sheet=2");
expect(open).not.toHaveBeenCalled();
});
it.each([
@@ -164,7 +188,8 @@ describe("Print entry points on the tabs", () => {
fireEvent.click(await screen.findByRole("tab", { name: new RegExp(`^${tab}`) }));
fireEvent.click(within(screen.getByRole("group", { name: "Sheets" })).getAllByRole("button")[1]!);
fireEvent.click(screen.getByRole("button", { name: "Print this sheet" }));
expect(open).toHaveBeenCalledWith(`/api/packets/1/print/${kind}?sheet=2`, "_blank", "noopener");
expect(printed).toHaveBeenCalledWith(`/api/packets/1/print/${kind}?sheet=2`);
expect(open).not.toHaveBeenCalled();
});
});
+2 -2
View File
@@ -3,7 +3,7 @@ import type { PacketRead } from "../api/types";
import { Sheet } from "../components/Sheet";
import { openPrint, printRows } from "./closeUtil";
/** The print menu: one row per print kind. A row opens the stamped PDF in a new tab and closes the sheet. */
/** The print menu: one row per print kind. A row closes the sheet and opens the print dialog on the stamped copy. */
export function PrintMenu({ p, sub, onClose }: { p: PacketRead; sub: string; onClose: () => void }) {
const base = useId();
const rows = printRows(p);
@@ -18,8 +18,8 @@ export function PrintMenu({ p, sub, onClose }: { p: PacketRead; sub: string; onC
aria-label={r.label}
aria-describedby={`${base}-${r.kind}`}
onClick={() => {
openPrint(p.id, r.kind);
onClose();
openPrint(p.id, r.kind);
}}
>
<span className="t">
+47 -9
View File
@@ -16,7 +16,7 @@ const MISSING_PREFIX = "Q.C. still needs";
const REJECT_MIN = 8;
/** The reject sheet: a reason, an NCR number, or both. */
/** The reject sheet: a reason always (change C2), and the NCR number when there is one. */
export function RejectSheet({
sub,
onCancel,
@@ -24,31 +24,35 @@ export function RejectSheet({
}: {
sub: string;
onCancel: () => void;
onReject: (body: { reason?: string; ncr?: string }) => void;
onReject: (body: { reason: string; ncr?: string }) => void;
}) {
const [reason, setReason] = useState("");
const [ncr, setNcr] = useState("");
const r = reason.trim();
const n = ncr.trim();
const ncrOk = isNcr(n);
const ok = r.length >= REJECT_MIN || ncrOk;
const ok = r.length >= REJECT_MIN && (!n || ncrOk);
const submit = (e: FormEvent) => {
e.preventDefault();
if (!ok) return;
onReject({ ...(r ? { reason: r } : {}), ...(ncrOk ? { ncr: normNcr(n) } : {}) });
onReject({ reason: r, ...(ncrOk ? { ncr: normNcr(n) } : {}) });
};
return (
<Sheet title="Reject" sub={sub} onClose={onCancel}>
<div className="small">Give a reason, an NCR number, or both. The supervisor sees it at the top of the packet.</div>
<div className="small">
Say what failed, in detail. Add the NCR number if there is one. Without it, Q.C. enters the NCR number before the packet can be accepted. The
supervisor sees the reason at the top of the packet.
</div>
<form className="stack" onSubmit={submit} noValidate>
<label className="f">
Reason / what failed
<textarea aria-label="Reason / what failed" className="in" value={reason} maxLength={500} onChange={(e) => setReason(e.target.value)} />
</label>
{r && r.length < REJECT_MIN ? <div className="small muted">At least {REJECT_MIN} characters.</div> : null}
<label className="f">
NCR number
NCR number (optional)
<input aria-label="NCR number" className="in w220 mono" value={ncr} maxLength={20} placeholder="NCR-2026-0021" onChange={(e) => setNcr(e.target.value)} />
</label>
{n && !ncrOk ? <div className="small pinktxt">Enter an NCR number like NCR-2026-0021.</div> : null}
@@ -75,12 +79,18 @@ export function QcBar({ p, busy, mutate, sub, goQc }: { p: PacketRead; busy: boo
const depts = useShopDepartments().filter((d) => d.code !== p.dept.code);
const [route, setRoute] = useState<string>("databook");
const [rejecting, setRejecting] = useState(false);
const [ncr, setNcr] = useState("");
if (p.status !== "qc") return null;
if (!has(p, "qc_accept") && !has(p, "qc_accept_and_route") && !has(p, "qc_reject")) return null;
const step = makeStep(p, mutate);
const toDept = route.startsWith("DEPT:") ? route.slice(5) : null;
const action = toDept ? "qc_accept_and_route" : "qc_accept";
// Change C2: a reject without an NCR number since the last accept; the accept carries the number.
const needNcr = !!p.ncr_required;
const ncrText = ncr.trim();
const ncrOk = isNcr(ncrText);
const ncrBody = needNcr && ncrOk ? { ncr: normNcr(ncrText) } : {};
const canAccept = p.can.includes(action);
const blocked = canAccept ? [] : (p.blocked[action] ?? p.blocked.qc_accept ?? []);
const reason = blocked.join(" ");
@@ -89,8 +99,9 @@ export function QcBar({ p, busy, mutate, sub, goQc }: { p: PacketRead; busy: boo
const other = listed ? blocked.filter((m) => !m.startsWith(MISSING_PREFIX)) : blocked;
const accept = () => {
if (toDept) void step("qc_accept_and_route", { dept: toDept }, { sign: SIGN_QC_ACCEPT_ROUTE, ok: "Q.C. accepted. Sent to the department." });
else void step("qc_accept", { routed_to: route as RouteKind }, { sign: SIGN_QC_ACCEPT, ok: "Q.C. accepted." });
if (needNcr && !ncrOk) return;
if (toDept) void step("qc_accept_and_route", { dept: toDept, ...ncrBody }, { sign: SIGN_QC_ACCEPT_ROUTE, ok: "Q.C. accepted. Sent to the department." });
else void step("qc_accept", { routed_to: route as RouteKind, ...ncrBody }, { sign: SIGN_QC_ACCEPT, ok: "Q.C. accepted." });
};
return (
@@ -119,6 +130,11 @@ export function QcBar({ p, busy, mutate, sub, goQc }: { p: PacketRead; busy: boo
)}
</div>
{other.length ? <div className="why small">{other.join(" ")}</div> : null}
{needNcr && (has(p, "qc_accept") || has(p, "qc_accept_and_route")) ? (
<div className="small" data-testid="ncr-required">
This packet was rejected without an NCR number. Enter the NCR number of the reject to accept it.
</div>
) : null}
<div className="acts center">
{has(p, "qc_accept") || has(p, "qc_accept_and_route") ? (
<>
@@ -134,8 +150,30 @@ export function QcBar({ p, busy, mutate, sub, goQc }: { p: PacketRead; busy: boo
<option value="shipping">{ROUTE_SHIPPING}</option>
</select>
</label>
{needNcr ? (
<label className="f">
NCR number (required)
<input
aria-label="NCR number of the reject"
className="in w220 mono"
value={ncr}
maxLength={20}
placeholder="NCR-2026-0021"
required
aria-invalid={ncrText !== "" && !ncrOk}
onChange={(e) => setNcr(e.target.value)}
/>
{ncrText && !ncrOk ? <span className="small pinktxt">Enter an NCR number like NCR-2026-0021.</span> : null}
</label>
) : null}
<div className="act">
<button className="btn pri" type="button" disabled={!canAccept || busy} title={!canAccept ? reason : undefined} onClick={accept}>
<button
className="btn pri"
type="button"
disabled={!canAccept || busy || (needNcr && !ncrOk)}
title={!canAccept ? reason : needNcr && !ncrOk ? "Enter the NCR number of the reject first." : undefined}
onClick={accept}
>
Accept
</button>
</div>
+63
View File
@@ -0,0 +1,63 @@
// Change C7: every received short needs the department supervisor's approval, one receipt at a time.
import { packetsApi } from "../api/packets";
import type { PacketRead, ShortReceipt } from "../api/types";
import { fmtTime } from "../lib/format";
import type { Mutate } from "./tabs";
/** The rule's signature meaning for SHORT_APPROVE. */
export const SIGN_SHORT_APPROVE = "Approve a received short";
/** "Received 2 · heat H123 · by Wes Warehouse <time>". */
function receiptText(r: ShortReceipt): string {
const parts = [`Received ${String(r.qty)}`, r.heat ? `heat ${r.heat}` : null, r.by?.display_name ? `by ${r.by.display_name}` : null, r.at ? fmtTime(r.at) : null];
return parts.filter((x): x is string => !!x).join(" · ");
}
/** "Approved by Sam Super <time>", with the note when there is one. */
function approvalText(a: NonNullable<ShortReceipt["approval"]>): string {
const head = ["Approved", a.by?.display_name ? `by ${a.by.display_name}` : null, a.at ? fmtTime(a.at) : null].filter((x): x is string => !!x).join(" ");
return a.note ? `${head}: ${a.note}` : head;
}
/** The receipts of one BOM line: who received each, who approved it, and Approve for a supervisor who may. */
export function ShortReceipts({ p, receipts, item, busy, mutate }: { p: PacketRead; receipts: ShortReceipt[]; item: number; busy: boolean; mutate: Mutate }) {
if (!receipts.length) return null;
const canApprove = p.can.includes("short_approve") && !p.removed;
const approve = (r: ShortReceipt) => {
if (r.id === undefined) return;
const id = r.id;
void mutate((g) => packetsApi.approveShortReceipt(p.id, id, {}, g), { sign: SIGN_SHORT_APPROVE, ok: "Short approved." });
};
return (
<div className="stack tight small" role="group" aria-label={`Receipts for item ${item}`}>
{receipts.map((r, i) => (
<div key={r.id ?? i} data-testid="short-receipt">
<div>{receiptText(r)}</div>
{r.approval ? (
<div>
<span className="pill st-ok">Approved</span> {approvalText(r.approval).replace(/^Approved ?/, "")}
</div>
) : (
<div>
<span className="due soon">Waiting for the supervisor&apos;s approval.</span>
{canApprove && r.id !== undefined ? (
<>
{" "}
<button
className="btn sm pri"
type="button"
disabled={busy}
title="Check the part and its heat number against what the warehouse issued, then approve."
onClick={() => approve(r)}
>
Approve
</button>
</>
) : null}
</div>
)}
</div>
))}
</div>
);
}
+23 -3
View File
@@ -3,19 +3,39 @@
import { packetsApi } from "../api/packets";
import type { PacketRead, PrintKind, PrintRow, VcloseCheck, WorkOrder } from "../api/types";
import { fmtTime } from "../lib/format";
import { printPdf } from "../lib/printPdf";
/** The rule's signature meanings for FILE and ADMIN_REOPEN. */
export const SIGN_FILE = "File in the data book";
export const SIGN_REOPEN = "Reopen a filed packet";
/** Opens a PDF (or any server URL) in a new tab. Every PDF view and print in the packet goes through here. */
/** Opens a PDF (or any server URL) in a new tab, to view it. Prints do not come here (change C4). */
export function openTab(url: string): void {
window.open(url, "_blank", "noopener");
}
/** Opens a printed copy of one kind. The server stamps it and records the print. */
/** The short error when a print cannot be made. */
export const PRINT_FAILED = "The print could not be made. Try again.";
type PrintErrorListener = (text: string) => void;
const printErrorListeners = new Set<PrintErrorListener>();
/** The packet page listens here to show a failed print. Returns the unsubscribe. */
export function onPrintError(fn: PrintErrorListener): () => void {
printErrorListeners.add(fn);
return () => {
printErrorListeners.delete(fn);
};
}
/**
* Prints a stamped copy of one kind straight to the browser's print dialog (change C4). The server stamps it and
* records the print when the PDF is fetched. A failure goes to the onPrintError listeners.
*/
export function openPrint(id: number, kind: PrintKind, opts: { sheet?: number | undefined; asset?: string | undefined } = {}): void {
openTab(packetsApi.printUrl(id, kind, opts));
void printPdf(packetsApi.printUrl(id, kind, opts)).catch(() => {
for (const fn of printErrorListeners) fn(PRINT_FAILED);
});
}
/** The asset id of a "/_blob/<id>" URL, for documents whose answer has no asset_id. */
+43 -46
View File
@@ -79,12 +79,13 @@ afterEach(() => {
});
describe("Close and file: ready to close", () => {
it("shows the state, the exports, and confirms the close", async () => {
it("a hand packet: shows the state, the exports, and Clerical confirms the close", async () => {
const open = vi.spyOn(window, "open").mockImplementation(() => null);
const fetch = mockFetch(base(toClose(), { "POST /api/packets/1/vclosed": { body: toFile({ version: 4 }) } }));
const hand = { source_kind: "hand" as const, vclose_check: null };
const fetch = mockFetch(base(toClose(hand), { "POST /api/packets/1/vclosed": { body: toFile({ version: 4, ...hand }) } }));
renderApp("/packets/1");
const box = await panel();
expect(box).toHaveTextContent("Back from the shop, ready to close.");
expect(box).toHaveTextContent("Back from the shop, ready to close. Close the job in VISUAL, then click below.");
const file = within(box).getByRole("button", { name: "File in the data book" });
expect(file).toBeDisabled();
expect(within(box).getByText("Close the job in VISUAL first.")).toHaveClass("why");
@@ -102,10 +103,30 @@ describe("Close and file: ready to close", () => {
await waitFor(() => expect(screen.getByRole("region", { name: "Close and file" })).toHaveTextContent("Ready for the data book."));
expect(callsTo(fetch, "POST", "/api/packets/1/vclosed")).toEqual([{ closed: true }]);
expect(screen.getByRole("region", { name: "Close and file" })).toHaveTextContent(/Job closed in VISUAL .* by Clara Clerk\./);
expect(screen.getByRole("button", { name: "Not closed in VISUAL, move back" })).toBeEnabled();
});
it("a VISUAL packet waits for the feed: no manual close, File says why it is blocked", async () => {
const why = "VISUAL still shows a work order open on this packet. Close it in VISUAL first.";
mockFetch(base(toClose({ blocked: { file: [why] }, vclose_check: check({ open_wos: ["L999001/001"] }) })));
renderApp("/packets/1");
const box = await panel();
expect(box).toHaveTextContent("Back from the shop, ready to close. Close the job in VISUAL. The packet moves on to the data book when the feed sees it closed.");
expect(within(box).queryByRole("button", { name: "Job closed in VISUAL" })).not.toBeInTheDocument();
expect(within(box).getByRole("button", { name: "File in the data book" })).toBeDisabled();
expect(within(box).getByText(why)).toHaveClass("why");
});
it("a VISUAL packet the feed saw closed is ready for the data book", async () => {
mockFetch(base(toClose({ can: ["file"], blocked: {}, vclose_check: check({ closed_at: "2026-10-17T06:00:00Z", agrees: false }) })));
renderApp("/packets/1");
const box = await panel();
expect(box).toHaveTextContent(/^Ready for the data book\. VISUAL shows the job closed since .+\. Check the data book, then file it\./);
expect(within(box).getByRole("button", { name: "File in the data book" })).toBeEnabled();
expect(within(box).queryByRole("button", { name: "Not closed in VISUAL, move back" })).not.toBeInTheDocument();
});
it.each([
["the feed saw the job closed", check({ closed_at: "2026-10-17T06:00:00Z", agrees: false }), /^VISUAL shows the job closed since .+\.$/],
["work orders still open", check({ open_wos: ["L999001/001", "L999001/002"] }), /^VISUAL still shows 2 work orders open: L999001\/001, L999001\/002\.$/],
["one work order open", check({ open_wos: ["L999001/001"] }), /^VISUAL still shows 1 work order open: L999001\/001\.$/],
["the feed has not looked", check({ observed_at: null }), /^The feed has not checked VISUAL yet\.$/],
@@ -145,8 +166,9 @@ describe("Close and file: ready for the data book", () => {
);
renderApp("/packets/1");
const box = await panel();
expect(box).toHaveTextContent("Ready for the data book.");
expect(within(box).getByRole("button", { name: "Not closed in VISUAL, move back" })).toBeEnabled();
expect(box).toHaveTextContent(/^Ready for the data book\. Job closed in VISUAL .+ by Clara Clerk\./);
// A VISUAL packet: the feed decides, so there is no manual move back.
expect(within(box).queryByRole("button", { name: "Not closed in VISUAL, move back" })).not.toBeInTheDocument();
fireEvent.click(within(box).getByRole("button", { name: "File in the data book" }));
expect(await within(await screen.findByRole("dialog", { name: "Sign" })).findByText("File in the data book")).toBeInTheDocument();
await sign();
@@ -157,57 +179,32 @@ describe("Close and file: ready for the data book", () => {
]);
});
it("warns in a callout when VISUAL still shows work orders open", async () => {
mockFetch(base(toFile({ vclose_check: check({ open_wos: ["L999001/001"], agrees: false }) })));
it("File is blocked while VISUAL shows work orders open, with no second confirmation", async () => {
const why = "VISUAL still shows a work order open on this packet. Close it in VISUAL first.";
const fetch = mockFetch(base(toFile({ can: [], blocked: { file: [why] }, vclose_check: check({ open_wos: ["L999001/001"], agrees: false }) })));
renderApp("/packets/1");
const box = await panel();
const line = within(box).getByTestId("visual-line");
expect(line).toHaveClass("callout", "warn");
expect(line).toHaveTextContent("VISUAL still shows 1 work order open: L999001/001.");
const file = within(box).getByRole("button", { name: "File in the data book" });
expect(file).toBeDisabled();
expect(within(box).getByText(why)).toHaveClass("why");
fireEvent.click(file);
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
expect(callsTo(fetch, "POST", "/api/packets/1/transitions")).toHaveLength(0);
});
it("asks on 409 open_work_orders and sends it again with confirm_open_wos", async () => {
const msg = "VISUAL still shows 1 work order open on this packet: L999001/001. File anyway?";
it("shows the server's refusal when filing is refused after all", async () => {
const detail = "VISUAL still shows a work order open on this packet. Close it in VISUAL first.";
const fetch = mockFetch(
base(toFile({ vclose_check: check({ open_wos: ["L999001/001"], agrees: false }) }), {
"POST /api/packets/1/transitions": (req) => {
const b = req.body as { sign?: unknown; confirm_open_wos?: boolean };
if (!b.confirm_open_wos) return { status: 409, body: { code: "open_work_orders", detail: msg, work_orders: ["L999001/001"] } };
if (!b.sign) return { status: 400, body: { code: "signature_required", detail: "This step needs your signature.", meaning: "File in the data book" } };
return { body: filed({ seal: seal({ open_wos: ["L999001/001"] }) }) };
},
"/api/packets/1/seal": { body: sealView(true, seal({ open_wos: ["L999001/001"] })) },
}),
base(toFile(), { "POST /api/packets/1/transitions": { status: 409, body: { code: "precondition_failed", detail } } }),
);
renderApp("/packets/1");
fireEvent.click(within(await panel()).getByRole("button", { name: "File in the data book" }));
const sheet = await screen.findByRole("dialog", { name: "File with open work orders?" });
expect(within(sheet).getByText(msg)).toBeInTheDocument();
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
fireEvent.click(within(sheet).getByRole("button", { name: "File anyway" }));
await sign();
await waitFor(() => expect(screen.getByRole("region", { name: "Close and file" })).toHaveTextContent("Filed in the data book"));
expect(callsTo(fetch, "POST", "/api/packets/1/transitions")).toEqual([
{ action: "file", expect: { status: "complete", dept: "03" } },
{ action: "file", expect: { status: "complete", dept: "03" }, confirm_open_wos: true },
{ action: "file", expect: { status: "complete", dept: "03" }, confirm_open_wos: true, sign: { factor: "password", secret: "pw-1" } },
]);
expect(screen.getByRole("region", { name: "Close and file" })).toHaveTextContent("Filed with work orders open in VISUAL: L999001/001.");
});
it("Cancel on the confirm sends nothing more", async () => {
const fetch = mockFetch(
base(toFile(), {
"POST /api/packets/1/transitions": { status: 409, body: { code: "open_work_orders", detail: "VISUAL still shows 1 work order open.", work_orders: ["L999001/001"] } },
}),
);
renderApp("/packets/1");
fireEvent.click(within(await panel()).getByRole("button", { name: "File in the data book" }));
const sheet = await screen.findByRole("dialog", { name: "File with open work orders?" });
fireEvent.click(within(sheet).getByRole("button", { name: "Cancel" }));
await waitFor(() => expect(screen.queryByRole("dialog", { name: "File with open work orders?" })).not.toBeInTheDocument());
expect(callsTo(fetch, "POST", "/api/packets/1/transitions")).toHaveLength(1);
expect(screen.getByRole("region", { name: "Close and file" })).toHaveTextContent("Ready for the data book.");
expect(await screen.findByRole("alert")).toHaveTextContent(detail);
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
expect(callsTo(fetch, "POST", "/api/packets/1/transitions")).toEqual([{ action: "file", expect: { status: "complete", dept: "03" } }]);
});
});
+77 -6
View File
@@ -178,29 +178,100 @@ describe("Q.C. bar", () => {
]);
});
it("the reject sheet enables on an NCR alone and sends it upper-cased", async () => {
it("the reject sheet needs a reason of 8 characters; the NCR number is optional", async () => {
const fetch = mockFetch({ ...base(qcPacket()), "POST /api/packets/1/transitions": { body: makePacket({ status: "review", version: 6, can: [], blocked: {} }) } });
renderApp("/packets/1");
const bar = await screen.findByRole("region", { name: "Q.C. inspection" });
fireEvent.click(within(bar).getByRole("button", { name: "Reject" }));
const sheet = await screen.findByRole("dialog", { name: "Reject" });
expect(within(sheet).getByText("Give a reason, an NCR number, or both. The supervisor sees it at the top of the packet.")).toBeInTheDocument();
expect(sheet).toHaveTextContent("Say what failed, in detail. Add the NCR number if there is one.");
const submit = within(sheet).getByRole("button", { name: "Reject to supervisor" });
expect(submit).toBeDisabled();
fireEvent.change(within(sheet).getByLabelText("Reason / what failed"), { target: { value: "short" } });
expect(submit).toBeDisabled();
// An NCR number alone no longer stands in for the reason.
const ncr = within(sheet).getByLabelText("NCR number");
expect(ncr).toHaveAttribute("placeholder", "NCR-2026-0021");
fireEvent.change(within(sheet).getByLabelText("Reason / what failed"), { target: { value: "" } });
fireEvent.change(ncr, { target: { value: "ncr-2026-0021" } });
expect(submit).toBeDisabled();
const reason = within(sheet).getByLabelText("Reason / what failed");
fireEvent.change(reason, { target: { value: "short" } });
expect(submit).toBeDisabled();
expect(within(sheet).getByText("At least 8 characters.")).toBeInTheDocument();
fireEvent.change(reason, { target: { value: "undercut at W1" } });
expect(submit).toBeEnabled();
// A malformed NCR number holds it back.
fireEvent.change(ncr, { target: { value: "NCR-26" } });
expect(submit).toBeDisabled();
fireEvent.change(ncr, { target: { value: "ncr-2026-0021" } });
fireEvent.click(submit);
expect(await within(await screen.findByRole("dialog", { name: "Sign" })).findByText("Q.C. reject")).toBeInTheDocument();
await sign();
expect(callsTo(fetch, "POST", "/api/packets/1/transitions")).toEqual([
{ action: "qc_reject", expect: { status: "qc", dept: "03" }, ncr: "NCR-2026-0021", sign: { factor: "password", secret: "pw-1" } },
{ action: "qc_reject", expect: { status: "qc", dept: "03" }, reason: "undercut at W1", ncr: "NCR-2026-0021", sign: { factor: "password", secret: "pw-1" } },
]);
});
it("rejects with a reason and no NCR number", async () => {
const fetch = mockFetch({ ...base(qcPacket()), "POST /api/packets/1/transitions": { body: makePacket({ status: "review", version: 6, can: [], blocked: {} }) } });
renderApp("/packets/1");
fireEvent.click(within(await screen.findByRole("region", { name: "Q.C. inspection" })).getByRole("button", { name: "Reject" }));
const sheet = await screen.findByRole("dialog", { name: "Reject" });
fireEvent.change(within(sheet).getByLabelText("Reason / what failed"), { target: { value: " porosity in W2 " } });
fireEvent.click(within(sheet).getByRole("button", { name: "Reject to supervisor" }));
await sign();
expect(callsTo(fetch, "POST", "/api/packets/1/transitions")).toEqual([
{ action: "qc_reject", expect: { status: "qc", dept: "03" }, reason: "porosity in W2", sign: { factor: "password", secret: "pw-1" } },
]);
});
it("after a reject without an NCR number, Accept needs the NCR number and sends it", async () => {
const p = qcPacket({ qc: inspection({ missing: [] }), ncr_required: true, can: ["qc_accept", "qc_accept_and_route", "qc_reject"], blocked: {} });
const fetch = mockFetch({
...base(p),
"POST /api/packets/1/transitions": { body: makePacket({ status: "complete", version: 6, routed_to: "databook", can: [], blocked: {}, holder: "Clerical" }) },
});
renderApp("/packets/1");
const bar = await screen.findByRole("region", { name: "Q.C. inspection" });
expect(within(bar).getByTestId("ncr-required")).toHaveTextContent("This packet was rejected without an NCR number. Enter the NCR number of the reject to accept it.");
const accept = within(bar).getByRole("button", { name: "Accept" });
expect(accept).toBeDisabled();
expect(accept).toHaveAttribute("title", "Enter the NCR number of the reject first.");
const ncr = within(bar).getByLabelText("NCR number of the reject");
expect(ncr).toBeRequired();
fireEvent.change(ncr, { target: { value: "NCR-2026" } });
expect(accept).toBeDisabled();
expect(within(bar).getByText("Enter an NCR number like NCR-2026-0021.")).toBeInTheDocument();
fireEvent.change(ncr, { target: { value: " ncr-2026-0042 " } });
expect(accept).toBeEnabled();
fireEvent.click(accept);
await sign();
expect(callsTo(fetch, "POST", "/api/packets/1/transitions")).toEqual([
{ action: "qc_accept", expect: { status: "qc", dept: "03" }, routed_to: "databook", ncr: "NCR-2026-0042", sign: { factor: "password", secret: "pw-1" } },
]);
});
it("accept and route also carries the NCR number when it is required", async () => {
const p = qcPacket({ qc: inspection({ missing: [] }), ncr_required: true, can: ["qc_accept", "qc_accept_and_route", "qc_reject"], blocked: {} });
const fetch = mockFetch({ ...base(p), "POST /api/packets/1/transitions": { body: makePacket({ status: "ready", version: 6, can: [], blocked: {} }) } });
renderApp("/packets/1");
const bar = await screen.findByRole("region", { name: "Q.C. inspection" });
fireEvent.change(within(bar).getByLabelText("Route"), { target: { value: "DEPT:09" } });
fireEvent.change(within(bar).getByLabelText("NCR number of the reject"), { target: { value: "NCR-2026-0042" } });
fireEvent.click(within(bar).getByRole("button", { name: "Accept" }));
await sign();
expect(callsTo(fetch, "POST", "/api/packets/1/transitions")).toEqual([
{ action: "qc_accept_and_route", expect: { status: "qc", dept: "03" }, dept: "09", ncr: "NCR-2026-0042", sign: { factor: "password", secret: "pw-1" } },
]);
});
it("no NCR field when none is required", async () => {
const p = qcPacket({ qc: inspection({ missing: [] }), ncr_required: false, can: ["qc_accept", "qc_accept_and_route", "qc_reject"], blocked: {} });
mockFetch(base(p));
renderApp("/packets/1");
const bar = await screen.findByRole("region", { name: "Q.C. inspection" });
expect(within(bar).queryByLabelText("NCR number of the reject")).not.toBeInTheDocument();
expect(within(bar).queryByTestId("ncr-required")).not.toBeInTheDocument();
expect(within(bar).getByRole("button", { name: "Accept" })).toBeEnabled();
});
});
describe("Callouts", () => {
+10 -1
View File
@@ -1,7 +1,7 @@
// Small helpers the shop tabs share: the drawing page list, the user's level, the department people.
import { useEffect, useState } from "react";
import { packetsApi, peopleApi } from "../api/packets";
import type { DeptPerson, Guarded, PacketPage, PacketRead, PacketStatus, TransitionBody } from "../api/types";
import type { BomLine, DeptPerson, Guarded, PacketPage, PacketRead, PacketStatus, ShortReceipt, TransitionBody } from "../api/types";
import type { Mutate } from "./tabs";
import { parseRole } from "../auth/roles";
@@ -102,3 +102,12 @@ export function makeStep(p: PacketRead, mutate: Mutate): StepFn {
return (action, extra = {}, opts = {}) =>
mutate((g: Guarded) => packetsApi.transition(p.id, { action, expect, ...extra, ...g }), { sign: opts.sign, ok: opts.ok });
}
/**
* The receipts of a BOM line's shorts, open or closed: `shorts` on the read when the server sends it, else the
* open short on the line.
*/
export function lineReceipts(p: PacketRead, b: BomLine): ShortReceipt[] {
if (p.shorts) return p.shorts.filter((s) => s.item === b.item).flatMap((s) => s.receipts ?? []);
return b.short?.receipts ?? [];
}
+127
View File
@@ -0,0 +1,127 @@
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import type { BomLine, BomShort, PacketRead, PacketShort } from "../api/types";
import { DEPARTMENTS, makeMe, renderApp } from "../test/auth";
import { callsTo, mockFetch } from "../test/fetchMock";
import { makePacket } from "../test/packets";
const WES = { id: "33333333-3333-3333-3333-333333333333", display_name: "Wes Warehouse" };
const SAM = { id: "44444444-4444-4444-4444-444444444444", display_name: "Sam Super" };
function short(over: Partial<BomShort> = {}): BomShort {
return {
id: 5,
qty: 2,
qty_open: 0,
reason: "Not in stock",
ref: "PO-1",
eta: null,
note: null,
opened_at: "2026-10-16T08:00:00Z",
receipts: [
{ id: 11, qty: 1, heat: "H100", note: null, at: "2026-10-17T08:00:00Z", by: WES, approval: { at: "2026-10-17T09:00:00Z", by: SAM, note: null } },
{ id: 12, qty: 1, heat: "H200", note: null, at: "2026-10-18T08:00:00Z", by: WES, approval: null },
],
row_version: 2,
...over,
};
}
/** The read's `shorts` row of a short. */
function row(s: BomShort, closed = true): PacketShort {
return { ...s, item: 3, closed_at: closed ? "2026-10-18T08:00:00Z" : null, receipts: s.receipts ?? [] };
}
function line(s: BomShort | null): BomLine {
return { item: 3, qty: 2, part_id: "P-1", description: "Pipe", dwg_ref: null, length: null, material: "A106", heat: "H100", pulled: false, short: s };
}
function packet(over: Partial<PacketRead> = {}): PacketRead {
// A fully received short is closed: it leaves the BOM line and stays in `shorts`.
return makePacket({
status: "in_progress",
holder: "03 Weld Parts",
bom: [line(null)],
shorts: [row(short())],
shorts_unapproved: 1,
can: ["short_approve"],
blocked: {},
...over,
});
}
function base(p: PacketRead, roles = ["shop:03:supervisor"]) {
return {
"/api/auth/me": { body: makeMe({ roles, screens: ["supervisor"] }) },
"/api/departments": { body: DEPARTMENTS },
"/api/packets/1": { body: p },
"/api/packets/1/log": { body: [] },
};
}
async function openMaterials() {
renderApp("/packets/1");
fireEvent.click(await screen.findByRole("tab", { name: /^Materials/ }));
return screen.getByRole("group", { name: "Receipts for item 3" });
}
describe("Short receipts (change C7)", () => {
it("shows who received each receipt and who approved it", async () => {
mockFetch(base(packet({ can: [] })));
const box = await openMaterials();
const rows = within(box).getAllByTestId("short-receipt");
expect(rows).toHaveLength(2);
expect(rows[0]).toHaveTextContent(/^Received 1 · heat H100 · by Wes Warehouse · .+Approved by Sam Super /);
expect(rows[1]).toHaveTextContent(/^Received 1 · heat H200 · by Wes Warehouse · .+Waiting for the supervisor's approval\.$/);
expect(screen.getByText("Short received")).toHaveClass("pill", "st-ok");
expect(screen.getByTestId("shorts-unapproved")).toHaveTextContent("1 short is still to receive or approve.");
// Without short_approve there is no button.
expect(within(box).queryByRole("button", { name: "Approve" })).not.toBeInTheDocument();
});
it("a supervisor approves one receipt with the signature", async () => {
const done = packet({
version: 4,
shorts_unapproved: 0,
shorts: [row(short({ receipts: (short().receipts ?? []).map((r) => ({ ...r, approval: r.approval ?? { at: "2026-10-18T09:00:00Z", by: SAM, note: null } })) }))],
});
const fetch = mockFetch({
...base(packet()),
"POST /api/packets/1/shorts/receipts/12/approve": (req) =>
(req.body as { sign?: unknown }).sign
? { body: done }
: { status: 400, body: { code: "signature_required", detail: "This step needs your signature.", meaning: "Approve a received short" } },
});
const box = await openMaterials();
const approve = within(box).getAllByRole("button", { name: "Approve" });
expect(approve).toHaveLength(1);
fireEvent.click(approve[0]!);
const sheet = await screen.findByRole("dialog", { name: "Sign" });
expect(within(sheet).getByText("Approve a received short")).toBeInTheDocument();
fireEvent.change(within(sheet).getByLabelText("Password"), { target: { value: "pw-1" } });
fireEvent.click(within(sheet).getByRole("button", { name: "Sign" }));
await waitFor(() => expect(screen.queryByRole("dialog", { name: "Sign" })).not.toBeInTheDocument());
await waitFor(() => expect(screen.queryByTestId("shorts-unapproved")).not.toBeInTheDocument());
expect(screen.queryByRole("button", { name: "Approve" })).not.toBeInTheDocument();
expect(callsTo(fetch, "POST", "/api/packets/1/shorts/receipts/12/approve")).toEqual([{ sign: { factor: "password", secret: "pw-1" } }]);
});
it("an open short keeps its chip and lists the receipts so far", async () => {
const open = short({ qty_open: 1, receipts: [short().receipts![1]!] });
mockFetch(base(packet({ can: [], shorts_unapproved: 2, bom: [line(open)], shorts: [row(open, false)] })));
const box = await openMaterials();
expect(screen.getByText("Short 1")).toBeInTheDocument();
expect(within(box).getAllByTestId("short-receipt")).toHaveLength(1);
expect(screen.getByTestId("shorts-unapproved")).toHaveTextContent("2 shorts are still to receive or approve.");
});
it("without `shorts` on the read, the open short's own receipts show", async () => {
const open = short({ qty_open: 1, receipts: [short().receipts![1]!] });
const p = packet({ bom: [line(open)] });
delete p.shorts;
mockFetch(base(p));
const box = await openMaterials();
expect(within(box).getAllByTestId("short-receipt")).toHaveLength(1);
expect(within(box).getByRole("button", { name: "Approve" })).toBeEnabled();
});
});
+17 -3
View File
@@ -12,6 +12,8 @@ import { MtrViewer } from "../warehouse/MtrViewer";
import { PullTable, ShortChip, type Write } from "../warehouse/PullTable";
import { ShortSheet } from "../warehouse/ShortSheet";
import { assetOfUrl, kioskCode, openPrint, printLineText } from "./closeUtil";
import { ShortReceipts } from "./ShortReceipts";
import { lineReceipts } from "./shopUtil";
export type Mutate = (
fn: (g: Guarded) => Promise<PacketRead | null | undefined>,
@@ -173,6 +175,12 @@ export function MaterialsTab({
</button>
</div>
) : null}
{(p.shorts_unapproved ?? 0) > 0 ? (
<div className="small due soon" data-testid="shorts-unapproved">
{p.shorts_unapproved === 1 ? "1 short is" : `${p.shorts_unapproved} shorts are`} still to receive or approve. The department supervisor approves every
received short before the packet can be complete.
</div>
) : null}
{p.bom.length ? (
<div className="tw">
<table>
@@ -227,9 +235,7 @@ export function MaterialsTab({
) : null}
</td>
<td>
{b.na && !b.short ? (
<span className="pill st-filed">N/A</span>
) : b.short ? (
{b.short ? (
<div className="stack tight">
<ShortChip s={b.short} />
{canReceive && write ? (
@@ -237,7 +243,15 @@ export function MaterialsTab({
Receive
</button>
) : null}
<ShortReceipts p={p} receipts={lineReceipts(p, b)} item={b.item} busy={busy} mutate={mutate} />
</div>
) : lineReceipts(p, b).length ? (
<div className="stack tight">
<span className="pill st-ok">Short received</span>
<ShortReceipts p={p} receipts={lineReceipts(p, b)} item={b.item} busy={busy} mutate={mutate} />
</div>
) : b.na ? (
<span className="pill st-filed">N/A</span>
) : b.pulled ? (
<span className="pill st-ok">Pulled</span>
) : (