232 lines
8.2 KiB
TypeScript
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>
|
||
|
|
);
|
||
|
|
}
|