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
+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>
);
}