Phase 3: VISUAL feed and M-Files drawings in the worker
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:
@@ -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>
|
||||
|
||||
@@ -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.");
|
||||
});
|
||||
});
|
||||
@@ -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">-></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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user