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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user