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
+27
View File
@@ -0,0 +1,27 @@
import { Link } from "react-router";
import type { PacketRead } from "../api/types";
import { holdItemsText, holdText } from "../lib/qc";
/**
* The open ITP hold, witness or rejected points for this packet's department (the old itpHoldNote). A warning
* only: nothing is blocked by it. Users with the Q.C. screen get a link to the ITP.
*/
export function HoldCallout({ p, qcScreen }: { p: PacketRead; qcScreen: boolean }) {
const hold = p.itp_hold;
if (!hold || p.status === "filed" || !hold.count) return null;
const items = holdItemsText(hold);
return (
<div className="callout warn itphold" role="note">
<b>{holdText(hold)}</b>
{items ? ` ${items}.` : ""} Check with Q.C. before moving past these points.
{qcScreen ? (
<>
{" "}
<Link className="lnk" to={`/qc/units/${p.unit.id}/itp`}>
Open ITP
</Link>
</>
) : null}
</div>
);
}
+18 -6
View File
@@ -20,13 +20,18 @@ import { ChangesTab } from "./ChangesTab";
import { ChecklistTab } from "./ChecklistTab";
import { HeatMapTab } from "./HeatMapTab";
import { ShopBar } from "./ShopActions";
import { HoldCallout } from "./HoldCallout";
import { QcBar } from "./QcActions";
import { QcRejectedCallout } from "./QcRejectedCallout";
import { showsQcRejected } from "../lib/qc";
import { QcTab } from "./QcTab";
import { WeldMapTab } from "./WeldMapTab";
import type { Write } from "../warehouse/PullTable";
import { packetMessage, pillClassFor, WH_STAGES, pillText, progress, whLine, whStage } from "../warehouse/stages";
import { useWhMove } from "../warehouse/useWhMove";
type Tab = "drawing" | "heat" | "materials" | "checklist" | "welds" | "workorders" | "changes" | "docs" | "history";
const TABS: readonly Tab[] = ["drawing", "heat", "materials", "checklist", "welds", "workorders", "changes", "docs", "history"];
type Tab = "drawing" | "heat" | "materials" | "checklist" | "welds" | "qc" | "workorders" | "changes" | "docs" | "history";
const TABS: readonly Tab[] = ["drawing", "heat", "materials", "checklist", "welds", "qc", "workorders", "changes", "docs", "history"];
function isTab(v: string | null): v is Tab {
return v !== null && (TABS as readonly string[]).includes(v);
}
@@ -317,7 +322,9 @@ export function PacketPage() {
const drawingPages = p.pages.filter((x) => x.type === "drawing");
const review = coToReview(p);
const shownTab: Tab = tab === "changes" && !(p.change_orders ?? []).length ? "materials" : (tab ?? (drawingPages.length ? "drawing" : "materials"));
const hasQc = !!p.qc || p.qc_history.length > 0;
const hidden = (tab === "changes" && !(p.change_orders ?? []).length) || (tab === "qc" && !hasQc);
const shownTab: Tab = hidden ? "materials" : (tab ?? (drawingPages.length ? "drawing" : "materials"));
// The warehouse short-parts exception and the data book date are not edits of the packet itself.
const passive = new Set(["wh_receive_short", "set_databook_at"]);
const viewOnly =
@@ -354,9 +361,10 @@ export function PacketPage() {
{ id: "materials", label: "Materials", count: p.bom.length },
{ id: "checklist", label: "Checklist", ...(p.stage ? { count: ckOpen } : {}) },
{ id: "welds", label: "Weld map", count: p.welds.length },
...(hasQc ? [{ id: "qc" as const, label: "Q.C." }] : []),
{ id: "workorders", label: "Work orders", count: p.work_orders.length },
...(cos ? [{ id: "changes" as const, label: "Changes", count: cos }] : []),
{ id: "docs", label: "Documents", count: p.docs.length },
{ id: "docs", label: "Documents", count: p.docs.filter((d) => !d.removed).length },
{ id: "history", label: "History" },
];
const onTabError = (text: string) => setMsg({ ok: false, text });
@@ -463,7 +471,8 @@ export function PacketPage() {
{p.removed.by ? ` by ${p.removed.by.display_name}` : ""}. {p.removed.reason ?? ""}
</div>
) : null}
{p.return_note ? (
<QcRejectedCallout p={p} />
{p.return_note && !(showsQcRejected(p) && p.return_note === p.qc_last?.note) ? (
<div className="callout warn">
<b>Returned:</b> {p.return_note}
</div>
@@ -652,6 +661,8 @@ export function PacketPage() {
) : null}
<ShopBar p={p} busy={busy} mutate={mutate} askReason={askReason} sub={job} goTab={(t) => setTab(t)} />
<QcBar p={p} busy={busy} mutate={mutate} sub={job} goQc={() => setTab("qc")} />
<HoldCallout p={p} qcScreen={me?.screens.includes("qc") ?? false} />
<div className="tabs" role="tablist" aria-label="Packet">
{tabs.map((t) => (
@@ -692,9 +703,10 @@ export function PacketPage() {
{shownTab === "welds" ? (
<WeldMapTab key={p.id} p={p} write={write} mutate={mutate} reload={reload} askReason={askReason} sub={job} myId={me?.user.id ?? null} onError={onTabError} />
) : null}
{shownTab === "qc" ? <QcTab p={p} write={write} mutate={mutate} reload={reload} onError={onTabError} /> : null}
{shownTab === "workorders" ? <WorkOrdersTab p={p} /> : null}
{shownTab === "changes" ? <ChangesTab p={p} roles={me?.roles ?? []} sub={job} mutate={mutate} busy={busy} askReason={askReason} /> : null}
{shownTab === "docs" ? <DocumentsTab p={p} mutate={mutate} busy={busy} /> : null}
{shownTab === "docs" ? <DocumentsTab p={p} mutate={mutate} busy={busy} write={write} askReason={askReason} sub={job} /> : null}
{shownTab === "history" ? <HistoryTab p={p} /> : null}
</div>
+170
View File
@@ -0,0 +1,170 @@
import { useState, type FormEvent } from "react";
import type { PacketRead, RouteKind } from "../api/types";
import { Sheet } from "../components/Sheet";
import { isNcr, normNcr, ROUTE_DATABOOK, ROUTE_SHIPPING } from "../lib/qc";
import { useShopDepartments } from "../lib/useDepartments";
import { makeStep } from "./shopUtil";
import type { Mutate } from "./tabs";
/** Signature meanings, as the rules state them. */
export const SIGN_QC_ACCEPT = "Q.C. accept";
export const SIGN_QC_ACCEPT_ROUTE = "Q.C. accept and route";
export const SIGN_QC_REJECT = "Q.C. reject";
/** The precondition message the bar shows as the list of links instead. */
const MISSING_PREFIX = "Q.C. still needs";
const REJECT_MIN = 8;
/** The reject sheet: a reason, an NCR number, or both. */
export function RejectSheet({
sub,
onCancel,
onReject,
}: {
sub: string;
onCancel: () => void;
onReject: (body: { reason?: string; ncr?: string }) => void;
}) {
const [reason, setReason] = useState("");
const [ncr, setNcr] = useState("");
const r = reason.trim();
const n = ncr.trim();
const ncrOk = isNcr(n);
const ok = r.length >= REJECT_MIN || ncrOk;
const submit = (e: FormEvent) => {
e.preventDefault();
if (!ok) return;
onReject({ ...(r ? { reason: r } : {}), ...(ncrOk ? { ncr: normNcr(n) } : {}) });
};
return (
<Sheet title="Reject" sub={sub} onClose={onCancel}>
<div className="small">Give a reason, an NCR number, or both. The supervisor sees it at the top of the packet.</div>
<form className="stack" onSubmit={submit} noValidate>
<label className="f">
Reason / what failed
<textarea aria-label="Reason / what failed" className="in" value={reason} maxLength={500} onChange={(e) => setReason(e.target.value)} />
</label>
<label className="f">
NCR number
<input aria-label="NCR number" className="in w220 mono" value={ncr} maxLength={20} placeholder="NCR-2026-0021" onChange={(e) => setNcr(e.target.value)} />
</label>
{n && !ncrOk ? <div className="small pinktxt">Enter an NCR number like NCR-2026-0021.</div> : null}
<div className="sheet-acts">
<button className="btn ghost" type="button" onClick={onCancel}>
Cancel
</button>
<button className="btn pink" type="submit" disabled={!ok}>
Reject to supervisor
</button>
</div>
</form>
</Sheet>
);
}
const has = (p: PacketRead, a: string) => p.can.includes(a) || !!p.blocked[a];
/**
* The Q.C. bar on a packet at Q.C.: what is still needed (links to the Q.C. tab), the route, Accept and Reject.
* The server decides; `can` and `blocked` only shape the buttons. Accept and reject are signed.
*/
export function QcBar({ p, busy, mutate, sub, goQc }: { p: PacketRead; busy: boolean; mutate: Mutate; sub: string; goQc: () => void }) {
const depts = useShopDepartments().filter((d) => d.code !== p.dept.code);
const [route, setRoute] = useState<string>("databook");
const [rejecting, setRejecting] = useState(false);
if (p.status !== "qc") return null;
if (!has(p, "qc_accept") && !has(p, "qc_accept_and_route") && !has(p, "qc_reject")) return null;
const step = makeStep(p, mutate);
const toDept = route.startsWith("DEPT:") ? route.slice(5) : null;
const action = toDept ? "qc_accept_and_route" : "qc_accept";
const canAccept = p.can.includes(action);
const blocked = canAccept ? [] : (p.blocked[action] ?? p.blocked.qc_accept ?? []);
const reason = blocked.join(" ");
const missing = p.qc?.missing ?? [];
const listed = blocked.some((m) => m.startsWith(MISSING_PREFIX)) && missing.length > 0;
const other = listed ? blocked.filter((m) => !m.startsWith(MISSING_PREFIX)) : blocked;
const accept = () => {
if (toDept) void step("qc_accept_and_route", { dept: toDept }, { sign: SIGN_QC_ACCEPT_ROUTE, ok: "Q.C. accepted. Sent to the department." });
else void step("qc_accept", { routed_to: route as RouteKind }, { sign: SIGN_QC_ACCEPT, ok: "Q.C. accepted." });
};
return (
<section className="panel actbox qcbar" aria-label="Q.C. inspection">
<div>
{listed ? (
<span data-testid="qc-missing">
<b>Still needed:</b>{" "}
{missing.map((m, i) => (
<span key={m}>
{i ? " · " : ""}
<button className="lnk" type="button" onClick={goQc}>
{m}
</button>
</span>
))}
</span>
) : canAccept ? (
<>
<b>Everything is recorded.</b> Pick where it goes and accept, or reject it back to the supervisor.
</>
) : (
<>
<b>Inspect the spool and the data book package.</b> Record the checks, the visual exam and NDE on the Q.C. tab.
</>
)}
</div>
{other.length ? <div className="why small">{other.join(" ")}</div> : null}
<div className="acts center">
{has(p, "qc_accept") || has(p, "qc_accept_and_route") ? (
<>
<label className="f qcroute">
Route
<select aria-label="Route" className="in" value={route} onChange={(e) => setRoute(e.target.value)}>
<option value="databook">{ROUTE_DATABOOK}</option>
{depts.map((d) => (
<option key={d.code} value={`DEPT:${d.code}`}>
{d.code} {d.name}
</option>
))}
<option value="shipping">{ROUTE_SHIPPING}</option>
</select>
</label>
<div className="act">
<button className="btn pri" type="button" disabled={!canAccept || busy} title={!canAccept ? reason : undefined} onClick={accept}>
Accept
</button>
</div>
</>
) : null}
{has(p, "qc_reject") ? (
<div className="act">
<button
className="btn pink"
type="button"
disabled={!p.can.includes("qc_reject") || busy}
title={!p.can.includes("qc_reject") ? (p.blocked.qc_reject ?? []).join(" ") : undefined}
onClick={() => setRejecting(true)}
>
Reject
</button>
</div>
) : null}
</div>
{rejecting ? (
<RejectSheet
sub={sub}
onCancel={() => setRejecting(false)}
onReject={(body) => {
setRejecting(false);
void step("qc_reject", body, { sign: SIGN_QC_REJECT, ok: "Q.C. rejected. It is back with the supervisor." });
}}
/>
) : null}
</section>
);
}
+30
View File
@@ -0,0 +1,30 @@
import type { PacketRead } from "../api/types";
import { fmtTime } from "../lib/format";
import { ncrUrl, noteWithoutNcr, showsQcRejected } from "../lib/qc";
/** "Q.C. rejected <time> by Quinn QC: <note>", the NCR link and the rejected welds. */
export function QcRejectedCallout({ p }: { p: PacketRead }) {
const h = p.qc_last;
if (!h || !showsQcRejected(p)) return null;
const who = h.inspector || h.by?.display_name || "";
const note = noteWithoutNcr(h.note, h.ncr);
const welds = h.rejected_welds ?? [];
return (
<div className="callout warn qcrej" role="note">
<b>
Q.C. rejected {fmtTime(h.at)}
{who ? ` by ${who}` : ""}:
</b>
{note ? ` ${note}` : ""}
{h.ncr ? (
<>
{" "}
<a className="lnk" href={ncrUrl(h.ncr)} target="_blank" rel="noopener noreferrer">
NCR {h.ncr}
</a>
</>
) : null}
{welds.length ? <div className="small mt4">Rejected welds: {welds.join(", ")}</div> : null}
</div>
);
}
+406
View File
@@ -0,0 +1,406 @@
import { useRef, useState } from "react";
import { ApiError } from "../api/client";
import { packetsApi } from "../api/packets";
import type { PacketRead, QcFields, QcHistoryRow, QcInspection, QcItem, Weld } from "../api/types";
import { errorText, fmtTime } from "../lib/format";
import { historyLine, NDE_METHODS, NDE_NONE, NDE_RESULTS, plural } from "../lib/qc";
import type { Write } from "../warehouse/PullTable";
import type { Mutate } from "./tabs";
type TextField = Exclude<keyof QcFields, "nde_method" | "nde_result">;
/** The earlier inspections, newest last as the old build listed them. */
function Earlier({ rows }: { rows: QcHistoryRow[] }) {
if (!rows.length) return null;
return (
<section className="sec" aria-label="Earlier inspections">
<div className="sec-h">
<h2>Earlier inspections</h2>
</div>
<div className="stack tight">
{rows.map((h) => (
<div key={h.id} className="small">
{historyLine(h)}
</div>
))}
</div>
</section>
);
}
function groupsOf(items: readonly QcItem[]): Array<{ group: string; items: QcItem[] }> {
const out: Array<{ group: string; items: QcItem[] }> = [];
for (const it of items) {
const g = out.find((x) => x.group === it.group);
if (g) g.items.push(it);
else out.push({ group: it.group, items: [it] });
}
return out;
}
function weldDetail(w: Weld): string {
const parts = [
w.items.length ? `items ${w.items.join(" + ")}` : null,
w.wo_base ? `${w.wo_base}${w.piece ? ` #${w.piece}` : ""}` : null,
w.process,
w.stamp,
].filter((x): x is string => !!x);
return parts.join(" · ");
}
/**
* The Q.C. inspection (the old qcTab): the supervisor's send-off, the inspector, the ten checks, the visual exam
* per weld, NDE and hardness, notes, and the earlier inspections. Editable only with `edit_qc`. Each check and
* each weld is its own PUT; the other fields save on blur with a field-level base.
*/
export function QcTab({
p,
write,
mutate,
reload,
onError,
}: {
p: PacketRead;
write: Write;
mutate: Mutate;
reload: () => Promise<void>;
onError: (text: string) => void;
}) {
const q = p.qc;
if (!q) {
return (
<div className="stack">
<div className="empty slim">No open Q.C. inspection.</div>
<Earlier rows={p.qc_history} />
</div>
);
}
return <Inspection key={q.id} p={p} q={q} write={write} mutate={mutate} reload={reload} onError={onError} />;
}
function Inspection({
p,
q,
write,
mutate,
reload,
onError,
}: {
p: PacketRead;
q: QcInspection;
write: Write;
mutate: Mutate;
reload: () => Promise<void>;
onError: (text: string) => void;
}) {
const ed = p.can.includes("edit_qc");
const [pending, setPending] = useState<Record<string, boolean>>({});
const [vtPending, setVtPending] = useState<Record<string, boolean>>({});
const [draft, setDraft] = useState<Partial<Record<TextField, string>>>({});
const [notes, setNotes] = useState<Record<string, string>>({});
const saving = useRef(new Set<string>());
const fail = async (err: unknown) => {
if (err instanceof ApiError && (err.status === 409 || err.status === 412)) await reload();
onError(errorText(err));
};
const tick = async (key: string, done: boolean) => {
setPending((o) => ({ ...o, [key]: done }));
const r = await write((g) => packetsApi.qcCheck(p.id, key, done, g));
setPending((o) => {
const n = { ...o };
delete n[key];
return n;
});
if (!r.ok && r.err !== undefined) await fail(r.err);
};
const exam = async (no: number, body: { result?: "accept" | "reject" | ""; note?: string }) => {
const k = String(no);
setVtPending((o) => ({ ...o, [k]: true }));
const r = await write((g) => packetsApi.qcWeld(p.id, no, body, g));
setVtPending((o) => {
const n = { ...o };
delete n[k];
return n;
});
if (!r.ok && r.err !== undefined) await fail(r.err);
return r.ok;
};
const saveNote = async (no: number) => {
const k = String(no);
const now = notes[k];
if (now === undefined) return;
const was = q.weld_exam[k]?.note ?? "";
if (now.trim() !== was.trim()) await exam(no, { note: now.trim() });
setNotes((o) => {
const n = { ...o };
delete n[k];
return n;
});
};
const server = (k: keyof QcFields) => q[k] ?? "";
const shown = (k: TextField) => draft[k] ?? server(k);
const setField = async (k: keyof QcFields, value: string) => {
if (saving.current.has(k)) return;
const was = q[k] ?? null;
const now = value.trim() || null;
if (now === was) return;
saving.current.add(k);
try {
await mutate((g) => packetsApi.qcPatch(p.id, { set: { [k]: now }, base: { [k]: was }, ...g }));
} finally {
saving.current.delete(k);
}
};
const saveText = async (k: TextField) => {
const now = draft[k];
if (now === undefined) return;
await setField(k, now);
// On a conflict the page reloads; the field then shows the value someone else saved.
setDraft((d) => ({ ...d, [k]: undefined }));
};
const textInput = (k: TextField, label: string, ph: string, max: number, cls = "in") => (
<label className="f" key={k}>
{label}
<input
aria-label={label}
className={cls}
value={shown(k)}
maxLength={max}
placeholder={ph}
onChange={(e) => setDraft((d) => ({ ...d, [k]: e.target.value }))}
onBlur={() => void saveText(k)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
void saveText(k);
}
}}
/>
</label>
);
const groups = groupsOf(p.qc_items);
const welds = p.welds;
const method = q.nde_method || NDE_NONE;
const hardLabel = q.nace_label ? "Hardness (NACE)" : "Hardness";
return (
<div className="stack qctab">
<div className="callout">
<b>Sent {fmtTime(q.sent_at)}</b>: data book package {q.book ? "ready" : "not confirmed"}, spool {q.spool ? "ready" : "not confirmed"}. Spool location:{" "}
<b>{q.location || "-"}</b>
{q.sent_note ? <div className="small mt4">Note: {q.sent_note}</div> : null}
</div>
<div className="frow qcinsp">
{ed ? (
<>
{textInput("inspector", "Inspector", "Name", 80)}
{textInput("stamp", "Stamp / ID", "ID", 20)}
</>
) : (
<div className="small">
<span className="muted">Inspector: </span>
<b className="near">
{q.inspector || "-"}
{q.stamp ? ` [${q.stamp}]` : ""}
</b>
</div>
)}
</div>
{groups.map((g) => (
<section className="sec" key={g.group} aria-label={g.group}>
<div className="sec-h">
<h2>{g.group}</h2>
</div>
<div className="ck">
{g.items.map((i) => {
const c = q.checks[i.key];
const done = pending[i.key] ?? !!c?.done;
const by = c?.done ? [c.stamp || c.by_name, fmtTime(c.at)].filter(Boolean).join(" · ") : "";
return (
<label key={i.key} className={`ck-item${done ? " done" : ""}`} aria-disabled={!ed}>
<input
type="checkbox"
aria-label={i.text}
checked={done}
disabled={!ed || pending[i.key] !== undefined}
onChange={(e) => void tick(i.key, e.target.checked)}
/>
<span className="t">
{i.text}
{i.hint ? <span className="hint small muted mono">{i.hint}</span> : null}
</span>
<span className="by">{by}</span>
</label>
);
})}
</div>
</section>
))}
<section className="sec" aria-label="Visual weld inspection">
<div className="sec-h">
<h2>Visual weld inspection</h2>
<span className="ct">{plural(welds.length, "weld")} · open the Weld map tab to find them</span>
</div>
<div className="tw">
<table>
<thead>
<tr>
<th>Weld</th>
<th>Detail</th>
<th>Result</th>
<th>Note</th>
</tr>
</thead>
<tbody>
{welds.length ? (
welds.map((w) => {
const k = String(w.weld_no);
const v = q.weld_exam[k] ?? {};
const res = v.result || "";
const busy = !!vtPending[k];
return (
<tr key={k} aria-label={`Weld W${w.weld_no}`}>
<td>
<span className="balloon pinkb">{w.weld_no}</span>
</td>
<td>
{[w.type, w.size].filter(Boolean).join(" ")}
<div className="sub">{weldDetail(w)}</div>
</td>
<td>
{ed ? (
<div className="vtbs">
<button
type="button"
className="vtb acc"
aria-pressed={res === "accept"}
disabled={busy}
onClick={() => void exam(w.weld_no, { result: res === "accept" ? "" : "accept" })}
>
Accept
</button>
<button
type="button"
className="vtb rej"
aria-pressed={res === "reject"}
disabled={busy}
onClick={() => void exam(w.weld_no, { result: res === "reject" ? "" : "reject" })}
>
Reject
</button>
</div>
) : res ? (
<span className={`pill ${res === "accept" ? "st-in_progress" : "st-review"}`}>{res}</span>
) : (
<span className="muted small">-</span>
)}
</td>
<td>
{ed ? (
<input
className="in"
aria-label={`W${w.weld_no} note`}
value={notes[k] ?? v.note ?? ""}
maxLength={300}
placeholder={res === "reject" ? "Defect (undercut, porosity…)" : "Note"}
onChange={(e) => setNotes((o) => ({ ...o, [k]: e.target.value }))}
onBlur={() => void saveNote(w.weld_no)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
void saveNote(w.weld_no);
}
}}
/>
) : (
<span className="small">{v.note ?? ""}</span>
)}
</td>
</tr>
);
})
) : (
<tr>
<td colSpan={4} className="muted">
No welds on the weld map.
</td>
</tr>
)}
</tbody>
</table>
</div>
</section>
<section className="sec" aria-label="NDE & testing">
<div className="sec-h">
<h2>NDE &amp; testing</h2>
</div>
{ed ? (
<>
<div className="frow qcnde">
<label className="f">
NDE method
<select aria-label="NDE method" className="in" value={method} onChange={(e) => void setField("nde_method", e.target.value)}>
{NDE_METHODS.map((m) => (
<option key={m} value={m}>
{m}
</option>
))}
</select>
</label>
<label className="f">
NDE result
<select aria-label="NDE result" className="in" value={q.nde_result ?? ""} onChange={(e) => void setField("nde_result", e.target.value)}>
{NDE_RESULTS.map((m) => (
<option key={m} value={m}>
{m || "-"}
</option>
))}
</select>
</label>
{textInput("nde_report", "Report no.", "Optional", 60)}
{textInput("hardness", hardLabel, "e.g. 198 HB", 40)}
</div>
<label className="f qcnotes">
Inspection notes
<textarea
aria-label="Inspection notes"
className="in"
value={shown("notes")}
maxLength={2000}
onChange={(e) => setDraft((d) => ({ ...d, notes: e.target.value }))}
onBlur={() => void saveText("notes")}
/>
</label>
</>
) : (
<div className="stack tight">
<div className="small">
<span className="muted">NDE: </span>
{[method, q.nde_result, q.nde_report].filter(Boolean).join(" · ")}
</div>
<div className="small">
<span className="muted">{hardLabel}: </span>
{q.hardness || "-"}
</div>
{q.notes ? <div className="small">{q.notes}</div> : null}
</div>
)}
</section>
<Earlier rows={p.qc_history} />
</div>
);
}
+3 -4
View File
@@ -291,16 +291,15 @@ function ReviewBox({
const depts = useShopDepartments().filter((d) => d.code !== p.dept.code);
const [book, setBook] = useState(false);
const [spool, setSpool] = useState(false);
const [loc, setLoc] = useState(
String((p.qc as { location?: string } | null)?.location ?? ""),
);
// After a Q.C. reject the inspection is closed into qc_last; the open inspection (qc) is null.
const [loc, setLoc] = useState(String(p.qc?.location ?? p.qc_last?.location ?? ""));
const [note, setNote] = useState("");
const [ret, setRet] = useState("");
const [route, setRoute] = useState("");
const routeTo = depts.some((d) => d.code === route)
? route
: (depts[0]?.code ?? "");
const rejected = (p.qc as { result?: string } | null)?.result === "rejected";
const rejected = p.qc_last?.result === "rejected";
const ready = book && spool && !!loc.trim();
const returnIt = async () => {
+316
View File
@@ -0,0 +1,316 @@
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import type { PacketRead, QcInspection } from "../api/types";
import { DEPARTMENTS, makeMe, renderApp } from "../test/auth";
import { callsTo, mockFetch } from "../test/fetchMock";
import { makePacket } from "../test/packets";
const DEPTS = [...DEPARTMENTS, { code: "09", slug: "Hydro", name: "Hydro", kind: "shop", stations: [] }];
const ITEMS = [
{ group: "Data book package", key: "db_heats", text: "Heat number recorded for every BOM item", hint: "1/2 recorded" },
{ group: "Physical spool", key: "ph_dim", text: "Dimensions verified against CI-DWG-1 Rev 0", hint: null },
];
function inspection(over: Partial<QcInspection> = {}): QcInspection {
return {
id: 9,
sent_at: "2026-10-17T08:00:00Z",
book: true,
spool: true,
location: "Bay 2",
sent_note: "Rush",
nde_method: "None required",
nde_result: null,
nde_report: null,
hardness: null,
inspector: null,
stamp: null,
notes: null,
result: null,
result_at: null,
result_note: null,
row_version: 1,
checks: {},
weld_exam: {},
ncr: null,
nace_label: false,
missing: ["2 inspection items", "1 weld without a visual result", "inspector name"],
...over,
};
}
function qcPacket(over: Partial<PacketRead> = {}): PacketRead {
return makePacket({
status: "qc",
holder: "Q.C.",
qc: inspection(),
qc_items: ITEMS,
welds: [
{ weld_no: 1, page: 1, fx: 0.1, fy: 0.1, wo_base: "L999001", piece: 1, items: [1, 2], type: "Fillet", size: '5/16"', process: "GMAW", stamp: "75", at: null, row_version: 1, welder: null },
],
can: ["edit_qc", "qc_reject", "doc_add", "doc_remove"],
blocked: { qc_accept: ["Q.C. still needs: 2 inspection items, 1 weld without a visual result, inspector name."], qc_accept_and_route: ["Q.C. still needs: 2 inspection items."] },
...over,
});
}
function base(packet: PacketRead, roles = ["qc"], screens = ["qc"]) {
return {
"/api/auth/me": { body: makeMe({ roles, screens, user: { id: "quinn", display_name: "Quinn QC" } }) },
"/api/departments": { body: DEPTS },
"/api/packets/1": { body: packet },
"/api/packets/1/log": { body: [] },
};
}
async function sign(secret = "pw-1") {
const sheet = await screen.findByRole("dialog", { name: "Sign" });
fireEvent.change(within(sheet).getByLabelText("Password"), { target: { value: secret } });
fireEvent.click(within(sheet).getByRole("button", { name: "Sign" }));
await waitFor(() => expect(screen.queryByRole("dialog", { name: "Sign" })).not.toBeInTheDocument());
}
describe("Q.C. tab", () => {
it("each tick PUTs its key; the weld toggles PUT the result", async () => {
const p = qcPacket();
const after = qcPacket({ version: 4, qc: inspection({ checks: { db_heats: { done: true, at: "2026-10-17T09:00:00Z", stamp: "Q1" } } }) });
const fetch = mockFetch({
...base(p),
"PUT /api/packets/1/qc/checks/db_heats": { body: after },
"PUT /api/packets/1/qc/welds/1": { body: qcPacket({ version: 5, qc: inspection({ weld_exam: { "1": { result: "accept" } } }) }) },
});
renderApp("/packets/1");
fireEvent.click(await screen.findByRole("tab", { name: /^Q\.C\./ }));
expect(screen.getByText(/data book package ready, spool ready/)).toHaveTextContent("Spool location: Bay 2");
expect(screen.getByText("1/2 recorded")).toBeInTheDocument();
const box = screen.getByRole("checkbox", { name: "Heat number recorded for every BOM item" });
fireEvent.click(box);
await waitFor(() => expect(screen.getByRole("checkbox", { name: "Heat number recorded for every BOM item" })).toBeChecked());
expect(callsTo(fetch, "PUT", "/api/packets/1/qc/checks/db_heats")).toEqual([{ done: true }]);
expect(screen.getByText(/^Q1 · /)).toBeInTheDocument();
const row = screen.getByRole("row", { name: "Weld W1" });
expect(within(row).getByText("items 1 + 2 · L999001 #1 · GMAW · 75")).toBeInTheDocument();
fireEvent.click(within(row).getByRole("button", { name: "Accept" }));
await waitFor(() => expect(within(screen.getByRole("row", { name: "Weld W1" })).getByRole("button", { name: "Accept" })).toHaveAttribute("aria-pressed", "true"));
expect(callsTo(fetch, "PUT", "/api/packets/1/qc/welds/1")).toEqual([{ result: "accept" }]);
expect(screen.getByText("1 weld · open the Weld map tab to find them")).toBeInTheDocument();
});
it("saves the inspector on blur with its base", async () => {
const fetch = mockFetch({ ...base(qcPacket()), "PATCH /api/packets/1/qc": { body: qcPacket({ version: 4, qc: inspection({ inspector: "Quinn QC" }) }) } });
renderApp("/packets/1");
fireEvent.click(await screen.findByRole("tab", { name: /^Q\.C\./ }));
const f = screen.getByLabelText("Inspector");
fireEvent.change(f, { target: { value: "Quinn QC" } });
fireEvent.blur(f);
await waitFor(() => expect(callsTo(fetch, "PATCH", "/api/packets/1/qc")).toEqual([{ set: { inspector: "Quinn QC" }, base: { inspector: null } }]));
});
it("is read-only without edit_qc and lists earlier inspections", async () => {
const p = qcPacket({
can: [],
blocked: {},
qc_history: [{ id: 1, at: "2026-10-16T08:00:00Z", result: "rejected", inspector: "Quinn QC", note: "NCR-2026-0021 · undercut", dept_code: "03", ncr: "NCR-2026-0021", rejected_welds: ["W1", "W3"] }],
});
mockFetch(base(p, ["shop:03:supervisor"], ["supervisor"]));
renderApp("/packets/1");
fireEvent.click(await screen.findByRole("tab", { name: /^Q\.C\./ }));
for (const box of screen.getAllByRole("checkbox")) expect(box).toBeDisabled();
expect(screen.queryByRole("button", { name: "Accept" })).not.toBeInTheDocument();
expect(screen.queryByLabelText("Inspector")).not.toBeInTheDocument();
expect(screen.getByText(/REJECTED by Quinn QC: undercut · NCR-2026-0021 · welds W1, W3$/)).toBeInTheDocument();
});
it("is not shown without an inspection or history", async () => {
mockFetch(base(makePacket()));
renderApp("/packets/1");
await screen.findByRole("tab", { name: /^Materials/ });
expect(screen.queryByRole("tab", { name: /^Q\.C\./ })).not.toBeInTheDocument();
});
});
describe("Q.C. bar", () => {
it("lists what is still needed and keeps Accept disabled", async () => {
mockFetch(base(qcPacket()));
renderApp("/packets/1");
const bar = await screen.findByRole("region", { name: "Q.C. inspection" });
expect(within(bar).getByTestId("qc-missing")).toHaveTextContent("Still needed: 2 inspection items · 1 weld without a visual result · inspector name");
const accept = within(bar).getByRole("button", { name: "Accept" });
expect(accept).toBeDisabled();
expect(accept).toHaveAttribute("title", "Q.C. still needs: 2 inspection items, 1 weld without a visual result, inspector name.");
const route = within(bar).getByLabelText("Route");
expect(within(route).getAllByRole("option").map((o) => o.textContent)).toEqual(["Clerical – Data Book", "09 Hydro", "14 Electrical", "Shipping"]);
fireEvent.click(within(bar).getByRole("button", { name: "inspector name" }));
expect(await screen.findByLabelText("Inspector")).toBeInTheDocument();
});
it("accepts to the data book after the signature", async () => {
const p = qcPacket({ qc: inspection({ missing: [] }), can: ["edit_qc", "qc_accept", "qc_accept_and_route", "qc_reject"], blocked: {} });
const fetch = mockFetch({
...base(p),
"POST /api/packets/1/transitions": { body: makePacket({ status: "complete", version: 6, routed_to: "databook", can: [], blocked: {}, holder: "Clerical" }) },
});
renderApp("/packets/1");
const bar = await screen.findByRole("region", { name: "Q.C. inspection" });
expect(within(bar).queryByTestId("qc-missing")).not.toBeInTheDocument();
fireEvent.click(within(bar).getByRole("button", { name: "Accept" }));
expect(await within(await screen.findByRole("dialog", { name: "Sign" })).findByText("Q.C. accept")).toBeInTheDocument();
await sign();
expect(callsTo(fetch, "POST", "/api/packets/1/transitions")).toEqual([
{ action: "qc_accept", expect: { status: "qc", dept: "03" }, routed_to: "databook", sign: { factor: "password", secret: "pw-1" } },
]);
await waitFor(() => expect(screen.getByTestId("status-pill")).toHaveTextContent("Routed"));
});
it("accepts and routes to a department", async () => {
const p = qcPacket({ qc: inspection({ missing: [] }), can: ["qc_accept", "qc_accept_and_route", "qc_reject"], blocked: {} });
const fetch = mockFetch({ ...base(p), "POST /api/packets/1/transitions": { body: makePacket({ status: "ready", version: 6, can: [], blocked: {} }) } });
renderApp("/packets/1");
const bar = await screen.findByRole("region", { name: "Q.C. inspection" });
fireEvent.change(within(bar).getByLabelText("Route"), { target: { value: "DEPT:09" } });
fireEvent.click(within(bar).getByRole("button", { name: "Accept" }));
expect(await within(await screen.findByRole("dialog", { name: "Sign" })).findByText("Q.C. accept and route")).toBeInTheDocument();
await sign();
expect(callsTo(fetch, "POST", "/api/packets/1/transitions")).toEqual([
{ action: "qc_accept_and_route", expect: { status: "qc", dept: "03" }, dept: "09", sign: { factor: "password", secret: "pw-1" } },
]);
});
it("the reject sheet enables on an NCR alone and sends it upper-cased", async () => {
const fetch = mockFetch({ ...base(qcPacket()), "POST /api/packets/1/transitions": { body: makePacket({ status: "review", version: 6, can: [], blocked: {} }) } });
renderApp("/packets/1");
const bar = await screen.findByRole("region", { name: "Q.C. inspection" });
fireEvent.click(within(bar).getByRole("button", { name: "Reject" }));
const sheet = await screen.findByRole("dialog", { name: "Reject" });
expect(within(sheet).getByText("Give a reason, an NCR number, or both. The supervisor sees it at the top of the packet.")).toBeInTheDocument();
const submit = within(sheet).getByRole("button", { name: "Reject to supervisor" });
expect(submit).toBeDisabled();
fireEvent.change(within(sheet).getByLabelText("Reason / what failed"), { target: { value: "short" } });
expect(submit).toBeDisabled();
const ncr = within(sheet).getByLabelText("NCR number");
expect(ncr).toHaveAttribute("placeholder", "NCR-2026-0021");
fireEvent.change(within(sheet).getByLabelText("Reason / what failed"), { target: { value: "" } });
fireEvent.change(ncr, { target: { value: "ncr-2026-0021" } });
expect(submit).toBeEnabled();
fireEvent.click(submit);
expect(await within(await screen.findByRole("dialog", { name: "Sign" })).findByText("Q.C. reject")).toBeInTheDocument();
await sign();
expect(callsTo(fetch, "POST", "/api/packets/1/transitions")).toEqual([
{ action: "qc_reject", expect: { status: "qc", dept: "03" }, ncr: "NCR-2026-0021", sign: { factor: "password", secret: "pw-1" } },
]);
});
});
describe("Callouts", () => {
const hold = {
no: "L999001-ITP",
count: 5,
rejected: true,
pages: ["WP"] as string[] | null,
items: [
{ n: "3.4", t: "Fit-up", codes: ["W"], rejected: false, ncr: null },
{ n: "4.2", t: "Prior to vessel closing", codes: ["H"], rejected: false, ncr: null },
{ n: "4.4", t: "Hydro", codes: ["H"], rejected: true, ncr: "NCR-2026-0021" },
{ n: "4.5", t: "Final", codes: ["H"], rejected: true, ncr: null },
{ n: "5.1", t: "Paint", codes: ["W"], rejected: false, ncr: null },
],
};
it("the hold callout names the points and links Q.C. users to the ITP", async () => {
mockFetch(base(makePacket({ status: "ready", can: [], blocked: {}, itp_hold: hold })));
renderApp("/packets/1");
const note = await screen.findByText(/^ITP L999001-ITP:/);
expect(note).toHaveTextContent("ITP L999001-ITP: 5 open hold / witness or rejected points for this stage.");
const box = note.closest(".itphold");
expect(box).toHaveTextContent(
"3.4 Fit-up (W) · 4.2 Prior to vessel closing (H) · 4.4 Hydro (rejected, NCR NCR-2026-0021) · 4.5 Final (rejected, NCR pending) · and 1 more. Check with Q.C. before moving past these points.",
);
expect(screen.getByRole("link", { name: "Open ITP" })).toHaveAttribute("href", "/qc/units/11/itp");
});
it("the hold callout without pages says nothing about the stage, and no link without the qc screen", async () => {
mockFetch(base(makePacket({ status: "ready", can: [], blocked: {}, itp_hold: { ...hold, pages: null, rejected: false, count: 1, items: hold.items.slice(0, 1) } }), ["shop:03:supervisor"], ["supervisor"]));
renderApp("/packets/1");
expect(await screen.findByText(/^ITP L999001-ITP:/)).toHaveTextContent(/^ITP L999001-ITP: 1 open hold \/ witness point\.$/);
expect(screen.queryByRole("link", { name: "Open ITP" })).not.toBeInTheDocument();
});
it("the Q.C. rejected callout shows the note, the NCR link and the welds", async () => {
const p = makePacket({
status: "review",
can: [],
blocked: {},
return_note: "NCR-2026-0021 · undercut at W1",
qc_history: [{ id: 1, at: "2026-10-16T08:00:00Z", result: "rejected", inspector: "Quinn QC", note: "NCR-2026-0021 · undercut at W1", dept_code: "03", ncr: "NCR-2026-0021", rejected_welds: ["W1"] }],
qc_last: { id: 1, at: "2026-10-16T08:00:00Z", result: "rejected", inspector: "Quinn QC", note: "NCR-2026-0021 · undercut at W1", dept_code: "03", ncr: "NCR-2026-0021", rejected_welds: ["W1"] },
});
mockFetch(base(p, ["shop:03:supervisor"], ["supervisor"]));
renderApp("/packets/1");
const note = await screen.findByText(/^Q\.C\. rejected/);
const box = note.closest(".callout") as HTMLElement;
expect(box).toHaveTextContent(/by Quinn QC: undercut at W1 NCR NCR-2026-0021Rejected welds: W1$/);
const link = within(box).getByRole("link", { name: "NCR NCR-2026-0021" });
expect(link).toHaveAttribute("href", "https://ncr.pescoinc.biz/ncrs/NCR-2026-0021");
expect(link).toHaveAttribute("rel", "noopener noreferrer");
expect(screen.queryByText(/^Returned:/)).not.toBeInTheDocument();
});
});
describe("Documents tab", () => {
const docs = [
{ id: 4, asset_id: "a4", kind: "nde", kind_label: "NDE report (RT / UT / MT / PT)", title: "RT report W1-W4", note: "RPT-2210, accept", file_name: "rt.pdf", size: 900, added_at: "2026-10-17T08:00:00Z", added_by: { id: "quinn", display_name: "Quinn QC" }, url: "/_blob/a4", removed: null },
{ id: 5, asset_id: "a5", kind: "other", kind_label: "Other", title: "Old note", file_name: "o.pdf", added_at: "2026-10-16T08:00:00Z", added_by: null, url: "/_blob/a5", removed: { at: "2026-10-16T09:00:00Z", by: { id: "quinn", display_name: "Quinn QC" }, reason: "wrong packet" } },
];
it("takes a document out with the reason, and lists the taken-out rows", async () => {
const p = qcPacket({ docs, qc_folder_docs: [{ id: 1, type: "WPS / PQR", title: "WPS-101", rev: "2", url: "/_blob/w1", scope: "project", added_at: null }] });
const fetch = mockFetch({ ...base(p), "DELETE /api/packets/1/docs/4": { body: qcPacket({ version: 4 }) } });
renderApp("/packets/1");
fireEvent.click(await screen.findByRole("tab", { name: /^Documents/ }));
expect(screen.getByRole("tab", { name: /^Documents/ })).toHaveTextContent("Documents1");
const sec = screen.getByRole("region", { name: "Documents for the data book" });
expect(within(sec).getByText("1 on this packet")).toBeInTheDocument();
expect(within(sec).getByText(/rt\.pdf · added by Quinn QC .* · RPT-2210, accept/)).toBeInTheDocument();
expect(within(sec).getByRole("button", { name: "Add document" })).toBeInTheDocument();
fireEvent.click(within(sec).getByRole("button", { name: "Show 1 taken out" }));
expect(within(sec).getByText(/Taken out of the data book by Quinn QC .*: wrong packet/)).toBeInTheDocument();
const folder = screen.getByRole("region", { name: "Also in the data book from the Q.C. folder" });
expect(within(folder).getByText("from the project")).toBeInTheDocument();
fireEvent.click(within(sec).getByRole("button", { name: "Take out of data book" }));
const sheet = await screen.findByRole("dialog", { name: "Take out of the data book" });
fireEvent.change(within(sheet).getByLabelText("Reason"), { target: { value: "wrong packet" } });
fireEvent.click(within(sheet).getByRole("button", { name: "Take out" }));
await waitFor(() => expect(callsTo(fetch, "DELETE", "/api/packets/1/docs/4")).toEqual([{ reason: "wrong packet" }]));
});
it("adds a document: uploads, then posts the kind, title and note", async () => {
const fetch = mockFetch({
...base(qcPacket()),
"POST /api/assets": { status: 201, body: { id: "a9", mime: "application/pdf", size: 9, sha256: "00", pages: 1, original_name: "rt.pdf", url: "/_blob/a9" } },
"POST /api/packets/1/docs": { body: qcPacket({ version: 4, docs }) },
});
renderApp("/packets/1");
fireEvent.click(await screen.findByRole("tab", { name: /^Documents/ }));
fireEvent.click(screen.getByRole("button", { name: "Add document" }));
const sheet = await screen.findByRole("dialog", { name: "Add a document" });
expect(within(within(sheet).getByLabelText("Type")).getAllByRole("option")).toHaveLength(9);
fireEvent.change(within(sheet).getByLabelText("Title"), { target: { value: "RT report W1-W4" } });
fireEvent.change(within(sheet).getByLabelText("Note (optional)"), { target: { value: "RPT-2210, accept" } });
const file = new File(["%PDF-1.4\n"], "rt.pdf", { type: "application/pdf" });
fireEvent.change(within(sheet).getByLabelText("File (PDF, PNG or JPG, up to 25 MB)"), { target: { files: [file] } });
fireEvent.click(within(sheet).getByRole("button", { name: "Add to packet" }));
await waitFor(() => expect(screen.queryByRole("dialog", { name: "Add a document" })).not.toBeInTheDocument());
expect(callsTo(fetch, "POST", "/api/packets/1/docs")).toEqual([{ asset_id: "a9", kind: "nde", title: "RT report W1-W4", note: "RPT-2210, accept" }]);
});
it("shows no add button without doc_add and an empty folder text", async () => {
mockFetch(base(qcPacket({ can: [], blocked: {} }), ["warehouse"], ["warehouse"]));
renderApp("/packets/1");
fireEvent.click(await screen.findByRole("tab", { name: /^Documents/ }));
expect(screen.queryByRole("button", { name: "Add document" })).not.toBeInTheDocument();
expect(screen.getByText("No ITP or Q.C. folder documents for this unit yet. Q.C. adds them under Project folders on the Q.C. screen.")).toBeInTheDocument();
});
});
+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>