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
+775
View File
@@ -0,0 +1,775 @@
import { useEffect, useRef, useState, type FormEvent } from "react";
import { Link, useParams } from "react-router";
import { ApiError } from "../../api/client";
import {
enterNcr,
getItp,
getUnitFolder,
patchBlock,
patchItp,
patchLine,
rejectLine,
reopenItp,
sectionNa,
signBlock,
signLine,
signOff,
unsignBlock,
unsignLine,
type HeaderField,
type Itp,
type ItpBlock,
type ItpItem,
type ItpLine,
type LinePatch,
type QcWho,
} from "../../api/qc";
import type { Guarded } from "../../api/types";
import { useAuth } from "../../auth/useAuth";
import { Sheet } from "../../components/Sheet";
import { useGuard, type RunOptions } from "../../components/useGuard";
import { useChanged } from "../../lib/changes";
import { errorText, fmtTime } from "../../lib/format";
import { useLoad } from "../../lib/useLoad";
import { fmtQcDate } from "./folders";
import {
blockScope,
initials,
isApproved,
isNa,
isOpenHold,
isRejected,
ITP_CUST_CODES,
ITP_PAGE_ORDER,
ITP_PAGES,
lastReject,
lineOf,
progressText,
sectionsOf,
todayIso,
} from "./itpView";
/** What a write came to. A 409 reloads the ITP and shows the server's text. */
type Outcome = "ok" | "cancel" | "conflict" | { error: string };
type LineSheetState = { kind: "line" | "reject" | "ncr"; key: string } | null;
/** An input saved on blur when its value changed. */
function BlurInput({
label,
value,
disabled,
maxLength,
required = false,
onSave,
}: {
label: string;
value: string;
disabled: boolean;
maxLength: number;
/** An empty value goes back to the saved one. */
required?: boolean;
onSave: (next: string, prev: string) => Promise<boolean>;
}) {
const [v, setV] = useState(value);
const focused = useRef(false);
useEffect(() => {
if (!focused.current) setV(value);
}, [value]);
const blur = async () => {
focused.current = false;
const next = v.trim();
if (next === value || (required && !next)) {
setV(value);
return;
}
const ok = await onSave(next, value);
if (!ok) setV(value);
};
return (
<label className="f">
{label}
<input
className="in"
aria-label={label}
value={v}
disabled={disabled}
maxLength={maxLength}
onFocus={() => {
focused.current = true;
}}
onChange={(e) => setV(e.target.value)}
onBlur={() => void blur()}
/>
</label>
);
}
/** The line details sheet (the old openItpLine): N/A, the customer code and witness, the note. */
function LineSheet({ item, line, onSave, onCancel }: { item: ItpItem; line: ItpLine; onSave: (p: LinePatch) => Promise<Outcome>; onCancel: () => void }) {
const approved = isApproved(line);
const rej = isRejected(line);
const [na, setNa] = useState(isNa(line));
const [cc, setCc] = useState(line.cust_code ?? "");
const [cby, setCby] = useState(line.cust_by ?? "");
const [cat, setCat] = useState(line.cust_at ? line.cust_at.slice(0, 10) : todayIso());
const [note, setNote] = useState(line.note ?? "");
const [busy, setBusy] = useState(false);
const [err, setErr] = useState<string | null>(null);
const submit = async (e: FormEvent) => {
e.preventDefault();
const p: LinePatch = {};
if (!approved && !rej && na !== isNa(line)) p.na = na;
if (cc !== (line.cust_code ?? "")) p.cust_code = cc;
const by = cby.trim();
if (by !== (line.cust_by ?? "")) p.cust_by = by;
if (by && (p.cust_by !== undefined || cat !== (line.cust_at ?? "").slice(0, 10))) p.cust_at = cat || null;
const n = note.trim();
if (n !== (line.note ?? "")) p.note = n;
if (!Object.keys(p).length) {
onCancel();
return;
}
setBusy(true);
setErr(null);
const o = await onSave(p);
if (typeof o === "object") {
setErr(o.error);
setBusy(false);
} else if (o === "cancel") setBusy(false);
};
return (
<Sheet title="ITP line" sub={`${item.n} ${item.t}`} onClose={onCancel} wide>
<div className="small muted">{item.crit}</div>
<div className="small">
PESCO code <b>{item.code || "-"}</b> · customer code on the template <b>{item.cust || "-"}</b>
</div>
<form className="stack" onSubmit={submit} noValidate>
<label className="itpck">
<input type="checkbox" checked={na} disabled={approved || rej} onChange={(e) => setNa(e.target.checked)} />
Not applicable to this unit{approved ? " (undo the approval first)" : rej ? " (line is rejected)" : ""}
</label>
<div className="frow">
<label className="f">
Customer code
<select className="in" aria-label="Customer code" value={cc} onChange={(e) => setCc(e.target.value)}>
<option value="">as template ({item.cust || "-"})</option>
{ITP_CUST_CODES.map((c) => (
<option key={c} value={c}>
{c}
</option>
))}
</select>
</label>
<label className="f">
Customer witness / reviewer
<input
className="in"
aria-label="Customer witness / reviewer"
maxLength={80}
placeholder="Name (leave blank if not witnessed)"
value={cby}
onChange={(e) => setCby(e.target.value)}
/>
</label>
<label className="f">
Date
<input className="in" aria-label="Date" type="date" value={cat} onChange={(e) => setCat(e.target.value)} />
</label>
</div>
<label className="f">
Note
<textarea className="in" aria-label="Note" rows={2} maxLength={500} value={note} onChange={(e) => setNote(e.target.value)} />
</label>
{err ? (
<div className="callout warn flush" role="alert">
{err}
</div>
) : null}
<div className="sheet-acts">
<button className="btn ghost" type="button" onClick={onCancel}>
Cancel
</button>
<button className="btn pri" type="submit" disabled={busy}>
Save
</button>
</div>
</form>
</Sheet>
);
}
/** The reject sheet, or the NCR sheet for a reject without its number (the old openItpReject). */
function RejectSheet({
item,
line,
ncrOnly,
onSave,
onCancel,
}: {
item: ItpItem;
line: ItpLine;
ncrOnly: boolean;
onSave: (ncr: string, note: string) => Promise<Outcome>;
onCancel: () => void;
}) {
const [ncr, setNcr] = useState("");
const [note, setNote] = useState("");
const [busy, setBusy] = useState(false);
const [err, setErr] = useState<string | null>(null);
const lr = lastReject(line);
const submit = async (e: FormEvent) => {
e.preventDefault();
const n = ncr.trim();
if (ncrOnly && !n) {
setErr("Enter the NCR #.");
return;
}
setBusy(true);
setErr(null);
const o = await onSave(n, note.trim());
if (typeof o === "object") {
setErr(o.error);
setBusy(false);
} else if (o === "cancel") setBusy(false);
};
return (
<Sheet title={ncrOnly ? `NCR # for ${item.n} ${item.t}` : "Reject check point"} sub={ncrOnly ? undefined : `${item.n} ${item.t}`} onClose={onCancel}>
{ncrOnly && lr ? (
<div className="small muted">
Rejected {fmtQcDate(lr.at)}
{lr.by ? ` by ${lr.by.display_name}` : ""}
{lr.note ? `: ${lr.note}` : ""}
</div>
) : (
<div className="small muted">{item.crit}</div>
)}
<form className="stack" onSubmit={submit} noValidate>
<label className="f">
NCR #
<input
className="in"
aria-label="NCR #"
maxLength={40}
autoComplete="off"
placeholder={ncrOnly ? "NCR number" : "NCR number (can be added later)"}
value={ncr}
onChange={(e) => setNcr(e.target.value)}
/>
</label>
{ncrOnly ? null : (
<label className="f">
Reason / what failed
<textarea className="in" aria-label="Reason / what failed" rows={2} maxLength={500} value={note} onChange={(e) => setNote(e.target.value)} />
</label>
)}
<div className="small muted">The line can't be approved until its NCR # is entered.</div>
{err ? (
<div className="callout warn flush" role="alert">
{err}
</div>
) : null}
<div className="sheet-acts">
<button className="btn ghost" type="button" onClick={onCancel}>
Cancel
</button>
<button className={ncrOnly ? "btn pri" : "btn rejb"} type="submit" disabled={busy}>
{ncrOnly ? "Save NCR #" : "Reject"}
</button>
</div>
</form>
</Sheet>
);
}
/** The customer complete inputs on a block. */
function CustRecord({ onRecord }: { onRecord: (name: string, date: string) => Promise<boolean> }) {
const [name, setName] = useState("");
const [date, setDate] = useState(todayIso());
const [busy, setBusy] = useState(false);
const record = async () => {
setBusy(true);
const ok = await onRecord(name.trim(), date);
setBusy(false);
if (ok) setName("");
};
return (
<span className="custrec">
<input className="in" aria-label="Customer rep name" placeholder="Customer rep name" maxLength={80} value={name} onChange={(e) => setName(e.target.value)} />
<input className="in" aria-label="Customer complete date" type="date" value={date} onChange={(e) => setDate(e.target.value)} />
<button type="button" className="btn sm" disabled={busy || !name.trim()} onClick={() => void record()}>
Record
</button>
</span>
);
}
/** /qc/units/:unitId/itp (the old itpView). */
export function ItpPage() {
const { unitId } = useParams();
const uid = Number(unitId);
const { me } = useAuth();
const roles = me?.roles ?? [];
const isAdmin = roles.includes("admin");
const guard = useGuard();
const [load, reload] = useLoad((s) => getItp(uid, s), String(uid));
const [folderLoad] = useLoad((s) => getUnitFolder(uid, s), String(uid));
const [fresh, setFresh] = useState<Itp | null>(null);
const [sheet, setSheet] = useState<LineSheetState>(null);
const [showLog, setShowLog] = useState(false);
const [msg, setMsg] = useState<string | null>(null);
useChanged(reload, { delayMs: 500, filter: (ev) => ev.kind === "itp" && Number(ev.unit_id) === uid });
useEffect(() => {
if (!msg) return;
const t = window.setTimeout(() => setMsg(null), 8000);
return () => window.clearTimeout(t);
}, [msg]);
const loaded = load.state === "ok" ? load.data : null;
// A write's answer is the newest copy until a reload brings one as new or newer.
const r = fresh && fresh.unit_id === uid && (!loaded || fresh.version > loaded.version) ? fresh : loaded;
const folder = folderLoad.state === "ok" ? folderLoad.data : null;
const projectId = folder?.project.id;
const projectNo = folder?.project.number ?? r?.hdr.pno ?? "";
const unitNo = folder?.unit.number ?? r?.hdr.unit ?? "";
const exec = async (fn: (g: Guarded) => Promise<Itp>, opts?: RunOptions): Promise<Outcome> => {
setMsg(null);
try {
const out = await guard.run(fn, opts);
if (out === undefined) return "cancel";
setFresh(out);
return "ok";
} catch (err) {
if (err instanceof ApiError && err.status === 409) {
reload();
setSheet(null);
setMsg(errorText(err));
return "conflict";
}
return { error: errorText(err) };
}
};
const write = async (fn: (g: Guarded) => Promise<Itp>, opts?: RunOptions): Promise<boolean> => {
const o = await exec(fn, opts);
if (typeof o === "object") setMsg(o.error);
return o === "ok";
};
const back = (
<Link className="back" to={`/qc/units/${uid}`}>
← Back
</Link>
);
const crumbs = (
<nav className="crumbs" aria-label="Where you are">
<Link className="back" to="/qc">
All project folders
</Link>
<span>›</span>
{projectId ? (
<Link className="back" to={`/qc/projects/${projectId}`}>
{projectNo}
</Link>
) : (
<span>{projectNo}</span>
)}
<span>›</span>
<Link className="back" to={`/qc/units/${uid}`}>
{unitNo || "Unit"}
</Link>
<span>›</span>
<b>ITP</b>
</nav>
);
if (!r) {
return (
<>
{back}
{crumbs}
{load.state === "loading" ? <div className="empty slim">Loading</div> : null}
{load.state === "error" ? <div className="callout warn">{load.message}</div> : null}
{load.state === "ok" ? (
<div className="itpcard">
<div className="grow">
<b>No digital ITP yet.</b>
<div className="small muted">Start it from the unit's Q.C. folder.</div>
</div>
<Link className="btn" to={`/qc/units/${uid}`}>
Open the unit folder
</Link>
</div>
) : null}
</>
);
}
const id = r.id;
const complete = r.status === "complete";
const ed = r.can.edit && !complete;
const mayUndo = (who: QcWho | null) => ed && (isAdmin || (!!who && who.id === me?.user.id));
const sections = r.template.sections;
const pages = r.pages.length
? r.pages
: ITP_PAGE_ORDER.filter((c) => sectionsOf(sections, c).length).map((c) => ({ code: c, name: ITP_PAGES[c] ?? c }));
const blockAt = new Map<string, ItpBlock>(r.blocks.map((b) => [b.key, b]));
const hdr = r.hdr;
const saveHdr = (field: HeaderField) => (next: string, prev: string) => write((g) => patchItp(id, { set: { [field]: next }, base: { [field]: prev } }, g));
const sheetItem = sheet ? sections.flatMap((s) => s.items).find((i) => i.n === sheet.key) : undefined;
const reopen = async () => {
const reason = await guard.askReason({ title: "Reopen ITP", sub: r.no, label: "Reason", min: 8, max: 300, submitLabel: "Reopen" });
if (reason === null) return;
await write((g) => reopenItp(id, { reason, ...(g.it_reason ? { it_reason: g.it_reason } : {}) }));
};
return (
<>
{back}
{crumbs}
<div className="dhead">
<div className="grow0" style={{ minWidth: 0 }}>
<div className="eyebrow">Q.C. · Inspection and Test Plan · {complete ? "Signed off" : "Open"}</div>
<h1>{r.no}</h1>
<div className="small muted">
{progressText({ ...r.progress, status: r.status })} · template {r.template.ver}
</div>
</div>
<div className="pacts">
{r.can.sign_off ? (
<button className="btn pri" type="button" onClick={() => void write((g) => signOff(id, g.sign, g), { sign: "Sign off the ITP", sub: r.no })}>
Sign off ITP
</button>
) : null}
{r.can.reopen ? (
<button className="btn ghost" type="button" onClick={() => void reopen()}>
Reopen
</button>
) : null}
</div>
</div>
{complete && r.signoff ? (
<div className="callout mb10" role="note">
Signed off {fmtQcDate(r.signoff.at)} by {r.signoff.by?.display_name ?? ""}. The ITP is locked; a signed PDF copy{" "}
{r.signoff.asset_id ? "is filed in this unit's documents." : "will be filed when the ITP PDF is built."}
</div>
) : null}
<section className="sec" aria-label="ITP header">
<div className="itphdr">
<BlurInput label="Inspection plan no." value={r.no} disabled={!ed} maxLength={120} required onSave={saveHdr("no")} />
<BlurInput label="Client" value={hdr.client} disabled={!ed} maxLength={120} onSave={saveHdr("client")} />
<BlurInput label="Project name" value={hdr.pname} disabled={!ed} maxLength={120} onSave={saveHdr("pname")} />
<BlurInput label="Project no." value={hdr.pno} disabled={!ed} maxLength={120} onSave={saveHdr("pno")} />
<BlurInput label="Purchase order" value={hdr.po} disabled={!ed} maxLength={120} onSave={saveHdr("po")} />
<BlurInput label="Unit no." value={hdr.unit} disabled={!ed} maxLength={120} onSave={saveHdr("unit")} />
</div>
<div className="small muted mt8">
<b>Codes:</b> {r.template.legend}
</div>
</section>
{pages.map((pg, pi) => {
const secs = sectionsOf(sections, pg.code);
if (!secs.length) return null;
return (
<div key={pg.code} className="itppagewrap">
<div className="itppage">
<span>
{pi + 1} of {pages.length} · {pg.code}
</span>{" "}
{pg.name}
</div>
{secs.map((s) => {
const lines = s.items.map((i) => lineOf(r, i.n));
const anyApproved = lines.some(isApproved);
const allNa = lines.length > 0 && lines.every(isNa);
const b = blockAt.get(s.n);
const hid = `itpsec-${s.n}`;
return (
<div key={s.n}>
<section className="sec itpsec" aria-labelledby={hid}>
<div className="sec-h">
<h2 id={hid}>
{s.n}. {s.t}
</h2>
<span className="grow" />
{ed && !anyApproved ? (
<button type="button" className="btn sm ghost" onClick={() => void write((g) => sectionNa(id, s.n, !allNa, g))}>
{allNa ? "Section applies" : "Section N/A"}
</button>
) : null}
</div>
<div className="itprows">
<div className="itprow hd" aria-hidden="true">
<span>No.</span>
<span>Inspection item · acceptance</span>
<span>PESCO</span>
<span>Q.C. approval</span>
<span>Customer</span>
<span />
</div>
{s.items.map((i) => {
const l = lineOf(r, i.n);
const na = isNa(l);
const approved = isApproved(l);
const rej = isRejected(l);
const lr = lastReject(l);
const ncr = l.ncr ?? lr?.ncr ?? null;
const rejTxt =
lr || rej ? (
<span className="rejtag">
{rej ? "Rejected" : "Was rejected"} {fmtQcDate(lr?.at)} ·{" "}
{ncr ? (
<>
NCR <b className="mono">{ncr}</b>
</>
) : (
<b>NCR # needed</b>
)}
</span>
) : null;
const cls = ["itprow", na ? "na" : "", isOpenHold(i, l) ? "hold" : "", rej ? "rej" : ""].filter(Boolean).join(" ");
let qc;
if (na) qc = <span className="muted">N/A</span>;
else if (approved)
qc = (
<>
<span className="ini" title={l.by?.display_name ?? undefined}>
{l.initials || initials(l.by?.display_name)}
</span>
<span className="small muted">Approved {fmtQcDate(l.at)}</span>
{mayUndo(l.by) ? (
<button type="button" className="lnk small" onClick={() => void write((g) => unsignLine(id, i.n, g))}>
undo
</button>
) : null}
{rejTxt}
</>
);
else if (ed)
qc = (
<>
{rejTxt}
<span className="apr">
<button
type="button"
className="btn sm pri"
disabled={l.ncr_missing}
title={l.ncr_missing ? "Enter the NCR # first" : undefined}
onClick={() => void write((g) => signLine(id, i.n, r.lines[i.n]?.row_version, g))}
>
Approve
</button>
{rej ? null : (
<button type="button" className="btn sm rejb" onClick={() => setSheet({ kind: "reject", key: i.n })}>
Reject
</button>
)}
{l.ncr_missing ? (
<button type="button" className="btn sm" onClick={() => setSheet({ kind: "ncr", key: i.n })}>
Enter NCR #
</button>
) : null}
</span>
</>
);
else qc = rejTxt ?? <span className="muted small">open</span>;
return (
<div key={i.n} className={cls} role="group" aria-label={`Line ${i.n}`}>
<span className="n mono">{i.n}</span>
<div className="t">
<b>{i.t}</b>
<div className="small muted">{i.crit}</div>
{l.note ? <div className="small">Note: {l.note}</div> : null}
</div>
<span className="cd" title="PESCO inspection code">
{i.code || "-"}
</span>
<div className="qc">{qc}</div>
<div className="cu" title="Customer code">
<span className="cd">{l.cust_code || i.cust || "-"}</span>
{l.cust_by ? (
<span className="small muted">
{l.cust_by} · {fmtQcDate(l.cust_at)}
</span>
) : null}
</div>
{ed ? (
<button type="button" className="btn sm ghost mb" aria-label={`Line ${i.n} details`} onClick={() => setSheet({ kind: "line", key: i.n })}>
⋯
</button>
) : (
<span />
)}
</div>
);
})}
</div>
</section>
{b ? <BlockView itpId={id} b={b} ed={ed} mayUndo={mayUndo} write={write} /> : null}
</div>
);
})}
</div>
);
})}
<section className="sec" aria-labelledby="itplog-h">
<div className="sec-h">
<h2 id="itplog-h">ITP log</h2>
<span className="ct">
{r.log.length} entr{r.log.length === 1 ? "y" : "ies"}
</span>
<span className="grow" />
<button type="button" className="btn sm ghost" aria-expanded={showLog} onClick={() => setShowLog((v) => !v)}>
{showLog ? "Hide log" : "Show log"}
</button>
</div>
{showLog ? (
r.log.length ? (
<ol className="itplog">
{r.log.map((e) => (
<li key={e.id} className="small">
{[fmtTime(e.at), e.by_name || e.by?.display_name || "", e.action, e.note ?? ""].filter(Boolean).join(" · ")}
</li>
))}
</ol>
) : (
<div className="empty slim">Nothing logged yet.</div>
)
) : null}
</section>
{sheet && sheetItem && sheet.kind === "line" ? (
<LineSheet
item={sheetItem}
line={lineOf(r, sheetItem.n)}
onCancel={() => setSheet(null)}
onSave={async (p) => {
const o = await exec((g) => patchLine(id, sheetItem.n, p, g));
if (o === "ok") setSheet(null);
return o;
}}
/>
) : null}
{sheet && sheetItem && sheet.kind !== "line" ? (
<RejectSheet
item={sheetItem}
line={lineOf(r, sheetItem.n)}
ncrOnly={sheet.kind === "ncr"}
onCancel={() => setSheet(null)}
onSave={async (ncr, note) => {
const o = await exec((g) =>
sheet.kind === "ncr"
? enterNcr(id, sheetItem.n, ncr, g)
: rejectLine(id, sheetItem.n, { ...(ncr ? { ncr } : {}), ...(note ? { note } : {}) }, g),
);
if (o === "ok") setSheet(null);
return o;
}}
/>
) : null}
{guard.sheet}
{msg ? (
<div className="callout warn itpmsg" role="alert">
{msg}{" "}
<button type="button" className="lnk small" onClick={() => setMsg(null)}>
Dismiss
</button>
</div>
) : null}
</>
);
}
/** The signature block after a signing section (the old itpBlockHtml). */
function BlockView({
itpId,
b,
ed,
mayUndo,
write,
}: {
itpId: number;
b: ItpBlock;
ed: boolean;
mayUndo: (who: QcWho | null) => boolean;
write: (fn: (g: Guarded) => Promise<Itp>, opts?: RunOptions) => Promise<boolean>;
}) {
const scope = b.scope || blockScope(b.sections);
return (
<div className="itpblock" role="group" aria-label={`ITP block ${b.key}`}>
<div className="frow">
<BlurInput label="QN #" value={b.qn ?? ""} disabled={!ed} maxLength={40} onSave={(v) => write((g) => patchBlock(itpId, b.key, { qn: v }, g))} />
<BlurInput label="Remarks / notes" value={b.remarks ?? ""} disabled={!ed} maxLength={400} onSave={(v) => write((g) => patchBlock(itpId, b.key, { remarks: v }, g))} />
</div>
<div className="sigs">
<div>
<div className="eyebrow">PESCO Q.C. signature · {scope}</div>
{b.signed ? (
<>
<b>{b.signed_by?.display_name ?? ""}</b> {b.signed_by?.emp_no ? <span className="mono small">{b.signed_by.emp_no}</span> : null}{" "}
<span className="small muted">{fmtQcDate(b.signed_at)} · signed</span>
{mayUndo(b.signed_by) ? (
<>
{" "}
<button type="button" className="lnk small" onClick={() => void write((g) => unsignBlock(itpId, b.key, g))}>
undo
</button>
</>
) : null}
</>
) : ed ? (
<>
<button
type="button"
className={b.ready ? "btn sm pri" : "btn sm"}
disabled={!b.ready}
onClick={() => void write((g) => signBlock(itpId, b.key, g.sign, g), { sign: "Sign an ITP block", sub: scope })}
>
{b.all_na ? "Sign (all N/A)" : "Sign section"}
</button>
{b.ready ? null : <span className="small muted"> every line must be initialed or N/A first</span>}
</>
) : (
<span className="muted small">not signed</span>
)}
</div>
<div>
<div className="eyebrow">{b.final ? "COP complete" : "Customer complete"}</div>
{b.cust_name ? (
<>
<b>{b.cust_name}</b>{" "}
<span className="small muted">
{fmtQcDate(b.cust_at)} · recorded by {b.cust_by?.display_name ?? ""}
</span>
{ed ? (
<>
{" "}
<button type="button" className="lnk small" onClick={() => void write((g) => patchBlock(itpId, b.key, { cust_clear: true }, g))}>
clear
</button>
</>
) : null}
</>
) : ed ? (
<CustRecord onRecord={(name, date) => write((g) => patchBlock(itpId, b.key, { cust_name: name, ...(date ? { cust_at: date } : {}) }, g))} />
) : (
<span className="muted small">-</span>
)}
</div>
</div>
</div>
);
}