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
+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";
}