Files
PacketStation/frontend/src/lib/qc.ts
T
spencermandClaude Fable 5.1 00531cb329
ci / frontend (push) Successful in 37s
ci / server (push) Successful in 9m18s
ci / image (push) Successful in 35s
ci / e2e (push) Successful in 2m17s
Phase 6: Q.C. inspection, project folders, packet documents and the digital ITP
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]>
2026-10-17 20:08:53 -06:00

113 lines
4.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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";
}