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]>
157 lines
4.7 KiB
TypeScript
157 lines
4.7 KiB
TypeScript
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]);
|
|
}
|