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
@@ -0,0 +1,108 @@
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";
import { makeCard, makeProject, makeUnit } from "../../test/packets";
const CARDS = [
makeCard({ id: 1, flag: "Made by hand - check it and send it on" }),
makeCard({ id: 2, unit: { id: 12, number: "L999002" }, op: "FIT-UP" }),
makeCard({ id: 3, status: "warehouse", holder: "Warehouse" }),
makeCard({ id: 4, status: "in_progress" }),
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" } }),
];
function base() {
return {
"/api/auth/me": { body: makeMe() },
"/api/departments": { body: DEPARTMENTS },
"/api/packets": { body: { packets: CARDS } },
"/api/projects": { body: [makeProject()] },
};
}
function column(title: string): HTMLElement {
return screen.getByRole("heading", { name: new RegExp(`^${title}`) }).closest("section") as HTMLElement;
}
describe("ClericalHome", () => {
it("renders the boards and the numbers from the list answer", async () => {
mockFetch(base());
renderApp("/clerical");
expect(await screen.findByRole("heading", { name: "Clerical" })).toBeInTheDocument();
await screen.findByRole("heading", { name: /^Flagged/ });
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("Ready to close: 1")).toHaveClass("hot");
expect(screen.getByLabelText("Changes to approve: 0")).toHaveClass("zero");
expect(within(column("Changes to approve")).getByText("Arrives in phase 3 with the VISUAL feed.")).toBeInTheDocument();
const flagged = column("Flagged");
expect(within(flagged).getByText("L999001 · WELD-PART")).toBeInTheDocument();
expect(within(flagged).getByText(/Made by hand/)).toBeInTheDocument();
expect(within(column("Going to the warehouse")).getByText("L999002 · FIT-UP")).toBeInTheDocument();
expect(within(column("Ready to close")).getByText("L999003 · WELD-PART")).toBeInTheDocument();
expect(within(column("Ready for data book")).getByText("L999004 · WELD-PART")).toBeInTheDocument();
const filed = column("Filed");
expect(within(filed).getByText("L999005 · WELD-PART")).toBeInTheDocument();
expect(within(filed).getByText("Filed in data book")).toHaveClass("pill", "st-filed");
// 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);
});
it("the picker loads the units after a project is chosen", async () => {
const fetch = mockFetch({
...base(),
"/api/units": { body: [makeUnit(), makeUnit({ id: 12, number: "L999002", lot: "002" })] },
});
renderApp("/clerical");
const bar = await screen.findByRole("region", { name: "Find jobs" });
expect(callsTo(fetch, "GET", "/api/units")).toHaveLength(0);
fireEvent.click(await within(bar).findByRole("button", { name: /L999/ }));
const unit = await within(bar).findByLabelText("Unit");
await within(unit).findByRole("option", { name: "L999002" });
const urls = fetch.mock.calls.map(([u]) => String(u));
expect(urls).toContain("/api/units?project_id=7");
// Choosing the project narrows the boards.
await waitFor(() => expect(urls.concat(fetch.mock.calls.map(([u]) => String(u)))).toContain("/api/packets?limit=500&project_id=7"));
fireEvent.change(unit, { target: { value: "12" } });
await waitFor(() => expect(fetch.mock.calls.map(([u]) => String(u))).toContain("/api/packets?limit=500&unit_id=12"));
});
it("the new project sheet posts and then opens Add units", async () => {
const fetch = mockFetch({
...base(),
"POST /api/projects": { status: 201, body: makeProject({ id: 9, number: "L998", name: "New one", units: 0 }) },
"POST /api/projects/9/units": { status: 201, body: [makeUnit({ id: 21, project_id: 9, number: "L998001" })] },
"/api/projects/9": { body: makeProject({ id: 9, number: "L998", name: "New one", units: [makeUnit({ id: 21, project_id: 9, number: "L998001" })] }) },
});
renderApp("/clerical");
fireEvent.click(await screen.findByRole("button", { name: "New project" }));
const sheet = await screen.findByRole("dialog", { name: "New project" });
fireEvent.change(within(sheet).getByLabelText("Project number"), { target: { value: "l998" } });
fireEvent.change(within(sheet).getByLabelText("Project name"), { target: { value: "New one" } });
fireEvent.click(within(sheet).getByRole("button", { name: "Create project" }));
const add = await screen.findByRole("dialog", { name: "Add units" });
expect(callsTo(fetch, "POST", "/api/projects")).toEqual([{ number: "L998", name: "New one", customer: "", po: "", due: null, notes: "" }]);
expect(within(add).getByLabelText("Lot")).toHaveValue("001");
// Several units: a preview of the numbers.
fireEvent.click(within(add).getByRole("button", { name: "Several units" }));
fireEvent.change(within(add).getByLabelText("How many"), { target: { value: "3" } });
expect(within(add).getByText("Numbers: L998001, L998002, L998003")).toBeInTheDocument();
fireEvent.click(within(add).getByRole("button", { name: "Add" }));
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: "" }]);
});
});
@@ -0,0 +1,233 @@
import { useEffect, useMemo, useState, type KeyboardEvent } from "react";
import { useNavigate } from "react-router";
import { packetsApi } from "../../api/packets";
import type { PacketCard } from "../../api/types";
import { useAuth } from "../../auth/useAuth";
import { Board } from "../../components/Board";
import { FindBar, type BoardView } from "../../components/FindBar";
import { Kpis } from "../../components/Kpis";
import type { UnitPick } from "../../components/PickUnit";
import { canEditProjects } from "../../lib/access";
import { useChanged } from "../../lib/changes";
import { fmtTime } from "../../lib/format";
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 { ProjectsSection } from "./ProjectsSection";
import { NewPacketSheet } from "./sheets";
type SortKey = "updated" | "unit" | "due" | "status" | "dept";
const SORTS: ReadonlyArray<[SortKey, string]> = [
["updated", "Last updated"],
["unit", "Unit"],
["due", "Due date"],
["status", "Status"],
["dept", "Department"],
];
function sorter(k: SortKey): (a: PacketCard, b: PacketCard) => number {
switch (k) {
case "unit":
return (a, b) => a.unit.number.localeCompare(b.unit.number, undefined, { numeric: true }) || (a.op ?? "").localeCompare(b.op ?? "");
case "due":
return (a, b) => (a.due_effective ?? "9999").localeCompare(b.due_effective ?? "9999");
case "status":
return (a, b) => STATUSES.indexOf(a.status) - STATUSES.indexOf(b.status);
case "dept":
return (a, b) => a.dept.code.localeCompare(b.dept.code);
default:
return (a, b) => byTime(b.updated_at, a.updated_at);
}
}
function JobList({ rows, sort, onSort }: { rows: PacketCard[]; sort: SortKey; onSort: (k: SortKey) => void }) {
const navigate = useNavigate();
const sorted = useMemo(() => [...rows].sort(sorter(sort)), [rows, sort]);
const onKey = (e: KeyboardEvent, id: number) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
navigate(`/packets/${id}`);
}
};
return (
<section className="sec" aria-labelledby="jobs-h">
<div className="sec-h">
<h2 id="jobs-h">Jobs</h2>
<span className="ct">
{sorted.length} packet{sorted.length === 1 ? "" : "s"}
</span>
<span className="grow" />
<label className="f">
Sort by
<select className="in" value={sort} onChange={(e) => onSort(e.target.value as SortKey)}>
{SORTS.map(([k, l]) => (
<option key={k} value={k}>
{l}
</option>
))}
</select>
</label>
</div>
<div className="tw">
<table className="joblist">
<thead>
<tr>
<th>Job</th>
<th>Operation</th>
<th>Department</th>
<th>Status</th>
<th>Drawing</th>
<th>Due</th>
<th>Updated</th>
<th>Flags</th>
</tr>
</thead>
<tbody>
{sorted.map((p) => {
const di = dueInfo(p.due_effective);
return (
<tr key={p.id} className="rowlink" tabIndex={0} onClick={() => navigate(`/packets/${p.id}`)} onKeyDown={(e) => onKey(e, p.id)}>
<td>
<b>{jobName(p)}</b>
<div className="sub">{p.project.name ?? ""}</div>
</td>
<td>
{p.op ?? ""}
<div className="sub">{p.title ?? ""}</div>
</td>
<td className="small">{p.dept.name}</td>
<td>
<span className={pillClass(p.status)}>{statusLabel(p.status)}</span>
</td>
<td className="mono small">
{p.drawing ?? ""} {p.rev ? `R${p.rev}` : ""}
</td>
<td className="small">
<span className={di.cls}>{di.txt.replace(/^Due /, "")}</span>
</td>
<td className="small mono">{fmtTime(p.updated_at)}</td>
<td>
{p.open_shorts ? <span className="pill st-review">{p.open_shorts} short</span> : null}
{p.flag ? <div className={`small ${flagClass(p.flag)}`}>⚑ {p.flag}</div> : null}
</td>
</tr>
);
})}
{sorted.length === 0 ? (
<tr>
<td colSpan={8} className="muted">
No packets match.
</td>
</tr>
) : null}
</tbody>
</table>
</div>
</section>
);
}
/** /clerical: numbers, attention row, find bar, boards or list, then projects. */
export function ClericalHome() {
const { me } = useAuth();
const navigate = useNavigate();
const [q, setQ] = useState("");
const [query, setQuery] = useState("");
const [pick, setPick] = useState<UnitPick>({ project: null, unit: null });
const [view, setView] = useState<BoardView>("board");
const [sort, setSort] = useState<SortKey>("updated");
const [drafting, setDrafting] = useState(false);
const projectId = pick.project?.id;
const unitId = pick.unit?.id;
useEffect(() => {
const t = window.setTimeout(() => setQuery(q.trim()), 250);
return () => window.clearTimeout(t);
}, [q]);
const [load, reload] = useLoad(
(s) => packetsApi.list({ ...(query ? { q: query } : {}), ...(unitId ? { unit_id: unitId } : projectId ? { project_id: projectId } : {}) }, s),
`${query}|${projectId ?? ""}|${unitId ?? ""}`,
);
useChanged(reload, { delayMs: 500 });
const all = useMemo(() => (load.state === "ok" ? load.data : []), [load]);
const b = useMemo(() => clericalBoards(all), [all]);
const shown = useMemo(() => {
const ids = new Set<number>();
const out: PacketCard[] = [];
for (const c of b.columns) {
for (const p of c.cards) {
if (ids.has(p.id)) continue;
ids.add(p.id);
out.push(p);
}
}
return out;
}, [b]);
const editable = canEditProjects(me?.roles ?? []);
return (
<>
<header className="phead">
<div>
<div className="eyebrow">Projects and job packets</div>
<h1>Clerical</h1>
</div>
{editable ? (
<div className="pacts">
<button className="btn pri" type="button" onClick={() => setDrafting(true)}>
New packet
</button>
</div>
) : null}
</header>
<Kpis
items={[
{ n: 0, 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" },
{ n: b.toClose, label: "Ready to close", alert: true },
]}
/>
{b.flagged || b.toClose ? (
<div className="attn" role="status">
{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.
</div>
) : null}
{b.toClose ? (
<div className="callout flush">
<b>{b.toClose === 1 ? "1 packet is" : `${b.toClose} packets are`} back from the shop.</b> Close the job in VISUAL, then mark it closed on the packet.
</div>
) : null}
</div>
) : null}
<FindBar q={q} onQ={setQ} pick={pick} onPick={setPick} view={view} onView={setView} count={load.state === "ok" ? shown.length : null} />
{load.state === "loading" ? <div className="empty slim">Loading</div> : null}
{load.state === "error" ? <div className="callout warn">{load.message}</div> : null}
{load.state === "ok" ? view === "board" ? <Board columns={b.columns} /> : <JobList rows={shown} sort={sort} onSort={setSort} /> : null}
<ProjectsSection />
{drafting ? (
<NewPacketSheet
initial={pick.unit ? pick : { project: pick.project, unit: null }}
onCancel={() => setDrafting(false)}
onCreated={(p) => {
setDrafting(false);
navigate(`/packets/${p.id}`);
}}
/>
) : null}
</>
);
}
@@ -0,0 +1,290 @@
import { useEffect, useState, type KeyboardEvent } from "react";
import { Link, useNavigate, useParams } from "react-router";
import { projectsApi } from "../../api/projects";
import type { Project, Unit } from "../../api/types";
import { useAuth } from "../../auth/useAuth";
import { Kpis } from "../../components/Kpis";
import { useGuard } from "../../components/useGuard";
import { canEditProjects, canRenumber } from "../../lib/access";
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 { AddUnitsSheet, ProjectSheet, RenumberSheet } from "./sheets";
type UnitShow = "active" | "hold" | "closed" | "all";
const UNIT_SHOWS: ReadonlyArray<[UnitShow, string]> = [
["active", "Active"],
["hold", "On hold"],
["closed", "Closed"],
["all", "All"],
];
function unitsOf(p: Project): Unit[] {
return Array.isArray(p.units) ? [...p.units].sort((a, b) => a.number.localeCompare(b.number, undefined, { numeric: true })) : [];
}
function sumCounts(units: Unit[]) {
const c = stageCounts(null);
for (const u of units) {
const x = stageCounts(u.packets);
c.draft += x.draft;
c.warehouse += x.warehouse;
c.shop += x.shop;
c.qc += x.qc;
c.complete += x.complete;
c.filed += x.filed;
c.total += x.total;
}
return c;
}
function UnitTable({ project, units }: { project: Project; units: Unit[] }) {
const navigate = useNavigate();
const [q, setQ] = useState("");
const [show, setShow] = useState<UnitShow>("active");
const t = q.trim().toLowerCase();
const rows = units.filter(
(u) =>
(show === "all" || (show === "active" ? u.status !== "closed" : u.status === show)) &&
(!t || [u.number, u.lot, u.name ?? "", u.notes ?? ""].join(" ").toLowerCase().includes(t)),
);
const onKey = (e: KeyboardEvent, id: number) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
navigate(`/clerical/units/${id}`);
}
};
return (
<>
<div className="unitbar">
<label className="f grow">
<span className="sr">Find a unit</span>
<input className="in" type="search" placeholder="Find a unit: number or description" autoComplete="off" value={q} onChange={(e) => setQ(e.target.value)} />
</label>
<div className="chips" role="group" aria-label="Show units">
{UNIT_SHOWS.map(([k, l]) => (
<button key={k} type="button" className="chip sm" aria-pressed={show === k} onClick={() => setShow(k)}>
{l}
</button>
))}
</div>
<span className="small muted">
{rows.length} of {units.length} unit{units.length === 1 ? "" : "s"}
</span>
</div>
<div className="tw">
<table className="joblist unitlist">
<thead>
<tr>
<th>Unit</th>
<th>Description</th>
<th>Status</th>
<th>Due</th>
<th>Packets</th>
<th className="wide">Progress</th>
</tr>
</thead>
<tbody>
{rows.map((u) => {
const c = stageCounts(u.packets);
const own = !!u.due;
const di = dueInfo(u.due_effective ?? project.due, u.status === "closed" || project.status === "closed");
return (
<tr key={u.id} className="rowlink" tabIndex={0} onClick={() => navigate(`/clerical/units/${u.id}`)} onKeyDown={(e) => onKey(e, u.id)}>
<td className="mono">
<b>{u.number}</b>
</td>
<td>{u.name || "-"}</td>
<td>
<ProjectPill status={u.status} />
</td>
<td className="small">
<span className={di.cls}>{di.txt.replace(/^Due /, "")}</span>
{own ? null : <span className="muted"> (project)</span>}
</td>
<td className="small">
<b>{c.total}</b>
{c.total ? <span className="muted"> · {c.filed} filed</span> : null}
</td>
<td>
<ProgressBar c={c} />
</td>
</tr>
);
})}
{rows.length === 0 ? (
<tr>
<td colSpan={6} className="muted">
No units match.
</td>
</tr>
) : null}
</tbody>
</table>
</div>
</>
);
}
/** /clerical/projects/:projectId. */
export function ProjectPage() {
const { projectId } = useParams();
const id = Number(projectId);
const { me } = useAuth();
const navigate = useNavigate();
const guard = useGuard();
const [load, reload] = useLoad((s) => projectsApi.get(id, s), String(id));
const [sheet, setSheet] = useState<"edit" | "units" | "renumber" | null>(null);
const [toast, setToast] = useState<string | null>(null);
const roles = me?.roles ?? [];
useChanged(reload, { delayMs: 500 });
useEffect(() => {
if (!toast) return;
const t = window.setTimeout(() => setToast(null), 2600);
return () => window.clearTimeout(t);
}, [toast]);
const back = (
<Link className="back" to="/clerical">
← All projects
</Link>
);
if (load.state === "loading") return <>{back}<div className="empty slim">Loading</div></>;
if (load.state === "error") return <>{back}<div className="callout warn">{load.message}</div></>;
const p = load.data;
const units = unitsOf(p);
const c = p.packets ? stageCounts(p.packets) : sumCounts(units);
const di = dueInfo(p.due, p.status === "closed");
const editable = canEditProjects(roles);
const aliases = (p.aliases ?? []).map((a) => (typeof a === "string" ? a : a.alias)).filter((a) => a && a !== p.number);
return (
<>
{back}
<div className="dhead">
<div className="grow0">
<div className="eyebrow">Project · {PROJECT_STATUS_LABEL[p.status]}</div>
<h1>
{p.number}
{p.name ? ` · ${p.name}` : ""}
</h1>
<div className="facts">
<div>
<span>Customer </span>
<b>{p.customer || "-"}</b>
</div>
<div>
<span>Customer PO </span>
<b>{p.po || "-"}</b>
</div>
<div>
<span>Due </span>
<b className={di.cls}>{di.txt.replace(/^Due /, "")}</b>
</div>
<div>
<span>Units </span>
<b>{units.length}</b>
</div>
<div>
<span>Packets </span>
<b>{c.total}</b>
</div>
{aliases.length ? (
<div>
<span>Old numbers </span>
<b>{aliases.join(", ")}</b>
</div>
) : null}
</div>
{p.notes ? <div className="small pre">{p.notes}</div> : null}
</div>
<div className="pacts">
<ProjectPill status={p.status} />
{editable ? (
<button className="btn sm" type="button" onClick={() => setSheet("edit")}>
Edit project
</button>
) : null}
{canRenumber(roles) ? (
<button className="btn sm" type="button" onClick={() => setSheet("renumber")}>
Renumber
</button>
) : null}
</div>
</div>
<Kpis
items={[
{ n: c.draft, label: "Ready to release" },
{ n: c.warehouse, label: "With warehouse" },
{ n: c.shop + c.qc, label: "In the shop / Q.C." },
{ n: c.complete, label: "Back for data book" },
{ n: c.filed, label: "Filed" },
]}
/>
<ProgressBar c={c} />
<section className="sec" aria-labelledby="units-h">
<div className="sec-h">
<h2 id="units-h">Units</h2>
<span className="ct">each unit holds the packets for that piece of equipment</span>
<span className="grow" />
{editable && p.status !== "closed" ? (
<button className="btn pri" type="button" onClick={() => setSheet("units")}>
Add units
</button>
) : null}
</div>
{p.status === "closed" ? <div className="callout">This project is closed. Reopen it (Edit project) to add units or packets.</div> : null}
{units.length ? <UnitTable project={p} units={units} /> : <div className="empty">No units yet.{editable ? " Add one to start adding packets." : ""}</div>}
</section>
{sheet === "edit" ? (
<ProjectSheet
project={p}
onCancel={() => setSheet(null)}
onSaved={() => {
setSheet(null);
setToast("Project saved");
reload();
}}
/>
) : null}
{sheet === "units" ? (
<AddUnitsSheet
project={p}
lots={units.map((u) => u.lot)}
onCancel={() => setSheet(null)}
onDone={(added) => {
setSheet(null);
setToast(added.length === 1 ? `Unit ${added[0]?.number ?? ""} added` : `${added.length} units added`);
reload();
}}
/>
) : null}
{sheet === "renumber" ? (
<RenumberSheet
kind="project"
current={p.number}
onCancel={() => setSheet(null)}
onSave={async (number, reason) => {
const r = await guard.run((g) => projectsApi.renumber(p.id, { number, reason, ...g }), { sub: p.number });
if (r === undefined) return;
setSheet(null);
setToast(`Project renumbered to ${number}`);
reload();
if (r.id && r.id !== p.id) navigate(`/clerical/projects/${r.id}`);
}}
/>
) : null}
{guard.sheet}
{toast ? (
<div className="toast" role="status">
{toast}
</div>
) : null}
</>
);
}
@@ -0,0 +1,109 @@
import { useEffect, useState } from "react";
import { useNavigate } from "react-router";
import { projectsApi, type ProjectShow } from "../../api/projects";
import type { Project } from "../../api/types";
import { useAuth } from "../../auth/useAuth";
import { canEditProjects } from "../../lib/access";
import { useChanged } from "../../lib/changes";
import { useLoad } from "../../lib/useLoad";
import { ProjectCard } from "./common";
import { AddUnitsSheet, ProjectSheet } from "./sheets";
const SHOWS: ReadonlyArray<[ProjectShow, string]> = [
["active", "Active"],
["open", "Open"],
["hold", "On hold"],
["closed", "Closed"],
["all", "All"],
];
/** Projects on the Clerical home: find box, status chips, cards, New project then Add units. */
export function ProjectsSection() {
const { me } = useAuth();
const navigate = useNavigate();
const [show, setShow] = useState<ProjectShow>("active");
const [q, setQ] = useState("");
const [query, setQuery] = useState("");
const [creating, setCreating] = useState(false);
const [adding, setAdding] = useState<Project | null>(null);
const [load, reload] = useLoad((s) => projectsApi.list(query, show, s), `${show}|${query}`);
const editable = canEditProjects(me?.roles ?? []);
useEffect(() => {
const t = window.setTimeout(() => setQuery(q.trim()), 250);
return () => window.clearTimeout(t);
}, [q]);
useChanged(reload, { delayMs: 500 });
const list = load.state === "ok" ? load.data : [];
return (
<section className="sec" aria-labelledby="projects-h">
<div className="sec-h">
<h2 id="projects-h">Projects</h2>
<span className="ct">project, then units, then packets</span>
<span className="grow" />
<div className="chips" role="group" aria-label="Show projects">
{SHOWS.map(([k, l]) => (
<button key={k} type="button" className="chip sm" aria-pressed={show === k} onClick={() => setShow(k)}>
{l}
</button>
))}
</div>
{editable ? (
<button className="btn pri" type="button" onClick={() => setCreating(true)}>
New project
</button>
) : null}
</div>
<div className="unitbar">
<label className="f grow">
<span className="sr">Find a project</span>
<input
className="in"
type="search"
placeholder="Find a project: L670, customer, PO"
autoComplete="off"
value={q}
onChange={(e) => setQ(e.target.value)}
/>
</label>
</div>
{load.state === "loading" ? <div className="empty slim">Loading</div> : null}
{load.state === "error" ? <div className="callout warn">{load.message}</div> : null}
{load.state === "ok" ? (
list.length ? (
<div className="projgrid">
{list.map((p) => (
<ProjectCard key={p.id} p={p} />
))}
</div>
) : (
<div className="empty">{query ? `Nothing matches "${query}".` : "No projects in this view. Set one up, add its units, then add packets into each unit."}</div>
)
) : null}
{creating ? (
<ProjectSheet
onCancel={() => setCreating(false)}
onSaved={(p) => {
setCreating(false);
setAdding(p);
reload();
}}
/>
) : null}
{adding ? (
<AddUnitsSheet
project={adding}
lots={[]}
onCancel={() => setAdding(null)}
onDone={() => {
const id = adding.id;
setAdding(null);
navigate(`/clerical/projects/${id}`);
}}
/>
) : null}
</section>
);
}
+206
View File
@@ -0,0 +1,206 @@
import { useEffect, useState } from "react";
import { Link, useNavigate, useParams } from "react-router";
import { packetsApi } from "../../api/packets";
import { projectsApi } from "../../api/projects";
import { useAuth } from "../../auth/useAuth";
import { Board } from "../../components/Board";
import { Kpis } from "../../components/Kpis";
import { useGuard } from "../../components/useGuard";
import { canEditProjects, canRenumber } from "../../lib/access";
import { useChanged } from "../../lib/changes";
import { countCards, dueInfo, IN_SHOP_OR_QC, PROJECT_STATUS_LABEL } from "../../lib/packets";
import { useLoad } from "../../lib/useLoad";
import { ProgressBar, ProjectPill } from "./common";
import { NewPacketSheet, RenumberSheet, UnitSheet } from "./sheets";
/** /clerical/units/:unitId: the unit's facts and its packets. */
export function UnitPage() {
const { unitId } = useParams();
const id = Number(unitId);
const { me } = useAuth();
const navigate = useNavigate();
const guard = useGuard();
const [unitLoad, reloadUnit] = useLoad((s) => projectsApi.unit(id, s), String(id));
const [packetsLoad, reloadPackets] = useLoad((s) => packetsApi.list({ unit_id: id }, s), String(id));
const [sheet, setSheet] = useState<"edit" | "renumber" | "packet" | null>(null);
const [toast, setToast] = useState<string | null>(null);
const roles = me?.roles ?? [];
useChanged(
() => {
reloadUnit();
reloadPackets();
},
{ delayMs: 500 },
);
useEffect(() => {
if (!toast) return;
const t = window.setTimeout(() => setToast(null), 2600);
return () => window.clearTimeout(t);
}, [toast]);
if (unitLoad.state === "loading") return <div className="empty slim">Loading</div>;
if (unitLoad.state === "error")
return (
<>
<Link className="back" to="/clerical">
← All projects
</Link>
<div className="callout warn">{unitLoad.message}</div>
</>
);
const u = unitLoad.data;
const t = u.project ?? null;
const list = packetsLoad.state === "ok" ? packetsLoad.data : [];
const c = countCards(list);
const di = dueInfo(u.due_effective, u.status === "closed" || t?.status === "closed");
const open = u.status !== "closed" && t?.status !== "closed";
const editable = canEditProjects(roles);
const grp = (st: string) => list.filter((p) => p.status === st);
return (
<>
<nav className="crumbs" aria-label="Where you are">
<Link className="back" to="/clerical">
All projects
</Link>
<span>›</span>
{t ? (
<>
<Link className="back" to={`/clerical/projects/${t.id}`}>
{t.number} {t.name ?? ""}
</Link>
<span>›</span>
</>
) : null}
<b>{u.number}</b>
</nav>
<div className="dhead">
<div className="grow0">
<div className="eyebrow">
Unit{t ? ` of ${t.number}` : ""} · {PROJECT_STATUS_LABEL[u.status]}
</div>
<h1>
{u.number}
{u.name ? ` · ${u.name}` : ""}
</h1>
<div className="facts">
<div>
<span>Project </span>
<b>{t ? `${t.number} ${t.name ?? ""}` : "-"}</b>
</div>
<div>
<span>Customer </span>
<b>{t?.customer || "-"}</b>
</div>
<div>
<span>Customer PO </span>
<b>{t?.po || "-"}</b>
</div>
<div>
<span>Due </span>
<b className={di.cls}>
{di.txt.replace(/^Due /, "")}
{u.due ? "" : " (project)"}
</b>
</div>
<div>
<span>Packets </span>
<b>{c.total}</b>
</div>
</div>
{u.notes ? <div className="small pre">{u.notes}</div> : null}
</div>
<div className="pacts">
<ProjectPill status={u.status} />
{editable ? (
<button className="btn sm" type="button" onClick={() => setSheet("edit")}>
Edit unit
</button>
) : null}
{canRenumber(roles) ? (
<button className="btn sm" type="button" onClick={() => setSheet("renumber")}>
Renumber
</button>
) : null}
{editable && open ? (
<button className="btn pri" type="button" onClick={() => setSheet("packet")}>
Add packet
</button>
) : null}
</div>
</div>
<Kpis
items={[
{ n: c.draft, label: "Ready to release" },
{ n: c.warehouse, label: "With warehouse" },
{ n: list.filter((p) => IN_SHOP_OR_QC.includes(p.status)).length, label: "In the shop / Q.C." },
{ n: c.complete, label: "Back for data book" },
{ n: c.filed, label: "Filed" },
]}
/>
<ProgressBar c={c} />
{!open ? <div className="callout">This {u.status === "closed" ? "unit" : "project"} is closed. Reopen it to add packets.</div> : null}
{packetsLoad.state === "error" ? <div className="callout warn">{packetsLoad.message}</div> : null}
<Board
columns={[
{ key: "flagged", title: "Flagged", cards: grp("draft").filter((p) => p.flag), empty: "Nothing flagged." },
{ key: "draft", title: "Ready to release", cards: grp("draft").filter((p) => !p.flag), empty: "Nothing ready to release in this unit." },
{
key: "prog",
title: "In progress",
cards: list.filter((p) => p.status === "warehouse" || IN_SHOP_OR_QC.includes(p.status)),
empty: "Nothing in progress.",
},
{ key: "back", title: "Back for data book", cards: grp("complete"), empty: "Nothing accepted yet." },
{ key: "filed", title: "Filed", cards: grp("filed"), empty: "Nothing filed yet." },
]}
/>
{sheet === "edit" ? (
<UnitSheet
unit={u}
onCancel={() => setSheet(null)}
onSaved={() => {
setSheet(null);
setToast("Unit saved");
reloadUnit();
}}
/>
) : null}
{sheet === "renumber" ? (
<RenumberSheet
kind="unit"
current={u.lot}
prefix={t?.number ?? ""}
onCancel={() => setSheet(null)}
onSave={async (lot, reason) => {
const r = await guard.run((g) => projectsApi.renumberUnit(u.id, { lot, reason, ...g }), { sub: u.number });
if (r === undefined) return;
setSheet(null);
setToast(`Unit renumbered to ${r.number ?? lot}`);
reloadUnit();
reloadPackets();
}}
/>
) : null}
{sheet === "packet" ? (
<NewPacketSheet
initial={{ project: t, unit: u }}
onCancel={() => setSheet(null)}
onCreated={(p) => {
setSheet(null);
navigate(`/packets/${p.id}`);
}}
/>
) : null}
{guard.sheet}
{toast ? (
<div className="toast" role="status">
{toast}
</div>
) : null}
</>
);
}
+41
View File
@@ -0,0 +1,41 @@
// The Clerical home boards, from one list answer.
import type { ColumnDef } from "../../components/Board";
import type { PacketCard } from "../../api/types";
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 } {
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);
const going = drafts.filter((p) => !p.flag);
const toClose = back.filter((p) => !p.vclosed).sort((a, b) => byTime(a.status_at, b.status_at));
const toBook = back.filter((p) => p.vclosed).sort((a, b) => byTime(a.vclosed?.at ?? null, b.vclosed?.at ?? null));
const filed = all
.filter((p) => p.status === "filed")
.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: "flagged", title: "Flagged", cards: flagged, empty: "Nothing flagged." },
{ key: "going", title: "Going to the warehouse", cards: going, empty: "Drafts with nothing flagged wait here." },
{
key: "close",
title: "Ready to close",
cards: toClose,
empty: "Finished packets come back here. Close the job in VISUAL, then mark it closed on the packet.",
},
{ key: "book", title: "Ready for data book", cards: toBook, empty: "Jobs closed in VISUAL wait here for the data book." },
{ key: "filed", title: "Filed", cards: filed, empty: "Nothing filed yet." },
];
return {
columns,
flagged: flagged.length,
warehouse: all.filter((p) => p.status === "warehouse").length,
shop: all.filter((p) => IN_SHOP_OR_QC.includes(p.status)).length,
toClose: toClose.length,
};
}
+72
View File
@@ -0,0 +1,72 @@
import { Link } from "react-router";
import type { Project, StageCounts } from "../../api/types";
import { dueInfo, projectPillClass, PROJECT_STATUS_LABEL, stageCounts } from "../../lib/packets";
/** The old stageChips(): how many packets sit at each stage. */
export function StageChips({ c }: { c: StageCounts }) {
const items: Array<[string, number]> = [
["Ready to release", c.draft],
["Warehouse", c.warehouse],
["Shop", c.shop],
["Q.C.", c.qc],
["Accepted", c.complete],
["Filed", c.filed],
];
return (
<div className="stg">
{items.map(([l, n]) => (
<span key={l} className={n ? undefined : "z"}>
<b>{n}</b> {l}
</span>
))}
</div>
);
}
/** The old progressBar(): filed in cyan, accepted in green. */
export function ProgressBar({ c }: { c: StageCounts }) {
const n = c.total || 0;
const f = n ? (c.filed / n) * 100 : 0;
const a = n ? (c.complete / n) * 100 : 0;
return (
<div className="pbar" title={`${c.filed} filed, ${c.complete} accepted, ${Math.max(0, n - c.filed - c.complete)} in progress of ${n}`}>
<i className="f" style={{ width: `${f}%` }} />
<i className="c" style={{ width: `${a}%` }} />
</div>
);
}
export function ProjectPill({ status }: { status: Project["status"] }) {
return <span className={projectPillClass(status)}>{PROJECT_STATUS_LABEL[status]}</span>;
}
/** The old projCard(). */
export function ProjectCard({ p }: { p: Project }) {
const c = stageCounts(p.packets);
const di = dueInfo(p.due, p.status === "closed");
const units = typeof p.units === "number" ? p.units : p.units.length;
return (
<Link className="card pcard" to={`/clerical/projects/${p.id}`}>
<div className="r1">
<span className="job">{p.number}</span>
<ProjectPill status={p.status} />
</div>
<div className="tt">{p.name || "-"}</div>
<div className="small muted">
{p.customer ?? ""}
{p.po ? (
<>
{" "}
· PO <span className="mono">{p.po}</span>
</>
) : null}
</div>
<div className={`small ${di.cls}`}>{di.txt}</div>
<StageChips c={c} />
<ProgressBar c={c} />
<div className="small muted">
{units} unit{units === 1 ? "" : "s"} · {c.total} packet{c.total === 1 ? "" : "s"} · {c.filed} filed
</div>
</Link>
);
}
+735
View File
@@ -0,0 +1,735 @@
import { useState, type FormEvent } from "react";
import { packetsApi } from "../../api/packets";
import { projectsApi, type ProjectFields, type UnitFields } from "../../api/projects";
import type { PacketCreate, PacketRead, Project, ProjectRef, ProjectStatus, Unit } from "../../api/types";
import { PickUnit, type UnitPick } from "../../components/PickUnit";
import { Sheet } from "../../components/Sheet";
import { useGuard } from "../../components/useGuard";
import { errorText } from "../../lib/format";
import { useShopDepartments } from "../../lib/useDepartments";
import { lotSequence, nextLot, normLot, PROJECT_STATUS_LABEL, RX_LOT, RX_PROJECT } from "../../lib/packets";
const STATUSES: ProjectStatus[] = ["open", "hold", "closed"];
function Problem({ text }: { text: string | null }) {
if (!text) return null;
return (
<div className="callout warn flush" role="alert">
{text}
</div>
);
}
/** The keys whose value changed, with the old values as the base. */
function diff<T extends object>(before: T, after: T): { set: Partial<T>; base: Partial<T> } {
const set: Partial<T> = {};
const base: Partial<T> = {};
for (const k of Object.keys(after) as Array<keyof T>) {
if ((before[k] ?? null) !== (after[k] ?? null)) {
set[k] = after[k];
base[k] = before[k];
}
}
return { set, base };
}
const orNull = (s: string) => (s.trim() === "" ? null : s.trim());
/* ---------- Project: new or edit ---------- */
export function ProjectSheet({ project, onSaved, onCancel }: { project?: Project; onSaved: (p: Project) => void; onCancel: () => void }) {
const guard = useGuard();
const editing = !!project;
const [number, setNumber] = useState(project?.number ?? "");
const [name, setName] = useState(project?.name ?? "");
const [customer, setCustomer] = useState(project?.customer ?? "");
const [po, setPo] = useState(project?.po ?? "");
const [due, setDue] = useState(project?.due ?? "");
const [status, setStatus] = useState<ProjectStatus>(project?.status ?? "open");
const [notes, setNotes] = useState(project?.notes ?? "");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const submit = async (e: FormEvent) => {
e.preventDefault();
setError(null);
const num = number.trim().toUpperCase();
if (!editing && !RX_PROJECT.test(num)) {
setError("Project number: 1 to 20 letters, numbers or dashes, for example L670.");
return;
}
if (!name.trim()) {
setError("Give the project a name.");
return;
}
setBusy(true);
try {
let saved: Project | undefined;
if (project) {
const before: ProjectFields = { name: project.name, customer: project.customer, po: project.po, due: project.due, status: project.status, notes: project.notes };
const after: ProjectFields = { name: orNull(name), customer: orNull(customer), po: orNull(po), due: orNull(due), status, notes: orNull(notes) };
const cas = diff(before, after);
if (Object.keys(cas.set).length === 0) {
onCancel();
return;
}
saved = await guard.run((g) => projectsApi.patch(project.id, { ...cas, ...g }), { sub: project.number });
} else {
saved = await guard.run((g) =>
projectsApi.create({ number: num, name: name.trim(), customer: customer.trim(), po: po.trim(), due: orNull(due), notes: notes.trim(), ...g }),
);
}
if (saved) onSaved(saved);
else setBusy(false);
} catch (err) {
setError(errorText(err));
setBusy(false);
}
};
return (
<>
<Sheet title={editing ? "Edit project" : "New project"} sub={project?.number} onClose={onCancel} wide>
<form className="stack" onSubmit={submit} noValidate>
<div className="frow">
{editing ? null : (
<label className="f">
Project number
<input className="in" value={number} maxLength={20} placeholder="e.g. L670" autoCapitalize="characters" onChange={(e) => setNumber(e.target.value)} />
</label>
)}
<label className="f">
Project name
<input className="in" value={name} maxLength={120} placeholder="e.g. BPX separator package" onChange={(e) => setName(e.target.value)} />
</label>
</div>
<div className="frow">
<label className="f">
Customer
<input className="in" value={customer} maxLength={120} onChange={(e) => setCustomer(e.target.value)} />
</label>
<label className="f">
Customer PO
<input className="in" value={po} maxLength={60} onChange={(e) => setPo(e.target.value)} />
</label>
<label className="f">
Due / ship date
<input className="in" type="date" value={due} onChange={(e) => setDue(e.target.value)} />
</label>
{editing ? (
<label className="f">
Status
<select className="in" value={status} onChange={(e) => setStatus(e.target.value as ProjectStatus)}>
{STATUSES.map((s) => (
<option key={s} value={s}>
{PROJECT_STATUS_LABEL[s]}
</option>
))}
</select>
</label>
) : null}
</div>
<label className="f">
Notes
<textarea className="in" rows={3} maxLength={2000} value={notes} onChange={(e) => setNotes(e.target.value)} />
</label>
{editing ? null : <div className="small muted">After creating the project, add its units. Packets go into a unit.</div>}
<Problem text={error} />
<div className="sheet-acts">
<button className="btn ghost" type="button" onClick={onCancel}>
Cancel
</button>
<button className="btn pri" type="submit" disabled={busy}>
{editing ? "Save changes" : "Create project"}
</button>
</div>
</form>
</Sheet>
{guard.sheet}
</>
);
}
/* ---------- Add units: one, or several counting up ---------- */
export function AddUnitsSheet({
project,
lots,
onDone,
onCancel,
}: {
project: ProjectRef;
/** Lots already in use, for the next free number. */
lots: string[];
onDone: (units: Unit[]) => void;
onCancel: () => void;
}) {
const guard = useGuard();
const first = nextLot(lots);
const [many, setMany] = useState(false);
const [lot, setLot] = useState(first);
const [count, setCount] = useState("");
const [start, setStart] = useState(first);
const [name, setName] = useState("");
const [due, setDue] = useState("");
const [notes, setNotes] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const seq = many ? lotSequence(start, Number(count)) : null;
const preview = seq?.map((l) => `${project.number}${l}`) ?? [];
const submit = async (e: FormEvent) => {
e.preventDefault();
setError(null);
let body: { lot?: string; lots?: string[] };
if (many) {
const n = Number(count);
if (!Number.isInteger(n) || n < 1 || n > 200) {
setError("How many: 1 to 200.");
return;
}
if (!seq) {
setError("The starting lot has to be digits, for example 001, and the last one 999 or less.");
return;
}
body = { lots: seq };
} else {
const l = normLot(lot);
if (!RX_LOT.test(l)) {
setError("Lot: 1 to 3 letters or digits, for example 001 or 7A.");
return;
}
body = { lot: l };
}
setBusy(true);
try {
const units = await guard.run((g) => projectsApi.addUnits(project.id, { ...body, name: name.trim(), due: orNull(due), notes: notes.trim(), ...g }), { sub: project.number });
if (units) onDone(units);
else setBusy(false);
} catch (err) {
setError(errorText(err));
setBusy(false);
}
};
return (
<>
<Sheet title="Add units" sub={`Project ${project.number}${project.name ? ` · ${project.name}` : ""}`} onClose={onCancel} wide>
<form className="stack" onSubmit={submit} noValidate>
<div className="seg" role="group" aria-label="One or several">
<button type="button" aria-pressed={!many} onClick={() => setMany(false)}>
One unit
</button>
<button type="button" aria-pressed={many} onClick={() => setMany(true)}>
Several units
</button>
</div>
{many ? (
<>
<div className="frow">
<label className="f">
How many
<input className="in" type="number" min={1} max={200} inputMode="numeric" placeholder="e.g. 12" value={count} onChange={(e) => setCount(e.target.value)} />
</label>
<label className="f">
Starting lot
<input className="in mono" value={start} maxLength={3} onChange={(e) => setStart(e.target.value)} />
</label>
</div>
<div className="small muted" aria-live="polite">
{preview.length
? preview.length <= 12
? `Numbers: ${preview.join(", ")}`
: `Numbers: ${preview.slice(0, 6).join(", ")} and ${preview.length - 6} more, up to ${preview[preview.length - 1] ?? ""}`
: "Numbers count up from the starting lot."}
</div>
</>
) : (
<div className="frow">
<label className="f">
Lot
<input className="in mono" value={lot} maxLength={3} autoCapitalize="characters" onChange={(e) => setLot(e.target.value)} />
</label>
<div className="end small muted">
Unit number <b className="mono">{`${project.number}${normLot(lot)}`}</b>
</div>
</div>
)}
<div className="frow">
<label className="f">
{many ? "Description (all of them)" : "Description"}
<input className="in" value={name} maxLength={120} placeholder='e.g. 48" horizontal separator' onChange={(e) => setName(e.target.value)} />
</label>
<label className="f">
Due / ship date
<input className="in" type="date" value={due} onChange={(e) => setDue(e.target.value)} />
</label>
</div>
<div className="small muted">Leave the date blank to use the project's date.</div>
<label className="f">
Notes
<textarea className="in" rows={2} maxLength={2000} value={notes} onChange={(e) => setNotes(e.target.value)} />
</label>
<Problem text={error} />
<div className="sheet-acts">
<button className="btn ghost" type="button" onClick={onCancel}>
Cancel
</button>
<button className="btn pri" type="submit" disabled={busy}>
Add
</button>
</div>
</form>
</Sheet>
{guard.sheet}
</>
);
}
/* ---------- Edit a unit ---------- */
export function UnitSheet({ unit, onSaved, onCancel }: { unit: Unit; onSaved: (u: Unit) => void; onCancel: () => void }) {
const guard = useGuard();
const [name, setName] = useState(unit.name ?? "");
const [due, setDue] = useState(unit.due ?? "");
const [status, setStatus] = useState<ProjectStatus>(unit.status);
const [notes, setNotes] = useState(unit.notes ?? "");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const submit = async (e: FormEvent) => {
e.preventDefault();
const before: UnitFields = { name: unit.name, due: unit.due, status: unit.status, notes: unit.notes ?? null };
const cas = diff(before, { name: orNull(name), due: orNull(due), status, notes: orNull(notes) });
if (Object.keys(cas.set).length === 0) {
onCancel();
return;
}
setBusy(true);
setError(null);
try {
const saved = await guard.run((g) => projectsApi.patchUnit(unit.id, { ...cas, ...g }), { sub: unit.number });
if (saved) onSaved(saved);
else setBusy(false);
} catch (err) {
setError(errorText(err));
setBusy(false);
}
};
return (
<>
<Sheet title="Edit unit" sub={unit.number} onClose={onCancel} wide>
<form className="stack" onSubmit={submit} noValidate>
<div className="frow">
<label className="f">
Description
<input className="in" value={name} maxLength={120} onChange={(e) => setName(e.target.value)} />
</label>
<label className="f">
Due / ship date
<input className="in" type="date" value={due} onChange={(e) => setDue(e.target.value)} />
</label>
<label className="f">
Status
<select className="in" value={status} onChange={(e) => setStatus(e.target.value as ProjectStatus)}>
{STATUSES.map((s) => (
<option key={s} value={s}>
{PROJECT_STATUS_LABEL[s]}
</option>
))}
</select>
</label>
</div>
<label className="f">
Notes
<textarea className="in" rows={2} maxLength={2000} value={notes} onChange={(e) => setNotes(e.target.value)} />
</label>
<Problem text={error} />
<div className="sheet-acts">
<button className="btn ghost" type="button" onClick={onCancel}>
Cancel
</button>
<button className="btn pri" type="submit" disabled={busy}>
Save changes
</button>
</div>
</form>
</Sheet>
{guard.sheet}
</>
);
}
/* ---------- Renumber a project or a unit ---------- */
export function RenumberSheet({
kind,
current,
prefix,
onSave,
onCancel,
}: {
kind: "project" | "unit";
/** The current project number, or the unit's lot. */
current: string;
/** For a unit: the project number, to show the new unit number. */
prefix?: string;
onSave: (value: string, reason: string) => Promise<void>;
onCancel: () => void;
}) {
const [value, setValue] = useState("");
const [reason, setReason] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const submit = async (e: FormEvent) => {
e.preventDefault();
setError(null);
const v = kind === "project" ? value.trim().toUpperCase() : normLot(value);
if (kind === "project" ? !RX_PROJECT.test(v) : !RX_LOT.test(v)) {
setError(kind === "project" ? "Project number: 1 to 20 letters, numbers or dashes." : "Lot: 1 to 3 letters or digits.");
return;
}
if (v === current) {
setError("That is the current number.");
return;
}
if (!reason.trim()) {
setError("Give a reason.");
return;
}
setBusy(true);
try {
await onSave(v, reason.trim());
} catch (err) {
setError(errorText(err));
setBusy(false);
}
};
const title = kind === "project" ? "Renumber project" : "Renumber unit";
return (
<Sheet title={title} sub={kind === "project" ? current : `${prefix ?? ""}${current}`} onClose={onCancel}>
<div className="small">
{kind === "project"
? "The old number stays as an alias, so prints with it still find this project. Its units keep their lots and move with it."
: "The old number stays as an alias. The unit's packets move with it."}
</div>
<form className="stack" onSubmit={submit} noValidate>
<label className="f">
{kind === "project" ? "New project number" : "New lot"}
<input className="in mono" value={value} maxLength={kind === "project" ? 20 : 3} autoCapitalize="characters" onChange={(e) => setValue(e.target.value)} />
</label>
<label className="f">
Reason
<input className="in" value={reason} maxLength={500} onChange={(e) => setReason(e.target.value)} />
</label>
<Problem text={error} />
<div className="sheet-acts">
<button className="btn ghost" type="button" onClick={onCancel}>
Cancel
</button>
<button className="btn pri" type="submit" disabled={busy}>
Change number
</button>
</div>
</form>
</Sheet>
);
}
/* ---------- New packet: a hand draft ---------- */
interface WoRow {
wo_base: string;
lot: string;
split: string;
sub: string;
qty: string;
target_hrs: string;
}
interface BomRow {
qty: string;
part_id: string;
description: string;
material: string;
length: string;
dwg_ref: string;
}
const EMPTY_WO: WoRow = { wo_base: "", lot: "", split: "", sub: "", qty: "", target_hrs: "" };
const EMPTY_BOM: BomRow = { qty: "", part_id: "", description: "", material: "", length: "", dwg_ref: "" };
const num = (s: string): number | null => {
const t = s.trim();
if (t === "") return null;
const n = Number(t);
return Number.isFinite(n) ? n : null;
};
export function NewPacketSheet({
initial,
onCreated,
onCancel,
}: {
initial: UnitPick;
onCreated: (p: PacketRead) => void;
onCancel: () => void;
}) {
const guard = useGuard();
const depts = useShopDepartments();
const [pick, setPick] = useState<UnitPick>(initial);
const [dept, setDept] = useState("");
const [op, setOp] = useState("");
const [title, setTitle] = useState("");
const [customer, setCustomer] = useState(initial.project?.customer ?? "");
const [drawing, setDrawing] = useState("");
const [rev, setRev] = useState("");
const [wps, setWps] = useState("");
const [wos, setWos] = useState<WoRow[]>([{ ...EMPTY_WO }]);
const [bom, setBom] = useState<BomRow[]>([]);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const deptValue = dept || depts[0]?.code || "";
const onPick = (v: UnitPick) => {
setPick(v);
if (v.project?.customer && !customer) setCustomer(v.project.customer);
};
const setWo = (i: number, k: keyof WoRow, v: string) => setWos((rows) => rows.map((r, j) => (j === i ? { ...r, [k]: v } : r)));
const setBl = (i: number, k: keyof BomRow, v: string) => setBom((rows) => rows.map((r, j) => (j === i ? { ...r, [k]: v } : r)));
const submit = async (e: FormEvent) => {
e.preventDefault();
setError(null);
if (!pick.unit) {
setError("Pick the project and the unit first.");
return;
}
if (!deptValue) {
setError("Pick the first department.");
return;
}
if (!op.trim()) {
setError("Give the operation, for example WELD-PART.");
return;
}
const work_orders = wos
.filter((w) => w.wo_base.trim())
.map((w) => ({
wo_base: w.wo_base.trim().toUpperCase(),
lot: w.lot.trim().toUpperCase(),
split: w.split.trim().toUpperCase(),
sub: w.sub.trim().toUpperCase(),
qty: num(w.qty),
target_hrs: num(w.target_hrs),
}));
const lines = bom
.filter((b) => b.part_id.trim() || b.description.trim())
.map((b, i) => ({
item: i + 1,
qty: num(b.qty),
part_id: b.part_id.trim() || null,
description: b.description.trim() || null,
material: b.material.trim() || null,
length: b.length.trim() || null,
dwg_ref: b.dwg_ref.trim() || null,
}));
const body: PacketCreate = {
unit_id: pick.unit.id,
dept_code: deptValue,
op: op.trim().toUpperCase(),
title: title.trim(),
customer: customer.trim(),
drawing: drawing.trim(),
rev: rev.trim(),
wps: wps.trim(),
work_orders,
bom: lines,
};
setBusy(true);
try {
const created = await guard.run((g) => packetsApi.create({ ...body, ...g } as PacketCreate));
if (created) onCreated(created);
else setBusy(false);
} catch (err) {
setError(errorText(err));
setBusy(false);
}
};
return (
<>
<Sheet title="New packet" sub="Made by hand" onClose={onCancel} wide>
<form className="stack" onSubmit={submit} noValidate>
<PickUnit value={pick} onChange={onPick} requireUnit />
<div className="frow">
<label className="f">
First department
<select className="in" value={deptValue} 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">
Operation
<input className="in" value={op} maxLength={40} placeholder="e.g. WELD-PART" autoCapitalize="characters" onChange={(e) => setOp(e.target.value)} />
</label>
<label className="f">
Title
<input className="in" value={title} maxLength={200} onChange={(e) => setTitle(e.target.value)} />
</label>
<label className="f">
Customer
<input className="in" value={customer} maxLength={120} onChange={(e) => setCustomer(e.target.value)} />
</label>
</div>
<div className="frow">
<label className="f">
Drawing no.
<input className="in" value={drawing} maxLength={60} onChange={(e) => setDrawing(e.target.value)} />
</label>
<label className="f">
Rev
<input className="in" value={rev} maxLength={10} onChange={(e) => setRev(e.target.value)} />
</label>
<label className="f">
WPS
<input className="in" value={wps} maxLength={40} onChange={(e) => setWps(e.target.value)} />
</label>
</div>
<h3>
Work orders <span className="muted mono small">({wos.filter((w) => w.wo_base.trim()).length})</span>
</h3>
<div className="tw">
<table className="edit">
<thead>
<tr>
<th>WO</th>
<th>Lot</th>
<th>Split</th>
<th>Sub</th>
<th className="num">Qty</th>
<th className="num">Target hrs</th>
<th />
</tr>
</thead>
<tbody>
{wos.map((w, i) => (
<tr key={i}>
<td>
<input className="in sm mono" aria-label={`Work order ${i + 1}`} value={w.wo_base} maxLength={30} onChange={(e) => setWo(i, "wo_base", e.target.value)} />
</td>
<td>
<input className="in sm mono" aria-label={`Work order ${i + 1} lot`} value={w.lot} maxLength={10} onChange={(e) => setWo(i, "lot", e.target.value)} />
</td>
<td>
<input className="in sm mono" aria-label={`Work order ${i + 1} split`} value={w.split} maxLength={10} onChange={(e) => setWo(i, "split", e.target.value)} />
</td>
<td>
<input className="in sm mono" aria-label={`Work order ${i + 1} sub`} value={w.sub} maxLength={10} onChange={(e) => setWo(i, "sub", e.target.value)} />
</td>
<td>
<input className="in sm num" aria-label={`Work order ${i + 1} quantity`} inputMode="decimal" value={w.qty} onChange={(e) => setWo(i, "qty", e.target.value)} />
</td>
<td>
<input className="in sm num" aria-label={`Work order ${i + 1} target hours`} inputMode="decimal" value={w.target_hrs} onChange={(e) => setWo(i, "target_hrs", e.target.value)} />
</td>
<td>
<button className="btn sm ghost" type="button" aria-label={`Remove work order ${i + 1}`} onClick={() => setWos((rows) => rows.filter((_, j) => j !== i))}>
Remove
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div>
<button className="btn sm" type="button" onClick={() => setWos((rows) => [...rows, { ...EMPTY_WO }])}>
Add work order
</button>
</div>
<h3>
Bill of materials <span className="muted mono small">(optional, {bom.length} lines)</span>
</h3>
{bom.length ? (
<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>
{bom.map((b, 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={b.qty} onChange={(e) => setBl(i, "qty", e.target.value)} />
</td>
<td>
<input className="in sm mono" aria-label={`Line ${i + 1} part ID`} value={b.part_id} maxLength={60} onChange={(e) => setBl(i, "part_id", e.target.value)} />
</td>
<td>
<input className="in sm" aria-label={`Line ${i + 1} description`} value={b.description} maxLength={240} onChange={(e) => setBl(i, "description", e.target.value)} />
</td>
<td>
<input className="in sm" aria-label={`Line ${i + 1} material`} value={b.material} maxLength={120} onChange={(e) => setBl(i, "material", e.target.value)} />
</td>
<td>
<input className="in sm" aria-label={`Line ${i + 1} length`} value={b.length} maxLength={40} onChange={(e) => setBl(i, "length", e.target.value)} />
</td>
<td>
<input className="in sm" aria-label={`Line ${i + 1} drawing reference`} value={b.dwg_ref} maxLength={60} onChange={(e) => setBl(i, "dwg_ref", e.target.value)} />
</td>
<td>
<button className="btn sm ghost" type="button" aria-label={`Remove line ${i + 1}`} onClick={() => setBom((rows) => rows.filter((_, j) => j !== i))}>
Remove
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
) : null}
<div>
<button className="btn sm" type="button" onClick={() => setBom((rows) => [...rows, { ...EMPTY_BOM }])}>
Add BOM line
</button>
</div>
<div className="small muted">The packet is saved as a draft. Attach the drawing and say whether it needs a heat map before you send it on.</div>
<Problem text={error} />
<div className="sheet-acts">
<button className="btn ghost" type="button" onClick={onCancel}>
Discard
</button>
<button className="btn pri" type="submit" disabled={busy}>
Save packet as draft
</button>
</div>
</form>
</Sheet>
{guard.sheet}
</>
);
}