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, opts?: { sign?: string | undefined; ok?: string | undefined }, ) => Promise; 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) | undefined; }) { const editable = p.status === "draft" && p.source_kind === "hand" && p.can.includes("edit_bom"); const [lines, setLines] = useState(null); const [receiveFor, setReceiveFor] = useState(null); const [viewFor, setViewFor] = useState(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 (
{lines.map((l, i) => ( ))}
Item Qty Part ID Description Material Length Dwg ref
{i + 1} set(i, "qty", e.target.value)} /> set(i, "part_id", e.target.value)} /> set(i, "description", e.target.value)} /> set(i, "material", e.target.value)} /> set(i, "length", e.target.value)} /> set(i, "dwg_ref", e.target.value)} />
); } if (p.status === "warehouse" && p.can.includes("edit_bom") && write && reload) { return ; } 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[2]): Promise => { 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 (
{editable ? (
This packet was made by hand. Clerical can change its lines while it is a draft.
) : null} {(p.shorts_unapproved ?? 0) > 0 ? (
{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.
) : null} {p.bom.length ? (
{p.bom.map((b) => ( ))}
Item Qty Part ID Description Material Length Heat Pulled
{b.item} {show(b.qty)} {show(b.part_id)} {show(b.description)} {b.dwg_ref ?
Dwg ref {b.dwg_ref}
: null}
{show(b.material)} {show(b.length)} {show(b.heat)} {b.mtr_asset_id ? ( <> {" "} {!p.removed ? ( <> {" "} ) : null} ) : null} {b.short ? (
{canReceive && write ? ( ) : null}
) : lineReceipts(p, b).length ? (
Short received
) : b.na ? ( N/A ) : b.pulled ? ( Pulled ) : ( Not yet )}
) : (
No BOM lines on this packet.
)} {receiving ? ( setReceiveFor(null)} onReceive={(body) => receive(receiving.item, body)} /> ) : null} {viewed?.mtr_asset_id ? ( setViewFor(null)} /> ) : null}
); } /* ---------- Work orders ---------- */ function woKey(w: Pick): string { return `${w.wo_base}/${w.lot}/${w.split}/${w.sub}`; } function Qr({ text }: { text: string }) { const ref = useRef(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 (
{failed ? No QR : }
); } /** 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
No work orders on this packet.
; return (
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. {!p.removed ? ( <> Traveler (PDF) ) : null}
{p.work_orders.map((w) => ( ))}
WO Lot Split Sub Seq Operation Qty Target hrs
{w.wo_base} {w.lot} {w.split} {w.sub} {show(w.seq)} {show(w.op)} {show(w.qty)} {show(w.target_hrs)}
{p.work_orders.map((w) => { const code = kioskCode(w); return (
{w.wo_base}
Lot {w.lot} · Seq {show(w.seq)} {w.op ?? ""}
Target {show(w.target_hrs)} h · Qty {show(w.qty)}
{code}
); })}
); } /* ---------- Documents ---------- */ type AskReason = (opts: ReasonOptions) => Promise; 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(null); const [error, setError] = useState(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 (
{error ? (
{error}
) : null}
); } 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 (
{label} {d.title || label}
{meta.join(" · ")}
{old && d.removed ? (
Taken out of the data book by {d.removed.by?.display_name ?? "-"} {fmtTime(d.removed.at)}: {d.removed.reason ?? ""}
) : null}
{!old && onPrint ? ( ) : null} {!old && canRemove ? ( ) : null}
); } /** * 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 (

Documents for the data book

{live.length} on this packet {canAdd ? ( ) : null}
NDE, hydrotest, dimensional, PMI and coating reports, certificates, customer inspection reports and NCRs. They go into the data book export with the unit's ITP and Q.C. folder documents. Drawings are never added here. They only come from M-Files.
{live.length ? ( live.map((d) => ( void takeOut(d)} onPrint={p.removed ? undefined : () => openPrint(p.id, "doc", { asset: d.asset_id })} /> )) ) : (
No documents added to this packet yet.{canAdd ? " Use Add document." : " Q.C. or Clerical add them."}
)}
{gone.length ? (
{showOld ? (
{gone.map((d) => ( undefined} /> ))}
) : null}
) : null}

Also in the data book from the Q.C. folder

{p.unit.number}
{folder.length ? ( folder.map((d) => (
{d.type} {d.title || d.type} {d.rev ? ` Rev ${d.rev}` : ""} {d.scope === "project" ? "from the project" : "this unit"}
{!p.removed ? ( ) : null}
)) ) : (
No ITP or Q.C. folder documents for this unit yet. Q.C. adds them under Project folders on the Q.C. screen.
)}
{adding ? setAdding(false)} onCancel={() => setAdding(false)} /> : null}
); } /* ---------- 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 (
{load.state === "loading" ? (
Loading
) : load.state === "error" ? (
{load.message}
) : load.data.length ? ( ) : (
Nothing recorded yet.
)}
); } /** 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 (

Printed copies and exports

{load.state === "ok" ? {load.data.length} : null}
{load.state === "loading" ? (
Loading
) : load.state === "error" ? (
{load.message}
) : load.data.length ? (
    {load.data.map((r) => ( ))}
) : (
Nothing printed yet.
)}
); } function PrintLine({ r }: { r: PrintRow }) { return
  • {printLineText(r)}
  • ; } /** One row per closed Q.C. inspection, oldest first. */ function QcInspections({ rows }: { rows: QcHistoryRow[] }) { if (!rows.length) return null; return (

    Q.C. inspections

    {rows.length}
    {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 (
    {String(h.result ?? "").toUpperCase()}{" "} {h.sent_at ? `${fmtTime(h.sent_at)} → ` : ""} {fmtTime(h.at)} {who ? {who} : null} {h.dept_code ? · {h.dept_code} : null}
    {note ?
    {note}
    : null} {h.ncr || tail.length ? (
    {h.ncr ? ( NCR {h.ncr} ) : null} {h.ncr && tail.length ? " · " : ""} {tail.join(" · ")}
    ) : null}
    ); })}
    ); } function LogTable({ rows }: { rows: LogRow[] }) { return (
    {rows.map((r) => ( ))}
    When Who What Note
    {fmtTime(r.at)} {r.by?.display_name ?? "System"} {r.role ?
    {r.role}
    : null}
    {r.action} {r.signed ? Signed : null} {r.it_reason ? IT edit : null} {r.note ?? ""} {r.it_reason ?
    IT reason: {r.it_reason}
    : null}
    ); }