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
+15
View File
@@ -0,0 +1,15 @@
// The VISUAL feed endpoints for Clerical (phase 3 contract, api/feed.py).
import { api } from "./client";
import { asList, qs } from "./query";
import type { FeedOverview, IgnoredWo } from "./types";
export type FeedJob = "visual-feed" | "drawing-lookup";
export const feedApi = {
overview: (signal?: AbortSignal) => api.get<FeedOverview>("/api/feed/overview", signal),
run: (job?: FeedJob) => api.post<{ requested: boolean; pending: number }>("/api/feed/run", job ? { job } : {}),
pullIn: (id: number, reason: string) =>
api.post<IgnoredWo>(`/api/feed/ignored/${encodeURIComponent(String(id))}/pull-in`, { reason }),
changes: async (opts: { batch_id?: number; limit?: number } = {}, signal?: AbortSignal): Promise<unknown[]> =>
asList<unknown>(await api.get<unknown>(`/api/feed/changes${qs({ batch_id: opts.batch_id, limit: opts.limit ?? 200 })}`, signal), "changes"),
};
+30
View File
@@ -0,0 +1,30 @@
// The worker's jobs (phase 3 contract, api/jobs.py) and the Admin feed settings (api/feed_settings.py).
import { api } from "./client";
import { asList, qs } from "./query";
import type { FeedSettings, FeedSettingsAnswer, JobInfo, JobRun, JobsAnswer, ResourceMapRow, WorkerStatus } from "./types";
const enc = encodeURIComponent;
/** GET /api/jobs may come as {jobs, heartbeat} or as a bare list. Both give the same answer. */
function asJobs(raw: unknown): JobsAnswer {
const jobs = asList<JobInfo>(raw, "jobs");
const hb = raw && typeof raw === "object" && !Array.isArray(raw) ? ((raw as { heartbeat?: WorkerStatus | null }).heartbeat ?? null) : null;
return { jobs, heartbeat: hb };
}
export const jobsApi = {
list: async (signal?: AbortSignal): Promise<JobsAnswer> => asJobs(await api.get<unknown>("/api/jobs", signal)),
runs: async (job: string, limit = 10, signal?: AbortSignal): Promise<JobRun[]> =>
asList<JobRun>(await api.get<unknown>(`/api/jobs/${enc(job)}/runs${qs({ limit })}`, signal), "runs"),
run: (job: string) => api.post<{ requested: boolean; pending: number }>(`/api/jobs/${enc(job)}/run`, {}),
};
export const feedAdminApi = {
settings: (signal?: AbortSignal) => api.get<FeedSettingsAnswer>("/api/admin/feed/settings", signal),
saveSettings: (patch: Partial<FeedSettings>) => api.patch<FeedSettingsAnswer>("/api/admin/feed/settings", patch),
resourceMap: async (signal?: AbortSignal): Promise<ResourceMapRow[]> =>
asList<ResourceMapRow>(await api.get<unknown>("/api/admin/feed/resource-map", signal), "resources"),
mapResource: (resource: string, target: string) =>
api.put<ResourceMapRow>(`/api/admin/feed/resource-map/${enc(resource)}`, { target }),
unmapResource: (resource: string) => api.del<null>(`/api/admin/feed/resource-map/${enc(resource)}`),
};
+25
View File
@@ -43,4 +43,29 @@ export const packetsApi = {
setVclosed: (id: number, closed: boolean, extra: Guarded = {}) =>
api.post<PacketRead>(`${base(id)}/vclosed`, { closed, ...extra }),
databook: (id: number) => api.post<PacketRead>(`${base(id)}/databook`, {}),
/* Phase 3: VISUAL packets. */
revApprove: (id: number, body: { reason: string } & Guarded) => api.post<PacketRead>(`${base(id)}/rev-approve`, body),
handDrawing: (id: number, body: { asset_id: string; reason: string } & Guarded) =>
api.post<PacketRead>(`${base(id)}/hand/drawing`, body),
/** Multipart cannot carry JSON, so the signature rides as sign_factor and sign_secret. */
handBom: (id: number, file: File, reason: string, extra: Guarded = {}) => {
const fd = new FormData();
fd.append("file", file, file.name);
fd.append("reason", reason);
if (extra.sign) {
fd.append("sign_factor", extra.sign.factor);
fd.append("sign_secret", extra.sign.secret);
}
if (extra.it_reason) fd.append("it_reason", extra.it_reason);
return request<PacketRead>(`${base(id)}/hand/bom`, { method: "POST", body: fd });
},
removeHandBom: (id: number, extra: Guarded = {}) => api.delWithBody<PacketRead>(`${base(id)}/hand/bom`, extra),
/* Change orders. */
coApprove: (id: number, no: number, extra: Guarded = {}) => api.post<PacketRead>(`${base(id)}/change-orders/${no}/approve`, extra),
coReject: (id: number, no: number, body: { reason: string } & Guarded) =>
api.post<PacketRead>(`${base(id)}/change-orders/${no}/reject`, body),
coHold: (id: number, no: number, hold: boolean, extra: Guarded = {}) =>
api.post<PacketRead>(`${base(id)}/change-orders/${no}/hold`, { hold, ...extra }),
};
+4
View File
@@ -12,6 +12,8 @@ export interface ProjectFields {
due: string | null;
status: Project["status"];
notes: string | null;
order_pin?: string | null;
order_pin_reason?: string | null;
}
export interface UnitFields {
@@ -19,6 +21,8 @@ export interface UnitFields {
due: string | null;
status: Unit["status"];
notes: string | null;
order_pin?: string | null;
order_pin_reason?: string | null;
}
const pid = (id: number) => `/api/projects/${encodeURIComponent(String(id))}`;
+262 -2
View File
@@ -72,6 +72,9 @@ export interface Unit {
packets?: StageCounts;
version: number;
project?: ProjectRef;
order_ref?: OrderRef | null;
order_pin?: string | null;
order_pin_reason?: string | null;
}
/** One row of GET /api/projects. GET /api/projects/{id} adds units and aliases. */
@@ -91,6 +94,11 @@ export interface Project {
packets?: StageCounts;
aliases?: Array<string | { alias: string; kind?: string }>;
version: number;
/** Phase 3: cached VISUAL orders for the project and its bases. */
orders?: VisualOrder[];
order_ref?: OrderRef | null;
order_pin?: string | null;
order_pin_reason?: string | null;
}
export interface ProjectCreate {
@@ -161,6 +169,12 @@ export interface PacketCard {
blocked: Record<string, string[]>;
/** Not in the contract's card shape. Shown when the server sends it. */
wo_count?: number;
/** Phase 3: the raw flag kinds (drawing_needed, rev_mismatch, ...). */
flag_kinds?: string[];
/** Phase 3: a feed change order waits for Clerical. */
co_pending?: boolean;
/** Phase 3: every leg is closed in VISUAL (observed). */
visual_closed?: boolean;
}
export interface PacketList {
@@ -174,6 +188,8 @@ export interface PacketListQuery {
project_id?: number;
q?: string;
flag?: string;
/** "pending": packets with a pending feed change order. */
co?: string;
limit?: number;
}
@@ -282,7 +298,9 @@ export interface PacketRead {
source: Record<string, unknown>;
drawing_state: string;
flags: string[];
visual: Record<string, unknown>;
visual: VisualInfo;
/** Phase 3: what the feed last observed in VISUAL. */
visual_state?: VisualState | null;
removed: { at: string; by: WhoRef | null; reason: string | null } | null;
work_orders: WorkOrder[];
pages: PacketPage[];
@@ -295,7 +313,7 @@ export interface PacketRead {
wh: Record<string, unknown> | null;
qc: Record<string, unknown> | null;
qc_history: unknown[];
change_orders: unknown[];
change_orders: ChangeOrder[];
open_cos: number[];
open_shorts: number;
docs: PacketDoc[];
@@ -394,3 +412,245 @@ export interface Guarded {
it_reason?: string;
sign?: SignBody;
}
/* ---------- Phase 3: VISUAL feed on the packet ---------- */
/** visual.appr: the last M-Files answer for the drawing. */
export interface VisualAppr {
ok?: boolean;
rev?: number | string | null;
vrev?: string | null;
name?: string | null;
note?: string | null;
base?: string | null;
draft_rev?: number | null;
draft_where?: string | null;
in_drafting?: boolean;
at?: string | null;
}
/** visual.manual_dwg and visual.manual_bom. `by` may be a who reference or a name. */
export interface HandUpload {
asset_id?: string;
name?: string | null;
reason?: string | null;
by?: WhoRef | string | null;
at?: string | null;
vis_rev?: string | null;
lines?: number;
}
/** One folded operation (MFG, inspection and the like) kept on the packet for reference. */
export interface FoldedOp {
key?: string | { base?: string; lot?: string; split?: string; sub?: string };
wo?: string;
wo_base?: string;
lot?: string;
seq?: number;
op?: string;
tgt?: number | string;
}
/** The packet's `visual` jsonb. Every key is optional; hand drafts have {}. */
export interface VisualInfo {
appr?: VisualAppr;
/** Set by the read model from drawing_lookups when M-Files has a staged copy. */
staged_rev?: { rev: number | string; name: string | null };
partspecs?: string[];
folded?: FoldedOp[];
manual_dwg?: HandUpload;
manual_bom?: HandUpload;
rev_ok?: { rev: string | number; vis_rev?: string | null; by?: WhoRef | string | null; at?: string | null; reason?: string | null };
rev_confirmed?: boolean;
[key: string]: unknown;
}
export interface VisualLeg {
key?: string;
seq?: number;
st?: string;
}
export interface VisualState {
legs: VisualLeg[];
ticket_hours: number | string | null;
order_ref: OrderRef | null;
observed_at: string | null;
closed_at: string | null;
cancelled_at: string | null;
}
/** change_orders[].vis for feed change orders. */
export interface CoVis {
from_dwg?: string | null;
from_rev?: string | null;
to_dwg?: string | null;
to_rev?: string | null;
dwg_change?: boolean;
changes?: string[];
[key: string]: unknown;
}
export interface ChangeOrderItem {
old_page: number | null;
old_dwg: string | null;
old_rev: string | null;
new_page: number | null;
new_dwg: string | null;
new_rev: string | null;
added?: boolean;
}
export interface ChangeOrder {
id: number;
co_no: number;
origin: string;
review: string;
minor: boolean;
hold: boolean;
ecn: string | null;
reason: string | null;
issued_at: string | null;
issued_by: WhoRef | null;
approved_at?: string | null;
approved_by?: WhoRef | null;
rejected_at?: string | null;
rejected_by?: WhoRef | null;
rejected_reason?: string | null;
items: ChangeOrderItem[];
required: string[];
verified: Record<string, { by: WhoRef | null; at: string; note: string | null }>;
vis?: CoVis | null;
waiting?: { dwg: string; rev: string; since?: string | null; err?: string | null } | null;
staged?: { asset_id: string; name: string | null; rev: string; dwg: string; sha256?: string; pages?: number | null } | null;
target_fp?: string | null;
row_version?: number;
}
/* ---------- Phase 3: orders ---------- */
/** One cached VISUAL customer order. */
export interface VisualOrder {
cust_order_id: string;
status: string | null;
customer_id?: string | null;
customer_name: string | null;
customer_po_ref: string | null;
order_date: string | null;
desired_ship_date: string | null;
promise_date: string | null;
/** "project" or "base" link, when the server says. */
kind?: string;
key?: string;
top_jobs?: string[];
}
/** The chosen order stored on projects and units. */
export interface OrderRef {
cust_order_id: string;
via: string;
status: string | null;
customer_name: string | null;
customer_po_ref: string | null;
order_date: string | null;
desired_ship_date: string | null;
promise_date: string | null;
chosen_at?: string | null;
}
/* ---------- Phase 3: jobs and the feed ---------- */
export interface JobRun {
id: number;
job: string;
trigger: string;
requested_by?: string[];
started_at: string;
finished_at: string | null;
status: string;
counts: Record<string, unknown>;
errors: string[];
build_version?: string | null;
visual_target?: string | null;
host?: string | null;
}
export interface WorkerStatus {
at: string | null;
build: string | null;
running: string[];
stale: boolean;
}
export interface IgnoredWo {
id: number;
wo_base: string;
lot: string;
split: string;
sub: string;
rel: string | null;
project: string | null;
unit_number: string | null;
res: string | null;
dwg: string | null;
pulled_in_at: string | null;
}
export interface FeedOverview {
status: { paused: boolean; next_run_at: string | null; worker: WorkerStatus | null };
last_run: JobRun | null;
runs: JobRun[];
drawing_runs: JobRun[];
ignored: IgnoredWo[];
unmapped: Array<{ resource: string; rows: number }>;
projects_without_names: Array<{ id: number; number: string; units: number; packets: number }>;
golive_date: string | null;
departments: string[];
}
/** One row of GET /api/jobs. The contract leaves the shape open. */
export interface JobInfo {
job: string;
next_run_at: string | null;
interval_seconds: number | null;
paused: boolean;
last_run: JobRun | null;
}
export interface JobsAnswer {
jobs: JobInfo[];
heartbeat: WorkerStatus | null;
}
/** The feed settings row (feed.config.FeedSettings). */
export interface FeedSettings {
interval_shop_minutes: number;
interval_off_minutes: number;
shop_days: number[];
shop_start: string;
shop_end: string;
window_back_days: number;
window_ahead_days: number;
golive_date: string | null;
departments: string[];
drawing_recheck_hours: number;
drawing_budget: number;
drawing_interval_minutes: number;
labor_tickets: boolean;
auto_release: boolean;
auto_release_max: number;
paused: boolean;
}
export interface FeedSettingsAnswer extends FeedSettings {
next_run_at?: string | null;
runs?: JobRun[];
}
export interface ResourceMapRow {
resource: string;
target: string | null;
confirmed: boolean;
rows_last_run: number | null;
updated_at: string | null;
}
+5
View File
@@ -49,6 +49,11 @@ export function useEvents(sessionId: string | null, handlers: EventHandlers): Pr
es.addEventListener("changed", (ev) => {
ref.current.onChanged?.(parse((ev as MessageEvent).data));
});
// Phase 3: the worker finished a feed run. Fanned out with the changed events, marked event "feed".
es.addEventListener("feed", (ev) => {
const p = parse((ev as MessageEvent).data);
ref.current.onChanged?.({ ...(p && typeof p === "object" ? p : {}), event: "feed" });
});
return () => {
es.close();
+2
View File
@@ -40,6 +40,8 @@ export function Card({ p, extra }: { p: PacketCard; extra?: ReactNode }) {
{cos ? <span className="pill st-review">CO {cos}</span> : null}
</div>
{p.flag ? <div className={`small ${flagClass(p.flag)}`}>⚑ {p.flag}</div> : null}
{p.co_pending ? <div className="small due soon">Change from VISUAL to approve</div> : null}
{p.visual_closed && p.status !== "filed" ? <div className="small muted">Closed in VISUAL</div> : null}
{extra}
</Link>
);
+17 -1
View File
@@ -1,3 +1,4 @@
import { FLAG_FILTERS } from "../lib/packets";
import { PickUnit, type UnitPick } from "./PickUnit";
export type BoardView = "board" | "list";
@@ -11,10 +12,13 @@ interface FindBarProps {
onView: (v: BoardView) => void;
/** "Showing n packets". */
count: number | null;
/** The flag kind filter. Shown when onFlag is given. */
flag?: string;
onFlag?: (v: string) => void;
}
/** Find a job: the search box, the project then unit picker, and the Board | List toggle. */
export function FindBar({ q, onQ, pick, onPick, view, onView, count }: FindBarProps) {
export function FindBar({ q, onQ, pick, onPick, view, onView, count, flag = "", onFlag }: FindBarProps) {
return (
<section className="fltbar" aria-label="Find jobs">
<div className="fltrow">
@@ -39,6 +43,18 @@ export function FindBar({ q, onQ, pick, onPick, view, onView, count }: FindBarPr
<div className="f fltp">
<PickUnit value={pick} onChange={onPick} />
</div>
{onFlag ? (
<label className="f">
<span>Flags</span>
<select className="in" value={flag} onChange={(e) => onFlag(e.target.value)}>
{FLAG_FILTERS.map(([v, l]) => (
<option key={v} value={v}>
{l}
</option>
))}
</select>
</label>
) : null}
<div className="fltbtns">
<div className="seg" role="group" aria-label="View">
<button type="button" aria-pressed={view === "board"} onClick={() => onView("board")}>
+50
View File
@@ -0,0 +1,50 @@
import type { JobRun } from "../api/types";
import { count, runPillClass, RUN_STATUS_LABEL, TRIGGER_LABEL } from "../lib/feed";
import { fmtTime } from "../lib/format";
/** The last runs of the VISUAL feed as a small table. `attached` maps a run id to the drawings attached after it. */
export function RunsTable({ runs, attached, label = "Last runs" }: { runs: JobRun[]; attached?: Map<number, number>; label?: string }) {
if (!runs.length) return <div className="empty slim">No runs yet.</div>;
return (
<div className="tw">
<table aria-label={label}>
<thead>
<tr>
<th>When</th>
<th>Trigger</th>
<th>Status</th>
<th className="num">New</th>
<th className="num">Updated</th>
<th className="num">Change orders</th>
<th className="num">Drawings attached</th>
<th>Errors</th>
</tr>
</thead>
<tbody>
{runs.map((r) => (
<tr key={r.id}>
<td className="small mono nowrap">{fmtTime(r.started_at)}</td>
<td className="small">{TRIGGER_LABEL[r.trigger] ?? r.trigger}</td>
<td>
<span className={runPillClass(r.status)}>{RUN_STATUS_LABEL[r.status] ?? r.status}</span>
</td>
<td className="num">{count(r, "new_drafts")}</td>
<td className="num">{count(r, "drafts_updated")}</td>
<td className="num">{count(r, "cos_opened") + count(r, "cos_reaimed")}</td>
<td className="num">{attached?.get(r.id) ?? count(r, "attached")}</td>
<td className="small">
{r.errors.length ? (
<span className="due over" title={r.errors.join("\n")}>
{r.errors.length === 1 ? r.errors[0] : `${r.errors.length} errors: ${r.errors[0] ?? ""}`}
</span>
) : (
<span className="muted">None</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
+27
View File
@@ -0,0 +1,27 @@
// Change order words shared by the packet page and the boards.
import type { ChangeOrder, PacketRead } from "../api/types";
export const feedOrigin = (co: ChangeOrder) => co.origin === "visual" || co.origin === "drawing";
/** The old coSummary(): "M00543 Rev 1 -> Rev 2 + 2 other changes", or the first changes. */
export function coSummary(co: ChangeOrder): string {
if (feedOrigin(co) && co.review !== "approved") {
const v = co.vis ?? {};
const ch = v.changes ?? [];
if (v.dwg_change) {
const from = v.from_dwg && v.to_dwg && v.from_dwg.replace(/\s/g, "").toUpperCase() !== v.to_dwg.replace(/\s/g, "").toUpperCase() ? `${v.from_dwg} -> ` : "";
return `${from}${v.to_dwg ?? ""} Rev ${v.from_rev || "?"} -> Rev ${v.to_rev ?? "?"}${ch.length ? ` + ${ch.length} other change${ch.length > 1 ? "s" : ""}` : ""}`;
}
return ch.slice(0, 2).join("; ") + (ch.length > 2 ? ` (+${ch.length - 2} more)` : "") || co.reason || "change from VISUAL";
}
const parts = co.items.filter((i) => !i.added).map((i) => `${i.old_dwg || "sheet"} Rev ${i.old_rev || "?"} -> Rev ${i.new_rev || "?"}`);
const added = co.items.filter((i) => i.added).length;
if (added) parts.push(`${added} sheet${added > 1 ? "s" : ""} added`);
return parts.join(", ") || co.reason || "documents changed";
}
/** The pending feed change order Clerical should review, if any. */
export function coToReview(p: PacketRead): ChangeOrder | undefined {
return (p.change_orders ?? []).find((c) => c.review === "pending" && feedOrigin(c));
}
+90
View File
@@ -0,0 +1,90 @@
// Words for the worker's job runs and the feed status line. Shared by Clerical and Admin.
import type { FeedOverview, JobRun } from "../api/types";
import { fmtAgo, fmtIn } from "./format";
/** One count from a run, as a number. Missing or odd values are 0. */
export function count(run: Pick<JobRun, "counts"> | null | undefined, key: string): number {
const v = run?.counts?.[key];
const n = typeof v === "number" ? v : Number(v);
return Number.isFinite(n) ? n : 0;
}
const plural = (n: number, one: string, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
/** "4 new, 2 updated, 1 change order" or "no changes". */
export function runSummary(run: JobRun): string {
if (run.status === "skipped") {
const why = run.counts?.reason;
return typeof why === "string" && why ? `skipped (${why})` : "skipped";
}
if (run.status === "failed") return run.errors[0] ? `failed: ${run.errors[0]}` : "failed";
if (run.status === "running") return "running";
if (run.status === "abandoned") return "stopped when the worker restarted";
const parts: string[] = [];
const nw = count(run, "new_drafts");
const up = count(run, "drafts_updated");
const co = count(run, "cos_opened") + count(run, "cos_reaimed");
if (nw) parts.push(`${nw} new`);
if (up) parts.push(`${up} updated`);
if (co) parts.push(plural(co, "change order"));
const att = count(run, "attached");
if (att) parts.push(`${att} drawing${att === 1 ? "" : "s"} attached`);
const text = parts.length ? parts.join(", ") : "no changes";
return run.status === "degraded" ? `${text} (some reads failed)` : text;
}
export const RUN_STATUS_LABEL: Record<string, string> = {
running: "Running",
ok: "OK",
degraded: "Degraded",
failed: "Failed",
skipped: "Skipped",
abandoned: "Abandoned",
};
export function runPillClass(status: string): string {
if (status === "ok") return "pill st-ok";
if (status === "failed" || status === "abandoned") return "pill st-bad";
if (status === "degraded") return "pill st-review";
if (status === "running") return "pill st-in_progress";
return "pill st-filed";
}
export const TRIGGER_LABEL: Record<string, string> = {
schedule: "Schedule",
request: "Run now",
chain: "After the feed",
cli: "Command line",
};
/** Is the feed running right now, by the heartbeat or the last run row. */
export function feedRunning(ov: FeedOverview): boolean {
return !!ov.status.worker?.running?.includes("visual-feed") || ov.last_run?.status === "running";
}
/** The status line on the Clerical panel. */
export function statusLine(ov: FeedOverview, now: Date = new Date()): { text: string; warn: boolean } {
const w = ov.status.worker;
if (!w || w.stale) return { text: "Worker not running. VISUAL is not being read. Tell IT.", warn: true };
if (feedRunning(ov)) return { text: "Reading VISUAL now.", warn: false };
const last = ov.last_run;
const lastText = last ? `last run ${fmtAgo(last.finished_at ?? last.started_at, now)}: ${runSummary(last)}` : "no runs yet";
if (ov.status.paused) return { text: `Paused by the admin · ${lastText}`, warn: true };
const next = ov.status.next_run_at ? `next run ${fmtIn(ov.status.next_run_at, now)}` : "next run not scheduled";
return { text: `On · ${next} · ${lastText}`, warn: last?.status === "failed" };
}
/** Drawings attached by the drawing runs that followed each feed run. Runs are newest first. */
export function drawingsAfter(runs: JobRun[], drawingRuns: JobRun[]): Map<number, number> {
const out = new Map<number, number>();
runs.forEach((r, i) => {
const from = r.started_at;
const to = i > 0 ? runs[i - 1]?.started_at : undefined;
let n = 0;
for (const d of drawingRuns) {
if (d.started_at >= from && (to === undefined || d.started_at < to)) n += count(d, "attached");
}
out.set(r.id, n);
});
return out;
}
+91
View File
@@ -0,0 +1,91 @@
// The Admin feed settings form: fields, ranges and the patch of changed fields.
import type { FeedSettings } from "../api/types";
export const DAYS: ReadonlyArray<[number, string]> = [
[0, "Mon"],
[1, "Tue"],
[2, "Wed"],
[3, "Thu"],
[4, "Fri"],
[5, "Sat"],
[6, "Sun"],
];
export type NumKey =
| "interval_shop_minutes"
| "interval_off_minutes"
| "window_back_days"
| "window_ahead_days"
| "drawing_recheck_hours"
| "drawing_budget"
| "drawing_interval_minutes"
| "auto_release_max";
/** Each number field: label, lowest, highest. The server checks the same ranges. */
export const NUMS: ReadonlyArray<[NumKey, string, number, number]> = [
["interval_shop_minutes", "Every (min) in shop hours", 1, 1440],
["interval_off_minutes", "Every (min) outside shop hours", 1, 1440],
["window_back_days", "Released up to (days back)", 0, 120],
["window_ahead_days", "and (days ahead)", 0, 365],
["drawing_interval_minutes", "Drawing lookups every (min)", 1, 1440],
["drawing_recheck_hours", "Recheck a drawing after (hours)", 1, 168],
["drawing_budget", "Drawing lookups per run", 1, 500],
["auto_release_max", "Auto-release at most (per run)", 1, 500],
];
export interface Form {
nums: Record<NumKey, string>;
shop_days: number[];
shop_start: string;
shop_end: string;
golive_date: string;
departments: string[];
labor_tickets: boolean;
auto_release: boolean;
paused: boolean;
}
export function toForm(s: FeedSettings): Form {
const nums = {} as Record<NumKey, string>;
for (const [k] of NUMS) nums[k] = String(s[k] ?? "");
return {
nums,
shop_days: [...(s.shop_days ?? [])].sort(),
shop_start: s.shop_start ?? "",
shop_end: s.shop_end ?? "",
golive_date: s.golive_date ?? "",
departments: [...(s.departments ?? [])].sort(),
labor_tickets: !!s.labor_tickets,
auto_release: !!s.auto_release,
paused: !!s.paused,
};
}
const sameList = <T,>(a: readonly T[], b: readonly T[]) => a.length === b.length && a.every((x, i) => x === b[i]);
/** The changed fields only, or an error text. */
export function feedPatch(was: FeedSettings, f: Form): { patch: Partial<FeedSettings> } | { error: string } {
const patch: Partial<FeedSettings> = {};
for (const [k, label, lo, hi] of NUMS) {
const raw = f.nums[k].trim();
const n = Number(raw);
if (raw === "" || !Number.isInteger(n) || n < lo || n > hi) return { error: `${label}: a whole number from ${lo} to ${hi}.` };
if (n !== was[k]) patch[k] = n;
}
if (!/^\d{2}:\d{2}$/.test(f.shop_start) || !/^\d{2}:\d{2}$/.test(f.shop_end)) return { error: "Shop hours need a start and an end time." };
if (f.shop_start >= f.shop_end) return { error: "Shop hours must end after they start." };
if (!f.shop_days.length) return { error: "Pick at least one shop day." };
const days = [...f.shop_days].sort();
if (!sameList(days, [...(was.shop_days ?? [])].sort())) patch.shop_days = days;
if (f.shop_start !== was.shop_start) patch.shop_start = f.shop_start;
if (f.shop_end !== was.shop_end) patch.shop_end = f.shop_end;
const golive = f.golive_date || null;
if (golive !== (was.golive_date ?? null)) patch.golive_date = golive;
const depts = [...f.departments].sort();
if (!sameList(depts, [...(was.departments ?? [])].sort())) patch.departments = depts;
if (f.labor_tickets !== !!was.labor_tickets) patch.labor_tickets = f.labor_tickets;
if (f.auto_release !== !!was.auto_release) patch.auto_release = f.auto_release;
if (f.paused !== !!was.paused) patch.paused = f.paused;
return { patch };
}
+39
View File
@@ -42,3 +42,42 @@ export function errorText(err: unknown): string {
}
export const DIGITS = /^\d+$/;
/** Whole minutes between two times, rounded, never negative. */
function minutesBetween(a: number, b: number): number {
return Math.max(0, Math.round((b - a) / 60_000));
}
function spanText(mins: number): string {
if (mins < 1) return "under a minute";
if (mins < 90) return `${mins} min`;
const h = Math.round(mins / 60);
if (h < 36) return `${h} h`;
return `${Math.round(h / 24)} d`;
}
/** "2 min ago", "just now", or a dash. */
export function fmtAgo(iso: string | null | undefined, now: Date = new Date()): string {
if (!iso) return "-";
const t = new Date(iso).getTime();
if (Number.isNaN(t)) return iso;
const m = minutesBetween(t, now.getTime());
return m < 1 ? "just now" : `${spanText(m)} ago`;
}
/** "in 3 min", "now", or a dash. */
export function fmtIn(iso: string | null | undefined, now: Date = new Date()): string {
if (!iso) return "-";
const t = new Date(iso).getTime();
if (Number.isNaN(t)) return iso;
const m = minutesBetween(now.getTime(), t);
return m < 1 ? "now" : `in ${spanText(m)}`;
}
/** A date-only ISO string as "Oct 16 2026". */
export function fmtDay(iso: string | null | undefined): string {
if (!iso) return "-";
const d = new Date(`${iso.slice(0, 10)}T12:00:00`);
if (Number.isNaN(d.getTime())) return iso;
return d.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" });
}
+38 -1
View File
@@ -39,9 +39,46 @@ export function projectPillClass(s: ProjectStatus): string {
/** A flag text that names a hard problem shows in pink, the rest in amber. */
export function flagClass(flag: string): string {
return /couldn't|not in|mismatch|No approved|Not released/i.test(flag) ? "due over" : "due soon";
return /couldn't|not in|mismatch|No approved|Not released|failed|cancelled|not a number/i.test(flag) ? "due over" : "due soon";
}
/** The flag kinds the find bar filters on. The value goes to GET /api/packets?flag=. "1" is any flag. */
export const FLAG_FILTERS: ReadonlyArray<[string, string]> = [
["", "All packets"],
["1", "All flags"],
["drawing_needed", "Drawing needed"],
["rev_mismatch", "Rev mismatch"],
["not_released", "Not released"],
["not_found", "Not found"],
["lookup_failed", "Lookup failed"],
["wo_gone", "Work order gone"],
["cancelled", "Cancelled"],
["hand", "Hand upload"],
];
/** Short labels for flag kinds on cards and lists. */
export const FLAG_KIND_LABEL: Record<string, string> = {
drawing_needed: "Drawing needed",
rev_mismatch: "Rev mismatch",
not_released: "Not released",
not_found: "Not found",
lookup_failed: "Lookup failed",
bad_rev: "Bad rev",
no_drawing: "No drawing",
wo_gone: "Work order gone",
cancelled: "Cancelled",
hand: "Made by hand",
hand_drawing: "Hand drawing",
hand_bom: "Hand BOM",
};
export function flagKindLabel(k: string): string {
return FLAG_KIND_LABEL[k] ?? k.replace(/_/g, " ");
}
/** The drawing flags a VISUAL draft carries until M-Files gives a usable drawing. */
export const DRAWING_FLAGS = ["drawing_needed", "rev_mismatch", "not_released", "not_found", "lookup_failed", "bad_rev"] as const;
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
/** The old dueInfo(): "Due Oct 20 '26 · 4 d left", with a class for colour. */
+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>
);
}
+6 -1
View File
@@ -5,8 +5,9 @@ import type { AdLookup, AdRefreshResult, AdminUser, GroupRow, PinState } from ".
import { useAuth } from "../../auth/useAuth";
import { errorText, fmtTime } from "../../lib/format";
import { useLoad, type Load } from "../../lib/useLoad";
import { FeedTab, ResourcesTab } from "./FeedTabs";
type Tab = "users" | "lookup" | "devices" | "groups" | "attempts";
type Tab = "users" | "lookup" | "devices" | "groups" | "attempts" | "feed" | "resources";
const TABS: ReadonlyArray<{ id: Tab; label: string }> = [
{ id: "users", label: "Users" },
@@ -14,6 +15,8 @@ const TABS: ReadonlyArray<{ id: Tab; label: string }> = [
{ id: "devices", label: "Devices" },
{ id: "groups", label: "Group map" },
{ id: "attempts", label: "Sign-in attempts" },
{ id: "feed", label: "Feed" },
{ id: "resources", label: "VISUAL resources" },
];
const ROLES_NOTE = "Roles come from AD groups. Ask IT to change them.";
@@ -696,6 +699,8 @@ export function AdminPage() {
{tab === "devices" ? <DevicesTab /> : null}
{tab === "groups" ? <GroupsTab /> : null}
{tab === "attempts" ? <AttemptsTab /> : null}
{tab === "feed" ? <FeedTab /> : null}
{tab === "resources" ? <ResourcesTab /> : null}
</div>
</>
);
@@ -0,0 +1,113 @@
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { DEPARTMENTS, makeMe, renderApp } from "../../test/auth";
import { callsTo, mockFetch } from "../../test/fetchMock";
const ADMIN = makeMe({
user: { display_name: "Ann Admin", sam_account: "admin.ann" },
roles: ["admin"],
screens: ["clerical", "warehouse", "supervisor", "stall", "qc", "pm", "admin"],
});
const SETTINGS = {
interval_shop_minutes: 5,
interval_off_minutes: 30,
shop_days: [0, 1, 2, 3, 4],
shop_start: "06:00",
shop_end: "18:00",
window_back_days: 14,
window_ahead_days: 30,
golive_date: null,
departments: [],
drawing_recheck_hours: 4,
drawing_budget: 60,
drawing_interval_minutes: 30,
labor_tickets: true,
auto_release: true,
auto_release_max: 25,
paused: false,
next_run_at: "2026-10-16T08:05:00Z",
};
const RUN = {
id: 7,
job: "visual-feed",
trigger: "schedule",
started_at: "2026-10-16T07:58:00Z",
finished_at: "2026-10-16T07:58:10Z",
status: "ok",
counts: { new_drafts: 1 },
errors: [],
};
function base() {
return {
"/api/auth/me": { body: ADMIN },
"/api/departments": { body: DEPARTMENTS },
"/api/admin/users": { body: [] },
"/api/admin/feed/settings": { body: SETTINGS },
"/api/jobs/visual-feed/runs": { body: [RUN] },
"/api/jobs": { body: { jobs: [], heartbeat: { at: "2026-10-16T07:59:00Z", build: "1.0", running: [], stale: false } } },
"/api/admin/feed/resource-map": {
body: [
{ resource: "WELD-PART", target: "03", confirmed: true, rows_last_run: 40, updated_at: null },
{ resource: "NEWRES", target: null, confirmed: false, rows_last_run: 3, updated_at: null },
{ resource: "MFG", target: "fold", confirmed: false, rows_last_run: 10, updated_at: null },
],
},
};
}
describe("Admin feed tabs", () => {
it("the Feed tab PATCHes only the changed fields", async () => {
const fetch = mockFetch({ ...base(), "PATCH /api/admin/feed/settings": { body: { ...SETTINGS, paused: true, interval_shop_minutes: 10, shop_days: [0, 1, 2, 3, 4, 5], departments: ["03"] } } });
renderApp("/admin");
fireEvent.click(await screen.findByRole("tab", { name: "Feed" }));
const form = await screen.findByRole("form", { name: "Feed settings" });
expect(screen.getByRole("table", { name: "Last feed runs" })).toBeInTheDocument();
fireEvent.change(within(form).getByLabelText("Every (min) in shop hours"), { target: { value: "10" } });
fireEvent.click(within(within(form).getByRole("group", { name: "Shop days" })).getByRole("button", { name: "Sat" }));
fireEvent.click(await within(within(form).getByRole("group", { name: "Departments" })).findByRole("button", { name: "03 Weld Parts" }));
fireEvent.click(within(form).getByLabelText(/^Paused/));
fireEvent.click(within(form).getByRole("button", { name: "Save" }));
expect(await screen.findByText("Saved. The worker picks up the new settings at once.")).toBeInTheDocument();
expect(callsTo(fetch, "PATCH", "/api/admin/feed/settings")).toEqual([
{ interval_shop_minutes: 10, shop_days: [0, 1, 2, 3, 4, 5], departments: ["03"], paused: true },
]);
});
it("the Feed tab refuses a number out of range and sends nothing", async () => {
const fetch = mockFetch(base());
renderApp("/admin");
fireEvent.click(await screen.findByRole("tab", { name: "Feed" }));
const form = await screen.findByRole("form", { name: "Feed settings" });
fireEvent.change(within(form).getByLabelText("Drawing lookups per run"), { target: { value: "900" } });
fireEvent.click(within(form).getByRole("button", { name: "Save" }));
expect(await screen.findByText("Drawing lookups per run: a whole number from 1 to 500.")).toBeInTheDocument();
expect(callsTo(fetch, "PATCH", "/api/admin/feed/settings")).toHaveLength(0);
});
it("the VISUAL resources tab saves a row with PUT and an unassign with DELETE", async () => {
const fetch = mockFetch({
...base(),
"PUT /api/admin/feed/resource-map/NEWRES": { body: { resource: "NEWRES", target: "14", confirmed: true, rows_last_run: 3, updated_at: null } },
"DELETE /api/admin/feed/resource-map/MFG": { status: 204 },
});
renderApp("/admin");
fireEvent.click(await screen.findByRole("tab", { name: "VISUAL resources" }));
const table = await screen.findByRole("table", { name: "VISUAL resources" });
expect(screen.getByText("1 resource is not assigned. Their work orders are left out of the feed.")).toBeInTheDocument();
const rows = within(table).getAllByRole("row");
expect(rows.map((r) => within(r).queryAllByRole("cell")[0]?.textContent ?? "")).toEqual(["", "WELD-PART", "MFG", "NEWRES"]);
fireEvent.change(await within(table).findByLabelText("NEWRES goes to"), { target: { value: "14" } });
fireEvent.click(within(table).getByRole("button", { name: "Save NEWRES" }));
await waitFor(() => expect(callsTo(fetch, "PUT", "/api/admin/feed/resource-map/NEWRES")).toEqual([{ target: "14" }]));
fireEvent.change(within(table).getByLabelText("MFG goes to"), { target: { value: "" } });
fireEvent.click(await screen.findByRole("button", { name: "Save 1 change" }));
await waitFor(() => expect(callsTo(fetch, "DELETE", "/api/admin/feed/resource-map/MFG")).toHaveLength(1));
});
});
+350
View File
@@ -0,0 +1,350 @@
// Admin: the VISUAL feed settings and the resource map (phase 3).
import { useEffect, useMemo, useState, type FormEvent } from "react";
import { feedAdminApi, jobsApi } from "../../api/jobs";
import type { ResourceMapRow } from "../../api/types";
import { DAYS, feedPatch, NUMS, toForm, type Form, type NumKey } from "../../lib/feedSettings";
import { RunsTable } from "../../components/RunsTable";
import { useChanged } from "../../lib/changes";
import { errorText, fmtAgo, fmtIn, fmtTime } from "../../lib/format";
import { useShopDepartments } from "../../lib/useDepartments";
import { useLoad } from "../../lib/useLoad";
const toggle = <T,>(list: T[], v: T) => (list.includes(v) ? list.filter((x) => x !== v) : [...list, v]);
function Msg({ msg }: { msg: { ok: boolean; text: string } | null }) {
if (!msg) return null;
return (
<div className={msg.ok ? "callout flush" : "callout warn flush"} role={msg.ok ? "status" : "alert"}>
{msg.text}
</div>
);
}
/* ---------- Feed ---------- */
export function FeedTab() {
const depts = useShopDepartments();
const [load, reload] = useLoad((s) => feedAdminApi.settings(s));
const [runs, reloadRuns] = useLoad((s) => jobsApi.runs("visual-feed", 10, s));
const [jobs, reloadJobs] = useLoad((s) => jobsApi.list(s));
const [form, setForm] = useState<Form | null>(null);
const [busy, setBusy] = useState(false);
const [msg, setMsg] = useState<{ ok: boolean; text: string } | null>(null);
const settings = load.state === "ok" ? load.data : null;
useEffect(() => {
if (settings) setForm(toForm(settings));
}, [settings]);
useChanged(
() => {
reloadRuns();
reloadJobs();
},
{ delayMs: 1000, filter: (ev) => ev.event === "feed" },
);
if (load.state === "loading") return <div className="empty slim">Loading</div>;
if (load.state === "error") return <div className="callout warn">{load.message}</div>;
if (!settings || !form) return null;
const setNum = (k: NumKey, v: string) => setForm((f) => (f ? { ...f, nums: { ...f.nums, [k]: v } } : f));
const set = <K extends keyof Form>(k: K, v: Form[K]) => setForm((f) => (f ? { ...f, [k]: v } : f));
const save = async (e: FormEvent) => {
e.preventDefault();
const r = feedPatch(settings, form);
if ("error" in r) {
setMsg({ ok: false, text: r.error });
return;
}
if (!Object.keys(r.patch).length) {
setMsg({ ok: true, text: "Nothing changed." });
return;
}
setBusy(true);
setMsg(null);
try {
await feedAdminApi.saveSettings(r.patch);
setMsg({ ok: true, text: "Saved. The worker picks up the new settings at once." });
reload();
reloadJobs();
} catch (err) {
setMsg({ ok: false, text: errorText(err) });
} finally {
setBusy(false);
}
};
const runNow = async (job: string) => {
setBusy(true);
setMsg(null);
try {
await jobsApi.run(job);
setMsg({ ok: true, text: job === "visual-feed" ? "Feed run requested." : "Drawing lookup requested." });
reloadJobs();
} catch (err) {
setMsg({ ok: false, text: errorText(err) });
} finally {
setBusy(false);
}
};
const hb = jobs.state === "ok" ? jobs.data.heartbeat : null;
const feedJob = jobs.state === "ok" ? jobs.data.jobs.find((j) => j.job === "visual-feed") : undefined;
const next = settings.next_run_at ?? feedJob?.next_run_at ?? null;
return (
<div className="stack">
<div className="sec-h">
<span className="small">
{hb ? (
hb.stale ? (
<span className="due over">Worker not running (last seen {fmtAgo(hb.at)}).</span>
) : (
<>Worker seen {fmtAgo(hb.at)}{hb.build ? ` · build ${hb.build}` : ""}. </>
)
) : null}
{settings.paused ? <b className="due over">Paused. </b> : null}
Next feed run: <b>{next ? `${fmtTime(next)} (${fmtIn(next)})` : "not scheduled"}</b>
</span>
<span className="grow" />
<button className="btn sm" type="button" disabled={busy} onClick={() => void runNow("drawing-lookup")}>
Look up drawings now
</button>
<button className="btn sm pri" type="button" disabled={busy} onClick={() => void runNow("visual-feed")}>
Run now
</button>
</div>
<Msg msg={msg} />
<form className="panel stack" onSubmit={save} noValidate aria-label="Feed settings">
<label className="ckl">
<input type="checkbox" checked={form.paused} onChange={(e) => set("paused", e.target.checked)} />
Paused: no scheduled feed, drawing or auto-release runs. Run now still works.
</label>
<h3>Schedule</h3>
<div className="chips" role="group" aria-label="Shop days">
{DAYS.map(([d, l]) => (
<button key={d} type="button" className="chip sm" aria-pressed={form.shop_days.includes(d)} onClick={() => set("shop_days", toggle(form.shop_days, d).sort())}>
{l}
</button>
))}
</div>
<div className="frow">
<label className="f">
Shop hours start
<input className="in" type="time" value={form.shop_start} onChange={(e) => set("shop_start", e.target.value)} />
</label>
<label className="f">
Shop hours end
<input className="in" type="time" value={form.shop_end} onChange={(e) => set("shop_end", e.target.value)} />
</label>
{NUMS.slice(0, 2).map(([k, l, lo, hi]) => (
<label key={k} className="f">
{l}
<input className="in" type="number" min={lo} max={hi} value={form.nums[k]} onChange={(e) => setNum(k, e.target.value)} />
</label>
))}
</div>
<h3>What is read</h3>
<div className="frow">
{NUMS.slice(2, 4).map(([k, l, lo, hi]) => (
<label key={k} className="f">
{l}
<input className="in" type="number" min={lo} max={hi} value={form.nums[k]} onChange={(e) => setNum(k, e.target.value)} />
</label>
))}
<label className="f">
Go-live date
<input className="in" type="date" value={form.golive_date} onChange={(e) => set("golive_date", e.target.value)} />
</label>
</div>
<div className="small muted">Work orders released before the go-live date stay on paper. Clerical can pull one in.</div>
<div className="small">
<b>Departments</b> <span className="muted">Only work orders for these become packets. None picked means every department the resource map routes to.</span>
</div>
<div className="chips" role="group" aria-label="Departments">
{depts.map((d) => (
<button
key={d.code}
type="button"
className="chip sm"
aria-pressed={form.departments.includes(d.code)}
onClick={() => set("departments", toggle(form.departments, d.code).sort())}
>
{d.code} {d.name}
</button>
))}
</div>
<label className="ckl">
<input type="checkbox" checked={form.labor_tickets} onChange={(e) => set("labor_tickets", e.target.checked)} />
Read labor ticket hours (at most every 30 minutes)
</label>
<h3>Drawings and release</h3>
<div className="frow">
{NUMS.slice(4).map(([k, l, lo, hi]) => (
<label key={k} className="f">
{l}
<input className="in" type="number" min={lo} max={hi} value={form.nums[k]} onChange={(e) => setNum(k, e.target.value)} />
</label>
))}
</div>
<label className="ckl">
<input type="checkbox" checked={form.auto_release} onChange={(e) => set("auto_release", e.target.checked)} />
Send drafts to the warehouse on their own when the drawing is attached and nothing is flagged
</label>
<div>
<button className="btn pri" type="submit" disabled={busy}>
Save
</button>
</div>
</form>
<h3>Last ten feed runs</h3>
{runs.state === "ok" ? <RunsTable runs={runs.data} label="Last feed runs" /> : runs.state === "error" ? <div className="callout warn">{runs.message}</div> : null}
</div>
);
}
/* ---------- VISUAL resources ---------- */
export function ResourcesTab() {
const depts = useShopDepartments();
const [load, reload] = useLoad((s) => feedAdminApi.resourceMap(s));
const [edits, setEdits] = useState<Record<string, string>>({});
const [busy, setBusy] = useState(false);
const [msg, setMsg] = useState<{ ok: boolean; text: string } | null>(null);
const rows = useMemo(
() => (load.state === "ok" ? [...load.data].sort((a, b) => (b.rows_last_run ?? 0) - (a.rows_last_run ?? 0) || a.resource.localeCompare(b.resource)) : []),
[load],
);
const byName = useMemo(() => new Map(rows.map((r) => [r.resource, r])), [rows]);
const pending = Object.keys(edits).filter((k) => (byName.get(k)?.target ?? "") !== edits[k]);
const saveOne = async (r: ResourceMapRow, target: string) => {
if (target === "") {
if (r.target) await feedAdminApi.unmapResource(r.resource);
} else {
await feedAdminApi.mapResource(r.resource, target);
}
};
const save = async (names: string[]) => {
setBusy(true);
setMsg(null);
let done = 0;
try {
for (const k of names) {
const r = byName.get(k);
const t = edits[k];
if (!r || t === undefined) continue;
await saveOne(r, t);
done++;
setEdits((e) => {
const n = { ...e };
delete n[k];
return n;
});
}
setMsg({ ok: true, text: done === 1 ? "Saved. The feed uses it from its next run." : `${done} saved. The feed uses them from its next run.` });
} catch (err) {
setMsg({ ok: false, text: errorText(err) });
} finally {
setBusy(false);
reload();
}
};
if (load.state === "loading") return <div className="empty slim">Loading</div>;
if (load.state === "error") return <div className="callout warn">{load.message}</div>;
const unassigned = rows.filter((r) => !r.target).length;
return (
<div className="stack">
<div className="sec-h">
<span className="small muted">
Which department gets the packet for each VISUAL resource (RESOURCE_ID). Starting assignments are a best guess from the resource names. Check each one.
Lines are counted from the last feed run.
</span>
<span className="grow" />
{pending.length ? (
<button className="btn sm pri" type="button" disabled={busy} onClick={() => void save(pending)}>
Save {pending.length} change{pending.length === 1 ? "" : "s"}
</button>
) : null}
</div>
<Msg msg={msg} />
{unassigned ? (
<div className="callout warn flush">
{unassigned === 1 ? "1 resource is" : `${unassigned} resources are`} not assigned. Their work orders are left out of the feed.
</div>
) : null}
<div className="tw">
<table aria-label="VISUAL resources">
<thead>
<tr>
<th>Resource</th>
<th className="num">Lines in last run</th>
<th>Goes to</th>
<th>Confirmed</th>
<th />
</tr>
</thead>
<tbody>
{rows.map((r) => {
const cur = edits[r.resource] ?? r.target ?? "";
const dirty = cur !== (r.target ?? "");
return (
<tr key={r.resource}>
<td className="mono">{r.resource}</td>
<td className="num">{r.rows_last_run ?? "-"}</td>
<td>
<select
className="in"
aria-label={`${r.resource} goes to`}
value={cur}
onChange={(e) => setEdits((x) => ({ ...x, [r.resource]: e.target.value }))}
>
<option value="">Not assigned</option>
{depts.map((d) => (
<option key={d.code} value={d.code}>
{d.code} {d.name}
</option>
))}
{cur && cur !== "fold" && cur !== "ignore" && !depts.some((d) => d.code === cur) ? <option value={cur}>{cur}</option> : null}
<option value="fold">Fold into the packet (MFG, inspection, material)</option>
<option value="ignore">Ignore</option>
</select>
{dirty ? <span className="small due soon"> unsaved</span> : null}
</td>
<td className="small">{r.confirmed ? "✓ confirmed" : r.target ? <span className="muted">guess</span> : <span className="due over">not assigned</span>}</td>
<td>
{dirty ? (
<button className="btn sm" type="button" disabled={busy} onClick={() => void save([r.resource])} aria-label={`Save ${r.resource}`}>
Save
</button>
) : null}
</td>
</tr>
);
})}
{rows.length === 0 ? (
<tr>
<td colSpan={5} className="muted">
No resources yet. They appear after the first feed run.
</td>
</tr>
) : null}
</tbody>
</table>
</div>
{rows.some((r) => r.updated_at) ? (
<div className="small muted">Last change {fmtTime(rows.map((r) => r.updated_at ?? "").sort().pop() ?? null)}.</div>
) : null}
</div>
);
}
@@ -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,