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]>
This commit is contained in:
+273
-123
@@ -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'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>
|
||||
|
||||
Reference in New Issue
Block a user