Files
PacketStation/frontend/src/screens/qc/folders.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

83 lines
3.5 KiB
TypeScript

// 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];
}