import { useCallback, useEffect, useRef, useState, type FormEvent } from "react"; import { Link, useLocation, useNavigate, useParams, useSearchParams } from "react-router"; import { assetsApi } from "../api/assets"; import { ApiError, errorCode } from "../api/client"; import { packetsApi } from "../api/packets"; import type { PacketHeader, PacketRead, TransitionBody } from "../api/types"; import { homePath } from "../auth/roles"; import { useAuth } from "../auth/useAuth"; import { presenceText, usePacketPresence } from "../auth/useEvents"; import { useGuard } from "../components/useGuard"; import { useChanged } from "../lib/changes"; import { errorText } from "../lib/format"; import { dueInfo, flagClass, jobName, pillClass, statusLabel, targetHours } from "../lib/packets"; import { Act } from "./Act"; import { CloseFile } from "./CloseFile"; import { DrawingViewer } from "./DrawingViewer"; import { openPrint } from "./closeUtil"; import { PrintMenu } from "./PrintMenu"; import { MoveSheet, SendToDeptSheet } from "./sheets"; import { DocumentsTab, HistoryTab, MaterialsTab, WorkOrdersTab, type Mutate } from "./tabs"; import { coSummary, coToReview } from "../lib/changeOrders"; import { VisualBox } from "./visual"; import { ChangesTab } from "./ChangesTab"; import { ChecklistTab } from "./ChecklistTab"; import { HeatMapTab } from "./HeatMapTab"; import { ShopBar } from "./ShopActions"; import { HoldCallout } from "./HoldCallout"; import { QcBar } from "./QcActions"; import { QcRejectedCallout } from "./QcRejectedCallout"; import { showsQcRejected } from "../lib/qc"; import { QcTab } from "./QcTab"; import { WeldMapTab } from "./WeldMapTab"; import type { Write } from "../warehouse/PullTable"; import { packetMessage, pillClassFor, WH_STAGES, pillText, progress, whLine, whStage } from "../warehouse/stages"; import { useWhMove } from "../warehouse/useWhMove"; type Tab = "drawing" | "heat" | "materials" | "checklist" | "welds" | "qc" | "workorders" | "changes" | "docs" | "history"; const TABS: readonly Tab[] = ["drawing", "materials", "heat", "checklist", "welds", "qc", "workorders", "changes", "docs", "history"]; function isTab(v: string | null): v is Tab { return v !== null && (TABS as readonly string[]).includes(v); } const TOAST_MS = 5000; /** The rule's signature meaning for attaching a drawing by hand. */ const SIGN_ATTACH = "Attach a drawing by hand"; /** Title, customer, WPS, drawing and rev, edited in place with a field-level base. */ function HeaderEdit({ p, mutate, busy, onDone }: { p: PacketRead; mutate: Mutate; busy: boolean; onDone: () => void }) { const head = p.can.includes("edit_header"); const dwg = p.can.includes("edit_drawing_ref"); const [v, setV] = useState({ title: p.title ?? "", customer: p.customer ?? "", wps: p.wps ?? "", drawing: p.drawing ?? "", rev: p.rev ?? "" }); const field = (k: keyof typeof v, label: string, max: number, on: boolean) => on ? ( ) : null; const save = async (e: FormEvent) => { e.preventDefault(); const set: Partial = {}; const base: Partial = {}; for (const k of Object.keys(v) as Array) { const now = v[k].trim() || null; const was = p[k] ?? null; if (now !== was) { set[k] = now; base[k] = was; } } if (Object.keys(set).length === 0) { onDone(); return; } if (await mutate((g) => packetsApi.patch(p.id, { set, base, ...g }), { ok: "Saved" })) onDone(); }; return (
{field("title", "Title", 200, head)} {field("customer", "Customer", 120, head)} {field("wps", "WPS", 40, head)} {field("drawing", "Drawing no.", 60, dwg)} {field("rev", "Rev", 10, dwg)}
); } /** /packets/:id: the packet for every signed-in role. Controls come from `can` and `blocked`. */ export function PacketPage() { const params = useParams(); const id = Number(params.id); const navigate = useNavigate(); const { me } = useAuth(); const { run, askReason, sheet: guardSheet } = useGuard(); const [packet, setPacket] = useState(null); const [loadError, setLoadError] = useState(null); const [msg, setMsg] = useState<{ ok: boolean; text: string } | null>(null); const [busy, setBusy] = useState(false); const [search] = useSearchParams(); const wantTab = search.get("tab"); const [tab, setTab] = useState(isTab(wantTab) ? wantTab : null); const [sheet, setSheet] = useState<"dept" | "move" | "print" | null>(null); const [editing, setEditing] = useState(false); const [toast, setToast] = useState(null); const others = usePacketPresence(Number.isFinite(id) ? id : null); // Phase 8: Back is one step back in this app's history (to the same scroll spot); a packet opened straight // from a link has no app entry before it ("default"), so Back goes to the screen home. const location = useLocation(); const hasAppHistory = location.key !== "default"; const versionRef = useRef(null); const fileRef = useRef(null); // A read that was sent before a write committed can answer after the write's own answer: never go back. const take = useCallback((p: PacketRead) => setPacket((cur) => (cur && cur.id === p.id && cur.version > p.version ? cur : p)), []); const reload = useCallback(async () => { try { const p = await packetsApi.get(id); take(p); setLoadError(null); } catch (err) { setLoadError(errorText(err)); } }, [id, take]); useEffect(() => { setPacket(null); setTab(isTab(wantTab) ? wantTab : null); void reload(); }, [reload, wantTab]); useEffect(() => { versionRef.current = packet?.version ?? null; }, [packet]); useEffect(() => { if (!toast) return; const t = window.setTimeout(() => setToast(null), TOAST_MS); return () => window.clearTimeout(t); }, [toast]); // Live refresh: another screen changed this packet. useChanged(() => void reload(), { delayMs: 500, filter: (ev) => Number(ev.packet_id) === id && (ev.version === undefined || ev.version !== versionRef.current), }); const job = packet ? jobName(packet) : ""; // Line writes: the answer replaces the packet; the error goes back to the row that made it. const write: Write = useCallback( async (fn) => { try { const r = await run(fn, { sub: job }); if (r === undefined) return { ok: false }; if (r && typeof r === "object" && "id" in r) take(r as PacketRead); else await reload(); return { ok: true }; } catch (err) { return { ok: false, err }; } }, [run, reload, job, take], ); const wh = useWhMove({ run, onDone: setPacket, onError: (err) => { if (err instanceof ApiError && err.status === 409) void reload(); setMsg({ ok: false, text: errorText(err) }); }, }); const mutate: Mutate = useCallback( async (fn, opts = {}) => { setBusy(true); setMsg(null); try { const r = await run(fn, { sign: opts.sign, sub: job }); if (r === undefined) return false; if (r && typeof r === "object" && "id" in r) take(r as PacketRead); else await reload(); if (opts.ok) setMsg({ ok: true, text: opts.ok }); return true; } catch (err) { // stale_step, field_conflict, row_conflict: someone else moved first. Show their state. if (err instanceof ApiError && (err.status === 409 || err.status === 412)) await reload(); // A step someone else already took: the page shows their state and says so once, as a toast. if (errorCode(err) === "stale_step") setToast(errorText(err)); else setMsg({ ok: false, text: errorText(err) }); return false; } finally { setBusy(false); } }, [run, reload, job, take], ); if (!packet) { return loadError ?
{loadError}
:
Loading
; } const p = packet; const can = (a: string) => p.can.includes(a); const draft = p.status === "draft"; const transition = (action: string, extra: Omit = {}, ok?: string) => mutate((g) => packetsApi.transition(p.id, { action, expect: { status: p.status, dept: p.dept.code }, ...extra, ...g }), { ok }); const setHeatMap = (v: boolean) => mutate((g) => packetsApi.patch(p.id, { set: { heat_map: v }, base: { heat_map: p.heat_map }, ...g })); const attach = async (file: File) => { setBusy(true); setMsg(null); let asset; try { asset = await assetsApi.upload(file, { packet_id: p.id, purpose: "drawing" }); } catch (err) { setMsg({ ok: false, text: errorText(err) }); setBusy(false); return; } finally { if (fileRef.current) fileRef.current.value = ""; } setBusy(false); if (asset.mime !== "application/pdf") { setMsg({ ok: false, text: "The drawing must be a PDF." }); return; } const pages = asset.pages ?? 0; await mutate( (g) => packetsApi.attachPages(p.id, { asset_id: asset.id, type: "drawing", ...(p.drawing ? { dwg: p.drawing } : {}), ...(p.rev ? { rev: p.rev } : {}), ...g, }), { sign: SIGN_ATTACH, ok: pages ? `Drawing attached (${pages} page${pages === 1 ? "" : "s"}).` : "Drawing attached." }, ); }; const sendToQc = async () => { const note = await askReason({ title: "Send straight to Q.C.", sub: job, label: "Note (optional, Q.C. will see it)", note: "Skips the warehouse and the shop. Q.C. gets it for inspection.", min: 0, submitLabel: "Send to Q.C.", }); if (note === null) return; await transition("send_draft_to_qc", note ? { note } : {}, "Sent to Q.C."); }; const remove = async () => { const reason = await askReason({ title: "Remove packet", sub: job, note: "The packet leaves every board. It stays in the record with your reason.", min: 8, danger: true, submitLabel: "Remove packet", }); if (reason === null) return; setBusy(true); setMsg(null); try { const r = await run((g) => packetsApi.remove(p.id, { reason, ...g }), { sub: job }); if (r !== undefined) navigate(`/clerical/units/${p.unit.id}`); } catch (err) { if (err instanceof ApiError && err.status === 409) await reload(); setMsg({ ok: false, text: errorText(err) }); } finally { setBusy(false); } }; const removeDrawing = async (n: number) => { await mutate((g) => packetsApi.removePages(p.id, n, g), { ok: "Drawing removed." }); }; const drawingPages = p.pages.filter((x) => x.type === "drawing"); const review = coToReview(p); const hasQc = !!p.qc || p.qc_history.length > 0; const hidden = (tab === "changes" && !(p.change_orders ?? []).length) || (tab === "qc" && !hasQc); const shownTab: Tab = hidden ? "materials" : (tab ?? (drawingPages.length ? "drawing" : "materials")); // The warehouse short-parts exception and the data book date are not edits of the packet itself. const passive = new Set(["wh_receive_short", "set_databook_at"]); const viewOnly = p.can.every((a) => passive.has(a)) && Object.keys(p.blocked).every((a) => passive.has(a)); const project = p.unit.project; const di = dueInfo(p.unit.due_effective, project.status === "closed"); const target = targetHours(p); const lotQty = Math.max(0, ...p.work_orders.map((w) => Number(w.qty) || 0)); const backTo = me?.screens.includes("clerical") ? `/clerical/units/${p.unit.id}` : homePath(me?.screens ?? []); const atWh = p.status === "warehouse"; const whCan = atWh && can("set_wh_stage"); const stage = whStage(p); const whMsg = whCan ? packetMessage(p) : null; const whTarget = { id: p.id, stage, job, progress: progress(p) }; const sendBack = async () => { const reason = await askReason({ title: "Send back to Clerical", sub: job, note: "The packet goes back to Clerical as a draft. Say what needs fixing.", min: 8, submitLabel: "Send back to Clerical", }); if (reason === null) return; await transition("wh_send_back", { reason }, "Sent back to Clerical."); }; const hasDraftControls = draft && ["edit_header", "send_to_warehouse", "release_to_shop", "send_draft_to_qc", "edit_document_set", "move_unit", "remove"].some((a) => can(a) || p.blocked[a]); const cos = (p.change_orders ?? []).length; const ckOpen = p.checklist_items.filter((i) => !p.stage?.checklist?.[i.key]?.done).length; const tabs: Array<{ id: Tab; label: string; count?: number }> = [ { id: "drawing", label: "Drawing", count: drawingPages.length }, { id: "materials", label: "Materials", count: p.bom.length }, { id: "heat", label: "Heat map" }, { id: "checklist", label: "Checklist", ...(p.stage ? { count: ckOpen } : {}) }, { id: "welds", label: "Weld map", count: p.welds.length }, ...(hasQc ? [{ id: "qc" as const, label: "Q.C." }] : []), { id: "workorders", label: "Work orders", count: p.work_orders.length }, ...(cos ? [{ id: "changes" as const, label: "Changes", count: cos }] : []), { id: "docs", label: "Documents", count: p.docs.filter((d) => !d.removed).length }, { id: "history", label: "History" }, ]; const onTabError = (text: string) => setMsg({ ok: false, text }); return ( <> { if (!hasAppHistory || e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0) return; e.preventDefault(); navigate(-1); }} > ← Back
{p.op ?? ""} · {statusLabel(p.status)}

{job}

{p.title ?
{p.title}
: null}
Project {project.number} {project.name ?? ""} › Unit {p.unit.number} {p.unit.name ?? ""} {project.po ? ( <> {" "} · PO {project.po} ) : null}{" "} · {di.txt}
{lotQty ? (
Lot qty {lotQty}
) : null}
Customer {p.customer || "-"}
Drawing {p.drawing ? `${p.drawing} R${p.rev || "-"}` : "-"}
WPS {p.wps || "-"}
Department {p.dept.code} {p.dept.name}
Target {target.toFixed(1)} h
Heat map {p.heat_map === true ? "Yes" : p.heat_map === false ? "Not required" : "Not answered"}
{p.for_sup ? (
For {p.for_sup.display_name}
) : null} {p.station ? (
Station {p.station.name}
) : null} {p.welder ? (
Welder {p.welder.display_name} {p.welder.stamp ? ` [${p.welder.stamp}]` : ""}
) : null}
{statusLabel(p.status)} {can("edit_header") || can("edit_drawing_ref") ? ( ) : null} {can("move_unit") ? ( ) : null} {!p.removed ? ( ) : null}
{p.flag ?
⚑ {p.flag}
: null} {p.removed ? (
Removed {p.removed.by ? ` by ${p.removed.by.display_name}` : ""}. {p.removed.reason ?? ""}
) : null} {p.return_note && !(showsQcRejected(p) && p.return_note === p.qc_last?.note) ? (
Returned: {p.return_note}
) : null} {viewOnly && !p.removed ? (
View only. This packet is with {p.holder}. You can follow its progress. Changes open up only when it is sent to you or sent back.
) : null} {others.length ? (
) : null} {msg ? (
{msg.text}
) : null} {review && (me?.roles ?? []).some((r) => r === "clerical" || r === "clerical_supervisor" || r === "admin") ? (
Change order CO-{review.co_no}: a change from VISUAL is waiting for your approval - {coSummary(review)}. Nothing has gone to the shop packet yet. {review.hold ? Shop packet on hold. : null}{" "}
) : null} setMsg({ ok: false, text })} /> {editing ? setEditing(false)} /> : null} {hasDraftControls ? (
Ready to release. Check the BOM and work orders, attach the drawing, and say whether it needs a heat map. Then send it to the warehouse for the parts pull, or straight to a department or Q.C.
{can("edit_header") ? (
Heat map required? {p.heat_map === null ? choose before sending : null}
) : null}
{can("edit_document_set") ? (
) : null} void transition("send_to_warehouse", {}, "Sent to the warehouse.")}> Send to warehouse setSheet("dept")}> Send to a department void sendToQc()}> Send to Q.C. void remove()}> Remove
) : null} {atWh ? (
{pillText(stage)}
{whLine(p)}
{whMsg ? (
{whMsg.head} {whMsg.rest}
) : null} {whCan || can("wh_send_back") || can("wh_release") || p.blocked.wh_release ? (
void sendBack()}> Send back to Clerical {whCan && stage === "incoming" ? (
) : null} {whCan && stage === "hold" ? (
) : null} {whCan && stage === "pulling" ? (
) : null} {whCan && stage === "pulled" ? (
) : null} {whCan && (stage === "incoming" || stage === "pulling") ? (
) : null} void transition("wh_release", {}, "Released to supervisor.")} > Release to supervisor{p.open_shorts ? ` (${p.open_shorts} short)` : ""}
) : null}
) : null} setSheet("print")} /> setTab(t)} /> setTab("qc")} />
{tabs.map((t) => ( ))}
{shownTab === "drawing" ? (
{draft && can("edit_document_set") && drawingPages.length ? (
{drawingPages.length} drawing page{drawingPages.length === 1 ? "" : "s"} attached by hand.
) : null} openPrint(p.id, "clean", { sheet: n }) })} />
) : null} {shownTab === "heat" ? : null} {shownTab === "materials" ? : null} {shownTab === "checklist" ? : null} {shownTab === "welds" ? ( ) : null} {shownTab === "qc" ? : null} {shownTab === "workorders" ? : null} {shownTab === "changes" ? : null} {shownTab === "docs" ? : null} {shownTab === "history" ? : null}
{sheet === "dept" ? ( setSheet(null)} onSend={(dept, note) => { setSheet(null); void transition("release_to_shop", { dept, ...(note ? { note } : {}) }, "Sent to the department."); }} /> ) : null} {sheet === "move" ? ( setSheet(null)} onMove={(unitId, reason) => { setSheet(null); void mutate((g) => packetsApi.move(p.id, { unit_id: unitId, ...(reason ? { reason } : {}), ...g }), { ok: "Packet moved." }); }} /> ) : null} {sheet === "print" ? setSheet(null)} /> : null} {wh.sheet} {guardSheet} {toast ? (
{toast}
) : null} ); }