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 { errorText, fmtTime } from "../lib/format"; import { useLoad } from "../lib/useLoad"; import { MtrViewer } from "../warehouse/MtrViewer"; import { PullTable, ShortChip, type Write } from "../warehouse/PullTable"; import { ShortSheet } from "../warehouse/ShortSheet"; 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.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 ? ( <> {" "} ) : null} {b.na && !b.short ? ( N/A ) : b.short ? (
{canReceive && write ? ( ) : null}
) : 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, and one QR code per work order key. */ export function WorkOrdersTab({ p }: { p: PacketRead }) { if (!p.work_orders.length) return
No work orders on this packet.
; return (
{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 k = woKey(w); return (
{w.wo_base}
{k}
); })}
); } /* ---------- Documents ---------- */ const DOC_KINDS: ReadonlyArray<[string, string]> = [ ["mtr", "Material test report"], ["nde", "NDE report"], ["procedure", "Procedure (WPS / PQR)"], ["photo", "Photo"], ["other", "Other"], ]; const kindLabel = (k: string) => DOC_KINDS.find(([v]) => v === k)?.[1] ?? k; /** 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(null); const [kind, setKind] = useState("mtr"); const [title, setTitle] = useState(""); const [file, setFile] = useState(null); const [error, setError] = useState(null); const [uploading, setUploading] = useState(false); const [confirm, setConfirm] = useState(null); const add = async (e: FormEvent) => { e.preventDefault(); setError(null); if (!file) { setError("Choose a file."); return; } setUploading(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 = ""; } } catch (err) { setError(errorText(err)); } finally { setUploading(false); } }; return (
{p.docs.length ? (
{p.docs.map((d) => ( ))}
Title Kind Added By
{d.title || "Untitled"} {kindLabel(d.kind)} {fmtTime(d.added_at)} {d.added_by?.display_name ?? "-"} {canRemove ? ( confirm === d.id ? ( <> {" "} ) : ( ) ) : null}
) : (
No documents on this packet.
)} {canAdd ? (

Add a document

{error ? (
{error}
) : null}
) : null}
); } /* ---------- History ---------- */ /** GET /log, newest first. IT edits and signatures are marked. */ 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
Loading
; if (load.state === "error") return
{load.message}
; const rows = load.data; if (!rows.length) return
Nothing recorded yet.
; 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}
); }