2026-10-17 23:49:14 -06:00
|
|
|
|
import { useCallback, useEffect, useRef, useState, type FormEvent } from "react";
|
2026-10-18 15:08:34 -06:00
|
|
|
|
import { Link, useLocation, useNavigate, useParams, useSearchParams } from "react-router";
|
2026-10-16 09:31:45 -06:00
|
|
|
|
import { assetsApi } from "../api/assets";
|
2026-10-16 22:46:56 -06:00
|
|
|
|
import { ApiError, errorCode } from "../api/client";
|
2026-10-16 09:31:45 -06:00
|
|
|
|
import { packetsApi } from "../api/packets";
|
|
|
|
|
|
import type { PacketHeader, PacketRead, TransitionBody } from "../api/types";
|
|
|
|
|
|
import { homePath } from "../auth/roles";
|
|
|
|
|
|
import { useAuth } from "../auth/useAuth";
|
2026-10-18 15:08:34 -06:00
|
|
|
|
import { presenceText, usePacketPresence } from "../auth/useEvents";
|
2026-10-16 09:31:45 -06:00
|
|
|
|
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";
|
2026-10-17 23:49:14 -06:00
|
|
|
|
import { Act } from "./Act";
|
|
|
|
|
|
import { CloseFile } from "./CloseFile";
|
2026-10-16 09:31:45 -06:00
|
|
|
|
import { DrawingViewer } from "./DrawingViewer";
|
2026-10-17 23:49:14 -06:00
|
|
|
|
import { openPrint } from "./closeUtil";
|
|
|
|
|
|
import { PrintMenu } from "./PrintMenu";
|
2026-10-16 09:31:45 -06:00
|
|
|
|
import { MoveSheet, SendToDeptSheet } from "./sheets";
|
|
|
|
|
|
import { DocumentsTab, HistoryTab, MaterialsTab, WorkOrdersTab, type Mutate } from "./tabs";
|
2026-10-16 14:45:38 -06:00
|
|
|
|
import { coSummary, coToReview } from "../lib/changeOrders";
|
2026-10-16 22:46:56 -06:00
|
|
|
|
import { VisualBox } from "./visual";
|
|
|
|
|
|
import { ChangesTab } from "./ChangesTab";
|
|
|
|
|
|
import { ChecklistTab } from "./ChecklistTab";
|
|
|
|
|
|
import { HeatMapTab } from "./HeatMapTab";
|
|
|
|
|
|
import { ShopBar } from "./ShopActions";
|
2026-10-17 20:08:53 -06:00
|
|
|
|
import { HoldCallout } from "./HoldCallout";
|
|
|
|
|
|
import { QcBar } from "./QcActions";
|
|
|
|
|
|
import { QcRejectedCallout } from "./QcRejectedCallout";
|
|
|
|
|
|
import { showsQcRejected } from "../lib/qc";
|
|
|
|
|
|
import { QcTab } from "./QcTab";
|
2026-10-16 22:46:56 -06:00
|
|
|
|
import { WeldMapTab } from "./WeldMapTab";
|
2026-10-16 17:13:31 -06:00
|
|
|
|
import type { Write } from "../warehouse/PullTable";
|
|
|
|
|
|
import { packetMessage, pillClassFor, WH_STAGES, pillText, progress, whLine, whStage } from "../warehouse/stages";
|
|
|
|
|
|
import { useWhMove } from "../warehouse/useWhMove";
|
2026-10-16 09:31:45 -06:00
|
|
|
|
|
2026-10-17 20:08:53 -06:00
|
|
|
|
type Tab = "drawing" | "heat" | "materials" | "checklist" | "welds" | "qc" | "workorders" | "changes" | "docs" | "history";
|
|
|
|
|
|
const TABS: readonly Tab[] = ["drawing", "heat", "materials", "checklist", "welds", "qc", "workorders", "changes", "docs", "history"];
|
2026-10-16 22:46:56 -06:00
|
|
|
|
function isTab(v: string | null): v is Tab {
|
|
|
|
|
|
return v !== null && (TABS as readonly string[]).includes(v);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const TOAST_MS = 5000;
|
2026-10-16 09:31:45 -06:00
|
|
|
|
|
|
|
|
|
|
/** 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);
|
2026-10-16 22:46:56 -06:00
|
|
|
|
const [search] = useSearchParams();
|
|
|
|
|
|
const wantTab = search.get("tab");
|
|
|
|
|
|
const [tab, setTab] = useState<Tab | null>(isTab(wantTab) ? wantTab : null);
|
2026-10-17 23:49:14 -06:00
|
|
|
|
const [sheet, setSheet] = useState<"dept" | "move" | "print" | null>(null);
|
2026-10-16 09:31:45 -06:00
|
|
|
|
const [editing, setEditing] = useState(false);
|
2026-10-16 22:46:56 -06:00
|
|
|
|
const [toast, setToast] = useState<string | null>(null);
|
|
|
|
|
|
const others = usePacketPresence(Number.isFinite(id) ? id : null);
|
2026-10-18 15:08:34 -06:00
|
|
|
|
// 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";
|
2026-10-16 09:31:45 -06:00
|
|
|
|
const versionRef = useRef<number | null>(null);
|
|
|
|
|
|
const fileRef = useRef<HTMLInputElement>(null);
|
|
|
|
|
|
|
2026-10-16 17:13:31 -06:00
|
|
|
|
// 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)), []);
|
|
|
|
|
|
|
2026-10-16 09:31:45 -06:00
|
|
|
|
const reload = useCallback(async () => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const p = await packetsApi.get(id);
|
2026-10-16 17:13:31 -06:00
|
|
|
|
take(p);
|
2026-10-16 09:31:45 -06:00
|
|
|
|
setLoadError(null);
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
setLoadError(errorText(err));
|
|
|
|
|
|
}
|
2026-10-16 17:13:31 -06:00
|
|
|
|
}, [id, take]);
|
2026-10-16 09:31:45 -06:00
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
setPacket(null);
|
2026-10-16 22:46:56 -06:00
|
|
|
|
setTab(isTab(wantTab) ? wantTab : null);
|
2026-10-16 09:31:45 -06:00
|
|
|
|
void reload();
|
2026-10-16 22:46:56 -06:00
|
|
|
|
}, [reload, wantTab]);
|
2026-10-16 09:31:45 -06:00
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
versionRef.current = packet?.version ?? null;
|
|
|
|
|
|
}, [packet]);
|
|
|
|
|
|
|
2026-10-16 22:46:56 -06:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (!toast) return;
|
|
|
|
|
|
const t = window.setTimeout(() => setToast(null), TOAST_MS);
|
|
|
|
|
|
return () => window.clearTimeout(t);
|
|
|
|
|
|
}, [toast]);
|
|
|
|
|
|
|
2026-10-16 09:31:45 -06:00
|
|
|
|
// 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) : "";
|
|
|
|
|
|
|
2026-10-16 17:13:31 -06:00
|
|
|
|
// 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) });
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-10-16 09:31:45 -06:00
|
|
|
|
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;
|
2026-10-16 17:13:31 -06:00
|
|
|
|
if (r && typeof r === "object" && "id" in r) take(r as PacketRead);
|
2026-10-16 09:31:45 -06:00
|
|
|
|
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();
|
2026-10-16 22:46:56 -06:00
|
|
|
|
// 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) });
|
2026-10-16 09:31:45 -06:00
|
|
|
|
return false;
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setBusy(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
2026-10-16 17:13:31 -06:00
|
|
|
|
[run, reload, job, take],
|
2026-10-16 09:31:45 -06:00
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
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");
|
2026-10-16 14:45:38 -06:00
|
|
|
|
const review = coToReview(p);
|
2026-10-17 20:08:53 -06:00
|
|
|
|
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"));
|
2026-10-16 09:31:45 -06:00
|
|
|
|
// 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 ?? []);
|
2026-10-16 17:13:31 -06:00
|
|
|
|
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.");
|
|
|
|
|
|
};
|
2026-10-16 09:31:45 -06:00
|
|
|
|
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]);
|
|
|
|
|
|
|
2026-10-16 22:46:56 -06:00
|
|
|
|
const cos = (p.change_orders ?? []).length;
|
|
|
|
|
|
const ckOpen = p.checklist_items.filter((i) => !p.stage?.checklist?.[i.key]?.done).length;
|
2026-10-16 09:31:45 -06:00
|
|
|
|
const tabs: Array<{ id: Tab; label: string; count?: number }> = [
|
|
|
|
|
|
{ id: "drawing", label: "Drawing", count: drawingPages.length },
|
2026-10-16 22:46:56 -06:00
|
|
|
|
{ id: "heat", label: "Heat map" },
|
2026-10-16 09:31:45 -06:00
|
|
|
|
{ id: "materials", label: "Materials", count: p.bom.length },
|
2026-10-16 22:46:56 -06:00
|
|
|
|
{ id: "checklist", label: "Checklist", ...(p.stage ? { count: ckOpen } : {}) },
|
|
|
|
|
|
{ id: "welds", label: "Weld map", count: p.welds.length },
|
2026-10-17 20:08:53 -06:00
|
|
|
|
...(hasQc ? [{ id: "qc" as const, label: "Q.C." }] : []),
|
2026-10-16 09:31:45 -06:00
|
|
|
|
{ id: "workorders", label: "Work orders", count: p.work_orders.length },
|
2026-10-16 22:46:56 -06:00
|
|
|
|
...(cos ? [{ id: "changes" as const, label: "Changes", count: cos }] : []),
|
2026-10-17 20:08:53 -06:00
|
|
|
|
{ id: "docs", label: "Documents", count: p.docs.filter((d) => !d.removed).length },
|
2026-10-16 09:31:45 -06:00
|
|
|
|
{ id: "history", label: "History" },
|
|
|
|
|
|
];
|
2026-10-16 22:46:56 -06:00
|
|
|
|
const onTabError = (text: string) => setMsg({ ok: false, text });
|
2026-10-16 09:31:45 -06:00
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<>
|
2026-10-18 15:08:34 -06:00
|
|
|
|
<Link
|
|
|
|
|
|
className="back"
|
|
|
|
|
|
to={backTo}
|
|
|
|
|
|
onClick={(e) => {
|
|
|
|
|
|
if (!hasAppHistory || e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0) return;
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
navigate(-1);
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
2026-10-16 09:31:45 -06:00
|
|
|
|
← 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}
|
2026-10-17 23:49:14 -06:00
|
|
|
|
{!p.removed ? (
|
|
|
|
|
|
<button className="btn sm" type="button" onClick={() => setSheet("print")}>
|
|
|
|
|
|
Print…
|
|
|
|
|
|
</button>
|
|
|
|
|
|
) : null}
|
2026-10-16 09:31:45 -06:00
|
|
|
|
</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}
|
2026-10-17 20:08:53 -06:00
|
|
|
|
<QcRejectedCallout p={p} />
|
|
|
|
|
|
{p.return_note && !(showsQcRejected(p) && p.return_note === p.qc_last?.note) ? (
|
2026-10-16 09:31:45 -06:00
|
|
|
|
<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}
|
2026-10-16 22:46:56 -06:00
|
|
|
|
{others.length ? (
|
|
|
|
|
|
<div className="presence small" data-testid="presence" role="status">
|
|
|
|
|
|
<span className="pdot" aria-hidden="true" />
|
2026-10-18 15:08:34 -06:00
|
|
|
|
{presenceText(others)}
|
2026-10-16 22:46:56 -06:00
|
|
|
|
</div>
|
|
|
|
|
|
) : null}
|
2026-10-16 09:31:45 -06:00
|
|
|
|
{msg ? (
|
|
|
|
|
|
<div className={msg.ok ? "callout" : "callout warn"} role={msg.ok ? "status" : "alert"}>
|
|
|
|
|
|
{msg.text}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
|
2026-10-16 14:45:38 -06:00
|
|
|
|
{review && (me?.roles ?? []).some((r) => r === "clerical" || r === "clerical_supervisor" || r === "admin") ? (
|
|
|
|
|
|
<div className="callout warn" role="status">
|
2026-10-16 22:46:56 -06:00
|
|
|
|
<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.
|
2026-10-16 14:45:38 -06:00
|
|
|
|
{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 })} />
|
|
|
|
|
|
|
2026-10-16 09:31:45 -06:00
|
|
|
|
{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}
|
|
|
|
|
|
|
2026-10-16 17:13:31 -06:00
|
|
|
|
{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}
|
|
|
|
|
|
|
2026-10-17 23:49:14 -06:00
|
|
|
|
<CloseFile p={p} busy={busy} mutate={mutate} askReason={askReason} sub={job} onPrint={() => setSheet("print")} />
|
2026-10-16 09:31:45 -06:00
|
|
|
|
|
2026-10-16 22:46:56 -06:00
|
|
|
|
<ShopBar p={p} busy={busy} mutate={mutate} askReason={askReason} sub={job} goTab={(t) => setTab(t)} />
|
2026-10-17 20:08:53 -06:00
|
|
|
|
<QcBar p={p} busy={busy} mutate={mutate} sub={job} goQc={() => setTab("qc")} />
|
|
|
|
|
|
<HoldCallout p={p} qcScreen={me?.screens.includes("qc") ?? false} />
|
2026-10-16 22:46:56 -06:00
|
|
|
|
|
2026-10-16 09:31:45 -06:00
|
|
|
|
<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>
|
2026-10-18 15:08:34 -06:00
|
|
|
|
<div key={shownTab} className="m-tab" role="tabpanel" id={`ppanel-${shownTab}`} aria-labelledby={`ptab-${shownTab}`}>
|
2026-10-16 09:31:45 -06:00
|
|
|
|
{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}
|
2026-10-17 23:49:14 -06:00
|
|
|
|
<DrawingViewer pages={drawingPages} {...(p.removed ? {} : { onPrintSheet: (n: number) => openPrint(p.id, "clean", { sheet: n }) })} />
|
2026-10-16 09:31:45 -06:00
|
|
|
|
</div>
|
|
|
|
|
|
) : null}
|
2026-10-16 22:46:56 -06:00
|
|
|
|
{shownTab === "heat" ? <HeatMapTab key={p.id} p={p} write={write} reload={reload} sub={job} onError={onTabError} /> : null}
|
2026-10-16 17:13:31 -06:00
|
|
|
|
{shownTab === "materials" ? <MaterialsTab p={p} mutate={mutate} busy={busy} write={write} reload={reload} /> : null}
|
2026-10-16 22:46:56 -06:00
|
|
|
|
{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}
|
2026-10-17 20:08:53 -06:00
|
|
|
|
{shownTab === "qc" ? <QcTab p={p} write={write} mutate={mutate} reload={reload} onError={onTabError} /> : null}
|
2026-10-16 09:31:45 -06:00
|
|
|
|
{shownTab === "workorders" ? <WorkOrdersTab p={p} /> : null}
|
2026-10-16 14:45:38 -06:00
|
|
|
|
{shownTab === "changes" ? <ChangesTab p={p} roles={me?.roles ?? []} sub={job} mutate={mutate} busy={busy} askReason={askReason} /> : null}
|
2026-10-17 20:08:53 -06:00
|
|
|
|
{shownTab === "docs" ? <DocumentsTab p={p} mutate={mutate} busy={busy} write={write} askReason={askReason} sub={job} /> : null}
|
2026-10-16 09:31:45 -06:00
|
|
|
|
{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}
|
2026-10-17 23:49:14 -06:00
|
|
|
|
{sheet === "print" ? <PrintMenu p={p} sub={job} onClose={() => setSheet(null)} /> : null}
|
2026-10-16 17:13:31 -06:00
|
|
|
|
{wh.sheet}
|
2026-10-16 09:31:45 -06:00
|
|
|
|
{guardSheet}
|
2026-10-16 22:46:56 -06:00
|
|
|
|
{toast ? (
|
|
|
|
|
|
<div className="toast" role="status" data-testid="toast">
|
|
|
|
|
|
{toast}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : null}
|
2026-10-16 09:31:45 -06:00
|
|
|
|
</>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|