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
+36
View File
@@ -0,0 +1,36 @@
import type { ReactNode } from "react";
import type { PacketCard } from "../api/types";
import { Card } from "./Card";
export interface ColumnDef {
key: string;
title: string;
cards: PacketCard[];
/** Shown when the column is empty. */
empty: ReactNode;
}
/** The old column(): a titled column of cards with a count, or the empty note. */
export function Column({ title, cards, empty }: Omit<ColumnDef, "key">) {
const id = `col-${title.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`;
return (
<section className="col" aria-labelledby={id}>
<h3 className="col-h" id={id}>
{title}
<span className="c">{cards.length}</span>
</h3>
{cards.length ? cards.map((p) => <Card key={p.id} p={p} />) : <div className="empty">{empty}</div>}
</section>
);
}
/** A row of columns. */
export function Board({ columns }: { columns: ColumnDef[] }) {
return (
<div className="board">
{columns.map((c) => (
<Column key={c.key} title={c.title} cards={c.cards} empty={c.empty} />
))}
</div>
);
}
+46
View File
@@ -0,0 +1,46 @@
import type { ReactNode } from "react";
import { Link } from "react-router";
import type { PacketCard } from "../api/types";
import { flagClass, jobName, openCoCount, pillClass, statusLabel } from "../lib/packets";
/** One packet on a board. The old card(): job name and pill, title, department, meta, flag line. */
export function Card({ p, extra }: { p: PacketCard; extra?: ReactNode }) {
const shorts = p.open_shorts || 0;
const cos = openCoCount(p);
return (
<Link className="card" to={`/packets/${p.id}`} aria-label={`${jobName(p)}, ${statusLabel(p.status)}`}>
<div className="r1">
<span className="job">{jobName(p)}</span>
<span className={pillClass(p.status)}>{statusLabel(p.status)}</span>
</div>
<div className="tt">{p.title || "Untitled"}</div>
<div className="small dept">{p.dept.name}</div>
<div className="meta">
<span>
Dwg <b className="mono">{p.drawing || "-"}</b>
{p.rev ? <> R{p.rev}</> : null}
</span>
{p.wo_count !== undefined ? (
<span>
<b>{p.wo_count}</b> WO
</span>
) : null}
{p.station ? (
<span>
<b>{p.station.name}</b>
{p.welder ? ` · ${p.welder.display_name}` : ""}
</span>
) : null}
{p.status === "complete" && p.routed_to ? (
<span>
To <b>{p.routed_to === "databook" ? "Clerical (data book)" : "Shipping"}</b>
</span>
) : null}
{shorts ? <span className="pill st-review">{shorts} short</span> : null}
{cos ? <span className="pill st-review">CO {cos}</span> : null}
</div>
{p.flag ? <div className={`small ${flagClass(p.flag)}`}>⚑ {p.flag}</div> : null}
{extra}
</Link>
);
}
+63
View File
@@ -0,0 +1,63 @@
import { PickUnit, type UnitPick } from "./PickUnit";
export type BoardView = "board" | "list";
interface FindBarProps {
q: string;
onQ: (q: string) => void;
pick: UnitPick;
onPick: (v: UnitPick) => void;
view: BoardView;
onView: (v: BoardView) => void;
/** "Showing n packets". */
count: number | null;
}
/** Find a job: the search box, the project then unit picker, and the Board | List toggle. */
export function FindBar({ q, onQ, pick, onPick, view, onView, count }: FindBarProps) {
return (
<section className="fltbar" aria-label="Find jobs">
<div className="fltrow">
<label className="f fltq">
<span>Find a job</span>
<input
className="in"
type="search"
placeholder="Project, unit, lot, drawing, WO, customer, PO, title"
autoComplete="off"
spellCheck={false}
value={q}
onChange={(e) => onQ(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Escape" && q) {
e.stopPropagation();
onQ("");
}
}}
/>
</label>
<div className="f fltp">
<PickUnit value={pick} onChange={onPick} />
</div>
<div className="fltbtns">
<div className="seg" role="group" aria-label="View">
<button type="button" aria-pressed={view === "board"} onClick={() => onView("board")}>
Board
</button>
<button type="button" aria-pressed={view === "list"} onClick={() => onView("list")}>
List
</button>
</div>
</div>
</div>
{count !== null ? (
<div className="fltrow2">
<span className="grow" />
<span className="small muted">
Showing <b>{count}</b> packet{count === 1 ? "" : "s"}
</span>
</div>
) : null}
</section>
);
}
+26
View File
@@ -0,0 +1,26 @@
import type { ReactNode } from "react";
export interface Kpi {
n: ReactNode;
label: string;
/** Turns pink when above zero. */
alert?: boolean;
}
/** The old kpis(): number tiles. Zero is grey, an alert above zero is pink. */
export function Kpis({ items, label = "Numbers" }: { items: Kpi[]; label?: string }) {
return (
<div className="kpis" role="group" aria-label={label}>
{items.map((k) => {
const zero = String(k.n) === "0";
const cls = ["kpi", k.alert && !zero ? "hot" : "", zero ? "zero" : ""].filter(Boolean).join(" ");
return (
<div key={k.label} className={cls} aria-label={`${k.label}: ${String(k.n)}`}>
<div className="n">{k.n}</div>
<div className="l">{k.label}</div>
</div>
);
})}
</div>
);
}
+148
View File
@@ -0,0 +1,148 @@
import { useEffect, useId, useState } from "react";
import { projectsApi } from "../api/projects";
import type { Project, ProjectRef, Unit } from "../api/types";
import { errorText } from "../lib/format";
import { projectPillClass, PROJECT_STATUS_LABEL } from "../lib/packets";
export interface UnitPick {
project: ProjectRef | null;
unit: Unit | null;
}
interface PickUnitProps {
value: UnitPick;
onChange: (v: UnitPick) => void;
/** A packet must go in a unit: no "All units" choice, and closed units are left out. */
requireUnit?: boolean;
/** Label of the first step. */
label?: string;
}
/** Two steps: find the project, then pick one of its units. */
export function PickUnit({ value, onChange, requireUnit = false, label = "Project" }: PickUnitProps) {
const id = useId();
const [q, setQ] = useState("");
const [query, setQuery] = useState("");
const [hits, setHits] = useState<Project[] | null>(null);
const [units, setUnits] = useState<Unit[] | null>(null);
const [error, setError] = useState<string | null>(null);
const projectId = value.project?.id ?? null;
useEffect(() => {
const t = window.setTimeout(() => setQuery(q.trim()), 250);
return () => window.clearTimeout(t);
}, [q]);
// Step one: projects matching the search, while no project is chosen.
useEffect(() => {
if (projectId !== null) return;
const ctl = new AbortController();
projectsApi
.list(query, "active", ctl.signal)
.then((list) => {
if (!ctl.signal.aborted) setHits(list);
})
.catch((err: unknown) => {
if (!ctl.signal.aborted) setError(errorText(err));
});
return () => ctl.abort();
}, [query, projectId]);
// Step two: the chosen project's units.
useEffect(() => {
if (projectId === null) {
setUnits(null);
return;
}
const ctl = new AbortController();
projectsApi
.units(projectId, "", ctl.signal)
.then((list) => {
if (!ctl.signal.aborted) setUnits(list);
})
.catch((err: unknown) => {
if (!ctl.signal.aborted) setError(errorText(err));
});
return () => ctl.abort();
}, [projectId]);
const choose = (p: Project) => {
setError(null);
onChange({ project: { id: p.id, number: p.number, name: p.name, customer: p.customer, po: p.po, status: p.status, due: p.due }, unit: null });
};
if (!value.project) {
const shown = (hits ?? []).slice(0, 8);
return (
<div className="pick">
<label className="f">
{label}
<input
className="in"
type="search"
placeholder="Find a project: number, name, customer, PO"
autoComplete="off"
spellCheck={false}
value={q}
onChange={(e) => setQ(e.target.value)}
/>
</label>
{error ? <div className="small due over">{error}</div> : null}
{hits === null ? null : shown.length ? (
<div className="projhits" role="group" aria-label="Projects found">
{shown.map((p) => (
<button key={p.id} type="button" className="projhit" onClick={() => choose(p)}>
<b className="mono">{p.number}</b> {p.name ?? ""}
<span className="muted small">{p.customer ?? ""}</span>
<span className={projectPillClass(p.status)}>{PROJECT_STATUS_LABEL[p.status]}</span>
</button>
))}
</div>
) : (
<div className="small muted">{query ? `Nothing matches "${query}".` : "No open projects yet."}</div>
)}
</div>
);
}
const list = (units ?? []).filter((u) => !requireUnit || u.status !== "closed" || u.id === value.unit?.id);
return (
<div className="pick">
<div className="pickp">
<span className="small muted">{label}</span>
<b className="mono">{value.project.number}</b> <span>{value.project.name ?? ""}</span>
<button className="lnk small" type="button" onClick={() => onChange({ project: null, unit: null })}>
Change
</button>
</div>
<label className="f" htmlFor={`${id}-unit`}>
Unit
</label>
{units === null ? (
<div className="small muted">Loading units</div>
) : list.length === 0 ? (
<div className="small muted">This project has no open units. Add one from the project page first.</div>
) : (
<select
id={`${id}-unit`}
className="in"
value={value.unit ? String(value.unit.id) : ""}
onChange={(e) => {
const u = list.find((x) => String(x.id) === e.target.value) ?? null;
onChange({ project: value.project, unit: u });
}}
>
<option value="">{requireUnit ? "Choose a unit" : "All units"}</option>
{list.map((u) => (
<option key={u.id} value={String(u.id)}>
{u.number}
{u.name ? ` · ${u.name}` : ""}
{u.status === "hold" ? " (on hold)" : u.status === "closed" ? " (closed)" : ""}
</option>
))}
</select>
)}
{error ? <div className="small due over">{error}</div> : null}
</div>
);
}
+75
View File
@@ -0,0 +1,75 @@
import { useState, type FormEvent } from "react";
import { Sheet } from "./Sheet";
export interface ReasonOptions {
title: string;
sub?: string | undefined;
/** The field label. */
label?: string;
/** A line under the title. */
note?: string | undefined;
placeholder?: string | undefined;
/** Shortest reason accepted. 0 makes the field optional. */
min?: number;
max?: number;
submitLabel?: string;
danger?: boolean;
}
interface ReasonSheetProps extends ReasonOptions {
onSubmit: (reason: string) => void;
onCancel: () => void;
}
/** Asks for a reason, or an optional note, before a step. */
export function ReasonSheet({
title,
sub,
label = "Reason",
note,
placeholder,
min = 1,
max = 500,
submitLabel = "Continue",
danger = false,
onSubmit,
onCancel,
}: ReasonSheetProps) {
const [text, setText] = useState("");
const [error, setError] = useState<string | null>(null);
const submit = (e: FormEvent) => {
e.preventDefault();
const t = text.trim();
if (t.length < min) {
setError(min <= 1 ? "Give a reason." : `Give a reason of at least ${min} characters.`);
return;
}
onSubmit(t);
};
return (
<Sheet title={title} sub={sub} onClose={onCancel}>
{note ? <div className="small">{note}</div> : null}
<form className="stack" onSubmit={submit} noValidate>
<label className="f">
{label}
<input className="in" value={text} maxLength={max} placeholder={placeholder} onChange={(e) => setText(e.target.value)} />
</label>
{error ? (
<div className="callout warn flush" role="alert">
{error}
</div>
) : null}
<div className="sheet-acts">
<button className="btn ghost" type="button" onClick={onCancel}>
Cancel
</button>
<button className={danger ? "btn pink" : "btn pri"} type="submit">
{submitLabel}
</button>
</div>
</form>
</Sheet>
);
}
+60
View File
@@ -0,0 +1,60 @@
import { useEffect, useId, useRef, type ReactNode } from "react";
interface SheetProps {
title: string;
/** Small text next to the title, like the job name. */
sub?: string | undefined;
onClose: () => void;
children: ReactNode;
/** The old sheets were 560 px for short forms and 720 px or more for long ones. */
wide?: boolean;
}
/** Open sheets, innermost last. Only the innermost one answers Escape. */
const open: string[] = [];
/** A modal sheet over a scrim. Escape and a click on the scrim close it. The first field gets the focus. */
export function Sheet({ title, sub, onClose, children, wide = false }: SheetProps) {
const id = useId();
const ref = useRef<HTMLDivElement>(null);
const closeRef = useRef(onClose);
useEffect(() => {
closeRef.current = onClose;
});
useEffect(() => {
const el = ref.current;
const first = el?.querySelector<HTMLElement>("input:not([type=hidden]):not([type=file]):not([disabled]), select, textarea");
(first ?? el)?.focus();
open.push(id);
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape" && open[open.length - 1] === id) {
e.stopPropagation();
closeRef.current();
}
};
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("keydown", onKey);
const i = open.lastIndexOf(id);
if (i >= 0) open.splice(i, 1);
};
}, [id]);
return (
<div
className="scrim"
onMouseDown={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<div ref={ref} className={wide ? "sheet" : "sheet narrow"} role="dialog" aria-modal="true" aria-labelledby={id} tabIndex={-1}>
<div className="sec-h flush">
<h2 id={id}>{title}</h2>
{sub ? <span className="ct">{sub}</span> : null}
</div>
{children}
</div>
</div>
);
}
+154
View File
@@ -0,0 +1,154 @@
import { useEffect, useRef, useState, type FormEvent } from "react";
import { ApiError, errorCode } from "../api/client";
import type { SignBody } from "../api/types";
import type { Factor } from "../auth/types";
import { useAuth } from "../auth/useAuth";
import { errorText } from "../lib/format";
import { applyKey, type KeypadKey } from "../lib/keypad";
import { Keypad } from "./Keypad";
import { Sheet } from "./Sheet";
const PIN_MAX = 12;
interface SignatureSheetProps {
/** What the signature means, from the rule. */
meaning: string;
sub?: string | undefined;
/** Sends the step with the signature. A wrong secret keeps the sheet open. */
onSign: (sign: SignBody) => Promise<void>;
/** Any other failure. The caller closes the sheet. */
onFail: (err: unknown) => void;
onCancel: () => void;
}
function attemptsLeft(err: unknown): number | null {
if (err instanceof ApiError && err.body && typeof err.body === "object" && "attempts_left" in err.body) {
const n = Number((err.body as { attempts_left: unknown }).attempts_left);
return Number.isFinite(n) ? n : null;
}
return null;
}
/** Signs a step with the user's PIN or password. */
export function SignatureSheet({ meaning, sub, onSign, onFail, onCancel }: SignatureSheetProps) {
const auth = useAuth();
const me = auth.me;
const hasPin = me ? me.pin.set && !me.pin.disabled : false;
const [factor, setFactor] = useState<Factor>(hasPin ? "pin" : "password");
const [secret, setSecret] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [left, setLeft] = useState<number | null>(null);
const [missed, setMissed] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
inputRef.current?.focus();
}, [factor]);
const switchTo = (f: Factor) => {
setFactor(f);
setSecret("");
setError(null);
};
const submit = async (e: FormEvent) => {
e.preventDefault();
if (!secret || busy) return;
setBusy(true);
setError(null);
try {
await onSign({ factor, secret });
} catch (err) {
const code = errorCode(err);
if (code === "session_ended") {
// The auth layer signs out on this 401 too. Make sure, with the server's words.
auth.endSession(errorText(err));
return;
}
if (code === "wrong_secret" || code === "bad_secret") {
setError(errorText(err));
setLeft(attemptsLeft(err));
setMissed(true);
setSecret("");
setBusy(false);
inputRef.current?.focus();
return;
}
onFail(err);
}
};
const onKey = (k: KeypadKey) => setSecret((v) => applyKey(v, k, PIN_MAX));
return (
<Sheet title="Sign" sub={sub} onClose={onCancel}>
<div className="sigmean">
<div className="eyebrow">This signature means</div>
<div className="tt">{meaning}</div>
</div>
<div className="small">
You are signing as <b>{me?.user.display_name ?? "yourself"}</b>.
</div>
{error ? (
<div className="callout warn flush" role="alert">
{error}
{left !== null ? <div className="small">{left === 1 ? "1 attempt left." : `${left} attempts left.`}</div> : null}
{missed ? <div className="small">Three wrong attempts end the session.</div> : null}
</div>
) : null}
<form className="auth-form" onSubmit={submit} noValidate autoComplete="off">
{factor === "pin" ? (
<>
<label className="f">
PIN
<input
ref={inputRef}
className="in big"
name="sign-pin"
type="password"
inputMode="numeric"
autoComplete="off"
maxLength={PIN_MAX}
value={secret}
onChange={(e) => setSecret(e.target.value.replace(/\D/g, "").slice(0, PIN_MAX))}
/>
</label>
<Keypad onKey={onKey} disabled={busy} label="Keypad, signature PIN" />
</>
) : (
<label className="f">
Password
<input
ref={inputRef}
className="in"
name="sign-password"
type="password"
autoComplete="current-password"
value={secret}
onChange={(e) => setSecret(e.target.value)}
/>
</label>
)}
<div className="sheet-acts">
{factor === "pin" ? (
<button className="lnk small" type="button" onClick={() => switchTo("password")}>
Use your password instead
</button>
) : hasPin ? (
<button className="lnk small" type="button" onClick={() => switchTo("pin")}>
Use your PIN instead
</button>
) : null}
<span className="grow" />
<button className="btn ghost" type="button" onClick={onCancel}>
Cancel
</button>
<button className="btn pri" type="submit" disabled={busy || !secret}>
{busy ? "Signing" : "Sign"}
</button>
</div>
</form>
</Sheet>
);
}
+156
View File
@@ -0,0 +1,156 @@
import { useCallback, useMemo, useState, type ReactNode } from "react";
import { ApiError, errorCode } from "../api/client";
import type { Guarded, SignBody } from "../api/types";
import { ReasonSheet, type ReasonOptions } from "./ReasonSheet";
import { SignatureSheet } from "./SignatureSheet";
type Ask =
| { kind: "reason"; opts: ReasonOptions; resolve: (r: string | null) => void }
| {
kind: "sign";
meaning: string;
sub: string | undefined;
attempt: (sign: SignBody) => Promise<unknown>;
resolve: (r: { ok: true; value: unknown } | { ok: false }) => void;
reject: (err: unknown) => void;
};
export interface RunOptions {
/** The signature meaning when the step is known to need one. Otherwise the sheet opens on 400 signature_required. */
sign?: string | undefined;
/** Shown next to the sheet titles, like the job name. */
sub?: string | undefined;
}
export interface Guard {
/**
* Runs a call. On 400 it_reason_required it asks for the IT reason and retries with `it_reason`.
* On 400 signature_required it opens the signature sheet and retries with `sign`.
* Resolves undefined when the user cancels. Other errors are thrown.
*/
run: <T>(fn: (g: Guarded) => Promise<T>, opts?: RunOptions) => Promise<T | undefined>;
/** Asks for a reason. Null when cancelled. */
askReason: (opts: ReasonOptions) => Promise<string | null>;
/** The open sheet, if any. Render it once in the screen. */
sheet: ReactNode;
}
export const IT_REASON: ReasonOptions = {
title: "IT reason",
label: "Why IT is making this change",
note: "You are changing this as IT. The reason is recorded with the change.",
min: 8,
submitLabel: "Continue",
};
function meaningOf(err: unknown): string {
if (err instanceof ApiError && err.body && typeof err.body === "object" && "meaning" in err.body) {
const m = (err.body as { meaning: unknown }).meaning;
if (typeof m === "string" && m) return m;
}
return "Sign this step";
}
/** The IT reason and signature retries, as sheets. */
export function useGuard(): Guard {
const [ask, setAsk] = useState<Ask | null>(null);
const askReason = useCallback(
(opts: ReasonOptions) =>
new Promise<string | null>((resolve) => {
setAsk({ kind: "reason", opts, resolve });
}),
[],
);
const askSign = useCallback(
<T,>(meaning: string, sub: string | undefined, attempt: (sign: SignBody) => Promise<T>) =>
new Promise<{ ok: true; value: T } | { ok: false }>((resolve, reject) => {
setAsk({
kind: "sign",
meaning,
sub,
attempt,
resolve: resolve as (r: { ok: true; value: unknown } | { ok: false }) => void,
reject,
});
}),
[],
);
const run = useCallback(
async <T,>(fn: (g: Guarded) => Promise<T>, opts: RunOptions = {}): Promise<T | undefined> => {
let itReason: string | undefined;
let meaning = opts.sign;
for (let round = 0; round < 4; round++) {
const call = (sign?: SignBody) => {
const g: Guarded = {};
if (itReason) g.it_reason = itReason;
if (sign) g.sign = sign;
return fn(g);
};
try {
if (meaning) {
const r = await askSign(meaning, opts.sub, call);
return r.ok ? r.value : undefined;
}
return await call();
} catch (err) {
const code = errorCode(err);
if (code === "it_reason_required" && !itReason) {
const r = await askReason({ ...IT_REASON, sub: opts.sub });
if (r === null) return undefined;
itReason = r;
continue;
}
if (code === "signature_required" && !meaning) {
meaning = meaningOf(err);
continue;
}
throw err;
}
}
return undefined;
},
[askReason, askSign],
);
let sheet: ReactNode = null;
if (ask?.kind === "reason") {
sheet = (
<ReasonSheet
{...ask.opts}
onSubmit={(r) => {
setAsk(null);
ask.resolve(r);
}}
onCancel={() => {
setAsk(null);
ask.resolve(null);
}}
/>
);
} else if (ask?.kind === "sign") {
sheet = (
<SignatureSheet
meaning={ask.meaning}
sub={ask.sub}
onSign={async (sign) => {
const value = await ask.attempt(sign);
setAsk(null);
ask.resolve({ ok: true, value });
}}
onFail={(err) => {
setAsk(null);
ask.reject(err);
}}
onCancel={() => {
setAsk(null);
ask.resolve({ ok: false });
}}
/>
);
}
return useMemo(() => ({ run, askReason, sheet }), [run, askReason, sheet]);
}