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

- 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 13:33:51 -06:00
co-authored by Claude Opus 5.5
parent 1ae6819289
commit 1cd6688247
101 changed files with 3871 additions and 1098 deletions
+4
View File
@@ -8,12 +8,14 @@ import { PacketPage } from "./packet/PacketPage";
import { NotFoundPage } from "./pages/NotFoundPage";
import { SignInPage } from "./pages/SignInPage";
import { AccountPage } from "./screens/account/AccountPage";
import { ChecklistsPage } from "./screens/checklists/ChecklistsPage";
import { ProjectPage } from "./screens/clerical/ProjectPage";
import { UnitPage } from "./screens/clerical/UnitPage";
import { ItpPage } from "./screens/qc/ItpPage";
import { QcProjectFolder } from "./screens/qc/QcProjectFolder";
import { QcUnitFolder } from "./screens/qc/QcUnitFolder";
import { RequireScreen, ScreenRoute } from "./screens/ScreenRoute";
import { StampCheckPage } from "./screens/stamp/StampCheckPage";
/** Route table. Exported apart from the router so tests can mount it in a MemoryRouter. */
export function AppRoutes() {
@@ -25,6 +27,8 @@ export function AppRoutes() {
<Route path="about" element={<AboutPage />} />
<Route element={<RequireAuth />}>
<Route path="account" element={<AccountPage />} />
<Route path="checklists" element={<ChecklistsPage />} />
<Route path="stamp-check" element={<StampCheckPage />} />
<Route path=":screen" element={<ScreenRoute />} />
<Route
path="clerical/projects/:projectId"
+3 -31
View File
@@ -1,5 +1,6 @@
// Phase 9 Admin: departments, profiles, checklists, security, access log, presence, stamp check, directory flags,
// the AD reconcile, and notifications. Field names follow the phase 9 design contract exactly.
// Phase 9 Admin: departments, profiles, security, access log, presence, directory flags, the AD reconcile, and
// notifications. The stamp check result type stays here; the calls are in stamp.ts (C9), the checklists in
// checklists.ts (C8). Field names follow the phase 9 design contract exactly.
import type { AdminUser } from "../auth/types";
import { api } from "./client";
import { qs } from "./query";
@@ -96,25 +97,6 @@ export interface ImportAnswer {
applied: boolean;
}
/* ---------- Checklists ---------- */
export interface ChecklistItem {
group: string;
key: string;
text: string;
}
export interface ChecklistLists {
weld: ChecklistItem[];
plain: ChecklistItem[];
}
export interface ChecklistsAnswer {
value: ChecklistLists;
defaults: ChecklistLists;
version: number;
}
/* ---------- Security ---------- */
export interface SecuritySettingsAnswer {
@@ -367,9 +349,6 @@ export const admin9Api = {
patchProfile: (id: string, patch: ProfilePatch) => api.patch<ProfileUser>(`${A}/users/${enc(id)}/profile`, patch),
importProfiles: (csv: string, apply: boolean) => api.post<ImportAnswer>(`${A}/users/import`, { csv, apply }),
checklists: (signal?: AbortSignal) => api.get<ChecklistsAnswer>(`${A}/checklists`, signal),
saveChecklists: (value: ChecklistLists, version: number) => api.put<ChecklistsAnswer>(`${A}/checklists`, { value, version }),
securitySettings: (signal?: AbortSignal) => api.get<SecuritySettingsAnswer>(`${A}/security/settings`, signal),
saveSecuritySettings: (values: Record<string, number>, version: number) =>
api.put<SecuritySettingsAnswer>(`${A}/security/settings`, { values, version }),
@@ -382,13 +361,6 @@ export const admin9Api = {
presence: (signal?: AbortSignal) => api.get<PresenceAnswer>(`${A}/presence`, signal),
stampVerify: (text: string) => api.post<StampResult>(`${A}/stamp-verify`, { text }),
stampVerifyPdf: (file: File) => {
const fd = new FormData();
fd.append("file", file);
return api.post<{ stamps: StampResult[] }>(`${A}/stamp-verify/pdf`, fd);
},
directoryFlags: (signal?: AbortSignal) => api.get<DirectoryFlagsAnswer>(`${A}/directory-flags`, signal),
refreshAd: () => api.post<RefreshRequested>(`${A}/ad/refresh`, {}),
reconcile: (signal?: AbortSignal) => api.get<ReconcileAnswer>(`${A}/ad/reconcile`, signal),
+59
View File
@@ -0,0 +1,59 @@
// The department checklists (change C8). A department supervisor edits their own department's list, Admin any.
// A department without its own list uses the default for its kind (weld or plain). A stage freezes the list at START,
// so a change applies to stages not started yet.
import { api } from "./client";
import { asList } from "./query";
export interface ChecklistItem {
group: string;
key: string;
text: string;
}
/** One row of GET /api/checklists, for every shop department. */
export interface DeptChecklistRow {
dept_code: string;
dept_name: string;
weld: boolean;
/** The department has its own list. False: it uses the default for its kind. */
custom: boolean;
version: number;
/** The caller may save this department's list. */
editable: boolean;
}
/** GET /api/checklists/{dept}, and the answer of a save. */
export interface DeptChecklist extends DeptChecklistRow {
items: ChecklistItem[];
/** The default list for the department's kind. */
defaults: ChecklistItem[];
}
const enc = encodeURIComponent;
function list<T>(raw: unknown): T[] {
return Array.isArray(raw) ? (raw as T[]) : [];
}
/** Every list present, so the screen never trips on a missing field. */
export function normaliseChecklist(raw: Partial<DeptChecklist> | null | undefined, dept = ""): DeptChecklist {
return {
dept_code: raw?.dept_code ?? dept,
dept_name: raw?.dept_name ?? "",
weld: !!raw?.weld,
custom: !!raw?.custom,
version: typeof raw?.version === "number" ? raw.version : 0,
editable: !!raw?.editable,
items: list<ChecklistItem>(raw?.items),
defaults: list<ChecklistItem>(raw?.defaults),
};
}
export const checklistsApi = {
list: async (signal?: AbortSignal) => asList<DeptChecklistRow>(await api.get<unknown>("/api/checklists", signal), "checklists"),
get: async (dept: string, signal?: AbortSignal) =>
normaliseChecklist(await api.get<Partial<DeptChecklist> | null>(`/api/checklists/${enc(dept)}`, signal), dept),
/** 409 with the current list when `base` is stale; 403 when the caller may not edit this department. */
save: async (dept: string, items: ChecklistItem[], base: number) =>
normaliseChecklist(await api.put<Partial<DeptChecklist> | null>(`/api/checklists/${enc(dept)}`, { items, base }), dept),
};
+4
View File
@@ -22,6 +22,7 @@ import type {
QcFields,
ReceiptBody,
SealView,
ShortApproveBody,
ShortBody,
StageFields,
TransitionBody,
@@ -104,6 +105,9 @@ export const packetsApi = {
api.delWithBody<PacketRead>(`${base(id)}/bom/${item}/short`, { ...body, ...g }),
receiveShort: (id: number, item: number, body: ReceiptBody, g: Guarded = {}) =>
api.post<PacketRead>(`${base(id)}/bom/${item}/short/receipts`, { ...body, ...g }),
/** Change C7: the department supervisor approves one received short. Signed. */
approveShortReceipt: (id: number, receiptId: number, body: ShortApproveBody = {}, g: Guarded = {}) =>
api.post<PacketRead>(`${base(id)}/shorts/receipts/${encodeURIComponent(String(receiptId))}/approve`, { ...body, ...g }),
setWhStage: (id: number, body: WhStageBody, g: Guarded = {}) => api.post<PacketRead>(`${base(id)}/wh`, { ...body, ...g }),
/* Phase 5: the shop. Every answer is the packet. */
+40
View File
@@ -0,0 +1,40 @@
// The Stall screen's own call (change C11): the packets assigned to me, across stations. Nobody picks a station on
// the Stall screen; each card carries the station the supervisor picked when assigning.
import { api } from "./client";
import { qs } from "./query";
import { normaliseCard, type ShopCard } from "./shop";
import { normaliseFacets, type BoardQuery, type Facets } from "./types";
/** A stall card: the shop card plus the station it is assigned to. */
export interface StallCard extends ShopCard {
station_id: number | null;
station_name: string | null;
}
/** GET /api/stall/mine: the stall board shape, without a station. */
export interface StallMine {
mine: StallCard[];
submitted: StallCard[];
facets?: Facets;
}
function cards(raw: unknown): StallCard[] {
if (!Array.isArray(raw)) return [];
return (raw as Array<Partial<StallCard> & ShopCard>).map((c) => {
const card = normaliseCard(c);
const id = c.station_id ?? c.station?.id ?? null;
const name = c.station_name ?? c.station?.name ?? null;
// The card's own station line reads `station`; fill it from the flat fields when the server sends only those.
const station = card.station ?? (id !== null ? { id, name: name ?? "" } : null);
return { ...card, station, station_id: id, station_name: name };
});
}
/** The find bar's query applies; `me` and the session decide whose packets. */
export async function getStallMine(q: BoardQuery = {}, signal?: AbortSignal): Promise<StallMine> {
const raw = await api.get<Partial<StallMine> | null>(`/api/stall/mine${qs({ ...q })}`, signal);
const facets = normaliseFacets(raw?.facets);
return { mine: cards(raw?.mine), submitted: cards(raw?.submitted), ...(facets ? { facets } : {}) };
}
export const stallApi = { getStallMine };
+15
View File
@@ -0,0 +1,15 @@
// The stamp check (change C9): Admin, Q.C. and Clerical check a printed or saved copy against the print log.
// /api/stamp-verify and /api/stamp-verify/pdf take the same bodies and answer the same as the /api/admin aliases.
import type { StampResult } from "./admin";
import { api } from "./client";
export type { StampResult };
export const stampApi = {
verify: (text: string) => api.post<StampResult>("/api/stamp-verify", { text }),
verifyPdf: (file: File) => {
const fd = new FormData();
fd.append("file", file);
return api.post<{ stamps: StampResult[] }>("/api/stamp-verify/pdf", fd);
},
};
+41 -3
View File
@@ -161,6 +161,8 @@ export interface PacketCard {
/** CO numbers, or a count. Both are handled. */
open_cos: number[] | number;
open_shorts: number;
/** Change C7: open shorts plus received shorts the supervisor has not approved. */
shorts_unapproved?: number;
wh_stage: string | null;
due_effective: string | null;
updated_at: string | null;
@@ -296,11 +298,43 @@ export interface BomShort {
}
export interface ShortReceipt {
/** Change C7: the short_receipts row id, for the supervisor's approval. */
id?: number;
qty: number | string;
heat: string | null;
note: string | null;
at: string | null;
by?: WhoRef | null;
/** Change C7: the department supervisor's approval of this receipt, or null while it waits. */
approval?: ShortApproval | null;
}
/** Change C7: PacketRead.shorts, every short of the packet, open or closed, with its BOM item and its receipts. */
export interface PacketShort {
id: number;
item: number;
qty: number | string;
qty_open: number | string;
reason: string | null;
ref: string | null;
eta: string | null;
note: string | null;
opened_at: string | null;
closed_at: string | null;
opened_by?: WhoRef | null;
receipts: ShortReceipt[];
}
/** Change C7: one supervisor approval of a received short. */
export interface ShortApproval {
at: string;
by: WhoRef | null;
note: string | null;
}
/** Change C7: POST /api/packets/{id}/shorts/receipts/{receipt_id}/approve. The signature rides in Guarded. */
export interface ShortApproveBody {
note?: string;
}
export interface BomLine {
@@ -551,6 +585,12 @@ export interface PacketRead {
itp?: ItpSummary | null;
/** Phase 6: open ITP hold points for this packet's department. */
itp_hold?: ItpHold | null;
/** Change C2: a Q.C. reject since the last accept has no NCR number, so the accept must carry one. */
ncr_required?: boolean;
/** Change C7: open shorts plus received shorts the supervisor has not approved. */
shorts_unapproved?: number;
/** Change C7: every short, open or closed, with who received and who approved each receipt. Not on a sealed read. */
shorts?: PacketShort[];
}
/* ---------- Phase 7: close, file, PDFs and prints ---------- */
@@ -831,10 +871,8 @@ export interface TransitionBody {
spool?: boolean;
location?: string;
for_sup_id?: string;
/** Phase 6: QC_REJECT. An NCR number like NCR-2026-0021. */
/** QC_REJECT, and QC_ACCEPT / QC_ACCEPT_AND_ROUTE when `ncr_required` (change C2). An NCR number like NCR-2026-0021. */
ncr?: string;
/** Phase 7: FILE. Files although the feed still shows work orders open in VISUAL. */
confirm_open_wos?: boolean;
sign?: SignBody;
it_reason?: string;
}
+13 -2
View File
@@ -1,9 +1,10 @@
import { useEffect, useLayoutEffect, useRef } from "react";
import { Link, Outlet, useLocation } from "react-router";
import { SCREEN_LABELS, firstScreen, isScreen, orderScreens, type Screen } from "../auth/roles";
import { SCREEN_LABELS, firstScreen, isScreen, type Screen } from "../auth/roles";
import { useAuth } from "../auth/useAuth";
import { replayClass } from "../lib/motion";
import { useScrollRestore } from "../lib/scrollRestore";
import { canChecklists, canStampCheck, shellScreens } from "../lib/toolAccess";
import { LockScreen } from "./LockScreen";
import { RoleSwitcher } from "./RoleSwitcher";
import { TopBar } from "./TopBar";
@@ -22,7 +23,7 @@ export function Layout() {
const locked = auth.status === "locked";
const me = auth.status === "ready" || locked ? auth.me : null;
const screens = me ? orderScreens(me.screens) : [];
const screens = me ? shellScreens(me.screens, me.roles) : [];
const seg = pathname.split("/")[1];
const active = isScreen(seg) && screens.includes(seg) ? seg : null;
const shown = active ?? firstScreen(screens);
@@ -51,6 +52,16 @@ export function Layout() {
<>
<b className="uname">{me.user.display_name}</b>
{shown ? <span className={chipClass(shown)}>{SCREEN_LABELS[shown]}</span> : null}
{canChecklists(me.roles) ? (
<Link className="btn sm ghost" to="/checklists">
Checklists
</Link>
) : null}
{canStampCheck(me.roles) ? (
<Link className="btn sm ghost" to="/stamp-check">
Stamp check
</Link>
) : null}
<Link className="btn sm ghost" to="/account">
Account
</Link>
@@ -12,7 +12,7 @@ function routes() {
"/api/auth/me": { body: LOU },
"/api/departments": { body: DEPARTMENTS },
"/api/shop/board": { body: {} },
"/api/shop/stations": { body: [] },
"/api/stall/mine": { body: { mine: [], submitted: [] } },
"/api/projects": { body: [] },
};
}
@@ -29,7 +29,7 @@ describe("RoleSwitcher", () => {
await waitFor(() => expect(within(nav).getByRole("button", { name: "Supervisor" })).toHaveAttribute("aria-pressed", "true"));
fireEvent.click(within(nav).getByRole("button", { name: "Weld Stall" }));
expect(window.localStorage.getItem(LAST_SCREEN_KEY)).toBe("stall");
expect(await screen.findByText("You aren't assigned to a station. Ask an admin to add you to a department.")).toBeInTheDocument();
expect(await screen.findByText("Your supervisor assigns packets to you.")).toBeInTheDocument();
});
it("lands on the remembered screen when it is still allowed", async () => {
+2 -2
View File
@@ -113,12 +113,12 @@ describe("toQuery", () => {
expect(toQuery(f(), { project: PROJECT, unit: null }, { screen: "qc" })).toEqual({ project_id: 7 });
});
it("the stall sends no department, station, worker, flag kind or project, and its Mine names the station", () => {
it("the stall sends no department, station, worker or flag kind, and Mine never needs a station", () => {
const q = toQuery(f({ dept: "03", stall: "4", who: "u-1", flag: "rev", flags: ["mine", "short"], status: "station" }), { project: PROJECT, unit: null }, {
screen: "stall",
stationId: 9,
});
expect(q).toEqual({ status: "station", flags: "short", project_id: 7 });
expect(toQuery(f({ flags: ["mine"] }), { project: null, unit: null }, { screen: "supervisor" })).toEqual({ flags: "mine", mine_screen: "supervisor" });
expect(toQuery(f({ flags: ["mine"] }), { project: null, unit: null }, { screen: "warehouse" })).toEqual({ flags: "mine", mine_screen: "warehouse" });
expect(toQuery(f({ flag: "rev" }), { project: null, unit: null }, { screen: "supervisor" })).toEqual({});
});
+8 -9
View File
@@ -245,8 +245,11 @@ export function pickQuery(pick: UnitPick): PacketListQuery {
return {};
}
/** The server query for the filters and the pick. Defaults are left out. */
export function toQuery(f: Filters, pick: UnitPick, opts: { screen: Screen; stationId?: number | null }): PacketListQuery {
/**
* The server query for the filters and the pick. Defaults are left out. Mine goes with the screen only: the stall's
* Mine no longer needs a picked station (change C11), the session decides whose packets.
*/
export function toQuery(f: Filters, pick: UnitPick, opts: { screen: Screen }): PacketListQuery {
const e = effective(f, opts.screen);
const out: PacketListQuery = {};
const q = e.q.trim();
@@ -258,10 +261,7 @@ export function toQuery(f: Filters, pick: UnitPick, opts: { screen: Screen; stat
if (e.flag !== ALL) out.flag = e.flag;
if (e.flags.length) {
out.flags = [...e.flags].sort((a, b) => FLT_FLAGS.findIndex(([k]) => k === a) - FLT_FLAGS.findIndex(([k]) => k === b)).join(",");
if (e.flags.includes("mine")) {
out.mine_screen = opts.screen;
if (opts.stationId) out.mine_station_id = opts.stationId;
}
if (e.flags.includes("mine")) out.mine_screen = opts.screen;
}
if (e.sort !== "updated") out.sort = e.sort;
return { ...out, ...pickQuery(pick) };
@@ -323,7 +323,7 @@ export interface FiltersState {
}
/** The find bar state for one screen. Saves on every change except q; keeps the pick in ps.proj. */
export function useFilters(screen: Screen, opts: { stationId?: number | null } = {}): FiltersState {
export function useFilters(screen: Screen): FiltersState {
const [f, setF] = useState<Filters>(loadFilters);
const [pick, setPickState] = useState<UnitPick>(loadPick);
const [dq, setDq] = useState("");
@@ -359,8 +359,7 @@ export function useFilters(screen: Screen, opts: { stationId?: number | null } =
const view: ViewKind = partsFor(screen).view && f.views[screen] === "list" ? "list" : "board";
const setView = useCallback((v: ViewKind) => setF((prev) => ({ ...prev, views: { ...prev.views, [screen]: v } })), [screen]);
const stationId = opts.stationId ?? null;
const query = useMemo(() => toQuery({ ...f, q: dq }, pick, { screen, stationId }), [f, dq, pick, screen, stationId]);
const query = useMemo(() => toQuery({ ...f, q: dq }, pick, { screen }), [f, dq, pick, screen]);
const queryKey = JSON.stringify(query);
const scope = useMemo(() => pickQuery(pick), [pick]);
const scopeKey = JSON.stringify(scope);
+84
View File
@@ -0,0 +1,84 @@
// Prints a server PDF from the page, as the old build did (change C4, Gitea issue 27): pdf.js renders each page to an
// image in a hidden print area, and the browser's print dialog opens on the same tab. No new tab, no frame, so the
// CSP stays as it is. The server stamps the copy and records the print when the PDF is fetched.
import { loadPdfJs } from "./pdf";
const ROOT_ID = "ps-print-root";
const STYLE_ID = "ps-print-style";
/** Render scale: 2 is about 150 dpi on a letter page, sharp enough for drawings and stamps. */
const SCALE = 2;
const CSS = `
#${ROOT_ID} { display: none; }
@media print {
@page { margin: 0; }
body > *:not(#${ROOT_ID}) { display: none !important; }
#${ROOT_ID} { display: block !important; }
#${ROOT_ID} img { display: block; width: 100%; height: auto; max-height: 100vh; object-fit: contain; break-after: page; }
#${ROOT_ID} img:last-child { break-after: auto; }
}`;
let busy = false;
function ensureStyle(): void {
if (document.getElementById(STYLE_ID)) return;
const s = document.createElement("style");
s.id = STYLE_ID;
s.textContent = CSS;
document.head.appendChild(s);
}
function canvasUrl(canvas: HTMLCanvasElement): Promise<string> {
return new Promise((resolve, reject) => {
canvas.toBlob((b) => (b ? resolve(URL.createObjectURL(b)) : reject(new Error("The page could not be drawn."))), "image/png");
});
}
/**
* Fetches the PDF at a same-origin URL, draws every page and opens the print dialog. Resolves when the dialog closes.
* A second call while one is running is ignored. Throws when the PDF cannot be loaded, so the caller can say so.
*/
export async function printPdf(url: string): Promise<void> {
if (busy) return;
busy = true;
const urls: string[] = [];
const root = document.createElement("div");
root.id = ROOT_ID;
try {
const pdfjs = await loadPdfJs();
const task = pdfjs.getDocument({ url, withCredentials: true, useWasm: false });
const doc = await task.promise;
try {
for (let n = 1; n <= doc.numPages; n++) {
const page = await doc.getPage(n);
const vp = page.getViewport({ scale: SCALE });
const canvas = document.createElement("canvas");
canvas.width = Math.ceil(vp.width);
canvas.height = Math.ceil(vp.height);
const ctx = canvas.getContext("2d");
if (!ctx) throw new Error("The page could not be drawn.");
await page.render({ canvas, canvasContext: ctx, viewport: vp }).promise;
const u = await canvasUrl(canvas);
urls.push(u);
const img = document.createElement("img");
img.alt = `Page ${n}`;
img.src = u;
root.appendChild(img);
page.cleanup();
}
} finally {
void task.destroy().catch(() => undefined);
}
ensureStyle();
document.getElementById(ROOT_ID)?.remove();
document.body.appendChild(root);
await Promise.all(
Array.from(root.querySelectorAll("img")).map((img) => (img.complete ? Promise.resolve() : img.decode().catch(() => undefined))),
);
window.print();
} finally {
root.remove();
for (const u of urls) URL.revokeObjectURL(u);
busy = false;
}
}
+29
View File
@@ -0,0 +1,29 @@
// Who sees the screens outside the role switcher, and the PM screen. The server decides on every request; this only
// hides what cannot work.
import { orderScreens, parseRole, type Screen } from "../auth/roles";
/** The Project Management screen: PS-ProjectMgmt and Admin only (change C10). */
export function canPm(roles: readonly string[]): boolean {
return roles.some((r) => r === "pm" || r === "admin");
}
/** The stamp check: Admin, Q.C. and every Clerical level (change C9). */
export function canStampCheck(roles: readonly string[]): boolean {
return roles.some((r) => r === "admin" || r === "qc" || r.startsWith("qc_") || r === "clerical" || r.startsWith("clerical_"));
}
/** The department checklists: a department supervisor (a lead is not enough) or Admin (change C8). */
export function canChecklists(roles: readonly string[]): boolean {
return roles.some((r) => {
if (r === "admin") return true;
const p = parseRole(r);
return p.shop && p.level === "supervisor";
});
}
export const PM_WHO = "It is for Project Management and Admin.";
/** The screens the shell offers: the session's list, with PM only for PS-ProjectMgmt and Admin (change C10). */
export function shellScreens(screens: readonly string[], roles: readonly string[]): Screen[] {
return orderScreens(screens).filter((s) => s !== "pm" || canPm(roles));
}
+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>
) : (
+6 -2
View File
@@ -2,6 +2,7 @@ import type { ReactNode } from "react";
import { Navigate, useParams } from "react-router";
import { homePath, isScreen, orderScreens, type Screen } from "../auth/roles";
import { useAuth } from "../auth/useAuth";
import { PM_WHO, shellScreens } from "../lib/toolAccess";
import { AdminPage } from "./admin/AdminPage";
import { ClericalHome } from "./clerical/ClericalHome";
import { PlaceholderScreen } from "./PlaceholderScreen";
@@ -9,6 +10,7 @@ import { PmHome } from "./pm/PmHome";
import { QcHome } from "./qc/QcHome";
import { StallHome } from "./stall/StallHome";
import { SupervisorHome } from "./supervisor/SupervisorHome";
import { NotForRole } from "./tools/RequireRole";
import { WarehouseHome } from "./warehouse/WarehouseHome";
/** A page under a screen, like /clerical/projects/7. Users without the screen go to their first screen. */
@@ -24,8 +26,10 @@ export function ScreenRoute() {
const { screen } = useParams();
const { me } = useAuth();
if (!me) return null;
const allowed = orderScreens(me.screens);
if (!isScreen(screen) || !allowed.includes(screen)) return <Navigate to={homePath(me.screens)} replace />;
const allowed = shellScreens(me.screens, me.roles);
// The PM screen says why instead of moving on (change C10).
if (screen === "pm" && !allowed.includes("pm")) return <NotForRole what="The Project Management screen" who={PM_WHO} />;
if (!isScreen(screen) || !allowed.includes(screen)) return <Navigate to={homePath(allowed)} replace />;
if (screen === "admin") return <AdminPage />;
if (screen === "clerical") return <ClericalHome />;
if (screen === "warehouse") return <WarehouseHome />;
@@ -1,94 +1,35 @@
import { fireEvent, screen, waitFor } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { adminBase } from "../../test/admin";
import { renderApp } from "../../test/auth";
import { callsTo, mockFetch } from "../../test/fetchMock";
import { memoryStorage } from "../supervisor/memoryStorage";
const VALUE = {
weld: [
{ group: "Before welding", key: "dwg", text: "Drawing {drawing} Rev {rev} matches the packet" },
{ group: "Before welding", key: "wps", text: "WPS reviewed: {wps}" },
{ group: "After welding", key: "vt", text: "Visual inspection of all welds acceptable" },
],
plain: [{ group: "Before starting", key: "dwg", text: "Drawing {drawing} Rev {rev} matches the packet" }],
};
const DEFAULTS = { weld: VALUE.weld.slice(0, 2), plain: VALUE.plain };
const ITEMS = [{ group: "Before welding", key: "dwg", text: "Drawing {drawing} Rev {rev} matches the packet" }];
async function openTab() {
renderApp("/admin");
fireEvent.click(await screen.findByRole("tab", { name: "Checklists" }));
return screen.findByLabelText("Weld item 1 text");
}
beforeEach(() => {
vi.stubGlobal("localStorage", memoryStorage());
});
describe("Admin Checklists tab", () => {
it("edits, adds, reorders and removes items, checks them, and saves with the version", async () => {
it("shows the department editor on the new endpoints and links the Checklists screen", async () => {
const fetch = mockFetch({
...adminBase(),
"GET /api/admin/checklists": { body: { value: VALUE, defaults: DEFAULTS, version: 4 } },
"PUT /api/admin/checklists": { body: { value: VALUE, defaults: DEFAULTS, version: 5 } },
});
await openTab();
expect(screen.getByLabelText("Weld item 1 key")).toHaveAttribute("readonly");
expect(screen.getByLabelText("Plain item 1 group")).toHaveValue("Before starting");
fireEvent.change(screen.getByLabelText("Weld item 3 text"), { target: { value: "Visual check of every weld" } });
fireEvent.click(screen.getByRole("button", { name: "Move weld item 3 up" }));
fireEvent.click(screen.getByRole("button", { name: "Remove weld item 1" }));
fireEvent.click(screen.getByRole("button", { name: "Add weld item" }));
const key = screen.getByLabelText("Weld item 3 key");
expect(key).not.toHaveAttribute("readonly");
fireEvent.change(key, { target: { value: "Clean" } });
fireEvent.change(screen.getByLabelText("Weld item 3 text"), { target: { value: "Slag removed near {part}" } });
fireEvent.click(screen.getByRole("button", { name: "Save checklists" }));
expect(await screen.findByRole("alert")).toHaveTextContent("Weld list, item 3: the key is lower case");
fireEvent.change(key, { target: { value: "clean" } });
fireEvent.click(screen.getByRole("button", { name: "Save checklists" }));
expect(await screen.findByRole("alert")).toHaveTextContent("only {drawing}, {rev} and {wps}");
fireEvent.change(screen.getByLabelText("Weld item 3 text"), { target: { value: "Slag removed" } });
fireEvent.click(screen.getByRole("button", { name: "Save checklists" }));
expect(await screen.findByText(/Checklists saved/)).toBeInTheDocument();
expect(callsTo(fetch, "PUT", "/api/admin/checklists")).toEqual([
{
version: 4,
value: {
weld: [
{ group: "After welding", key: "vt", text: "Visual check of every weld" },
{ group: "Before welding", key: "wps", text: "WPS reviewed: {wps}" },
{ group: "Before welding", key: "clean", text: "Slag removed" },
],
plain: VALUE.plain,
},
"GET /api/checklists": {
body: [
{ dept_code: "03", dept_name: "Weld Parts", weld: true, custom: false, version: 0, editable: true },
{ dept_code: "14", dept_name: "Electrical", weld: false, custom: true, version: 2, editable: true },
],
},
]);
});
it("loads the other version after a 409", async () => {
let gets = 0;
const fetch = mockFetch({
...adminBase(),
"GET /api/admin/checklists": () => {
gets += 1;
return { body: { value: gets > 1 ? { ...VALUE, plain: [{ ...VALUE.plain[0], text: "Changed by Bob" }] } : VALUE, defaults: DEFAULTS, version: 3 + gets } };
"GET /api/checklists/03": {
body: { dept_code: "03", dept_name: "Weld Parts", weld: true, custom: false, version: 0, editable: true, items: ITEMS, defaults: ITEMS },
},
"PUT /api/admin/checklists": { status: 409, body: { detail: "Changed by someone else", code: "version_conflict" } },
});
await openTab();
fireEvent.change(screen.getByLabelText("Plain item 1 text"), { target: { value: "Mine" } });
fireEvent.click(screen.getByRole("button", { name: "Save checklists" }));
expect(await screen.findByRole("alert")).toHaveTextContent("Someone else saved the checklists first.");
await waitFor(() => expect(screen.getByLabelText("Plain item 1 text")).toHaveValue("Changed by Bob"));
expect(callsTo(fetch, "GET", "/api/admin/checklists")).toHaveLength(2);
});
it("copies the default list in", async () => {
mockFetch({ ...adminBase(), "GET /api/admin/checklists": { body: { value: VALUE, defaults: DEFAULTS, version: 1 } } });
await openTab();
expect(screen.getByLabelText("Weld item 3 text")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Use the default weld list" }));
expect(screen.queryByLabelText("Weld item 3 text")).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Discard changes" }));
expect(screen.getByLabelText("Weld item 3 text")).toBeInTheDocument();
renderApp("/admin");
fireEvent.click(await screen.findByRole("tab", { name: "Checklists" }));
expect(await screen.findByLabelText("Item 1 text")).toHaveValue(ITEMS[0]?.text);
expect(within(screen.getByRole("main")).getByRole("link", { name: "Checklists" })).toHaveAttribute("href", "/checklists");
expect(screen.getByRole("combobox", { name: "Department" })).toHaveValue("03");
await waitFor(() => expect(callsTo(fetch, "GET", "/api/admin/checklists")).toHaveLength(0));
});
});
+10 -222
View File
@@ -1,231 +1,19 @@
// Admin: the department checklists, one list for weld departments and one for the rest (phase 9).
import { useEffect, useState } from "react";
import { admin9Api, type ChecklistItem, type ChecklistLists, type ChecklistsAnswer } from "../../api/admin";
import { ApiError } from "../../api/client";
import { errorText } from "../../lib/format";
import { useLoad } from "../../lib/useLoad";
import { Notice, Problem, type Msg } from "./common";
type ListId = "weld" | "plain";
const LISTS: ReadonlyArray<{ id: ListId; title: string; one: string }> = [
{ id: "weld", title: "Weld departments", one: "weld" },
{ id: "plain", title: "Other departments", one: "plain" },
];
const RX_KEY = /^[a-z][a-z0-9_]{0,29}$/;
const MAX_ITEMS = 40;
const cap = (s: string) => s.charAt(0).toUpperCase() + s.slice(1);
const PLACEHOLDERS = new Set(["drawing", "rev", "wps"]);
interface Row extends ChecklistItem {
/** True for a key the saved list already has. Its key cannot change, because answers on open stages use it. */
saved: boolean;
}
type Draft = Record<ListId, Row[]>;
function toDraft(v: ChecklistLists): Draft {
return {
weld: v.weld.map((it) => ({ ...it, saved: true })),
plain: v.plain.map((it) => ({ ...it, saved: true })),
};
}
/** The first problem with one list, or null. Mirrors the server's rules. */
function listProblem(rows: readonly ChecklistItem[], title: string): string | null {
if (rows.length === 0) return `${title}: the list needs at least one item.`;
if (rows.length > MAX_ITEMS) return `${title}: at most ${MAX_ITEMS} items.`;
const seen = new Set<string>();
for (const [i, r] of rows.entries()) {
const n = i + 1;
if (!r.group.trim()) return `${title}, item ${n}: give it a group.`;
if (!r.text.trim()) return `${title}, item ${n}: give it a text.`;
if (!RX_KEY.test(r.key)) return `${title}, item ${n}: the key is lower case letters, digits and _, starting with a letter.`;
if (seen.has(r.key)) return `${title}, item ${n}: the key ${r.key} is used twice.`;
seen.add(r.key);
for (const m of r.text.matchAll(/\{([^}]*)\}/g)) {
if (!PLACEHOLDERS.has(m[1] ?? "")) return `${title}, item ${n}: only {drawing}, {rev} and {wps} can be filled in.`;
}
}
return null;
}
function ListEditor({
id,
title,
one,
rows,
onChange,
onDefaults,
}: {
id: ListId;
title: string;
one: string;
rows: Row[];
onChange: (rows: Row[]) => void;
onDefaults: () => void;
}) {
const set = (i: number, patch: Partial<Row>) => onChange(rows.map((r, j) => (j === i ? { ...r, ...patch } : r)));
const move = (i: number, d: number) => {
const j = i + d;
if (j < 0 || j >= rows.length) return;
const out = [...rows];
const a = out[i];
const b = out[j];
if (!a || !b) return;
out[i] = b;
out[j] = a;
onChange(out);
};
const add = () => {
const last = rows[rows.length - 1];
onChange([...rows, { group: last?.group ?? "", key: "", text: "", saved: false }]);
};
return (
<section className="panel stack" aria-labelledby={`ckl-${id}-h`}>
<div className="sec-h flush">
<h3 id={`ckl-${id}-h`}>{title}</h3>
<span className="ct">{rows.length} items</span>
<span className="grow" />
<button className="btn sm ghost" type="button" onClick={onDefaults}>
{`Use the default ${one} list`}
</button>
</div>
{rows.map((r, i) => {
const n = i + 1;
return (
<div className="frow" key={`${i}-${r.saved ? r.key : "new"}`}>
<label className="f">
{`${cap(one)} item ${n} group`}
<input className="in" value={r.group} maxLength={200} onChange={(e) => set(i, { group: e.target.value })} />
</label>
<label className="f">
{`${cap(one)} item ${n} key`}
<input
className="in mono"
value={r.key}
maxLength={30}
readOnly={r.saved}
title={r.saved ? "A saved key cannot change. Answers on open stages use it." : undefined}
onChange={(e) => set(i, { key: e.target.value })}
/>
</label>
<label className="f" style={{ gridColumn: "span 2" }}>
{`${cap(one)} item ${n} text`}
<input className="in" value={r.text} maxLength={200} onChange={(e) => set(i, { text: e.target.value })} />
</label>
<div className="end nowrap">
<button className="btn sm ghost" type="button" aria-label={`Move ${one} item ${n} up`} disabled={i === 0} onClick={() => move(i, -1)}>
Up
</button>{" "}
<button
className="btn sm ghost"
type="button"
aria-label={`Move ${one} item ${n} down`}
disabled={i === rows.length - 1}
onClick={() => move(i, 1)}
>
Down
</button>{" "}
<button className="btn sm rejb" type="button" aria-label={`Remove ${one} item ${n}`} onClick={() => onChange(rows.filter((_, j) => j !== i))}>
Remove
</button>
</div>
</div>
);
})}
<div>
<button className="btn sm" type="button" disabled={rows.length >= MAX_ITEMS} onClick={add}>
{`Add ${one} item`}
</button>
</div>
</section>
);
}
// Admin: the department checklists. Since change C8 every department has its own list, edited on the Checklists
// screen; Admin may edit any department, so this tab shows the same editor on the same endpoints (/api/checklists).
import { Link } from "react-router";
import { DeptChecklists } from "../checklists/DeptChecklists";
export function ChecklistsTab() {
const [load, reload] = useLoad((s) => admin9Api.checklists(s));
const [base, setBase] = useState<ChecklistsAnswer | null>(null);
const [draft, setDraft] = useState<Draft | null>(null);
const [busy, setBusy] = useState(false);
const [msg, setMsg] = useState<Msg>(null);
useEffect(() => {
if (load.state === "ok") {
setBase(load.data);
setDraft(toDraft(load.data.value));
}
}, [load]);
if (!base || !draft) return <Problem load={load} />;
const strip = (rows: Row[]): ChecklistItem[] => rows.map((r) => ({ group: r.group.trim(), key: r.key.trim(), text: r.text.trim() }));
const savedKeys = new Set([...base.value.weld, ...base.value.plain].map((r) => r.key));
const save = async () => {
const value: ChecklistLists = { weld: strip(draft.weld), plain: strip(draft.plain) };
const problem = listProblem(value.weld, "Weld list") ?? listProblem(value.plain, "Other list");
if (problem) {
setMsg({ ok: false, text: problem });
return;
}
setBusy(true);
setMsg(null);
try {
const r = await admin9Api.saveChecklists(value, base.version);
setBase(r);
setDraft(toDraft(r.value));
setMsg({ ok: true, text: "Checklists saved. New checklists use them. Open stages keep their answers." });
} catch (err) {
if (err instanceof ApiError && err.status === 409) {
setMsg({ ok: false, text: "Someone else saved the checklists first. Their version is loaded now. Make your change again." });
reload();
} else {
setMsg({ ok: false, text: errorText(err) });
}
} finally {
setBusy(false);
}
};
const defaults = (id: ListId) =>
setDraft((cur) => (cur ? { ...cur, [id]: base.defaults[id].map((it) => ({ ...it, saved: savedKeys.has(it.key) })) } : cur));
return (
<div className="stack">
<div className="small muted">
Weld departments get the first list, the others the second. Text can use {"{drawing}"}, {"{rev}"} and {"{wps}"}. A removed item drops out of new
checklists only.
</div>
{LISTS.map((l) => (
<ListEditor
key={l.id}
id={l.id}
title={l.title}
one={l.one}
rows={draft[l.id]}
onChange={(rows) => setDraft((cur) => (cur ? { ...cur, [l.id]: rows } : cur))}
onDefaults={() => defaults(l.id)}
/>
))}
<Notice msg={msg} />
<div className="chips">
<button className="btn pri" type="button" disabled={busy} onClick={() => void save()}>
Save checklists
</button>
<button
className="btn ghost"
type="button"
disabled={busy}
onClick={() => {
setDraft(toDraft(base.value));
setMsg(null);
}}
>
Discard changes
</button>
Department supervisors edit their own department&apos;s list on the{" "}
<Link className="lnk" to="/checklists">
Checklists
</Link>{" "}
screen.
</div>
<DeptChecklists />
</div>
);
}
@@ -25,7 +25,7 @@ describe("Admin Stamp check tab", () => {
it("checks a typed stamp line", async () => {
const fetch = mockFetch({
...adminBase(),
"POST /api/admin/stamp-verify": { body: { found: true, valid: true, reason: null, print: PRINT } },
"POST /api/stamp-verify": { body: { found: true, valid: true, reason: null, print: PRINT } },
});
const box = await openTab();
fireEvent.click(screen.getByRole("button", { name: "Check stamp" }));
@@ -37,14 +37,14 @@ describe("Admin Stamp check tab", () => {
expect(within(result).getByText("Clara Clerk")).toBeInTheDocument();
expect(within(result).getByText("L662001/1")).toBeInTheDocument();
expect(within(result).getByText("Data book, 12 pages")).toBeInTheDocument();
expect(callsTo(fetch, "POST", "/api/admin/stamp-verify")).toEqual([{ text: "PS 0123456789abcdef" }]);
expect(callsTo(fetch, "POST", "/api/stamp-verify")).toEqual([{ text: "PS 0123456789abcdef" }]);
});
it("says when a stamp is not found or does not verify", async () => {
let n = 0;
mockFetch({
...adminBase(),
"POST /api/admin/stamp-verify": () => {
"POST /api/stamp-verify": () => {
n += 1;
return n === 1
? { body: { found: false, valid: false, reason: null, print: null } }
@@ -63,7 +63,7 @@ describe("Admin Stamp check tab", () => {
it("uploads a PDF and lists each stamp found", async () => {
const fetch = mockFetch({
...adminBase(),
"POST /api/admin/stamp-verify/pdf": {
"POST /api/stamp-verify/pdf": {
body: { stamps: [{ found: true, valid: true, reason: null, print: PRINT }, { found: false, valid: false, reason: null, print: null }] },
},
});
@@ -78,8 +78,8 @@ describe("Admin Stamp check tab", () => {
const results = await screen.findByRole("status", { name: "PDF stamp results" });
expect(results).toHaveTextContent("Stamp 1: Genuine.");
expect(results).toHaveTextContent("Stamp 2: Not found.");
await waitFor(() => expect(callsTo(fetch, "POST", "/api/admin/stamp-verify/pdf")).toHaveLength(1));
const init = fetch.mock.calls.find(([u]) => String(u) === "/api/admin/stamp-verify/pdf")?.[1];
await waitFor(() => expect(callsTo(fetch, "POST", "/api/stamp-verify/pdf")).toHaveLength(1));
const init = fetch.mock.calls.find(([u]) => String(u) === "/api/stamp-verify/pdf")?.[1];
expect(init?.body).toBeInstanceOf(FormData);
expect((init?.body as FormData).get("file")).toBeInstanceOf(File);
});
@@ -1,6 +1,7 @@
// Admin: check the stamp on a printed or saved copy against the print log (phase 9).
// Check the stamp on a printed or saved copy against the print log (phase 9). Admin's tab and, since change C9, the
// Stamp check screen for Admin, Q.C. and Clerical. Both call /api/stamp-verify.
import { useState, type FormEvent } from "react";
import { admin9Api, type StampResult } from "../../api/admin";
import { stampApi, type StampResult } from "../../api/stamp";
import { errorText, fmtTime } from "../../lib/format";
import { Notice, type Msg } from "./common";
@@ -65,7 +66,7 @@ export function StampVerifyTab() {
setMsg(null);
setOne(null);
try {
setOne(await admin9Api.stampVerify(text.trim()));
setOne(await stampApi.verify(text.trim()));
} catch (err) {
setMsg({ ok: false, text: errorText(err) });
} finally {
@@ -91,7 +92,7 @@ export function StampVerifyTab() {
setMsg(null);
setMany(null);
try {
setMany((await admin9Api.stampVerifyPdf(file)).stamps);
setMany((await stampApi.verifyPdf(file)).stamps);
} catch (err) {
setMsg({ ok: false, text: errorText(err) });
} finally {
@@ -0,0 +1,151 @@
import { fireEvent, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { DEPARTMENTS, makeMe, renderApp } from "../../test/auth";
import { callsTo, mockFetch, type Route } from "../../test/fetchMock";
import { memoryStorage } from "../supervisor/memoryStorage";
import { CONFLICT_TEXT, NONE_TEXT, SAVED_TEXT } from "./DeptChecklists";
const SUP = makeMe({
roles: ["shop:03:supervisor"],
screens: ["supervisor", "stall"],
user: { display_name: "Wes Super", sam_account: "sup.wes" },
});
const LEAD = makeMe({ roles: ["shop:03:lead"], screens: ["supervisor", "stall"] });
const QC = makeMe({ roles: ["qc"], screens: ["qc"] });
const DEFAULTS = [
{ group: "Before welding", key: "dwg", text: "Drawing {drawing} Rev {rev} matches the packet" },
{ group: "Before welding", key: "wps", text: "WPS reviewed: {wps}" },
];
const OWN = [
{ group: "Before welding", key: "dwg", text: "Drawing {drawing} Rev {rev} matches the packet" },
{ group: "After welding", key: "vt", text: "Visual inspection of all welds acceptable" },
];
const ROWS = [
{ dept_code: "03", dept_name: "Weld Parts", weld: true, custom: true, version: 3, editable: true },
{ dept_code: "14", dept_name: "Electrical", weld: false, custom: false, version: 0, editable: false },
];
function dept03(over: Record<string, unknown> = {}) {
return { dept_code: "03", dept_name: "Weld Parts", weld: true, custom: true, version: 3, editable: true, items: OWN, defaults: DEFAULTS, ...over };
}
function base(me = SUP, extra: Record<string, Route> = {}): Record<string, Route> {
return {
"/api/auth/me": { body: me },
"/api/departments": { body: DEPARTMENTS },
"GET /api/checklists": { body: ROWS },
"GET /api/checklists/03": { body: dept03() },
...extra,
};
}
beforeEach(() => {
vi.stubGlobal("localStorage", memoryStorage());
});
describe("Checklists screen", () => {
it("is in the top bar for a department supervisor, and lists only their own departments", async () => {
mockFetch(base());
renderApp("/stall");
const link = await screen.findByRole("link", { name: "Checklists" });
expect(link).toHaveAttribute("href", "/checklists");
fireEvent.click(link);
expect(await screen.findByRole("heading", { name: "Checklists" })).toBeInTheDocument();
expect(await screen.findByRole("heading", { name: "03 Weld Parts" })).toBeInTheDocument();
expect(screen.getByText(/Own list · 2 items/)).toBeInTheDocument();
// One editable department: no picker, and Electrical is not offered.
expect(screen.queryByRole("combobox", { name: "Department" })).not.toBeInTheDocument();
expect(screen.queryByText(/Electrical/)).not.toBeInTheDocument();
});
it("edits, checks and saves with the base version", async () => {
const fetch = mockFetch(base(SUP, { "PUT /api/checklists/03": { body: dept03({ version: 4 }) } }));
renderApp("/checklists");
expect(await screen.findByLabelText("Item 1 key")).toHaveAttribute("readonly");
fireEvent.click(screen.getByRole("button", { name: "Add item" }));
fireEvent.change(screen.getByLabelText("Item 3 text"), { target: { value: "Weld size checked" } });
fireEvent.click(screen.getByRole("button", { name: "Save checklist" }));
expect(await screen.findByRole("alert")).toHaveTextContent("Item 3: the key is lower case letters");
fireEvent.change(screen.getByLabelText("Item 3 key"), { target: { value: "size" } });
fireEvent.click(screen.getByRole("button", { name: "Move item 3 up" }));
fireEvent.click(screen.getByRole("button", { name: "Save checklist" }));
expect(await screen.findByRole("status")).toHaveTextContent(SAVED_TEXT);
expect(callsTo(fetch, "PUT", "/api/checklists/03")).toEqual([
{
items: [OWN[0], { group: "After welding", key: "size", text: "Weld size checked" }, OWN[1]],
base: 3,
},
]);
});
it("starts from the default list for the kind", async () => {
const fetch = mockFetch(base(SUP, { "PUT /api/checklists/03": { body: dept03({ items: DEFAULTS, version: 4 }) } }));
renderApp("/checklists");
fireEvent.click(await screen.findByRole("button", { name: "Start from the default weld list" }));
expect(screen.getByLabelText("Item 2 text")).toHaveValue("WPS reviewed: {wps}");
fireEvent.click(screen.getByRole("button", { name: "Save checklist" }));
await waitFor(() => expect(callsTo(fetch, "PUT", "/api/checklists/03")).toEqual([{ items: DEFAULTS, base: 3 }]));
});
it.each([
["under current", (c: object) => ({ detail: "Someone saved first.", code: "version_conflict", current: c })],
["as the body", (c: object) => c],
])("loads the newer list a 409 carries %s", async (_how, wrap) => {
const newer = dept03({ version: 5, items: [OWN[0]] });
mockFetch(base(SUP, { "PUT /api/checklists/03": { status: 409, body: wrap(newer) } }));
renderApp("/checklists");
fireEvent.change(await screen.findByLabelText("Item 2 text"), { target: { value: "Changed" } });
fireEvent.click(screen.getByRole("button", { name: "Save checklist" }));
expect(await screen.findByRole("alert")).toHaveTextContent(CONFLICT_TEXT);
expect(screen.queryByLabelText("Item 2 text")).not.toBeInTheDocument();
expect(screen.getByText(/Own list · 1 items/)).toBeInTheDocument();
});
it("shows the server's message on a 403", async () => {
mockFetch(base(SUP, { "PUT /api/checklists/03": { status: 403, body: { detail: "Only the department supervisor or Admin edits this checklist." } } }));
renderApp("/checklists");
await screen.findByLabelText("Item 1 text");
fireEvent.click(screen.getByRole("button", { name: "Save checklist" }));
expect(await screen.findByRole("alert")).toHaveTextContent("Only the department supervisor or Admin edits this checklist.");
});
it("offers a picker when more than one department is editable", async () => {
const fetch = mockFetch(
base(makeMe({ roles: ["shop:03:supervisor", "shop:14:supervisor"], screens: ["supervisor", "stall"] }), {
"GET /api/checklists": { body: ROWS.map((r) => ({ ...r, editable: true })) },
"GET /api/checklists/14": {
body: { dept_code: "14", dept_name: "Electrical", weld: false, custom: false, version: 0, editable: true, items: [], defaults: [] },
},
}),
);
renderApp("/checklists");
const pick = await screen.findByRole("combobox", { name: "Department" });
expect(pick).toHaveTextContent("14 Electrical · Uses the default list");
fireEvent.change(pick, { target: { value: "14" } });
expect(await screen.findByRole("heading", { name: "14 Electrical" })).toBeInTheDocument();
expect(callsTo(fetch, "GET", "/api/checklists/14")).toHaveLength(1);
});
it("says so when nothing is editable", async () => {
mockFetch(base(SUP, { "GET /api/checklists": { body: ROWS.map((r) => ({ ...r, editable: false })) } }));
renderApp("/checklists");
expect(await screen.findByText(NONE_TEXT)).toBeInTheDocument();
});
it("is not offered to a lead or Q.C., and the URL says it is not for their role", async () => {
const fetch = mockFetch(base(LEAD));
renderApp("/checklists");
expect(await screen.findByTestId("not-for-role")).toHaveTextContent("The Checklists screen is not for your role.");
expect(screen.queryByRole("link", { name: "Checklists" })).not.toBeInTheDocument();
expect(callsTo(fetch, "GET", "/api/checklists")).toHaveLength(0);
});
it("is not for Q.C.", async () => {
mockFetch(base(QC, { "/api/qc/folders": { body: { projects: [] } } }));
renderApp("/checklists");
expect(await screen.findByTestId("not-for-role")).toBeInTheDocument();
expect(screen.queryByRole("link", { name: "Checklists" })).not.toBeInTheDocument();
});
});
@@ -0,0 +1,18 @@
import { canChecklists } from "../../lib/toolAccess";
import { RequireRole } from "../tools/RequireRole";
import { DeptChecklists } from "./DeptChecklists";
/** /checklists: department supervisors edit their own department's checklist, Admin any (change C8). */
export function ChecklistsPage() {
return (
<RequireRole allow={canChecklists} what="The Checklists screen" who="It is for department supervisors and Admin.">
<header className="phead">
<div style={{ minWidth: 0 }}>
<div className="eyebrow">Department checklists</div>
<h1>Checklists</h1>
</div>
</header>
<DeptChecklists />
</RequireRole>
);
}
@@ -0,0 +1,289 @@
// The department checklists (change C8). A department supervisor edits their own department's list, Admin any.
// Q.C. does not edit them. A stage freezes its list at START, so a saved change applies to stages not started yet.
import { useEffect, useState } from "react";
import { checklistsApi, normaliseChecklist, type ChecklistItem, type DeptChecklist, type DeptChecklistRow } from "../../api/checklists";
import { ApiError } from "../../api/client";
import { errorText } from "../../lib/format";
import { useLoad } from "../../lib/useLoad";
import { Notice, Problem, type Msg } from "../admin/common";
const RX_KEY = /^[a-z][a-z0-9_]{0,29}$/;
const MAX_ITEMS = 40;
const PLACEHOLDERS = new Set(["drawing", "rev", "wps"]);
const DEPT_KEY = "ps.ckdept";
export const SAVED_TEXT = "Checklist saved. Stages not started yet use it. Started stages keep the list they started with.";
export const CONFLICT_TEXT = "Someone else saved this checklist first. Their version is loaded now. Make your change again.";
export const NONE_TEXT = "No department checklist is yours to edit. A department supervisor edits their own department's list.";
interface Row extends ChecklistItem {
/** A key the saved list already has. It cannot change, because ticks on open stages use it. */
saved: boolean;
}
function lsGet(): string | null {
try {
return window.localStorage.getItem(DEPT_KEY);
} catch {
return null;
}
}
function lsSet(v: string): void {
try {
window.localStorage.setItem(DEPT_KEY, v);
} catch {
// Storage may be blocked. The pick still holds for this page.
}
}
const toRows = (items: readonly ChecklistItem[], savedKeys: ReadonlySet<string>): Row[] => items.map((it) => ({ ...it, saved: savedKeys.has(it.key) }));
const keysOf = (items: readonly ChecklistItem[]) => new Set(items.map((i) => i.key));
/** The first problem with the list, or null. Mirrors the server's rules. */
function listProblem(rows: readonly ChecklistItem[]): string | null {
if (rows.length === 0) return "The list needs at least one item.";
if (rows.length > MAX_ITEMS) return `At most ${MAX_ITEMS} items.`;
const seen = new Set<string>();
for (const [i, r] of rows.entries()) {
const n = i + 1;
if (!r.group.trim()) return `Item ${n}: give it a group.`;
if (!r.text.trim()) return `Item ${n}: give it a text.`;
if (!RX_KEY.test(r.key)) return `Item ${n}: the key is lower case letters, digits and _, starting with a letter.`;
if (seen.has(r.key)) return `Item ${n}: the key ${r.key} is used twice.`;
seen.add(r.key);
for (const m of r.text.matchAll(/\{([^}]*)\}/g)) {
if (!PLACEHOLDERS.has(m[1] ?? "")) return `Item ${n}: only {drawing}, {rev} and {wps} can be filled in.`;
}
}
return null;
}
/** The current list a 409 carries, under `current` or at the top. */
function currentOf(body: unknown): Partial<DeptChecklist> | null {
if (!body || typeof body !== "object") return null;
const b = body as { current?: unknown; items?: unknown };
if (b.current && typeof b.current === "object" && "items" in b.current) return b.current as Partial<DeptChecklist>;
return "items" in b ? (b as Partial<DeptChecklist>) : null;
}
function kindText(d: Pick<DeptChecklistRow, "custom" | "weld">): string {
if (d.custom) return "Own list";
return d.weld ? "Uses the default weld list" : "Uses the default list";
}
function ListEditor({ rows, onChange }: { rows: Row[]; onChange: (rows: Row[]) => void }) {
const set = (i: number, patch: Partial<Row>) => onChange(rows.map((r, j) => (j === i ? { ...r, ...patch } : r)));
const move = (i: number, d: number) => {
const j = i + d;
const a = rows[i];
const b = rows[j];
if (!a || !b) return;
const out = [...rows];
out[i] = b;
out[j] = a;
onChange(out);
};
const add = () => {
const last = rows[rows.length - 1];
onChange([...rows, { group: last?.group ?? "", key: "", text: "", saved: false }]);
};
return (
<>
{rows.map((r, i) => {
const n = i + 1;
return (
<div className="frow" key={`${i}-${r.saved ? r.key : "new"}`}>
<label className="f">
{`Item ${n} group`}
<input className="in" value={r.group} maxLength={200} onChange={(e) => set(i, { group: e.target.value })} />
</label>
<label className="f">
{`Item ${n} key`}
<input
className="in mono"
value={r.key}
maxLength={30}
readOnly={r.saved}
title={r.saved ? "A saved key cannot change. Ticks on open stages use it." : undefined}
onChange={(e) => set(i, { key: e.target.value })}
/>
</label>
<label className="f" style={{ gridColumn: "span 2" }}>
{`Item ${n} text`}
<input className="in" value={r.text} maxLength={200} onChange={(e) => set(i, { text: e.target.value })} />
</label>
<div className="end nowrap">
<button className="btn sm ghost" type="button" aria-label={`Move item ${n} up`} disabled={i === 0} onClick={() => move(i, -1)}>
Up
</button>{" "}
<button className="btn sm ghost" type="button" aria-label={`Move item ${n} down`} disabled={i === rows.length - 1} onClick={() => move(i, 1)}>
Down
</button>{" "}
<button className="btn sm rejb" type="button" aria-label={`Remove item ${n}`} onClick={() => onChange(rows.filter((_, j) => j !== i))}>
Remove
</button>
</div>
</div>
);
})}
<div>
<button className="btn sm" type="button" disabled={rows.length >= MAX_ITEMS} onClick={add}>
Add item
</button>
</div>
</>
);
}
/** A read-only list, for a department the caller may not edit. */
function ListView({ items }: { items: readonly ChecklistItem[] }) {
return (
<ul className="plain stack tight" aria-label="Checklist items">
{items.map((it) => (
<li key={it.key}>
<span className="muted small">{it.group}</span> · {it.text}
</li>
))}
</ul>
);
}
function DeptEditor({ dept, onSaved }: { dept: string; onSaved: () => void }) {
const [load, reload] = useLoad((s) => checklistsApi.get(dept, s), dept);
const [base, setBase] = useState<DeptChecklist | null>(null);
const [draft, setDraft] = useState<Row[] | null>(null);
const [busy, setBusy] = useState(false);
const [msg, setMsg] = useState<Msg>(null);
const take = (d: DeptChecklist) => {
setBase(d);
setDraft(toRows(d.items, keysOf(d.items)));
};
useEffect(() => {
if (load.state === "ok") {
setBase(load.data);
setDraft(toRows(load.data.items, keysOf(load.data.items)));
}
}, [load]);
if (!base || !draft || base.dept_code !== dept) return <Problem load={load} />;
const save = async () => {
const items = draft.map((r) => ({ group: r.group.trim(), key: r.key.trim(), text: r.text.trim() }));
const problem = listProblem(items);
if (problem) {
setMsg({ ok: false, text: problem });
return;
}
setBusy(true);
setMsg(null);
try {
take(await checklistsApi.save(dept, items, base.version));
onSaved();
setMsg({ ok: true, text: SAVED_TEXT });
} catch (err) {
if (err instanceof ApiError && err.status === 409) {
// The 409 carries the current list ({detail, code, current}, or the list itself). Without it, load it again.
const cur = currentOf(err.body);
if (cur) take(normaliseChecklist(cur, dept));
else reload();
setMsg({ ok: false, text: CONFLICT_TEXT });
} else {
setMsg({ ok: false, text: errorText(err) });
}
} finally {
setBusy(false);
}
};
const title = `${base.dept_code} ${base.dept_name}`.trim();
return (
<section className="panel stack" aria-labelledby="ckl-dept-h">
<div className="sec-h flush">
<h3 id="ckl-dept-h">{title}</h3>
<span className="ct">
{kindText(base)} · {base.items.length} items
</span>
<span className="grow" />
{base.editable ? (
<button
className="btn sm ghost"
type="button"
disabled={busy || base.defaults.length === 0}
onClick={() => setDraft(toRows(base.defaults, keysOf(base.items)))}
>
{base.weld ? "Start from the default weld list" : "Start from the default list"}
</button>
) : null}
</div>
{base.editable ? (
<>
<ListEditor rows={draft} onChange={setDraft} />
<Notice msg={msg} />
<div className="chips">
<button className="btn pri" type="button" disabled={busy} onClick={() => void save()}>
Save checklist
</button>
<button
className="btn ghost"
type="button"
disabled={busy}
onClick={() => {
take(base);
setMsg(null);
}}
>
Discard changes
</button>
</div>
</>
) : (
<>
<div className="small muted">You can read this list. Its department supervisor or Admin edits it.</div>
<ListView items={base.items} />
</>
)}
</section>
);
}
/** The department picker and the editor. Lists only the departments the caller may edit. */
export function DeptChecklists() {
const [load, reload] = useLoad((s) => checklistsApi.list(s));
const [picked, setPicked] = useState<string | null>(lsGet);
if (load.state !== "ok") return <Problem load={load} />;
const rows = load.data.filter((d) => d.editable);
const dept = rows.find((d) => d.dept_code === picked) ?? rows[0];
if (!dept) return <div className="empty">{NONE_TEXT}</div>;
const choose = (v: string) => {
setPicked(v);
lsSet(v);
};
return (
<div className="stack">
<div className="small muted">
Each department has its own checklist. Until it has one, it uses the default for its kind. Text can use {"{drawing}"}, {"{rev}"} and {"{wps}"}. A
stage keeps the list it started with, so a change applies to stages not started yet.
</div>
{rows.length > 1 ? (
<label className="f" style={{ maxWidth: 420 }}>
Department
<select className="in" value={dept.dept_code} onChange={(e) => choose(e.target.value)}>
{rows.map((d) => (
<option key={d.dept_code} value={d.dept_code}>
{`${d.dept_code} ${d.dept_name} · ${kindText(d)}`}
</option>
))}
</select>
</label>
) : null}
<DeptEditor key={dept.dept_code} dept={dept.dept_code} onSaved={reload} />
</div>
);
}
@@ -96,7 +96,7 @@ export function ClericalHome() {
) : null}
{k.toClose ? (
<div className="callout flush">
<b>{k.toClose === 1 ? "1 packet is" : `${k.toClose} packets are`} back from the shop.</b> Close the job in VISUAL, then mark it closed on the packet.
<b>{k.toClose === 1 ? "1 packet is" : `${k.toClose} packets are`} back from the shop.</b> Close the job in VISUAL. A VISUAL packet moves on when the feed sees it closed. Mark a hand packet closed on the packet.
</div>
) : null}
</div>
+1 -1
View File
@@ -38,7 +38,7 @@ export function clericalBoards(all: PacketCard[]): {
key: "close",
title: "Ready to close",
cards: toClose,
empty: "Finished packets come back here. Close the job in VISUAL, then mark it closed on the packet.",
empty: "Finished packets come back here. Close the job in VISUAL. A VISUAL packet moves on when the feed sees it closed; mark a hand packet closed on the packet.",
},
{ key: "book", title: "Ready for data book", cards: toBook, empty: "Jobs closed in VISUAL wait here for the data book." },
{ key: "filed", title: "Filed", cards: filed, empty: "Nothing filed yet." },
+42 -5
View File
@@ -38,7 +38,7 @@ const OVERVIEW: PmOverview = {
{ key: "open", n: 6, label: "Open packets", tab: "aging", alert: false },
{ key: "late_units", n: 1, label: "Late units", tab: "projects", alert: true },
{ key: "risk_units", n: 0, label: "Units at risk", tab: "projects", alert: true },
{ key: "over", n: 2, label: "Packets over target hours", tab: "hours", alert: true },
{ key: "over", n: 2, label: "Packets over target hours (tickets)", tab: "hours", alert: true },
{ key: "lead", n: "4.2 d", label: "Average lead time, created → filed", tab: "aging", alert: false },
],
summary: ["6 open packets in scope (All projects).", "Late: L670001."],
@@ -70,9 +70,9 @@ const HOURS: PmTables = {
{
key: "hours_over",
title: "Packets over target",
note: "Station timer hours and VISUAL ticket hours against the work-order target hours. The timer is the clock of record.",
head: ["Job", "Station / worker", "Status", "Timer h", "Ticket h", "Target h", "Over by"],
rows: [row(["L670001 · A", "Stall 1", "Working", "9.0", "8.5", "6.0", "+3.0 h"], { pid: 41, project_id: 7, unit_id: 12, flag: true })] as PmTable["rows"],
note: "VISUAL ticket hours against the work-order target hours decide over target. The station timer is shown beside them. A packet with no ticket hours is not over target.",
head: ["Job", "Station / worker", "Status", "Timer h", "Ticket h", "Target h", "Over by (tickets)"],
rows: [row(["L670001 · A", "Stall 1", "Working", "5.0", "8.5", "6.0", "+2.5 h"], { pid: 41, project_id: 7, unit_id: 12, flag: true })] as PmTable["rows"],
},
{ key: "hours_dept", title: "By department", note: null, head: ["Department", "Packets"], rows: [] },
],
@@ -151,7 +151,7 @@ describe("PmHome", () => {
expect(urls(fetch)).toContain("/api/pm/hours?period=90");
const over = screen.getByRole("table", { name: "Packets over target" });
const r = within(over).getByText("+3.0 h").closest("tr");
const r = within(over).getByText("+2.5 h").closest("tr");
expect(r).toHaveClass("flag", "rowlink");
expect(screen.getByText("1 row")).toBeInTheDocument();
expect(within(screen.getByRole("table", { name: "By department" })).getByText("Nothing in scope.")).toBeInTheDocument();
@@ -250,4 +250,41 @@ describe("PmHome", () => {
renderApp("/pm");
expect(await screen.findByText("Every unit with packets is on track.")).toBeInTheDocument();
});
it("labels over target as decided by the ticket hours and keeps the timer column (C1)", async () => {
mockFetch(routes());
renderApp("/pm");
fireEvent.click(await screen.findByTestId("pm-kpi-over"));
const over = await screen.findByRole("table", { name: "Packets over target" });
const heads = within(over).getAllByRole("columnheader").map((h) => h.textContent);
expect(heads).toEqual(["Job", "Station / worker", "Status", "Timer h", "Ticket h", "Target h", "Over by (tickets)"]);
expect(screen.getByText(/A packet with no ticket hours is not over target\./)).toBeInTheDocument();
// The server flags the row on the tickets: 8.5 ticket h over 6.0 target while the timer shows 5.0.
expect(within(over).getByText("5.0").closest("tr")).toHaveClass("flag");
});
});
describe("PM access (C10)", () => {
it("shows the PM nav entry to PS-ProjectMgmt and Admin only", async () => {
mockFetch(routes({ "/api/auth/me": { body: makeMe({ roles: ["pm", "qc"], screens: ["qc", "pm"] }) }, "/api/qc/folders": { body: { projects: [] } } }));
renderApp("/pm");
const nav = await screen.findByRole("navigation", { name: "Screens" });
expect(within(nav).getByRole("button", { name: "Project Mgmt" })).toBeInTheDocument();
});
it("a role without PM that types the URL sees a plain message and no PM call is made", async () => {
const fetch = mockFetch(routes({ "/api/auth/me": { body: makeMe({ roles: ["clerical"], screens: ["clerical"] }) } }));
renderApp("/pm");
expect(await screen.findByTestId("not-for-role")).toHaveTextContent(
"The Project Management screen is not for your role. It is for Project Management and Admin.",
);
expect(urls(fetch).some((u) => u.startsWith("/api/pm/"))).toBe(false);
});
it("drops PM from the switcher when the session lists it without the role", async () => {
mockFetch(routes({ "/api/auth/me": { body: makeMe({ roles: ["qc"], screens: ["qc", "pm"] }) }, "/api/qc/folders": { body: { projects: [] } } }));
renderApp("/pm");
expect(await screen.findByTestId("not-for-role")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Project Mgmt" })).not.toBeInTheDocument();
});
});
+17 -2
View File
@@ -32,6 +32,7 @@ import { Sheet } from "../../components/Sheet";
import { useGuard, type RunOptions } from "../../components/useGuard";
import { useChanged } from "../../lib/changes";
import { errorText, fmtTime } from "../../lib/format";
import { printPdf } from "../../lib/printPdf";
import { useLoad } from "../../lib/useLoad";
import { fmtQcDate } from "./folders";
import {
@@ -324,6 +325,7 @@ export function ItpPage() {
const [sheet, setSheet] = useState<LineSheetState>(null);
const [showLog, setShowLog] = useState(false);
const [msg, setMsg] = useState<string | null>(null);
const [printing, setPrinting] = useState(false);
useChanged(reload, { delayMs: 500, filter: (ev) => ev.kind === "itp" && Number(ev.unit_id) === uid });
useEffect(() => {
@@ -428,6 +430,19 @@ export function ItpPage() {
const sheetItem = sheet ? sections.flatMap((s) => s.items).find((i) => i.n === sheet.key) : undefined;
// Change C4: the stamped copy goes straight to the browser's print dialog, on this page.
const print = async () => {
setMsg(null);
setPrinting(true);
try {
await printPdf(itpPrintUrl(id));
} catch {
setMsg("The print could not be prepared. Try again, or open the ITP (PDF) and print it from there.");
} finally {
setPrinting(false);
}
};
const reopen = async () => {
const reason = await guard.askReason({ title: "Reopen ITP", sub: r.no, label: "Reason", min: 8, max: 300, submitLabel: "Reopen" });
if (reason === null) return;
@@ -453,8 +468,8 @@ export function ItpPage() {
<a className="btn sm ghost" href={itpPdfUrl(id, true)} download>
Download
</a>
<button className="btn sm" type="button" onClick={() => window.open(itpPrintUrl(id), "_blank", "noopener")}>
Print
<button className="btn sm" type="button" disabled={printing} onClick={() => void print()}>
{printing ? "Preparing print" : "Print"}
</button>
{r.can.sign_off ? (
<button className="btn pri" type="button" onClick={() => void write((g) => signOff(id, g.sign, g), { sign: "Sign off the ITP", sub: r.no })}>
+19 -3
View File
@@ -1,5 +1,6 @@
import { fireEvent, screen } from "@testing-library/react";
import { fireEvent, screen, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { printPdf } from "../../lib/printPdf";
import type { Itp } from "../../api/qc";
import { DEPARTMENTS, renderApp } from "../../test/auth";
import { mockFetch } from "../../test/fetchMock";
@@ -14,12 +15,16 @@ function base(itp: Itp) {
};
}
vi.mock("../../lib/printPdf", () => ({ printPdf: vi.fn(() => Promise.resolve()) }));
afterEach(() => {
vi.restoreAllMocks();
vi.mocked(printPdf).mockReset();
vi.mocked(printPdf).mockImplementation(() => Promise.resolve());
});
describe("ItpPage PDF and print", () => {
it("opens the ITP PDF, offers the download, and opens the printed copy", async () => {
it("opens the ITP PDF, offers the download, and prints on the page", async () => {
const open = vi.spyOn(window, "open").mockImplementation(() => null);
mockFetch(base(makeItp()));
renderApp("/qc/units/12/itp");
@@ -28,11 +33,22 @@ describe("ItpPage PDF and print", () => {
const dl = screen.getByRole("link", { name: "Download" });
expect(dl).toHaveAttribute("href", "/api/itps/5/pdf?download=1");
expect(dl).toHaveAttribute("download");
open.mockClear();
fireEvent.click(screen.getByRole("button", { name: "Print" }));
expect(open).toHaveBeenLastCalledWith("/api/itps/5/print", "_blank", "noopener");
await waitFor(() => expect(printPdf).toHaveBeenCalledWith("/api/itps/5/print"));
expect(open).not.toHaveBeenCalled();
expect(await screen.findByRole("button", { name: "Print" })).toBeEnabled();
expect(screen.queryByRole("link", { name: "Signed PDF" })).not.toBeInTheDocument();
});
it("says so when the print cannot be prepared", async () => {
vi.mocked(printPdf).mockRejectedValueOnce(new Error("no pdf"));
mockFetch(base(makeItp()));
renderApp("/qc/units/12/itp");
fireEvent.click(await screen.findByRole("button", { name: "Print" }));
expect(await screen.findByRole("alert")).toHaveTextContent("The print could not be prepared.");
});
it("links the signed PDF on a signed-off ITP", async () => {
mockFetch(
base(
+43 -62
View File
@@ -1,43 +1,32 @@
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ShopCard, StallAnswer, StationOption } from "../../api/shop";
import type { StallCard, StallMine } from "../../api/stall";
import { DEPARTMENTS, makeMe, renderApp } from "../../test/auth";
import { mockFetch } from "../../test/fetchMock";
import { makeCard } from "../../test/packets";
import { memoryStorage } from "../supervisor/memoryStorage";
import { NO_STATION, STALL_KEY } from "./StallHome";
import { NONE_ASSIGNED } from "./StallHome";
const ANDY = makeMe({ roles: ["shop:03:worker"], screens: ["stall"], user: { sam_account: "weld.andy", display_name: "Andy Welder" } });
const STATIONS: StationOption[] = [
{ dept_code: "03", dept_name: "Weld Parts", id: 1, name: "Stall 1" },
{ dept_code: "03", dept_name: "Weld Parts", id: 2, name: "Stall 2" },
];
function shopCard(id: number, over: Partial<ShopCard> = {}): ShopCard {
function stallCard(id: number, over: Partial<StallCard> = {}): StallCard {
const { open_cos: _ignored, ...base } = makeCard({ id, unit: { id: 100 + id, number: `L66200${id}` }, holder: "Stall 1" });
return { ...base, open_cos: [], ...over };
return { ...base, open_cos: [], station_id: null, station_name: null, ...over };
}
function stall(id: number): StallAnswer {
const st = STATIONS.find((s) => s.id === id) as StationOption;
return {
station: { id, name: st.name, dept_code: "03", dept_name: "Weld Parts" },
mine:
id === 1
? [shopCard(1, { status: "in_progress", station: { id: 1, name: "Stall 1" }, clock: { elapsed_ms: 3_600_000, running: false }, target_hrs: 4.5 })]
: [],
submitted: id === 1 ? [shopCard(2, { status: "review", station: { id: 1, name: "Stall 1" } })] : [],
};
}
const MINE: StallMine = {
mine: [
stallCard(1, { status: "in_progress", station: null, station_id: 1, station_name: "Stall 1", clock: { elapsed_ms: 3_600_000, running: false }, target_hrs: 4.5 }),
stallCard(3, { status: "assigned", station: null, station_id: 7, station_name: "Bay 2", dept: { code: "14", name: "Electrical" } }),
],
submitted: [stallCard(2, { status: "review", station: { id: 1, name: "Stall 1" }, station_id: 1, station_name: "Stall 1" })],
};
function base(stations: StationOption[] = STATIONS) {
function base(mine: StallMine = MINE) {
return {
"/api/auth/me": { body: ANDY },
"/api/departments": { body: DEPARTMENTS },
"/api/shop/stations": { body: stations },
"/api/shop/stall?station_id=1": { body: stall(1) },
"/api/shop/stall?station_id=2": { body: stall(2) },
"/api/stall/mine": { body: mine },
};
}
@@ -48,50 +37,42 @@ beforeEach(() => {
});
describe("StallHome", () => {
it("shows the hero, the picker and the two boards", async () => {
mockFetch(base());
renderApp("/stall");
expect(await screen.findByText("STALL 1")).toBeInTheDocument();
expect(screen.getByText("03 Weld Parts")).toHaveClass("eyebrow");
expect(screen.getByText("Packets your supervisor sends to this station appear here.")).toBeInTheDocument();
expect(screen.getByRole("combobox", { name: "Station" })).toHaveValue("1");
const mine = await waitFor(() => column("My packets"));
const card = within(mine).getByRole("link", { name: /^L662001/ });
expect(card).toHaveTextContent("01:00:00 paused · target 4.5 h");
expect(card).toHaveTextContent("Working");
expect(within(column("Submitted, waiting on supervisor")).getByRole("link", { name: /^L662002/ })).toBeInTheDocument();
});
it("switches station and remembers it", async () => {
it("lists my packets across stations, each with its station, and has no station picker", async () => {
const fetch = mockFetch(base());
renderApp("/stall");
const pick = await screen.findByRole("combobox", { name: "Station" });
fireEvent.change(pick, { target: { value: "2" } });
expect(await screen.findByText("STALL 2")).toBeInTheDocument();
await waitFor(() => expect(fetch.mock.calls.some(([u]) => String(u) === "/api/shop/stall?station_id=2")).toBe(true));
expect(window.localStorage.getItem(STALL_KEY)).toBe("2");
expect(await screen.findByText("No work assigned to this stall right now.")).toBeInTheDocument();
expect(screen.getByText("Nothing waiting.")).toBeInTheDocument();
});
const mine = await waitFor(() => column("My packets"));
const one = within(mine).getByRole("link", { name: /^L662001/ });
expect(one).toHaveTextContent("01:00:00 paused · target 4.5 h");
expect(one).toHaveTextContent("Stall 1");
expect(within(mine).getByRole("link", { name: /^L662003/ })).toHaveTextContent("Bay 2");
expect(within(column("Submitted, waiting on supervisor")).getByRole("link", { name: /^L662002/ })).toBeInTheDocument();
it("starts on the remembered station", async () => {
window.localStorage.setItem(STALL_KEY, "2");
mockFetch(base());
renderApp("/stall");
expect(await screen.findByText("STALL 2")).toBeInTheDocument();
});
it("hides the picker with one station", async () => {
mockFetch(base([STATIONS[0] as StationOption]));
renderApp("/stall");
expect(await screen.findByText("STALL 1")).toBeInTheDocument();
expect(screen.queryByRole("combobox", { name: "Station" })).not.toBeInTheDocument();
expect(screen.getByText("Weld stall · Andy Welder")).toHaveClass("eyebrow");
const urls = fetch.mock.calls.map(([u]) => String(u));
expect(urls.some((u) => u.startsWith("/api/stall/mine"))).toBe(true);
expect(urls.some((u) => u.includes("station_id") || u.startsWith("/api/shop/stations") || u.startsWith("/api/shop/stall"))).toBe(false);
});
it("says so when the user has no station", async () => {
mockFetch(base([]));
it("says the supervisor assigns packets when none are assigned", async () => {
mockFetch(base({ mine: [], submitted: [] }));
renderApp("/stall");
expect(await screen.findByText(NO_STATION)).toBeInTheDocument();
expect(await screen.findByText(NONE_ASSIGNED)).toBeInTheDocument();
expect(screen.queryByRole("heading", { name: /^My packets/ })).not.toBeInTheDocument();
});
it("keeps the board when only submitted packets are left", async () => {
mockFetch(base({ mine: [], submitted: MINE.submitted }));
renderApp("/stall");
const mine = await waitFor(() => column("My packets"));
expect(mine).toHaveTextContent(`Nothing to work on right now. ${NONE_ASSIGNED}`);
});
it("sends the find box to the server without a station", async () => {
const fetch = mockFetch(base());
renderApp("/stall");
await waitFor(() => column("My packets"));
fireEvent.change(screen.getByRole("searchbox"), { target: { value: "L662003" } });
await waitFor(() => expect(fetch.mock.calls.some(([u]) => String(u) === "/api/stall/mine?q=L662003")).toBe(true));
});
});
+31 -131
View File
@@ -1,11 +1,5 @@
import { useMemo, useRef, useState } from "react";
import {
getStall,
getStations,
type ShopCard,
type StallAnswer,
type StationOption,
} from "../../api/shop";
import { useMemo, useRef } from "react";
import { getStallMine, type StallCard, type StallMine } from "../../api/stall";
import { CardSlot } from "../../components/Board";
import { Card } from "../../components/Card";
import { FindBar } from "../../components/FindBar";
@@ -15,68 +9,30 @@ import { useChanged } from "../../lib/changes";
import { uniqueCards, useFilters } from "../../lib/filters";
import { sigMap, useFlip } from "../../lib/useFlip";
import { useLoad } from "../../lib/useLoad";
import {
asCard,
fmtClock,
fmtHrs,
liveMs,
lsGet,
lsSet,
useNow,
} from "../supervisor/shopView";
import { asCard, fmtClock, fmtHrs, liveMs, useNow } from "../supervisor/shopView";
/** localStorage key for the picked station id (the old ps.stall). */
export const STALL_KEY = "ps.stall";
/** The empty state: nobody picks a station here (change C11). */
export const NONE_ASSIGNED = "Your supervisor assigns packets to you.";
export const NO_STATION =
"You aren't assigned to a station. Ask an admin to add you to a department.";
type LoadedStall = StallMine & { fetchedAt: number };
type LoadedStall = StallAnswer & { fetchedAt: number };
function StallCard({
p,
fetchedAt,
now,
}: {
p: ShopCard;
fetchedAt: number;
now: number;
}) {
function StallCardView({ p, fetchedAt, now }: { p: StallCard; fetchedAt: number; now: number }) {
const running = !!p.clock?.running;
const state = running
? "running"
: p.status === "in_progress"
? "paused"
: "";
const state = running ? "running" : p.status === "in_progress" ? "paused" : "";
const extra = (
<>
<div className="small mono stclock">
<span className={running ? "sdot run" : "sdot"} aria-hidden="true" />
{fmtClock(liveMs(p.clock, fetchedAt, now))}
{state ? ` ${state}` : ""} · target {fmtHrs(Number(p.target_hrs) || 0)}{" "}
h
{state ? ` ${state}` : ""} · target {fmtHrs(Number(p.target_hrs) || 0)} h
</div>
{p.return_note && p.status === "assigned" ? (
<div className="small due soon">Returned: {p.return_note}</div>
) : null}
{p.return_note && p.status === "assigned" ? <div className="small due soon">Returned: {p.return_note}</div> : null}
</>
);
return <Card p={asCard(p)} extra={extra} />;
}
function StallColumn({
title,
cards,
empty,
fetchedAt,
now,
}: {
title: string;
cards: ShopCard[];
empty: string;
fetchedAt: number;
now: number;
}) {
function StallColumn({ title, cards, empty, fetchedAt, now }: { title: string; cards: StallCard[]; empty: string; fetchedAt: number; now: number }) {
const id = `col-${title.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`;
return (
<section className="col" aria-labelledby={id}>
@@ -87,7 +43,7 @@ function StallColumn({
{cards.length ? (
cards.map((p) => (
<CardSlot key={p.id} id={p.id}>
<StallCard p={p} fetchedAt={fetchedAt} now={now} />
<StallCardView p={p} fetchedAt={fetchedAt} now={now} />
</CardSlot>
))
) : (
@@ -97,37 +53,15 @@ function StallColumn({
);
}
function stationLabel(s: StationOption): string {
return `${s.dept_code} ${s.dept_name} · ${s.name}`;
}
/** /stall (the old homeStall): the station picker, the hero and the two boards. */
/**
* /stall: the packets assigned to me, each at the station the supervisor picked when assigning, across stations.
* Moving to another station is a re-assign by the supervisor.
*/
export function StallHome() {
const [stations] = useLoad<StationOption[]>(
(s) => getStations(s),
"stations",
);
const list = useMemo(
() => (stations.state === "ok" ? stations.data : []),
[stations],
);
const [picked, setPicked] = useState<string | null>(() => lsGet(STALL_KEY));
const station = list.find((s) => String(s.id) === picked) ?? list[0] ?? null;
const choose = (v: string) => {
setPicked(v);
lsSet(STALL_KEY, v);
};
const { departments } = useAuth();
const flt = useFilters("stall", { stationId: station?.id ?? null });
const { me, departments } = useAuth();
const flt = useFilters("stall");
const { f, query, queryKey } = flt;
const [stall, reload] = useLoad<LoadedStall | null>(
async (s) =>
station
? { ...(await getStall(station.id, s, query)), fetchedAt: Date.now() }
: null,
`${station?.id ?? ""}|${queryKey}`,
);
const [stall, reload] = useLoad<LoadedStall>(async (s) => ({ ...(await getStallMine(query, s)), fetchedAt: Date.now() }), queryKey);
useChanged(reload, { delayMs: 500 });
const data = stall.state === "ok" ? stall.data : null;
@@ -138,49 +72,20 @@ export function StallHome() {
const sigs = useMemo(() => sigMap(cols.flat()), [cols]);
useFlip(boardRef, "[data-card-id]", [cols], sigs);
const rows = useMemo(() => uniqueCards(cols).map(asCard), [cols]);
const total = data ? data.mine.length + data.submitted.length : 0;
const nothing = !!data && total === 0 && flt.active === 0;
if (stations.state === "loading")
return <div className="empty slim">Loading</div>;
if (stations.state === "error")
return <div className="callout warn">{stations.message}</div>;
if (!station) return <div className="empty stnone">{NO_STATION}</div>;
const deptLabel = `${station.dept_code} ${station.dept_name}`;
return (
<>
<div className="stallhero">
<div className="big">{station.name.toUpperCase()}</div>
<div className="grow">
<div className="eyebrow">{deptLabel}</div>
<div className="small muted">
Packets your supervisor sends to this station appear here.
</div>
<div className="eyebrow">Weld stall{me ? ` · ${me.user.display_name}` : ""}</div>
<div className="small muted">The packets assigned to you, each at the station your supervisor picked.</div>
</div>
{list.length > 1 ? (
<label className="f stpick">
<span>Station</span>
<select
aria-label="Station"
className="in"
value={String(station.id)}
onChange={(e) => choose(e.target.value)}
>
{list.map((s) => (
<option key={s.id} value={String(s.id)}>
{stationLabel(s)}
</option>
))}
</select>
</label>
) : null}
</div>
{stall.state === "loading" ? (
<div className="empty slim">Loading</div>
) : null}
{stall.state === "error" ? (
<div className="callout warn">{stall.message}</div>
) : null}
{stall.state === "loading" ? <div className="empty slim">Loading</div> : null}
{stall.state === "error" ? <div className="callout warn">{stall.message}</div> : null}
<FindBar
f={f}
@@ -190,29 +95,24 @@ export function StallHome() {
onPick={flt.setPick}
view={flt.view}
onView={flt.setView}
count={data ? data.mine.length + data.submitted.length : null}
count={data ? total : null}
facets={null}
depts={departments}
screen="stall"
/>
{data && flt.view === "list" ? <JobList rows={rows} sort={f.sort} onSort={(sort) => flt.set({ sort })} /> : null}
{data && flt.view === "board" ? (
{nothing ? <div className="empty stnone">{NONE_ASSIGNED}</div> : null}
{data && !nothing && flt.view === "list" ? <JobList rows={rows} sort={f.sort} onSort={(sort) => flt.set({ sort })} /> : null}
{data && !nothing && flt.view === "board" ? (
<div className="board" ref={boardRef}>
<StallColumn
title="My packets"
cards={data.mine}
empty="No work assigned to this stall right now."
fetchedAt={data.fetchedAt}
now={now}
/>
<StallColumn
title="Submitted, waiting on supervisor"
cards={data.submitted}
empty="Nothing waiting."
empty={flt.active ? "Nothing assigned to you matches the filters." : `Nothing to work on right now. ${NONE_ASSIGNED}`}
fetchedAt={data.fetchedAt}
now={now}
/>
<StallColumn title="Submitted, waiting on supervisor" cards={data.submitted} empty="Nothing waiting." fetchedAt={data.fetchedAt} now={now} />
</div>
) : null}
</>
@@ -0,0 +1,54 @@
import { fireEvent, screen, within } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { DEPARTMENTS, makeMe, renderApp } from "../../test/auth";
import { callsTo, mockFetch } from "../../test/fetchMock";
const VALID = {
found: true,
valid: true,
reason: null,
print: { id: 5, at: "2026-10-20T10:00:00Z", by_name: "Clara Clerk", kind: "databook", what: "Data book", pages: 12, packet_id: 42, packet_label: "L662001/1" },
};
function base(roles: string[], screens: string[]) {
return {
"/api/auth/me": { body: makeMe({ roles, screens }) },
"/api/departments": { body: DEPARTMENTS },
"POST /api/stamp-verify": { body: VALID },
};
}
describe("Stamp check screen", () => {
it.each([
["Clerical", ["clerical"], ["clerical"]],
["Clerical supervisor", ["clerical_supervisor"], ["clerical"]],
["Q.C.", ["qc"], ["qc"]],
["Q.C. supervisor", ["qc_supervisor"], ["qc"]],
["Admin", ["admin"], ["clerical", "warehouse", "supervisor", "stall", "qc", "pm", "admin"]],
])("is open to %s from the top bar and checks on /api/stamp-verify", async (_name, roles, screens) => {
const fetch = mockFetch(base(roles, screens));
renderApp("/account");
const link = await screen.findByRole("link", { name: "Stamp check" });
expect(link).toHaveAttribute("href", "/stamp-check");
fireEvent.click(link);
expect(await screen.findByRole("heading", { name: "Stamp check" })).toBeInTheDocument();
fireEvent.change(screen.getByLabelText("Stamp line or code"), { target: { value: "PS 0123456789abcdef" } });
fireEvent.click(screen.getByRole("button", { name: "Check stamp" }));
const result = await screen.findByRole("status", { name: "Stamp result" });
expect(within(result).getByText("L662001/1")).toBeInTheDocument();
expect(callsTo(fetch, "POST", "/api/stamp-verify")).toEqual([{ text: "PS 0123456789abcdef" }]);
expect(callsTo(fetch, "POST", "/api/admin/stamp-verify")).toHaveLength(0);
});
it.each([
["Warehouse", ["warehouse"], ["warehouse"]],
["a shop supervisor", ["shop:03:supervisor"], ["supervisor", "stall"]],
["Project Management", ["pm"], ["pm"]],
])("is not for %s", async (_name, roles, screens) => {
mockFetch(base(roles, screens));
renderApp("/stamp-check");
expect(await screen.findByTestId("not-for-role")).toHaveTextContent("The Stamp check is not for your role. It is for Admin, Q.C. and Clerical.");
expect(screen.queryByRole("link", { name: "Stamp check" })).not.toBeInTheDocument();
expect(screen.queryByLabelText("Stamp line or code")).not.toBeInTheDocument();
});
});
@@ -0,0 +1,18 @@
import { canStampCheck } from "../../lib/toolAccess";
import { StampVerifyTab } from "../admin/StampVerifyTab";
import { RequireRole } from "../tools/RequireRole";
/** /stamp-check: Admin, Q.C. and Clerical check a printed or saved copy against the print log (change C9). */
export function StampCheckPage() {
return (
<RequireRole allow={canStampCheck} what="The Stamp check" who="It is for Admin, Q.C. and Clerical.">
<header className="phead">
<div style={{ minWidth: 0 }}>
<div className="eyebrow">Controlled copies</div>
<h1>Stamp check</h1>
</div>
</header>
<StampVerifyTab />
</RequireRole>
);
}
@@ -0,0 +1,19 @@
import type { ReactNode } from "react";
import { useAuth } from "../../auth/useAuth";
/** A page some roles may open. Anyone else who types the URL sees a plain message, not the page. */
export function RequireRole({ allow, what, who, children }: { allow: (roles: readonly string[]) => boolean; what: string; who: string; children: ReactNode }) {
const { me } = useAuth();
if (!me) return null;
if (!allow(me.roles)) return <NotForRole what={what} who={who} />;
return <>{children}</>;
}
/** "The Stamp check is not for your role. It is for Admin, Q.C. and Clerical." */
export function NotForRole({ what, who }: { what: string; who: string }) {
return (
<div className="callout warn" role="alert" data-testid="not-for-role">
{what} is not for your role. {who}
</div>
);
}