Files
PacketStation/frontend/src/packet/PacketPage.tsx
T
spencermandClaude Opus 5.5 de5b361d85
ci / frontend (pull_request) Successful in 54s
ci / server (pull_request) Failing after 12m12s
ci / image (pull_request) Skipped
ci / e2e (pull_request) Skipped
Work-order codes no longer overlap (#42), Materials before Heat map (#44), 60 min idle for Admin and Clerical (#45)
- Traveler: each work-order line shrinks, then clips, to fit its own box; 18 pt gap between boxes
- Work orders tab: card text wraps inside the card, smaller work order, wider cards
- Packet tabs: Materials before Heat map
- Sessions: SESSION_DESK_MINUTES (60) for Admin and Clerical lock and sign-out; /me answers
  lock_minutes and the browser's idle lock follows it

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 13:16:26 -06:00

706 lines
28 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 { 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 ? (
<label className="f">
{label}
<input className="in" value={v[k]} maxLength={max} onChange={(e) => setV((o) => ({ ...o, [k]: e.target.value }))} />
</label>
) : null;
const save = async (e: FormEvent) => {
e.preventDefault();
const set: Partial<PacketHeader> = {};
const base: Partial<PacketHeader> = {};
for (const k of Object.keys(v) as Array<keyof typeof v>) {
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 (
<form className="panel stack" onSubmit={save} noValidate aria-label="Edit details">
<div className="frow">
{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)}
</div>
<div className="sheet-acts start">
<button className="btn pri" type="submit" disabled={busy}>
Save
</button>
<button className="btn ghost" type="button" onClick={onDone}>
Cancel
</button>
</div>
</form>
);
}
/** /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<PacketRead | null>(null);
const [loadError, setLoadError] = useState<string | null>(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<Tab | null>(isTab(wantTab) ? wantTab : null);
const [sheet, setSheet] = useState<"dept" | "move" | "print" | null>(null);
const [editing, setEditing] = useState(false);
const [toast, setToast] = useState<string | null>(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<number | null>(null);
const fileRef = useRef<HTMLInputElement>(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 ? <div className="callout warn">{loadError}</div> : <div className="empty slim">Loading</div>;
}
const p = packet;
const can = (a: string) => p.can.includes(a);
const draft = p.status === "draft";
const transition = (action: string, extra: Omit<TransitionBody, "action" | "expect"> = {}, 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 (
<>
<Link
className="back"
to={backTo}
onClick={(e) => {
if (!hasAppHistory || e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0) return;
e.preventDefault();
navigate(-1);
}}
>
← Back
</Link>
<div className="dhead">
<div className="grow0">
<div className="eyebrow">
{p.op ?? ""} · {statusLabel(p.status)}
</div>
<h1>{job}</h1>
{p.title ? <div className="dtitle">{p.title}</div> : null}
<div className="small crumbs2">
Project <b>{project.number}</b> {project.name ?? ""} <span className="muted">›</span> Unit <b>{p.unit.number}</b> {p.unit.name ?? ""}
{project.po ? (
<>
{" "}
· PO <span className="mono">{project.po}</span>
</>
) : null}{" "}
· <span className={di.cls}>{di.txt}</span>
</div>
<div className="facts">
{lotQty ? (
<div>
<span>Lot qty </span>
<b>{lotQty}</b>
</div>
) : null}
<div>
<span>Customer </span>
<b>{p.customer || "-"}</b>
</div>
<div>
<span>Drawing </span>
<b>{p.drawing ? `${p.drawing} R${p.rev || "-"}` : "-"}</b>
</div>
<div>
<span>WPS </span>
<b>{p.wps || "-"}</b>
</div>
<div>
<span>Department </span>
<b>
{p.dept.code} {p.dept.name}
</b>
</div>
<div>
<span>Target </span>
<b>{target.toFixed(1)} h</b>
</div>
<div>
<span>Heat map </span>
<b>{p.heat_map === true ? "Yes" : p.heat_map === false ? "Not required" : "Not answered"}</b>
</div>
{p.for_sup ? (
<div>
<span>For </span>
<b>{p.for_sup.display_name}</b>
</div>
) : null}
{p.station ? (
<div>
<span>Station </span>
<b>{p.station.name}</b>
</div>
) : null}
{p.welder ? (
<div>
<span>Welder </span>
<b>
{p.welder.display_name}
{p.welder.stamp ? ` [${p.welder.stamp}]` : ""}
</b>
</div>
) : null}
</div>
</div>
<div className="pacts">
<span className={pillClass(p.status)} data-testid="status-pill">
{statusLabel(p.status)}
</span>
{can("edit_header") || can("edit_drawing_ref") ? (
<button className="btn sm" type="button" onClick={() => setEditing((e) => !e)} aria-expanded={editing}>
Edit details
</button>
) : null}
{can("move_unit") ? (
<button className="btn sm" type="button" disabled={busy} onClick={() => setSheet("move")}>
Move
</button>
) : null}
{!p.removed ? (
<button className="btn sm" type="button" onClick={() => setSheet("print")}>
Print…
</button>
) : null}
</div>
</div>
{p.flag ? <div className={`small flagline ${flagClass(p.flag)}`}>⚑ {p.flag}</div> : null}
{p.removed ? (
<div className="callout warn">
<b>Removed</b>
{p.removed.by ? ` by ${p.removed.by.display_name}` : ""}. {p.removed.reason ?? ""}
</div>
) : null}
<QcRejectedCallout p={p} />
{p.return_note && !(showsQcRejected(p) && p.return_note === p.qc_last?.note) ? (
<div className="callout warn">
<b>Returned:</b> {p.return_note}
</div>
) : null}
{viewOnly && !p.removed ? (
<div className="viewonly">
<span aria-hidden="true">●</span>
<div>
<b>View only.</b> This packet is with <b>{p.holder}</b>. You can follow its progress. Changes open up only when it is sent to you or sent back.
</div>
</div>
) : null}
{others.length ? (
<div className="presence small" data-testid="presence" role="status">
<span className="pdot" aria-hidden="true" />
{presenceText(others)}
</div>
) : null}
{msg ? (
<div className={msg.ok ? "callout" : "callout warn"} role={msg.ok ? "status" : "alert"}>
{msg.text}
</div>
) : null}
{review && (me?.roles ?? []).some((r) => r === "clerical" || r === "clerical_supervisor" || r === "admin") ? (
<div className="callout warn" role="status">
<b>Change order CO-{review.co_no}: a change from VISUAL is waiting for your approval</b> - {coSummary(review)}. Nothing has gone to the shop packet yet.
{review.hold ? <b> Shop packet on hold.</b> : null}{" "}
<button className="btn sm pri" type="button" onClick={() => setTab("changes")}>
Review change
</button>
</div>
) : null}
<VisualBox p={p} roles={me?.roles ?? []} sub={job} mutate={mutate} busy={busy} askReason={askReason} onError={(text) => setMsg({ ok: false, text })} />
{editing ? <HeaderEdit key={p.version} p={p} mutate={mutate} busy={busy} onDone={() => setEditing(false)} /> : null}
{hasDraftControls ? (
<section className="panel actbox" aria-label="Draft steps">
<div>
<b>Ready to release.</b> 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.
</div>
{can("edit_header") ? (
<div className="hmq" role="group" aria-label="Heat map required?">
<b>Heat map required?</b>
<button type="button" className="chip sm" aria-pressed={p.heat_map === true} disabled={busy} onClick={() => void setHeatMap(true)}>
Yes
</button>
<button type="button" className="chip sm" aria-pressed={p.heat_map === false} disabled={busy} onClick={() => void setHeatMap(false)}>
No
</button>
{p.heat_map === null ? <span className="due soon small">choose before sending</span> : null}
</div>
) : null}
<div className="acts">
{can("edit_document_set") ? (
<div className="act">
<label className={busy ? "btn dim" : "btn"}>
{drawingPages.length ? "Attach another drawing" : "Attach drawing"}
<input
ref={fileRef}
className="sr"
type="file"
accept="application/pdf"
aria-label="Attach drawing (PDF)"
disabled={busy}
onChange={(e) => {
const f = e.target.files?.[0];
if (f) void attach(f);
}}
/>
</label>
</div>
) : null}
<Act p={p} action="send_to_warehouse" busy={busy} className="btn pri" onClick={() => void transition("send_to_warehouse", {}, "Sent to the warehouse.")}>
Send to warehouse
</Act>
<Act p={p} action="release_to_shop" busy={busy} onClick={() => setSheet("dept")}>
Send to a department
</Act>
<Act p={p} action="send_draft_to_qc" busy={busy} onClick={() => void sendToQc()}>
Send to Q.C.
</Act>
<Act p={p} action="remove" busy={busy} className="btn rejb" onClick={() => void remove()}>
Remove
</Act>
</div>
</section>
) : null}
{atWh ? (
<section className={whCan ? "panel actbox" : "callout"} aria-label="Warehouse">
<div className="whstep">
<span className={pillClassFor(stage)}>{pillText(stage)}</span>
<div className={`whline ${WH_STAGES[stage].cls}`}>{whLine(p)}</div>
</div>
{whMsg ? (
<div>
<b>{whMsg.head}</b> {whMsg.rest}
</div>
) : null}
{whCan || can("wh_send_back") || can("wh_release") || p.blocked.wh_release ? (
<div className="acts">
<Act p={p} action="wh_send_back" busy={busy} className="btn ghost" onClick={() => void sendBack()}>
Send back to Clerical
</Act>
{whCan && stage === "incoming" ? (
<div className="act">
<button className="btn" type="button" disabled={busy || wh.busy} onClick={() => wh.move(whTarget, "pulling")}>
Start pulling
</button>
</div>
) : null}
{whCan && stage === "hold" ? (
<div className="act">
<button className="btn" type="button" disabled={busy || wh.busy} onClick={() => wh.move(whTarget, "pulling")}>
Resume pulling
</button>
</div>
) : null}
{whCan && stage === "pulling" ? (
<div className="act">
<button className="btn" type="button" disabled={busy || wh.busy} onClick={() => wh.move(whTarget, "pulled")}>
Mark pulled
</button>
</div>
) : null}
{whCan && stage === "pulled" ? (
<div className="act">
<button className="btn" type="button" disabled={busy || wh.busy} onClick={() => wh.move(whTarget, "pulling")}>
Back to pulling
</button>
</div>
) : null}
{whCan && (stage === "incoming" || stage === "pulling") ? (
<div className="act">
<button className="btn" type="button" disabled={busy || wh.busy} onClick={() => wh.move(whTarget, "hold")}>
Hold
</button>
</div>
) : null}
<Act
p={p}
action="wh_release"
busy={busy || wh.busy}
className="btn pri"
onClick={() => void transition("wh_release", {}, "Released to supervisor.")}
>
Release to supervisor{p.open_shorts ? ` (${p.open_shorts} short)` : ""}
</Act>
</div>
) : null}
</section>
) : null}
<CloseFile p={p} busy={busy} mutate={mutate} askReason={askReason} sub={job} onPrint={() => setSheet("print")} />
<ShopBar p={p} busy={busy} mutate={mutate} askReason={askReason} sub={job} goTab={(t) => setTab(t)} />
<QcBar p={p} busy={busy} mutate={mutate} sub={job} goQc={() => setTab("qc")} />
<HoldCallout p={p} qcScreen={me?.screens.includes("qc") ?? false} />
<div className="tabs" role="tablist" aria-label="Packet">
{tabs.map((t) => (
<button
key={t.id}
type="button"
role="tab"
id={`ptab-${t.id}`}
aria-selected={shownTab === t.id}
aria-controls={`ppanel-${t.id}`}
onClick={() => setTab(t.id)}
>
{t.label}
{t.count !== undefined ? <span className="cnt">{t.count}</span> : null}
</button>
))}
</div>
<div key={shownTab} className="m-tab" role="tabpanel" id={`ppanel-${shownTab}`} aria-labelledby={`ptab-${shownTab}`}>
{shownTab === "drawing" ? (
<div className="stack">
{draft && can("edit_document_set") && drawingPages.length ? (
<div className="sec-h flush">
<span className="small muted">
{drawingPages.length} drawing page{drawingPages.length === 1 ? "" : "s"} attached by hand.
</span>
<span className="grow" />
<button className="btn sm ghost" type="button" disabled={busy} onClick={() => void removeDrawing(drawingPages[0]?.n ?? 1)}>
Remove drawing
</button>
</div>
) : null}
<DrawingViewer pages={drawingPages} {...(p.removed ? {} : { onPrintSheet: (n: number) => openPrint(p.id, "clean", { sheet: n }) })} />
</div>
) : null}
{shownTab === "heat" ? <HeatMapTab key={p.id} p={p} write={write} reload={reload} sub={job} onError={onTabError} /> : null}
{shownTab === "materials" ? <MaterialsTab p={p} mutate={mutate} busy={busy} write={write} reload={reload} /> : null}
{shownTab === "checklist" ? <ChecklistTab p={p} write={write} mutate={mutate} reload={reload} onError={onTabError} /> : null}
{shownTab === "welds" ? (
<WeldMapTab key={p.id} p={p} write={write} mutate={mutate} reload={reload} askReason={askReason} sub={job} myId={me?.user.id ?? null} onError={onTabError} />
) : null}
{shownTab === "qc" ? <QcTab p={p} write={write} mutate={mutate} reload={reload} onError={onTabError} /> : null}
{shownTab === "workorders" ? <WorkOrdersTab p={p} /> : null}
{shownTab === "changes" ? <ChangesTab p={p} roles={me?.roles ?? []} sub={job} mutate={mutate} busy={busy} askReason={askReason} /> : null}
{shownTab === "docs" ? <DocumentsTab p={p} mutate={mutate} busy={busy} write={write} askReason={askReason} sub={job} /> : null}
{shownTab === "history" ? <HistoryTab p={p} /> : null}
</div>
{sheet === "dept" ? (
<SendToDeptSheet
p={p}
sub={job}
onCancel={() => setSheet(null)}
onSend={(dept, note) => {
setSheet(null);
void transition("release_to_shop", { dept, ...(note ? { note } : {}) }, "Sent to the department.");
}}
/>
) : null}
{sheet === "move" ? (
<MoveSheet
p={p}
sub={job}
onCancel={() => 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" ? <PrintMenu p={p} sub={job} onClose={() => setSheet(null)} /> : null}
{wh.sheet}
{guardSheet}
{toast ? (
<div className="toast" role="status" data-testid="toast">
{toast}
</div>
) : null}
</>
);
}