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
@@ -12,14 +12,62 @@ const CARDS = [
makeCard({ id: 5, status: "complete", routed_to: "databook", unit: { id: 13, number: "L999003" } }),
makeCard({ id: 6, status: "complete", routed_to: "databook", vclosed: { at: "2026-10-15T10:00:00Z", by: null }, unit: { id: 14, number: "L999004" } }),
makeCard({ id: 7, status: "filed", unit: { id: 15, number: "L999005" } }),
makeCard({ id: 8, status: "in_progress", co_pending: true, source_kind: "visual", unit: { id: 16, number: "L999006" } }),
];
const OVERVIEW = {
status: { paused: false, next_run_at: "2026-10-16T08:03:00Z", worker: { at: "2026-10-16T07:59:50Z", build: "1.0", running: [], stale: false } },
last_run: {
id: 42,
job: "visual-feed",
trigger: "schedule",
started_at: "2026-10-16T07:58:00Z",
finished_at: "2026-10-16T07:58:20Z",
status: "ok",
counts: { new_drafts: 4, drafts_updated: 2, cos_opened: 1 },
errors: [],
},
runs: [
{
id: 42,
job: "visual-feed",
trigger: "schedule",
started_at: "2026-10-16T07:58:00Z",
finished_at: "2026-10-16T07:58:20Z",
status: "ok",
counts: { new_drafts: 4, drafts_updated: 2, cos_opened: 1 },
errors: [],
},
{
id: 41,
job: "visual-feed",
trigger: "request",
started_at: "2026-10-16T07:50:00Z",
finished_at: "2026-10-16T07:50:30Z",
status: "degraded",
counts: {},
errors: ["orders read failed"],
},
],
drawing_runs: [
{ id: 90, job: "drawing-lookup", trigger: "chain", started_at: "2026-10-16T07:58:21Z", finished_at: null, status: "ok", counts: { attached: 3 }, errors: [] },
],
ignored: [
{ id: 5, wo_base: "L900099", lot: "1", split: "0", sub: "0", rel: "2026-09-01", project: "L900", unit_number: "L900001", res: "WELD-PART", dwg: "D-1", pulled_in_at: null },
],
unmapped: [{ resource: "NEWRES", rows: 3 }],
projects_without_names: [{ id: 70, number: "L900", units: 2, packets: 4 }],
golive_date: "2026-10-01",
departments: [],
};
function base() {
return {
"/api/auth/me": { body: makeMe() },
"/api/departments": { body: DEPARTMENTS },
"/api/packets": { body: { packets: CARDS } },
"/api/projects": { body: [makeProject()] },
"/api/feed/overview": { body: OVERVIEW },
};
}
@@ -36,11 +84,13 @@ describe("ClericalHome", () => {
expect(screen.getByLabelText("Flagged: 1")).toHaveClass("kpi", "hot");
expect(screen.getByLabelText("With warehouse: 1")).toBeInTheDocument();
expect(screen.getByLabelText("In the shop: 1")).toBeInTheDocument();
expect(screen.getByLabelText("In the shop: 2")).toBeInTheDocument();
expect(screen.getByLabelText("Ready to close: 1")).toHaveClass("hot");
expect(screen.getByLabelText("Changes to approve: 0")).toHaveClass("zero");
expect(screen.getByLabelText("Changes to approve: 1")).toHaveClass("hot");
expect(within(column("Changes to approve")).getByText("Arrives in phase 3 with the VISUAL feed.")).toBeInTheDocument();
const cos = column("Changes to approve");
expect(within(cos).getByText("L999006 · WELD-PART")).toBeInTheDocument();
expect(within(cos).getByText("Change from VISUAL to approve")).toBeInTheDocument();
const flagged = column("Flagged");
expect(within(flagged).getByText("L999001 · WELD-PART")).toBeInTheDocument();
expect(within(flagged).getByText(/Made by hand/)).toBeInTheDocument();
@@ -53,8 +103,8 @@ describe("ClericalHome", () => {
// The list view holds the same packets.
fireEvent.click(screen.getByRole("button", { name: "List" }));
const table = await screen.findByRole("table");
expect(within(table).getAllByRole("row")).toHaveLength(1 + 5);
const table = await screen.findByRole("table", { name: "Jobs" });
expect(within(table).getAllByRole("row")).toHaveLength(1 + 6);
});
it("the picker loads the units after a project is chosen", async () => {
@@ -105,4 +155,74 @@ describe("ClericalHome", () => {
expect(await screen.findByRole("heading", { name: "L998 · New one" })).toBeInTheDocument();
expect(callsTo(fetch, "POST", "/api/projects/9/units")).toEqual([{ lots: ["001", "002", "003"], name: "", due: null, notes: "" }]);
});
it("the From VISUAL panel shows the status and the runs, and Run now posts a request", async () => {
const fetch = mockFetch({ ...base(), "POST /api/feed/run": { body: { requested: true, pending: 1 } } });
renderApp("/clerical");
const status = await screen.findByTestId("feed-status");
expect(status).toHaveTextContent(/^On · next run .* · last run .*: 4 new, 2 updated, 1 change order$/);
const runs = screen.getByRole("table", { name: "Last feed runs" });
const rows = within(runs).getAllByRole("row");
expect(rows).toHaveLength(3);
expect(within(rows[1] as HTMLElement).getByText("OK")).toHaveClass("pill", "st-ok");
// The chained drawing run after run 42 attached 3 drawings.
expect(within(rows[1] as HTMLElement).getAllByRole("cell")[6]).toHaveTextContent("3");
expect(within(rows[2] as HTMLElement).getByText("orders read failed")).toBeInTheDocument();
expect(screen.getByText("NEWRES")).toBeInTheDocument();
expect(screen.getByText(/IT assigns them on the Admin page/)).toBeInTheDocument();
expect(screen.getByRole("link", { name: "L900" })).toHaveAttribute("href", "/clerical/projects/70");
fireEvent.click(screen.getByRole("button", { name: "Run now" }));
expect(await screen.findByText("Run requested. The boards update when it finishes.")).toBeInTheDocument();
expect(callsTo(fetch, "POST", "/api/feed/run")).toEqual([{ job: "visual-feed" }]);
});
it("shows Worker not running and Paused from the overview", async () => {
mockFetch({ ...base(), "/api/feed/overview": { body: { ...OVERVIEW, status: { ...OVERVIEW.status, worker: { ...OVERVIEW.status.worker, stale: true } } } } });
renderApp("/clerical");
expect(await screen.findByTestId("feed-status")).toHaveTextContent(/^Worker not running/);
});
it("Pull in asks a reason and posts it", async () => {
const fetch = mockFetch({ ...base(), "POST /api/feed/ignored/5/pull-in": { body: { ...OVERVIEW.ignored[0], pulled_in_at: "2026-10-16T08:00:00Z" } } });
renderApp("/clerical");
fireEvent.click(await screen.findByRole("button", { name: "Pull in L900099/1/0/0" }));
const sheet = await screen.findByRole("dialog", { name: "Pull in work order" });
fireEvent.change(within(sheet).getByLabelText("Reason"), { target: { value: "Customer wants it here" } });
fireEvent.click(within(sheet).getByRole("button", { name: "Pull in" }));
expect(await screen.findByText("L900099/1/0/0 pulled in. It comes through on the next run.")).toBeInTheDocument();
expect(callsTo(fetch, "POST", "/api/feed/ignored/5/pull-in")).toEqual([{ reason: "Customer wants it here" }]);
});
it("the flag kind select filters the list and the Flagged column shows the flag text", async () => {
const flagged = makeCard({ id: 9, source_kind: "visual", flag: "Rev mismatch: VISUAL calls rev 2, M-Files has rev 3", flag_kinds: ["rev_mismatch"] });
const fetch = mockFetch({
...base(),
"/api/packets?limit=500&flag=rev_mismatch": { body: { packets: [flagged] } },
});
renderApp("/clerical");
const bar = await screen.findByRole("region", { name: "Find jobs" });
const select = within(bar).getByLabelText("Flags");
expect(within(select).getAllByRole("option").map((o) => o.textContent)).toEqual([
"All packets",
"All flags",
"Drawing needed",
"Rev mismatch",
"Not released",
"Not found",
"Lookup failed",
"Work order gone",
"Cancelled",
"Hand upload",
]);
fireEvent.change(select, { target: { value: "rev_mismatch" } });
await waitFor(() => expect(fetch.mock.calls.map(([u]) => String(u))).toContain("/api/packets?limit=500&flag=rev_mismatch"));
const col = await waitFor(() => {
const c = column("Flagged");
expect(within(c).getByText(/Rev mismatch: VISUAL calls rev 2/)).toHaveClass("due", "over");
return c;
});
expect(within(col).queryByText("L999001 · WELD-PART", { exact: false })).toBeInTheDocument();
expect(screen.getByLabelText("Flagged: 1")).toBeInTheDocument();
});
});
+20 -6
View File
@@ -14,6 +14,7 @@ import { STATUSES } from "../../api/types";
import { dueInfo, flagClass, jobName, pillClass, statusLabel } from "../../lib/packets";
import { useLoad } from "../../lib/useLoad";
import { byTime, clericalBoards } from "./boards";
import { FromVisualPanel } from "./FromVisualPanel";
import { ProjectsSection } from "./ProjectsSection";
import { NewPacketSheet } from "./sheets";
@@ -71,7 +72,7 @@ function JobList({ rows, sort, onSort }: { rows: PacketCard[]; sort: SortKey; on
</label>
</div>
<div className="tw">
<table className="joblist">
<table className="joblist" aria-label="Jobs">
<thead>
<tr>
<th>Job</th>
@@ -139,6 +140,7 @@ export function ClericalHome() {
const [view, setView] = useState<BoardView>("board");
const [sort, setSort] = useState<SortKey>("updated");
const [drafting, setDrafting] = useState(false);
const [flag, setFlag] = useState("");
const projectId = pick.project?.id;
const unitId = pick.unit?.id;
@@ -148,8 +150,12 @@ export function ClericalHome() {
}, [q]);
const [load, reload] = useLoad(
(s) => packetsApi.list({ ...(query ? { q: query } : {}), ...(unitId ? { unit_id: unitId } : projectId ? { project_id: projectId } : {}) }, s),
`${query}|${projectId ?? ""}|${unitId ?? ""}`,
(s) =>
packetsApi.list(
{ ...(query ? { q: query } : {}), ...(flag ? { flag } : {}), ...(unitId ? { unit_id: unitId } : projectId ? { project_id: projectId } : {}) },
s,
),
`${query}|${flag}|${projectId ?? ""}|${unitId ?? ""}`,
);
useChanged(reload, { delayMs: 500 });
@@ -187,7 +193,7 @@ export function ClericalHome() {
<Kpis
items={[
{ n: 0, label: "Changes to approve", alert: true },
{ n: b.cos, label: "Changes to approve", alert: true },
{ n: b.flagged, label: "Flagged", alert: true },
{ n: b.warehouse, label: "With warehouse" },
{ n: b.shop, label: "In the shop" },
@@ -195,8 +201,16 @@ export function ClericalHome() {
]}
/>
{b.flagged || b.toClose ? (
{editable ? <FromVisualPanel /> : null}
{b.cos || b.flagged || b.toClose ? (
<div className="attn" role="status">
{b.cos ? (
<div className="callout warn flush">
<b>{b.cos === 1 ? "1 change from VISUAL waits" : `${b.cos} changes from VISUAL wait`} for your approval.</b> Nothing goes to the shop
packet until you approve it.
</div>
) : null}
{b.flagged ? (
<div className="callout warn flush">
<b>{b.flagged === 1 ? "1 draft is flagged." : `${b.flagged} drafts are flagged.`}</b> Check {b.flagged === 1 ? "it" : "them"} and send {b.flagged === 1 ? "it" : "them"} on.
@@ -210,7 +224,7 @@ export function ClericalHome() {
</div>
) : null}
<FindBar q={q} onQ={setQ} pick={pick} onPick={setPick} view={view} onView={setView} count={load.state === "ok" ? shown.length : null} />
<FindBar q={q} onQ={setQ} pick={pick} onPick={setPick} view={view} onView={setView} count={load.state === "ok" ? shown.length : null} flag={flag} onFlag={setFlag} />
{load.state === "loading" ? <div className="empty slim">Loading</div> : null}
{load.state === "error" ? <div className="callout warn">{load.message}</div> : null}
@@ -0,0 +1,207 @@
import { useEffect, useState } from "react";
import { Link } from "react-router";
import { feedApi } from "../../api/feed";
import type { IgnoredWo } from "../../api/types";
import { useGuard } from "../../components/useGuard";
import { RunsTable } from "../../components/RunsTable";
import { useChanged } from "../../lib/changes";
import { drawingsAfter, feedRunning, statusLine } from "../../lib/feed";
import { errorText, fmtDay } from "../../lib/format";
import { useLoad } from "../../lib/useLoad";
const IGNORED_SHOWN = 25;
const woText = (w: Pick<IgnoredWo, "wo_base" | "lot" | "split" | "sub">) => `${w.wo_base}/${w.lot}/${w.split}/${w.sub}`;
/** From VISUAL on the Clerical home: the worker's status, Run now, the last runs and what needs a hand. */
export function FromVisualPanel() {
const [load, reload] = useLoad((s) => feedApi.overview(s));
const { askReason, sheet } = useGuard();
const [busy, setBusy] = useState(false);
const [msg, setMsg] = useState<{ ok: boolean; text: string } | null>(null);
const [allIgnored, setAllIgnored] = useState(false);
const [, setTick] = useState(0);
// A feed run or a packet change refreshes the panel. Bursts give one reload.
useChanged(reload, { delayMs: 1000 });
// The "in 3 min" and "2 min ago" words age without a reload.
useEffect(() => {
const t = window.setInterval(() => setTick((n) => n + 1), 30_000);
return () => window.clearInterval(t);
}, []);
const runNow = async () => {
setBusy(true);
setMsg(null);
try {
const r = await feedApi.run("visual-feed");
setMsg({ ok: true, text: r.pending > 1 ? "Run requested. One is already waiting, so they run together." : "Run requested. The boards update when it finishes." });
reload();
} catch (err) {
setMsg({ ok: false, text: errorText(err) });
} finally {
setBusy(false);
}
};
const pullIn = async (w: IgnoredWo) => {
const reason = await askReason({
title: "Pull in work order",
sub: woText(w),
note: "It was released before the go-live date, so the feed left it out. Pulled in, it becomes a packet on the next run.",
min: 3,
max: 300,
submitLabel: "Pull in",
});
if (reason === null) return;
setBusy(true);
setMsg(null);
try {
await feedApi.pullIn(w.id, reason);
setMsg({ ok: true, text: `${woText(w)} pulled in. It comes through on the next run.` });
reload();
} catch (err) {
setMsg({ ok: false, text: errorText(err) });
} finally {
setBusy(false);
}
};
const head = (running: boolean) => (
<div className="sec-h flush">
<h2 id="fromvis-h">From VISUAL</h2>
<span className="ct">read by the server, nothing to upload</span>
<span className="grow" />
<button className="btn pri" type="button" disabled={busy || running} onClick={() => void runNow()}>
{running ? "Running" : "Run now"}
</button>
</div>
);
if (load.state !== "ok") {
return (
<section className="sec" aria-labelledby="fromvis-h">
<div className="panel stack">
{head(false)}
{load.state === "loading" ? <div className="small muted">Loading</div> : <div className="small due over">The feed status could not be read: {load.message}</div>}
{msg ? (
<div className={msg.ok ? "callout flush" : "callout warn flush"} role={msg.ok ? "status" : "alert"}>
{msg.text}
</div>
) : null}
</div>
</section>
);
}
const ov = load.data;
const running = feedRunning(ov);
const line = statusLine(ov);
const attached = drawingsAfter(ov.runs, ov.drawing_runs ?? []);
const ignored = ov.ignored ?? [];
const shownIgnored = allIgnored ? ignored : ignored.slice(0, IGNORED_SHOWN);
const unmapped = ov.unmapped ?? [];
const noName = ov.projects_without_names ?? [];
return (
<section className="sec" aria-labelledby="fromvis-h">
<div className="panel stack">
{head(running)}
<div className={line.warn ? "small due over" : "small"} data-testid="feed-status">
{line.text}
</div>
<div className="small muted">
Released work orders become packets ready to release under their unit, one per drawing, lot and operation. Drawings found in M-Files are attached
automatically. Anything that needs a hand goes to the <b>Flagged</b> column.
{ov.departments?.length ? ` Departments read: ${ov.departments.join(", ")}.` : ""}
</div>
{msg ? (
<div className={msg.ok ? "callout flush" : "callout warn flush"} role={msg.ok ? "status" : "alert"}>
{msg.text}
</div>
) : null}
{noName.length ? (
<div className="small">
{noName.length === 1 ? "1 project" : `${noName.length} projects`} came in from VISUAL without a name, customer or PO:{" "}
{noName.slice(0, 12).map((p, i) => (
<span key={p.id}>
{i ? " · " : ""}
<Link className="lnk mono" to={`/clerical/projects/${p.id}`}>
{p.number}
</Link>
</span>
))}
{noName.length > 12 ? ` and ${noName.length - 12} more` : ""}.
</div>
) : null}
{unmapped.length ? (
<div className="callout warn flush">
<b>
{unmapped.length === 1 ? "1 VISUAL resource isn't" : `${unmapped.length} VISUAL resources aren't`} assigned to a department yet
</b>
, so their work orders were left out:{" "}
{unmapped.slice(0, 10).map((u, i) => (
<span key={u.resource}>
{i ? ", " : ""}
<span className="mono">{u.resource}</span> ({u.rows})
</span>
))}
{unmapped.length > 10 ? " and more" : ""}. IT assigns them on the Admin page under VISUAL resources.
</div>
) : null}
<details>
<summary className="small">Last {ov.runs.length === 1 ? "run" : `${ov.runs.length} runs`}</summary>
<RunsTable runs={ov.runs} attached={attached} label="Last feed runs" />
</details>
{ignored.length ? (
<details>
<summary className="small">
{ignored.length === 1 ? "1 work order" : `${ignored.length} work orders`} released before go-live{ov.golive_date ? ` (${fmtDay(ov.golive_date)})` : ""}, left out
</summary>
<div className="small muted">These stay on paper. Pull one in when it should get a packet here.</div>
<div className="tw">
<table aria-label="Work orders left out">
<thead>
<tr>
<th>Work order</th>
<th>Unit</th>
<th>Resource</th>
<th>Drawing</th>
<th>Released</th>
<th />
</tr>
</thead>
<tbody>
{shownIgnored.map((w) => (
<tr key={w.id}>
<td className="mono">{woText(w)}</td>
<td className="mono small">{w.unit_number ?? w.project ?? "-"}</td>
<td className="mono small">{w.res ?? "-"}</td>
<td className="mono small">{w.dwg || "-"}</td>
<td className="small">{fmtDay(w.rel)}</td>
<td>
<button className="btn sm" type="button" disabled={busy} onClick={() => void pullIn(w)} aria-label={`Pull in ${woText(w)}`}>
Pull in
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{ignored.length > IGNORED_SHOWN && !allIgnored ? (
<button className="lnk small" type="button" onClick={() => setAllIgnored(true)}>
Show all {ignored.length}
</button>
) : null}
</details>
) : null}
</div>
{sheet}
</section>
);
}
@@ -0,0 +1,130 @@
import { useState } from "react";
import { projectsApi } from "../../api/projects";
import type { Project, VisualOrder } from "../../api/types";
import { useGuard } from "../../components/useGuard";
import { errorText, fmtDay } from "../../lib/format";
/** How the feed chose the order (orders.choose `via`). */
const VIA_LABEL: Record<string, string> = {
pin: "pinned by Clerical",
base: "linked to the work order",
unit_top_job: "names this unit",
project_open: "first open order",
project_newest: "newest order",
};
const ORDER_STATUS: Record<string, string> = { F: "Firmed", R: "Released", H: "On hold", C: "Closed", X: "Cancelled", U: "Unreleased" };
/** The project's orders as VISUAL has them, which one the feed uses, and the pin. */
export function OrdersSection({ project, editable, onSaved }: { project: Project; editable: boolean; onSaved: (text: string) => void }) {
const guard = useGuard();
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const orders: VisualOrder[] = project.orders ?? [];
const ref = project.order_ref ?? null;
const pin = project.order_pin ?? null;
if (!orders.length && !ref) return null;
const setPin = async (next: string | null) => {
const reason = await guard.askReason({
title: next ? `Pin order ${next}` : "Unpin the order",
sub: project.number,
note: next
? "The feed uses this order for the customer, PO and dates from its next run. A value typed by hand is never overwritten."
: "The feed goes back to choosing the order itself on its next run.",
min: 3,
max: 300,
submitLabel: next ? "Pin" : "Unpin",
});
if (reason === null) return;
setBusy(true);
setError(null);
try {
const r = await guard.run(
(g) => projectsApi.patch(project.id, { set: { order_pin: next, order_pin_reason: reason }, base: { order_pin: pin }, ...g }),
{ sub: project.number },
);
if (r !== undefined) onSaved(next ? `Order ${next} pinned` : "Order unpinned");
} catch (err) {
setError(errorText(err));
} finally {
setBusy(false);
}
};
return (
<section className="sec" aria-labelledby="orders-h">
<div className="sec-h">
<h2 id="orders-h">Orders in VISUAL</h2>
<span className="ct">
{ref ? (
<>
the feed uses <b className="mono">{ref.cust_order_id}</b> ({VIA_LABEL[ref.via] ?? ref.via})
</>
) : (
"no order chosen yet"
)}
</span>
<span className="grow" />
{editable && pin ? (
<button className="btn sm ghost" type="button" disabled={busy} onClick={() => void setPin(null)}>
Unpin
</button>
) : null}
</div>
{project.order_pin_reason && pin ? <div className="small muted">Pinned: {project.order_pin_reason}</div> : null}
{error ? (
<div className="callout warn" role="alert">
{error}
</div>
) : null}
{orders.length ? (
<div className="tw">
<table aria-label="Orders in VISUAL">
<thead>
<tr>
<th>Order</th>
<th>Status</th>
<th>Customer</th>
<th>Customer PO</th>
<th>Ordered</th>
<th>Ship</th>
<th />
</tr>
</thead>
<tbody>
{orders.map((o) => {
const used = ref?.cust_order_id === o.cust_order_id;
const pinned = pin === o.cust_order_id;
return (
<tr key={o.cust_order_id}>
<td className="mono">
<b>{o.cust_order_id}</b>
{used ? <div className="sub">used by the feed</div> : null}
</td>
<td className="small">{o.status ? (ORDER_STATUS[o.status] ?? o.status) : "-"}</td>
<td className="small">{o.customer_name ?? "-"}</td>
<td className="mono small">{o.customer_po_ref ?? "-"}</td>
<td className="small">{fmtDay(o.order_date)}</td>
<td className="small">{fmtDay(o.promise_date ?? o.desired_ship_date)}</td>
<td className="nowrap">
{pinned ? <span className="pill st-ok">Pinned</span> : null}
{editable && !pinned ? (
<button className="btn sm" type="button" disabled={busy} onClick={() => void setPin(o.cust_order_id)} aria-label={`Pin order ${o.cust_order_id}`}>
Pin
</button>
) : null}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
) : (
<div className="empty slim">No orders cached for this project.</div>
)}
{guard.sheet}
</section>
);
}
@@ -0,0 +1,64 @@
import { fireEvent, screen, within } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import type { OrderRef, VisualOrder } from "../../api/types";
import { DEPARTMENTS, makeMe, renderApp } from "../../test/auth";
import { callsTo, mockFetch } from "../../test/fetchMock";
import { makeProject, makeUnit } from "../../test/packets";
const ORDERS: VisualOrder[] = [
{
cust_order_id: "CO-100",
status: "F",
customer_name: "Test Co",
customer_po_ref: "PO-1",
order_date: "2026-09-01",
desired_ship_date: "2026-12-01",
promise_date: null,
},
{
cust_order_id: "CO-101",
status: "F",
customer_name: "Test Co",
customer_po_ref: "PO-2",
order_date: "2026-09-15",
desired_ship_date: null,
promise_date: "2026-12-20",
},
];
const REF: OrderRef = { ...ORDERS[0]!, via: "project_open", chosen_at: "2026-10-16T08:00:00Z" };
describe("ProjectPage orders", () => {
it("lists the VISUAL orders, marks the one in use, and pins another with a reason", async () => {
const project = makeProject({ units: [makeUnit()], orders: ORDERS, order_ref: REF, order_pin: null });
const fetch = mockFetch({
"/api/auth/me": { body: makeMe() },
"/api/departments": { body: DEPARTMENTS },
"/api/projects/7": { body: project },
"PATCH /api/projects/7": { body: { ...project, order_pin: "CO-101", version: 2 } },
});
renderApp("/clerical/projects/7");
const table = await screen.findByRole("table", { name: "Orders in VISUAL" });
expect(screen.getByText("first open order", { exact: false })).toBeInTheDocument();
expect(within(table).getByText("used by the feed")).toBeInTheDocument();
fireEvent.click(within(table).getByRole("button", { name: "Pin order CO-101" }));
const ask = await screen.findByRole("dialog", { name: "Pin order CO-101" });
fireEvent.change(within(ask).getByLabelText("Reason"), { target: { value: "Second order covers this unit" } });
fireEvent.click(within(ask).getByRole("button", { name: "Pin" }));
expect(await screen.findByText("Order CO-101 pinned")).toBeInTheDocument();
expect(callsTo(fetch, "PATCH", "/api/projects/7")).toEqual([
{ set: { order_pin: "CO-101", order_pin_reason: "Second order covers this unit" }, base: { order_pin: null } },
]);
});
it("shows no orders section for a project without VISUAL orders", async () => {
mockFetch({
"/api/auth/me": { body: makeMe() },
"/api/departments": { body: DEPARTMENTS },
"/api/projects/7": { body: makeProject({ units: [makeUnit()] }) },
});
renderApp("/clerical/projects/7");
await screen.findByRole("heading", { name: "Units" });
expect(screen.queryByRole("heading", { name: "Orders in VISUAL" })).not.toBeInTheDocument();
});
});
@@ -10,6 +10,7 @@ import { useChanged } from "../../lib/changes";
import { dueInfo, PROJECT_STATUS_LABEL, stageCounts } from "../../lib/packets";
import { useLoad } from "../../lib/useLoad";
import { ProgressBar, ProjectPill } from "./common";
import { OrdersSection } from "./OrdersSection";
import { AddUnitsSheet, ProjectSheet, RenumberSheet } from "./sheets";
type UnitShow = "active" | "hold" | "closed" | "all";
@@ -241,6 +242,15 @@ export function ProjectPage() {
{units.length ? <UnitTable project={p} units={units} /> : <div className="empty">No units yet.{editable ? " Add one to start adding packets." : ""}</div>}
</section>
<OrdersSection
project={p}
editable={editable}
onSaved={(text) => {
setToast(text);
reload();
}}
/>
{sheet === "edit" ? (
<ProjectSheet
project={p}
+16 -2
View File
@@ -6,7 +6,15 @@ import { IN_SHOP_OR_QC } from "../../lib/packets";
export const byTime = (a: string | null, b: string | null) => (a ?? "").localeCompare(b ?? "");
/** The boards of the Clerical home, from one list answer. */
export function clericalBoards(all: PacketCard[]): { columns: ColumnDef[]; flagged: number; warehouse: number; shop: number; toClose: number } {
export function clericalBoards(all: PacketCard[]): {
columns: ColumnDef[];
cos: number;
flagged: number;
warehouse: number;
shop: number;
toClose: number;
} {
const cos = all.filter((p) => p.co_pending).sort((a, b) => byTime(a.updated_at, b.updated_at));
const drafts = all.filter((p) => p.status === "draft").sort((a, b) => byTime(a.updated_at, b.updated_at));
const back = all.filter((p) => p.status === "complete" && p.routed_to === "databook");
const flagged = drafts.filter((p) => p.flag);
@@ -18,7 +26,12 @@ export function clericalBoards(all: PacketCard[]): { columns: ColumnDef[]; flagg
.sort((a, b) => byTime(b.status_at, a.status_at))
.slice(0, 25);
const columns: ColumnDef[] = [
{ key: "cos", title: "Changes to approve", cards: [], empty: "Arrives in phase 3 with the VISUAL feed." },
{
key: "cos",
title: "Changes to approve",
cards: cos,
empty: "Changes from VISUAL to packets already past draft wait here for your approval.",
},
{ key: "flagged", title: "Flagged", cards: flagged, empty: "Nothing flagged." },
{ key: "going", title: "Going to the warehouse", cards: going, empty: "Drafts with nothing flagged wait here." },
{
@@ -32,6 +45,7 @@ export function clericalBoards(all: PacketCard[]): { columns: ColumnDef[]; flagg
];
return {
columns,
cos: cos.length,
flagged: flagged.length,
warehouse: all.filter((p) => p.status === "warehouse").length,
shop: all.filter((p) => IN_SHOP_OR_QC.includes(p.status)).length,