Phase 5: Supervisor and Stall screens, weld map, heat table, clock and CO verification
The shop at parity with the old build. Supervisor screen (department chips, CO headline, numbers, Change orders
on hold, station grid with clocks and CO tags, boards) and Stall screen (station picker, hero, My packets,
Submitted). Shop steps on the packet page: assign with station, welder picker and stamp; pull back; Start, Pause,
Resume; Submit with a PIN signature and the missing steps as links; review with Send to Q.C., Return to station
and Skip Q.C. route; a lead's read-only review. Weld map (pdf.js viewer with pan, pinch, zoom, full screen; marks
with leader lines; weld sheet; weld record with field CAS; carried marks). Heat map (heat table placed, dragged,
sized, removed; legacy amber tags; borrowed heat map read-only). Checklist tab with templates from settings.
VOID stamps on voided pages. Change orders: CO-n labels, supervisor verify with signature, approval copies the
heat table to the new sheet and the BOM merge keeps the shop's values. Server clock of record with VISUAL ticket
hours beside it. Presence per packet over SSE. Migration 0006 (bfx, bfy, carried_from, checklist_templates).
New endpoints /api/shop/board, /api/shop/stations, /api/shop/stall, /api/departments/{code}/people.
Tests: policy oracle for every shop action, role and status; clock arithmetic; concurrent inserts; CO apply;
frontend geometry, heat table, clock, checklist, weld sheet, screens; seven Playwright scenarios. Docs updated.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
@@ -0,0 +1,310 @@
|
||||
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { PacketRead, Stage } from "../api/types";
|
||||
import { DEPARTMENTS, makeMe, renderApp } from "../test/auth";
|
||||
import { callsTo, mockFetch } from "../test/fetchMock";
|
||||
import { makePacket } from "../test/packets";
|
||||
import { WeldSheet } from "./WeldSheet";
|
||||
|
||||
const DEPTS = DEPARTMENTS.map((d) => (d.code === "03" ? { ...d, weld: true, stations: [{ id: 1, name: "Stall 1" }, { id: 2, name: "Stall 2" }] } : d));
|
||||
|
||||
const CHECK = [
|
||||
{ group: "Before welding", key: "dwg", text: "Drawing CI-DWG-1 Rev 0 matches the packet" },
|
||||
{ group: "Before welding", key: "heat", text: "Heat numbers on material match the Materials tab" },
|
||||
{ group: "After welding", key: "vt", text: "Visual inspection of all welds acceptable" },
|
||||
];
|
||||
|
||||
function stage(over: Partial<Stage> = {}): Stage {
|
||||
return {
|
||||
id: 5,
|
||||
dept_code: "03",
|
||||
station: { id: 1, name: "Stall 1" },
|
||||
welder: { id: "andy", display_name: "Andy Welder", stamp: "75" },
|
||||
checklist: {},
|
||||
filler: null,
|
||||
filler_lot: null,
|
||||
preheat: null,
|
||||
notes: null,
|
||||
clock: { elapsed_ms: 3_723_000, running: true, running_since: "2026-10-16T08:00:00Z", started_at: "2026-10-16T08:00:00Z" },
|
||||
opened_at: "2026-10-16T07:00:00Z",
|
||||
row_version: 1,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function shopPacket(over: Partial<PacketRead> = {}): PacketRead {
|
||||
return makePacket({
|
||||
status: "in_progress",
|
||||
holder: "Stall 1",
|
||||
station: { id: 1, name: "Stall 1" },
|
||||
welder: { id: "andy", display_name: "Andy Welder", stamp: "75" },
|
||||
heat_map: true,
|
||||
bom: [
|
||||
{ item: 1, qty: 1, part_id: "P-1", description: "Pipe", dwg_ref: null, length: null, material: "A106", heat: "H1", row_version: 1 },
|
||||
{ item: 2, qty: 2, part_id: "P-2", description: "Flange", dwg_ref: null, length: null, material: "A105", heat: null, row_version: 1 },
|
||||
],
|
||||
work_orders: [{ id: 1, wo_base: "L999001", lot: "001", split: "0", sub: "0", qty: 3, target_hrs: 4.5, row_version: 1 }],
|
||||
checklist_items: CHECK,
|
||||
stage: stage(),
|
||||
can: [],
|
||||
blocked: {},
|
||||
...over,
|
||||
});
|
||||
}
|
||||
|
||||
function base(packet: PacketRead, roles: string[], name = "Wes Super") {
|
||||
return {
|
||||
"/api/auth/me": { body: makeMe({ roles, screens: ["supervisor", "stall"], user: { id: name === "Andy Welder" ? "andy" : "wes", display_name: name } }) },
|
||||
"/api/departments": { body: DEPTS },
|
||||
"/api/packets/1": { body: packet },
|
||||
"/api/packets/1/log": { body: [] },
|
||||
"/api/departments/03/people": { body: [{ id: "andy", display_name: "Andy Welder", stamp: "75", level: "worker", station_limits: [] }] },
|
||||
};
|
||||
}
|
||||
|
||||
async function sign(secret = "pw-1") {
|
||||
const sheet = await screen.findByRole("dialog", { name: "Sign" });
|
||||
fireEvent.change(within(sheet).getByLabelText("Password"), { target: { value: secret } });
|
||||
fireEvent.click(within(sheet).getByRole("button", { name: "Sign" }));
|
||||
await waitFor(() => expect(screen.queryByRole("dialog", { name: "Sign" })).not.toBeInTheDocument());
|
||||
}
|
||||
|
||||
describe("Checklist tab", () => {
|
||||
it("each tick PUTs its key and the answer replaces the packet", async () => {
|
||||
const p = shopPacket({ can: ["edit_checklist", "edit_stage_fields"] });
|
||||
const after = shopPacket({ version: 4, can: ["edit_checklist", "edit_stage_fields"], stage: stage({ checklist: { dwg: { done: true, at: "2026-10-16T09:00:00Z", stamp: "75" } } }) });
|
||||
const fetch = mockFetch({ ...base(p, ["shop:03:worker"], "Andy Welder"), "PUT /api/packets/1/stage/checklist/dwg": { body: after } });
|
||||
renderApp("/packets/1");
|
||||
fireEvent.click(await screen.findByRole("tab", { name: /Checklist/ }));
|
||||
const box = screen.getByRole("checkbox", { name: /Drawing CI-DWG-1 Rev 0 matches the packet/ });
|
||||
expect(box).toBeEnabled();
|
||||
fireEvent.click(box);
|
||||
await waitFor(() => expect(screen.getByRole("checkbox", { name: /Drawing CI-DWG-1/ })).toBeChecked());
|
||||
expect(callsTo(fetch, "PUT", "/api/packets/1/stage/checklist/dwg")).toEqual([{ done: true }]);
|
||||
expect(screen.getByText(/^75 · /)).toBeInTheDocument();
|
||||
expect(screen.getByRole("group", { name: "Weld record" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("is read-only without edit_checklist", async () => {
|
||||
mockFetch(base(shopPacket({ can: [] }), ["shop:03:lead"]));
|
||||
renderApp("/packets/1");
|
||||
fireEvent.click(await screen.findByRole("tab", { name: /Checklist/ }));
|
||||
for (const box of screen.getAllByRole("checkbox")) expect(box).toBeDisabled();
|
||||
expect(screen.getByText("Checklist is completed at the stall.")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Weld record", () => {
|
||||
it("saves a field on blur with its base", async () => {
|
||||
const p = shopPacket({ can: ["edit_checklist", "edit_stage_fields"] });
|
||||
const fetch = mockFetch({ ...base(p, ["shop:03:worker"], "Andy Welder"), "PATCH /api/packets/1/stage": { body: shopPacket({ version: 4, stage: stage({ filler: "ER70S-6" }) }) } });
|
||||
renderApp("/packets/1");
|
||||
fireEvent.click(await screen.findByRole("tab", { name: /Checklist/ }));
|
||||
const filler = screen.getByLabelText("Filler metal");
|
||||
fireEvent.change(filler, { target: { value: "ER70S-6" } });
|
||||
fireEvent.blur(filler);
|
||||
await waitFor(() => expect(callsTo(fetch, "PATCH", "/api/packets/1/stage")).toEqual([{ set: { filler: "ER70S-6" }, base: { filler: null } }]));
|
||||
});
|
||||
});
|
||||
|
||||
describe("Weld sheet", () => {
|
||||
it("needs at least one item, then sends the point and the default label position", () => {
|
||||
const onSave = vi.fn();
|
||||
render(
|
||||
<WeldSheet
|
||||
p={shopPacket()}
|
||||
weld={null}
|
||||
at={{ page: 1, fx: 0.5, fy: 0.5 }}
|
||||
editable
|
||||
myStamp="75"
|
||||
sub="L999001 · WELD-PART"
|
||||
onSave={onSave}
|
||||
onRemove={() => undefined}
|
||||
onClose={() => undefined}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByLabelText("Size")).toHaveValue('5/16"');
|
||||
expect(screen.getByLabelText("Type")).toHaveValue("Fillet");
|
||||
expect(screen.getByLabelText("Process")).toHaveValue("GMAW");
|
||||
expect(screen.getByLabelText("Welder stamp")).toHaveValue("75");
|
||||
expect(screen.getByLabelText("Piece")).toHaveTextContent("1 of 3");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Log weld" }));
|
||||
expect(screen.getByRole("alert")).toHaveTextContent("Pick at least one BOM item this weld joins.");
|
||||
expect(onSave).not.toHaveBeenCalled();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Item 2" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Item 1" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Log weld" }));
|
||||
expect(onSave).toHaveBeenCalledTimes(1);
|
||||
const body = onSave.mock.calls[0]?.[0] as Record<string, unknown>;
|
||||
expect(body).toMatchObject({ page: 1, fx: 0.5, fy: 0.5, wo_base: "L999001", piece: 1, items: [1, 2], type: "Fillet", size: '5/16"', process: "GMAW", stamp: "75" });
|
||||
expect(body.bfx).toBeCloseTo(0.55);
|
||||
expect(body.bfy).toBeCloseTo(0.43);
|
||||
});
|
||||
|
||||
it("is read-only without edit rights", () => {
|
||||
render(
|
||||
<WeldSheet
|
||||
p={shopPacket()}
|
||||
weld={{ weld_no: 4, page: 1, fx: 0.2, fy: 0.3, wo_base: "L999001", piece: 1, items: [1], type: "Groove", size: "1/4\"", process: "GTAW", stamp: "75", at: null, row_version: 1, welder: null }}
|
||||
at={null}
|
||||
editable={false}
|
||||
myStamp={null}
|
||||
sub=""
|
||||
onSave={() => undefined}
|
||||
onRemove={() => undefined}
|
||||
onClose={() => undefined}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole("heading", { name: "Weld W4" })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Save changes" })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Close" })).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Type")).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Packet page action bars", () => {
|
||||
it("sup.wes on a review packet: Send to Q.C. waits for both boxes and a location, then signs", async () => {
|
||||
const p = shopPacket({ status: "review", holder: "Supervisor", stage: stage({ clock: { elapsed_ms: 1000, running: false, running_since: null, started_at: null } }), can: ["send_to_qc", "return_to_station", "route_from_review"] });
|
||||
const fetch = mockFetch({
|
||||
...base(p, ["shop:03:supervisor"]),
|
||||
"POST /api/packets/1/transitions": { body: shopPacket({ status: "qc", version: 5, holder: "Q.C.", can: [], blocked: {} }) },
|
||||
});
|
||||
renderApp("/packets/1");
|
||||
const box = await screen.findByRole("region", { name: "Supervisor review" });
|
||||
const send = within(box).getByRole("button", { name: "Send to Q.C." });
|
||||
expect(send).toBeDisabled();
|
||||
fireEvent.click(within(box).getByLabelText("Data book package ready"));
|
||||
expect(send).toBeDisabled();
|
||||
fireEvent.click(within(box).getByLabelText("Spool ready for inspection"));
|
||||
expect(send).toBeDisabled();
|
||||
fireEvent.change(within(box).getByLabelText("Spool location"), { target: { value: "Bay 2" } });
|
||||
expect(send).toBeEnabled();
|
||||
expect(within(box).getByRole("button", { name: "Return to station" })).toBeEnabled();
|
||||
expect(within(box).getByRole("button", { name: "Send to department" })).toBeEnabled();
|
||||
fireEvent.click(send);
|
||||
expect(await within(await screen.findByRole("dialog", { name: "Sign" })).findByText("Send to Q.C.")).toBeInTheDocument();
|
||||
await sign();
|
||||
expect(callsTo(fetch, "POST", "/api/packets/1/transitions")).toEqual([
|
||||
{ action: "send_to_qc", expect: { status: "review", dept: "03" }, book: true, spool: true, location: "Bay 2", sign: { factor: "password", secret: "pw-1" } },
|
||||
]);
|
||||
await waitFor(() => expect(screen.getByTestId("status-pill")).toHaveTextContent("Q.C. inspection"));
|
||||
});
|
||||
|
||||
it("a lead sees the review read-only", async () => {
|
||||
mockFetch(base(shopPacket({ status: "review", holder: "Supervisor", can: [] }), ["shop:03:lead"], "Lou Lead"));
|
||||
renderApp("/packets/1");
|
||||
expect(await screen.findByTestId("review-readonly")).toHaveTextContent("A supervisor for 03 Weld Parts reviews it and sends it on.");
|
||||
expect(screen.queryByRole("button", { name: "Send to Q.C." })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("weld.andy in progress: the clock, Pause, and Submit with the missing steps as links", async () => {
|
||||
const p = shopPacket({
|
||||
can: ["clock", "edit_checklist", "edit_welds", "edit_stage_fields"],
|
||||
blocked: { submit: ["Finish the checklist first.", "Record at least one weld first.", "Enter the filler metal first."] },
|
||||
labor: { hours: 3.24, tickets: 2, per_wo: [], observed_at: null },
|
||||
});
|
||||
mockFetch(base(p, ["shop:03:worker"], "Andy Welder"));
|
||||
renderApp("/packets/1");
|
||||
const bar = await screen.findByRole("region", { name: "Station" });
|
||||
expect(within(bar).getByRole("button", { name: "Submit to supervisor" })).toBeDisabled();
|
||||
expect(within(bar).getByTestId("submit-missing")).toHaveTextContent("Before submitting: 3 checklist items · no welds marked on the drawing · filler metal");
|
||||
expect(within(bar).getByRole("button", { name: "Pause" })).toBeEnabled();
|
||||
expect(within(bar).getByTestId("clock")).toHaveTextContent(/01:02:0\d running · target 4.5 h/);
|
||||
expect(within(bar).getByTestId("labor")).toHaveTextContent("VISUAL tickets 3.2 h");
|
||||
fireEvent.click(within(bar).getByRole("button", { name: "no welds marked on the drawing" }));
|
||||
expect(screen.getByRole("tab", { name: /Weld map/ })).toHaveAttribute("aria-selected", "true");
|
||||
});
|
||||
|
||||
it("Submit signs and sends the expect guard; a stale step reloads and toasts the server text", async () => {
|
||||
let fresh = false;
|
||||
const p = shopPacket({ can: ["clock", "submit"], stage: stage({ filler: "ER70S-6", checklist: { dwg: { done: true }, heat: { done: true }, vt: { done: true } } }), welds: [{ weld_no: 1, page: 1, fx: 0.4, fy: 0.4, wo_base: "L999001", piece: 1, items: [1], type: "Fillet", size: '5/16"', process: "GMAW", stamp: "75", at: null, row_version: 1, welder: null, dept_code: "03" }] });
|
||||
const back = shopPacket({ status: "ready", version: 9, station: null, welder: null, stage: null, holder: "Supervisor", can: [], blocked: {} });
|
||||
const fetch = mockFetch({
|
||||
...base(p, ["shop:03:worker"], "Andy Welder"),
|
||||
"/api/packets/1": () => ({ body: fresh ? back : p }),
|
||||
"POST /api/packets/1/transitions": () => {
|
||||
fresh = true;
|
||||
return { status: 409, body: { detail: "Someone else already took this step.", code: "stale_step" } };
|
||||
},
|
||||
});
|
||||
renderApp("/packets/1");
|
||||
const bar = await screen.findByRole("region", { name: "Station" });
|
||||
expect(within(bar).getByText(/Everything is filled in/)).toBeInTheDocument();
|
||||
fireEvent.click(within(bar).getByRole("button", { name: "Submit to supervisor" }));
|
||||
await sign();
|
||||
expect(callsTo(fetch, "POST", "/api/packets/1/transitions")).toEqual([{ action: "submit", expect: { status: "in_progress", dept: "03" }, sign: { factor: "password", secret: "pw-1" } }]);
|
||||
expect(await screen.findByTestId("toast")).toHaveTextContent("Someone else already took this step.");
|
||||
await waitFor(() => expect(screen.getByTestId("status-pill")).toHaveTextContent("Ready to assign"));
|
||||
});
|
||||
|
||||
it("the assign sheet sends the station, the welder and the stamp", async () => {
|
||||
const p = shopPacket({ status: "ready", station: null, welder: null, stage: null, holder: "Supervisor", can: ["assign", "send_back_to_warehouse"] });
|
||||
const fetch = mockFetch({ ...base(p, ["shop:03:supervisor"]), "POST /api/packets/1/transitions": { body: shopPacket({ status: "assigned", version: 5, can: [] }) } });
|
||||
renderApp("/packets/1");
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Send to station" }));
|
||||
const sheet = await screen.findByRole("dialog", { name: "Send to station" });
|
||||
fireEvent.change(within(sheet).getByLabelText("Station"), { target: { value: "2" } });
|
||||
await within(sheet).findByRole("option", { name: "Andy Welder [75]" });
|
||||
fireEvent.change(within(sheet).getByLabelText("Find a welder"), { target: { value: "andy" } });
|
||||
fireEvent.change(within(sheet).getByLabelText("Welder"), { target: { value: "andy" } });
|
||||
expect(within(sheet).getByLabelText("Stamp")).toHaveValue("75");
|
||||
fireEvent.click(within(sheet).getByRole("button", { name: "Send to station" }));
|
||||
await waitFor(() =>
|
||||
expect(callsTo(fetch, "POST", "/api/packets/1/transitions")).toEqual([
|
||||
{ action: "assign", expect: { status: "ready", dept: "03" }, station_id: 2, welder_id: "andy", stamp: "75" },
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Changes tab, verify", () => {
|
||||
it("a supervisor of a pending department ticks, verifies and signs", async () => {
|
||||
const co = {
|
||||
id: 9,
|
||||
co_no: 2,
|
||||
label: "CO-2",
|
||||
origin: "manual",
|
||||
review: "approved",
|
||||
minor: false,
|
||||
hold: false,
|
||||
ecn: null,
|
||||
reason: "New rev",
|
||||
issued_at: "2026-10-16T08:00:00Z",
|
||||
issued_by: { id: "clara", display_name: "Clara Clerk" },
|
||||
items: [],
|
||||
required: ["03", "09"],
|
||||
verified: { "09": { by: { id: "x", display_name: "Hal Hydro" }, at: "2026-10-16T09:00:00Z", note: null, signed: true } },
|
||||
pending_depts: ["03"],
|
||||
open: true,
|
||||
} as const;
|
||||
const p = shopPacket({ change_orders: [{ ...co, items: [], verified: { ...co.verified }, required: [...co.required], pending_depts: [...co.pending_depts] }] });
|
||||
const fetch = mockFetch({ ...base(p, ["shop:03:supervisor"]), "POST /api/packets/1/change-orders/2/verify": { body: shopPacket({ version: 6 }) } });
|
||||
renderApp("/packets/1");
|
||||
fireEvent.click(await screen.findByRole("tab", { name: /Changes/ }));
|
||||
const box = await screen.findByRole("group", { name: "Verify CO-2 for 03 Weld Parts" });
|
||||
const verify = within(box).getByRole("button", { name: "Verify" });
|
||||
expect(verify).toBeDisabled();
|
||||
fireEvent.click(within(box).getByLabelText("I reviewed this change with my department"));
|
||||
fireEvent.change(within(box).getByLabelText("Note (optional)"), { target: { value: "Checked with the crew" } });
|
||||
fireEvent.click(verify);
|
||||
expect(await within(await screen.findByRole("dialog", { name: "Sign" })).findByText("Verify a change order for the department")).toBeInTheDocument();
|
||||
await sign();
|
||||
expect(callsTo(fetch, "POST", "/api/packets/1/change-orders/2/verify")).toEqual([
|
||||
{ dept: "03", note: "Checked with the crew", sign: { factor: "password", secret: "pw-1" } },
|
||||
]);
|
||||
});
|
||||
|
||||
it("a lead gets no verify box", async () => {
|
||||
const p = shopPacket({
|
||||
change_orders: [
|
||||
{ id: 9, co_no: 2, origin: "manual", review: "approved", minor: false, hold: false, ecn: null, reason: null, issued_at: null, issued_by: null, items: [], required: ["03"], verified: {} },
|
||||
],
|
||||
});
|
||||
mockFetch(base(p, ["shop:03:lead"], "Lou Lead"));
|
||||
renderApp("/packets/1");
|
||||
fireEvent.click(await screen.findByRole("tab", { name: /Changes/ }));
|
||||
await screen.findByRole("region", { name: "Change order CO-2" });
|
||||
expect(screen.queryByRole("button", { name: "Verify" })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user