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