Build Seth's changes C1 to C11 (Gitea issues 1, 8, 18, 23, 24, 25, 27, 31, 32, 33, 36)
- 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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
@@ -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" } }]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 ?? [];
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
Reference in New Issue
Block a user