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