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