Phase 5: Supervisor and Stall screens, weld map, heat table, clock and CO verification
ci / frontend (push) Successful in 34s
ci / server (push) Successful in 7m15s
ci / image (push) Successful in 39s
ci / e2e (push) Successful in 2m4s

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:
2026-10-16 22:46:56 -06:00
co-authored by Claude Fable 5.1
parent 39a4eb5213
commit 5a4174e0c2
81 changed files with 11532 additions and 352 deletions
+104
View File
@@ -0,0 +1,104 @@
// 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 { Mutate } from "./tabs";
import { parseRole } from "../auth/roles";
/** Live drawing pages first, then the VOID copies (the old viewPages). */
export function viewPages(pages: readonly PacketPage[]): PacketPage[] {
const dwg = pages.filter((x) => x.type === "drawing");
return [...dwg.filter((x) => !x.void), ...dwg.filter((x) => !!x.void)];
}
/**
* The page to show: the one picked when it exists, else the first live page. A VOID page that was not picked on
* purpose lands on the sheet that replaced it.
*/
export function landingPage(pages: readonly PacketPage[], picked: number | null, onPurpose: boolean): PacketPage | undefined {
let pg = pages.find((x) => x.n === picked) ?? pages[0];
for (let i = 0; i < 10 && pg?.void && !onPurpose; i++) {
const next = pages.find((x) => x.n === pg?.void?.new_n);
if (!next) break;
pg = next;
}
return pg;
}
/** "M00543 R2 · VOID" for the page buttons. */
export function pageLabel(pg: PacketPage): string {
return `${pg.dwg || `Page ${pg.n}`}${pg.rev ? ` R${pg.rev}` : ""}${pg.void ? " · VOID" : ""}`;
}
export type Level = "worker" | "lead" | "supervisor";
/** The highest shop level in a department; admin counts as supervisor. */
export function shopLevel(roles: readonly string[], dept: string): Level | null {
if (roles.includes("admin")) return "supervisor";
const order: Level[] = ["worker", "lead", "supervisor"];
let best = -1;
for (const r of roles) {
const p = parseRole(r);
if (p.shop && p.dept === dept) best = Math.max(best, order.indexOf(p.level));
}
return best < 0 ? null : (order[best] ?? null);
}
/** May this user verify a change order for the department? Mirrors the server's can_verify. */
export function canVerifyDept(roles: readonly string[], dept: string): boolean {
if (roles.includes("admin")) return true;
if (dept === "WH") return roles.includes("warehouse_supervisor");
if (dept === "QC") return roles.includes("qc_supervisor");
if (dept === "CL" || dept === "PM") return false;
return shopLevel(roles, dept) === "supervisor";
}
/** The active people of a shop department, for the welder picker and the weld sheet's stamp. */
export function usePeople(dept: string | null): { people: DeptPerson[]; loaded: boolean; error: string | null } {
const [state, setState] = useState<{ dept: string | null; people: DeptPerson[]; error: string | null } | null>(null);
useEffect(() => {
if (!dept) return;
const ctl = new AbortController();
peopleApi
.list(dept, ctl.signal)
.then((people) => {
if (!ctl.signal.aborted) setState({ dept, people, error: null });
})
.catch((err: unknown) => {
if (ctl.signal.aborted) return;
setState({ dept, people: [], error: err instanceof Error ? err.message : "The people list could not be read." });
});
return () => ctl.abort();
}, [dept]);
const mine = state && state.dept === dept ? state : null;
return { people: mine?.people ?? [], loaded: !!mine, error: mine?.error ?? null };
}
export type ShopTab = "checklist" | "welds";
/** The old missing(): what the stall still has to do before Submit, each with the tab that fixes it. */
export function stallMissing(p: PacketRead): Array<{ t: string; tab: ShopTab }> {
const out: Array<{ t: string; tab: ShopTab }> = [];
const done = p.stage?.checklist ?? {};
const open = p.checklist_items.filter((i) => !done[i.key]?.done).length;
if (open) out.push({ t: `${open} checklist item${open > 1 ? "s" : ""}`, tab: "checklist" });
if (p.dept.weld !== false) {
const here = p.welds.some((w) => !w.carried && (w.dept_code === undefined || w.dept_code === null || w.dept_code === p.dept.code));
if (!here) out.push({ t: "no welds marked on the drawing", tab: "welds" });
if (!p.stage?.filler) out.push({ t: "filler metal", tab: "checklist" });
}
return out;
}
export type StepFn = (
action: string,
extra?: Omit<TransitionBody, "action" | "expect">,
opts?: { sign?: string; ok?: string },
) => Promise<boolean>;
/** A transition with the expect guard, through the page's mutate. */
export function makeStep(p: PacketRead, mutate: Mutate): StepFn {
const expect: { status: PacketStatus; dept: string } = { status: p.status, dept: p.dept.code };
return (action, extra = {}, opts = {}) =>
mutate((g: Guarded) => packetsApi.transition(p.id, { action, expect, ...extra, ...g }), { sign: opts.sign, ok: opts.ok });
}