Phase 6: Q.C. inspection, project folders, packet documents and the digital ITP
ci / frontend (push) Successful in 37s
ci / server (push) Successful in 9m18s
ci / image (push) Successful in 35s
ci / e2e (push) Successful in 2m17s

The Q.C. screen (KPIs, boards, project folders), the Q.C. tab on the packet
page (check items from the qc_check_items settings row, visual exam per weld,
NDE, hardness with the NACE label, notes), accept with a route and a
signature gated by qc_missing_empty, reject with a reason or an NCR number
closing the inspection into qc_history, Q.C. folder documents per project and
unit with superseding revisions, packet documents as a closed set with
take-out reasons, the digital ITP per unit from the frozen template (line
initials with CAS, unsign, N/A, customer codes, rejects with NCR, block
signatures, sign-off with the PDF hook, admin reopen, the ITP log), hold-point
warnings by department page group, the scoped policy check for unit and
project writes, migration 0007, tests, Playwright scenarios and docs.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
2026-10-17 20:08:53 -06:00
co-authored by Claude Fable 5.1
parent 8bb1f7ae4d
commit 00531cb329
64 changed files with 11715 additions and 260 deletions
+27
View File
@@ -10,6 +10,9 @@ import { SignInPage } from "./pages/SignInPage";
import { AccountPage } from "./screens/account/AccountPage";
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";
/** Route table. Exported apart from the router so tests can mount it in a MemoryRouter. */
@@ -39,6 +42,30 @@ export function AppRoutes() {
</RequireScreen>
}
/>
<Route
path="qc/projects/:projectId"
element={
<RequireScreen screen="qc">
<QcProjectFolder />
</RequireScreen>
}
/>
<Route
path="qc/units/:unitId"
element={
<RequireScreen screen="qc">
<QcUnitFolder />
</RequireScreen>
}
/>
<Route
path="qc/units/:unitId/itp"
element={
<RequireScreen screen="qc">
<ItpPage />
</RequireScreen>
}
/>
<Route path="packets/:id" element={<PacketPage />} />
</Route>
<Route path="*" element={<NotFoundPage />} />
+16 -3
View File
@@ -16,6 +16,7 @@ import type {
PacketHeader,
PacketListQuery,
PacketRead,
QcFields,
ReceiptBody,
ShortBody,
StageFields,
@@ -43,10 +44,12 @@ export const packetsApi = {
attachPages: (id: number, body: AttachPagesBody) => api.post<PacketRead>(`${base(id)}/pages`, body),
removePages: (id: number, n: number, body: { reason?: string } & Guarded = {}) =>
api.delWithBody<PacketRead>(`${base(id)}/pages/${n}`, body),
addDoc: (id: number, body: { asset_id: string; kind: string; title: string } & Guarded) =>
/** Phase 6: kind from the closed set; the title defaults to the kind label on the server. */
addDoc: (id: number, body: { asset_id: string; kind: string; title?: string; note?: string } & Guarded) =>
api.post<PacketRead>(`${base(id)}/docs`, body),
removeDoc: (id: number, docId: number, extra: Guarded = {}) =>
api.delWithBody<PacketRead>(`${base(id)}/docs/${docId}`, extra),
/** Takes a document out of the data book. The row stays, with the reason. */
removeDoc: (id: number, docId: number, body: { reason: string } & Guarded) =>
api.delWithBody<PacketRead>(`${base(id)}/docs/${docId}`, body),
move: (id: number, body: { unit_id: number; reason?: string } & Guarded) => api.post<PacketRead>(`${base(id)}/move`, body),
remove: (id: number, body: { reason: string } & Guarded) => api.delWithBody<PacketRead | null>(base(id), body),
setVclosed: (id: number, closed: boolean, extra: Guarded = {}) =>
@@ -114,6 +117,16 @@ export const packetsApi = {
clock: (id: number, op: "start" | "pause" | "resume", g: Guarded = {}) => api.post<PacketRead>(`${base(id)}/stage/clock`, { op, ...g }),
coVerify: (id: number, no: number, body: { dept: string; note?: string } & Guarded) =>
api.post<PacketRead>(`${base(id)}/change-orders/${no}/verify`, body),
/* Phase 6: the Q.C. inspection. Every answer is the packet. */
/** One check of the open inspection. */
qcCheck: (id: number, key: string, done: boolean, g: Guarded = {}) =>
api.put<PacketRead>(`${base(id)}/qc/checks/${encodeURIComponent(key)}`, { done, ...g }),
/** The visual exam of one weld. result "" clears it. */
qcWeld: (id: number, no: number, body: { result?: "accept" | "reject" | ""; note?: string }, g: Guarded = {}) =>
api.put<PacketRead>(`${base(id)}/qc/welds/${no}`, { ...body, ...g }),
/** The scalar inspection fields with a field-level base: 409 field_conflict when someone changed one first. */
qcPatch: (id: number, body: FieldCas<QcFields>) => api.patch<PacketRead>(`${base(id)}/qc`, body),
};
/** GET /api/departments/{code}/people: the welder picker and the weld sheet's stamp. */
+436
View File
@@ -0,0 +1,436 @@
// The Q.C. home, the Q.C. folders and the digital ITP (phase 6 contract, api/qc.py and api/itp.py).
// These types belong to the Q.C. screens. The packet page types stay in types.ts.
import { api, request } from "./client";
import { qs } from "./query";
import type { Guarded, PacketCard, ProjectStatus, SignBody } from "./types";
/** A person on a record. */
export interface QcWho {
id: string;
display_name: string;
emp_no?: string | null;
}
/* ---------- Q.C. home ---------- */
export interface QcCardInfo {
location: string | null;
sent_at: string | null;
inspector: string | null;
result: string | null;
}
/** A board card: the list card plus the inspection facts and the open ITP hold count. */
export interface QcCard extends PacketCard {
qc?: QcCardInfo | null;
itp_hold_count?: number | null;
}
export interface QcKpis {
waiting: number;
accepted_7d: number;
rejected_7d: number;
}
export const QC_COLUMNS = ["ready", "accepted", "rejected"] as const;
export type QcColumn = (typeof QC_COLUMNS)[number];
/** One project on the folder grid. */
export interface QcFolderCard {
id: number;
number: string;
name: string | null;
customer: string | null;
po: string | null;
status: ProjectStatus;
units: number;
docs: number;
units_without_itp: number;
}
export interface QcHome {
kpis: QcKpis;
columns: Record<QcColumn, QcCard[]>;
folders: QcFolderCard[];
}
export interface QcHomeQuery {
project_id?: number;
unit_id?: number;
}
/* ---------- Q.C. folders ---------- */
/** A Q.C. folder document. Nothing is deleted; a new revision supersedes the old row. */
export interface QcDoc {
id: number;
project_id: number | null;
unit_id: number | null;
type: string;
title: string | null;
rev: string | null;
note: string | null;
asset_id: string;
url: string;
file_name: string | null;
size: number | null;
auto: boolean;
added_at: string;
added_by: QcWho | null;
superseded_by: number | null;
superseded_at: string | null;
superseded_by_user: QcWho | null;
}
/** The unit's ITP in short. */
export interface ItpSummary {
id: number;
no: string;
status: ItpStatus;
done: number;
total: number;
holds: number;
}
export interface QcFolderProject {
id: number;
number: string;
name: string | null;
customer: string | null;
po: string | null;
status: ProjectStatus;
}
export interface QcFolderUnitRow {
id: number;
number: string;
lot: string;
name: string | null;
status: ProjectStatus;
docs: number;
itp: ItpSummary | null;
has_itp_doc: boolean;
}
export interface QcProjectFolder {
project: QcFolderProject;
docs: QcDoc[];
superseded: QcDoc[];
units: QcFolderUnitRow[];
}
export interface QcUnitFolder {
unit: { id: number; number: string; lot: string; name: string | null; status: ProjectStatus; po: string | null };
project: QcFolderProject;
docs: QcDoc[];
inherited: QcDoc[];
superseded: QcDoc[];
itp: ItpSummary | null;
no_itp: boolean;
}
export type QcDocBody = ({ project_id: number; unit_id?: never } | { unit_id: number; project_id?: never }) & {
type: string;
title?: string;
rev?: string;
note?: string;
asset_id: string;
replaces?: number;
} & Guarded;
/* ---------- Digital ITP ---------- */
export type ItpStatus = "open" | "complete";
export type ItpPageCode = "WP" | "C" | "FA";
export interface ItpItem {
n: string;
t: string;
code: string;
cust: string;
crit: string;
}
export interface ItpSection {
n: string;
t: string;
page: ItpPageCode | string;
sig?: boolean;
final?: boolean;
items: ItpItem[];
}
export interface ItpTemplate {
ver: string;
legend: string;
sections: ItpSection[];
}
export interface ItpReject {
at: string;
by: QcWho | null;
ncr: string | null;
note: string | null;
kind: "reject" | "ncr";
}
export interface ItpLine {
key: string;
result: "approve" | "reject" | "na" | null;
na: boolean;
by: QcWho | null;
initials: string | null;
at: string | null;
ncr: string | null;
/** Rejected, no NCR yet. */
ncr_missing: boolean;
note: string | null;
cust_code: string | null;
cust_by: string | null;
cust_at: string | null;
cust_rec: QcWho | null;
rejects: ItpReject[];
row_version: number;
}
export interface ItpBlock {
key: string;
sections: string[];
/** "Sections 1–3" or "Section 4". */
scope: string;
final: boolean;
ready: boolean;
all_na: boolean;
signed: boolean;
signed_at: string | null;
signed_by: QcWho | null;
qn: string | null;
remarks: string | null;
cust_name: string | null;
cust_at: string | null;
cust_by: QcWho | null;
row_version: number;
}
export interface ItpHoldRow {
n: string;
t: string;
page: string;
codes: string[];
rejected: boolean;
ncr: string | null;
}
export interface ItpLogRow {
id: number;
at: string;
by: QcWho | null;
by_name: string | null;
action: string;
note: string | null;
signed: boolean;
}
export interface ItpHeader {
client: string;
pname: string;
pno: string;
po: string;
unit: string;
}
export interface Itp {
id: number;
unit_id: number;
no: string;
hdr: ItpHeader;
status: ItpStatus;
version: number;
template: ItpTemplate;
pages: Array<{ code: string; name: string }>;
lines: Record<string, ItpLine>;
blocks: ItpBlock[];
progress: { done: number; total: number; holds: number };
holds: ItpHoldRow[];
signoff: { at: string; by: QcWho | null; asset_id: string | null } | null;
reopen: { at: string; by: QcWho | null; reason: string } | null;
created_at: string;
created_by: QcWho | null;
can: { edit: boolean; sign_off: boolean; reopen: boolean };
log: ItpLogRow[];
}
/** GET /api/units/{id}/itp when the unit has none. */
export interface NoItp {
itp: null;
unit: { id: number; number: string };
}
export type HeaderField = "no" | keyof ItpHeader;
export interface ItpPatch {
set: Partial<Record<HeaderField, string>>;
base: Partial<Record<HeaderField, string>>;
}
export interface LinePatch {
na?: boolean;
cust_code?: string;
cust_by?: string;
cust_at?: string | null;
note?: string;
}
export interface BlockPatch {
qn?: string;
remarks?: string;
cust_name?: string;
cust_at?: string;
cust_clear?: true;
}
/* ---------- normalising ---------- */
function list<T>(raw: unknown): T[] {
return Array.isArray(raw) ? (raw as T[]) : [];
}
export function normaliseHome(raw: Partial<QcHome> | null): QcHome {
const cols = (raw?.columns ?? {}) as Partial<Record<QcColumn, unknown>>;
const columns = Object.fromEntries(QC_COLUMNS.map((c) => [c, list<QcCard>(cols[c])])) as QcHome["columns"];
return {
kpis: { waiting: 0, accepted_7d: 0, rejected_7d: 0, ...(raw?.kpis ?? {}) },
columns,
folders: list<QcFolderCard>(raw?.folders),
};
}
function normaliseProjectFolder(raw: QcProjectFolder): QcProjectFolder {
return { ...raw, docs: list(raw.docs), superseded: list(raw.superseded), units: list(raw.units) };
}
function normaliseUnitFolder(raw: QcUnitFolder): QcUnitFolder {
return { ...raw, docs: list(raw.docs), inherited: list(raw.inherited), superseded: list(raw.superseded), itp: raw.itp ?? null };
}
/** Every list present, so the screen never trips on a missing field. */
export function normaliseItp(raw: Itp): Itp {
const lines: Record<string, ItpLine> = {};
for (const [k, l] of Object.entries(raw.lines ?? {})) lines[k] = { ...l, rejects: list(l.rejects) };
return {
...raw,
hdr: { client: "", pname: "", pno: "", po: "", unit: "", ...((raw.hdr ?? {}) as Partial<ItpHeader>) },
template: { ...raw.template, sections: list(raw.template?.sections) },
pages: list(raw.pages),
lines,
blocks: list<ItpBlock>(raw.blocks).map((b) => ({ ...b, sections: list(b.sections) })),
progress: { done: 0, total: 0, holds: 0, ...((raw.progress ?? {}) as Partial<Itp["progress"]>) },
holds: list(raw.holds),
log: list(raw.log),
can: { edit: false, sign_off: false, reopen: false, ...((raw.can ?? {}) as Partial<Itp["can"]>) },
};
}
function isItp(raw: Itp | NoItp | null): raw is Itp {
return !!raw && "id" in raw && typeof (raw as Itp).id === "number";
}
/* ---------- calls ---------- */
const enc = encodeURIComponent;
export async function getQcHome(q: QcHomeQuery = {}, signal?: AbortSignal): Promise<QcHome> {
return normaliseHome(await api.get<Partial<QcHome> | null>(`/api/qc/home${qs({ ...q })}`, signal));
}
export async function getFolders(show: "active" | "all" = "active", signal?: AbortSignal): Promise<QcFolderCard[]> {
const raw = await api.get<unknown>(`/api/qc/folders${qs({ show })}`, signal);
if (Array.isArray(raw)) return raw as QcFolderCard[];
return list<QcFolderCard>((raw as { folders?: unknown } | null)?.folders);
}
export async function getProjectFolder(id: number, signal?: AbortSignal): Promise<QcProjectFolder> {
return normaliseProjectFolder(await api.get<QcProjectFolder>(`/api/qc/folders/projects/${id}`, signal));
}
export async function getUnitFolder(id: number, signal?: AbortSignal): Promise<QcUnitFolder> {
return normaliseUnitFolder(await api.get<QcUnitFolder>(`/api/qc/folders/units/${id}`, signal));
}
/** Answers the folder read of the scope the document went into. */
export async function addQcDoc(body: QcDocBody): Promise<QcProjectFolder | QcUnitFolder> {
const raw = await api.post<QcProjectFolder | QcUnitFolder>("/api/qc/docs", body);
return "unit" in raw ? normaliseUnitFolder(raw) : normaliseProjectFolder(raw);
}
/** The unit's ITP, or null when it has none yet. */
export async function getItp(unitId: number, signal?: AbortSignal): Promise<Itp | null> {
const raw = await api.get<Itp | NoItp | null>(`/api/units/${unitId}/itp`, signal);
return isItp(raw) ? normaliseItp(raw) : null;
}
const itp = async (p: Promise<Itp>) => normaliseItp(await p);
export const startItp = (unitId: number, g: Guarded = {}) => itp(api.post<Itp>(`/api/units/${unitId}/itp`, { ...g }));
export const patchItp = (id: number, body: ItpPatch, g: Guarded = {}) => itp(api.patch<Itp>(`/api/itps/${id}`, { ...body, ...g }));
/** Approve (initial) a line. `rowVersion` makes it a compare-and-set. */
export const signLine = (id: number, key: string, rowVersion?: number, g: Guarded = {}) =>
itp(
request<Itp>(`/api/itps/${id}/lines/${enc(key)}/sign`, {
method: "POST",
body: { ...g },
...(rowVersion !== undefined ? { headers: { "If-Match": String(rowVersion) } } : {}),
}),
);
export const unsignLine = (id: number, key: string, g: Guarded = {}) => itp(api.post<Itp>(`/api/itps/${id}/lines/${enc(key)}/unsign`, { ...g }));
export const patchLine = (id: number, key: string, body: LinePatch, g: Guarded = {}) =>
itp(api.patch<Itp>(`/api/itps/${id}/lines/${enc(key)}`, { ...body, ...g }));
export const sectionNa = (id: number, n: string, na: boolean, g: Guarded = {}) =>
itp(api.post<Itp>(`/api/itps/${id}/sections/${enc(n)}/na`, { na, ...g }));
export const rejectLine = (id: number, key: string, body: { ncr?: string; note?: string }, g: Guarded = {}) =>
itp(api.post<Itp>(`/api/itps/${id}/lines/${enc(key)}/reject`, { ...body, ...g }));
export const enterNcr = (id: number, key: string, ncr: string, g: Guarded = {}) =>
itp(api.post<Itp>(`/api/itps/${id}/lines/${enc(key)}/ncr`, { ncr, ...g }));
export const patchBlock = (id: number, key: string, body: BlockPatch, g: Guarded = {}) =>
itp(api.patch<Itp>(`/api/itps/${id}/blocks/${enc(key)}`, { ...body, ...g }));
export const signBlock = (id: number, key: string, sign: SignBody | undefined, g: Guarded = {}) =>
itp(api.post<Itp>(`/api/itps/${id}/blocks/${enc(key)}/sign`, { ...g, ...(sign ? { sign } : {}) }));
export const unsignBlock = (id: number, key: string, g: Guarded = {}) =>
itp(request<Itp>(`/api/itps/${id}/blocks/${enc(key)}/sign`, { method: "DELETE", body: { ...g } }));
export const signOff = (id: number, sign: SignBody | undefined, g: Guarded = {}) =>
itp(api.post<Itp>(`/api/itps/${id}/signoff`, { ...g, ...(sign ? { sign } : {}) }));
export const reopenItp = (id: number, body: { reason: string; it_reason?: string }) => itp(api.post<Itp>(`/api/itps/${id}/reopen`, body));
export const qcApi = {
getQcHome,
getFolders,
getProjectFolder,
getUnitFolder,
addQcDoc,
getItp,
startItp,
patchItp,
signLine,
unsignLine,
patchLine,
sectionNa,
rejectLine,
enterNcr,
patchBlock,
signBlock,
unsignBlock,
signOff,
reopenItp,
};
+144 -3
View File
@@ -293,6 +293,137 @@ export interface PacketDoc {
added_at: string | null;
added_by: WhoRef | null;
url: string;
/** Phase 6: the kind's label from the closed set. */
kind_label?: string;
note?: string | null;
file_name?: string | null;
size?: number | null;
/** Phase 6: a document taken out of the data book. The row stays as a record. */
removed?: { at: string; by: WhoRef | null; reason: string | null } | null;
}
/* ---------- Phase 6: Q.C. on the packet ---------- */
/** One Q.C. check of the open inspection. */
export interface QcCheckEntry {
done: boolean;
at?: string | null;
by?: string | null;
by_name?: string | null;
stamp?: string | null;
}
/** The visual exam of one weld, keyed by the weld number as text. An empty result means cleared. */
export interface QcWeldExam {
result?: "accept" | "reject" | "" | null;
note?: string | null;
at?: string | null;
by?: string | null;
by_name?: string | null;
}
/** The open Q.C. inspection (`qc` on the packet read). */
export interface QcInspection {
id: number;
sent_at: string | null;
sent_by?: WhoRef | null;
book: boolean | null;
spool: boolean | null;
location: string | null;
sent_note: string | null;
nde_method: string | null;
nde_result: string | null;
nde_report: string | null;
hardness: string | null;
inspector: string | null;
stamp: string | null;
notes: string | null;
result: "accepted" | "rejected" | null;
result_at: string | null;
result_note: string | null;
result_by?: WhoRef | null;
row_version: number;
checks: Record<string, QcCheckEntry>;
weld_exam: Record<string, QcWeldExam>;
ncr?: string | null;
/** The packet's WPS matches /NACE/i. */
nace_label?: boolean;
/** What Q.C. still has to record before an accept (the server's qc_missing). */
missing?: string[];
}
/** One closed inspection (`qc_history[]` and `qc_last`). */
export interface QcHistoryRow {
id: number;
at: string | null;
result: "accepted" | "rejected" | string | null;
inspector: string | null;
note: string | null;
dept_code: string | null;
ncr?: string | null;
rejected_welds?: string[];
by?: WhoRef | null;
location?: string | null;
sent_at?: string | null;
}
/** One Q.C. check item with the old qcTab hint. */
export interface QcItem {
group: string;
key: string;
text: string;
hint?: string | null;
}
/** A current Q.C. folder document of the packet's unit or project. */
export interface QcFolderDoc {
id: number;
type: string;
title: string | null;
rev: string | null;
url: string;
scope: "unit" | "project";
added_at: string | null;
}
/** The unit's ITP summary. */
export interface ItpSummary {
id: number;
no: string | null;
status: "open" | "complete" | string;
done: number;
total: number;
holds: number;
}
/** One open hold, witness or rejected point on the packet's ITP pages. */
export interface ItpHoldItem {
n: string;
t: string;
codes: string[];
rejected: boolean;
ncr: string | null;
}
/** The open ITP hold points for the packet's department (a warning only). */
export interface ItpHold {
no: string | null;
count: number;
rejected: boolean;
/** The department's page codes, or null for every page. */
pages: string[] | null;
items: ItpHoldItem[];
}
/** PATCH /api/packets/{id}/qc: the scalar inspection fields. */
export interface QcFields {
inspector: string | null;
stamp: string | null;
nde_method: string | null;
nde_result: string | null;
nde_report: string | null;
hardness: string | null;
notes: string | null;
}
/** GET /api/packets/{id} and every packet mutation answer. Children the phase 2 screens do not use stay loose. */
@@ -343,8 +474,8 @@ export interface PacketRead {
stage: Stage | null;
stages: Stage[];
wh: WhInfo | null;
qc: Record<string, unknown> | null;
qc_history: unknown[];
qc: QcInspection | null;
qc_history: QcHistoryRow[];
change_orders: ChangeOrder[];
open_cos: number[];
open_shorts: number;
@@ -354,7 +485,7 @@ export interface PacketRead {
blocked: Record<string, string[]>;
holder: string;
checklist_items: Array<{ group: string; key: string; text: string }>;
qc_items: unknown[];
qc_items: QcItem[];
flag: string | null;
/** Phase 5: the database now(), to tick the clock from. */
server_now?: string | null;
@@ -364,6 +495,14 @@ export interface PacketRead {
labor?: Labor | null;
/** Phase 5: the borrowed heat map of a sibling packet, read-only. */
heat_src?: HeatSrc | null;
/** Phase 6: the newest closed inspection, or null. */
qc_last?: QcHistoryRow | null;
/** Phase 6: the current Q.C. folder documents of the unit and the project. */
qc_folder_docs?: QcFolderDoc[];
/** Phase 6: the unit's ITP summary. */
itp?: ItpSummary | null;
/** Phase 6: open ITP hold points for this packet's department. */
itp_hold?: ItpHold | null;
}
/* ---------- Phase 5: the shop on the packet ---------- */
@@ -585,6 +724,8 @@ export interface TransitionBody {
spool?: boolean;
location?: string;
for_sup_id?: string;
/** Phase 6: QC_REJECT. An NCR number like NCR-2026-0021. */
ncr?: string;
sign?: SignBody;
it_reason?: string;
}
+76
View File
@@ -0,0 +1,76 @@
import { describe, expect, it } from "vitest";
import type { ItpHold } from "../api/types";
import { makePacket } from "../test/packets";
import { DOC_KINDS, docKindLabel, historyLine, holdItemsText, holdItemText, holdText, isNcr, ncrUrl, NDE_METHODS, NDE_RESULTS, normNcr, noteWithoutNcr, plural, rejectedWelds, showsQcRejected } from "./qc";
const hold = (over: Partial<ItpHold> = {}): ItpHold => ({
no: "L073028-ITP",
count: 2,
rejected: false,
pages: ["WP"],
items: [
{ n: "3.4", t: "Fit-up check", codes: ["W"], rejected: false, ncr: null },
{ n: "4.2", t: "Prior to vessel closing", codes: ["H", "W"], rejected: false, ncr: null },
],
...over,
});
describe("lib/qc", () => {
it("has the closed lists", () => {
expect(NDE_METHODS).toEqual(["None required", "RT", "UT", "MT", "PT"]);
expect(NDE_RESULTS).toEqual(["", "Acceptable", "Rejectable"]);
expect(DOC_KINDS.map(([k]) => k)).toEqual(["nde", "hydro", "dimensional", "pmi", "coating", "coc", "customer", "ncr", "other"]);
expect(docKindLabel("nde")).toBe("NDE report (RT / UT / MT / PT)");
expect(docKindLabel("mystery")).toBe("mystery");
});
it("checks and links NCR numbers", () => {
expect(isNcr("NCR-2026-0021")).toBe(true);
expect(isNcr(" ncr-2026-0021 ")).toBe(true);
expect(isNcr("NCR-26-21")).toBe(false);
expect(isNcr("")).toBe(false);
expect(normNcr(" ncr-2026-0021 ")).toBe("NCR-2026-0021");
expect(ncrUrl("NCR-2026-0021")).toBe("https://ncr.pescoinc.biz/ncrs/NCR-2026-0021");
});
it("lists rejected welds by number with their notes", () => {
expect(rejectedWelds({ "10": { result: "reject" }, "3": { result: "reject", note: " undercut " }, "2": { result: "accept" }, "1": { result: "" } })).toEqual([
{ id: "W3", note: "undercut" },
{ id: "W10", note: null },
]);
expect(rejectedWelds(null)).toEqual([]);
});
it("builds the hold callout text with and without pages", () => {
expect(holdText(hold())).toBe("ITP L073028-ITP: 2 open hold / witness points for this stage.");
expect(holdText(hold({ pages: null }))).toBe("ITP L073028-ITP: 2 open hold / witness points.");
expect(holdText(hold({ rejected: true }))).toBe("ITP L073028-ITP: 2 open hold / witness or rejected points for this stage.");
expect(holdText(hold({ count: 1 }))).toBe("ITP L073028-ITP: 1 open hold / witness point for this stage.");
});
it("names the points, the rejected ones with their NCR", () => {
expect(holdItemText({ n: "4.2", t: "Prior to vessel closing", codes: ["H"], rejected: false, ncr: null })).toBe("4.2 Prior to vessel closing (H)");
expect(holdItemText({ n: "1.3", t: "Fit-up", codes: ["W"], rejected: true, ncr: "NCR-2026-0021" })).toBe("1.3 Fit-up (rejected, NCR NCR-2026-0021)");
expect(holdItemText({ n: "1.3", t: "Fit-up", codes: ["W"], rejected: true, ncr: null })).toBe("1.3 Fit-up (rejected, NCR pending)");
expect(holdItemsText(hold())).toBe("3.4 Fit-up check (W) · 4.2 Prior to vessel closing (H, W)");
const many = hold({ count: 6, items: Array.from({ length: 6 }, (_, i) => ({ n: `5.${i + 1}`, t: "Point", codes: ["W"], rejected: false, ncr: null })) });
expect(holdItemsText(many)).toBe("5.1 Point (W) · 5.2 Point (W) · 5.3 Point (W) · 5.4 Point (W) · and 2 more");
});
it("takes the NCR off the front of a reject note", () => {
expect(noteWithoutNcr("NCR-2026-0021 · undercut at W1", "NCR-2026-0021")).toBe("undercut at W1");
expect(noteWithoutNcr("NCR-2026-0021", "NCR-2026-0021")).toBe("");
expect(noteWithoutNcr("undercut", null)).toBe("undercut");
expect(plural(1, "weld")).toBe("1 weld");
expect(plural(3, "weld")).toBe("3 welds");
});
it("builds the earlier inspection line and knows when the rejected callout shows", () => {
const h = { id: 1, at: null, result: "rejected", inspector: "Quinn QC", note: "NCR-2026-0021 · undercut", dept_code: "03", ncr: "NCR-2026-0021", rejected_welds: ["W1", "W3"] };
expect(historyLine(h)).toBe("-: REJECTED by Quinn QC: undercut · NCR-2026-0021 · welds W1, W3");
expect(historyLine({ ...h, result: "accepted", note: null, ncr: null, rejected_welds: [] })).toBe("-: ACCEPTED by Quinn QC");
expect(showsQcRejected(makePacket({ status: "review", qc_last: h }))).toBe(true);
expect(showsQcRejected(makePacket({ status: "qc", qc_last: h }))).toBe(false);
expect(showsQcRejected(makePacket({ status: "assigned", qc_last: { ...h, result: "accepted" } }))).toBe(false);
});
});
+112
View File
@@ -0,0 +1,112 @@
// Q.C. on the packet: the closed lists, NCR numbers and the texts the packet page builds. Pure functions.
import type { ItpHold, ItpHoldItem, PacketRead, QcHistoryRow, QcWeldExam } from "../api/types";
import { fmtTime } from "./format";
/** The NDE methods (the old NDE_METHODS). */
export const NDE_METHODS = ["None required", "RT", "UT", "MT", "PT"] as const;
export const NDE_NONE = "None required";
/** The NDE results. Empty means no result yet. */
export const NDE_RESULTS = ["", "Acceptable", "Rejectable"] as const;
/** Packet document kinds and their labels (the old PDOC_TYPES), in the old order. */
export const DOC_KINDS: ReadonlyArray<readonly [string, string]> = [
["nde", "NDE report (RT / UT / MT / PT)"],
["hydro", "Hydrotest report"],
["dimensional", "Dimensional report"],
["pmi", "PMI report"],
["coating", "Coating / paint report"],
["coc", "Certificate of conformance"],
["customer", "Customer inspection report"],
["ncr", "NCR"],
["other", "Other"],
];
/** The label of a document kind. An unknown kind shows as it is. */
export function docKindLabel(kind: string): string {
return DOC_KINDS.find(([k]) => k === kind)?.[1] ?? kind;
}
/** The accept routes besides the shop departments. The en dash is the old text. */
export const ROUTE_DATABOOK = "Clerical – Data Book";
export const ROUTE_SHIPPING = "Shipping";
const RX_NCR = /^NCR-\d{4}-\d{4}$/;
/** "NCR-2026-0021" from " ncr-2026-0021 ". The server does the same. */
export function normNcr(text: string): string {
return text.trim().toUpperCase();
}
/** A well-formed NCR number, after trimming and upper-casing. */
export function isNcr(text: string): boolean {
return RX_NCR.test(normNcr(text));
}
/** The NCR's page in pesco-ncr. */
export function ncrUrl(ncr: string): string {
return `https://ncr.pescoinc.biz/ncrs/${encodeURIComponent(normNcr(ncr))}`;
}
/** "W3" from 3. */
export function weldId(no: number | string): string {
return `W${no}`;
}
/** The rejected welds of a visual exam, by weld number: [{id: "W1", note: "undercut"}, ...]. */
export function rejectedWelds(exam: Record<string, QcWeldExam> | null | undefined): Array<{ id: string; note: string | null }> {
return Object.entries(exam ?? {})
.filter(([, v]) => v?.result === "reject")
.map(([k, v]) => ({ no: Number(k), id: weldId(k), note: v.note?.trim() || null }))
.sort((a, b) => (Number.isFinite(a.no) && Number.isFinite(b.no) ? a.no - b.no : a.id.localeCompare(b.id)))
.map(({ id, note }) => ({ id, note }));
}
/** "2 welds", "1 weld". */
export function plural(n: number, word: string): string {
return `${n} ${word}${n === 1 ? "" : "s"}`;
}
/** The old itpHoldNote head: "ITP L073028-ITP: 2 open hold / witness points for this stage." */
export function holdText(hold: ItpHold): string {
const n = hold.count;
return `ITP ${hold.no ?? ""}: ${n} open hold / witness ${hold.rejected ? "or rejected " : ""}point${n === 1 ? "" : "s"}${hold.pages ? " for this stage" : ""}.`;
}
/** "4.2 Prior to vessel closing (H)" or "(rejected, NCR NCR-2026-0021)" or "(rejected, NCR pending)". */
export function holdItemText(i: ItpHoldItem): string {
const why = i.rejected ? `rejected, NCR ${i.ncr || "pending"}` : i.codes.join(", ");
return `${i.n} ${i.t} (${why})`;
}
/** The first four points joined by " · ", then "· and N more". */
export function holdItemsText(hold: ItpHold, show = 4): string {
const head = hold.items.slice(0, show).map(holdItemText).join(" · ");
const more = hold.items.length - show;
return more > 0 ? `${head} · and ${more} more` : head;
}
/** A reject note without the NCR number in front: the server stores "NCR-2026-0021 · reason". */
export function noteWithoutNcr(note: string | null | undefined, ncr: string | null | undefined): string {
const t = (note ?? "").trim();
if (!ncr) return t;
if (t === ncr) return "";
const lead = `${ncr} · `;
return t.startsWith(lead) ? t.slice(lead.length) : t;
}
/** "<time>: REJECTED by Quinn QC: note · NCR-2026-0021 · welds W1, W3". */
export function historyLine(h: QcHistoryRow): string {
const who = h.inspector || h.by?.display_name || "";
const parts = [noteWithoutNcr(h.note, h.ncr), h.ncr, h.rejected_welds?.length ? `welds ${h.rejected_welds.join(", ")}` : null].filter(
(x): x is string => !!x,
);
return `${fmtTime(h.at)}: ${String(h.result ?? "").toUpperCase()}${who ? ` by ${who}` : ""}${parts.length ? `: ${parts.join(" · ")}` : ""}`;
}
const REJECTED_SHOWN_ON = new Set(["review", "assigned", "in_progress"]);
/** True when the packet shows the "Q.C. rejected" callout: back in the shop after a reject. */
export function showsQcRejected(p: PacketRead): boolean {
return REJECTED_SHOWN_ON.has(p.status) && p.qc_last?.result === "rejected";
}
+27
View File
@@ -0,0 +1,27 @@
import { Link } from "react-router";
import type { PacketRead } from "../api/types";
import { holdItemsText, holdText } from "../lib/qc";
/**
* The open ITP hold, witness or rejected points for this packet's department (the old itpHoldNote). A warning
* only: nothing is blocked by it. Users with the Q.C. screen get a link to the ITP.
*/
export function HoldCallout({ p, qcScreen }: { p: PacketRead; qcScreen: boolean }) {
const hold = p.itp_hold;
if (!hold || p.status === "filed" || !hold.count) return null;
const items = holdItemsText(hold);
return (
<div className="callout warn itphold" role="note">
<b>{holdText(hold)}</b>
{items ? ` ${items}.` : ""} Check with Q.C. before moving past these points.
{qcScreen ? (
<>
{" "}
<Link className="lnk" to={`/qc/units/${p.unit.id}/itp`}>
Open ITP
</Link>
</>
) : null}
</div>
);
}
+18 -6
View File
@@ -20,13 +20,18 @@ import { ChangesTab } from "./ChangesTab";
import { ChecklistTab } from "./ChecklistTab";
import { HeatMapTab } from "./HeatMapTab";
import { ShopBar } from "./ShopActions";
import { HoldCallout } from "./HoldCallout";
import { QcBar } from "./QcActions";
import { QcRejectedCallout } from "./QcRejectedCallout";
import { showsQcRejected } from "../lib/qc";
import { QcTab } from "./QcTab";
import { WeldMapTab } from "./WeldMapTab";
import type { Write } from "../warehouse/PullTable";
import { packetMessage, pillClassFor, WH_STAGES, pillText, progress, whLine, whStage } from "../warehouse/stages";
import { useWhMove } from "../warehouse/useWhMove";
type Tab = "drawing" | "heat" | "materials" | "checklist" | "welds" | "workorders" | "changes" | "docs" | "history";
const TABS: readonly Tab[] = ["drawing", "heat", "materials", "checklist", "welds", "workorders", "changes", "docs", "history"];
type Tab = "drawing" | "heat" | "materials" | "checklist" | "welds" | "qc" | "workorders" | "changes" | "docs" | "history";
const TABS: readonly Tab[] = ["drawing", "heat", "materials", "checklist", "welds", "qc", "workorders", "changes", "docs", "history"];
function isTab(v: string | null): v is Tab {
return v !== null && (TABS as readonly string[]).includes(v);
}
@@ -317,7 +322,9 @@ export function PacketPage() {
const drawingPages = p.pages.filter((x) => x.type === "drawing");
const review = coToReview(p);
const shownTab: Tab = tab === "changes" && !(p.change_orders ?? []).length ? "materials" : (tab ?? (drawingPages.length ? "drawing" : "materials"));
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"]);
const viewOnly =
@@ -354,9 +361,10 @@ export function PacketPage() {
{ id: "materials", label: "Materials", count: p.bom.length },
{ id: "checklist", label: "Checklist", ...(p.stage ? { count: ckOpen } : {}) },
{ id: "welds", label: "Weld map", count: p.welds.length },
...(hasQc ? [{ id: "qc" as const, label: "Q.C." }] : []),
{ id: "workorders", label: "Work orders", count: p.work_orders.length },
...(cos ? [{ id: "changes" as const, label: "Changes", count: cos }] : []),
{ id: "docs", label: "Documents", count: p.docs.length },
{ id: "docs", label: "Documents", count: p.docs.filter((d) => !d.removed).length },
{ id: "history", label: "History" },
];
const onTabError = (text: string) => setMsg({ ok: false, text });
@@ -463,7 +471,8 @@ export function PacketPage() {
{p.removed.by ? ` by ${p.removed.by.display_name}` : ""}. {p.removed.reason ?? ""}
</div>
) : null}
{p.return_note ? (
<QcRejectedCallout p={p} />
{p.return_note && !(showsQcRejected(p) && p.return_note === p.qc_last?.note) ? (
<div className="callout warn">
<b>Returned:</b> {p.return_note}
</div>
@@ -652,6 +661,8 @@ export function PacketPage() {
) : null}
<ShopBar p={p} busy={busy} mutate={mutate} askReason={askReason} sub={job} goTab={(t) => setTab(t)} />
<QcBar p={p} busy={busy} mutate={mutate} sub={job} goQc={() => setTab("qc")} />
<HoldCallout p={p} qcScreen={me?.screens.includes("qc") ?? false} />
<div className="tabs" role="tablist" aria-label="Packet">
{tabs.map((t) => (
@@ -692,9 +703,10 @@ export function PacketPage() {
{shownTab === "welds" ? (
<WeldMapTab key={p.id} p={p} write={write} mutate={mutate} reload={reload} askReason={askReason} sub={job} myId={me?.user.id ?? null} onError={onTabError} />
) : null}
{shownTab === "qc" ? <QcTab p={p} write={write} mutate={mutate} reload={reload} onError={onTabError} /> : null}
{shownTab === "workorders" ? <WorkOrdersTab p={p} /> : null}
{shownTab === "changes" ? <ChangesTab p={p} roles={me?.roles ?? []} sub={job} mutate={mutate} busy={busy} askReason={askReason} /> : null}
{shownTab === "docs" ? <DocumentsTab p={p} mutate={mutate} busy={busy} /> : null}
{shownTab === "docs" ? <DocumentsTab p={p} mutate={mutate} busy={busy} write={write} askReason={askReason} sub={job} /> : null}
{shownTab === "history" ? <HistoryTab p={p} /> : null}
</div>
+170
View File
@@ -0,0 +1,170 @@
import { useState, type FormEvent } from "react";
import type { PacketRead, RouteKind } from "../api/types";
import { Sheet } from "../components/Sheet";
import { isNcr, normNcr, ROUTE_DATABOOK, ROUTE_SHIPPING } from "../lib/qc";
import { useShopDepartments } from "../lib/useDepartments";
import { makeStep } from "./shopUtil";
import type { Mutate } from "./tabs";
/** Signature meanings, as the rules state them. */
export const SIGN_QC_ACCEPT = "Q.C. accept";
export const SIGN_QC_ACCEPT_ROUTE = "Q.C. accept and route";
export const SIGN_QC_REJECT = "Q.C. reject";
/** The precondition message the bar shows as the list of links instead. */
const MISSING_PREFIX = "Q.C. still needs";
const REJECT_MIN = 8;
/** The reject sheet: a reason, an NCR number, or both. */
export function RejectSheet({
sub,
onCancel,
onReject,
}: {
sub: string;
onCancel: () => 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 submit = (e: FormEvent) => {
e.preventDefault();
if (!ok) return;
onReject({ ...(r ? { 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>
<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>
<label className="f">
NCR number
<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}
<div className="sheet-acts">
<button className="btn ghost" type="button" onClick={onCancel}>
Cancel
</button>
<button className="btn pink" type="submit" disabled={!ok}>
Reject to supervisor
</button>
</div>
</form>
</Sheet>
);
}
const has = (p: PacketRead, a: string) => p.can.includes(a) || !!p.blocked[a];
/**
* The Q.C. bar on a packet at Q.C.: what is still needed (links to the Q.C. tab), the route, Accept and Reject.
* The server decides; `can` and `blocked` only shape the buttons. Accept and reject are signed.
*/
export function QcBar({ p, busy, mutate, sub, goQc }: { p: PacketRead; busy: boolean; mutate: Mutate; sub: string; goQc: () => void }) {
const depts = useShopDepartments().filter((d) => d.code !== p.dept.code);
const [route, setRoute] = useState<string>("databook");
const [rejecting, setRejecting] = useState(false);
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";
const canAccept = p.can.includes(action);
const blocked = canAccept ? [] : (p.blocked[action] ?? p.blocked.qc_accept ?? []);
const reason = blocked.join(" ");
const missing = p.qc?.missing ?? [];
const listed = blocked.some((m) => m.startsWith(MISSING_PREFIX)) && missing.length > 0;
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." });
};
return (
<section className="panel actbox qcbar" aria-label="Q.C. inspection">
<div>
{listed ? (
<span data-testid="qc-missing">
<b>Still needed:</b>{" "}
{missing.map((m, i) => (
<span key={m}>
{i ? " · " : ""}
<button className="lnk" type="button" onClick={goQc}>
{m}
</button>
</span>
))}
</span>
) : canAccept ? (
<>
<b>Everything is recorded.</b> Pick where it goes and accept, or reject it back to the supervisor.
</>
) : (
<>
<b>Inspect the spool and the data book package.</b> Record the checks, the visual exam and NDE on the Q.C. tab.
</>
)}
</div>
{other.length ? <div className="why small">{other.join(" ")}</div> : null}
<div className="acts center">
{has(p, "qc_accept") || has(p, "qc_accept_and_route") ? (
<>
<label className="f qcroute">
Route
<select aria-label="Route" className="in" value={route} onChange={(e) => setRoute(e.target.value)}>
<option value="databook">{ROUTE_DATABOOK}</option>
{depts.map((d) => (
<option key={d.code} value={`DEPT:${d.code}`}>
{d.code} {d.name}
</option>
))}
<option value="shipping">{ROUTE_SHIPPING}</option>
</select>
</label>
<div className="act">
<button className="btn pri" type="button" disabled={!canAccept || busy} title={!canAccept ? reason : undefined} onClick={accept}>
Accept
</button>
</div>
</>
) : null}
{has(p, "qc_reject") ? (
<div className="act">
<button
className="btn pink"
type="button"
disabled={!p.can.includes("qc_reject") || busy}
title={!p.can.includes("qc_reject") ? (p.blocked.qc_reject ?? []).join(" ") : undefined}
onClick={() => setRejecting(true)}
>
Reject
</button>
</div>
) : null}
</div>
{rejecting ? (
<RejectSheet
sub={sub}
onCancel={() => setRejecting(false)}
onReject={(body) => {
setRejecting(false);
void step("qc_reject", body, { sign: SIGN_QC_REJECT, ok: "Q.C. rejected. It is back with the supervisor." });
}}
/>
) : null}
</section>
);
}
+30
View File
@@ -0,0 +1,30 @@
import type { PacketRead } from "../api/types";
import { fmtTime } from "../lib/format";
import { ncrUrl, noteWithoutNcr, showsQcRejected } from "../lib/qc";
/** "Q.C. rejected <time> by Quinn QC: <note>", the NCR link and the rejected welds. */
export function QcRejectedCallout({ p }: { p: PacketRead }) {
const h = p.qc_last;
if (!h || !showsQcRejected(p)) return null;
const who = h.inspector || h.by?.display_name || "";
const note = noteWithoutNcr(h.note, h.ncr);
const welds = h.rejected_welds ?? [];
return (
<div className="callout warn qcrej" role="note">
<b>
Q.C. rejected {fmtTime(h.at)}
{who ? ` by ${who}` : ""}:
</b>
{note ? ` ${note}` : ""}
{h.ncr ? (
<>
{" "}
<a className="lnk" href={ncrUrl(h.ncr)} target="_blank" rel="noopener noreferrer">
NCR {h.ncr}
</a>
</>
) : null}
{welds.length ? <div className="small mt4">Rejected welds: {welds.join(", ")}</div> : null}
</div>
);
}
+406
View File
@@ -0,0 +1,406 @@
import { useRef, useState } from "react";
import { ApiError } from "../api/client";
import { packetsApi } from "../api/packets";
import type { PacketRead, QcFields, QcHistoryRow, QcInspection, QcItem, Weld } from "../api/types";
import { errorText, fmtTime } from "../lib/format";
import { historyLine, NDE_METHODS, NDE_NONE, NDE_RESULTS, plural } from "../lib/qc";
import type { Write } from "../warehouse/PullTable";
import type { Mutate } from "./tabs";
type TextField = Exclude<keyof QcFields, "nde_method" | "nde_result">;
/** The earlier inspections, newest last as the old build listed them. */
function Earlier({ rows }: { rows: QcHistoryRow[] }) {
if (!rows.length) return null;
return (
<section className="sec" aria-label="Earlier inspections">
<div className="sec-h">
<h2>Earlier inspections</h2>
</div>
<div className="stack tight">
{rows.map((h) => (
<div key={h.id} className="small">
{historyLine(h)}
</div>
))}
</div>
</section>
);
}
function groupsOf(items: readonly QcItem[]): Array<{ group: string; items: QcItem[] }> {
const out: Array<{ group: string; items: QcItem[] }> = [];
for (const it of items) {
const g = out.find((x) => x.group === it.group);
if (g) g.items.push(it);
else out.push({ group: it.group, items: [it] });
}
return out;
}
function weldDetail(w: Weld): string {
const parts = [
w.items.length ? `items ${w.items.join(" + ")}` : null,
w.wo_base ? `${w.wo_base}${w.piece ? ` #${w.piece}` : ""}` : null,
w.process,
w.stamp,
].filter((x): x is string => !!x);
return parts.join(" · ");
}
/**
* The Q.C. inspection (the old qcTab): the supervisor's send-off, the inspector, the ten checks, the visual exam
* per weld, NDE and hardness, notes, and the earlier inspections. Editable only with `edit_qc`. Each check and
* each weld is its own PUT; the other fields save on blur with a field-level base.
*/
export function QcTab({
p,
write,
mutate,
reload,
onError,
}: {
p: PacketRead;
write: Write;
mutate: Mutate;
reload: () => Promise<void>;
onError: (text: string) => void;
}) {
const q = p.qc;
if (!q) {
return (
<div className="stack">
<div className="empty slim">No open Q.C. inspection.</div>
<Earlier rows={p.qc_history} />
</div>
);
}
return <Inspection key={q.id} p={p} q={q} write={write} mutate={mutate} reload={reload} onError={onError} />;
}
function Inspection({
p,
q,
write,
mutate,
reload,
onError,
}: {
p: PacketRead;
q: QcInspection;
write: Write;
mutate: Mutate;
reload: () => Promise<void>;
onError: (text: string) => void;
}) {
const ed = p.can.includes("edit_qc");
const [pending, setPending] = useState<Record<string, boolean>>({});
const [vtPending, setVtPending] = useState<Record<string, boolean>>({});
const [draft, setDraft] = useState<Partial<Record<TextField, string>>>({});
const [notes, setNotes] = useState<Record<string, string>>({});
const saving = useRef(new Set<string>());
const fail = async (err: unknown) => {
if (err instanceof ApiError && (err.status === 409 || err.status === 412)) await reload();
onError(errorText(err));
};
const tick = async (key: string, done: boolean) => {
setPending((o) => ({ ...o, [key]: done }));
const r = await write((g) => packetsApi.qcCheck(p.id, key, done, g));
setPending((o) => {
const n = { ...o };
delete n[key];
return n;
});
if (!r.ok && r.err !== undefined) await fail(r.err);
};
const exam = async (no: number, body: { result?: "accept" | "reject" | ""; note?: string }) => {
const k = String(no);
setVtPending((o) => ({ ...o, [k]: true }));
const r = await write((g) => packetsApi.qcWeld(p.id, no, body, g));
setVtPending((o) => {
const n = { ...o };
delete n[k];
return n;
});
if (!r.ok && r.err !== undefined) await fail(r.err);
return r.ok;
};
const saveNote = async (no: number) => {
const k = String(no);
const now = notes[k];
if (now === undefined) return;
const was = q.weld_exam[k]?.note ?? "";
if (now.trim() !== was.trim()) await exam(no, { note: now.trim() });
setNotes((o) => {
const n = { ...o };
delete n[k];
return n;
});
};
const server = (k: keyof QcFields) => q[k] ?? "";
const shown = (k: TextField) => draft[k] ?? server(k);
const setField = async (k: keyof QcFields, value: string) => {
if (saving.current.has(k)) return;
const was = q[k] ?? null;
const now = value.trim() || null;
if (now === was) return;
saving.current.add(k);
try {
await mutate((g) => packetsApi.qcPatch(p.id, { set: { [k]: now }, base: { [k]: was }, ...g }));
} finally {
saving.current.delete(k);
}
};
const saveText = async (k: TextField) => {
const now = draft[k];
if (now === undefined) return;
await setField(k, now);
// On a conflict the page reloads; the field then shows the value someone else saved.
setDraft((d) => ({ ...d, [k]: undefined }));
};
const textInput = (k: TextField, label: string, ph: string, max: number, cls = "in") => (
<label className="f" key={k}>
{label}
<input
aria-label={label}
className={cls}
value={shown(k)}
maxLength={max}
placeholder={ph}
onChange={(e) => setDraft((d) => ({ ...d, [k]: e.target.value }))}
onBlur={() => void saveText(k)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
void saveText(k);
}
}}
/>
</label>
);
const groups = groupsOf(p.qc_items);
const welds = p.welds;
const method = q.nde_method || NDE_NONE;
const hardLabel = q.nace_label ? "Hardness (NACE)" : "Hardness";
return (
<div className="stack qctab">
<div className="callout">
<b>Sent {fmtTime(q.sent_at)}</b>: data book package {q.book ? "ready" : "not confirmed"}, spool {q.spool ? "ready" : "not confirmed"}. Spool location:{" "}
<b>{q.location || "-"}</b>
{q.sent_note ? <div className="small mt4">Note: {q.sent_note}</div> : null}
</div>
<div className="frow qcinsp">
{ed ? (
<>
{textInput("inspector", "Inspector", "Name", 80)}
{textInput("stamp", "Stamp / ID", "ID", 20)}
</>
) : (
<div className="small">
<span className="muted">Inspector: </span>
<b className="near">
{q.inspector || "-"}
{q.stamp ? ` [${q.stamp}]` : ""}
</b>
</div>
)}
</div>
{groups.map((g) => (
<section className="sec" key={g.group} aria-label={g.group}>
<div className="sec-h">
<h2>{g.group}</h2>
</div>
<div className="ck">
{g.items.map((i) => {
const c = q.checks[i.key];
const done = pending[i.key] ?? !!c?.done;
const by = c?.done ? [c.stamp || c.by_name, fmtTime(c.at)].filter(Boolean).join(" · ") : "";
return (
<label key={i.key} className={`ck-item${done ? " done" : ""}`} aria-disabled={!ed}>
<input
type="checkbox"
aria-label={i.text}
checked={done}
disabled={!ed || pending[i.key] !== undefined}
onChange={(e) => void tick(i.key, e.target.checked)}
/>
<span className="t">
{i.text}
{i.hint ? <span className="hint small muted mono">{i.hint}</span> : null}
</span>
<span className="by">{by}</span>
</label>
);
})}
</div>
</section>
))}
<section className="sec" aria-label="Visual weld inspection">
<div className="sec-h">
<h2>Visual weld inspection</h2>
<span className="ct">{plural(welds.length, "weld")} · open the Weld map tab to find them</span>
</div>
<div className="tw">
<table>
<thead>
<tr>
<th>Weld</th>
<th>Detail</th>
<th>Result</th>
<th>Note</th>
</tr>
</thead>
<tbody>
{welds.length ? (
welds.map((w) => {
const k = String(w.weld_no);
const v = q.weld_exam[k] ?? {};
const res = v.result || "";
const busy = !!vtPending[k];
return (
<tr key={k} aria-label={`Weld W${w.weld_no}`}>
<td>
<span className="balloon pinkb">{w.weld_no}</span>
</td>
<td>
{[w.type, w.size].filter(Boolean).join(" ")}
<div className="sub">{weldDetail(w)}</div>
</td>
<td>
{ed ? (
<div className="vtbs">
<button
type="button"
className="vtb acc"
aria-pressed={res === "accept"}
disabled={busy}
onClick={() => void exam(w.weld_no, { result: res === "accept" ? "" : "accept" })}
>
Accept
</button>
<button
type="button"
className="vtb rej"
aria-pressed={res === "reject"}
disabled={busy}
onClick={() => void exam(w.weld_no, { result: res === "reject" ? "" : "reject" })}
>
Reject
</button>
</div>
) : res ? (
<span className={`pill ${res === "accept" ? "st-in_progress" : "st-review"}`}>{res}</span>
) : (
<span className="muted small">-</span>
)}
</td>
<td>
{ed ? (
<input
className="in"
aria-label={`W${w.weld_no} note`}
value={notes[k] ?? v.note ?? ""}
maxLength={300}
placeholder={res === "reject" ? "Defect (undercut, porosity…)" : "Note"}
onChange={(e) => setNotes((o) => ({ ...o, [k]: e.target.value }))}
onBlur={() => void saveNote(w.weld_no)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
void saveNote(w.weld_no);
}
}}
/>
) : (
<span className="small">{v.note ?? ""}</span>
)}
</td>
</tr>
);
})
) : (
<tr>
<td colSpan={4} className="muted">
No welds on the weld map.
</td>
</tr>
)}
</tbody>
</table>
</div>
</section>
<section className="sec" aria-label="NDE & testing">
<div className="sec-h">
<h2>NDE &amp; testing</h2>
</div>
{ed ? (
<>
<div className="frow qcnde">
<label className="f">
NDE method
<select aria-label="NDE method" className="in" value={method} onChange={(e) => void setField("nde_method", e.target.value)}>
{NDE_METHODS.map((m) => (
<option key={m} value={m}>
{m}
</option>
))}
</select>
</label>
<label className="f">
NDE result
<select aria-label="NDE result" className="in" value={q.nde_result ?? ""} onChange={(e) => void setField("nde_result", e.target.value)}>
{NDE_RESULTS.map((m) => (
<option key={m} value={m}>
{m || "-"}
</option>
))}
</select>
</label>
{textInput("nde_report", "Report no.", "Optional", 60)}
{textInput("hardness", hardLabel, "e.g. 198 HB", 40)}
</div>
<label className="f qcnotes">
Inspection notes
<textarea
aria-label="Inspection notes"
className="in"
value={shown("notes")}
maxLength={2000}
onChange={(e) => setDraft((d) => ({ ...d, notes: e.target.value }))}
onBlur={() => void saveText("notes")}
/>
</label>
</>
) : (
<div className="stack tight">
<div className="small">
<span className="muted">NDE: </span>
{[method, q.nde_result, q.nde_report].filter(Boolean).join(" · ")}
</div>
<div className="small">
<span className="muted">{hardLabel}: </span>
{q.hardness || "-"}
</div>
{q.notes ? <div className="small">{q.notes}</div> : null}
</div>
)}
</section>
<Earlier rows={p.qc_history} />
</div>
);
}
+3 -4
View File
@@ -291,16 +291,15 @@ function ReviewBox({
const depts = useShopDepartments().filter((d) => d.code !== p.dept.code);
const [book, setBook] = useState(false);
const [spool, setSpool] = useState(false);
const [loc, setLoc] = useState(
String((p.qc as { location?: string } | null)?.location ?? ""),
);
// After a Q.C. reject the inspection is closed into qc_last; the open inspection (qc) is null.
const [loc, setLoc] = useState(String(p.qc?.location ?? p.qc_last?.location ?? ""));
const [note, setNote] = useState("");
const [ret, setRet] = useState("");
const [route, setRoute] = useState("");
const routeTo = depts.some((d) => d.code === route)
? route
: (depts[0]?.code ?? "");
const rejected = (p.qc as { result?: string } | null)?.result === "rejected";
const rejected = p.qc_last?.result === "rejected";
const ready = book && spool && !!loc.trim();
const returnIt = async () => {
+316
View File
@@ -0,0 +1,316 @@
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import type { PacketRead, QcInspection } from "../api/types";
import { DEPARTMENTS, makeMe, renderApp } from "../test/auth";
import { callsTo, mockFetch } from "../test/fetchMock";
import { makePacket } from "../test/packets";
const DEPTS = [...DEPARTMENTS, { code: "09", slug: "Hydro", name: "Hydro", kind: "shop", stations: [] }];
const ITEMS = [
{ group: "Data book package", key: "db_heats", text: "Heat number recorded for every BOM item", hint: "1/2 recorded" },
{ group: "Physical spool", key: "ph_dim", text: "Dimensions verified against CI-DWG-1 Rev 0", hint: null },
];
function inspection(over: Partial<QcInspection> = {}): QcInspection {
return {
id: 9,
sent_at: "2026-10-17T08:00:00Z",
book: true,
spool: true,
location: "Bay 2",
sent_note: "Rush",
nde_method: "None required",
nde_result: null,
nde_report: null,
hardness: null,
inspector: null,
stamp: null,
notes: null,
result: null,
result_at: null,
result_note: null,
row_version: 1,
checks: {},
weld_exam: {},
ncr: null,
nace_label: false,
missing: ["2 inspection items", "1 weld without a visual result", "inspector name"],
...over,
};
}
function qcPacket(over: Partial<PacketRead> = {}): PacketRead {
return makePacket({
status: "qc",
holder: "Q.C.",
qc: inspection(),
qc_items: ITEMS,
welds: [
{ weld_no: 1, page: 1, fx: 0.1, fy: 0.1, wo_base: "L999001", piece: 1, items: [1, 2], type: "Fillet", size: '5/16"', process: "GMAW", stamp: "75", at: null, row_version: 1, welder: null },
],
can: ["edit_qc", "qc_reject", "doc_add", "doc_remove"],
blocked: { qc_accept: ["Q.C. still needs: 2 inspection items, 1 weld without a visual result, inspector name."], qc_accept_and_route: ["Q.C. still needs: 2 inspection items."] },
...over,
});
}
function base(packet: PacketRead, roles = ["qc"], screens = ["qc"]) {
return {
"/api/auth/me": { body: makeMe({ roles, screens, user: { id: "quinn", display_name: "Quinn QC" } }) },
"/api/departments": { body: DEPTS },
"/api/packets/1": { body: packet },
"/api/packets/1/log": { body: [] },
};
}
async function sign(secret = "pw-1") {
const sheet = await screen.findByRole("dialog", { name: "Sign" });
fireEvent.change(within(sheet).getByLabelText("Password"), { target: { value: secret } });
fireEvent.click(within(sheet).getByRole("button", { name: "Sign" }));
await waitFor(() => expect(screen.queryByRole("dialog", { name: "Sign" })).not.toBeInTheDocument());
}
describe("Q.C. tab", () => {
it("each tick PUTs its key; the weld toggles PUT the result", async () => {
const p = qcPacket();
const after = qcPacket({ version: 4, qc: inspection({ checks: { db_heats: { done: true, at: "2026-10-17T09:00:00Z", stamp: "Q1" } } }) });
const fetch = mockFetch({
...base(p),
"PUT /api/packets/1/qc/checks/db_heats": { body: after },
"PUT /api/packets/1/qc/welds/1": { body: qcPacket({ version: 5, qc: inspection({ weld_exam: { "1": { result: "accept" } } }) }) },
});
renderApp("/packets/1");
fireEvent.click(await screen.findByRole("tab", { name: /^Q\.C\./ }));
expect(screen.getByText(/data book package ready, spool ready/)).toHaveTextContent("Spool location: Bay 2");
expect(screen.getByText("1/2 recorded")).toBeInTheDocument();
const box = screen.getByRole("checkbox", { name: "Heat number recorded for every BOM item" });
fireEvent.click(box);
await waitFor(() => expect(screen.getByRole("checkbox", { name: "Heat number recorded for every BOM item" })).toBeChecked());
expect(callsTo(fetch, "PUT", "/api/packets/1/qc/checks/db_heats")).toEqual([{ done: true }]);
expect(screen.getByText(/^Q1 · /)).toBeInTheDocument();
const row = screen.getByRole("row", { name: "Weld W1" });
expect(within(row).getByText("items 1 + 2 · L999001 #1 · GMAW · 75")).toBeInTheDocument();
fireEvent.click(within(row).getByRole("button", { name: "Accept" }));
await waitFor(() => expect(within(screen.getByRole("row", { name: "Weld W1" })).getByRole("button", { name: "Accept" })).toHaveAttribute("aria-pressed", "true"));
expect(callsTo(fetch, "PUT", "/api/packets/1/qc/welds/1")).toEqual([{ result: "accept" }]);
expect(screen.getByText("1 weld · open the Weld map tab to find them")).toBeInTheDocument();
});
it("saves the inspector on blur with its base", async () => {
const fetch = mockFetch({ ...base(qcPacket()), "PATCH /api/packets/1/qc": { body: qcPacket({ version: 4, qc: inspection({ inspector: "Quinn QC" }) }) } });
renderApp("/packets/1");
fireEvent.click(await screen.findByRole("tab", { name: /^Q\.C\./ }));
const f = screen.getByLabelText("Inspector");
fireEvent.change(f, { target: { value: "Quinn QC" } });
fireEvent.blur(f);
await waitFor(() => expect(callsTo(fetch, "PATCH", "/api/packets/1/qc")).toEqual([{ set: { inspector: "Quinn QC" }, base: { inspector: null } }]));
});
it("is read-only without edit_qc and lists earlier inspections", async () => {
const p = qcPacket({
can: [],
blocked: {},
qc_history: [{ id: 1, at: "2026-10-16T08:00:00Z", result: "rejected", inspector: "Quinn QC", note: "NCR-2026-0021 · undercut", dept_code: "03", ncr: "NCR-2026-0021", rejected_welds: ["W1", "W3"] }],
});
mockFetch(base(p, ["shop:03:supervisor"], ["supervisor"]));
renderApp("/packets/1");
fireEvent.click(await screen.findByRole("tab", { name: /^Q\.C\./ }));
for (const box of screen.getAllByRole("checkbox")) expect(box).toBeDisabled();
expect(screen.queryByRole("button", { name: "Accept" })).not.toBeInTheDocument();
expect(screen.queryByLabelText("Inspector")).not.toBeInTheDocument();
expect(screen.getByText(/REJECTED by Quinn QC: undercut · NCR-2026-0021 · welds W1, W3$/)).toBeInTheDocument();
});
it("is not shown without an inspection or history", async () => {
mockFetch(base(makePacket()));
renderApp("/packets/1");
await screen.findByRole("tab", { name: /^Materials/ });
expect(screen.queryByRole("tab", { name: /^Q\.C\./ })).not.toBeInTheDocument();
});
});
describe("Q.C. bar", () => {
it("lists what is still needed and keeps Accept disabled", async () => {
mockFetch(base(qcPacket()));
renderApp("/packets/1");
const bar = await screen.findByRole("region", { name: "Q.C. inspection" });
expect(within(bar).getByTestId("qc-missing")).toHaveTextContent("Still needed: 2 inspection items · 1 weld without a visual result · inspector name");
const accept = within(bar).getByRole("button", { name: "Accept" });
expect(accept).toBeDisabled();
expect(accept).toHaveAttribute("title", "Q.C. still needs: 2 inspection items, 1 weld without a visual result, inspector name.");
const route = within(bar).getByLabelText("Route");
expect(within(route).getAllByRole("option").map((o) => o.textContent)).toEqual(["Clerical – Data Book", "09 Hydro", "14 Electrical", "Shipping"]);
fireEvent.click(within(bar).getByRole("button", { name: "inspector name" }));
expect(await screen.findByLabelText("Inspector")).toBeInTheDocument();
});
it("accepts to the data book after the signature", async () => {
const p = qcPacket({ qc: inspection({ missing: [] }), can: ["edit_qc", "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).queryByTestId("qc-missing")).not.toBeInTheDocument();
fireEvent.click(within(bar).getByRole("button", { name: "Accept" }));
expect(await within(await screen.findByRole("dialog", { name: "Sign" })).findByText("Q.C. accept")).toBeInTheDocument();
await sign();
expect(callsTo(fetch, "POST", "/api/packets/1/transitions")).toEqual([
{ action: "qc_accept", expect: { status: "qc", dept: "03" }, routed_to: "databook", sign: { factor: "password", secret: "pw-1" } },
]);
await waitFor(() => expect(screen.getByTestId("status-pill")).toHaveTextContent("Routed"));
});
it("accepts and routes to a department", async () => {
const p = qcPacket({ qc: inspection({ missing: [] }), 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.click(within(bar).getByRole("button", { name: "Accept" }));
expect(await within(await screen.findByRole("dialog", { name: "Sign" })).findByText("Q.C. accept and route")).toBeInTheDocument();
await sign();
expect(callsTo(fetch, "POST", "/api/packets/1/transitions")).toEqual([
{ action: "qc_accept_and_route", expect: { status: "qc", dept: "03" }, dept: "09", sign: { factor: "password", secret: "pw-1" } },
]);
});
it("the reject sheet enables on an NCR alone and sends it upper-cased", 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();
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();
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).toBeEnabled();
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" } },
]);
});
});
describe("Callouts", () => {
const hold = {
no: "L999001-ITP",
count: 5,
rejected: true,
pages: ["WP"] as string[] | null,
items: [
{ n: "3.4", t: "Fit-up", codes: ["W"], rejected: false, ncr: null },
{ n: "4.2", t: "Prior to vessel closing", codes: ["H"], rejected: false, ncr: null },
{ n: "4.4", t: "Hydro", codes: ["H"], rejected: true, ncr: "NCR-2026-0021" },
{ n: "4.5", t: "Final", codes: ["H"], rejected: true, ncr: null },
{ n: "5.1", t: "Paint", codes: ["W"], rejected: false, ncr: null },
],
};
it("the hold callout names the points and links Q.C. users to the ITP", async () => {
mockFetch(base(makePacket({ status: "ready", can: [], blocked: {}, itp_hold: hold })));
renderApp("/packets/1");
const note = await screen.findByText(/^ITP L999001-ITP:/);
expect(note).toHaveTextContent("ITP L999001-ITP: 5 open hold / witness or rejected points for this stage.");
const box = note.closest(".itphold");
expect(box).toHaveTextContent(
"3.4 Fit-up (W) · 4.2 Prior to vessel closing (H) · 4.4 Hydro (rejected, NCR NCR-2026-0021) · 4.5 Final (rejected, NCR pending) · and 1 more. Check with Q.C. before moving past these points.",
);
expect(screen.getByRole("link", { name: "Open ITP" })).toHaveAttribute("href", "/qc/units/11/itp");
});
it("the hold callout without pages says nothing about the stage, and no link without the qc screen", async () => {
mockFetch(base(makePacket({ status: "ready", can: [], blocked: {}, itp_hold: { ...hold, pages: null, rejected: false, count: 1, items: hold.items.slice(0, 1) } }), ["shop:03:supervisor"], ["supervisor"]));
renderApp("/packets/1");
expect(await screen.findByText(/^ITP L999001-ITP:/)).toHaveTextContent(/^ITP L999001-ITP: 1 open hold \/ witness point\.$/);
expect(screen.queryByRole("link", { name: "Open ITP" })).not.toBeInTheDocument();
});
it("the Q.C. rejected callout shows the note, the NCR link and the welds", async () => {
const p = makePacket({
status: "review",
can: [],
blocked: {},
return_note: "NCR-2026-0021 · undercut at W1",
qc_history: [{ id: 1, at: "2026-10-16T08:00:00Z", result: "rejected", inspector: "Quinn QC", note: "NCR-2026-0021 · undercut at W1", dept_code: "03", ncr: "NCR-2026-0021", rejected_welds: ["W1"] }],
qc_last: { id: 1, at: "2026-10-16T08:00:00Z", result: "rejected", inspector: "Quinn QC", note: "NCR-2026-0021 · undercut at W1", dept_code: "03", ncr: "NCR-2026-0021", rejected_welds: ["W1"] },
});
mockFetch(base(p, ["shop:03:supervisor"], ["supervisor"]));
renderApp("/packets/1");
const note = await screen.findByText(/^Q\.C\. rejected/);
const box = note.closest(".callout") as HTMLElement;
expect(box).toHaveTextContent(/by Quinn QC: undercut at W1 NCR NCR-2026-0021Rejected welds: W1$/);
const link = within(box).getByRole("link", { name: "NCR NCR-2026-0021" });
expect(link).toHaveAttribute("href", "https://ncr.pescoinc.biz/ncrs/NCR-2026-0021");
expect(link).toHaveAttribute("rel", "noopener noreferrer");
expect(screen.queryByText(/^Returned:/)).not.toBeInTheDocument();
});
});
describe("Documents tab", () => {
const docs = [
{ id: 4, asset_id: "a4", kind: "nde", kind_label: "NDE report (RT / UT / MT / PT)", title: "RT report W1-W4", note: "RPT-2210, accept", file_name: "rt.pdf", size: 900, added_at: "2026-10-17T08:00:00Z", added_by: { id: "quinn", display_name: "Quinn QC" }, url: "/_blob/a4", removed: null },
{ id: 5, asset_id: "a5", kind: "other", kind_label: "Other", title: "Old note", file_name: "o.pdf", added_at: "2026-10-16T08:00:00Z", added_by: null, url: "/_blob/a5", removed: { at: "2026-10-16T09:00:00Z", by: { id: "quinn", display_name: "Quinn QC" }, reason: "wrong packet" } },
];
it("takes a document out with the reason, and lists the taken-out rows", async () => {
const p = qcPacket({ docs, qc_folder_docs: [{ id: 1, type: "WPS / PQR", title: "WPS-101", rev: "2", url: "/_blob/w1", scope: "project", added_at: null }] });
const fetch = mockFetch({ ...base(p), "DELETE /api/packets/1/docs/4": { body: qcPacket({ version: 4 }) } });
renderApp("/packets/1");
fireEvent.click(await screen.findByRole("tab", { name: /^Documents/ }));
expect(screen.getByRole("tab", { name: /^Documents/ })).toHaveTextContent("Documents1");
const sec = screen.getByRole("region", { name: "Documents for the data book" });
expect(within(sec).getByText("1 on this packet")).toBeInTheDocument();
expect(within(sec).getByText(/rt\.pdf · added by Quinn QC .* · RPT-2210, accept/)).toBeInTheDocument();
expect(within(sec).getByRole("button", { name: "Add document" })).toBeInTheDocument();
fireEvent.click(within(sec).getByRole("button", { name: "Show 1 taken out" }));
expect(within(sec).getByText(/Taken out of the data book by Quinn QC .*: wrong packet/)).toBeInTheDocument();
const folder = screen.getByRole("region", { name: "Also in the data book from the Q.C. folder" });
expect(within(folder).getByText("from the project")).toBeInTheDocument();
fireEvent.click(within(sec).getByRole("button", { name: "Take out of data book" }));
const sheet = await screen.findByRole("dialog", { name: "Take out of the data book" });
fireEvent.change(within(sheet).getByLabelText("Reason"), { target: { value: "wrong packet" } });
fireEvent.click(within(sheet).getByRole("button", { name: "Take out" }));
await waitFor(() => expect(callsTo(fetch, "DELETE", "/api/packets/1/docs/4")).toEqual([{ reason: "wrong packet" }]));
});
it("adds a document: uploads, then posts the kind, title and note", async () => {
const fetch = mockFetch({
...base(qcPacket()),
"POST /api/assets": { status: 201, body: { id: "a9", mime: "application/pdf", size: 9, sha256: "00", pages: 1, original_name: "rt.pdf", url: "/_blob/a9" } },
"POST /api/packets/1/docs": { body: qcPacket({ version: 4, docs }) },
});
renderApp("/packets/1");
fireEvent.click(await screen.findByRole("tab", { name: /^Documents/ }));
fireEvent.click(screen.getByRole("button", { name: "Add document" }));
const sheet = await screen.findByRole("dialog", { name: "Add a document" });
expect(within(within(sheet).getByLabelText("Type")).getAllByRole("option")).toHaveLength(9);
fireEvent.change(within(sheet).getByLabelText("Title"), { target: { value: "RT report W1-W4" } });
fireEvent.change(within(sheet).getByLabelText("Note (optional)"), { target: { value: "RPT-2210, accept" } });
const file = new File(["%PDF-1.4\n"], "rt.pdf", { type: "application/pdf" });
fireEvent.change(within(sheet).getByLabelText("File (PDF, PNG or JPG, up to 25 MB)"), { target: { files: [file] } });
fireEvent.click(within(sheet).getByRole("button", { name: "Add to packet" }));
await waitFor(() => expect(screen.queryByRole("dialog", { name: "Add a document" })).not.toBeInTheDocument());
expect(callsTo(fetch, "POST", "/api/packets/1/docs")).toEqual([{ asset_id: "a9", kind: "nde", title: "RT report W1-W4", note: "RPT-2210, accept" }]);
});
it("shows no add button without doc_add and an empty folder text", async () => {
mockFetch(base(qcPacket({ can: [], blocked: {} }), ["warehouse"], ["warehouse"]));
renderApp("/packets/1");
fireEvent.click(await screen.findByRole("tab", { name: /^Documents/ }));
expect(screen.queryByRole("button", { name: "Add document" })).not.toBeInTheDocument();
expect(screen.getByText("No ITP or Q.C. folder documents for this unit yet. Q.C. adds them under Project folders on the Q.C. screen.")).toBeInTheDocument();
});
});
+273 -123
View File
@@ -2,8 +2,11 @@ import QRCode from "qrcode";
import { useEffect, useRef, useState, type FormEvent } from "react";
import { assetsApi } from "../api/assets";
import { packetsApi } from "../api/packets";
import type { BomLine, Guarded, PacketRead, WorkOrder } from "../api/types";
import type { BomLine, Guarded, LogRow, PacketDoc, PacketRead, QcHistoryRow, WorkOrder } from "../api/types";
import type { ReasonOptions } from "../components/ReasonSheet";
import { Sheet } from "../components/Sheet";
import { errorText, fmtTime } from "../lib/format";
import { DOC_KINDS, docKindLabel, ncrUrl, noteWithoutNcr } from "../lib/qc";
import { useLoad } from "../lib/useLoad";
import { MtrViewer } from "../warehouse/MtrViewer";
import { PullTable, ShortChip, type Write } from "../warehouse/PullTable";
@@ -331,161 +334,308 @@ export function WorkOrdersTab({ p }: { p: PacketRead }) {
/* ---------- Documents ---------- */
const DOC_KINDS: ReadonlyArray<[string, string]> = [
["mtr", "Material test report"],
["nde", "NDE report"],
["procedure", "Procedure (WPS / PQR)"],
["photo", "Photo"],
["other", "Other"],
];
type AskReason = (opts: ReasonOptions) => Promise<string | null>;
const kindLabel = (k: string) => DOC_KINDS.find(([v]) => v === k)?.[1] ?? k;
const openBlob = (url: string) => {
window.open(url, "_blank", "noopener,noreferrer");
};
/** Documents attached to the packet. Clerical and Q.C. add and remove them. */
export function DocumentsTab({ p, mutate, busy }: { p: PacketRead; mutate: Mutate; busy: boolean }) {
const canAdd = p.can.includes("doc_add");
const canRemove = p.can.includes("doc_remove");
const fileRef = useRef<HTMLInputElement>(null);
const [kind, setKind] = useState("mtr");
/** The add sheet: type, title, note and the file. Uploads, then adds; errors stay in the sheet. */
function AddDocSheet({ p, sub, write, onDone, onCancel }: { p: PacketRead; sub: string; write: Write; onDone: () => void; onCancel: () => void }) {
const [kind, setKind] = useState(DOC_KINDS[0]?.[0] ?? "other");
const [title, setTitle] = useState("");
const [note, setNote] = useState("");
const [file, setFile] = useState<File | null>(null);
const [error, setError] = useState<string | null>(null);
const [uploading, setUploading] = useState(false);
const [confirm, setConfirm] = useState<number | null>(null);
const [working, setWorking] = useState(false);
const add = async (e: FormEvent) => {
e.preventDefault();
setError(null);
if (!file) {
setError("Choose a file.");
setError("Choose the file to add.");
return;
}
setUploading(true);
setWorking(true);
try {
const asset = await assetsApi.upload(file, { packet_id: p.id, purpose: "document" });
const ok = await mutate((g) => packetsApi.addDoc(p.id, { asset_id: asset.id, kind, title: title.trim() || file.name, ...g }), { ok: "Document added" });
if (ok) {
setTitle("");
setFile(null);
if (fileRef.current) fileRef.current.value = "";
}
const t = title.trim();
const n = note.trim();
const r = await write((g) => packetsApi.addDoc(p.id, { asset_id: asset.id, kind, ...(t ? { title: t } : {}), ...(n ? { note: n } : {}), ...g }));
if (r.ok) onDone();
else if (r.err !== undefined) setError(errorText(r.err));
} catch (err) {
setError(errorText(err));
} finally {
setUploading(false);
setWorking(false);
}
};
return (
<div className="stack">
{p.docs.length ? (
<div className="tw">
<table>
<thead>
<tr>
<th>Title</th>
<th>Kind</th>
<th>Added</th>
<th>By</th>
<th />
</tr>
</thead>
<tbody>
{p.docs.map((d) => (
<tr key={d.id}>
<td>
<a className="lnk" href={d.url} target="_blank" rel="noopener noreferrer">
{d.title || "Untitled"}
</a>
</td>
<td className="small">{kindLabel(d.kind)}</td>
<td className="small mono">{fmtTime(d.added_at)}</td>
<td className="small">{d.added_by?.display_name ?? "-"}</td>
<td className="nowrap">
{canRemove ? (
confirm === d.id ? (
<>
<button
className="btn sm rejb"
type="button"
disabled={busy}
onClick={() => void mutate((g) => packetsApi.removeDoc(p.id, d.id, g), { ok: "Document removed" }).then(() => setConfirm(null))}
>
Confirm remove
</button>{" "}
<button className="btn sm ghost" type="button" onClick={() => setConfirm(null)}>
Cancel
</button>
</>
) : (
<button className="btn sm ghost" type="button" onClick={() => setConfirm(d.id)}>
Remove
</button>
)
) : null}
</td>
</tr>
))}
</tbody>
</table>
<Sheet title="Add a document" sub={sub} onClose={onCancel}>
<form className="stack" onSubmit={add} noValidate>
<label className="f">
Type
<select aria-label="Type" className="in" value={kind} onChange={(e) => setKind(e.target.value)}>
{DOC_KINDS.map(([v, l]) => (
<option key={v} value={v}>
{l}
</option>
))}
</select>
</label>
<label className="f">
Title
<input aria-label="Title" className="in" value={title} maxLength={140} placeholder="e.g. RT report, welds W1-W6" onChange={(e) => setTitle(e.target.value)} />
</label>
<label className="f">
Note (optional)
<input aria-label="Note (optional)" className="in" value={note} maxLength={300} placeholder="Report no., inspector, result" onChange={(e) => setNote(e.target.value)} />
</label>
<label className="f">
File (PDF, PNG or JPG, up to 25 MB)
<input
aria-label="File (PDF, PNG or JPG, up to 25 MB)"
className="in"
type="file"
accept="application/pdf,image/png,image/jpeg"
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
/>
</label>
{error ? (
<div className="callout warn flush" role="alert">
{error}
</div>
) : null}
<div className="sheet-acts">
<button className="btn ghost" type="button" onClick={onCancel}>
Cancel
</button>
<button className="btn pri" type="submit" disabled={working}>
{working ? "Uploading" : "Add to packet"}
</button>
</div>
) : (
<div className="empty slim">No documents on this packet.</div>
)}
{canAdd ? (
<form className="panel stack" onSubmit={add} noValidate aria-labelledby="docadd-h">
<h3 id="docadd-h">Add a document</h3>
<div className="frow">
<label className="f">
File (PDF, PNG or JPEG)
<input
ref={fileRef}
className="in"
type="file"
accept="application/pdf,image/png,image/jpeg"
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
/>
</label>
<label className="f">
Kind
<select className="in" value={kind} onChange={(e) => setKind(e.target.value)}>
{DOC_KINDS.map(([v, l]) => (
<option key={v} value={v}>
{l}
</option>
))}
</select>
</label>
<label className="f">
Title
<input className="in" value={title} maxLength={160} onChange={(e) => setTitle(e.target.value)} />
</label>
</form>
</Sheet>
);
}
function DocRow({ d, old, canRemove, busy, onOut }: { d: PacketDoc; old: boolean; canRemove: boolean; busy: boolean; onOut: () => void }) {
const label = d.kind_label || docKindLabel(d.kind);
const meta = [d.file_name || null, `added by ${d.added_by?.display_name ?? "-"} ${fmtTime(d.added_at)}`, d.note || null].filter((x): x is string => !!x);
return (
<div className={`qcdoc${old ? " old" : ""}`}>
<div className="m">
<div>
<span className="pill st-ready">{label}</span> <b>{d.title || label}</b>
</div>
<div className="small muted">{meta.join(" · ")}</div>
{old && d.removed ? (
<div className="small due soon">
Taken out of the data book by {d.removed.by?.display_name ?? "-"} {fmtTime(d.removed.at)}: {d.removed.reason ?? ""}
</div>
{error ? (
<div className="callout warn flush" role="alert">
{error}
</div>
) : null}
<div>
<button className="btn pri" type="submit" disabled={busy || uploading}>
{uploading ? "Uploading" : "Add document"}
) : null}
</div>
<div className="a">
<button className="btn sm" type="button" onClick={() => openBlob(d.url)}>
View
</button>
{!old && canRemove ? (
<button className="btn sm ghost" type="button" disabled={busy} onClick={onOut}>
Take out of data book
</button>
) : null}
</div>
</div>
);
}
/**
* The documents for the data book (the old pdocsTab): reports Q.C. and Clerical add. Nothing is deleted; a take-out
* keeps the row with the reason. Below them the unit's and the project's Q.C. folder documents, read-only.
*/
export function DocumentsTab({
p,
mutate,
busy,
write,
askReason,
sub,
}: {
p: PacketRead;
mutate: Mutate;
busy: boolean;
write: Write;
askReason: AskReason;
sub: string;
}) {
const canAdd = p.can.includes("doc_add");
const canRemove = p.can.includes("doc_remove");
const [adding, setAdding] = useState(false);
const [showOld, setShowOld] = useState(false);
const live = p.docs.filter((d) => !d.removed);
const gone = p.docs.filter((d) => !!d.removed);
const folder = p.qc_folder_docs ?? [];
const takeOut = async (d: PacketDoc) => {
const reason = await askReason({
title: "Take out of the data book",
sub: d.title || docKindLabel(d.kind),
note: "The file and this record are kept and shown under taken out. Say why.",
placeholder: "e.g. wrong packet, superseded by a new report",
min: 3,
max: 200,
danger: true,
submitLabel: "Take out",
});
if (reason === null) return;
await mutate((g) => packetsApi.removeDoc(p.id, d.id, { reason, ...g }), { ok: "Taken out of the data book." });
};
return (
<div className="stack">
<section className="sec" aria-label="Documents for the data book">
<div className="sec-h">
<h2>Documents for the data book</h2>
<span className="ct">{live.length} on this packet</span>
<span className="grow" />
{canAdd ? (
<button className="btn pri sm" type="button" disabled={busy} onClick={() => setAdding(true)}>
<span aria-hidden="true">+ </span>Add document
</button>
) : null}
</div>
<div className="small muted mb8">
NDE, hydrotest, dimensional, PMI and coating reports, certificates, customer inspection reports and NCRs. They go into the data book export with the unit&apos;s
ITP and Q.C. folder documents. Drawings are never added here. They only come from M-Files.
</div>
<div className="qcdocs">
{live.length ? (
live.map((d) => <DocRow key={d.id} d={d} old={false} canRemove={canRemove} busy={busy} onOut={() => void takeOut(d)} />)
) : (
<div className="empty slim">No documents added to this packet yet.{canAdd ? " Use Add document." : " Q.C. or Clerical add them."}</div>
)}
</div>
{gone.length ? (
<div className="stack tight mt8">
<div>
<button className="lnk small" type="button" aria-expanded={showOld} onClick={() => setShowOld((v) => !v)}>
{showOld ? "Hide" : "Show"} {gone.length} taken out
</button>
</div>
{showOld ? (
<div className="qcdocs">
{gone.map((d) => (
<DocRow key={d.id} d={d} old canRemove={false} busy={busy} onOut={() => undefined} />
))}
</div>
) : null}
</div>
</form>
) : null}
) : null}
</section>
<section className="sec" aria-label="Also in the data book from the Q.C. folder">
<div className="sec-h">
<h2>Also in the data book from the Q.C. folder</h2>
<span className="ct">{p.unit.number}</span>
</div>
<div className="qcdocs">
{folder.length ? (
folder.map((d) => (
<div key={d.id} className="qcdoc">
<div className="m">
<span className="pill st-ready">{d.type}</span> <b>{d.title || d.type}</b>
{d.rev ? ` Rev ${d.rev}` : ""} <span className="small muted">{d.scope === "project" ? "from the project" : "this unit"}</span>
</div>
<div className="a">
<button className="btn sm" type="button" onClick={() => openBlob(d.url)}>
View
</button>
</div>
</div>
))
) : (
<div className="empty slim">No ITP or Q.C. folder documents for this unit yet. Q.C. adds them under Project folders on the Q.C. screen.</div>
)}
</div>
</section>
{adding ? <AddDocSheet p={p} sub={sub} write={write} onDone={() => setAdding(false)} onCancel={() => setAdding(false)} /> : null}
</div>
);
}
/* ---------- History ---------- */
/** GET /log, newest first. IT edits and signatures are marked. */
/** GET /log, newest first. IT edits and signatures are marked. The closed Q.C. inspections sit above the log. */
export function HistoryTab({ p }: { p: PacketRead }) {
const [load] = useLoad((s) => packetsApi.log(p.id, { limit: 300 }, s), `${p.id}:${p.version}`);
if (load.state === "loading") return <div className="empty slim">Loading</div>;
if (load.state === "error") return <div className="callout warn">{load.message}</div>;
const rows = load.data;
if (!rows.length) return <div className="empty slim">Nothing recorded yet.</div>;
return (
<div className="stack">
<QcInspections rows={p.qc_history} />
{load.state === "loading" ? (
<div className="empty slim">Loading</div>
) : load.state === "error" ? (
<div className="callout warn">{load.message}</div>
) : load.data.length ? (
<LogTable rows={load.data} />
) : (
<div className="empty slim">Nothing recorded yet.</div>
)}
</div>
);
}
/** One row per closed Q.C. inspection, oldest first. */
function QcInspections({ rows }: { rows: QcHistoryRow[] }) {
if (!rows.length) return null;
return (
<section className="sec" aria-label="Q.C. inspections">
<div className="sec-h">
<h2>Q.C. inspections</h2>
<span className="ct">{rows.length}</span>
</div>
<div className="qcdocs">
{rows.map((h) => {
const ok = h.result === "accepted";
const who = h.inspector || h.by?.display_name || "";
const note = noteWithoutNcr(h.note, h.ncr);
const welds = h.rejected_welds ?? [];
const tail = [welds.length ? `Rejected welds: ${welds.join(", ")}` : null, h.location ? `Spool at ${h.location}` : null].filter((x): x is string => !!x);
return (
<div key={h.id} className="qcdoc qchist">
<div className="m">
<div>
<span className={`pill ${ok ? "st-in_progress" : "st-review"}`}>{String(h.result ?? "").toUpperCase()}</span>{" "}
<span className="small mono">
{h.sent_at ? `${fmtTime(h.sent_at)} → ` : ""}
{fmtTime(h.at)}
</span>
{who ? <b> {who}</b> : null}
{h.dept_code ? <span className="small muted"> · {h.dept_code}</span> : null}
</div>
{note ? <div className="small">{note}</div> : null}
{h.ncr || tail.length ? (
<div className="small muted">
{h.ncr ? (
<a className="lnk" href={ncrUrl(h.ncr)} target="_blank" rel="noopener noreferrer">
NCR {h.ncr}
</a>
) : null}
{h.ncr && tail.length ? " · " : ""}
{tail.join(" · ")}
</div>
) : null}
</div>
</div>
);
})}
</div>
</section>
);
}
function LogTable({ rows }: { rows: LogRow[] }) {
return (
<div className="tw">
<table>
+2
View File
@@ -5,6 +5,7 @@ import { useAuth } from "../auth/useAuth";
import { AdminPage } from "./admin/AdminPage";
import { ClericalHome } from "./clerical/ClericalHome";
import { PlaceholderScreen } from "./PlaceholderScreen";
import { QcHome } from "./qc/QcHome";
import { StallHome } from "./stall/StallHome";
import { SupervisorHome } from "./supervisor/SupervisorHome";
import { WarehouseHome } from "./warehouse/WarehouseHome";
@@ -29,5 +30,6 @@ export function ScreenRoute() {
if (screen === "warehouse") return <WarehouseHome />;
if (screen === "supervisor") return <SupervisorHome />;
if (screen === "stall") return <StallHome />;
if (screen === "qc") return <QcHome />;
return <PlaceholderScreen key={screen} screen={screen} />;
}
+226
View File
@@ -0,0 +1,226 @@
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import type { Itp } from "../../api/qc";
import { DEPARTMENTS, renderApp } from "../../test/auth";
import { callsTo, mockFetch, type Route } from "../../test/fetchMock";
import { makeItp, makeUnitFolder, QUINN } from "./fixtures";
function base(itp: Itp = makeItp(), extra: Record<string, Route> = {}) {
return {
"/api/auth/me": { body: QUINN },
"/api/departments": { body: DEPARTMENTS },
"/api/units/12/itp": { body: itp },
"/api/qc/folders/units/12": { body: makeUnitFolder() },
...extra,
};
}
const approved = (itp: Itp, key: string): Itp => ({
...itp,
version: itp.version + 1,
lines: { ...itp.lines, [key]: { ...itp.lines[key]!, result: "approve", by: { id: "u-quinn", display_name: "Quinn QC" }, initials: "QQ", at: "2026-10-17T10:00:00Z", row_version: 2 } },
});
describe("ItpPage", () => {
it("renders the head, the header fields, the pages, the sections and the rows", async () => {
mockFetch(base());
renderApp("/qc/units/12/itp");
expect(await screen.findByRole("heading", { name: "L999002-ITP" })).toBeInTheDocument();
expect(screen.getByText("Q.C. · Inspection and Test Plan · Open")).toBeInTheDocument();
expect(screen.getByText("1 of 5 lines signed or N/A · 1 open hold / witness point · template ITP_Generation_Revised_2")).toBeInTheDocument();
const crumbs = screen.getByRole("navigation", { name: "Where you are" });
expect(await within(crumbs).findByRole("link", { name: "L999" })).toHaveAttribute("href", "/qc/projects/7");
expect(within(crumbs).getByRole("link", { name: "L999002" })).toHaveAttribute("href", "/qc/units/12");
expect(screen.getByLabelText("Inspection plan no.")).toHaveValue("L999002-ITP");
expect(screen.getByLabelText("Client")).toHaveValue("Test Co");
expect(screen.getByLabelText("Unit no.")).toHaveValue("L999002");
expect(screen.getByText("1 of 3 · WP")).toBeInTheDocument();
expect(screen.getByText("3 of 3 · FA")).toBeInTheDocument();
const sec1 = screen.getByRole("region", { name: "1. GENERAL FABRICATION and DATA" });
expect(within(sec1).getAllByRole("group")).toHaveLength(2);
const l12 = within(sec1).getByRole("group", { name: "Line 1.2" });
expect(l12).toHaveClass("itprow", "hold");
expect(within(l12).getByRole("button", { name: "Approve" })).toBeEnabled();
expect(within(l12).getByRole("button", { name: "Reject" })).toBeInTheDocument();
expect(within(sec1).getByRole("button", { name: "Section N/A" })).toBeInTheDocument();
const l31 = screen.getByRole("group", { name: "Line 3.1" });
expect(l31).toHaveClass("na");
expect(l31).toHaveTextContent("N/A");
expect(within(screen.getByRole("region", { name: "3. CODE" })).getByRole("button", { name: "Section applies" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Sign off ITP" })).not.toBeInTheDocument();
});
it("approves a line with a POST sign and the row version, and shows the initials", async () => {
const fetch = mockFetch(base(makeItp(), { "POST /api/itps/5/lines/1.1/sign": { body: approved(makeItp(), "1.1") } }));
renderApp("/qc/units/12/itp");
const row = await screen.findByRole("group", { name: "Line 1.1" });
fireEvent.click(within(row).getByRole("button", { name: "Approve" }));
await waitFor(() => expect(callsTo(fetch, "POST", "/api/itps/5/lines/1.1/sign")).toHaveLength(1));
const call = fetch.mock.calls.find(([u]) => String(u) === "/api/itps/5/lines/1.1/sign");
expect((call?.[1]?.headers as Record<string, string>)["If-Match"]).toBe("1");
const after = await screen.findByRole("group", { name: "Line 1.1" });
expect(await within(after).findByText("QQ")).toHaveClass("ini");
expect(within(after).getByText(/^Approved /)).toBeInTheDocument();
expect(within(after).getByRole("button", { name: "undo" })).toBeInTheDocument();
expect(within(after).queryByRole("button", { name: "Approve" })).not.toBeInTheDocument();
expect(within(screen.getByRole("region", { name: "1. GENERAL FABRICATION and DATA" })).queryByRole("button", { name: "Section N/A" })).not.toBeInTheDocument();
});
it("marks a section N/A", async () => {
const fetch = mockFetch(base(makeItp(), { "POST /api/itps/5/sections/4/na": { body: makeItp({ version: 4 }) } }));
renderApp("/qc/units/12/itp");
const sec = await screen.findByRole("region", { name: "4. FINAL" });
fireEvent.click(within(sec).getByRole("button", { name: "Section N/A" }));
await waitFor(() => expect(callsTo(fetch, "POST", "/api/itps/5/sections/4/na")).toEqual([{ na: true }]));
});
it("disables the block button until ready, and signs a ready block with the signature sheet", async () => {
const signed = makeItp({ version: 4 });
signed.blocks = signed.blocks.map((b) => (b.key === "3" ? { ...b, signed: true, signed_at: "2026-10-17T10:00:00Z", signed_by: { id: "u-quinn", display_name: "Quinn QC" } } : b));
const fetch = mockFetch(base(makeItp(), { "POST /api/itps/5/blocks/3/sign": { body: signed } }));
renderApp("/qc/units/12/itp");
const b2 = await screen.findByRole("group", { name: "ITP block 2" });
expect(b2).toHaveTextContent("PESCO Q.C. signature · Sections 1–2");
expect(within(b2).getByRole("button", { name: "Sign section" })).toBeDisabled();
expect(b2).toHaveTextContent("every line must be initialed or N/A first");
expect(b2).toHaveTextContent("Customer complete");
expect(screen.getByRole("group", { name: "ITP block 4" })).toHaveTextContent("COP complete");
const b3 = screen.getByRole("group", { name: "ITP block 3" });
fireEvent.click(within(b3).getByRole("button", { name: "Sign (all N/A)" }));
const dlg = await screen.findByRole("dialog", { name: "Sign" });
expect(dlg).toHaveTextContent("Sign an ITP block");
fireEvent.change(within(dlg).getByLabelText("Password"), { target: { value: "secret-1" } });
fireEvent.click(within(dlg).getByRole("button", { name: "Sign" }));
await waitFor(() => expect(callsTo(fetch, "POST", "/api/itps/5/blocks/3/sign")).toHaveLength(1));
expect(callsTo(fetch, "POST", "/api/itps/5/blocks/3/sign")[0]).toEqual({ sign: { factor: "password", secret: "secret-1" } });
const after = await screen.findByRole("group", { name: "ITP block 3" });
await waitFor(() => expect(after).toHaveTextContent("Quinn QC"));
expect(within(after).getByRole("button", { name: "undo" })).toBeInTheDocument();
});
it("enables Sign off ITP when the server says so and signs off with the meaning", async () => {
mockFetch(base(makeItp({ can: { edit: true, sign_off: true, reopen: false } })));
renderApp("/qc/units/12/itp");
fireEvent.click(await screen.findByRole("button", { name: "Sign off ITP" }));
const dlg = await screen.findByRole("dialog", { name: "Sign" });
expect(dlg).toHaveTextContent("Sign off the ITP");
});
it("rejects a line, keeps Approve locked until the NCR, and saves the NCR", async () => {
const rejected = makeItp({ version: 4 });
rejected.lines["1.1"] = {
...rejected.lines["1.1"]!,
result: "reject",
ncr_missing: true,
rejects: [{ at: "2026-10-17T10:00:00Z", by: { id: "u-quinn", display_name: "Quinn QC" }, ncr: null, note: "undercut", kind: "reject" }],
};
const fetch = mockFetch(
base(makeItp(), {
"POST /api/itps/5/lines/1.1/reject": { body: rejected },
"POST /api/itps/5/lines/1.1/ncr": { status: 422, body: { detail: "Enter an NCR number like NCR-2026-0021.", code: "invalid_ncr" } },
}),
);
renderApp("/qc/units/12/itp");
fireEvent.click(within(await screen.findByRole("group", { name: "Line 1.1" })).getByRole("button", { name: "Reject" }));
const dlg = screen.getByRole("dialog", { name: "Reject check point" });
expect(within(dlg).getByLabelText("NCR #")).toHaveAttribute("placeholder", "NCR number (can be added later)");
expect(dlg).toHaveTextContent("The line can't be approved until its NCR # is entered.");
fireEvent.change(within(dlg).getByLabelText("Reason / what failed"), { target: { value: "undercut" } });
fireEvent.click(within(dlg).getByRole("button", { name: "Reject" }));
await waitFor(() => expect(callsTo(fetch, "POST", "/api/itps/5/lines/1.1/reject")).toEqual([{ note: "undercut" }]));
const row = await screen.findByRole("group", { name: "Line 1.1" });
await waitFor(() => expect(row).toHaveClass("rej"));
expect(row).toHaveTextContent(/Rejected .* · NCR # needed/);
const approve = within(row).getByRole("button", { name: "Approve" });
expect(approve).toBeDisabled();
expect(approve).toHaveAttribute("title", "Enter the NCR # first");
expect(within(row).queryByRole("button", { name: "Reject" })).not.toBeInTheDocument();
fireEvent.click(within(row).getByRole("button", { name: "Enter NCR #" }));
const ncr = screen.getByRole("dialog", { name: /^NCR # for 1\.1/ });
fireEvent.change(within(ncr).getByLabelText("NCR #"), { target: { value: "bad" } });
fireEvent.click(within(ncr).getByRole("button", { name: "Save NCR #" }));
expect(await within(ncr).findByRole("alert")).toHaveTextContent("Enter an NCR number like NCR-2026-0021.");
expect(callsTo(fetch, "POST", "/api/itps/5/lines/1.1/ncr")).toEqual([{ ncr: "bad" }]);
});
it("saves the line details", async () => {
const fetch = mockFetch(base(makeItp(), { "PATCH /api/itps/5/lines/1.2": { body: makeItp({ version: 4 }) } }));
renderApp("/qc/units/12/itp");
fireEvent.click(await screen.findByRole("button", { name: "Line 1.2 details" }));
const dlg = screen.getByRole("dialog", { name: "ITP line" });
expect(within(dlg).getByLabelText("Customer code")).toHaveDisplayValue("as template (W)");
expect(within(dlg).getByLabelText(/^Not applicable to this unit/)).not.toBeChecked();
fireEvent.change(within(dlg).getByLabelText("Customer witness / reviewer"), { target: { value: "Rick Inspector" } });
fireEvent.change(within(dlg).getByLabelText("Date"), { target: { value: "2026-10-17" } });
fireEvent.change(within(dlg).getByLabelText("Note"), { target: { value: "witnessed" } });
fireEvent.click(within(dlg).getByRole("button", { name: "Save" }));
await waitFor(() =>
expect(callsTo(fetch, "PATCH", "/api/itps/5/lines/1.2")).toEqual([{ cust_by: "Rick Inspector", cust_at: "2026-10-17", note: "witnessed" }]),
);
await waitFor(() => expect(screen.queryByRole("dialog", { name: "ITP line" })).not.toBeInTheDocument());
});
it("saves a header field on blur with set and base", async () => {
const fetch = mockFetch(base(makeItp(), { "PATCH /api/itps/5": { body: makeItp({ version: 4, hdr: { ...makeItp().hdr, client: "ACME" } }) } }));
renderApp("/qc/units/12/itp");
const client = await screen.findByLabelText("Client");
fireEvent.focus(client);
fireEvent.change(client, { target: { value: "ACME" } });
fireEvent.blur(client);
await waitFor(() => expect(callsTo(fetch, "PATCH", "/api/itps/5")).toEqual([{ set: { client: "ACME" }, base: { client: "Test Co" } }]));
});
it("reloads on a 409 and shows the server's text", async () => {
let gets = 0;
const fetch = mockFetch(
base(makeItp(), {
"GET /api/units/12/itp": () => {
gets += 1;
return { body: gets === 1 ? makeItp() : approved(makeItp(), "1.1") };
},
"POST /api/itps/5/lines/1.1/sign": { status: 409, body: { detail: "This line is already initialed.", code: "already_signed" } },
}),
);
renderApp("/qc/units/12/itp");
fireEvent.click(within(await screen.findByRole("group", { name: "Line 1.1" })).getByRole("button", { name: "Approve" }));
expect(await screen.findByRole("alert")).toHaveTextContent("This line is already initialed.");
await waitFor(() => expect(gets).toBe(2));
expect(await within(screen.getByRole("group", { name: "Line 1.1" })).findByText("QQ")).toBeInTheDocument();
expect(callsTo(fetch, "POST", "/api/itps/5/lines/1.1/sign")).toHaveLength(1);
});
it("is read-only on a signed-off ITP and shows the callout", async () => {
mockFetch(
base(
makeItp({
status: "complete",
can: { edit: false, sign_off: false, reopen: true },
signoff: { at: "2026-10-17T12:00:00Z", by: { id: "u-quinn", display_name: "Quinn QC" }, asset_id: null },
}),
),
);
renderApp("/qc/units/12/itp");
expect(await screen.findByText("Q.C. · Inspection and Test Plan · Signed off")).toBeInTheDocument();
expect(screen.getByRole("note")).toHaveTextContent(
/^Signed off .* by Quinn QC\. The ITP is locked; a signed PDF copy will be filed when the ITP PDF is built\.$/,
);
expect(screen.queryByRole("button", { name: "Approve" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: /details$/ })).not.toBeInTheDocument();
expect(screen.getByLabelText("Client")).toBeDisabled();
fireEvent.click(screen.getByRole("button", { name: "Reopen" }));
expect(screen.getByRole("dialog", { name: "Reopen ITP" })).toBeInTheDocument();
});
it("shows the log behind the toggle", async () => {
mockFetch(base());
renderApp("/qc/units/12/itp");
fireEvent.click(await screen.findByRole("button", { name: "Show log" }));
expect(screen.getByText(/· Quinn QC · ITP started · L999002-ITP · template ITP_Generation_Revised_2$/)).toBeInTheDocument();
});
});
+775
View File
@@ -0,0 +1,775 @@
import { useEffect, useRef, useState, type FormEvent } from "react";
import { Link, useParams } from "react-router";
import { ApiError } from "../../api/client";
import {
enterNcr,
getItp,
getUnitFolder,
patchBlock,
patchItp,
patchLine,
rejectLine,
reopenItp,
sectionNa,
signBlock,
signLine,
signOff,
unsignBlock,
unsignLine,
type HeaderField,
type Itp,
type ItpBlock,
type ItpItem,
type ItpLine,
type LinePatch,
type QcWho,
} from "../../api/qc";
import type { Guarded } from "../../api/types";
import { useAuth } from "../../auth/useAuth";
import { Sheet } from "../../components/Sheet";
import { useGuard, type RunOptions } from "../../components/useGuard";
import { useChanged } from "../../lib/changes";
import { errorText, fmtTime } from "../../lib/format";
import { useLoad } from "../../lib/useLoad";
import { fmtQcDate } from "./folders";
import {
blockScope,
initials,
isApproved,
isNa,
isOpenHold,
isRejected,
ITP_CUST_CODES,
ITP_PAGE_ORDER,
ITP_PAGES,
lastReject,
lineOf,
progressText,
sectionsOf,
todayIso,
} from "./itpView";
/** What a write came to. A 409 reloads the ITP and shows the server's text. */
type Outcome = "ok" | "cancel" | "conflict" | { error: string };
type LineSheetState = { kind: "line" | "reject" | "ncr"; key: string } | null;
/** An input saved on blur when its value changed. */
function BlurInput({
label,
value,
disabled,
maxLength,
required = false,
onSave,
}: {
label: string;
value: string;
disabled: boolean;
maxLength: number;
/** An empty value goes back to the saved one. */
required?: boolean;
onSave: (next: string, prev: string) => Promise<boolean>;
}) {
const [v, setV] = useState(value);
const focused = useRef(false);
useEffect(() => {
if (!focused.current) setV(value);
}, [value]);
const blur = async () => {
focused.current = false;
const next = v.trim();
if (next === value || (required && !next)) {
setV(value);
return;
}
const ok = await onSave(next, value);
if (!ok) setV(value);
};
return (
<label className="f">
{label}
<input
className="in"
aria-label={label}
value={v}
disabled={disabled}
maxLength={maxLength}
onFocus={() => {
focused.current = true;
}}
onChange={(e) => setV(e.target.value)}
onBlur={() => void blur()}
/>
</label>
);
}
/** The line details sheet (the old openItpLine): N/A, the customer code and witness, the note. */
function LineSheet({ item, line, onSave, onCancel }: { item: ItpItem; line: ItpLine; onSave: (p: LinePatch) => Promise<Outcome>; onCancel: () => void }) {
const approved = isApproved(line);
const rej = isRejected(line);
const [na, setNa] = useState(isNa(line));
const [cc, setCc] = useState(line.cust_code ?? "");
const [cby, setCby] = useState(line.cust_by ?? "");
const [cat, setCat] = useState(line.cust_at ? line.cust_at.slice(0, 10) : todayIso());
const [note, setNote] = useState(line.note ?? "");
const [busy, setBusy] = useState(false);
const [err, setErr] = useState<string | null>(null);
const submit = async (e: FormEvent) => {
e.preventDefault();
const p: LinePatch = {};
if (!approved && !rej && na !== isNa(line)) p.na = na;
if (cc !== (line.cust_code ?? "")) p.cust_code = cc;
const by = cby.trim();
if (by !== (line.cust_by ?? "")) p.cust_by = by;
if (by && (p.cust_by !== undefined || cat !== (line.cust_at ?? "").slice(0, 10))) p.cust_at = cat || null;
const n = note.trim();
if (n !== (line.note ?? "")) p.note = n;
if (!Object.keys(p).length) {
onCancel();
return;
}
setBusy(true);
setErr(null);
const o = await onSave(p);
if (typeof o === "object") {
setErr(o.error);
setBusy(false);
} else if (o === "cancel") setBusy(false);
};
return (
<Sheet title="ITP line" sub={`${item.n} ${item.t}`} onClose={onCancel} wide>
<div className="small muted">{item.crit}</div>
<div className="small">
PESCO code <b>{item.code || "-"}</b> · customer code on the template <b>{item.cust || "-"}</b>
</div>
<form className="stack" onSubmit={submit} noValidate>
<label className="itpck">
<input type="checkbox" checked={na} disabled={approved || rej} onChange={(e) => setNa(e.target.checked)} />
Not applicable to this unit{approved ? " (undo the approval first)" : rej ? " (line is rejected)" : ""}
</label>
<div className="frow">
<label className="f">
Customer code
<select className="in" aria-label="Customer code" value={cc} onChange={(e) => setCc(e.target.value)}>
<option value="">as template ({item.cust || "-"})</option>
{ITP_CUST_CODES.map((c) => (
<option key={c} value={c}>
{c}
</option>
))}
</select>
</label>
<label className="f">
Customer witness / reviewer
<input
className="in"
aria-label="Customer witness / reviewer"
maxLength={80}
placeholder="Name (leave blank if not witnessed)"
value={cby}
onChange={(e) => setCby(e.target.value)}
/>
</label>
<label className="f">
Date
<input className="in" aria-label="Date" type="date" value={cat} onChange={(e) => setCat(e.target.value)} />
</label>
</div>
<label className="f">
Note
<textarea className="in" aria-label="Note" rows={2} maxLength={500} value={note} onChange={(e) => setNote(e.target.value)} />
</label>
{err ? (
<div className="callout warn flush" role="alert">
{err}
</div>
) : null}
<div className="sheet-acts">
<button className="btn ghost" type="button" onClick={onCancel}>
Cancel
</button>
<button className="btn pri" type="submit" disabled={busy}>
Save
</button>
</div>
</form>
</Sheet>
);
}
/** The reject sheet, or the NCR sheet for a reject without its number (the old openItpReject). */
function RejectSheet({
item,
line,
ncrOnly,
onSave,
onCancel,
}: {
item: ItpItem;
line: ItpLine;
ncrOnly: boolean;
onSave: (ncr: string, note: string) => Promise<Outcome>;
onCancel: () => void;
}) {
const [ncr, setNcr] = useState("");
const [note, setNote] = useState("");
const [busy, setBusy] = useState(false);
const [err, setErr] = useState<string | null>(null);
const lr = lastReject(line);
const submit = async (e: FormEvent) => {
e.preventDefault();
const n = ncr.trim();
if (ncrOnly && !n) {
setErr("Enter the NCR #.");
return;
}
setBusy(true);
setErr(null);
const o = await onSave(n, note.trim());
if (typeof o === "object") {
setErr(o.error);
setBusy(false);
} else if (o === "cancel") setBusy(false);
};
return (
<Sheet title={ncrOnly ? `NCR # for ${item.n} ${item.t}` : "Reject check point"} sub={ncrOnly ? undefined : `${item.n} ${item.t}`} onClose={onCancel}>
{ncrOnly && lr ? (
<div className="small muted">
Rejected {fmtQcDate(lr.at)}
{lr.by ? ` by ${lr.by.display_name}` : ""}
{lr.note ? `: ${lr.note}` : ""}
</div>
) : (
<div className="small muted">{item.crit}</div>
)}
<form className="stack" onSubmit={submit} noValidate>
<label className="f">
NCR #
<input
className="in"
aria-label="NCR #"
maxLength={40}
autoComplete="off"
placeholder={ncrOnly ? "NCR number" : "NCR number (can be added later)"}
value={ncr}
onChange={(e) => setNcr(e.target.value)}
/>
</label>
{ncrOnly ? null : (
<label className="f">
Reason / what failed
<textarea className="in" aria-label="Reason / what failed" rows={2} maxLength={500} value={note} onChange={(e) => setNote(e.target.value)} />
</label>
)}
<div className="small muted">The line can't be approved until its NCR # is entered.</div>
{err ? (
<div className="callout warn flush" role="alert">
{err}
</div>
) : null}
<div className="sheet-acts">
<button className="btn ghost" type="button" onClick={onCancel}>
Cancel
</button>
<button className={ncrOnly ? "btn pri" : "btn rejb"} type="submit" disabled={busy}>
{ncrOnly ? "Save NCR #" : "Reject"}
</button>
</div>
</form>
</Sheet>
);
}
/** The customer complete inputs on a block. */
function CustRecord({ onRecord }: { onRecord: (name: string, date: string) => Promise<boolean> }) {
const [name, setName] = useState("");
const [date, setDate] = useState(todayIso());
const [busy, setBusy] = useState(false);
const record = async () => {
setBusy(true);
const ok = await onRecord(name.trim(), date);
setBusy(false);
if (ok) setName("");
};
return (
<span className="custrec">
<input className="in" aria-label="Customer rep name" placeholder="Customer rep name" maxLength={80} value={name} onChange={(e) => setName(e.target.value)} />
<input className="in" aria-label="Customer complete date" type="date" value={date} onChange={(e) => setDate(e.target.value)} />
<button type="button" className="btn sm" disabled={busy || !name.trim()} onClick={() => void record()}>
Record
</button>
</span>
);
}
/** /qc/units/:unitId/itp (the old itpView). */
export function ItpPage() {
const { unitId } = useParams();
const uid = Number(unitId);
const { me } = useAuth();
const roles = me?.roles ?? [];
const isAdmin = roles.includes("admin");
const guard = useGuard();
const [load, reload] = useLoad((s) => getItp(uid, s), String(uid));
const [folderLoad] = useLoad((s) => getUnitFolder(uid, s), String(uid));
const [fresh, setFresh] = useState<Itp | null>(null);
const [sheet, setSheet] = useState<LineSheetState>(null);
const [showLog, setShowLog] = useState(false);
const [msg, setMsg] = useState<string | null>(null);
useChanged(reload, { delayMs: 500, filter: (ev) => ev.kind === "itp" && Number(ev.unit_id) === uid });
useEffect(() => {
if (!msg) return;
const t = window.setTimeout(() => setMsg(null), 8000);
return () => window.clearTimeout(t);
}, [msg]);
const loaded = load.state === "ok" ? load.data : null;
// A write's answer is the newest copy until a reload brings one as new or newer.
const r = fresh && fresh.unit_id === uid && (!loaded || fresh.version > loaded.version) ? fresh : loaded;
const folder = folderLoad.state === "ok" ? folderLoad.data : null;
const projectId = folder?.project.id;
const projectNo = folder?.project.number ?? r?.hdr.pno ?? "";
const unitNo = folder?.unit.number ?? r?.hdr.unit ?? "";
const exec = async (fn: (g: Guarded) => Promise<Itp>, opts?: RunOptions): Promise<Outcome> => {
setMsg(null);
try {
const out = await guard.run(fn, opts);
if (out === undefined) return "cancel";
setFresh(out);
return "ok";
} catch (err) {
if (err instanceof ApiError && err.status === 409) {
reload();
setSheet(null);
setMsg(errorText(err));
return "conflict";
}
return { error: errorText(err) };
}
};
const write = async (fn: (g: Guarded) => Promise<Itp>, opts?: RunOptions): Promise<boolean> => {
const o = await exec(fn, opts);
if (typeof o === "object") setMsg(o.error);
return o === "ok";
};
const back = (
<Link className="back" to={`/qc/units/${uid}`}>
← Back
</Link>
);
const crumbs = (
<nav className="crumbs" aria-label="Where you are">
<Link className="back" to="/qc">
All project folders
</Link>
<span>›</span>
{projectId ? (
<Link className="back" to={`/qc/projects/${projectId}`}>
{projectNo}
</Link>
) : (
<span>{projectNo}</span>
)}
<span>›</span>
<Link className="back" to={`/qc/units/${uid}`}>
{unitNo || "Unit"}
</Link>
<span>›</span>
<b>ITP</b>
</nav>
);
if (!r) {
return (
<>
{back}
{crumbs}
{load.state === "loading" ? <div className="empty slim">Loading</div> : null}
{load.state === "error" ? <div className="callout warn">{load.message}</div> : null}
{load.state === "ok" ? (
<div className="itpcard">
<div className="grow">
<b>No digital ITP yet.</b>
<div className="small muted">Start it from the unit's Q.C. folder.</div>
</div>
<Link className="btn" to={`/qc/units/${uid}`}>
Open the unit folder
</Link>
</div>
) : null}
</>
);
}
const id = r.id;
const complete = r.status === "complete";
const ed = r.can.edit && !complete;
const mayUndo = (who: QcWho | null) => ed && (isAdmin || (!!who && who.id === me?.user.id));
const sections = r.template.sections;
const pages = r.pages.length
? r.pages
: ITP_PAGE_ORDER.filter((c) => sectionsOf(sections, c).length).map((c) => ({ code: c, name: ITP_PAGES[c] ?? c }));
const blockAt = new Map<string, ItpBlock>(r.blocks.map((b) => [b.key, b]));
const hdr = r.hdr;
const saveHdr = (field: HeaderField) => (next: string, prev: string) => write((g) => patchItp(id, { set: { [field]: next }, base: { [field]: prev } }, g));
const sheetItem = sheet ? sections.flatMap((s) => s.items).find((i) => i.n === sheet.key) : undefined;
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;
await write((g) => reopenItp(id, { reason, ...(g.it_reason ? { it_reason: g.it_reason } : {}) }));
};
return (
<>
{back}
{crumbs}
<div className="dhead">
<div className="grow0" style={{ minWidth: 0 }}>
<div className="eyebrow">Q.C. · Inspection and Test Plan · {complete ? "Signed off" : "Open"}</div>
<h1>{r.no}</h1>
<div className="small muted">
{progressText({ ...r.progress, status: r.status })} · template {r.template.ver}
</div>
</div>
<div className="pacts">
{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 })}>
Sign off ITP
</button>
) : null}
{r.can.reopen ? (
<button className="btn ghost" type="button" onClick={() => void reopen()}>
Reopen
</button>
) : null}
</div>
</div>
{complete && r.signoff ? (
<div className="callout mb10" role="note">
Signed off {fmtQcDate(r.signoff.at)} by {r.signoff.by?.display_name ?? ""}. The ITP is locked; a signed PDF copy{" "}
{r.signoff.asset_id ? "is filed in this unit's documents." : "will be filed when the ITP PDF is built."}
</div>
) : null}
<section className="sec" aria-label="ITP header">
<div className="itphdr">
<BlurInput label="Inspection plan no." value={r.no} disabled={!ed} maxLength={120} required onSave={saveHdr("no")} />
<BlurInput label="Client" value={hdr.client} disabled={!ed} maxLength={120} onSave={saveHdr("client")} />
<BlurInput label="Project name" value={hdr.pname} disabled={!ed} maxLength={120} onSave={saveHdr("pname")} />
<BlurInput label="Project no." value={hdr.pno} disabled={!ed} maxLength={120} onSave={saveHdr("pno")} />
<BlurInput label="Purchase order" value={hdr.po} disabled={!ed} maxLength={120} onSave={saveHdr("po")} />
<BlurInput label="Unit no." value={hdr.unit} disabled={!ed} maxLength={120} onSave={saveHdr("unit")} />
</div>
<div className="small muted mt8">
<b>Codes:</b> {r.template.legend}
</div>
</section>
{pages.map((pg, pi) => {
const secs = sectionsOf(sections, pg.code);
if (!secs.length) return null;
return (
<div key={pg.code} className="itppagewrap">
<div className="itppage">
<span>
{pi + 1} of {pages.length} · {pg.code}
</span>{" "}
{pg.name}
</div>
{secs.map((s) => {
const lines = s.items.map((i) => lineOf(r, i.n));
const anyApproved = lines.some(isApproved);
const allNa = lines.length > 0 && lines.every(isNa);
const b = blockAt.get(s.n);
const hid = `itpsec-${s.n}`;
return (
<div key={s.n}>
<section className="sec itpsec" aria-labelledby={hid}>
<div className="sec-h">
<h2 id={hid}>
{s.n}. {s.t}
</h2>
<span className="grow" />
{ed && !anyApproved ? (
<button type="button" className="btn sm ghost" onClick={() => void write((g) => sectionNa(id, s.n, !allNa, g))}>
{allNa ? "Section applies" : "Section N/A"}
</button>
) : null}
</div>
<div className="itprows">
<div className="itprow hd" aria-hidden="true">
<span>No.</span>
<span>Inspection item · acceptance</span>
<span>PESCO</span>
<span>Q.C. approval</span>
<span>Customer</span>
<span />
</div>
{s.items.map((i) => {
const l = lineOf(r, i.n);
const na = isNa(l);
const approved = isApproved(l);
const rej = isRejected(l);
const lr = lastReject(l);
const ncr = l.ncr ?? lr?.ncr ?? null;
const rejTxt =
lr || rej ? (
<span className="rejtag">
{rej ? "Rejected" : "Was rejected"} {fmtQcDate(lr?.at)} ·{" "}
{ncr ? (
<>
NCR <b className="mono">{ncr}</b>
</>
) : (
<b>NCR # needed</b>
)}
</span>
) : null;
const cls = ["itprow", na ? "na" : "", isOpenHold(i, l) ? "hold" : "", rej ? "rej" : ""].filter(Boolean).join(" ");
let qc;
if (na) qc = <span className="muted">N/A</span>;
else if (approved)
qc = (
<>
<span className="ini" title={l.by?.display_name ?? undefined}>
{l.initials || initials(l.by?.display_name)}
</span>
<span className="small muted">Approved {fmtQcDate(l.at)}</span>
{mayUndo(l.by) ? (
<button type="button" className="lnk small" onClick={() => void write((g) => unsignLine(id, i.n, g))}>
undo
</button>
) : null}
{rejTxt}
</>
);
else if (ed)
qc = (
<>
{rejTxt}
<span className="apr">
<button
type="button"
className="btn sm pri"
disabled={l.ncr_missing}
title={l.ncr_missing ? "Enter the NCR # first" : undefined}
onClick={() => void write((g) => signLine(id, i.n, r.lines[i.n]?.row_version, g))}
>
Approve
</button>
{rej ? null : (
<button type="button" className="btn sm rejb" onClick={() => setSheet({ kind: "reject", key: i.n })}>
Reject
</button>
)}
{l.ncr_missing ? (
<button type="button" className="btn sm" onClick={() => setSheet({ kind: "ncr", key: i.n })}>
Enter NCR #
</button>
) : null}
</span>
</>
);
else qc = rejTxt ?? <span className="muted small">open</span>;
return (
<div key={i.n} className={cls} role="group" aria-label={`Line ${i.n}`}>
<span className="n mono">{i.n}</span>
<div className="t">
<b>{i.t}</b>
<div className="small muted">{i.crit}</div>
{l.note ? <div className="small">Note: {l.note}</div> : null}
</div>
<span className="cd" title="PESCO inspection code">
{i.code || "-"}
</span>
<div className="qc">{qc}</div>
<div className="cu" title="Customer code">
<span className="cd">{l.cust_code || i.cust || "-"}</span>
{l.cust_by ? (
<span className="small muted">
{l.cust_by} · {fmtQcDate(l.cust_at)}
</span>
) : null}
</div>
{ed ? (
<button type="button" className="btn sm ghost mb" aria-label={`Line ${i.n} details`} onClick={() => setSheet({ kind: "line", key: i.n })}>
⋯
</button>
) : (
<span />
)}
</div>
);
})}
</div>
</section>
{b ? <BlockView itpId={id} b={b} ed={ed} mayUndo={mayUndo} write={write} /> : null}
</div>
);
})}
</div>
);
})}
<section className="sec" aria-labelledby="itplog-h">
<div className="sec-h">
<h2 id="itplog-h">ITP log</h2>
<span className="ct">
{r.log.length} entr{r.log.length === 1 ? "y" : "ies"}
</span>
<span className="grow" />
<button type="button" className="btn sm ghost" aria-expanded={showLog} onClick={() => setShowLog((v) => !v)}>
{showLog ? "Hide log" : "Show log"}
</button>
</div>
{showLog ? (
r.log.length ? (
<ol className="itplog">
{r.log.map((e) => (
<li key={e.id} className="small">
{[fmtTime(e.at), e.by_name || e.by?.display_name || "", e.action, e.note ?? ""].filter(Boolean).join(" · ")}
</li>
))}
</ol>
) : (
<div className="empty slim">Nothing logged yet.</div>
)
) : null}
</section>
{sheet && sheetItem && sheet.kind === "line" ? (
<LineSheet
item={sheetItem}
line={lineOf(r, sheetItem.n)}
onCancel={() => setSheet(null)}
onSave={async (p) => {
const o = await exec((g) => patchLine(id, sheetItem.n, p, g));
if (o === "ok") setSheet(null);
return o;
}}
/>
) : null}
{sheet && sheetItem && sheet.kind !== "line" ? (
<RejectSheet
item={sheetItem}
line={lineOf(r, sheetItem.n)}
ncrOnly={sheet.kind === "ncr"}
onCancel={() => setSheet(null)}
onSave={async (ncr, note) => {
const o = await exec((g) =>
sheet.kind === "ncr"
? enterNcr(id, sheetItem.n, ncr, g)
: rejectLine(id, sheetItem.n, { ...(ncr ? { ncr } : {}), ...(note ? { note } : {}) }, g),
);
if (o === "ok") setSheet(null);
return o;
}}
/>
) : null}
{guard.sheet}
{msg ? (
<div className="callout warn itpmsg" role="alert">
{msg}{" "}
<button type="button" className="lnk small" onClick={() => setMsg(null)}>
Dismiss
</button>
</div>
) : null}
</>
);
}
/** The signature block after a signing section (the old itpBlockHtml). */
function BlockView({
itpId,
b,
ed,
mayUndo,
write,
}: {
itpId: number;
b: ItpBlock;
ed: boolean;
mayUndo: (who: QcWho | null) => boolean;
write: (fn: (g: Guarded) => Promise<Itp>, opts?: RunOptions) => Promise<boolean>;
}) {
const scope = b.scope || blockScope(b.sections);
return (
<div className="itpblock" role="group" aria-label={`ITP block ${b.key}`}>
<div className="frow">
<BlurInput label="QN #" value={b.qn ?? ""} disabled={!ed} maxLength={40} onSave={(v) => write((g) => patchBlock(itpId, b.key, { qn: v }, g))} />
<BlurInput label="Remarks / notes" value={b.remarks ?? ""} disabled={!ed} maxLength={400} onSave={(v) => write((g) => patchBlock(itpId, b.key, { remarks: v }, g))} />
</div>
<div className="sigs">
<div>
<div className="eyebrow">PESCO Q.C. signature · {scope}</div>
{b.signed ? (
<>
<b>{b.signed_by?.display_name ?? ""}</b> {b.signed_by?.emp_no ? <span className="mono small">{b.signed_by.emp_no}</span> : null}{" "}
<span className="small muted">{fmtQcDate(b.signed_at)} · signed</span>
{mayUndo(b.signed_by) ? (
<>
{" "}
<button type="button" className="lnk small" onClick={() => void write((g) => unsignBlock(itpId, b.key, g))}>
undo
</button>
</>
) : null}
</>
) : ed ? (
<>
<button
type="button"
className={b.ready ? "btn sm pri" : "btn sm"}
disabled={!b.ready}
onClick={() => void write((g) => signBlock(itpId, b.key, g.sign, g), { sign: "Sign an ITP block", sub: scope })}
>
{b.all_na ? "Sign (all N/A)" : "Sign section"}
</button>
{b.ready ? null : <span className="small muted"> every line must be initialed or N/A first</span>}
</>
) : (
<span className="muted small">not signed</span>
)}
</div>
<div>
<div className="eyebrow">{b.final ? "COP complete" : "Customer complete"}</div>
{b.cust_name ? (
<>
<b>{b.cust_name}</b>{" "}
<span className="small muted">
{fmtQcDate(b.cust_at)} · recorded by {b.cust_by?.display_name ?? ""}
</span>
{ed ? (
<>
{" "}
<button type="button" className="lnk small" onClick={() => void write((g) => patchBlock(itpId, b.key, { cust_clear: true }, g))}>
clear
</button>
</>
) : null}
</>
) : ed ? (
<CustRecord onRecord={(name, date) => write((g) => patchBlock(itpId, b.key, { cust_name: name, ...(date ? { cust_at: date } : {}) }, g))} />
) : (
<span className="muted small">-</span>
)}
</div>
</div>
</div>
);
}
+130
View File
@@ -0,0 +1,130 @@
import { useState, type FormEvent } from "react";
import { assetsApi } from "../../api/assets";
import { addQcDoc, type QcDoc, type QcProjectFolder, type QcUnitFolder } from "../../api/qc";
import { Sheet } from "../../components/Sheet";
import { useGuard } from "../../components/useGuard";
import { errorText } from "../../lib/format";
import { QC_TYPES } from "./folders";
const MAX_BYTES = 25 * 1024 * 1024;
export interface QcDocTarget {
scope: "unit" | "project";
id: number;
number: string;
}
interface QcDocSheetProps {
target: QcDocTarget;
/** The document a new revision replaces. */
replaces?: QcDoc | null;
onDone: (folder: QcProjectFolder | QcUnitFolder, text: string) => void;
onCancel: () => void;
}
/** Add a Q.C. folder document, or file a new revision of one (the old openQcDocSheet). */
export function QcDocSheet({ target, replaces = null, onDone, onCancel }: QcDocSheetProps) {
const guard = useGuard();
const r = replaces;
const [type, setType] = useState<string>(r?.type ?? "ITP");
const [title, setTitle] = useState(r?.title ?? "");
const [rev, setRev] = useState("");
const [note, setNote] = useState("");
const [file, setFile] = useState<File | null>(null);
const [busy, setBusy] = useState(false);
const [msg, setMsg] = useState<string | null>(null);
const submit = async (e: FormEvent) => {
e.preventDefault();
if (busy) return;
if (!file) {
setMsg("Choose the file to add.");
return;
}
if (file.size > MAX_BYTES) {
setMsg("The file is over 25 MB.");
return;
}
setBusy(true);
setMsg(null);
try {
const up = await assetsApi.upload(file, { purpose: "document" });
const scope = target.scope === "unit" ? { unit_id: target.id } : { project_id: target.id };
const body = {
...scope,
type,
asset_id: up.id,
...(title.trim() ? { title: title.trim() } : {}),
...(rev.trim() ? { rev: rev.trim() } : {}),
...(note.trim() ? { note: note.trim() } : {}),
...(r ? { replaces: r.id } : {}),
};
const folder = await guard.run((g) => addQcDoc({ ...body, ...g }), { sub: `${target.scope} ${target.number}` });
if (folder === undefined) {
setBusy(false);
return;
}
onDone(folder, r ? "New revision filed." : `Added to ${target.number}.`);
} catch (err) {
setBusy(false);
setMsg(errorText(err));
}
};
return (
<>
<Sheet title={r ? "New revision" : "Add document"} sub={`${target.scope} ${target.number}`} onClose={onCancel} wide>
{r ? (
<div className="small muted">
Replaces <b>{r.title || r.file_name || "Document"}</b>
{r.rev ? ` Rev ${r.rev}` : ""}. The old one stays on file under Superseded.
</div>
) : null}
<form className="stack" onSubmit={submit} noValidate>
<div className="frow">
<label className="f">
Type
<select className="in" aria-label="Type" value={type} onChange={(e) => setType(e.target.value)}>
{QC_TYPES.map((t) => (
<option key={t} value={t}>
{t}
</option>
))}
</select>
</label>
<label className="f">
Title
<input className="in" aria-label="Title" maxLength={140} placeholder="e.g. Inspection & Test Plan" value={title} onChange={(e) => setTitle(e.target.value)} />
</label>
<label className="f">
Rev
<input className="in" aria-label="Rev" maxLength={12} placeholder={r?.rev ? `was ${r.rev}` : "0"} value={rev} onChange={(e) => setRev(e.target.value)} />
</label>
</div>
<label className="f">
File (PDF, PNG or JPG, up to 25 MB)
<input className="in" type="file" accept="application/pdf,image/png,image/jpeg" onChange={(e) => setFile(e.target.files?.[0] ?? null)} />
</label>
<label className="f">
Note
<textarea className="in" aria-label="Note" rows={2} maxLength={600} placeholder="Optional" value={note} onChange={(e) => setNote(e.target.value)} />
</label>
{msg ? (
<div className="callout warn flush" role="alert">
{msg}
</div>
) : null}
<div className="sheet-acts">
<button className="btn ghost" type="button" onClick={onCancel}>
Cancel
</button>
<button className="btn pri" type="submit" disabled={busy}>
{busy ? "Uploading" : r ? "File new revision" : "Add to folder"}
</button>
</div>
</form>
</Sheet>
{guard.sheet}
</>
);
}
+148
View File
@@ -0,0 +1,148 @@
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { DEPARTMENTS, makeMe, renderApp } from "../../test/auth";
import { callsTo, mockFetch } from "../../test/fetchMock";
import { makeDoc, makeProjectFolder, makeUnitFolder, QUINN } from "./fixtures";
function base(me = QUINN) {
return {
"/api/auth/me": { body: me },
"/api/departments": { body: DEPARTMENTS },
"/api/qc/folders/projects/7": { body: makeProjectFolder() },
"/api/qc/folders/units/12": { body: makeUnitFolder() },
};
}
describe("QcProjectFolder", () => {
it("shows the head, the documents, the unit tiles and the superseded rows", async () => {
mockFetch(base());
renderApp("/qc/projects/7");
expect(await screen.findByRole("heading", { name: "L999 — CI project" })).toBeInTheDocument();
expect(screen.getByText("Q.C. folder · Project · Open")).toBeInTheDocument();
expect(screen.getByText("Test Co · PO 5541")).toBeInTheDocument();
const crumbs = screen.getByRole("navigation", { name: "Where you are" });
expect(within(crumbs).getByRole("link", { name: "All project folders" })).toHaveAttribute("href", "/qc");
const docs = screen.getByRole("list", { name: "Project documents" });
const row = within(docs).getByRole("listitem");
expect(row).toHaveTextContent("WPS / PQR");
expect(row).toHaveTextContent("WPS-101 GTAW/SMAW");
expect(row).toHaveTextContent("Rev 2");
expect(row).toHaveTextContent(/wps\.pdf · added .* by Quinn QC/);
expect(within(row).getByRole("button", { name: "View" })).toBeInTheDocument();
expect(within(row).getByRole("button", { name: "New revision" })).toBeInTheDocument();
const units = screen.getByRole("region", { name: "Unit folders" });
expect(within(units).getByRole("link", { name: /L999001/ })).toHaveTextContent("ITP 12/68");
expect(within(units).getByText("ITP 12/68")).toHaveClass("due", "soon");
expect(within(units).getByRole("link", { name: /L999002/ })).toHaveTextContent("no ITP");
expect(within(units).getByRole("link", { name: /L999003/ })).toHaveTextContent("ITP ✓");
expect(within(units).getByRole("link", { name: /L999002/ })).toHaveAttribute("href", "/qc/units/12");
const old = screen.getByRole("region", { name: "Superseded" });
expect(within(old).queryByText("WPS-101 old")).not.toBeInTheDocument();
fireEvent.click(within(old).getByRole("button", { name: "Show" }));
expect(within(old).getByText("WPS-101 old")).toBeInTheDocument();
expect(within(old).getByRole("listitem")).toHaveTextContent(/superseded .* by Quinn QC/);
expect(within(old).getByRole("button", { name: "Hide" })).toBeInTheDocument();
});
it("files a new revision: upload with purpose document, then the post with replaces", async () => {
const after = makeProjectFolder({ docs: [makeDoc({ id: 2, rev: "3" })] });
const fetch = mockFetch({
...base(),
"POST /api/assets": { body: { id: "a2", mime: "application/pdf", size: 10, sha256: "x", pages: 1, original_name: "wps3.pdf", url: "/_blob/a2" } },
"POST /api/qc/docs": { body: after },
});
renderApp("/qc/projects/7");
const row = within(await screen.findByRole("list", { name: "Project documents" })).getByRole("listitem");
fireEvent.click(within(row).getByRole("button", { name: "New revision" }));
const dlg = screen.getByRole("dialog", { name: "New revision" });
expect(dlg).toHaveTextContent("project L999");
expect(dlg).toHaveTextContent("Replaces WPS-101 GTAW/SMAW Rev 2. The old one stays on file under Superseded.");
expect(within(dlg).getByLabelText("Type")).toHaveValue("WPS / PQR");
expect(within(dlg).getByLabelText("Rev")).toHaveAttribute("placeholder", "was 2");
fireEvent.change(within(dlg).getByLabelText("Rev"), { target: { value: "3" } });
const file = new File(["%PDF-1.4"], "wps3.pdf", { type: "application/pdf" });
fireEvent.change(within(dlg).getByLabelText("File (PDF, PNG or JPG, up to 25 MB)"), { target: { files: [file] } });
fireEvent.click(within(dlg).getByRole("button", { name: "File new revision" }));
await waitFor(() => expect(callsTo(fetch, "POST", "/api/qc/docs")).toHaveLength(1));
const upload = fetch.mock.calls.find(([u, init]) => String(u) === "/api/assets" && init?.method === "POST");
expect((upload?.[1]?.body as FormData).get("purpose")).toBe("document");
expect(callsTo(fetch, "POST", "/api/qc/docs")[0]).toEqual({
project_id: 7,
type: "WPS / PQR",
asset_id: "a2",
title: "WPS-101 GTAW/SMAW",
rev: "3",
replaces: 1,
});
await waitFor(() => expect(screen.queryByRole("dialog", { name: "New revision" })).not.toBeInTheDocument());
expect(screen.getByText("New revision filed.")).toBeInTheDocument();
expect(screen.getByText("Rev 3")).toBeInTheDocument();
});
it("shows the server's message for a wrong file type", async () => {
mockFetch({
...base(),
"POST /api/assets": { body: { id: "a3", mime: "text/plain", size: 10, sha256: "x", pages: null, original_name: "a.txt", url: "/_blob/a3" } },
"POST /api/qc/docs": { status: 422, body: { detail: "Documents must be a PDF, PNG or JPEG file.", code: "invalid_asset" } },
});
renderApp("/qc/projects/7");
fireEvent.click(await screen.findByRole("button", { name: "+ Add document" }));
const dlg = screen.getByRole("dialog", { name: "Add document" });
expect(within(dlg).getByLabelText("Type")).toHaveValue("ITP");
fireEvent.change(within(dlg).getByLabelText("File (PDF, PNG or JPG, up to 25 MB)"), { target: { files: [new File(["x"], "a.txt", { type: "text/plain" })] } });
fireEvent.click(within(dlg).getByRole("button", { name: "Add to folder" }));
expect(await within(dlg).findByRole("alert")).toHaveTextContent("Documents must be a PDF, PNG or JPEG file.");
});
it("hides Add document from a user who cannot add", async () => {
mockFetch(base(makeMe({ roles: ["pm"], screens: ["qc"] })));
renderApp("/qc/projects/7");
await screen.findByRole("heading", { name: "L999 — CI project" });
expect(screen.queryByRole("button", { name: "+ Add document" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "New revision" })).not.toBeInTheDocument();
});
});
describe("QcUnitFolder", () => {
it("shows the no ITP warning, the start card and the project documents", async () => {
mockFetch(base());
renderApp("/qc/units/12");
expect(await screen.findByRole("heading", { name: "L999002" })).toBeInTheDocument();
expect(screen.getByText("Q.C. folder · Unit · Open")).toBeInTheDocument();
expect(screen.getByText("Unit of L999 · CI project")).toBeInTheDocument();
expect(screen.getByText("No ITP on file for this unit yet.")).toBeInTheDocument();
expect(screen.getByText("No digital ITP yet.")).toBeInTheDocument();
const crumbs = screen.getByRole("navigation", { name: "Where you are" });
expect(within(crumbs).getByRole("link", { name: "L999" })).toHaveAttribute("href", "/qc/projects/7");
const inh = screen.getByRole("list", { name: "From project L999" });
expect(within(inh).getByRole("listitem")).toHaveTextContent("WPS-101 GTAW/SMAW");
expect(within(inh).queryByRole("button", { name: "New revision" })).not.toBeInTheDocument();
expect(screen.getByText('No documents yet. Use "Add document" to file the ITP or other Q.C. paperwork for this unit.')).toBeInTheDocument();
});
it("starts the ITP and opens it", async () => {
const fetch = mockFetch({ ...base(), "POST /api/units/12/itp": { body: { id: 5 } }, "/api/units/12/itp": { body: { itp: null, unit: { id: 12, number: "L999002" } } } });
renderApp("/qc/units/12");
fireEvent.click(await screen.findByRole("button", { name: "Start ITP from template" }));
await waitFor(() => expect(callsTo(fetch, "POST", "/api/units/12/itp")).toHaveLength(1));
await waitFor(() => expect(callsTo(fetch, "GET", "/api/units/12/itp")).toHaveLength(1));
expect(await screen.findByText("ITP")).toBeInTheDocument();
});
it("shows the open ITP card with its progress", async () => {
mockFetch({
...base(),
"/api/qc/folders/units/12": { body: makeUnitFolder({ no_itp: false, itp: { id: 5, no: "L999002-ITP", status: "open", done: 12, total: 68, holds: 2 } }) },
});
renderApp("/qc/units/12");
const card = await screen.findByRole("link", { name: /Open ITP/ });
expect(card).toHaveAttribute("href", "/qc/units/12/itp");
expect(card).toHaveTextContent("L999002-ITP");
expect(card).toHaveTextContent("Open");
expect(card).toHaveTextContent("12 of 68 lines signed or N/A · 2 open hold / witness points");
expect(screen.queryByText("No ITP on file for this unit yet.")).not.toBeInTheDocument();
});
});
+77
View File
@@ -0,0 +1,77 @@
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { DEPARTMENTS, makeMe, renderApp } from "../../test/auth";
import { mockFetch } from "../../test/fetchMock";
import { makeHome, QUINN } from "./fixtures";
function base(home = makeHome(), me = QUINN) {
return {
"/api/auth/me": { body: me },
"/api/departments": { body: DEPARTMENTS },
"/api/qc/home": { body: home },
"/api/projects": { body: [] },
};
}
describe("QcHome", () => {
it("shows the page head, the KPIs, the three boards and the folder grid", async () => {
mockFetch(base());
renderApp("/qc");
expect(await screen.findByRole("heading", { name: "Inspect the spool and the data book" })).toBeInTheDocument();
expect(screen.getByText("Q.C. · Inspection")).toBeInTheDocument();
await screen.findByRole("heading", { name: /^Ready for inspection/ });
expect(screen.getByLabelText("Waiting for inspection: 1")).toHaveClass("hot");
expect(screen.getByLabelText("Accepted last 7 days: 2")).not.toHaveClass("hot");
expect(screen.getByLabelText("Rejected last 7 days: 0")).toHaveClass("zero");
expect(screen.getByRole("heading", { name: /^Accepted/ })).toBeInTheDocument();
expect(screen.getByRole("heading", { name: /^Rejected — back with supervisor/ })).toBeInTheDocument();
expect(screen.getByText("No open rejections.")).toBeInTheDocument();
const waiting = screen.getByRole("link", { name: /^L999001 · WELD-PART/ });
expect(waiting).toHaveAttribute("href", "/packets/1");
expect(within(waiting).getByText("Spool at Bay 2")).toBeInTheDocument();
expect(within(waiting).getByText("ITP: 2 open hold / witness points")).toBeInTheDocument();
const folders = screen.getByRole("region", { name: "Project folders" });
const card = within(folders).getByRole("link", { name: /L999/ });
expect(card).toHaveAttribute("href", "/qc/projects/7");
expect(card).toHaveClass("pcard");
expect(card).toHaveTextContent("2 units · 3 documents");
expect(card).toHaveTextContent("1 unit without an ITP");
expect(card).toHaveTextContent("PO 5541");
});
it("filters the boards on the client", async () => {
mockFetch(base());
renderApp("/qc");
await screen.findByRole("link", { name: /^L999002/ });
fireEvent.change(screen.getByLabelText("Find a job"), { target: { value: "elbow" } });
expect(screen.queryByRole("link", { name: /^L999001 · WELD-PART/ })).not.toBeInTheDocument();
expect(screen.getByRole("link", { name: /^L999002/ })).toBeInTheDocument();
expect(screen.getByText("Nothing here matches the filters.")).toBeInTheDocument();
});
it("shows all folders on All, and the empty texts", async () => {
const fetch = mockFetch({
...base(makeHome({ folders: [], columns: { ready: [], accepted: [], rejected: [] } })),
"/api/qc/folders": { body: [] },
});
renderApp("/qc");
expect(await screen.findByText("Supervisors send packets here when the data book package and the spool are ready.")).toBeInTheDocument();
expect(screen.getByText("Nothing accepted yet.")).toBeInTheDocument();
expect(screen.getByText("No projects yet. Folders appear here as soon as Clerical sets up a project and its units.")).toBeInTheDocument();
const chips = screen.getByRole("group", { name: "Show folders" });
expect(within(chips).getByRole("button", { name: "Active" })).toHaveAttribute("aria-pressed", "true");
fireEvent.click(within(chips).getByRole("button", { name: "All" }));
await waitFor(() => expect(fetch.mock.calls.some(([u]) => String(u) === "/api/qc/folders?show=all")).toBe(true));
expect(within(chips).getByRole("button", { name: "All" })).toHaveAttribute("aria-pressed", "true");
});
it("sends a user without the screen to their own screen", async () => {
mockFetch(base(makeHome(), makeMe({ roles: ["warehouse"], screens: ["warehouse"] })));
renderApp("/qc/projects/7");
await waitFor(() => expect(screen.queryByText("Q.C. folder · Project · Open")).not.toBeInTheDocument());
});
});
+171
View File
@@ -0,0 +1,171 @@
import { useId, useState } from "react";
import { Link } from "react-router";
import { getFolders, getQcHome, type QcCard, type QcColumn, type QcFolderCard } from "../../api/qc";
import type { ColumnDef } from "../../components/Board";
import { Card } from "../../components/Card";
import { FindBar } from "../../components/FindBar";
import { Kpis } from "../../components/Kpis";
import type { UnitPick } from "../../components/PickUnit";
import { useChanged } from "../../lib/changes";
import { useLoad } from "../../lib/useLoad";
import { ProjectPill } from "../clerical/common";
import { matchCard, plural } from "./folders";
const COLUMNS: ReadonlyArray<{ key: QcColumn; title: string; empty: string }> = [
{ key: "ready", title: "Ready for inspection", empty: "Supervisors send packets here when the data book package and the spool are ready." },
{ key: "accepted", title: "Accepted", empty: "Nothing accepted yet." },
{ key: "rejected", title: "Rejected — back with supervisor", empty: "No open rejections." },
];
type FolderShow = "active" | "all";
function FolderCard({ f }: { f: QcFolderCard }) {
return (
<Link className="card pcard" to={`/qc/projects/${f.id}`}>
<div className="r1">
<span className="job">{f.number}</span>
<ProjectPill status={f.status} />
</div>
<div className="tt">{f.name || "-"}</div>
<div className="small muted">
{f.customer ?? ""}
{f.po ? (
<>
{f.customer ? " · " : ""}PO <span className="mono">{f.po}</span>
</>
) : null}
</div>
<div className="small muted">
{plural(f.units, "unit")} · {plural(f.docs, "document")}
</div>
{f.units_without_itp ? <div className="small due soon">{plural(f.units_without_itp, "unit")} without an ITP</div> : null}
</Link>
);
}
/** The project folders: Active from the home answer, All on its own call. */
function FoldersSection({ active }: { active: QcFolderCard[] }) {
const id = useId();
const [show, setShow] = useState<FolderShow>("active");
const [allLoad, reloadAll] = useLoad((s) => (show === "all" ? getFolders("all", s) : Promise.resolve(null)), show);
useChanged(() => {
if (show === "all") reloadAll();
});
const list = show === "all" ? (allLoad.state === "ok" ? (allLoad.data ?? []) : null) : active;
return (
<section className="sec qcfolders" aria-labelledby={id}>
<div className="sec-h">
<h2 id={id}>Project folders</h2>
<span className="ct">Q.C. documents for each project and unit: ITP, WPS/PQR, procedures</span>
<span className="grow" />
<div className="chips" role="group" aria-label="Show folders">
{(
[
["active", "Active"],
["all", "All"],
] as const
).map(([k, l]) => (
<button key={k} type="button" className="chip sm" aria-pressed={show === k} onClick={() => setShow(k)}>
{l}
</button>
))}
</div>
</div>
{show === "all" && allLoad.state === "error" ? <div className="callout warn">{allLoad.message}</div> : null}
{list === null ? (
allLoad.state === "loading" ? <div className="empty slim">Loading</div> : null
) : list.length ? (
<div className="projgrid">
{list.map((f) => (
<FolderCard key={f.id} f={f} />
))}
</div>
) : (
<div className="empty">No projects yet. Folders appear here as soon as Clerical sets up a project and its units.</div>
)}
</section>
);
}
function cardExtra(col: QcColumn, c: QcCard) {
const loc = col === "ready" ? c.qc?.location : null;
const holds = c.itp_hold_count ?? 0;
if (!loc && !holds) return undefined;
return (
<>
{loc ? <div className="small near">Spool at {loc}</div> : null}
{holds ? <div className="small due soon">ITP: {holds} open hold / witness point{holds === 1 ? "" : "s"}</div> : null}
</>
);
}
/** /qc (the old homeQC): the numbers, the three boards and the project folders. */
export function QcHome() {
const [q, setQ] = useState("");
const [pick, setPick] = useState<UnitPick>({ project: null, unit: null });
const projectId = pick.project?.id;
const unitId = pick.unit?.id;
const [load, reload] = useLoad(
(s) => getQcHome(unitId ? { unit_id: unitId } : projectId ? { project_id: projectId } : {}, s),
`${projectId ?? ""}|${unitId ?? ""}`,
);
useChanged(reload, { delayMs: 500 });
const data = load.state === "ok" ? load.data : null;
const k = data?.kpis;
const columns = COLUMNS.map((c) => {
const all = data?.columns[c.key] ?? [];
const shown = all.filter((x) => matchCard(x, q));
return { ...c, cards: shown, empty: all.length && !shown.length ? "Nothing here matches the filters." : c.empty };
});
const count = data ? columns.reduce((n, c) => n + c.cards.length, 0) : null;
return (
<>
<header className="phead">
<div style={{ minWidth: 0 }}>
<div className="eyebrow">Q.C. · Inspection</div>
<h1>Inspect the spool and the data book</h1>
</div>
</header>
<Kpis
items={[
{ n: k?.waiting ?? 0, label: "Waiting for inspection", alert: true },
{ n: k?.accepted_7d ?? 0, label: "Accepted last 7 days" },
{ n: k?.rejected_7d ?? 0, label: "Rejected last 7 days", alert: true },
]}
/>
<FindBar q={q} onQ={setQ} pick={pick} onPick={setPick} count={count} />
{load.state === "loading" ? <div className="empty slim">Loading</div> : null}
{load.state === "error" ? <div className="callout warn">{load.message}</div> : null}
{data ? (
<div className="board">
{columns.map((c) => (
<QcColumnView key={c.key} title={c.title} col={c.key} cards={c.cards} empty={c.empty} />
))}
</div>
) : null}
{data ? <FoldersSection active={data.folders} /> : null}
</>
);
}
/** The shared Column, with the Q.C. lines under each card. */
function QcColumnView({ title, col, cards, empty }: { title: string; col: QcColumn; cards: QcCard[]; empty: ColumnDef["empty"] }) {
const id = `col-${title.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`;
return (
<section className="col" aria-labelledby={id}>
<h3 className="col-h" id={id}>
{title}
<span className="c">{cards.length}</span>
</h3>
{cards.length ? cards.map((p) => <Card key={p.id} p={p} extra={cardExtra(col, p)} />) : <div className="empty">{empty}</div>}
</section>
);
}
+135
View File
@@ -0,0 +1,135 @@
import { useEffect, useState } from "react";
import { Link, useParams } from "react-router";
import { getProjectFolder, type QcDoc, type QcProjectFolder as Folder, type QcUnitFolder } from "../../api/qc";
import { useAuth } from "../../auth/useAuth";
import { useChanged } from "../../lib/changes";
import { PROJECT_STATUS_LABEL } from "../../lib/packets";
import { useLoad } from "../../lib/useLoad";
import { ProjectPill } from "../clerical/common";
import { canQcDocs, itpTile, plural, useToast } from "./folders";
import { DocList, FolderSection, SupersededSection, Toast } from "./parts";
import { QcDocSheet, type QcDocTarget } from "./QcDocSheet";
/** /qc/projects/:projectId (the old qcFolderView for a project). */
export function QcProjectFolder() {
const { projectId } = useParams();
const id = Number(projectId);
const { me } = useAuth();
const roles = me?.roles ?? [];
const [load, reload] = useLoad((s) => getProjectFolder(id, s), String(id));
const [data, setData] = useState<Folder | null>(null);
const [sheet, setSheet] = useState<{ replaces: QcDoc | null } | null>(null);
const [toast, setToast] = useToast();
useChanged(reload, { delayMs: 500 });
useEffect(() => {
if (load.state === "ok") setData(load.data);
}, [load]);
const back = (
<Link className="back" to="/qc">
← Back
</Link>
);
if (!data) {
if (load.state === "error")
return (
<>
{back}
<div className="callout warn">{load.message}</div>
</>
);
return (
<>
{back}
<div className="empty slim">Loading</div>
</>
);
}
const p = data.project;
const canAdd = canQcDocs(roles);
const projectHasItp = data.docs.some((d) => d.type === "ITP");
const target: QcDocTarget = { scope: "project", id: p.id, number: p.number };
const units = [...data.units].sort((a, b) => a.number.localeCompare(b.number, undefined, { numeric: true }));
return (
<>
{back}
<nav className="crumbs" aria-label="Where you are">
<Link className="back" to="/qc">
All project folders
</Link>
<span>›</span>
<b>{p.number}</b>
</nav>
<div className="dhead">
<div className="grow0" style={{ minWidth: 0 }}>
<div className="eyebrow">Q.C. folder · Project · {PROJECT_STATUS_LABEL[p.status] ?? p.status}</div>
<h1>
{p.number}
{p.name ? ` — ${p.name}` : ""}
</h1>
<div className="small muted">{[p.customer, p.po ? `PO ${p.po}` : ""].filter(Boolean).join(" · ")}</div>
</div>
<div className="pacts">
{canAdd ? (
<button className="btn pri" type="button" onClick={() => setSheet({ replaces: null })}>
+ Add document
</button>
) : null}
</div>
</div>
{load.state === "error" ? <div className="callout warn">{load.message}</div> : null}
<FolderSection title="Project documents" ct="cover every unit of this project">
{data.docs.length ? (
<DocList docs={data.docs} label="Project documents" onRevise={canAdd ? (d) => setSheet({ replaces: d }) : undefined} />
) : (
<div className="empty">No documents yet.{canAdd ? ' Use "Add document" to file the ITP or other Q.C. paperwork for this project.' : ""}</div>
)}
</FolderSection>
<FolderSection title="Unit folders" ct={String(units.length)}>
{units.length ? (
<div className="qcunits">
{units.map((u) => {
const tile = itpTile(u, projectHasItp);
return (
<Link key={u.id} className="card qcunit" to={`/qc/units/${u.id}`}>
<div className="r1">
<span className="job">{u.number}</span>
<ProjectPill status={u.status} />
</div>
<div className="qm">
<span>{plural(u.docs, "doc")}</span>
{tile ? <span className={tile.cls || undefined}>{tile.text}</span> : null}
</div>
</Link>
);
})}
</div>
) : (
<div className="empty">No units yet. They appear here when Clerical adds them.</div>
)}
</FolderSection>
<SupersededSection docs={data.superseded} />
{sheet ? (
<QcDocSheet
target={target}
replaces={sheet.replaces}
onCancel={() => setSheet(null)}
onDone={(folder: Folder | QcUnitFolder, text) => {
setSheet(null);
if ("units" in folder) setData(folder);
else reload();
setToast(text);
}}
/>
) : null}
<Toast text={toast} />
</>
);
}
+178
View File
@@ -0,0 +1,178 @@
import { useEffect, useState } from "react";
import { Link, useNavigate, useParams } from "react-router";
import { getUnitFolder, startItp, type ItpSummary, type QcDoc, type QcProjectFolder, type QcUnitFolder as Folder } from "../../api/qc";
import { useAuth } from "../../auth/useAuth";
import { useGuard } from "../../components/useGuard";
import { useChanged } from "../../lib/changes";
import { errorText } from "../../lib/format";
import { PROJECT_STATUS_LABEL } from "../../lib/packets";
import { useLoad } from "../../lib/useLoad";
import { canItp, canQcDocs, useToast } from "./folders";
import { progressText } from "./itpView";
import { DocList, FolderSection, SupersededSection, Toast } from "./parts";
import { QcDocSheet, type QcDocTarget } from "./QcDocSheet";
/** The old itpFolderCard: start the ITP, or open it with its progress. */
function ItpCard({ unitId, itp, canStart, onStart, busy }: { unitId: number; itp: ItpSummary | null; canStart: boolean; onStart: () => void; busy: boolean }) {
if (!itp)
return (
<div className="itpcard">
<div className="grow">
<b>No digital ITP yet.</b>
<div className="small muted">
Start it from the Q.C. template. The header fills in from this project and unit. Mark lines N/A that don't apply to this unit.
</div>
</div>
{canStart ? (
<button className="btn pri" type="button" disabled={busy} onClick={onStart}>
Start ITP from template
</button>
) : null}
</div>
);
const pct = Math.round((itp.done / Math.max(1, itp.total)) * 100);
const done = itp.status === "complete";
return (
<Link className="itpcard card" to={`/qc/units/${unitId}/itp`}>
<div className="grow">
<div className="r1">
<span className="job">{itp.no}</span>
{done ? <span className="pill st-in_progress">Signed off</span> : <span className="pill st-review">Open</span>}
</div>
<div className="bar" role="progressbar" aria-label="ITP progress" aria-valuemin={0} aria-valuemax={100} aria-valuenow={pct}>
<i style={{ width: `${pct}%` }} />
</div>
<div className={`small ${!done && itp.holds ? "due soon" : "muted"}`}>{progressText(itp)}</div>
</div>
<span className="btn sm">Open ITP</span>
</Link>
);
}
/** /qc/units/:unitId: the unit's Q.C. folder, its ITP and the project's documents. */
export function QcUnitFolder() {
const { unitId } = useParams();
const id = Number(unitId);
const { me } = useAuth();
const roles = me?.roles ?? [];
const navigate = useNavigate();
const guard = useGuard();
const [load, reload] = useLoad((s) => getUnitFolder(id, s), String(id));
const [data, setData] = useState<Folder | null>(null);
const [sheet, setSheet] = useState<{ replaces: QcDoc | null } | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [toast, setToast] = useToast();
useChanged(reload, { delayMs: 500 });
useEffect(() => {
if (load.state === "ok") setData(load.data);
}, [load]);
const back = (
<Link className="back" to={data ? `/qc/projects/${data.project.id}` : "/qc"}>
← Back
</Link>
);
if (!data) {
return (
<>
{back}
{load.state === "error" ? <div className="callout warn">{load.message}</div> : <div className="empty slim">Loading</div>}
</>
);
}
const u = data.unit;
const t = data.project;
const canAdd = canQcDocs(roles);
const target: QcDocTarget = { scope: "unit", id: u.id, number: u.number };
const start = async () => {
setBusy(true);
setError(null);
try {
const r = await guard.run((g) => startItp(u.id, g), { sub: u.number });
if (r) navigate(`/qc/units/${u.id}/itp`);
else setBusy(false);
} catch (err) {
setBusy(false);
setError(errorText(err));
reload();
}
};
return (
<>
{back}
<nav className="crumbs" aria-label="Where you are">
<Link className="back" to="/qc">
All project folders
</Link>
<span>›</span>
<Link className="back" to={`/qc/projects/${t.id}`}>
{t.number}
</Link>
<span>›</span>
<b>{u.number}</b>
</nav>
<div className="dhead">
<div className="grow0" style={{ minWidth: 0 }}>
<div className="eyebrow">Q.C. folder · Unit · {PROJECT_STATUS_LABEL[u.status] ?? u.status}</div>
<h1>{u.number}</h1>
<div className="small muted">
Unit of {t.number}
{t.name ? ` · ${t.name}` : ""}
</div>
</div>
<div className="pacts">
{canAdd ? (
<button className="btn pri" type="button" onClick={() => setSheet({ replaces: null })}>
+ Add document
</button>
) : null}
</div>
</div>
{data.no_itp ? <div className="callout warn mb10">No ITP on file for this unit yet.</div> : null}
{error ? (
<div className="callout warn" role="alert">
{error}
</div>
) : null}
<FolderSection title="Inspection and Test Plan">
<ItpCard unitId={u.id} itp={data.itp} canStart={canItp(roles)} onStart={() => void start()} busy={busy} />
</FolderSection>
<FolderSection title="Unit documents" ct="for this unit only">
{data.docs.length ? (
<DocList docs={data.docs} label="Unit documents" onRevise={canAdd ? (d) => setSheet({ replaces: d }) : undefined} />
) : (
<div className="empty">No documents yet.{canAdd ? ' Use "Add document" to file the ITP or other Q.C. paperwork for this unit.' : ""}</div>
)}
</FolderSection>
<FolderSection title={`From project ${t.number}`} ct="apply to every unit">
{data.inherited.length ? <DocList docs={data.inherited} label={`From project ${t.number}`} /> : <div className="empty">No project-wide documents.</div>}
</FolderSection>
<SupersededSection docs={data.superseded} />
{sheet ? (
<QcDocSheet
target={target}
replaces={sheet.replaces}
onCancel={() => setSheet(null)}
onDone={(folder: Folder | QcProjectFolder, text) => {
setSheet(null);
if ("unit" in folder) setData(folder);
else reload();
setToast(text);
}}
/>
) : null}
{guard.sheet}
<Toast text={toast} />
</>
);
}
+161
View File
@@ -0,0 +1,161 @@
// Synthetic Q.C. answers for the screen tests. No real job data.
import type { Itp, ItpBlock, ItpLine, QcDoc, QcHome, QcProjectFolder, QcUnitFolder } from "../../api/qc";
import { makeMe } from "../../test/auth";
import { makeCard } from "../../test/packets";
import { emptyLine } from "./itpView";
export const QUINN = makeMe({
roles: ["qc"],
screens: ["qc"],
user: { id: "u-quinn", sam_account: "qc.quinn", display_name: "Quinn QC", emp_no: "05001" },
});
export function makeHome(over: Partial<QcHome> = {}): QcHome {
return {
kpis: { waiting: 1, accepted_7d: 2, rejected_7d: 0 },
columns: {
ready: [
{
...makeCard({ id: 1, status: "qc", unit: { id: 11, number: "L999001" } }),
qc: { location: "Bay 2", sent_at: "2026-10-17T08:00:00Z", inspector: null, result: null },
itp_hold_count: 2,
},
],
accepted: [{ ...makeCard({ id: 2, status: "complete", routed_to: "databook", unit: { id: 12, number: "L999002" }, title: "Elbow" }) }],
rejected: [],
},
folders: [
{ id: 7, number: "L999", name: "CI project", customer: "Test Co", po: "5541", status: "open", units: 2, docs: 3, units_without_itp: 1 },
],
...over,
};
}
export function makeDoc(over: Partial<QcDoc> = {}): QcDoc {
return {
id: 1,
project_id: 7,
unit_id: null,
type: "WPS / PQR",
title: "WPS-101 GTAW/SMAW",
rev: "2",
note: null,
asset_id: "a1",
url: "/_blob/a1",
file_name: "wps.pdf",
size: 1000,
auto: false,
added_at: "2026-10-17T08:00:00Z",
added_by: { id: "u-quinn", display_name: "Quinn QC" },
superseded_by: null,
superseded_at: null,
superseded_by_user: null,
...over,
};
}
const PROJECT = { id: 7, number: "L999", name: "CI project", customer: "Test Co", po: "5541", status: "open" as const };
export function makeProjectFolder(over: Partial<QcProjectFolder> = {}): QcProjectFolder {
return {
project: PROJECT,
docs: [makeDoc()],
superseded: [makeDoc({ id: 9, title: "WPS-101 old", rev: "1", superseded_by: 1, superseded_at: "2026-10-17T09:00:00Z", superseded_by_user: { id: "u-quinn", display_name: "Quinn QC" } })],
units: [
{ id: 11, number: "L999001", lot: "001", name: null, status: "open", docs: 1, itp: { id: 5, no: "L999001-ITP", status: "open", done: 12, total: 68, holds: 2 }, has_itp_doc: false },
{ id: 12, number: "L999002", lot: "002", name: null, status: "open", docs: 0, itp: null, has_itp_doc: false },
{ id: 13, number: "L999003", lot: "003", name: null, status: "open", docs: 0, itp: { id: 6, no: "L999003-ITP", status: "complete", done: 68, total: 68, holds: 0 }, has_itp_doc: true },
],
...over,
};
}
export function makeUnitFolder(over: Partial<QcUnitFolder> = {}): QcUnitFolder {
return {
unit: { id: 12, number: "L999002", lot: "002", name: null, status: "open", po: null },
project: PROJECT,
docs: [],
inherited: [makeDoc()],
superseded: [],
itp: null,
no_itp: true,
...over,
};
}
function line(key: string, over: Partial<ItpLine> = {}): ItpLine {
return { ...emptyLine(key), row_version: 1, ...over };
}
function block(key: string, sections: string[], over: Partial<ItpBlock> = {}): ItpBlock {
return {
key,
sections,
scope: sections.length > 1 ? `Sections ${sections[0]}–${sections[sections.length - 1]}` : `Section ${key}`,
final: false,
ready: false,
all_na: false,
signed: false,
signed_at: null,
signed_by: null,
qn: null,
remarks: null,
cust_name: null,
cust_at: null,
cust_by: null,
row_version: 1,
...over,
};
}
/** Four sections on three pages; blocks 2 (sections 1 to 2), 3 and 4 (final). */
export function makeItp(over: Partial<Itp> = {}): Itp {
return {
id: 5,
unit_id: 12,
no: "L999002-ITP",
hdr: { client: "Test Co", pname: "CI project", pno: "L999", po: "5541", unit: "L999002" },
status: "open",
version: 3,
template: {
ver: "ITP_Generation_Revised_2",
legend: "O = Observe, R = Review, H = Hold",
sections: [
{
n: "1",
t: "GENERAL FABRICATION and DATA",
page: "WP",
items: [
{ n: "1.1", t: "Material review", code: "R", cust: "R", crit: "MTRs match" },
{ n: "1.2", t: "Fit-up", code: "H", cust: "W", crit: "Per drawing" },
],
},
{ n: "2", t: "WELDING", page: "WP", sig: true, items: [{ n: "2.1", t: "Visual weld exam", code: "I", cust: "R", crit: "No defects" }] },
{ n: "3", t: "CODE", page: "C", sig: true, items: [{ n: "3.1", t: "Hydrotest", code: "W", cust: "W", crit: "Hold 30 min" }] },
{ n: "4", t: "FINAL", page: "FA", sig: true, final: true, items: [{ n: "4.1", t: "Final review", code: "R", cust: "R", crit: "All done" }] },
],
},
pages: [
{ code: "WP", name: "Weld Parts" },
{ code: "C", name: "Code" },
{ code: "FA", name: "Final Assembly" },
],
lines: {
"1.1": line("1.1"),
"1.2": line("1.2"),
"2.1": line("2.1"),
"3.1": line("3.1", { na: true, result: "na" }),
"4.1": line("4.1"),
},
blocks: [block("2", ["1", "2"]), block("3", ["3"], { ready: true, all_na: true }), block("4", ["4"], { final: true })],
progress: { done: 1, total: 5, holds: 1 },
holds: [{ n: "1.2", t: "Fit-up", page: "WP", codes: ["H", "W"], rejected: false, ncr: null }],
signoff: null,
reopen: null,
created_at: "2026-10-17T08:00:00Z",
created_by: { id: "u-quinn", display_name: "Quinn QC" },
can: { edit: true, sign_off: false, reopen: false },
log: [{ id: 1, at: "2026-10-17T08:00:00Z", by: { id: "u-quinn", display_name: "Quinn QC" }, by_name: "Quinn QC", action: "ITP started", note: "L999002-ITP · template ITP_Generation_Revised_2", signed: false }],
...over,
};
}
+82
View File
@@ -0,0 +1,82 @@
// Q.C. folder helpers: the document types, who may add, dates, the card search, the unit tile text and the toast.
import { useEffect, useState } from "react";
import type { ItpSummary, QcCard, QcDoc, QcFolderUnitRow } from "../../api/qc";
import { jobName } from "../../lib/packets";
/** The closed set of Q.C. folder document types (the old QC_TYPES). */
export const QC_TYPES = [
"ITP",
"WPS / PQR",
"NDE procedure",
"Hydrotest procedure",
"PMI / material",
"Calibration certificate",
"Customer spec",
"Other",
] as const;
/** Q.C. and Clerical add folder documents (QC_DOC_ADD, CL_OR_QC). Admin can, with an IT reason. */
export function canQcDocs(roles: readonly string[]): boolean {
return roles.some((r) => ["qc", "qc_supervisor", "clerical", "clerical_supervisor", "admin"].includes(r));
}
/** Q.C. starts and works the ITP. Admin can, with an IT reason. */
export function canItp(roles: readonly string[]): boolean {
return roles.some((r) => r === "qc" || r === "qc_supervisor" || r === "admin");
}
/** "Oct 17, 2026" (the old fmtQcDate), or empty. */
export function fmtQcDate(iso: string | null | undefined): string {
if (!iso) return "";
const d = new Date(/^\d{4}-\d{2}-\d{2}$/.test(iso) ? `${iso}T12:00:00` : iso);
if (Number.isNaN(d.getTime())) return iso;
return d.toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" });
}
/** "WPS-101.pdf · added Oct 17, 2026 by Quinn QC", plus the superseded part on an old row. */
export function docMeta(d: QcDoc): string {
const parts = [d.file_name ?? "", `added ${fmtQcDate(d.added_at)}${d.added_by ? ` by ${d.added_by.display_name}` : ""}`];
if (d.superseded_by !== null && d.superseded_by !== undefined) {
parts.push(`superseded ${fmtQcDate(d.superseded_at)}${d.superseded_by_user ? ` by ${d.superseded_by_user.display_name}` : ""}`);
}
return parts.filter(Boolean).join(" · ");
}
/** The unit tile's ITP text and colour: "ITP ✓", "ITP 12/68" (amber with holds), "no ITP" (amber), or null. */
export function itpTile(u: Pick<QcFolderUnitRow, "itp" | "has_itp_doc" | "status">, projectHasItpDoc = false): { text: string; cls: string } | null {
const r: ItpSummary | null = u.itp;
if (r) {
if (r.status === "complete") return { text: "ITP ✓", cls: "ok" };
return { text: `ITP ${r.done}/${r.total}`, cls: r.holds ? "due soon" : "" };
}
if (u.has_itp_doc || projectHasItpDoc) return { text: "ITP ✓", cls: "ok" };
if (u.status !== "closed") return { text: "no ITP", cls: "due soon" };
return null;
}
/** "1 unit", "2 units". */
export function plural(n: number, word: string): string {
return `${n} ${word}${n === 1 ? "" : "s"}`;
}
/** Whether a board card matches the find box: unit, op, title, drawing, customer, project or location. */
export function matchCard(c: QcCard, q: string): boolean {
const t = q.trim().toLowerCase();
if (!t) return true;
const hay = [jobName(c), c.title, c.drawing, c.customer, c.project?.number, c.project?.name, c.qc?.location, c.qc?.inspector]
.filter(Boolean)
.join(" ")
.toLowerCase();
return t.split(/\s+/).every((w) => hay.includes(w));
}
/** A toast text that clears itself after a moment. */
export function useToast(): [string | null, (t: string | null) => void] {
const [toast, setToast] = useState<string | null>(null);
useEffect(() => {
if (!toast) return;
const t = window.setTimeout(() => setToast(null), 2600);
return () => window.clearTimeout(t);
}, [toast]);
return [toast, setToast];
}
+62
View File
@@ -0,0 +1,62 @@
import { describe, expect, it } from "vitest";
import { itpTile } from "./folders";
import { blockScope, emptyLine, holdLabel, initials, isDone, isHoldCode, isOpenHold, progressText } from "./itpView";
describe("itpView helpers", () => {
it("initials: first letters, upper case, at most three", () => {
expect(initials("Quinn QC")).toBe("QQ");
expect(initials("mary ann de la cruz")).toBe("MAD");
expect(initials("")).toBe("");
expect(initials(null)).toBe("");
});
it("blockScope", () => {
expect(blockScope(["1", "2", "3"])).toBe("Sections 1–3");
expect(blockScope(["4"])).toBe("Section 4");
});
it("isHoldCode: H or W alone or after a slash", () => {
expect(isHoldCode("H")).toBe(true);
expect(isHoldCode("W")).toBe(true);
expect(isHoldCode("R/W")).toBe(true);
expect(isHoldCode("R/AU")).toBe(false);
expect(isHoldCode("NA")).toBe(false);
expect(isHoldCode("")).toBe(false);
});
it("holdLabel", () => {
expect(holdLabel({ n: "4.2", t: "Prior to vessel closing", codes: ["H"], rejected: false, ncr: null })).toBe("4.2 Prior to vessel closing (H)");
expect(holdLabel({ n: "4.2", t: "Prior to vessel closing", codes: ["H", "R"], rejected: true, ncr: "NCR-2026-0021" })).toBe(
"4.2 Prior to vessel closing (rejected, NCR NCR-2026-0021)",
);
expect(holdLabel({ n: "4.2", t: "X", codes: [], rejected: true, ncr: null })).toBe("4.2 X (rejected, NCR pending)");
});
it("progressText", () => {
expect(progressText({ done: 12, total: 68, holds: 2, status: "open" })).toBe("12 of 68 lines signed or N/A · 2 open hold / witness points");
expect(progressText({ done: 12, total: 68, holds: 1, status: "open" })).toBe("12 of 68 lines signed or N/A · 1 open hold / witness point");
expect(progressText({ done: 68, total: 68, holds: 0, status: "open" })).toBe("68 of 68 lines signed or N/A");
expect(progressText({ done: 60, total: 68, holds: 3, status: "complete" })).toBe("60 of 68 lines signed or N/A");
});
it("done and open holds", () => {
const item = { n: "1.2", t: "Fit-up", code: "R", cust: "W", crit: "" };
const l = emptyLine("1.2");
expect(isDone(l)).toBe(false);
expect(isOpenHold(item, l)).toBe(true);
expect(isOpenHold(item, { ...l, cust_code: "R" })).toBe(false);
expect(isOpenHold(item, { ...l, result: "approve" })).toBe(false);
expect(isDone({ ...l, na: true })).toBe(true);
});
it("itpTile", () => {
const itp = { id: 1, no: "X-ITP", status: "open" as const, done: 12, total: 68, holds: 2 };
expect(itpTile({ itp, has_itp_doc: false, status: "open" })).toEqual({ text: "ITP 12/68", cls: "due soon" });
expect(itpTile({ itp: { ...itp, holds: 0 }, has_itp_doc: false, status: "open" })).toEqual({ text: "ITP 12/68", cls: "" });
expect(itpTile({ itp: { ...itp, status: "complete" }, has_itp_doc: false, status: "open" })).toEqual({ text: "ITP ✓", cls: "ok" });
expect(itpTile({ itp: null, has_itp_doc: true, status: "open" })).toEqual({ text: "ITP ✓", cls: "ok" });
expect(itpTile({ itp: null, has_itp_doc: false, status: "open" }, true)).toEqual({ text: "ITP ✓", cls: "ok" });
expect(itpTile({ itp: null, has_itp_doc: false, status: "open" })).toEqual({ text: "no ITP", cls: "due soon" });
expect(itpTile({ itp: null, has_itp_doc: false, status: "closed" })).toBeNull();
});
});
+109
View File
@@ -0,0 +1,109 @@
// Pure helpers for the ITP screen. The server decides every rule; these only shape the page.
import type { Itp, ItpHoldRow, ItpItem, ItpLine, ItpSection, ItpSummary } from "../../api/qc";
/** Page names and order (the old ITP_PAGES and ITP_PAGE_ORDER). */
export const ITP_PAGES: Record<string, string> = { WP: "Weld Parts", C: "Code", FA: "Final Assembly" };
export const ITP_PAGE_ORDER = ["WP", "C", "FA"] as const;
/** Customer codes (the old ITP_CUST_CODES). */
export const ITP_CUST_CODES = ["R", "R/AU", "AU", "W", "H", "NA"] as const;
/** A hold or witness code: H or W, alone or after a slash. */
export function isHoldCode(code: string | null | undefined): boolean {
return /(^|\/)(H|W)$/.test(String(code ?? ""));
}
/** The first letter of each word of the name, upper case, at most three (the old initials()). */
export function initials(name: string | null | undefined): string {
return String(name ?? "")
.split(/\s+/)
.filter(Boolean)
.map((w) => (w[0] ?? "").toUpperCase())
.join("")
.slice(0, 3);
}
/** "Sections 1–3" or "Section 4". */
export function blockScope(sections: readonly string[]): string {
if (sections.length > 1) return `Sections ${sections[0] ?? ""}–${sections[sections.length - 1] ?? ""}`;
return `Section ${sections[0] ?? ""}`;
}
/** "4.2 Prior to vessel closing (H)", "(rejected, NCR NCR-2026-0021)" or "(rejected, NCR pending)". */
export function holdLabel(h: Pick<ItpHoldRow, "n" | "t" | "codes" | "rejected" | "ncr">): string {
const why = h.rejected ? `rejected, NCR ${h.ncr || "pending"}` : h.codes.filter(isHoldCode).join(", ");
return `${h.n} ${h.t} (${why})`;
}
/** "12 of 68 lines signed or N/A · 2 open hold / witness points". Holds only while the ITP is open. */
export function progressText(p: Pick<ItpSummary, "done" | "total" | "holds" | "status">): string {
const base = `${p.done} of ${p.total} lines signed or N/A`;
if (p.status === "complete" || !p.holds) return base;
return `${base} · ${p.holds} open hold / witness point${p.holds === 1 ? "" : "s"}`;
}
/** An empty line, for keys the server has no row for yet. */
export function emptyLine(key: string): ItpLine {
return {
key,
result: null,
na: false,
by: null,
initials: null,
at: null,
ncr: null,
ncr_missing: false,
note: null,
cust_code: null,
cust_by: null,
cust_at: null,
cust_rec: null,
rejects: [],
row_version: 0,
};
}
export function lineOf(r: Pick<Itp, "lines">, key: string): ItpLine {
return r.lines[key] ?? emptyLine(key);
}
export function isNa(l: ItpLine): boolean {
return l.na || l.result === "na";
}
export function isApproved(l: ItpLine): boolean {
return l.result === "approve";
}
/** Done: N/A or approved. */
export function isDone(l: ItpLine): boolean {
return isNa(l) || isApproved(l);
}
/** An open reject: rejected and not approved since. */
export function isRejected(l: ItpLine): boolean {
return l.result === "reject";
}
/** A hold point not yet done: H or W on the PESCO code or the customer code (the line's own, else the template's). */
export function isOpenHold(item: ItpItem, l: ItpLine): boolean {
return (isHoldCode(item.code) || isHoldCode(l.cust_code || item.cust)) && !isDone(l);
}
/** The newest reject entry (kind reject), if any. */
export function lastReject(l: ItpLine) {
const rs = l.rejects.filter((x) => x.kind !== "ncr");
return rs[rs.length - 1] ?? null;
}
/** The sections of a page, in template order. */
export function sectionsOf(sections: readonly ItpSection[], page: string): ItpSection[] {
return sections.filter((s) => s.page === page);
}
/** Today as a date input value: "2026-10-17". */
export function todayIso(now: Date = new Date()): string {
const m = String(now.getMonth() + 1).padStart(2, "0");
const d = String(now.getDate()).padStart(2, "0");
return `${now.getFullYear()}-${m}-${d}`;
}
+79
View File
@@ -0,0 +1,79 @@
import { useId, useState, type ReactNode } from "react";
import type { QcDoc } from "../../api/qc";
import { docMeta } from "./folders";
/** The folder documents as .qcdoc rows: type tag, title, rev, file and who, note, View and New revision. */
export function DocList({ docs, label, old = false, onRevise }: { docs: QcDoc[]; label: string; old?: boolean; onRevise?: ((d: QcDoc) => void) | undefined }) {
return (
<ul className="qcdocs" aria-label={label}>
{docs.map((d) => (
<li key={d.id} className={old ? "qcdoc old" : "qcdoc"}>
<span className="tag">{d.type || "Other"}</span>
<div className="m">
<b>{d.title || d.file_name || "Document"}</b>
{d.rev ? <span className="muted small"> Rev {d.rev}</span> : null}
<div className="small muted">{docMeta(d)}</div>
{d.note ? <div className="small">{d.note}</div> : null}
</div>
<div className="a">
<button type="button" className="btn sm" onClick={() => window.open(d.url, "_blank", "noopener")}>
View
</button>
{onRevise ? (
<button type="button" className="btn sm ghost" onClick={() => onRevise(d)}>
New revision
</button>
) : null}
</div>
</li>
))}
</ul>
);
}
/** A section with an h2, the count or note beside it, and its body. */
export function FolderSection({ title, ct, extra, children }: { title: string; ct?: string; extra?: ReactNode; children: ReactNode }) {
const id = useId();
return (
<section className="sec" aria-labelledby={id}>
<div className="sec-h">
<h2 id={id}>{title}</h2>
{ct ? <span className="ct">{ct}</span> : null}
{extra ? (
<>
<span className="grow" />
{extra}
</>
) : null}
</div>
{children}
</section>
);
}
/** The Superseded section: count, Show / Hide, the old rows. */
export function SupersededSection({ docs }: { docs: QcDoc[] }) {
const [open, setOpen] = useState(false);
if (!docs.length) return null;
return (
<FolderSection
title="Superseded"
ct={String(docs.length)}
extra={
<button type="button" className="btn sm ghost" aria-expanded={open} onClick={() => setOpen((v) => !v)}>
{open ? "Hide" : "Show"}
</button>
}
>
{open ? <DocList docs={docs} label="Superseded documents" old /> : null}
</FolderSection>
);
}
export function Toast({ text }: { text: string | null }) {
return text ? (
<div className="toast" role="status">
{text}
</div>
) : null;
}
+74
View File
@@ -559,3 +559,77 @@ body.fs-view .toast { z-index: 99; }
.mb10 { margin-bottom: 10px; }
.mt8 { margin-top: 8px; }
.mt10 { margin-top: 10px; }
/* Phase 6: packet page. From .vtb, .qcdoc, .qcdocs, .itphold and the qcTab layout of the old build. */
.vtbs { display: flex; gap: 6px; }
.vtb { border: 2px solid var(--line2); background: var(--panel); color: var(--body); border-radius: 8px; padding: 8px 14px; font-weight: 700; cursor: pointer; min-height: 42px; }
.vtb:disabled { opacity: .6; cursor: default; }
.vtb.acc[aria-pressed="true"] { background: var(--green); border-color: var(--green); color: var(--on-green); }
.vtb.rej[aria-pressed="true"] { background: var(--pink); border-color: var(--pink); color: var(--on-pink); }
.qctab .ck-item .t .hint { display: block; margin-top: 2px; font-weight: 400; }
.qcinsp { max-width: 720px; }
.qcnde { max-width: 900px; }
.qcnotes { margin-top: 12px; max-width: 900px; }
.qcroute { flex: 0 1 260px; }
.qcbar .acts { align-items: flex-end; }
.qcrej a.lnk, .itphold a.lnk { font-weight: 600; }
.itphold b { color: var(--amber); }
.qcdocs { display: grid; gap: 8px; }
.qcdoc { display: flex; gap: 12px; align-items: flex-start; flex-wrap: wrap; padding: 12px 14px; border: 1px solid var(--line); border-radius: 10px; background: var(--panel); }
.qcdoc.old { opacity: .7; }
.qcdoc .m { flex: 1 1 240px; min-width: 0; }
.qcdoc .a { display: flex; gap: 8px; flex-wrap: wrap; }
.qchist .pill { margin-right: 4px; }
input.in.w220 { width: 220px; max-width: 100%; }
.mt4 { margin-top: 4px; }
/* Phase 6: Q.C. screens. Ported from the old .qcunits, .qcunit, .qcdocs, .qcdoc, .itpcard, .itphdr, .itppage,
.itpsec, .itprows, .itprow, .itpblock, .sigs, .ini, .rejtag, .custrec blocks, plus a few new helpers. */
.qcfolders { margin-top: 22px; }
.qcunits { display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap: 10px; }
.qcunit { gap: 8px; padding: 10px 12px; align-items: stretch; text-align: left; }
.qcunit .job { font-size: 17px; }
.qcunit .qm { display: flex; justify-content: space-between; gap: 8px; font-size: 13px; color: var(--muted); }
.qcunit .qm .ok, .qcdoc .ok { color: var(--green); }
.qcdocs { display: grid; gap: 8px; list-style: none; margin: 0; padding: 0; }
.qcdoc { display: flex; gap: 12px; align-items: flex-start; flex-wrap: wrap; padding: 12px 14px; border: 1px solid var(--line); border-radius: 10px; background: var(--panel); }
.qcdoc.old { opacity: .7; }
.qcdoc .tag { font-size: 12px; font-weight: 700; padding: 3px 8px; border-radius: 6px; background: var(--panel2); border: 1px solid var(--line2); white-space: nowrap; }
.qcdoc .m { flex: 1 1 240px; min-width: 0; }
.qcdoc .a { display: flex; gap: 8px; flex-wrap: wrap; }
.itpcard { display: flex; gap: 14px; align-items: center; flex-wrap: wrap; padding: 14px 16px; border: 1px solid var(--line); border-radius: 10px; background: var(--panel); }
a.itpcard { flex-direction: row; text-align: left; }
.itpcard .grow { flex: 1 1 260px; min-width: 0; }
.itpcard .bar { background: var(--panel2); margin: 8px 0 6px; }
.itphdr { display: grid; grid-template-columns: repeat(auto-fill, minmax(210px, 1fr)); gap: 10px; }
.itppage { margin: 26px 0 4px; font-family: var(--f-disp); font-size: 13px; letter-spacing: .12em; text-transform: uppercase; color: var(--cyan); }
.itppage span { color: var(--muted); margin-right: 6px; }
.itpsec .sec-h h2 { font-size: 17px; }
.itprows { display: grid; gap: 6px; }
.itprow { display: grid; grid-template-columns: 42px minmax(0, 1fr) 44px 210px 130px 44px; gap: 10px; align-items: center; padding: 10px 12px; border: 1px solid var(--line); border-radius: 10px; background: var(--panel); }
.itprow.hold { border-left: 3px solid var(--amber); }
.itprow.rej { border-left: 3px solid var(--red); }
.itprow .rejtag { font-size: 12px; color: var(--red); flex-basis: 100%; }
.itprow .apr { display: flex; gap: 6px; flex-wrap: wrap; }
.itprow.na { opacity: .55; }
.itprow.na .t b { text-decoration: line-through; }
.itprow .cd { display: inline-block; min-width: 34px; text-align: center; font-weight: 700; font-size: 12px; padding: 3px 6px; border-radius: 6px; background: var(--panel2); border: 1px solid var(--line2); }
.itprow .qc, .itprow .cu { display: flex; flex-wrap: wrap; gap: 4px 8px; align-items: center; }
.itprow.hd { background: none; border: 0; padding: 0 12px; font-size: 11px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; color: var(--muted); }
.itprow .ini { font-family: var(--f-disp); font-weight: 700; font-size: 16px; color: var(--green); }
.itpblock { margin: 8px 0 18px; padding: 12px 14px; border: 1px dashed var(--line2); border-radius: 10px; }
.itpblock .sigs { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 12px; margin-top: 8px; }
.itpblock .custrec { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; }
.itpblock .custrec .in { max-width: 200px; }
@media (max-width: 760px) {
.itprow.hd { display: none; }
.itprow { grid-template-columns: 38px minmax(0, 1fr) 40px; }
.itprow .qc { grid-column: 2; grid-row: 2; }
.itprow .cu { grid-column: 2 / 4; grid-row: 3; }
.itprow .mb { grid-column: 3; grid-row: 2; }
}
/* New: the line sheet's N/A checkbox row, the ITP log list and the write message pinned to the bottom. */
.itpck { display: flex; align-items: center; gap: 10px; font-size: 14px; color: var(--near); }
.itpck input { width: 20px; height: 20px; }
.itplog { list-style: none; margin: 0; padding: 0; display: grid; gap: 4px; color: var(--body); }
.itpmsg { position: fixed; left: 50%; bottom: calc(20px + env(safe-area-inset-bottom, 0px)); transform: translateX(-50%); z-index: 60; max-width: calc(100% - 32px); box-shadow: 0 6px 20px rgba(0, 0, 0, .4); }