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
+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>