Phase 2: core domain, policy engine, packet API, Clerical screen

Migration 0003: projects, aliases, renumbers, units, packets and every child table,
change orders, assets, documents, ITPs, station limits, department weld flag.
Policy package: Principal, PacketState, Action rules, custody, preconditions,
CO hold, order of checks, can/blocked/holder on reads. docs/POLICY.md with the
tightenings table for Seth. Write pipeline: FOR UPDATE, CAS on status and dept,
If-Match row versions, server-assigned numbers, field CAS, version bump, packet
log, audit, NOTIFY, signatures, IT reasons, filed lock and HMAC seal.
API: /api/packets (header, transitions, work orders, pages, docs, move, remove,
BOM, shorts, welds, heat tags, heat table, stage, checklist, clock, warehouse
stage, Q.C., change orders, log, seal), /api/projects, /api/units, /api/assets,
/_blob. Frontend: Clerical home, projects and units, packet page with pdf.js
and QR codes, hand draft, heat map, send to warehouse, department or Q.C.
Tests: policy matrix, API, concurrency, schema, projects; Playwright Clerical
draft scenario. Verified on docker-1 with the CI stack and the Samba DC.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
2026-10-16 09:31:45 -06:00
co-authored by Claude Fable 5.1
parent 97b0973130
commit 37d9a0cb50
83 changed files with 16490 additions and 55 deletions
+111
View File
@@ -0,0 +1,111 @@
import { useEffect, useRef, useState } from "react";
import type { PacketPage } from "../api/types";
import { openPdf } from "../lib/pdf";
const ZOOMS = [0.5, 0.75, 1, 1.25, 1.5, 2, 3];
/** The drawing pages, one at a time, fitted to the width. Zoom buttons and a page list. No marking yet. */
export function DrawingViewer({ pages }: { pages: PacketPage[] }) {
const [sel, setSel] = useState(0);
const [zi, setZi] = useState(2);
const [width, setWidth] = useState(0);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const wrapRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const page = pages[Math.min(sel, pages.length - 1)];
const zoom = ZOOMS[zi] ?? 1;
const url = page?.url ?? "";
const pn = page?.pn ?? 1;
// Fit to width: follow the frame's width.
useEffect(() => {
const el = wrapRef.current;
if (!el) return;
const measure = () => setWidth(el.clientWidth);
measure();
if (typeof ResizeObserver === "undefined") return;
const ro = new ResizeObserver(measure);
ro.observe(el);
return () => ro.disconnect();
}, []);
useEffect(() => {
if (!url || !width) return;
let cancelled = false;
let task: { cancel: () => void; promise: Promise<unknown> } | null = null;
setBusy(true);
setError(null);
openPdf(url)
.then((doc) => doc.getPage(Math.max(1, Math.min(pn || 1, doc.numPages))))
.then(async (pg) => {
if (cancelled) return;
const canvas = canvasRef.current;
if (!canvas) return;
const base = pg.getViewport({ scale: 1 });
const scale = (Math.max(200, width - 2) / base.width) * zoom;
const ratio = window.devicePixelRatio || 1;
const vp = pg.getViewport({ scale: scale * ratio });
canvas.width = Math.floor(vp.width);
canvas.height = Math.floor(vp.height);
canvas.style.width = `${Math.floor(vp.width / ratio)}px`;
canvas.style.height = `${Math.floor(vp.height / ratio)}px`;
task = pg.render({ canvas, viewport: vp });
await task.promise;
if (!cancelled) setBusy(false);
})
.catch((err: unknown) => {
if (cancelled) return;
if (err && typeof err === "object" && "name" in err && (err as { name: string }).name === "RenderingCancelledException") return;
setBusy(false);
setError("The drawing could not be shown. Open it in a new tab instead.");
});
return () => {
cancelled = true;
task?.cancel();
};
}, [url, pn, width, zoom]);
if (!page) return <div className="empty">No drawing attached yet.</div>;
return (
<div className="pdfv">
<div className="pdfv-bar">
<div className="seg" role="group" aria-label="Zoom">
<button type="button" aria-label="Zoom out" disabled={zi === 0} onClick={() => setZi((z) => Math.max(0, z - 1))}>
−
</button>
<button type="button" aria-label="Fit to width" aria-pressed={zoom === 1} onClick={() => setZi(2)}>
Fit
</button>
<button type="button" aria-label="Zoom in" disabled={zi === ZOOMS.length - 1} onClick={() => setZi((z) => Math.min(ZOOMS.length - 1, z + 1))}>
+
</button>
</div>
<span className="small muted mono">{Math.round(zoom * 100)}%</span>
<span className="grow" />
<a className="btn sm ghost" href={page.url} target="_blank" rel="noopener noreferrer">
Open in a new tab
</a>
</div>
<div className="pdfv-body">
<ol className="pdfv-pages" aria-label="Pages">
{pages.map((p, i) => (
<li key={p.n}>
<button type="button" aria-current={i === sel ? "page" : undefined} onClick={() => setSel(i)}>
<span className="mono">p.{p.n}</span>
{p.dwg ? <span className="small muted"> {p.dwg}{p.rev ? ` R${p.rev}` : ""}</span> : null}
{p.void ? <span className="pill st-bad">Void</span> : null}
</button>
</li>
))}
</ol>
<div className="pdfv-frame" ref={wrapRef}>
{error ? <div className="callout warn flush">{error}</div> : null}
{busy && !error ? <div className="small muted">Drawing the page</div> : null}
<canvas ref={canvasRef} className="pdfv-canvas" role="img" aria-label={`Drawing page ${page.n}`} />
</div>
</div>
</div>
);
}
+190
View File
@@ -0,0 +1,190 @@
import { act, fireEvent, screen, waitFor, within } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { DEPARTMENTS, makeMe, renderApp } from "../test/auth";
import { callsTo, mockFetch } from "../test/fetchMock";
import { makePacket } from "../test/packets";
function base(packet = makePacket()) {
return {
"/api/auth/me": { body: makeMe() },
"/api/departments": { body: DEPARTMENTS },
"/api/packets/1": { body: packet },
"/api/packets/1/log": { body: [] },
};
}
describe("PacketPage", () => {
it("shows Send to warehouse disabled with the blocked reason", async () => {
mockFetch(base());
renderApp("/packets/1");
const send = await screen.findByRole("button", { name: "Send to warehouse" });
expect(send).toBeDisabled();
expect(screen.getByText("Say whether this packet needs a heat map.")).toHaveClass("why");
expect(screen.getByRole("heading", { name: "L999001 · WELD-PART" })).toBeInTheDocument();
expect(screen.getByTestId("status-pill")).toHaveTextContent("Ready to release");
expect(screen.queryByText("View only.")).not.toBeInTheDocument();
});
it("enables Send to warehouse when can includes it, and posts the transition with expect", async () => {
const ready = makePacket({ heat_map: true, can: ["edit_header", "send_to_warehouse"], blocked: {} });
const fetch = mockFetch({
...base(ready),
"POST /api/packets/1/transitions": { body: makePacket({ status: "warehouse", version: 4, can: [], blocked: {}, holder: "Warehouse" }) },
});
renderApp("/packets/1");
const send = await screen.findByRole("button", { name: "Send to warehouse" });
expect(send).toBeEnabled();
expect(screen.queryByText("Say whether this packet needs a heat map.")).not.toBeInTheDocument();
fireEvent.click(send);
await waitFor(() => expect(screen.getByTestId("status-pill")).toHaveTextContent("With warehouse"));
expect(callsTo(fetch, "POST", "/api/packets/1/transitions")).toEqual([{ action: "send_to_warehouse", expect: { status: "draft", dept: "03" } }]);
expect(screen.getByText("View only.")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Send to warehouse" })).not.toBeInTheDocument();
});
it("heat map chips PATCH with the base value", async () => {
const fetch = mockFetch({
...base(),
"PATCH /api/packets/1": { body: makePacket({ heat_map: true, version: 4 }) },
});
renderApp("/packets/1");
const group = await screen.findByRole("group", { name: "Heat map required?" });
fireEvent.click(within(group).getByRole("button", { name: "Yes" }));
await waitFor(() => expect(within(group).getByRole("button", { name: "Yes" })).toHaveAttribute("aria-pressed", "true"));
expect(callsTo(fetch, "PATCH", "/api/packets/1")).toEqual([{ set: { heat_map: true }, base: { heat_map: null } }]);
});
it("attaching a drawing uploads it, then signs and sends sign", async () => {
const fetch = mockFetch({
...base(),
"POST /api/assets": {
status: 201,
body: { id: "a1b2", mime: "application/pdf", size: 900, sha256: "00", pages: 1, original_name: "d.pdf", url: "/_blob/a1b2" },
},
"POST /api/packets/1/pages": { body: makePacket({ version: 4, drawing_state: "hand" }) },
});
renderApp("/packets/1");
const input = await screen.findByLabelText("Attach drawing (PDF)");
const file = new File(["%PDF-1.4\n"], "d.pdf", { type: "application/pdf" });
fireEvent.change(input, { target: { files: [file] } });
const sheet = await screen.findByRole("dialog", { name: "Sign" });
expect(within(sheet).getByText("Attach a drawing by hand")).toBeInTheDocument();
expect(within(sheet).getByText(/You are signing as/)).toHaveTextContent("You are signing as Clara Clerk.");
expect(callsTo(fetch, "POST", "/api/assets")).toHaveLength(1);
fireEvent.change(within(sheet).getByLabelText("Password"), { target: { value: "pw-123" } });
fireEvent.click(within(sheet).getByRole("button", { name: "Sign" }));
await waitFor(() => expect(screen.queryByRole("dialog", { name: "Sign" })).not.toBeInTheDocument());
expect(callsTo(fetch, "POST", "/api/packets/1/pages")).toEqual([
{ asset_id: "a1b2", type: "drawing", dwg: "CI-DWG-1", rev: "0", sign: { factor: "password", secret: "pw-123" } },
]);
expect(await screen.findByText("Drawing attached (1 page).")).toBeInTheDocument();
});
it("a wrong signature keeps the sheet open with the attempts left", async () => {
mockFetch({
...base(),
"POST /api/assets": { status: 201, body: { id: "a1", mime: "application/pdf", size: 9, sha256: "00", pages: 2, original_name: "d.pdf", url: "/_blob/a1" } },
"POST /api/packets/1/pages": { status: 403, body: { detail: "Wrong password.", code: "wrong_secret", attempts_left: 2 } },
});
renderApp("/packets/1");
fireEvent.change(await screen.findByLabelText("Attach drawing (PDF)"), { target: { files: [new File(["%PDF"], "d.pdf")] } });
const sheet = await screen.findByRole("dialog", { name: "Sign" });
fireEvent.change(within(sheet).getByLabelText("Password"), { target: { value: "nope" } });
fireEvent.click(within(sheet).getByRole("button", { name: "Sign" }));
expect(await within(sheet).findByText("Wrong password.")).toBeInTheDocument();
expect(within(sheet).getByText("2 attempts left.")).toBeInTheDocument();
expect(within(sheet).getByText("Three wrong attempts end the session.")).toBeInTheDocument();
});
it("a stale step shows the server message and reloads the packet", async () => {
let gets = 0;
const fetch = mockFetch({
...base(),
"/api/packets/1": () => {
gets++;
return gets === 1
? { body: makePacket({ heat_map: true, can: ["send_to_warehouse", "edit_header"], blocked: {} }) }
: { body: makePacket({ status: "warehouse", heat_map: true, can: [], blocked: {}, holder: "Warehouse" }) };
},
"POST /api/packets/1/transitions": {
status: 409,
body: { detail: "Someone else already took this step.", code: "stale_step", current: { status: "warehouse", dept: "03", version: 5 } },
},
});
renderApp("/packets/1");
fireEvent.click(await screen.findByRole("button", { name: "Send to warehouse" }));
expect(await screen.findByRole("alert")).toHaveTextContent("Someone else already took this step.");
await waitFor(() => expect(callsTo(fetch, "GET", "/api/packets/1")).toHaveLength(2));
expect(screen.getByTestId("status-pill")).toHaveTextContent("With warehouse");
});
it("asks an admin for the IT reason and retries with it", async () => {
const admin = makeMe({ roles: ["admin"], screens: ["clerical", "warehouse", "supervisor", "stall", "qc", "pm", "admin"] });
let tries = 0;
const fetch = mockFetch({
...base(makePacket({ heat_map: true, can: ["send_to_warehouse"], blocked: {} })),
"/api/auth/me": { body: admin },
"POST /api/packets/1/transitions": () =>
++tries === 1
? { status: 400, body: { detail: "Give an IT reason.", code: "it_reason_required" } }
: { body: makePacket({ status: "warehouse", can: [], blocked: {} }) },
});
renderApp("/packets/1");
fireEvent.click(await screen.findByRole("button", { name: "Send to warehouse" }));
const sheet = await screen.findByRole("dialog", { name: "IT reason" });
fireEvent.change(within(sheet).getByLabelText("Why IT is making this change"), { target: { value: "Clerk is out sick today" } });
fireEvent.click(within(sheet).getByRole("button", { name: "Continue" }));
await waitFor(() => expect(screen.getByTestId("status-pill")).toHaveTextContent("With warehouse"));
expect(callsTo(fetch, "POST", "/api/packets/1/transitions")).toEqual([
{ action: "send_to_warehouse", expect: { status: "draft", dept: "03" } },
{ action: "send_to_warehouse", expect: { status: "draft", dept: "03" }, it_reason: "Clerk is out sick today" },
]);
});
it("shows View only with the holder when the user has no actions", async () => {
mockFetch({
...base(makePacket({ can: [], blocked: {}, holder: "Clerical" })),
"/api/auth/me": { body: makeMe({ roles: ["warehouse"], screens: ["warehouse"] }) },
});
renderApp("/packets/1");
const note = (await screen.findByText("View only.")).closest(".viewonly") as HTMLElement;
expect(note).toHaveTextContent("This packet is with Clerical.");
expect(screen.queryByRole("button", { name: /Send/ })).not.toBeInTheDocument();
expect(screen.queryByRole("group", { name: "Heat map required?" })).not.toBeInTheDocument();
});
it("reloads when a changed event names this packet", async () => {
const sources: Array<{ emit: (t: string, d: unknown) => void }> = [];
class FakeEventSource {
listeners = new Map<string, (ev: MessageEvent) => void>();
constructor() {
sources.push(this);
}
addEventListener(type: string, fn: (ev: MessageEvent) => void) {
this.listeners.set(type, fn);
}
close() {}
emit(type: string, data: unknown) {
this.listeners.get(type)?.(new MessageEvent(type, { data: JSON.stringify(data) }));
}
}
vi.stubGlobal("EventSource", FakeEventSource);
const fetch = mockFetch(base());
renderApp("/packets/1");
await screen.findByRole("heading", { name: "L999001 · WELD-PART" });
expect(callsTo(fetch, "GET", "/api/packets/1")).toHaveLength(1);
vi.useFakeTimers();
try {
act(() => sources.at(-1)?.emit("changed", { event: "changed", packet_id: 2, version: 9 }));
act(() => sources.at(-1)?.emit("changed", { event: "changed", packet_id: 1, version: 9 }));
await act(async () => {
await vi.advanceTimersByTimeAsync(600);
});
} finally {
vi.useRealTimers();
}
await waitFor(() => expect(callsTo(fetch, "GET", "/api/packets/1")).toHaveLength(2));
});
});
+554
View File
@@ -0,0 +1,554 @@
import { useCallback, useEffect, useRef, useState, type FormEvent, type ReactNode } from "react";
import { Link, useNavigate, useParams } from "react-router";
import { assetsApi } from "../api/assets";
import { ApiError } from "../api/client";
import { packetsApi } from "../api/packets";
import type { PacketHeader, PacketRead, TransitionBody } from "../api/types";
import { homePath } from "../auth/roles";
import { useAuth } from "../auth/useAuth";
import { useGuard } from "../components/useGuard";
import { useChanged } from "../lib/changes";
import { errorText } from "../lib/format";
import { dueInfo, flagClass, jobName, pillClass, statusLabel, targetHours } from "../lib/packets";
import { DrawingViewer } from "./DrawingViewer";
import { MoveSheet, SendToDeptSheet } from "./sheets";
import { DocumentsTab, HistoryTab, MaterialsTab, WorkOrdersTab, type Mutate } from "./tabs";
type Tab = "drawing" | "materials" | "workorders" | "docs" | "history";
/** The rule's signature meaning for attaching a drawing by hand. */
const SIGN_ATTACH = "Attach a drawing by hand";
const SIGN_FILE = "File in the data book";
/** One step button. Hidden when the user can neither do it nor is blocked from it. Blocked shows the reason under it. */
function Act({
p,
action,
busy,
onClick,
className = "btn",
children,
}: {
p: PacketRead;
action: string;
busy: boolean;
onClick: () => void;
className?: string;
children: ReactNode;
}) {
const can = p.can.includes(action);
const why = p.blocked[action];
if (!can && !why) return null;
const reason = why?.join(" ") ?? "";
return (
<div className="act">
<button className={className} type="button" disabled={!can || busy} title={!can ? reason : undefined} onClick={onClick}>
{children}
</button>
{!can && reason ? <div className="why small">{reason}</div> : null}
</div>
);
}
/** Title, customer, WPS, drawing and rev, edited in place with a field-level base. */
function HeaderEdit({ p, mutate, busy, onDone }: { p: PacketRead; mutate: Mutate; busy: boolean; onDone: () => void }) {
const head = p.can.includes("edit_header");
const dwg = p.can.includes("edit_drawing_ref");
const [v, setV] = useState({ title: p.title ?? "", customer: p.customer ?? "", wps: p.wps ?? "", drawing: p.drawing ?? "", rev: p.rev ?? "" });
const field = (k: keyof typeof v, label: string, max: number, on: boolean) =>
on ? (
<label className="f">
{label}
<input className="in" value={v[k]} maxLength={max} onChange={(e) => setV((o) => ({ ...o, [k]: e.target.value }))} />
</label>
) : null;
const save = async (e: FormEvent) => {
e.preventDefault();
const set: Partial<PacketHeader> = {};
const base: Partial<PacketHeader> = {};
for (const k of Object.keys(v) as Array<keyof typeof v>) {
const now = v[k].trim() || null;
const was = p[k] ?? null;
if (now !== was) {
set[k] = now;
base[k] = was;
}
}
if (Object.keys(set).length === 0) {
onDone();
return;
}
if (await mutate((g) => packetsApi.patch(p.id, { set, base, ...g }), { ok: "Saved" })) onDone();
};
return (
<form className="panel stack" onSubmit={save} noValidate aria-label="Edit details">
<div className="frow">
{field("title", "Title", 200, head)}
{field("customer", "Customer", 120, head)}
{field("wps", "WPS", 40, head)}
{field("drawing", "Drawing no.", 60, dwg)}
{field("rev", "Rev", 10, dwg)}
</div>
<div className="sheet-acts start">
<button className="btn pri" type="submit" disabled={busy}>
Save
</button>
<button className="btn ghost" type="button" onClick={onDone}>
Cancel
</button>
</div>
</form>
);
}
/** /packets/:id: the packet for every signed-in role. Controls come from `can` and `blocked`. */
export function PacketPage() {
const params = useParams();
const id = Number(params.id);
const navigate = useNavigate();
const { me } = useAuth();
const { run, askReason, sheet: guardSheet } = useGuard();
const [packet, setPacket] = useState<PacketRead | null>(null);
const [loadError, setLoadError] = useState<string | null>(null);
const [msg, setMsg] = useState<{ ok: boolean; text: string } | null>(null);
const [busy, setBusy] = useState(false);
const [tab, setTab] = useState<Tab | null>(null);
const [sheet, setSheet] = useState<"dept" | "move" | null>(null);
const [editing, setEditing] = useState(false);
const versionRef = useRef<number | null>(null);
const fileRef = useRef<HTMLInputElement>(null);
const reload = useCallback(async () => {
try {
const p = await packetsApi.get(id);
setPacket(p);
setLoadError(null);
} catch (err) {
setLoadError(errorText(err));
}
}, [id]);
useEffect(() => {
setPacket(null);
setTab(null);
void reload();
}, [reload]);
useEffect(() => {
versionRef.current = packet?.version ?? null;
}, [packet]);
// Live refresh: another screen changed this packet.
useChanged(() => void reload(), {
delayMs: 500,
filter: (ev) => Number(ev.packet_id) === id && (ev.version === undefined || ev.version !== versionRef.current),
});
const job = packet ? jobName(packet) : "";
const mutate: Mutate = useCallback(
async (fn, opts = {}) => {
setBusy(true);
setMsg(null);
try {
const r = await run(fn, { sign: opts.sign, sub: job });
if (r === undefined) return false;
if (r && typeof r === "object" && "id" in r) setPacket(r);
else await reload();
if (opts.ok) setMsg({ ok: true, text: opts.ok });
return true;
} catch (err) {
// stale_step, field_conflict, row_conflict: someone else moved first. Show their state.
if (err instanceof ApiError && (err.status === 409 || err.status === 412)) await reload();
setMsg({ ok: false, text: errorText(err) });
return false;
} finally {
setBusy(false);
}
},
[run, reload, job],
);
if (!packet) {
return loadError ? <div className="callout warn">{loadError}</div> : <div className="empty slim">Loading</div>;
}
const p = packet;
const can = (a: string) => p.can.includes(a);
const draft = p.status === "draft";
const transition = (action: string, extra: Omit<TransitionBody, "action" | "expect"> = {}, ok?: string) =>
mutate((g) => packetsApi.transition(p.id, { action, expect: { status: p.status, dept: p.dept.code }, ...extra, ...g }), { ok });
const setHeatMap = (v: boolean) =>
mutate((g) => packetsApi.patch(p.id, { set: { heat_map: v }, base: { heat_map: p.heat_map }, ...g }));
const attach = async (file: File) => {
setBusy(true);
setMsg(null);
let asset;
try {
asset = await assetsApi.upload(file, { packet_id: p.id, purpose: "drawing" });
} catch (err) {
setMsg({ ok: false, text: errorText(err) });
setBusy(false);
return;
} finally {
if (fileRef.current) fileRef.current.value = "";
}
setBusy(false);
if (asset.mime !== "application/pdf") {
setMsg({ ok: false, text: "The drawing must be a PDF." });
return;
}
const pages = asset.pages ?? 0;
await mutate(
(g) =>
packetsApi.attachPages(p.id, {
asset_id: asset.id,
type: "drawing",
...(p.drawing ? { dwg: p.drawing } : {}),
...(p.rev ? { rev: p.rev } : {}),
...g,
}),
{ sign: SIGN_ATTACH, ok: pages ? `Drawing attached (${pages} page${pages === 1 ? "" : "s"}).` : "Drawing attached." },
);
};
const sendToQc = async () => {
const note = await askReason({
title: "Send straight to Q.C.",
sub: job,
label: "Note (optional, Q.C. will see it)",
note: "Skips the warehouse and the shop. Q.C. gets it for inspection.",
min: 0,
submitLabel: "Send to Q.C.",
});
if (note === null) return;
await transition("send_draft_to_qc", note ? { note } : {}, "Sent to Q.C.");
};
const remove = async () => {
const reason = await askReason({
title: "Remove packet",
sub: job,
note: "The packet leaves every board. It stays in the record with your reason.",
min: 8,
danger: true,
submitLabel: "Remove packet",
});
if (reason === null) return;
setBusy(true);
setMsg(null);
try {
const r = await run((g) => packetsApi.remove(p.id, { reason, ...g }), { sub: job });
if (r !== undefined) navigate(`/clerical/units/${p.unit.id}`);
} catch (err) {
if (err instanceof ApiError && err.status === 409) await reload();
setMsg({ ok: false, text: errorText(err) });
} finally {
setBusy(false);
}
};
const removeDrawing = async (n: number) => {
await mutate((g) => packetsApi.removePages(p.id, n, g), { ok: "Drawing removed." });
};
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 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"]);
const viewOnly =
p.can.every((a) => passive.has(a)) && Object.keys(p.blocked).every((a) => passive.has(a));
const project = p.unit.project;
const di = dueInfo(p.unit.due_effective, project.status === "closed");
const target = targetHours(p);
const lotQty = Math.max(0, ...p.work_orders.map((w) => Number(w.qty) || 0));
const backTo = me?.screens.includes("clerical") ? `/clerical/units/${p.unit.id}` : homePath(me?.screens ?? []);
const hasDraftControls =
draft && ["edit_header", "send_to_warehouse", "release_to_shop", "send_draft_to_qc", "edit_document_set", "move_unit", "remove"].some((a) => can(a) || p.blocked[a]);
const tabs: Array<{ id: Tab; label: string; count?: number }> = [
{ 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: "docs", label: "Documents", count: p.docs.length },
{ id: "history", label: "History" },
];
return (
<>
<Link className="back" to={backTo}>
← Back
</Link>
<div className="dhead">
<div className="grow0">
<div className="eyebrow">
{p.op ?? ""} · {statusLabel(p.status)}
</div>
<h1>{job}</h1>
{p.title ? <div className="dtitle">{p.title}</div> : null}
<div className="small crumbs2">
Project <b>{project.number}</b> {project.name ?? ""} <span className="muted">›</span> Unit <b>{p.unit.number}</b> {p.unit.name ?? ""}
{project.po ? (
<>
{" "}
· PO <span className="mono">{project.po}</span>
</>
) : null}{" "}
· <span className={di.cls}>{di.txt}</span>
</div>
<div className="facts">
{lotQty ? (
<div>
<span>Lot qty </span>
<b>{lotQty}</b>
</div>
) : null}
<div>
<span>Customer </span>
<b>{p.customer || "-"}</b>
</div>
<div>
<span>Drawing </span>
<b>{p.drawing ? `${p.drawing} R${p.rev || "-"}` : "-"}</b>
</div>
<div>
<span>WPS </span>
<b>{p.wps || "-"}</b>
</div>
<div>
<span>Department </span>
<b>
{p.dept.code} {p.dept.name}
</b>
</div>
<div>
<span>Target </span>
<b>{target.toFixed(1)} h</b>
</div>
<div>
<span>Heat map </span>
<b>{p.heat_map === true ? "Yes" : p.heat_map === false ? "Not required" : "Not answered"}</b>
</div>
{p.for_sup ? (
<div>
<span>For </span>
<b>{p.for_sup.display_name}</b>
</div>
) : null}
{p.station ? (
<div>
<span>Station </span>
<b>{p.station.name}</b>
</div>
) : null}
{p.welder ? (
<div>
<span>Welder </span>
<b>
{p.welder.display_name}
{p.welder.stamp ? ` [${p.welder.stamp}]` : ""}
</b>
</div>
) : null}
</div>
</div>
<div className="pacts">
<span className={pillClass(p.status)} data-testid="status-pill">
{statusLabel(p.status)}
</span>
{can("edit_header") || can("edit_drawing_ref") ? (
<button className="btn sm" type="button" onClick={() => setEditing((e) => !e)} aria-expanded={editing}>
Edit details
</button>
) : null}
{can("move_unit") ? (
<button className="btn sm" type="button" disabled={busy} onClick={() => setSheet("move")}>
Move
</button>
) : null}
</div>
</div>
{p.flag ? <div className={`small flagline ${flagClass(p.flag)}`}>⚑ {p.flag}</div> : null}
{p.removed ? (
<div className="callout warn">
<b>Removed</b>
{p.removed.by ? ` by ${p.removed.by.display_name}` : ""}. {p.removed.reason ?? ""}
</div>
) : null}
{p.return_note ? (
<div className="callout warn">
<b>Returned:</b> {p.return_note}
</div>
) : null}
{viewOnly && !p.removed ? (
<div className="viewonly">
<span aria-hidden="true">●</span>
<div>
<b>View only.</b> This packet is with <b>{p.holder}</b>. You can follow its progress. Changes open up only when it is sent to you or sent back.
</div>
</div>
) : null}
{msg ? (
<div className={msg.ok ? "callout" : "callout warn"} role={msg.ok ? "status" : "alert"}>
{msg.text}
</div>
) : null}
{editing ? <HeaderEdit key={p.version} p={p} mutate={mutate} busy={busy} onDone={() => setEditing(false)} /> : null}
{hasDraftControls ? (
<section className="panel actbox" aria-label="Draft steps">
<div>
<b>Ready to release.</b> Check the BOM and work orders, attach the drawing, and say whether it needs a heat map. Then send it to the warehouse for the
parts pull, or straight to a department or Q.C.
</div>
{can("edit_header") ? (
<div className="hmq" role="group" aria-label="Heat map required?">
<b>Heat map required?</b>
<button type="button" className="chip sm" aria-pressed={p.heat_map === true} disabled={busy} onClick={() => void setHeatMap(true)}>
Yes
</button>
<button type="button" className="chip sm" aria-pressed={p.heat_map === false} disabled={busy} onClick={() => void setHeatMap(false)}>
No
</button>
{p.heat_map === null ? <span className="due soon small">choose before sending</span> : null}
</div>
) : null}
<div className="acts">
{can("edit_document_set") ? (
<div className="act">
<label className={busy ? "btn dim" : "btn"}>
{drawingPages.length ? "Attach another drawing" : "Attach drawing"}
<input
ref={fileRef}
className="sr"
type="file"
accept="application/pdf"
aria-label="Attach drawing (PDF)"
disabled={busy}
onChange={(e) => {
const f = e.target.files?.[0];
if (f) void attach(f);
}}
/>
</label>
</div>
) : null}
<Act p={p} action="send_to_warehouse" busy={busy} className="btn pri" onClick={() => void transition("send_to_warehouse", {}, "Sent to the warehouse.")}>
Send to warehouse
</Act>
<Act p={p} action="release_to_shop" busy={busy} onClick={() => setSheet("dept")}>
Send to a department
</Act>
<Act p={p} action="send_draft_to_qc" busy={busy} onClick={() => void sendToQc()}>
Send to Q.C.
</Act>
<Act p={p} action="remove" busy={busy} className="btn rejb" onClick={() => void remove()}>
Remove
</Act>
</div>
</section>
) : null}
{!draft && (can("set_vclosed") || can("file") || p.blocked.file) ? (
<section className="panel actbox" aria-label="Close and file">
<div>
{p.vclosed ? (
<>
<b>Ready for the data book.</b> Job closed in VISUAL by {p.vclosed.by?.display_name ?? "Clerical"}. Mark it filed when the data book is done.
</>
) : (
<>
<b>Back from the shop, ready to close.</b> Close the job in VISUAL, then click below. The packet moves on to the data book.
</>
)}
</div>
<div className="acts">
{!p.vclosed ? (
<Act p={p} action="set_vclosed" busy={busy} className="btn pri" onClick={() => void mutate((g) => packetsApi.setVclosed(p.id, true, g), { ok: "Marked closed in VISUAL." })}>
Job closed in VISUAL
</Act>
) : (
<Act p={p} action="set_vclosed" busy={busy} className="btn ghost" onClick={() => void mutate((g) => packetsApi.setVclosed(p.id, false, g))}>
Not closed in VISUAL, move back
</Act>
)}
<Act p={p} action="file" busy={busy} onClick={() => void fileIt()}>
Mark filed
</Act>
</div>
</section>
) : null}
<div className="tabs" role="tablist" aria-label="Packet">
{tabs.map((t) => (
<button
key={t.id}
type="button"
role="tab"
id={`ptab-${t.id}`}
aria-selected={shownTab === t.id}
aria-controls={`ppanel-${t.id}`}
onClick={() => setTab(t.id)}
>
{t.label}
{t.count !== undefined ? <span className="cnt">{t.count}</span> : null}
</button>
))}
</div>
<div role="tabpanel" id={`ppanel-${shownTab}`} aria-labelledby={`ptab-${shownTab}`}>
{shownTab === "drawing" ? (
<div className="stack">
{draft && can("edit_document_set") && drawingPages.length ? (
<div className="sec-h flush">
<span className="small muted">
{drawingPages.length} drawing page{drawingPages.length === 1 ? "" : "s"} attached by hand.
</span>
<span className="grow" />
<button className="btn sm ghost" type="button" disabled={busy} onClick={() => void removeDrawing(drawingPages[0]?.n ?? 1)}>
Remove drawing
</button>
</div>
) : null}
<DrawingViewer pages={drawingPages} />
</div>
) : null}
{shownTab === "materials" ? <MaterialsTab p={p} mutate={mutate} busy={busy} /> : null}
{shownTab === "workorders" ? <WorkOrdersTab p={p} /> : null}
{shownTab === "docs" ? <DocumentsTab p={p} mutate={mutate} busy={busy} /> : null}
{shownTab === "history" ? <HistoryTab p={p} /> : null}
</div>
{sheet === "dept" ? (
<SendToDeptSheet
p={p}
sub={job}
onCancel={() => setSheet(null)}
onSend={(dept, note) => {
setSheet(null);
void transition("release_to_shop", { dept, ...(note ? { note } : {}) }, "Sent to the department.");
}}
/>
) : null}
{sheet === "move" ? (
<MoveSheet
p={p}
sub={job}
onCancel={() => setSheet(null)}
onMove={(unitId, reason) => {
setSheet(null);
void mutate((g) => packetsApi.move(p.id, { unit_id: unitId, ...(reason ? { reason } : {}), ...g }), { ok: "Packet moved." });
}}
/>
) : null}
{guardSheet}
</>
);
}
+117
View File
@@ -0,0 +1,117 @@
import { useState, type FormEvent } from "react";
import type { PacketRead } from "../api/types";
import { PickUnit, type UnitPick } from "../components/PickUnit";
import { Sheet } from "../components/Sheet";
import { useShopDepartments } from "../lib/useDepartments";
/** Clerical: send a draft straight to a shop department. Skips the warehouse parts pull. */
export function SendToDeptSheet({
p,
sub,
onSend,
onCancel,
}: {
p: PacketRead;
sub: string;
onSend: (dept: string, note: string) => void;
onCancel: () => void;
}) {
const depts = useShopDepartments();
const [dept, setDept] = useState(p.dept.code);
const [note, setNote] = useState("");
const value = depts.some((d) => d.code === dept) ? dept : (depts[0]?.code ?? "");
const submit = (e: FormEvent) => {
e.preventDefault();
if (value) onSend(value, note.trim());
};
return (
<Sheet title="Send straight to a department" sub={sub} onClose={onCancel}>
<div className="small">Skips the warehouse parts pull. The department's lead or supervisor gets it ready to assign.</div>
<form className="stack" onSubmit={submit} noValidate>
<label className="f">
Department
<select className="in" value={value} onChange={(e) => setDept(e.target.value)}>
{depts.map((d) => (
<option key={d.code} value={d.code}>
{d.code} {d.name}
</option>
))}
</select>
</label>
<label className="f">
Note (optional, they will see it)
<input className="in" value={note} maxLength={500} placeholder="e.g. parts already staged at the station" onChange={(e) => setNote(e.target.value)} />
</label>
<div className="sheet-acts">
<button className="btn ghost" type="button" onClick={onCancel}>
Cancel
</button>
<button className="btn pri" type="submit" disabled={!value}>
Send
</button>
</div>
</form>
</Sheet>
);
}
/** Clerical: move a packet to another unit, through the two-step picker. */
export function MoveSheet({
p,
sub,
onMove,
onCancel,
}: {
p: PacketRead;
sub: string;
onMove: (unitId: number, reason: string) => void;
onCancel: () => void;
}) {
const [pick, setPick] = useState<UnitPick>({
project: { id: p.unit.project.id, number: p.unit.project.number, name: p.unit.project.name, customer: p.unit.project.customer, po: p.unit.project.po, status: p.unit.project.status },
unit: null,
});
const [reason, setReason] = useState("");
const [error, setError] = useState<string | null>(null);
const submit = (e: FormEvent) => {
e.preventDefault();
if (!pick.unit) {
setError("Pick the unit to move it to.");
return;
}
if (pick.unit.id === p.unit.id) {
setError("The packet is in that unit already.");
return;
}
onMove(pick.unit.id, reason.trim());
};
return (
<Sheet title="Move to another unit" sub={sub} onClose={onCancel}>
<form className="stack" onSubmit={submit} noValidate>
<PickUnit value={pick} onChange={setPick} requireUnit />
<label className="f">
Reason (optional)
<input className="in" value={reason} maxLength={500} onChange={(e) => setReason(e.target.value)} />
</label>
<div className="small muted">The move is recorded in the packet history and the access log.</div>
{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">
Move packet
</button>
</div>
</form>
</Sheet>
);
}
+466
View File
@@ -0,0 +1,466 @@
import QRCode from "qrcode";
import { useEffect, useRef, useState, type FormEvent } from "react";
import { assetsApi } from "../api/assets";
import { packetsApi } from "../api/packets";
import type { BomLine, Guarded, PacketRead, WorkOrder } from "../api/types";
import { errorText, fmtTime } from "../lib/format";
import { useLoad } from "../lib/useLoad";
export type Mutate = (
fn: (g: Guarded) => Promise<PacketRead | null | undefined>,
opts?: { sign?: string | undefined; ok?: string | undefined },
) => Promise<boolean>;
const show = (v: unknown) => (v === null || v === undefined || v === "" ? "-" : String(v));
/* ---------- Materials ---------- */
interface LineDraft {
qty: string;
part_id: string;
description: string;
material: string;
length: string;
dwg_ref: string;
}
const toDraft = (b: BomLine): LineDraft => ({
qty: b.qty === null || b.qty === undefined ? "" : String(b.qty),
part_id: b.part_id ?? "",
description: b.description ?? "",
material: b.material ?? "",
length: b.length ?? "",
dwg_ref: b.dwg_ref ?? "",
});
const EMPTY_LINE: LineDraft = { qty: "", part_id: "", description: "", material: "", length: "", dwg_ref: "" };
/** The BOM. Clerical may edit the lines of a hand draft (PUT /bom, full replace). */
export function MaterialsTab({ p, mutate, busy }: { p: PacketRead; mutate: Mutate; busy: boolean }) {
const editable = p.status === "draft" && p.source_kind === "hand" && p.can.includes("edit_bom");
const [lines, setLines] = useState<LineDraft[] | null>(null);
const set = (i: number, k: keyof LineDraft, v: string) => setLines((rows) => (rows ?? []).map((r, j) => (j === i ? { ...r, [k]: v } : r)));
const save = async (e: FormEvent) => {
e.preventDefault();
if (!lines) return;
const bom: BomLine[] = lines
.filter((l) => l.part_id.trim() || l.description.trim())
.map((l, i) => ({
item: i + 1,
qty: l.qty.trim() === "" ? null : Number(l.qty),
part_id: l.part_id.trim() || null,
description: l.description.trim() || null,
material: l.material.trim() || null,
length: l.length.trim() || null,
dwg_ref: l.dwg_ref.trim() || null,
}));
const ok = await mutate((g) => packetsApi.putBom(p.id, p.version, bom, g), { ok: "BOM saved" });
if (ok) setLines(null);
};
if (lines) {
return (
<form className="stack" onSubmit={save} noValidate>
<div className="tw">
<table className="edit">
<thead>
<tr>
<th>Item</th>
<th className="num">Qty</th>
<th>Part ID</th>
<th>Description</th>
<th>Material</th>
<th>Length</th>
<th>Dwg ref</th>
<th />
</tr>
</thead>
<tbody>
{lines.map((l, i) => (
<tr key={i}>
<td className="mono">{i + 1}</td>
<td>
<input className="in sm num" aria-label={`Line ${i + 1} quantity`} inputMode="decimal" value={l.qty} onChange={(e) => set(i, "qty", e.target.value)} />
</td>
<td>
<input className="in sm mono" aria-label={`Line ${i + 1} part ID`} maxLength={60} value={l.part_id} onChange={(e) => set(i, "part_id", e.target.value)} />
</td>
<td>
<input className="in sm" aria-label={`Line ${i + 1} description`} maxLength={240} value={l.description} onChange={(e) => set(i, "description", e.target.value)} />
</td>
<td>
<input className="in sm" aria-label={`Line ${i + 1} material`} maxLength={120} value={l.material} onChange={(e) => set(i, "material", e.target.value)} />
</td>
<td>
<input className="in sm" aria-label={`Line ${i + 1} length`} maxLength={40} value={l.length} onChange={(e) => set(i, "length", e.target.value)} />
</td>
<td>
<input className="in sm" aria-label={`Line ${i + 1} drawing reference`} maxLength={60} value={l.dwg_ref} onChange={(e) => set(i, "dwg_ref", e.target.value)} />
</td>
<td>
<button className="btn sm ghost" type="button" aria-label={`Remove line ${i + 1}`} onClick={() => setLines((rows) => (rows ?? []).filter((_, j) => j !== i))}>
Remove
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="sheet-acts start">
<button className="btn sm" type="button" onClick={() => setLines((rows) => [...(rows ?? []), { ...EMPTY_LINE }])}>
Add line
</button>
<span className="grow" />
<button className="btn ghost" type="button" onClick={() => setLines(null)}>
Cancel
</button>
<button className="btn pri" type="submit" disabled={busy}>
Save BOM
</button>
</div>
</form>
);
}
return (
<div className="stack">
{editable ? (
<div className="sec-h flush">
<span className="small muted">This packet was made by hand. Clerical can change its lines while it is a draft.</span>
<span className="grow" />
<button className="btn sm" type="button" onClick={() => setLines(p.bom.length ? p.bom.map(toDraft) : [{ ...EMPTY_LINE }])}>
Edit lines
</button>
</div>
) : null}
{p.bom.length ? (
<div className="tw">
<table>
<thead>
<tr>
<th>Item</th>
<th className="num">Qty</th>
<th>Part ID</th>
<th>Description</th>
<th>Material</th>
<th>Length</th>
<th>Heat</th>
<th>Pulled</th>
</tr>
</thead>
<tbody>
{p.bom.map((b) => (
<tr key={b.id ?? b.item} className={b.na ? "muted" : undefined}>
<td>
<span className="balloon">{b.item}</span>
</td>
<td className="num">{show(b.qty)}</td>
<td className="mono small">{show(b.part_id)}</td>
<td>
{show(b.description)}
{b.dwg_ref ? <div className="sub">Dwg ref {b.dwg_ref}</div> : null}
</td>
<td className="small">{show(b.material)}</td>
<td className="small">{show(b.length)}</td>
<td className="mono small">{show(b.heat)}</td>
<td>
{b.na ? (
<span className="pill st-filed">N/A</span>
) : b.short ? (
<span className="pill st-review">{String(b.short.qty_open)} short</span>
) : b.pulled ? (
<span className="pill st-ok">Pulled</span>
) : (
<span className="muted small">Not yet</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="empty">No BOM lines on this packet.</div>
)}
</div>
);
}
/* ---------- Work orders ---------- */
function woKey(w: Pick<WorkOrder, "wo_base" | "lot" | "split" | "sub">): string {
return `${w.wo_base}/${w.lot}/${w.split}/${w.sub}`;
}
function Qr({ text }: { text: string }) {
const ref = useRef<HTMLCanvasElement>(null);
const [failed, setFailed] = useState(false);
useEffect(() => {
const c = ref.current;
if (!c) return;
let ok = true;
try {
QRCode.toCanvas(c, text, { width: 128, margin: 1, errorCorrectionLevel: "M" }).catch(() => {
if (ok) setFailed(true);
});
} catch {
setFailed(true);
}
return () => {
ok = false;
};
}, [text]);
return (
<div className="code">
{failed ? <span className="small muted">No QR</span> : <canvas ref={ref} role="img" aria-label={`QR code ${text}`} />}
</div>
);
}
/** The work orders, and one QR code per work order key. */
export function WorkOrdersTab({ p }: { p: PacketRead }) {
if (!p.work_orders.length) return <div className="empty">No work orders on this packet.</div>;
return (
<div className="stack">
<div className="tw">
<table>
<thead>
<tr>
<th>WO</th>
<th>Lot</th>
<th>Split</th>
<th>Sub</th>
<th>Seq</th>
<th>Operation</th>
<th className="num">Qty</th>
<th className="num">Target hrs</th>
</tr>
</thead>
<tbody>
{p.work_orders.map((w) => (
<tr key={w.id ?? woKey(w)} className={w.gone_at ? "muted" : undefined}>
<td className="mono">{w.wo_base}</td>
<td className="mono">{w.lot}</td>
<td className="mono">{w.split}</td>
<td className="mono">{w.sub}</td>
<td className="mono">{show(w.seq)}</td>
<td>{show(w.op)}</td>
<td className="num">{show(w.qty)}</td>
<td className="num">{show(w.target_hrs)}</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="qrs">
{p.work_orders.map((w) => {
const k = woKey(w);
return (
<div key={w.id ?? k} className="qr">
<Qr text={k} />
<div>
<div className="wo">{w.wo_base}</div>
<div className="mono small">{k}</div>
</div>
</div>
);
})}
</div>
</div>
);
}
/* ---------- Documents ---------- */
const DOC_KINDS: ReadonlyArray<[string, string]> = [
["mtr", "Material test report"],
["nde", "NDE report"],
["procedure", "Procedure (WPS / PQR)"],
["photo", "Photo"],
["other", "Other"],
];
const kindLabel = (k: string) => DOC_KINDS.find(([v]) => v === k)?.[1] ?? k;
/** Documents attached to the packet. Clerical and Q.C. add and remove them. */
export function DocumentsTab({ p, mutate, busy }: { p: PacketRead; mutate: Mutate; busy: boolean }) {
const canAdd = p.can.includes("doc_add");
const canRemove = p.can.includes("doc_remove");
const fileRef = useRef<HTMLInputElement>(null);
const [kind, setKind] = useState("mtr");
const [title, setTitle] = useState("");
const [file, setFile] = useState<File | null>(null);
const [error, setError] = useState<string | null>(null);
const [uploading, setUploading] = useState(false);
const [confirm, setConfirm] = useState<number | null>(null);
const add = async (e: FormEvent) => {
e.preventDefault();
setError(null);
if (!file) {
setError("Choose a file.");
return;
}
setUploading(true);
try {
const asset = await assetsApi.upload(file, { packet_id: p.id, purpose: "doc" });
const ok = await mutate((g) => packetsApi.addDoc(p.id, { asset_id: asset.id, kind, title: title.trim() || file.name, ...g }), { ok: "Document added" });
if (ok) {
setTitle("");
setFile(null);
if (fileRef.current) fileRef.current.value = "";
}
} catch (err) {
setError(errorText(err));
} finally {
setUploading(false);
}
};
return (
<div className="stack">
{p.docs.length ? (
<div className="tw">
<table>
<thead>
<tr>
<th>Title</th>
<th>Kind</th>
<th>Added</th>
<th>By</th>
<th />
</tr>
</thead>
<tbody>
{p.docs.map((d) => (
<tr key={d.id}>
<td>
<a className="lnk" href={d.url} target="_blank" rel="noopener noreferrer">
{d.title || "Untitled"}
</a>
</td>
<td className="small">{kindLabel(d.kind)}</td>
<td className="small mono">{fmtTime(d.added_at)}</td>
<td className="small">{d.added_by?.display_name ?? "-"}</td>
<td className="nowrap">
{canRemove ? (
confirm === d.id ? (
<>
<button
className="btn sm rejb"
type="button"
disabled={busy}
onClick={() => void mutate((g) => packetsApi.removeDoc(p.id, d.id, g), { ok: "Document removed" }).then(() => setConfirm(null))}
>
Confirm remove
</button>{" "}
<button className="btn sm ghost" type="button" onClick={() => setConfirm(null)}>
Cancel
</button>
</>
) : (
<button className="btn sm ghost" type="button" onClick={() => setConfirm(d.id)}>
Remove
</button>
)
) : null}
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="empty slim">No documents on this packet.</div>
)}
{canAdd ? (
<form className="panel stack" onSubmit={add} noValidate aria-labelledby="docadd-h">
<h3 id="docadd-h">Add a document</h3>
<div className="frow">
<label className="f">
File (PDF, PNG or JPEG)
<input
ref={fileRef}
className="in"
type="file"
accept="application/pdf,image/png,image/jpeg"
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
/>
</label>
<label className="f">
Kind
<select className="in" value={kind} onChange={(e) => setKind(e.target.value)}>
{DOC_KINDS.map(([v, l]) => (
<option key={v} value={v}>
{l}
</option>
))}
</select>
</label>
<label className="f">
Title
<input className="in" value={title} maxLength={160} onChange={(e) => setTitle(e.target.value)} />
</label>
</div>
{error ? (
<div className="callout warn flush" role="alert">
{error}
</div>
) : null}
<div>
<button className="btn pri" type="submit" disabled={busy || uploading}>
{uploading ? "Uploading" : "Add document"}
</button>
</div>
</form>
) : null}
</div>
);
}
/* ---------- History ---------- */
/** GET /log, newest first. IT edits and signatures are marked. */
export function HistoryTab({ p }: { p: PacketRead }) {
const [load] = useLoad((s) => packetsApi.log(p.id, { limit: 300 }, s), `${p.id}:${p.version}`);
if (load.state === "loading") return <div className="empty slim">Loading</div>;
if (load.state === "error") return <div className="callout warn">{load.message}</div>;
const rows = load.data;
if (!rows.length) return <div className="empty slim">Nothing recorded yet.</div>;
return (
<div className="tw">
<table>
<thead>
<tr>
<th>When</th>
<th>Who</th>
<th>What</th>
<th>Note</th>
</tr>
</thead>
<tbody>
{rows.map((r) => (
<tr key={r.id}>
<td className="small mono nowrap">{fmtTime(r.at)}</td>
<td className="small">
{r.by?.display_name ?? "System"}
{r.role ? <div className="sub">{r.role}</div> : null}
</td>
<td>
{r.action}
{r.signed ? <span className="pill st-ok">Signed</span> : null}
{r.it_reason ? <span className="pill st-warehouse">IT edit</span> : null}
</td>
<td className="small">
{r.note ?? ""}
{r.it_reason ? <div className="sub">IT reason: {r.it_reason}</div> : null}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}