Files
PacketStation/frontend/src/packet/tabs.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

792 lines
29 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 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, LogRow, PacketDoc, PacketRead, PrintRow, 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";
import { ShortSheet } from "../warehouse/ShortSheet";
import { assetOfUrl, kioskCode, openPrint, printLineText } from "./closeUtil";
import { ShortReceipts } from "./ShortReceipts";
import { lineReceipts } from "./shopUtil";
export type Mutate = (
fn: (g: Guarded) => Promise<PacketRead | null | undefined>,
opts?: { sign?: string | undefined; ok?: string | undefined },
) => Promise<boolean>;
const show = (v: unknown) => (v === null || v === undefined || v === "" ? "-" : String(v));
/* ---------- Materials ---------- */
interface LineDraft {
qty: string;
part_id: string;
description: string;
material: string;
length: string;
dwg_ref: string;
}
const toDraft = (b: BomLine): LineDraft => ({
qty: b.qty === null || b.qty === undefined ? "" : String(b.qty),
part_id: b.part_id ?? "",
description: b.description ?? "",
material: b.material ?? "",
length: b.length ?? "",
dwg_ref: b.dwg_ref ?? "",
});
const EMPTY_LINE: LineDraft = { qty: "", part_id: "", description: "", material: "", length: "", dwg_ref: "" };
/**
* The BOM. Clerical may edit the lines of a hand draft (PUT /bom, full replace). The warehouse gets the pull table
* while the packet is with it. Anyone who may receive a short gets Receive on the line.
*/
export function MaterialsTab({
p,
mutate,
busy,
write,
reload,
}: {
p: PacketRead;
mutate: Mutate;
busy: boolean;
write?: Write | undefined;
reload?: (() => Promise<void>) | undefined;
}) {
const editable = p.status === "draft" && p.source_kind === "hand" && p.can.includes("edit_bom");
const [lines, setLines] = useState<LineDraft[] | null>(null);
const [receiveFor, setReceiveFor] = useState<number | null>(null);
const [viewFor, setViewFor] = useState<number | null>(null);
const canReceive = p.can.includes("wh_receive_short") || p.can.includes("edit_bom");
const set = (i: number, k: keyof LineDraft, v: string) => setLines((rows) => (rows ?? []).map((r, j) => (j === i ? { ...r, [k]: v } : r)));
const save = async (e: FormEvent) => {
e.preventDefault();
if (!lines) return;
const bom: BomLine[] = lines
.filter((l) => l.part_id.trim() || l.description.trim())
.map((l, i) => ({
item: i + 1,
qty: l.qty.trim() === "" ? null : Number(l.qty),
part_id: l.part_id.trim() || null,
description: l.description.trim() || null,
material: l.material.trim() || null,
length: l.length.trim() || null,
dwg_ref: l.dwg_ref.trim() || null,
}));
const ok = await mutate((g) => packetsApi.putBom(p.id, p.version, bom, g), { ok: "BOM saved" });
if (ok) setLines(null);
};
if (lines) {
return (
<form className="stack" onSubmit={save} noValidate>
<div className="tw">
<table className="edit">
<thead>
<tr>
<th>Item</th>
<th className="num">Qty</th>
<th>Part ID</th>
<th>Description</th>
<th>Material</th>
<th>Length</th>
<th>Dwg ref</th>
<th />
</tr>
</thead>
<tbody>
{lines.map((l, i) => (
<tr key={i}>
<td className="mono">{i + 1}</td>
<td>
<input className="in sm num" aria-label={`Line ${i + 1} quantity`} inputMode="decimal" value={l.qty} onChange={(e) => set(i, "qty", e.target.value)} />
</td>
<td>
<input className="in sm mono" aria-label={`Line ${i + 1} part ID`} maxLength={60} value={l.part_id} onChange={(e) => set(i, "part_id", e.target.value)} />
</td>
<td>
<input className="in sm" aria-label={`Line ${i + 1} description`} maxLength={240} value={l.description} onChange={(e) => set(i, "description", e.target.value)} />
</td>
<td>
<input className="in sm" aria-label={`Line ${i + 1} material`} maxLength={120} value={l.material} onChange={(e) => set(i, "material", e.target.value)} />
</td>
<td>
<input className="in sm" aria-label={`Line ${i + 1} length`} maxLength={40} value={l.length} onChange={(e) => set(i, "length", e.target.value)} />
</td>
<td>
<input className="in sm" aria-label={`Line ${i + 1} drawing reference`} maxLength={60} value={l.dwg_ref} onChange={(e) => set(i, "dwg_ref", e.target.value)} />
</td>
<td>
<button className="btn sm ghost" type="button" aria-label={`Remove line ${i + 1}`} onClick={() => setLines((rows) => (rows ?? []).filter((_, j) => j !== i))}>
Remove
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="sheet-acts start">
<button className="btn sm" type="button" onClick={() => setLines((rows) => [...(rows ?? []), { ...EMPTY_LINE }])}>
Add line
</button>
<span className="grow" />
<button className="btn ghost" type="button" onClick={() => setLines(null)}>
Cancel
</button>
<button className="btn pri" type="submit" disabled={busy}>
Save BOM
</button>
</div>
</form>
);
}
if (p.status === "warehouse" && p.can.includes("edit_bom") && write && reload) {
return <PullTable p={p} write={write} reload={reload} busy={busy} />;
}
const receiving = receiveFor === null ? undefined : p.bom.find((b) => b.item === receiveFor);
const viewed = viewFor === null ? undefined : p.bom.find((b) => b.item === viewFor);
const receive = async (item: number, body: Parameters<typeof packetsApi.receiveShort>[2]): Promise<string | null> => {
if (!write) return "Receiving is not available here.";
const r = await write((g) => packetsApi.receiveShort(p.id, item, body, g));
if (r.ok || r.err === undefined) return null;
return errorText(r.err);
};
return (
<div className="stack">
{editable ? (
<div className="sec-h flush">
<span className="small muted">This packet was made by hand. Clerical can change its lines while it is a draft.</span>
<span className="grow" />
<button className="btn sm" type="button" onClick={() => setLines(p.bom.length ? p.bom.map(toDraft) : [{ ...EMPTY_LINE }])}>
Edit lines
</button>
</div>
) : null}
{(p.shorts_unapproved ?? 0) > 0 ? (
<div className="small due soon" data-testid="shorts-unapproved">
{p.shorts_unapproved === 1 ? "1 short is" : `${p.shorts_unapproved} shorts are`} still to receive or approve. The department supervisor approves every
received short before the packet can be complete.
</div>
) : null}
{p.bom.length ? (
<div className="tw">
<table>
<thead>
<tr>
<th>Item</th>
<th className="num">Qty</th>
<th>Part ID</th>
<th>Description</th>
<th>Material</th>
<th>Length</th>
<th>Heat</th>
<th>Pulled</th>
</tr>
</thead>
<tbody>
{p.bom.map((b) => (
<tr key={b.id ?? b.item} className={b.na ? "muted" : undefined}>
<td>
<span className="balloon">{b.item}</span>
</td>
<td className="num">{show(b.qty)}</td>
<td className="mono small">{show(b.part_id)}</td>
<td>
{show(b.description)}
{b.dwg_ref ? <div className="sub">Dwg ref {b.dwg_ref}</div> : null}
</td>
<td className="small">{show(b.material)}</td>
<td className="small">{show(b.length)}</td>
<td className="mono small">
{show(b.heat)}
{b.mtr_asset_id ? (
<>
{" "}
<button type="button" className="lnk small" onClick={() => setViewFor(b.item)}>
MTR
</button>
{!p.removed ? (
<>
{" "}
<button
type="button"
className="btn sm ghost"
title={`Print the MTR of item ${b.item}`}
onClick={() => openPrint(p.id, "doc", { asset: b.mtr_asset_id ?? undefined })}
>
Print
</button>
</>
) : null}
</>
) : null}
</td>
<td>
{b.short ? (
<div className="stack tight">
<ShortChip s={b.short} />
{canReceive && write ? (
<button className="btn sm pri" type="button" disabled={busy} onClick={() => setReceiveFor(b.item)}>
Receive
</button>
) : null}
<ShortReceipts p={p} receipts={lineReceipts(p, b)} item={b.item} busy={busy} mutate={mutate} />
</div>
) : lineReceipts(p, b).length ? (
<div className="stack tight">
<span className="pill st-ok">Short received</span>
<ShortReceipts p={p} receipts={lineReceipts(p, b)} item={b.item} busy={busy} mutate={mutate} />
</div>
) : b.na ? (
<span className="pill st-filed">N/A</span>
) : b.pulled ? (
<span className="pill st-ok">Pulled</span>
) : (
<span className="muted small">Not yet</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="empty">No BOM lines on this packet.</div>
)}
{receiving ? (
<ShortSheet mode="receive" line={receiving} onClose={() => setReceiveFor(null)} onReceive={(body) => receive(receiving.item, body)} />
) : null}
{viewed?.mtr_asset_id ? (
<MtrViewer assetId={viewed.mtr_asset_id} title={`MTR · item ${viewed.item}${viewed.heat ? ` · ${viewed.heat}` : ""}`} onClose={() => setViewFor(null)} />
) : null}
</div>
);
}
/* ---------- Work orders ---------- */
function woKey(w: Pick<WorkOrder, "wo_base" | "lot" | "split" | "sub">): string {
return `${w.wo_base}/${w.lot}/${w.split}/${w.sub}`;
}
function Qr({ text }: { text: string }) {
const ref = useRef<HTMLCanvasElement>(null);
const [failed, setFailed] = useState(false);
useEffect(() => {
const c = ref.current;
if (!c) return;
let ok = true;
try {
QRCode.toCanvas(c, text, { width: 128, margin: 1, errorCorrectionLevel: "M" }).catch(() => {
if (ok) setFailed(true);
});
} catch {
setFailed(true);
}
return () => {
ok = false;
};
}, [text]);
return (
<div className="code">
{failed ? <span className="small muted">No QR</span> : <canvas ref={ref} role="img" aria-label={`QR code ${text}`} />}
</div>
);
}
/** The work orders, the kiosk QR code of each (as the old ticketsTab), and the traveler print. */
export function WorkOrdersTab({ p }: { p: PacketRead }) {
if (!p.work_orders.length) return <div className="empty">No work orders on this packet.</div>;
return (
<div className="stack">
<div className="callout flush">
<div className="acts center">
<span className="grow">
Scan a code at the labor kiosk to clock on in Visual. Codes are rebuilt from the packet (WO · lot · split · sub · seq), same as the printed overlay.
</span>
{!p.removed ? (
<>
<button className="btn" type="button" onClick={() => openPrint(p.id, "traveler")}>
Print traveler
</button>
<a className="btn ghost" href={packetsApi.pdfUrl(p.id, "traveler")} target="_blank" rel="noopener">
Traveler (PDF)
</a>
</>
) : null}
</div>
</div>
<div className="tw">
<table>
<thead>
<tr>
<th>WO</th>
<th>Lot</th>
<th>Split</th>
<th>Sub</th>
<th>Seq</th>
<th>Operation</th>
<th className="num">Qty</th>
<th className="num">Target hrs</th>
</tr>
</thead>
<tbody>
{p.work_orders.map((w) => (
<tr key={w.id ?? woKey(w)} className={w.gone_at ? "muted" : undefined}>
<td className="mono">{w.wo_base}</td>
<td className="mono">{w.lot}</td>
<td className="mono">{w.split}</td>
<td className="mono">{w.sub}</td>
<td className="mono">{show(w.seq)}</td>
<td>{show(w.op)}</td>
<td className="num">{show(w.qty)}</td>
<td className="num">{show(w.target_hrs)}</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="qrs">
{p.work_orders.map((w) => {
const code = kioskCode(w);
return (
<div key={w.id ?? woKey(w)} className="qr">
<Qr text={code} />
<div className="txt">
<div className="wo">{w.wo_base}</div>
<div className="small">
Lot <b className="mono">{w.lot}</b> · Seq <b className="mono">{show(w.seq)}</b> {w.op ?? ""}
</div>
<div className="small">
Target <b className="mono">{show(w.target_hrs)} h</b> · Qty <b className="mono">{show(w.qty)}</b>
</div>
<div className="mono small" data-testid="kiosk-code">
{code}
</div>
</div>
</div>
);
})}
</div>
</div>
);
}
/* ---------- Documents ---------- */
type AskReason = (opts: ReasonOptions) => Promise<string | null>;
const openBlob = (url: string) => {
window.open(url, "_blank", "noopener,noreferrer");
};
/** 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 [working, setWorking] = useState(false);
const add = async (e: FormEvent) => {
e.preventDefault();
setError(null);
if (!file) {
setError("Choose the file to add.");
return;
}
setWorking(true);
try {
const asset = await assetsApi.upload(file, { packet_id: p.id, purpose: "document" });
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 {
setWorking(false);
}
};
return (
<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>
</form>
</Sheet>
);
}
function DocRow({
d,
old,
canRemove,
busy,
onOut,
onPrint,
}: {
d: PacketDoc;
old: boolean;
canRemove: boolean;
busy: boolean;
onOut: () => void;
onPrint?: (() => void) | undefined;
}) {
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>
) : null}
</div>
<div className="a">
<button className="btn sm" type="button" onClick={() => openBlob(d.url)}>
View
</button>
{!old && onPrint ? (
<button className="btn sm" type="button" onClick={onPrint}>
Print
</button>
) : null}
{!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)}
onPrint={p.removed ? undefined : () => openPrint(p.id, "doc", { asset: d.asset_id })}
/>
))
) : (
<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>
) : 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>
{!p.removed ? (
<button className="btn sm" type="button" onClick={() => openPrint(p.id, "doc", { asset: d.asset_id ?? assetOfUrl(d.url) })}>
Print
</button>
) : null}
</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. 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}`);
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>
)}
<PrintsList p={p} />
</div>
);
}
/** GET /prints: the printed copies and exports of this packet, newest first. */
function PrintsList({ p }: { p: PacketRead }) {
const [load] = useLoad((s) => packetsApi.prints(p.id, s), `${p.id}:${p.version}`);
return (
<section className="sec" aria-label="Printed copies and exports">
<div className="sec-h">
<h2>Printed copies and exports</h2>
{load.state === "ok" ? <span className="ct">{load.data.length}</span> : null}
</div>
{load.state === "loading" ? (
<div className="empty slim">Loading</div>
) : load.state === "error" ? (
<div className="callout warn">{load.message}</div>
) : load.data.length ? (
<ul className="prints">
{load.data.map((r) => (
<PrintLine key={r.id} r={r} />
))}
</ul>
) : (
<div className="empty slim">Nothing printed yet.</div>
)}
</section>
);
}
function PrintLine({ r }: { r: PrintRow }) {
return <li className="small">{printLineText(r)}</li>;
}
/** 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>
<thead>
<tr>
<th>When</th>
<th>Who</th>
<th>What</th>
<th>Note</th>
</tr>
</thead>
<tbody>
{rows.map((r) => (
<tr key={r.id}>
<td className="small mono nowrap">{fmtTime(r.at)}</td>
<td className="small">
{r.by?.display_name ?? "System"}
{r.role ? <div className="sub">{r.role}</div> : null}
</td>
<td>
{r.action}
{r.signed ? <span className="pill st-ok">Signed</span> : null}
{r.it_reason ? <span className="pill st-warehouse">IT edit</span> : null}
</td>
<td className="small">
{r.note ?? ""}
{r.it_reason ? <div className="sub">IT reason: {r.it_reason}</div> : null}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}