Phase 3: VISUAL feed and M-Files drawings in the worker
ci / frontend (push) Successful in 30s
ci / server (push) Successful in 4m12s
ci / image (push) Successful in 46s
ci / e2e (push) Successful in 1m24s

Migration 0004 (VISUAL and drawing caches, go-live ignored list, resource
map, job tables, order-derived columns with provenance; work order key
includes the operation sequence). The real worker on APScheduler with
settings re-read each fire, pause, run now, advisory locks, job_runs and a
heartbeat. The visual-feed job (five VisualKit reads, COPY and MERGE cache,
suspect-batch guard, the apply in one transaction: drafts, change orders,
retire-moved, work order gone, closed and cancelled observed, projects and
units from order links, auto-release) and the fingerprint. The drawing-lookup
job (queue, budget, download on version change, sha256 into assets, decision
table, staging for rev approval, drawing-origin change orders). Clerical From
VISUAL panel, flags, rev approval, hand uploads, change-order review; Admin
Feed settings and resource map. Synthetic fixture generator and a
fixture-driven worker in CI with a Playwright scenario. Docs updated.

Verified: 3249 server tests and 13 Playwright scenarios on the docker-1 CI
stack, 76 frontend tests, doctor at 0004; one feed and one drawing run
against VISUAL DEV and the vault from the developer machine (counts in the
changelog).

Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
2026-10-16 14:45:38 -06:00
co-authored by Claude Fable 5.1
parent 16950055e3
commit 591fe90076
103 changed files with 16063 additions and 151 deletions
+18 -1
View File
@@ -13,8 +13,10 @@ import { dueInfo, flagClass, jobName, pillClass, statusLabel, targetHours } from
import { DrawingViewer } from "./DrawingViewer";
import { MoveSheet, SendToDeptSheet } from "./sheets";
import { DocumentsTab, HistoryTab, MaterialsTab, WorkOrdersTab, type Mutate } from "./tabs";
import { coSummary, coToReview } from "../lib/changeOrders";
import { ChangesTab, VisualBox } from "./visual";
type Tab = "drawing" | "materials" | "workorders" | "docs" | "history";
type Tab = "drawing" | "materials" | "workorders" | "changes" | "docs" | "history";
/** The rule's signature meaning for attaching a drawing by hand. */
const SIGN_ATTACH = "Attach a drawing by hand";
@@ -259,6 +261,7 @@ export function PacketPage() {
const fileIt = () => mutate((g) => packetsApi.transition(p.id, { action: "file", expect: { status: p.status, dept: p.dept.code }, ...g }), { sign: SIGN_FILE, ok: "Filed in the data book." });
const drawingPages = p.pages.filter((x) => x.type === "drawing");
const review = coToReview(p);
const shownTab: Tab = 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"]);
@@ -276,6 +279,7 @@ export function PacketPage() {
{ id: "drawing", label: "Drawing", count: drawingPages.length },
{ id: "materials", label: "Materials", count: p.bom.length },
{ id: "workorders", label: "Work orders", count: p.work_orders.length },
{ id: "changes", label: "Changes", count: (p.change_orders ?? []).length },
{ id: "docs", label: "Documents", count: p.docs.length },
{ id: "history", label: "History" },
];
@@ -401,6 +405,18 @@ export function PacketPage() {
</div>
) : null}
{review && (me?.roles ?? []).some((r) => r === "clerical" || r === "clerical_supervisor" || r === "admin") ? (
<div className="callout warn" role="status">
<b>Change order {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 ? (
@@ -522,6 +538,7 @@ export function PacketPage() {
) : null}
{shownTab === "materials" ? <MaterialsTab p={p} mutate={mutate} busy={busy} /> : 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} /> : null}
{shownTab === "history" ? <HistoryTab p={p} /> : null}
</div>
+227
View File
@@ -0,0 +1,227 @@
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import type { ChangeOrder, PacketRead } from "../api/types";
import { DEPARTMENTS, makeMe, renderApp } from "../test/auth";
import { callsTo, mockFetch } from "../test/fetchMock";
import { makePacket } from "../test/packets";
const CLARA = makeMe();
const LEO = makeMe({ user: { display_name: "Leo Lead", sam_account: "clerk.leo" }, roles: ["clerical_supervisor"] });
function visualDraft(over: Partial<PacketRead> = {}): PacketRead {
return makePacket({
source_kind: "visual",
source: { kind: "visual", batch: 1 },
drawing: "M00543",
rev: "2",
drawing_state: "needed",
flags: ["rev_mismatch"],
flag: "Rev mismatch: VISUAL calls rev 2, M-Files has rev 3",
visual: {
appr: { ok: true, rev: 3, vrev: "2", name: "M00543 r3.pdf", note: "Rev mismatch: VISUAL calls rev 2, M-Files has rev 3" },
staged_rev: { rev: 3, name: "M00543 r3.pdf" },
partspecs: ["Grind welds flush.", "Paint after test."],
folded: [{ wo: "L900001", seq: 30, op: "MFG" }],
},
can: ["edit_header", "edit_document_set", "edit_bom", "remove"],
blocked: {},
...over,
});
}
function feedCo(over: Partial<ChangeOrder> = {}): ChangeOrder {
return {
id: 51,
co_no: 2,
origin: "visual",
review: "pending",
minor: false,
hold: false,
ecn: "VISUAL",
reason: "VISUAL changed",
issued_at: "2026-10-16T08:00:00Z",
issued_by: null,
items: [],
required: [],
verified: {},
vis: { from_dwg: "M00543", from_rev: "1", to_dwg: "M00543", to_rev: "2", dwg_change: true, changes: ["L900001 qty 2 -> 3"] },
waiting: null,
staged: { asset_id: "as-2", name: "M00543 r2.pdf", rev: "2", dwg: "M00543", pages: 1 },
target_fp: "fp",
...over,
};
}
function inShop(co: ChangeOrder): PacketRead {
return makePacket({
status: "in_progress",
source_kind: "visual",
source: { kind: "visual" },
drawing_state: "attached",
flags: [],
change_orders: [co],
open_cos: [co.co_no],
can: [],
blocked: {},
holder: "03 Weld Parts",
});
}
function base(packet: PacketRead, me = CLARA) {
return {
"/api/auth/me": { body: me },
"/api/departments": { body: DEPARTMENTS },
"/api/packets/1": { body: packet },
"/api/packets/1/log": { body: [] },
};
}
async function sign(secret = "pw-1") {
const sheet = await screen.findByRole("dialog", { name: "Sign" });
fireEvent.change(within(sheet).getByLabelText("Password"), { target: { value: secret } });
fireEvent.click(within(sheet).getByRole("button", { name: "Sign" }));
await waitFor(() => expect(screen.queryByRole("dialog", { name: "Sign" })).not.toBeInTheDocument());
}
describe("Packet page, VISUAL box", () => {
it("shows the flag, the planner instructions and folded operations; Clerical sees no rev approve", async () => {
mockFetch(base(visualDraft()));
renderApp("/packets/1");
expect(await screen.findByText("Made from VISUAL - drawing not attached yet.")).toBeInTheDocument();
expect(screen.getByText(/M-Files: Rev mismatch: VISUAL calls rev 2/)).toBeInTheDocument();
expect(screen.getByText("A Clerical supervisor can approve rev 3 for this packet.")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Approve rev 3 for this packet" })).not.toBeInTheDocument();
expect(screen.getByText("Planner instructions from VISUAL (2)")).toBeInTheDocument();
expect(screen.getByText("Grind welds flush.")).toBeInTheDocument();
expect(screen.getByText("Also on these work orders: L900001 seq 30 MFG")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Upload drawing" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Use VISUAL's BOM again" })).not.toBeInTheDocument();
});
it("a Clerical supervisor approves rev N with a reason and a signature", async () => {
const fetch = mockFetch({
...base(visualDraft(), LEO),
"POST /api/packets/1/rev-approve": { body: visualDraft({ version: 4, flags: [], flag: null, drawing_state: "attached" }) },
});
renderApp("/packets/1");
fireEvent.click(await screen.findByRole("button", { name: "Approve rev 3 for this packet" }));
const ask = await screen.findByRole("dialog", { name: "Approve rev 3 for M00543" });
fireEvent.change(within(ask).getByLabelText("Reason"), { target: { value: "Build to rev 3 per Drafting" } });
fireEvent.click(within(ask).getByRole("button", { name: "Approve and attach" }));
expect(await within(await screen.findByRole("dialog", { name: "Sign" })).findByText("Approve the M-Files revision")).toBeInTheDocument();
await sign();
expect(callsTo(fetch, "POST", "/api/packets/1/rev-approve")).toEqual([
{ reason: "Build to rev 3 per Drafting", sign: { factor: "password", secret: "pw-1" } },
]);
expect(await screen.findByText("Rev 3 approved and attached.")).toBeInTheDocument();
});
it("uploads a hand BOM as multipart with the signature fields", async () => {
const fetch = mockFetch({
...base(visualDraft()),
"POST /api/packets/1/hand/bom": { body: visualDraft({ version: 4, visual: { manual_bom: { name: "bom.csv", reason: "No materials yet", lines: 3 } } }) },
});
renderApp("/packets/1");
fireEvent.click(await screen.findByRole("button", { name: "Upload BOM" }));
const sheet = await screen.findByRole("dialog", { name: "Upload the BOM" });
const file = new File(["Item,Qty,Description\n1,2,Plate\n"], "bom.csv", { type: "text/csv" });
fireEvent.change(within(sheet).getByLabelText("File (XLSX or CSV, up to 25 MB)"), { target: { files: [file] } });
fireEvent.change(within(sheet).getByLabelText("Why by hand (kept on the packet)"), { target: { value: "No materials yet" } });
fireEvent.click(within(sheet).getByRole("button", { name: "Upload and sign" }));
await sign("pw-9");
const call = fetch.mock.calls.find(([u, init]) => String(u) === "/api/packets/1/hand/bom" && init?.method === "POST");
const fd = call?.[1]?.body as FormData;
expect(fd).toBeInstanceOf(FormData);
expect(fd.get("reason")).toBe("No materials yet");
expect(fd.get("sign_factor")).toBe("password");
expect(fd.get("sign_secret")).toBe("pw-9");
expect((fd.get("file") as File).name).toBe("bom.csv");
expect(await screen.findByRole("button", { name: "Use VISUAL's BOM again" })).toBeInTheDocument();
expect(screen.getByText("BOM uploaded by hand:", { exact: false })).toBeInTheDocument();
});
it("uploads a hand drawing: the asset first, then the attach with reason and sign", async () => {
const fetch = mockFetch({
...base(visualDraft()),
"POST /api/assets": { status: 201, body: { id: "as-9", mime: "application/pdf", size: 9, sha256: "00", pages: 1, original_name: "d.pdf", url: "/_blob/as-9" } },
"POST /api/packets/1/hand/drawing": { body: visualDraft({ version: 4, flags: ["hand_drawing"] }) },
});
renderApp("/packets/1");
fireEvent.click(await screen.findByRole("button", { name: "Upload drawing" }));
const sheet = await screen.findByRole("dialog", { name: "Upload the drawing" });
fireEvent.change(within(sheet).getByLabelText("File (PDF, up to 25 MB)"), { target: { files: [new File(["%PDF-1.4"], "d.pdf", { type: "application/pdf" })] } });
fireEvent.change(within(sheet).getByLabelText("Why by hand (kept on the packet)"), { target: { value: "Drafting sent it" } });
fireEvent.click(within(sheet).getByRole("button", { name: "Upload and sign" }));
await sign();
expect(callsTo(fetch, "POST", "/api/packets/1/hand/drawing")).toEqual([
{ asset_id: "as-9", reason: "Drafting sent it", sign: { factor: "password", secret: "pw-1" } },
]);
});
});
describe("Packet page, Changes tab", () => {
it("Clerical approves a staged feed change order with a signature", async () => {
const co = feedCo();
const fetch = mockFetch({
...base(inShop(co)),
"POST /api/packets/1/change-orders/2/approve": { body: inShop({ ...co, review: "approved", approved_by: { id: "u", display_name: "Clara Clerk" }, required: ["03"] }) },
});
renderApp("/packets/1");
expect(await screen.findByText(/Change order 2: a change from VISUAL is waiting for your approval/)).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Review change" }));
const sec = await screen.findByRole("region", { name: "Change order 2" });
expect(within(sec).getByText("M00543 Rev 1 -> Rev 2 + 1 other change")).toBeInTheDocument();
expect(within(sec).getByText("L900001 qty 2 -> 3")).toBeInTheDocument();
expect(within(sec).getByRole("link", { name: "Open the new drawing" })).toHaveAttribute("href", "/_blob/as-2");
fireEvent.click(within(sec).getByRole("button", { name: "Approve - update the shop packet" }));
expect(await within(await screen.findByRole("dialog", { name: "Sign" })).findByText("Approve a change order")).toBeInTheDocument();
await sign();
expect(callsTo(fetch, "POST", "/api/packets/1/change-orders/2/approve")).toEqual([{ sign: { factor: "password", secret: "pw-1" } }]);
expect(await screen.findByText("Change order 2 approved. The shop packet is updated.")).toBeInTheDocument();
expect(await screen.findByRole("table", { name: "Change order 2 verification" })).toBeInTheDocument();
});
it("Approve is disabled with the reason while the drawing waits; reject and hold post", async () => {
const co = feedCo({ staged: null, waiting: { dwg: "M00543", rev: "2" } });
const fetch = mockFetch({
...base(inShop(co)),
"POST /api/packets/1/change-orders/2/hold": { body: inShop({ ...co, hold: true }) },
"POST /api/packets/1/change-orders/2/reject": { body: inShop({ ...co, hold: true, review: "rejected", rejected_reason: "Not for this unit" }) },
});
renderApp("/packets/1");
fireEvent.click(await screen.findByRole("tab", { name: /Changes/ }));
const sec = await screen.findByRole("region", { name: "Change order 2" });
expect(within(sec).getByRole("button", { name: "Approve - update the shop packet" })).toBeDisabled();
expect(within(sec).getByText("Waits on that rev in M-Files.")).toHaveClass("why");
expect(within(sec).getByText(/That rev isn't released in M-Files yet/)).toBeInTheDocument();
fireEvent.click(within(sec).getByRole("button", { name: "Hold the shop packet now" }));
expect(await within(sec).findByRole("button", { name: "Release the hold" })).toBeInTheDocument();
expect(callsTo(fetch, "POST", "/api/packets/1/change-orders/2/hold")).toEqual([{ hold: true }]);
fireEvent.click(within(sec).getByRole("button", { name: "Reject" }));
const ask = await screen.findByRole("dialog", { name: "Reject change order 2" });
fireEvent.change(within(ask).getByLabelText("Reason"), { target: { value: "Not for this unit" } });
fireEvent.click(within(ask).getByRole("button", { name: "Reject" }));
await sign();
expect(callsTo(fetch, "POST", "/api/packets/1/change-orders/2/reject")).toEqual([
{ reason: "Not for this unit", sign: { factor: "password", secret: "pw-1" } },
]);
});
it("shows the co_not_staged answer as the callout text", async () => {
const co = feedCo();
mockFetch({
...base(inShop(co)),
"POST /api/packets/1/change-orders/2/approve": {
status: 409,
body: { detail: "The new drawing has not been fetched from M-Files yet.", code: "co_not_staged" },
},
});
renderApp("/packets/1");
fireEvent.click(await screen.findByRole("button", { name: "Review change" }));
fireEvent.click(await screen.findByRole("button", { name: "Approve - update the shop packet" }));
await sign();
expect(await screen.findByRole("alert")).toHaveTextContent("The new drawing has not been fetched from M-Files yet.");
});
});
+562
View File
@@ -0,0 +1,562 @@
// The VISUAL box and the Changes tab on the packet page (phase 3).
import { useState, type FormEvent } from "react";
import { assetsApi, blobUrl } from "../api/assets";
import { packetsApi } from "../api/packets";
import type { ChangeOrder, FoldedOp, HandUpload, PacketRead, WhoRef } from "../api/types";
import type { ReasonOptions } from "../components/ReasonSheet";
import { Sheet } from "../components/Sheet";
import { errorText, fmtTime } from "../lib/format";
import { coSummary, feedOrigin } from "../lib/changeOrders";
import { DRAWING_FLAGS } from "../lib/packets";
import type { Mutate } from "./tabs";
/** Signature meanings, as the rules state them. */
export const SIGN_REV_APPROVE = "Approve the M-Files revision";
export const SIGN_HAND_DRAWING = "Attach a drawing by hand";
export const SIGN_HAND_BOM = "Upload the BOM by hand";
export const SIGN_CO_APPROVE = "Approve a change order";
export const SIGN_CO_REJECT = "Reject a change order";
type AskReason = (opts: ReasonOptions) => Promise<string | null>;
const isCl = (roles: readonly string[]) => roles.some((r) => r === "clerical" || r === "clerical_supervisor" || r === "admin");
const isClSup = (roles: readonly string[]) => roles.some((r) => r === "clerical_supervisor" || r === "admin");
function whoName(by: WhoRef | string | null | undefined): string {
if (!by) return "";
return typeof by === "string" ? by : by.display_name;
}
function foldedText(f: FoldedOp): string {
const k = f.key;
const wo = f.wo ?? f.wo_base ?? (k && typeof k === "object" ? [k.base, k.lot].filter(Boolean).join("/") : typeof k === "string" ? k : "");
return [wo, f.seq !== undefined ? `seq ${f.seq}` : "", f.op ?? ""].filter(Boolean).join(" ");
}
/* ---------- Hand upload sheet ---------- */
function HandSheet({
p,
kind,
sub,
onCancel,
onSubmit,
}: {
p: PacketRead;
kind: "drawing" | "bom";
sub: string;
onCancel: () => void;
onSubmit: (file: File, reason: string) => void;
}) {
const dwg = kind === "drawing";
const [file, setFile] = useState<File | null>(null);
const [reason, setReason] = useState("");
const [error, setError] = useState<string | null>(null);
const submit = (e: FormEvent) => {
e.preventDefault();
if (!file) {
setError("Choose the file.");
return;
}
if (reason.trim().length < 3) {
setError("Say why it is uploaded by hand.");
return;
}
if (dwg && !/\.pdf$/i.test(file.name) && file.type !== "application/pdf") {
setError("The drawing must be a PDF.");
return;
}
onSubmit(file, reason.trim());
};
return (
<Sheet title={dwg ? "Upload the drawing" : "Upload the BOM"} sub={sub} onClose={onCancel}>
<div className="small">
{dwg ? (
<>
VISUAL calls{" "}
<b>
{p.drawing || "-"} rev {p.rev || "-"}
</b>
. Upload that drawing as a PDF. It replaces any drawing on this draft.
</>
) : (
<>Excel or CSV with a header row (Item, Qty, Part ID, Description, Length, Material, any of those names). It replaces the BOM on this draft. The feed will not overwrite it.</>
)}
</div>
<form className="stack" onSubmit={submit} noValidate>
<label className="f">
{dwg ? "File (PDF, up to 25 MB)" : "File (XLSX or CSV, up to 25 MB)"}
<input
className="in"
type="file"
accept={dwg ? "application/pdf" : ".xlsx,.csv,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,text/csv"}
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
/>
</label>
<label className="f">
Why by hand (kept on the packet)
<input
className="in"
value={reason}
maxLength={200}
placeholder={dwg ? "e.g. rev 2 not released in M-Files yet, Drafting sent the PDF" : "e.g. no materials in VISUAL yet, BOM from Planning"}
onChange={(e) => setReason(e.target.value)}
/>
</label>
{error ? (
<div className="callout warn flush" role="alert">
{error}
</div>
) : null}
<div className="sheet-acts">
<button className="btn ghost" type="button" onClick={onCancel}>
Cancel
</button>
<button className="btn pri" type="submit">
Upload and sign
</button>
</div>
</form>
</Sheet>
);
}
function HandLine({ what, m }: { what: string; m: HandUpload }) {
return (
<div className="small">
{what} uploaded by hand: <span className="mono">{m.name ?? ""}</span>
{m.by ? ` - ${whoName(m.by)}` : ""} {m.at ? fmtTime(m.at) : ""}
{m.reason ? ` · ${m.reason}` : ""}
{typeof m.lines === "number" ? ` · ${m.lines} line${m.lines === 1 ? "" : "s"}` : ""}
</div>
);
}
/* ---------- The VISUAL box ---------- */
export function VisualBox({
p,
roles,
sub,
mutate,
busy,
askReason,
onError,
}: {
p: PacketRead;
roles: readonly string[];
sub: string;
mutate: Mutate;
busy: boolean;
askReason: AskReason;
onError: (text: string) => void;
}) {
const [hand, setHand] = useState<"drawing" | "bom" | null>(null);
const [uploading, setUploading] = useState(false);
if (p.source_kind !== "visual") return null;
const v = p.visual ?? {};
const flags = p.flags ?? [];
const draft = p.status === "draft";
const hasDwg = p.pages.some((x) => x.type === "drawing" && !x.void);
const drawingFlag = flags.some((f) => (DRAWING_FLAGS as readonly string[]).includes(f));
const appr = v.appr;
const staged = v.staged_rev;
const mismatch = flags.includes("rev_mismatch");
const md = v.manual_dwg;
const mb = v.manual_bom;
const canHand = draft && !p.removed && (isCl(roles) || p.can.includes("edit_document_set")) && (flags.length > 0 || !!md || !!mb);
const canApproveRev = draft && !p.removed && (p.can.includes("rev_approve") || isClSup(roles));
const partspecs = v.partspecs ?? [];
const folded = v.folded ?? [];
const vs = p.visual_state;
const approveRev = async () => {
if (!staged) return;
const reason = await askReason({
title: `Approve rev ${staged.rev} for ${p.drawing ?? "this drawing"}`,
sub,
note: `VISUAL calls rev ${p.rev || "-"}. The drawing in M-Files is rev ${staged.rev}${staged.name ? `: ${staged.name}` : ""}. That drawing is attached to this packet. It is recorded with your name, signature and the reason. If VISUAL's rev changes, the packet is flagged again.`,
placeholder: "e.g. rev 2 not released yet - build to rev 1 per Drafting",
min: 3,
max: 400,
submitLabel: "Approve and attach",
});
if (reason === null) return;
await mutate((g) => packetsApi.revApprove(p.id, { reason, ...g }), { sign: SIGN_REV_APPROVE, ok: `Rev ${staged.rev} approved and attached.` });
};
const upload = async (kind: "drawing" | "bom", file: File, reason: string) => {
setHand(null);
if (kind === "bom") {
await mutate((g) => packetsApi.handBom(p.id, file, reason, g), { sign: SIGN_HAND_BOM, ok: "BOM uploaded. Check it, then send it on." });
return;
}
setUploading(true);
let asset;
try {
asset = await assetsApi.upload(file, { packet_id: p.id, purpose: "drawing" });
} catch (err) {
onError(errorText(err));
return;
} finally {
setUploading(false);
}
if (asset.mime !== "application/pdf") {
onError("The drawing must be a PDF.");
return;
}
await mutate((g) => packetsApi.handDrawing(p.id, { asset_id: asset.id, reason, ...g }), {
sign: SIGN_HAND_DRAWING,
ok: "Drawing attached. Check it, then send it on.",
});
};
const bomOff = () => mutate((g) => packetsApi.removeHandBom(p.id, g), { ok: "Hand BOM taken off. VISUAL's BOM is back." });
const working = busy || uploading;
return (
<section className="stack" aria-label="From VISUAL">
{v.rev_ok && hasDwg ? (
<div className="callout flush">
<b>Rev {String(v.rev_ok.rev)} approved by hand</b> in place of VISUAL rev {v.rev_ok.vis_rev || "-"}
{v.rev_ok.by ? ` - ${whoName(v.rev_ok.by)}` : ""} {v.rev_ok.at ? fmtTime(v.rev_ok.at) : ""}
{v.rev_ok.reason ? `: ${v.rev_ok.reason}` : ""}.
</div>
) : null}
{!hasDwg || drawingFlag ? (
<div className="callout warn flush">
<b>{hasDwg ? "Made from VISUAL - drawing needs a look." : "Made from VISUAL - drawing not attached yet."}</b> {p.drawing || "No drawing number"} rev {p.rev || "-"}. The drawing
is attached from M-Files automatically when its rev matches the rev VISUAL calls. Only Clerical can upload one by hand, and only while the packet is flagged.
{appr?.note ? <div className="small">M-Files: {appr.note}{appr.at ? ` (looked ${fmtTime(appr.at)})` : ""}</div> : null}
{mismatch ? (
<div className="small">
{staged ? (
<>
In M-Files: <span className="mono">{staged.name ?? `rev ${staged.rev}`}</span>.{" "}
{canApproveRev ? (
<button className="btn sm pri" type="button" disabled={working} onClick={() => void approveRev()}>
Approve rev {String(staged.rev)} for this packet
</button>
) : draft ? (
<span className="muted">A Clerical supervisor can approve rev {String(staged.rev)} for this packet.</span>
) : null}
</>
) : (
<span className="muted">The M-Files drawing is being copied. Check back after the next drawing run.</span>
)}
</div>
) : null}
</div>
) : null}
{canHand ? (
<div className="callout flush">
<b>Flagged - Clerical can upload the drawing or the BOM by hand.</b> Only if it cannot come from M-Files or VISUAL in time. It is recorded with your name,
signature and reason. The packet stays in Flagged until you send it on.
<div className="acts">
<button className="btn sm pri" type="button" disabled={working} onClick={() => setHand("drawing")}>
{md ? "Replace drawing" : "Upload drawing"}
</button>
<button className="btn sm" type="button" disabled={working} onClick={() => setHand("bom")}>
{mb ? "Replace BOM" : "Upload BOM"}
</button>
{mb ? (
<button className="btn sm ghost" type="button" disabled={working} onClick={() => void bomOff()}>
Use VISUAL's BOM again
</button>
) : null}
</div>
{md ? <HandLine what="Drawing" m={md} /> : null}
{mb ? <HandLine what="BOM" m={mb} /> : null}
</div>
) : null}
{vs && (vs.closed_at || vs.cancelled_at || vs.ticket_hours) ? (
<div className="small">
{vs.closed_at ? <span>Closed in VISUAL (seen {fmtTime(vs.closed_at)}). </span> : null}
{vs.cancelled_at ? <span className="due over">Cancelled in VISUAL (seen {fmtTime(vs.cancelled_at)}). </span> : null}
{vs.ticket_hours !== null && vs.ticket_hours !== undefined && Number(vs.ticket_hours) > 0 ? (
<span className="muted">Labor tickets in VISUAL: {Number(vs.ticket_hours).toFixed(1)} h.</span>
) : null}
</div>
) : null}
{partspecs.length || folded.length ? (
<details>
<summary className="small">
{partspecs.length ? `Planner instructions from VISUAL (${partspecs.length})` : `Also on these work orders (${folded.length})`}
</summary>
{partspecs.map((s, i) => (
<pre key={i} className="small pre">
{s}
</pre>
))}
{folded.length ? <div className="small muted">Also on these work orders: {folded.map(foldedText).join(", ")}</div> : null}
</details>
) : null}
{hand ? <HandSheet p={p} kind={hand} sub={sub} onCancel={() => setHand(null)} onSubmit={(f, r) => void upload(hand, f, r)} /> : null}
</section>
);
}
/* ---------- Change orders ---------- */
function coPill(co: ChangeOrder) {
if (co.review === "rejected") return <span className="pill st-filed">Rejected</span>;
if (co.review === "pending") return <span className="pill st-review">Waiting on Clerical</span>;
const open = co.required.filter((d) => !co.verified[d]);
if (open.length) return <span className="pill st-review">Open - {open.length} to verify</span>;
return <span className="pill st-in_progress">Verified</span>;
}
function ReviewPanel({
p,
co,
sub,
mutate,
busy,
askReason,
canReview,
}: {
p: PacketRead;
co: ChangeOrder;
sub: string;
mutate: Mutate;
busy: boolean;
askReason: AskReason;
canReview: boolean;
}) {
const v = co.vis ?? {};
const needsDrawing = !!v.dwg_change || !!co.waiting;
const ready = !needsDrawing || !!co.staged;
const why = ready ? "" : "Waits on that rev in M-Files.";
const old = p.pages.find((x) => x.type === "drawing" && !x.void);
const approve = () =>
mutate((g) => packetsApi.coApprove(p.id, co.co_no, g), { sign: SIGN_CO_APPROVE, ok: `Change order ${co.co_no} approved. The shop packet is updated.` });
const reject = async () => {
const reason = await askReason({
title: `Reject change order ${co.co_no}`,
sub,
note: "The shop packet is not changed. If VISUAL changes again, a new change order comes in.",
min: 3,
max: 400,
danger: true,
submitLabel: "Reject",
});
if (reason === null) return;
await mutate((g) => packetsApi.coReject(p.id, co.co_no, { reason, ...g }), { sign: SIGN_CO_REJECT, ok: `Change order ${co.co_no} rejected.` });
};
const hold = () =>
mutate((g) => packetsApi.coHold(p.id, co.co_no, !co.hold, g), { ok: co.hold ? "Hold released." : "Shop packet held." });
return (
<div className="panel stack">
<h3>Clerical review - nothing has gone to the shop yet</h3>
{needsDrawing ? (
co.staged ? (
<div className="small">
<span className="pill st-filed">NOW</span> {old?.dwg || v.from_dwg || "sheet"} Rev {old?.rev || v.from_rev || "?"} <span className="muted">-&gt;</span>{" "}
<span className="pill st-in_progress">NEW</span> {co.staged.dwg} Rev {co.staged.rev} <span className="muted small">{co.staged.name ?? ""}</span>{" "}
<a className="lnk" href={blobUrl(co.staged.asset_id)} target="_blank" rel="noopener noreferrer">
Open the new drawing
</a>
</div>
) : (
<div className="callout warn flush">
VISUAL calls {co.waiting?.dwg ?? v.to_dwg ?? ""} Rev {co.waiting?.rev ?? v.to_rev ?? "?"}. That rev isn't released in M-Files yet. It shows up here when it is.
{co.waiting?.err ? <div className="small">Last try: {co.waiting.err}</div> : null}
</div>
)
) : null}
{v.changes?.length ? (
<div className="small">
<b>Also changing on approval:</b>
<ul>
{v.changes.map((x, i) => (
<li key={i}>{x}</li>
))}
</ul>
<span className="muted">BOM lines keep the heats, pulls and issues already recorded on them.</span>
</div>
) : null}
{co.minor ? <div className="small muted">A minor change (target hours or want date). No department needs to verify it.</div> : null}
{canReview ? (
<>
<div className="acts">
<div className="act">
<button className="btn pri" type="button" disabled={busy || !ready} title={why || undefined} onClick={() => void approve()}>
Approve - update the shop packet
</button>
{why ? <div className="why small">{why}</div> : null}
</div>
<div className="act">
<button className="btn pink" type="button" disabled={busy} onClick={() => void reject()}>
Reject
</button>
</div>
<div className="act">
<button className="btn ghost" type="button" disabled={busy} onClick={() => void hold()}>
{co.hold ? "Release the hold" : "Hold the shop packet now"}
</button>
</div>
</div>
<div className="small muted">
{co.hold
? "Held. Sign-offs on this packet are stopped until you approve or reject."
: "The shop keeps working on the current drawing until you approve. Hold it if they should stop now."}
</div>
</>
) : (
<div className="small muted">A Clerical user approves or rejects this change.</div>
)}
</div>
);
}
/** The Changes tab: every change order, newest first. Clerical reviews the ones from VISUAL and M-Files here. */
export function ChangesTab({
p,
roles,
sub,
mutate,
busy,
askReason,
}: {
p: PacketRead;
roles: readonly string[];
sub: string;
mutate: Mutate;
busy: boolean;
askReason: AskReason;
}) {
const list = [...(p.change_orders ?? [])].sort((a, b) => b.co_no - a.co_no);
if (!list.length) return <div className="empty">No change orders on this packet.</div>;
const canReview = isCl(roles) || p.can.includes("co_approve");
return (
<div className="stack">
{list.map((co) => {
const feed = feedOrigin(co);
return (
<section key={co.id} className="sec" aria-label={`Change order ${co.co_no}`}>
<div className="sec-h flush">
<h2>
Change order {co.co_no}
{co.ecn ? ` · ${co.ecn}` : ""}
</h2>
{coPill(co)}
<span className="ct">
{feed ? (co.origin === "drawing" ? "from M-Files" : "from VISUAL") : `issued by ${co.issued_by?.display_name ?? "-"}`} {fmtTime(co.issued_at)}
{co.hold ? " · shop packet on hold" : ""}
</span>
</div>
<div className="small">
<b>{coSummary(co)}</b>
</div>
{co.review === "pending" && feed ? (
canReview ? (
<ReviewPanel p={p} co={co} sub={sub} mutate={mutate} busy={busy} askReason={askReason} canReview={canReview} />
) : (
<div className="callout warn flush">
<b>Clerical is reviewing a change from VISUAL.</b> Keep working to the current drawing. If Clerical approves it, the new drawing and changes come onto
this packet and you will be asked to verify.
</div>
)
) : null}
{co.review === "rejected" ? (
<div className="callout flush">
{co.rejected_by ? (
<>
<b>Rejected by Clerical</b> - {co.rejected_by.display_name} {fmtTime(co.rejected_at)}: {co.rejected_reason ?? ""}. The shop packet was not changed.
</>
) : (
<>
<b>Withdrawn</b> {fmtTime(co.rejected_at)}: {co.rejected_reason ?? "VISUAL went back to what the packet has"}. The shop packet was not changed.
</>
)}
</div>
) : null}
{co.review === "approved" && co.approved_by ? (
<div className="small">
<b>Approved by Clerical:</b> {co.approved_by.display_name} {fmtTime(co.approved_at)}
</div>
) : null}
{co.review === "approved" && feed && co.vis?.changes?.length ? (
<div className="small">
<b>Also changed from VISUAL:</b> {co.vis.changes.join("; ")}
</div>
) : null}
<div className="small">
<b>Reason:</b> {co.reason || "-"}
</div>
{co.items.length ? (
<div className="tw">
<table aria-label={`Change order ${co.co_no} sheets`}>
<thead>
<tr>
<th>Old sheet</th>
<th>New sheet</th>
</tr>
</thead>
<tbody>
{co.items.map((it, i) => (
<tr key={i}>
<td className="small">
{it.added ? (
<span className="muted">Added</span>
) : (
<>
<span className="pill st-filed">VOID</span> {it.old_dwg || "sheet"} Rev {it.old_rev || "?"}
{it.old_page ? <span className="muted"> · page {it.old_page}</span> : null}
</>
)}
</td>
<td className="small">
<span className="pill st-in_progress">{it.added ? "ADDED" : "NEW"}</span> {it.new_dwg || "sheet"} Rev {it.new_rev || "?"}
{it.new_page ? <span className="muted"> · page {it.new_page}</span> : null}
</td>
</tr>
))}
</tbody>
</table>
</div>
) : null}
{co.review === "approved" && co.required.length ? (
<div className="tw">
<table aria-label={`Change order ${co.co_no} verification`}>
<thead>
<tr>
<th>Department</th>
<th>Verified</th>
<th>Note</th>
</tr>
</thead>
<tbody>
{co.required.map((d) => {
const ver = co.verified[d];
return (
<tr key={d}>
<td className="mono">{d}</td>
<td className="small">{ver ? `${ver.by?.display_name ?? "-"} ${fmtTime(ver.at)}` : <span className="due soon">Pending</span>}</td>
<td className="small">{ver?.note ?? ""}</td>
</tr>
);
})}
</tbody>
</table>
</div>
) : null}
</section>
);
})}
</div>
);
}