Files
PacketStation/frontend/src/screens/admin/ChecklistsTab.tsx
T
spencermandClaude Opus 5.5 a82e056b6a
ci / frontend (pull_request) Successful in 1m11s
ci / server (pull_request) Successful in 12m2s
ci / image (pull_request) Skipped
ci / e2e (pull_request) Successful in 6m1s
Phase 9: Admin completion, reconcile, notifications, integrity, backups
Admin: Departments (name and stations; add shows the three groups IT must
create), Profiles (welder stamp, station limits, active) with a CSV import
that previews and applies only when clean, Checklists (weld and plain),
Security (session, lockout and rate-limit settings with bounds, alerts for
the last 24 hours, the integrity result), Access log with filters and a
guarded CSV, Who is in, Stamp check, Notifications, and the employeeID
flags. Every Admin change is audited.

Worker: ad-reconcile (nightly and on "refresh from AD now"), session-refresh
(15 minutes), integrity (seals, asset hashes, missing and orphan files, the
audit chain), backup-check, notify-send and notify-digest. Notifications
route per event type to AD groups, users, mailboxes or the department
Supervisor group, immediately or in a daily digest, over STARTTLS verified
against the PESCO CA, with dry run on the test stack.

Backups: scripts/backup/ps-backup.sh and ps-restore.sh with a systemd timer
at 02:00 America/Denver. Restore rehearsed on the test stack 2026-10-21.
CI: pip-audit and npm audit. pypdf 6.21.1 and certifi 2026.10.19.

Migration 0010. Closes the app part of I6, M3, O1, O2 and S3.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 18:27:09 -06:00

232 lines
8.2 KiB
TypeScript

// Admin: the department checklists, one list for weld departments and one for the rest (phase 9).
import { useEffect, useState } from "react";
import { admin9Api, type ChecklistItem, type ChecklistLists, type ChecklistsAnswer } from "../../api/admin";
import { ApiError } from "../../api/client";
import { errorText } from "../../lib/format";
import { useLoad } from "../../lib/useLoad";
import { Notice, Problem, type Msg } from "./common";
type ListId = "weld" | "plain";
const LISTS: ReadonlyArray<{ id: ListId; title: string; one: string }> = [
{ id: "weld", title: "Weld departments", one: "weld" },
{ id: "plain", title: "Other departments", one: "plain" },
];
const RX_KEY = /^[a-z][a-z0-9_]{0,29}$/;
const MAX_ITEMS = 40;
const cap = (s: string) => s.charAt(0).toUpperCase() + s.slice(1);
const PLACEHOLDERS = new Set(["drawing", "rev", "wps"]);
interface Row extends ChecklistItem {
/** True for a key the saved list already has. Its key cannot change, because answers on open stages use it. */
saved: boolean;
}
type Draft = Record<ListId, Row[]>;
function toDraft(v: ChecklistLists): Draft {
return {
weld: v.weld.map((it) => ({ ...it, saved: true })),
plain: v.plain.map((it) => ({ ...it, saved: true })),
};
}
/** The first problem with one list, or null. Mirrors the server's rules. */
function listProblem(rows: readonly ChecklistItem[], title: string): string | null {
if (rows.length === 0) return `${title}: the list needs at least one item.`;
if (rows.length > MAX_ITEMS) return `${title}: at most ${MAX_ITEMS} items.`;
const seen = new Set<string>();
for (const [i, r] of rows.entries()) {
const n = i + 1;
if (!r.group.trim()) return `${title}, item ${n}: give it a group.`;
if (!r.text.trim()) return `${title}, item ${n}: give it a text.`;
if (!RX_KEY.test(r.key)) return `${title}, item ${n}: the key is lower case letters, digits and _, starting with a letter.`;
if (seen.has(r.key)) return `${title}, item ${n}: the key ${r.key} is used twice.`;
seen.add(r.key);
for (const m of r.text.matchAll(/\{([^}]*)\}/g)) {
if (!PLACEHOLDERS.has(m[1] ?? "")) return `${title}, item ${n}: only {drawing}, {rev} and {wps} can be filled in.`;
}
}
return null;
}
function ListEditor({
id,
title,
one,
rows,
onChange,
onDefaults,
}: {
id: ListId;
title: string;
one: string;
rows: Row[];
onChange: (rows: Row[]) => void;
onDefaults: () => void;
}) {
const set = (i: number, patch: Partial<Row>) => onChange(rows.map((r, j) => (j === i ? { ...r, ...patch } : r)));
const move = (i: number, d: number) => {
const j = i + d;
if (j < 0 || j >= rows.length) return;
const out = [...rows];
const a = out[i];
const b = out[j];
if (!a || !b) return;
out[i] = b;
out[j] = a;
onChange(out);
};
const add = () => {
const last = rows[rows.length - 1];
onChange([...rows, { group: last?.group ?? "", key: "", text: "", saved: false }]);
};
return (
<section className="panel stack" aria-labelledby={`ckl-${id}-h`}>
<div className="sec-h flush">
<h3 id={`ckl-${id}-h`}>{title}</h3>
<span className="ct">{rows.length} items</span>
<span className="grow" />
<button className="btn sm ghost" type="button" onClick={onDefaults}>
{`Use the default ${one} list`}
</button>
</div>
{rows.map((r, i) => {
const n = i + 1;
return (
<div className="frow" key={`${i}-${r.saved ? r.key : "new"}`}>
<label className="f">
{`${cap(one)} item ${n} group`}
<input className="in" value={r.group} maxLength={200} onChange={(e) => set(i, { group: e.target.value })} />
</label>
<label className="f">
{`${cap(one)} item ${n} key`}
<input
className="in mono"
value={r.key}
maxLength={30}
readOnly={r.saved}
title={r.saved ? "A saved key cannot change. Answers on open stages use it." : undefined}
onChange={(e) => set(i, { key: e.target.value })}
/>
</label>
<label className="f" style={{ gridColumn: "span 2" }}>
{`${cap(one)} item ${n} text`}
<input className="in" value={r.text} maxLength={200} onChange={(e) => set(i, { text: e.target.value })} />
</label>
<div className="end nowrap">
<button className="btn sm ghost" type="button" aria-label={`Move ${one} item ${n} up`} disabled={i === 0} onClick={() => move(i, -1)}>
Up
</button>{" "}
<button
className="btn sm ghost"
type="button"
aria-label={`Move ${one} item ${n} down`}
disabled={i === rows.length - 1}
onClick={() => move(i, 1)}
>
Down
</button>{" "}
<button className="btn sm rejb" type="button" aria-label={`Remove ${one} item ${n}`} onClick={() => onChange(rows.filter((_, j) => j !== i))}>
Remove
</button>
</div>
</div>
);
})}
<div>
<button className="btn sm" type="button" disabled={rows.length >= MAX_ITEMS} onClick={add}>
{`Add ${one} item`}
</button>
</div>
</section>
);
}
export function ChecklistsTab() {
const [load, reload] = useLoad((s) => admin9Api.checklists(s));
const [base, setBase] = useState<ChecklistsAnswer | null>(null);
const [draft, setDraft] = useState<Draft | null>(null);
const [busy, setBusy] = useState(false);
const [msg, setMsg] = useState<Msg>(null);
useEffect(() => {
if (load.state === "ok") {
setBase(load.data);
setDraft(toDraft(load.data.value));
}
}, [load]);
if (!base || !draft) return <Problem load={load} />;
const strip = (rows: Row[]): ChecklistItem[] => rows.map((r) => ({ group: r.group.trim(), key: r.key.trim(), text: r.text.trim() }));
const savedKeys = new Set([...base.value.weld, ...base.value.plain].map((r) => r.key));
const save = async () => {
const value: ChecklistLists = { weld: strip(draft.weld), plain: strip(draft.plain) };
const problem = listProblem(value.weld, "Weld list") ?? listProblem(value.plain, "Other list");
if (problem) {
setMsg({ ok: false, text: problem });
return;
}
setBusy(true);
setMsg(null);
try {
const r = await admin9Api.saveChecklists(value, base.version);
setBase(r);
setDraft(toDraft(r.value));
setMsg({ ok: true, text: "Checklists saved. New checklists use them. Open stages keep their answers." });
} catch (err) {
if (err instanceof ApiError && err.status === 409) {
setMsg({ ok: false, text: "Someone else saved the checklists first. Their version is loaded now. Make your change again." });
reload();
} else {
setMsg({ ok: false, text: errorText(err) });
}
} finally {
setBusy(false);
}
};
const defaults = (id: ListId) =>
setDraft((cur) => (cur ? { ...cur, [id]: base.defaults[id].map((it) => ({ ...it, saved: savedKeys.has(it.key) })) } : cur));
return (
<div className="stack">
<div className="small muted">
Weld departments get the first list, the others the second. Text can use {"{drawing}"}, {"{rev}"} and {"{wps}"}. A removed item drops out of new
checklists only.
</div>
{LISTS.map((l) => (
<ListEditor
key={l.id}
id={l.id}
title={l.title}
one={l.one}
rows={draft[l.id]}
onChange={(rows) => setDraft((cur) => (cur ? { ...cur, [l.id]: rows } : cur))}
onDefaults={() => defaults(l.id)}
/>
))}
<Notice msg={msg} />
<div className="chips">
<button className="btn pri" type="button" disabled={busy} onClick={() => void save()}>
Save checklists
</button>
<button
className="btn ghost"
type="button"
disabled={busy}
onClick={() => {
setDraft(toDraft(base.value));
setMsg(null);
}}
>
Discard changes
</button>
</div>
</div>
);
}