Files
PacketStation/frontend/src/screens/qc/ItpPage.tsx
T
spencermandClaude Opus 5.5 70a97398f1
ci / server (pull_request) Successful in 12m48s
ci / frontend (pull_request) Successful in 54s
ci / image (pull_request) Skipped
ci / e2e (pull_request) Successful in 4m38s
Build Seth's changes C1 to C11 (Gitea issues 1, 8, 18, 23, 24, 25, 27, 31, 32, 33, 36)
- C1 (issue 1): over target follows VISUAL ticket hours, not the timer.
- C2 (issue 24): a Q.C. reject always needs a reason; accept needs an NCR number after a reject without one.
- C3 (issue 25): an incomplete unit ITP blocks filing.
- C4 (issue 27): prints open the print dialog on the page, no new tab.
- C5 (issue 31): filing is blocked while VISUAL shows a work order open; the feed sets vclosed on VISUAL packets.
- C6 (issue 8): the data book leaves out the history pages.
- C7 (issue 18): the supervisor approves each received short; accept and filing wait for it.
- C8 (issue 23): per-department checklists, edited by the department supervisor or Admin, frozen at START.
- C9 (issue 32): Q.C. and Clerical may run the stamp check.
- C10 (issue 33): the PM screen and reports are for PS-ProjectMgmt and Admin only.
- C11 (issue 36): the Stall screen lists the packets assigned to the welder, at the supervisor's stall.

Migration 0011. Docs: PLAN (also issue 9 confirmed, issue 4 asked), POLICY, SECURITY, SCHEMA, FEED, README, CHANGELOG.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 16:36:49 -06:00

811 lines
30 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useRef, useState, type FormEvent } from "react";
import { Link, useParams } from "react-router";
import { ApiError } from "../../api/client";
import {
enterNcr,
getItp,
getUnitFolder,
itpPdfUrl,
itpPrintUrl,
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 { printPdf } from "../../lib/printPdf";
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);
const [printing, setPrinting] = useState(false);
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;
// Change C4: the stamped copy goes straight to the browser's print dialog, on this page.
const print = async () => {
setMsg(null);
setPrinting(true);
try {
await printPdf(itpPrintUrl(id));
} catch {
setMsg("The print could not be prepared. Try again, or open the ITP (PDF) and print it from there.");
} finally {
setPrinting(false);
}
};
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">
<button className="btn sm" type="button" onClick={() => window.open(itpPdfUrl(id), "_blank", "noopener")}>
ITP (PDF)
</button>
<a className="btn sm ghost" href={itpPdfUrl(id, true)} download>
Download
</a>
<button className="btn sm" type="button" disabled={printing} onClick={() => void print()}>
{printing ? "Preparing print" : "Print"}
</button>
{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&apos;s documents.{" "}
<a className="lnk" href={`/_blob/${encodeURIComponent(r.signoff.asset_id)}`} target="_blank" rel="noopener">
Signed PDF
</a>
</>
) : (
"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>
);
}