// 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; 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(); 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) => 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 (

{title}

{rows.length} items
{rows.map((r, i) => { const n = i + 1; return (
{" "} {" "}
); })}
); } export function ChecklistsTab() { const [load, reload] = useLoad((s) => admin9Api.checklists(s)); const [base, setBase] = useState(null); const [draft, setDraft] = useState(null); const [busy, setBusy] = useState(false); const [msg, setMsg] = useState(null); useEffect(() => { if (load.state === "ok") { setBase(load.data); setDraft(toDraft(load.data.value)); } }, [load]); if (!base || !draft) return ; 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 (
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.
{LISTS.map((l) => ( setDraft((cur) => (cur ? { ...cur, [l.id]: rows } : cur))} onDefaults={() => defaults(l.id)} /> ))}
); }