Files
threadcount-community/app/app/settings/page.tsx
T
ThreadCount 96d5c10537 ThreadCount Community edition
Uniform stock management for healthcare linen rooms: the coordinator app, the phone counter and the staff app, for your own server. Built from 49da3a4 on 2026-09-13. Licensed under the Functional Source License (FSL-1.1-ALv2).
2026-09-13 11:38:24 +10:00

998 lines
83 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { Suspense, useEffect, useMemo, useRef, useState } from "react";
import TwoFactor from "@/components/TwoFactor";
import SsoSettings from "@/components/SsoSettings";
import PlanTab from "@/components/PlanTab";
import { useRouter, useSearchParams } from "next/navigation";
import { useSnap } from "@/lib/client";
import { PageHead, Dialog, ErrorLine, Field, LiveRegion, Seg } from "@/components/ui";
import { parseCsv, CSV_TEMPLATES } from "@/lib/csv";
import { LOCATION_KINDS, SET_GARMENTS, allowance, allowanceRoute, csvOf, daysBetween, fmtDate, groupKey, isKitGroup, isNursingGroup, kitGroupsOf, locMap, locPath, locTree, nursingGroupsOf, setsOnStart, type AllowanceRoute, type DeptRec, type SupplierRec, type UserRec } from "@/lib/compute";
import { downloadCsv } from "@/lib/print";
// Plan is last and appears only once plans are live, for admins — see PlanTab.
const TABS = ["General", "Locations", "Departments", "Suppliers", "Account", "Sign-in", "Data", "Plan"] as const;
type Tab = (typeof TABS)[number];
// The supplier details edited on the card below, which is also the shape of the keys their
// half-typed edits are filed under in `draft`.
type SupKey = "contact" | "phone" | "account" | "lead";
// Only used by a browser too old to have Intl.supportedValuesOf: the zones an Australian facility
// is actually in, so the picker is never empty on the one machine in the room that still runs it.
const FALLBACK_ZONES = ["Australia/Brisbane", "Australia/Sydney", "Australia/Melbourne", "Australia/Hobart", "Australia/Adelaide", "Australia/Darwin", "Australia/Perth", "Australia/Broken_Hill", "Australia/Lord_Howe"];
/* The three ways a staff group gets up to the ceiling, in the order a coordinator reads them. The
names are the ones the rest of the product uses for the routes, so a coordinator who reads
"Starting kit" here reads the same words on the order form and at the counter. */
const ROUTES: { id: AllowanceRoute; label: string; now: string }[] = [
{ id: "fte", label: "FTE table", now: "on the FTE table" },
{ id: "kit", label: "Starting kit", now: "on the starting kit" },
{ id: "approval", label: "Manager approval", now: "on manager approval" },
];
// Module-scope so React keeps the same element type across renders (defining it inside the page remounts the input on every keystroke).
function TextField({ label, hint, ph, value, onChange, disabled }: { label: string; hint?: string; ph?: string; value: string; onChange: (v: string) => void; disabled: boolean }) {
return <Field label={label} hint={hint}>{(c) => <input {...c} className="input" placeholder={ph} value={value} onChange={(e) => onChange(e.target.value)} disabled={disabled} />}</Field>;
}
/* Also module-scope, and for a sharper reason than tidiness: this is a live region now, and a live
region that is torn down and rebuilt announces its contents again. Defined inside the page it
would be a fresh component type on every keystroke, so "Saved." would be read out over and over
while somebody typed in an unrelated box. */
function Msg({ text }: { text?: string }) {
return <LiveRegion msg={text} style={{ fontSize: 12, color: "var(--color-accent-700)", fontWeight: 600, marginTop: "var(--space-2)", whiteSpace: "pre-wrap" }} />;
}
// useSearchParams needs a Suspense boundary for static rendering.
export default function SettingsPage() {
return <Suspense fallback={null}><SettingsInner /></Suspense>;
}
function SettingsInner() {
const { s, isAdmin, busy, mutate } = useSnap();
const router = useRouter();
const [tab, setTab] = useState<Tab>("General");
const sp = useSearchParams();
const planShown = isAdmin && !!s.plan?.live && !s.demo;
const tabs = planShown ? TABS : TABS.filter((t) => t !== "Plan");
// Deep links: ?tab=account (sidebar name), ?tab=data (dashboard setup card), ?tab=plan (the
// plan banner); #hash forms kept for old links.
useEffect(() => {
const want = (sp.get("tab") || window.location.hash.replace("#", "")).toLowerCase();
const t = TABS.find((x) => x.toLowerCase() === want);
if (t && (t !== "Plan" || planShown)) setTab(t);
}, [sp, planShown]);
const [nl, setNl] = useState({ name: "", kind: "Shelf", parentId: "" });
const [msg, setMsg] = useState<Record<string, string>>({});
const say = (k: string, v: string) => setMsg((m) => ({ ...m, [k]: v }));
const [draft, setDraft] = useState<Record<string, string>>({});
const timers = useRef<Record<string, ReturnType<typeof setTimeout>>>({});
useEffect(() => { const t = timers.current; return () => Object.values(t).forEach(clearTimeout); }, []);
/* `draft` as it stands now, rather than as it stood in the render that set a timer. A save that
fires after a pause carries the other boxes on the row along with it, and by the time it fires
the coordinator may have typed in one of them: a ward renamed and a cost centre typed straight
after used to save the new cost centre, then put the old one back a moment later when the
rename landed — and the ward's orders went on being costed to a number nobody meant any more. */
const draftNow = useRef(draft);
useEffect(() => { draftNow.current = draft; });
/* Take a half-typed edit back out of `draft`, so the box goes back to showing what the register
holds. Used where an edit is refused: a value nobody accepted must not be left on screen, where
it reads as saved and can be picked up by whatever else on the row saves the row.
`only` is the text that was refused, and the box is left alone if it no longer says that. A
refusal from the server arrives a moment after the name went to it, and by then the coordinator
may already be typing the correction — clearing the box then takes away letters nobody has so
much as looked at, mid-word, which reads as a field that eats what you type. What is left
behind is on its way to be checked in its own right, so nothing unchecked is left standing. */
const forgetDraft = (k: string, only?: string) => setDraft((d) => { if (only !== undefined && d[k] !== only) return d; const next = { ...d }; delete next[k]; return next; });
function debounced(k: string, v: string, op: string, payload: Record<string, unknown>, msgKey = "fields") {
setDraft((d) => ({ ...d, [k]: v }));
clearTimeout(timers.current[k]);
timers.current[k] = setTimeout(async () => { const r = await mutate(op, payload); say(msgKey, r.ok ? "Saved." : r.error); }, 500);
}
const NUMERIC = ["defaultEntitlement", "initialSets", "defaultReorder", "exceptionHigh", "capSets", "varianceReason"];
const setField = (k: string, v: string) => { if (NUMERIC.includes(k) && v === "") { setDraft((d) => ({ ...d, [k]: v })); return; } debounced(k, v, "settings.update", { [k]: v }); };
const val = (k: keyof typeof s.settings) => (draft[k] !== undefined ? draft[k] : String(s.settings[k] ?? ""));
const [newGroup, setNewGroup] = useState("");
const [nd, setNd] = useState({ name: "", cc: "" });
const [ns, setNs] = useState("");
const [userDlg, setUserDlg] = useState<UserRec | null | false>(false);
const [pw, setPw] = useState({ current: "", next: "", again: "" });
const [del, setDel] = useState({ open: false, password: "", confirm: "", busy: false, err: "" });
const [me, setMe] = useState({ first: s.session.first, last: s.session.last, title: s.session.title });
const meDirty = me.first !== s.session.first || me.last !== s.session.last || me.title !== s.session.title;
const [impKind, setImpKind] = useState("catalog");
const [impBusy, setImpBusy] = useState(false);
const [wipe, setWipe] = useState("");
const [reset, setReset] = useState("");
/* The ward notice is written from here and read nowhere on this side of the product: the snapshot
carries no notice, so this box starts empty even while one is up on every wearer's home screen.
Said out loud under the field rather than left to be worked out, because an empty box meaning
"this screen can't see the board" and an empty box meaning "the board is empty" are not the
same thing to a coordinator deciding whether to post. */
const [notice, setNotice] = useState({ body: "", endsAt: "" });
const [noticeBusy, setNoticeBusy] = useState(false);
// Optimistic: the snapshot refresh lags the click, and a checkbox that snaps back reads as a failure.
const [lookupOn, setLookupOn] = useState<boolean | null>(null);
const [tzPick, setTzPick] = useState<string | null>(null); // same reason as lookupOn
/* And each group's route, for the same reason again. A route saves by sending both lists whole,
and until the refreshed snapshot lands the row still reads the old ones, so the route pressed
would spring back to the one before — read as a save that didn't take, and pressed again. Held
only while the snapshot still carries the lists it was worked out from: once those change,
whether from this save landing or from somebody else's, the snapshot is the truth again. Held
any longer, a group renamed since would still be here under its old name, and the next route
pressed would send that name back and take the renamed group off its route. */
const [routePick, setRoutePick] = useState<{ base: string; nursing: string[]; kit: string[] } | null>(null);
const [renaming, setRenaming] = useState<string | null>(null);
const [tzErr, setTzErr] = useState("");
const [bkBusy, setBkBusy] = useState(false);
const [resetBusy, setResetBusy] = useState(false);
const [logoV, setLogoV] = useState(0);
/* The zone every date-only column in the product is written against — see facilityToday. A room
left on the Brisbane default gets its evenings filed against tomorrow: a Perth issue at 22:30 on
30 June counts against the next financial year's entitlement and drops out of June's exceptions
report and cost-centre journal. The names come from this browser's zone table; settings.update
checks a submitted name against the server's, and the two are not guaranteed to be the same list
— an older Node, or a browser new enough to offer a zone the server's ICU data predates, and the
server refuses something this select happily offered. Rare, and not something the client can
check for, so the refusal is put under the select instead of being left to a message further
down the page. The current zone is prepended if this browser has never heard of it, so a
facility can always see what it is on. */
const zones = useMemo(() => {
// Optional call on purpose: TypeScript's lib says this exists, the browser in the linen room
// may disagree.
const all = Intl.supportedValuesOf?.("timeZone") || FALLBACK_ZONES;
return all.includes(s.settings.timezone) ? all : [s.settings.timezone, ...all];
}, [s.settings.timezone]);
/* The facility's own two lists and nothing else. An empty one means no group is on that route:
there is no list of ours standing in for it, so nothing on this screen may behave as if there
were. */
const storedLists = { nursing: nursingGroupsOf(s), kit: kitGroupsOf(s) };
const listSig = JSON.stringify([storedLists.nursing, storedLists.kit]);
const lists = routePick && routePick.base === listSig ? routePick : storedLists;
// The same answer the counter, the order form and the wearer's own app reach, including the FTE
// table winning for a group somehow on both lists.
const routeOf = (g: string) => allowanceRoute({ nursing: isNursingGroup(lists.nursing, g), kit: isKitGroup(lists.kit, g) });
const routeNow = (r: AllowanceRoute) => ROUTES.find((x) => x.id === r)?.now ?? "";
/* Everybody still working, counted by the group they are filed under and compared the way the app
compares group names. It is what the remove button has to warn about, and what the list of
groups nobody has added yet is built from. */
const filedUnder: Record<string, number> = {};
const spelt: Record<string, string> = {};
for (const st of s.staff) {
const k = groupKey(st.group);
if (st.inactive || !k) continue;
filedUnder[k] = (filedUnder[k] || 0) + 1;
if (!spelt[k]) spelt[k] = st.group.trim();
}
const staffCount = (g: string) => filedUnder[groupKey(g)] || 0;
/* The groups on the list, then any name still on a route that is no longer on the list — left
there by a backup restored from an older file, or by the move to three routes. The people filed
under it are still on that route, so it stays in sight to be kept or let go, rather than
deciding somebody's kit from a list nobody can see. */
const listedKeys = new Set(s.settings.staffGroups.map(groupKey));
const offList: string[] = [];
for (const g of [...lists.nursing, ...lists.kit]) {
const k = groupKey(g);
if (!listedKeys.has(k) && !offList.some((x) => groupKey(x) === k)) offList.push(g);
}
const groupRows = [...s.settings.staffGroups.map((g) => ({ g, listed: true })), ...offList.map((g) => ({ g, listed: false }))];
/* Groups people are filed under that nobody has put on this list. A staff import files people
under whatever the roster calls them and adds nothing here, so on a facility that has just
loaded its register this is every group it has — and everybody in them is on manager approval
until the group is added and given a route. Named with a button each, biggest first, rather
than left for somebody to notice. */
const rowKeys = new Set(groupRows.map((r) => groupKey(r.g)));
const unlisted = Object.keys(filedUnder).filter((k) => !rowKeys.has(k)).map((k) => ({ g: spelt[k], n: filedUnder[k] })).sort((a, b) => b.n - a.n);
/* The figures the routes are described with, read off the boxes rather than the stored values, so
the words describe what walking away from this screen now would leave in force. A box left empty
saves nothing, so the stored figure stands for it. Put through allowance() — the sum the counter
and the wearer's app do — so a starting kit typed above the ceiling is quoted at the ceiling,
which is what the counter actually hands over. */
const typedSets = (k: "initialSets" | "capSets") => { const t = val(k).trim(); return t === "" ? s.settings[k] : Number(t); };
const shape = allowance({ held: 0, kit: true, startingSets: typedSets("initialSets"), capSets: typedSets("capSets") });
const ceiling = shape.max, kitStart = shape.start ?? 0;
const kitOverCeiling = setsOnStart(typedSets("initialSets")) > ceiling;
/* Sets and garments together, because they are one kit counted two ways and the argument at the
counter is always about garments. SET_GARMENTS rather than a bare 2: a set is a top and a bottom
everywhere in the product, and this is not the place to re-decide it. */
const sets = (n: number) => `${n} set${n === 1 ? "" : "s"} (${n * SET_GARMENTS} garments)`;
const routeSays: Record<AllowanceRoute, string> = {
fte: "First kit proposed from each person's hours; a manager can sign for more.",
kit: `${sets(kitStart)} on the first day, then more as needed.`,
approval: "Nothing on the first day; a manager approves each set.",
};
async function signOut() { await fetch("/api/auth/logout", { method: "POST" }); router.push("/auth"); router.refresh(); }
/* One change to the groups at a time. Each of these sends whole lists worked out from what is on
screen, so a second one sent before the first is back in the snapshot is worked out from the
lists as they were: an add straight after a rename would send the old name back, and the renamed
group would come off its route with it. `busy` covers the save and the refresh behind it, which
is well under a second. Refused out loud rather than by greying the buttons, because a button
switched off under the finger drops keyboard focus on the floor. */
const settled = () => { if (!busy) return true; say("groups", "Still saving the last change — try again in a moment."); return false; };
// Editing the group list, a ward or a supplier used to fire and forget: a refusal (the last group,
// a name already taken, a lost connection) left the chip sitting where it was with nothing said,
// and the admin clicked again. `name` is for the buttons that add a group somebody is already
// filed under; the box below the list sends nothing and is cleared once its group is in.
async function addGroup(name?: string) {
const g = (name ?? newGroup).trim();
if (!g || !settled()) return;
const route = routeOf(g);
const r = await mutate("settings.update", { staffGroups: [...s.settings.staffGroups, g] });
say("groups", r.ok ? `${g} added, ${routeNow(route)}${route === "approval" ? " until you choose another route" : ""}.` : r.error);
if (r.ok && name === undefined) setNewGroup("");
}
async function removeGroup(g: string) {
if (!settled()) return;
const n = staffCount(g), route = routeOf(g);
const who = `${n} staff member${n === 1 ? " is" : "s are"} filed under ${g}`;
/* Asked before, not reported after. Taking a group off the list takes it off its route too, and
everybody still filed under it goes onto manager approval — a coordinator tidying up a list is
owed that before a team's first kit goes, not in a message once it has. */
if (n && !confirm(route === "approval"
? `${who}. They stay filed under it, still on manager approval, but nobody new can be put in ${g}. Take it off the list?`
: `${who}, which is ${routeNow(route)}. Taking it off the list puts them on manager approval — move them to another group first to keep their route.\n\nTake ${g} off the list?`)) return;
const r = await mutate("settings.update", { staffGroups: s.settings.staffGroups.filter((x) => x !== g) });
say("groups", !r.ok ? r.error
: n ? `${g} removed. The ${n} staff member${n === 1 ? " filed under it is" : "s filed under it are"} on manager approval, and ${g} is listed below to add back.`
: `${g} removed.`);
}
// Both lists in one save, because moving a group is taking it off one route and putting it on
// another, and the server refuses any save that would leave it on two.
async function setRoute(g: string, to: AllowanceRoute) {
const from = routeOf(g);
if (from === to || !settled()) return;
const k = groupKey(g);
const nursing = lists.nursing.filter((x) => groupKey(x) !== k);
/* A group caught on both lists is on the FTE table already — allowanceRoute() says so — so
taking it off the kit list here changes nobody's route. What it does is let the save through:
the server refuses any save that leaves a group on both, whichever group the click was about. */
const kit = lists.kit.filter((x) => groupKey(x) !== k && !isNursingGroup(nursing, x));
if (to === "fte") nursing.push(g);
if (to === "kit") kit.push(g);
setRoutePick({ base: listSig, nursing, kit });
const r = await mutate("settings.update", { nursingGroups: nursing, kitGroups: kit });
// Put the row back where the server still has it, or the screen would go on claiming a change
// that was refused.
if (!r.ok) { setRoutePick(null); say("groups", r.error); return; }
say("groups", `${g} is ${routeNow(to)}. ${routeSays[to]}`);
}
/* The one message the linen room can put in front of everybody at once. It is not mail and it is
not a request: it is the board on the wall, and the ward reads it on the home screen of their
own app. An empty message takes the board down — the linen room's way of saying that's over. */
async function postNotice() {
const body = notice.body.trim(), endsAt = notice.endsAt.trim();
/* A day already gone is a notice nobody will ever see: the staff app only shows one whose end
date is today or later. Caught here, because the server takes the date happily and the first
anyone would know of it is that the ward never mentioned the thing they were told. */
if (body && endsAt && endsAt < s.today) { say("notice", `${fmtDate(endsAt)} has already gone, so nobody would see this. Pick today or later, or leave the date blank.`); return; }
setNoticeBusy(true);
const r = await mutate<{ cleared: boolean }>("notice.set", { body, endsAt });
setNoticeBusy(false);
if (!r.ok) { say("notice", r.error); return; }
say("notice", r.result.cleared
? "The board is clear. Nothing shows on anybody's home screen now."
: `Posted. Every staff member who has set up the app sees this on their home screen${endsAt ? `, up to and including ${fmtDate(endsAt)}` : ", until it is taken down"}.`);
}
/* Renaming a ward is safe to offer because dept.save carries the old name forward in the same
transaction: every staff record filed under it and every order costed to it moves with it, so
nothing is left pointing at a name that has gone.
A rename that is not going to happen has to leave the row showing the ward the register still
has. It used to leave the rejected text sitting in the box, which is worse than not checking at
all: the coordinator walks away reading a ward name that exists nowhere, and the cost centre box
beside it saves the whole row — so the next cost centre typed on that row was the thing that
finally saved the name nobody accepted. Every ending here either saves or puts the name back,
and says which — and either way it says so, because a name that was refused was refused whether
or not a better one is already being typed over it. */
function renameDept(d: DeptRec, v: string) {
const k = "deptname:" + d.id;
setDraft((x) => ({ ...x, [k]: v }));
clearTimeout(timers.current[k]);
/* Checked when the typing stops rather than on every keystroke, because half a ward's name on
the way to a whole one is not a refusal — clearing the box under somebody mid-word would make
the field unusable. The pause is the same one that commits the save. */
timers.current[k] = setTimeout(async () => {
const name = v.trim();
const no = deptNameRefusal(d, name);
if (no) { forgetDraft(k, v); say("depts", no); return; }
const r = await mutate("dept.save", { id: d.id, name, cc: deptCc(d, draftNow.current).trim() });
if (!r.ok) { forgetDraft(k, v); say("depts", r.error); return; }
say("depts", `Renamed to ${name}. Everyone filed under ${d.name}, and every order costed to it, moved with it.`);
}, 600);
}
/* The catch is the whole point of parseCsv refusing a malformed file. It throws to stop a
half-import, and without somewhere to land that refusal was an unhandled rejection: the admin
saw "Importing…" sit there forever and went looking for the staff it never loaded. Whatever
parseCsv says — which line, which quote — is what the admin needs on screen to fix the file. */
async function importFile(file: File) {
setImpBusy(true); say("import", "Importing…");
try {
const rows = parseCsv(await file.text());
if (!rows.length) { say("import", "No rows found — check the header row."); return; }
const r = await mutate<{ created: number; updated: number; skipped: number; styles?: number; errors: string[] }>("import.rows", { kind: impKind, rows });
if (!r.ok) { say("import", r.error); return; }
const x = r.result;
say("import", `${CSV_TEMPLATES[impKind].name}: ${x.created} created, ${x.updated} updated, ${x.skipped} skipped.${x.styles ? ` ${x.styles} uniform ${x.styles === 1 ? "style" : "styles"} set.` : ""}` + (x.errors.length ? "\n" + x.errors.join("\n") : ""));
}
catch (e) { say("import", (e as Error)?.message || "That file couldnt be read as a CSV. Nothing was imported."); }
finally { setImpBusy(false); }
}
async function restore(file: File) {
if (!confirm("Restore this backup? It replaces ALL data in this facility (catalogue, staff, orders, issues, stock, approvals). Users are kept.")) return;
say("backup", "Restoring…");
try {
const data = JSON.parse(await file.text());
const r = await mutate<{ photosSkipped: number }>("backup.restore", data);
if (!r.ok) { say("backup", r.error); return; }
// A restore takes back a capped number of photos and drops the rest rather than refusing the
// whole file. Said out loud, because the alternative is a room believing every signature and
// damage photo is back on the record when some of them only exist in the file.
const skipped = r.result?.photosSkipped || 0;
say("backup", skipped ? `Backup restored — every record came back, but ${skipped} photo${skipped === 1 ? "" : "s"} in the file did not. Keep the backup file: those images are only in it now.` : "Backup restored.");
}
catch (e) { say("backup", "Import failed — " + (e as Error).message); }
}
/* Fetched rather than a plain <a href="/api/backup">, because a browser downloading a file never
shows the page its contents: the export trims the oldest photos to keep the file inside what a
restore will take back, counts them in `photosOmitted`, and until this ran through fetch nobody
was ever told. A room finds out otherwise only on the day it restores. */
async function exportBackup() {
setBkBusy(true); say("backup", "Preparing the backup…");
try {
const res = await fetch("/api/backup");
/* Every other write on this page goes through mutate, which sends a dead session back to the
sign-in door; this one fetch was outside that and would have handed the admin a signed-out
error page saved as threadcount-backup.json — a file that looks like a backup and restores
nothing. Same destination as mutate's, carrying where they were so they land back here. */
if (res.status === 401) { window.location.assign(`/auth?next=${encodeURIComponent(location.pathname + location.search)}`); return; }
if (!res.ok) { say("backup", ((await res.json().catch(() => ({}))) as { error?: string }).error || "Export failed — nothing was downloaded."); return; }
const text = await res.text();
// Read out of the text rather than JSON.parse: the file carries every photo that travelled and
// can run to tens of megabytes, and parsing it a second time on a linen-room PC to learn one
// number is not worth the memory.
const omitted = Number(/"photosOmitted":\s*(\d+)/.exec(text)?.[1] || 0);
const name = /filename="([^"]+)"/.exec(res.headers.get("content-disposition") || "")?.[1] || "threadcount-backup.json";
const url = URL.createObjectURL(new Blob([text], { type: "application/json" }));
const a = document.createElement("a"); a.href = url; a.download = name; a.click();
setTimeout(() => URL.revokeObjectURL(url), 60_000);
say("backup", omitted
? `${name} downloaded. ${omitted} older photo${omitted === 1 ? " was" : "s were"} left out so the file stays small enough to restore — every record is in it, and the images stay on the server.`
: `${name} downloaded.`);
router.refresh();
} catch (e) { say("backup", "Export failed — " + (e as Error).message); }
finally { setBkBusy(false); }
}
function template(kind: string) {
const t = CSV_TEMPLATES[kind];
const a = document.createElement("a"); a.href = "data:text/csv;charset=utf-8," + encodeURIComponent(t.headers + "\n" + t.example + "\n"); a.download = `threadcount-${kind}-template.csv`; a.click();
}
function uploadLogo(file: File) {
if (file.size > 400 * 1024) { say("logo", "Logo must be under 400 KB."); return; }
const r = new FileReader();
r.onload = async () => { const res = await mutate("settings.update", { logoData: String(r.result) }); setLogoV((v) => v + 1); say("logo", res.ok ? "Logo saved — it prints top-right on slips." : res.error); };
r.readAsDataURL(file);
}
// How many sizes sit on each location, so an empty shelf is obvious before it is deleted.
const locCounts: Record<string, number> = {};
for (const k in s.placed) locCounts[s.placed[k]] = (locCounts[s.placed[k]] || 0) + 1;
const H = ({ children, top = 6 }: { children: React.ReactNode; top?: number }) => <div className="sec" style={{ marginTop: `var(--space-${top})` }}>{children}</div>;
const Note = ({ children }: { children: React.ReactNode }) => <div style={{ fontSize: 12, color: "var(--color-neutral-700)", marginTop: "var(--space-2)", lineHeight: 1.6 }}>{children}</div>;
/* Not a component defined in here. React compares element types by identity, so a helper declared
inside the render is a brand-new type on every keystroke: the whole field is torn down and
rebuilt, and the caret goes with it. TextField sits at module scope and is handed everything it
needs, which is why F is a plain function returning an element rather than <F />. */
const F = (k: keyof typeof s.settings, label: string, opts: { ph?: string; hint?: string; numeric?: boolean; demoFixed?: boolean } = {}) =>
<TextField label={label} hint={opts.hint} ph={opts.ph} value={val(k)} disabled={!isAdmin || (!!opts.demoFixed && !!s.demo)} onChange={(v) => setField(k, opts.numeric ? v.replace(/[^0-9]/g, "") : v)} />;
const grid: React.CSSProperties = { display: "grid", gridTemplateColumns: "1fr 1fr", gap: "var(--space-4)", marginTop: "var(--space-3)" };
const deptStaff: Record<string, number> = {}; for (const st of s.staff) deptStaff[st.dept] = (deptStaff[st.dept] || 0) + 1;
/* One reading of a field, used both by the input that edits it and by the export that writes it,
so the two cannot drift apart. debounced() holds a keystroke in `draft` for half a second before
it reaches the server, and `draft` is what the coordinator can see in the box — so `draft` is
what the file has to say. Overlaying it here rather than flushing the pending saves first is
deliberate: pressing Export must not write to the register (a half-typed cost centre would be
committed early), it must not wait on the network to hand over a file, and a save the server
refuses leaves the typed value on screen anyway — only the overlay still matches it. The ward's
own name is the exception, and exportDepts says why. A save that goes out after a pause passes
draftNow instead, for the reason given where that is kept. */
const deptCc = (d: DeptRec, from = draft) => from["dept:" + d.id] ?? d.cc;
/* The same overlay for the ward's own name, and for a second reason on top of the export's: the
cost centre box beside it saves the whole row, name included, so without this a cost centre
typed while a rename was still settling would quietly put the old name back. */
const deptName = (d: DeptRec) => draft["deptname:" + d.id] ?? d.name;
/* What is wrong with a ward name, in the words the coordinator needs, or nothing if it is fine.
One reading of it, because two boxes on the row both save the row — the name and the cost centre
— and if they disagreed about what counts as a name, the cost centre box would be the way a
rejected name got saved anyway. The register itself refuses both of these; asked here as well so
the answer arrives while the coordinator is still looking at the row they typed it on. */
function deptNameRefusal(d: DeptRec, name: string) {
if (!name) return `A ward needs a name — ${d.name} hasnt been changed.`;
const clash = s.depts.find((o) => o.id !== d.id && o.name.trim().toLowerCase() === name.toLowerCase());
return clash ? `${clash.name} is already on the list, and two wards with one name cant be told apart on a staff record or a journal line.` : "";
}
/* The name this row would be saved under: what has been typed, unless it is a name the register
would refuse, in which case the ward keeps the one it has. Typing a cost centre must never be
the thing that commits a rename. It is for the save alone — what the file hands to finance is
the name the register actually holds, see exportDepts. */
const deptSaveName = (d: DeptRec) => { const n = deptName(d).trim(); return deptNameRefusal(d, n) ? d.name : n; };
const supField = (sup: SupplierRec, k: SupKey) => draft[`sup:${sup.id}:${k}`] ?? (sup[k] === null ? "" : String(sup[k]));
/* The three registers on this page are the ones a coordinator is most often asked to hand over —
the shelf map before a stocktake, the ward list for finance, the supplier list for procurement —
and until now the only way out of any of them was to retype what was on the screen. Each tab
exports its own register and nothing else: somebody on Suppliers pressing Export means suppliers. */
function exportLocations() {
/* Nothing to overlay here, unlike the two below: the only editable thing on this tab is the
Inside select, and that is saved the moment it changes rather than held in `draft`. */
const byId = locMap(s);
/* A tree flattened into rows loses the thing that made it a tree, and "Bay B3" on its own is no
use to anybody walking the room — there is a B3 on every shelf. So each row carries its full
path as well as its own name, built with the helper the rest of the app renders a location
with, and a spreadsheet sorted any which way still reads Linen Room · Shelf B · Bay B3. */
const rows = locTree(s, true).map(({ loc }) => [loc.name, loc.kind, loc.parentId ? byId[loc.parentId]?.name ?? "" : "", locPath(byId, loc.id).map((l) => l.name).join(" · "), locCounts[loc.id] || 0]);
downloadCsv(`threadcount-locations-${s.today}.csv`, csvOf(["Location", "Kind", "Inside", "Full path", "Sizes"], rows));
}
/* dept and cc are the import template's own headers, not prettier ones that happen to normalise
onto them, so what comes out of here is exactly what the importer expects back: a coordinator
can export the wards, fix twenty cost centres in a spreadsheet and import the same file under
Data without touching the header row. The staff count is ours to be useful — the importer has
no alias for it, so it is ignored on the way back in and cannot create a ward of its own. */
function exportDepts() {
/* The ward's name as the register holds it, not as the box reads it. A rename is half a second
behind the typing and the server can still turn it down after that, so a name in the box is
not yet a ward. This file goes to finance and comes back in through Data, where a name that
never landed arrives as a ward of its own: the staff stay on the old one, the new cost centre
goes on the new one, and the ward is in two halves. The cost centre beside it is the typed
one on purpose — a code is typed into a ward that already exists, so the worst an unsaved one
does is carry a correction to finance a moment early.
Trimmed the way dept.save trims, so a code typed with a stray trailing space — invisible in
the box — reaches finance in the form the register will actually hold. */
downloadCsv(`threadcount-departments-${s.today}.csv`, csvOf(["dept", "cc", "staff"], s.depts.map((d) => [d.name, deptCc(d).trim(), deptStaff[d.name] || 0])));
}
// Everything procurement rings a supplier about: who to ask for, on which account, and how long
// they take — lead time being what dates the delivery on a new order. The product and order counts
// are the screen's own, and they say which of these names anybody is actually buying from.
function exportSuppliers() {
const rows = s.supplierDir.map((sup) => [sup.name, supField(sup, "contact"), supField(sup, "phone"), supField(sup, "account"), supField(sup, "lead"), s.catalog.filter((it) => it.supplier === sup.name).length, s.orders.filter((o) => o.supplier === sup.name).length]);
downloadCsv(`threadcount-suppliers-${s.today}.csv`, csvOf(["Supplier", "Contact", "Phone", "Account no.", "Lead time (days)", "Products", "Orders"], rows));
}
const lastBk = s.settings.lastBackup; const bkDays = lastBk ? daysBetween(lastBk, s.today) : null;
/* A week is the line. Past it the facility is one failed disk away from retyping its register by
hand, which is the only thing on this page worth interrupting somebody over. */
const bkStale = !lastBk || (bkDays ?? 0) > 7;
return (
/* The ink band runs the full width of the content column, so the reading measure is set on the
form underneath it rather than on the section. Set here, the head would bleed out to the left
gutter and stop dead at 760px on the right. */
<section>
<PageHead eyebrow="Admin" title="Settings" />
<div style={{ maxWidth: 760 }}>
{/* Seg rather than a hand-rolled strip, for what Seg carries: aria-pressed. Which tab you are
on used to be a fill colour and nothing else, so a coordinator on a screen reader heard six
identical buttons, and tapping one announced no change at all. */}
<span role="group" aria-label="Settings sections"><Seg opts={tabs} value={tab} onChange={setTab} style={{ flexWrap: "wrap", marginTop: "var(--space-4)" }} /></span>
{tab === "General" && (
<>
<H>Facility</H>
<div className="tc-grid" style={grid}>
{F("facility", "Facility")}{F("location", "Stock location")}{F("coordinator", "Coordinator name")}
{/* Fixed in the demo for the same reason the coordinator's name is, with more riding on
it: everyone shares that one facility, so an address or number typed here prints in
the foot of the order form in front of every other visitor — and it would be a real
person's address and a real phone. */}
{F("coordinatorEmail", "Coordinator e-mail", { ph: "e.g. uniforms@yourhospital.org.au", demoFixed: true })}
{F("coordinatorPhone", "Coordinator phone", { ph: "e.g. 07 3xxx xxxx", demoFixed: true })}
{/* Off in the demo for the same reason settings.update refuses the facility name and the
slip footers there: everyone shares that one facility, so a visitor setting it to
Honolulu re-dates the dashboard, the exceptions report and the journal for every
other visitor — the director of nursing and the Play reviewer included. */}
<Field label="Time zone" hint="Where the linen room actually is." error={tzErr || undefined}>{(c) => (
<select {...c} className="input" value={tzPick ?? s.settings.timezone} disabled={!isAdmin || !!s.demo}
onChange={async (e) => {
const z = e.target.value; setTzPick(z); setTzErr("");
const r = await mutate("settings.update", { timezone: z });
// The refusal belongs here, next to the select that snaps back, not in the shared
// message line under Staff groups two screens further down where it reads as
// nothing having happened at all.
if (!r.ok) { setTzPick(null); setTzErr(r.error); return; }
say("fields", `Dates now follow ${z} time.`);
}}>
{zones.map((z) => <option key={z} value={z}>{z}</option>)}
</select>
)}</Field>
</div>
<Note>These print on slips, purchase orders, reports and the uniform order form.</Note>
<H>Issuing &amp; stock</H>
<div className="tc-grid" style={grid}>{F("capSets", "Ceiling, every group (sets)", { numeric: true, ph: "e.g. 6", hint: "Held at any time — not a yearly allowance." })}{F("initialSets", "Starting kit (sets)", { numeric: true, ph: "e.g. 3", hint: "First day, Starting kit route only." })}{F("defaultEntitlement", "Yearly figure for reports (garments)", { numeric: true })}{F("defaultReorder", "Default reorder level", { numeric: true, hint: "For sizes without their own." })}{F("exceptionHigh", "Exception threshold (items/month)", { numeric: true })}</div>
{kitOverCeiling && <Note>Nobody is handed more than the ceiling, so the starting kit stops at {sets(kitStart)}.</Note>}
{/* Set on the counter phone and nowhere else until now, which meant the one number the
desktop stock take enforces could only be changed by somebody holding the phone —
and on a facility whose phones are all issued out, not at all. */}
<H>Stock takes</H>
<div className="tc-grid" style={grid}>{F("varianceReason", "Reason required at (garments)", { numeric: true, ph: "e.g. 5", hint: "Over or short, here and on the counter phone." })}</div>
<H>Finance &amp; journal</H>
<div className="tc-grid" style={grid}>{F("glAccount", "GL account", { ph: "e.g. 631020" })}{F("journalDesc", "Journal description prefix", { ph: "e.g. Uniform issues" })}</div>
<Note>Used by the Reports journal export and month-end pack.</Note>
<H>Slips &amp; logo</H>
<div className="tc-grid" style={grid}>
{F("slipOrg", "Organisation name on slips", { ph: "Printed when there is no logo" })}
{/* Not a Field: this cell holds a preview, a file picker and a remove button, and a single
<label> cannot name three controls. A named group is the honest markup. */}
<div className="field" role="group" aria-label="Logo (top-right on slips)">
<span aria-hidden="true" style={{ fontSize: 11, fontWeight: 600, letterSpacing: "0.08em", textTransform: "uppercase", color: "var(--color-neutral-700)" }}>Logo (top-right on slips)</span>
<div style={{ display: "flex", gap: "var(--space-2)", alignItems: "center", flexWrap: "wrap" }}>
{s.settings.hasLogo && <img src={`/api/logo?v=${logoV}`} alt="The logo currently printed on slips" style={{ height: 34, maxWidth: 140, objectFit: "contain", border: "1px solid var(--color-divider)", background: "#fff", padding: 2 }} />}
{isAdmin && <label className="btn btn-secondary" style={{ cursor: "pointer" }}>{s.settings.hasLogo ? "Replace" : "Upload"}<input type="file" accept="image/png,image/jpeg,image/webp,image/gif" aria-label={s.settings.hasLogo ? "Replace the slip logo" : "Upload a slip logo"} style={{ display: "none" }} onChange={(e) => { const f = e.target.files?.[0]; if (f) uploadLogo(f); e.target.value = ""; }} /></label>}
{isAdmin && s.settings.hasLogo && <button className="btn btn-ghost" aria-label="Remove the slip logo" onClick={async () => { const r = await mutate("settings.update", { logoData: "" }); say("logo", r.ok ? "Logo removed." : r.error); }}>Remove</button>}
</div>
</div>
<Field label="Collection slip footer" style={{ gridColumn: "1 / -1" }}>{(c) => <input {...c} className="input" value={val("slipCollectionFooter")} onChange={(e) => setField("slipCollectionFooter", e.target.value)} disabled={!isAdmin} />}</Field>
<Field label="Delivery slip footer" style={{ gridColumn: "1 / -1" }}>{(c) => <input {...c} className="input" value={val("slipDeliveryFooter")} onChange={(e) => setField("slipDeliveryFooter", e.target.value)} disabled={!isAdmin} />}</Field>
</div>
<Msg text={msg.logo} />
<H>Staff groups</H>
<Note>Each group takes one route; every route stops at the ceiling of {sets(ceiling)} held.</Note>
{/* One sentence per route, read once here rather than repeated down every row, and written
with this facility's own figures — the ones the counter and the wearer's app quote. */}
<div style={{ fontSize: 12, color: "var(--color-neutral-700)", marginTop: "var(--space-2)", lineHeight: 1.6 }}>
{ROUTES.map((r) => <div key={r.id}><b style={{ color: "var(--color-text)" }}>{r.label}.</b> {routeSays[r.id]}</div>)}
</div>
{/* Where every new facility starts: it names its own groups, and there is no list of ours to
stand in for them. Until it does, the only route anybody is on is manager approval, and
that is said here rather than left as an empty space to be puzzled over. */}
{!groupRows.length && (
<div className="tc-flag" style={{ fontSize: 13, marginTop: "var(--space-4)", paddingLeft: "var(--space-3)", lineHeight: 1.6 }}>
<span className="tc-mark" aria-hidden="true" />
<b>No staff groups yet</b>, so everybody is on manager approval{isAdmin ? " — add your groups below." : "."}
</div>
)}
{!!groupRows.length && (
<div style={{ marginTop: "var(--space-3)", borderTop: "1px solid var(--color-divider)" }}>
{groupRows.map(({ g, listed }) => {
const route = routeOf(g), n = staffCount(g);
return (
<div key={g} style={{ display: "flex", flexWrap: "wrap", alignItems: "center", gap: "var(--space-2) var(--space-3)", padding: "var(--space-2) 0", borderBottom: "1px solid var(--color-divider)", fontSize: 13 }}>
<div style={{ flex: "1 1 160px", minWidth: 0 }}>
<b>{g}</b>
<div style={{ fontSize: 12, color: "var(--color-neutral-700)" }}>{n ? `${n} staff member${n === 1 ? "" : "s"}` : "Nobody filed under it"}{listed ? "" : " · not on the list"}</div>
</div>
{/* Seg's markup rather than Seg itself, because Seg's buttons can't be switched off
for an issuer, who may read the routes but not change them. One pressed button
out of three is also what makes a group on two routes impossible to ask for. */}
<div className="seg" role="group" aria-label={`Route for ${g}`}>
{ROUTES.map((r) => <button key={r.id} className={"seg-opt" + (route === r.id ? " btn-primary" : "")} aria-pressed={route === r.id} disabled={!isAdmin} onClick={() => setRoute(g, r.id)}>{r.label}</button>)}
</div>
{isAdmin && (
<div style={{ display: "flex", gap: "var(--space-1)", alignItems: "center" }}>
{listed
? <button className="btn btn-ghost" aria-label={`Rename the ${g} staff group`} onClick={() => { if (settled()) setRenaming(g); }}>Rename</button>
: <button className="btn btn-ghost" aria-label={`Add to list — ${g}`} onClick={() => addGroup(g)}>Add to list</button>}
{/* A bare "×" announces as "times, button" and names nothing, so a screen-reader
user had no way to tell which group they were about to delete. */}
{listed && <button className="btn btn-ghost btn-icon" style={{ fontSize: 13 }} aria-label={`Remove the ${g} staff group`} onClick={() => removeGroup(g)}>×</button>}
</div>
)}
</div>
);
})}
</div>
)}
{isAdmin && <div style={{ display: "flex", gap: "var(--space-2)", flexWrap: "wrap", marginTop: "var(--space-3)" }}><input className="input" style={{ minHeight: 30, padding: "2px 8px", width: 200 }} aria-label="New staff group" placeholder="New staff group" value={newGroup} onChange={(e) => setNewGroup(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter" && newGroup.trim()) addGroup(); }} /><button className="btn btn-secondary" style={{ minHeight: 30 }} disabled={!newGroup.trim()} onClick={() => addGroup()}>Add</button></div>}
{!!offList.length && <Note>Groups not on the list keep their route but take nobody new add one back to keep it.</Note>}
{!!unlisted.length && (
<div style={{ marginTop: "var(--space-4)" }}>
<Note>{unlisted.length === 1 ? "This group is" : "These groups are"} on the staff register but not on this list, so {unlisted.length === 1 ? "its" : "their"} staff are on manager approval until added{isAdmin ? "" : " by an admin"}.</Note>
<div style={{ display: "flex", gap: "var(--space-2)", flexWrap: "wrap", marginTop: "var(--space-2)" }}>
{unlisted.map(({ g, n }) => isAdmin
? <button key={g} className="btn btn-secondary" style={{ minHeight: 30 }} aria-label={`Add ${g}${n} staff member${n === 1 ? " is" : "s are"} filed under it`} onClick={() => addGroup(g)}>Add {g} · {n}</button>
: <span key={g} className="tag tag-outline" style={{ fontSize: 12, textTransform: "none", letterSpacing: 0 }}>{g} · {n}</span>)}
</div>
</div>
)}
<Msg text={msg.groups} />
<Msg text={msg.fields} />
{isAdmin && (
<>
<H>Ward notice</H>
<Note>Shown on the home screen of the staff app.</Note>
<Field label="Message" hint="Replaces the current notice, which this box doesnt show." style={{ marginTop: "var(--space-3)" }}>{(c) => <textarea {...c} className="input" rows={3} maxLength={400} style={{ width: "100%" }} placeholder="e.g. The linen room is closed this Friday — collections move to Thursday." value={notice.body} onChange={(e) => setNotice({ ...notice, body: e.target.value })} />}</Field>
<div className="tc-grid" style={grid}>
<Field label="Last day shown" hint="Optional — left blank, it stays up until taken down.">{(c) => <input {...c} className="input" type="date" min={s.today} value={notice.endsAt} onChange={(e) => setNotice({ ...notice, endsAt: e.target.value })} />}</Field>
</div>
{/* The label is what pressing it does, rather than one word that means two opposite
things depending on whether the box above happens to be empty. */}
<div style={{ display: "flex", gap: "var(--space-2)", marginTop: "var(--space-3)", flexWrap: "wrap" }}>
<button className="btn btn-secondary" disabled={noticeBusy} onClick={postNotice}>{noticeBusy ? "Saving…" : notice.body.trim() ? "Post this notice" : "Take the notice down"}</button>
</div>
<Msg text={msg.notice} />
</>
)}
</>
)}
{tab === "Locations" && (
<>
<H>Where garments live</H>
<Note>Rooms hold shelves, shelves hold bays. Put a size on a shelf from Inventory.</Note>
<div style={{ display: "flex", justifyContent: "flex-end", marginTop: "var(--space-3)" }}>
<button className="btn btn-ghost" onClick={exportLocations} disabled={s.locations.length === 0}>Export CSV</button>
</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 130px 1fr 90px 32px", gap: "var(--space-2)", padding: "var(--space-3) 0 var(--space-1)", fontSize: 11, letterSpacing: "0.06em", textTransform: "uppercase", color: "var(--color-neutral-600)", fontWeight: 600 }}><div>Location</div><div>Kind</div><div>Inside</div><div style={{ textAlign: "right" }}>Sizes</div><div></div></div>
{locTree(s, true).map(({ loc, depth }) => (
<div key={loc.id} style={{ display: "grid", gridTemplateColumns: "1fr 130px 1fr 90px 32px", gap: "var(--space-2)", alignItems: "center", padding: "var(--space-1) 0", borderBottom: "1px solid var(--color-divider)", fontSize: 13 }}>
<div style={{ fontWeight: 600, paddingLeft: depth * 16 }}>{loc.name}</div>
<div style={{ color: "var(--color-neutral-700)" }}>{loc.kind}</div>
<select className="input" style={{ minHeight: 28, padding: "2px 6px" }} aria-label={`What ${loc.name} sits inside`} value={loc.parentId || ""} disabled={!isAdmin}
onChange={async (e) => { const r = await mutate("location.save", { id: loc.id, name: loc.name, kind: loc.kind, parentId: e.target.value }); say("locs", r.ok ? "Moved." : r.error); }}>
<option value=""> top level </option>
{locTree(s, true).filter(({ loc: o }) => o.id !== loc.id).map(({ loc: o, depth: d }) => <option key={o.id} value={o.id}>{"\u00a0".repeat(d * 2)}{o.name}</option>)}
</select>
<div style={{ textAlign: "right", color: "var(--color-neutral-700)" }}>{locCounts[loc.id] || 0}</div>
{isAdmin ? <button className="btn btn-ghost btn-icon" title="Remove — anything on it becomes unplaced" aria-label={`Remove ${loc.name} — anything on it becomes unplaced`} onClick={async () => { const r = await mutate("location.delete", { id: loc.id }); say("locs", r.ok ? "Removed." : r.error); }}>×</button> : <span />}
</div>
))}
{s.locations.length === 0 && <Note>No locations yet. Add the first shelf below.</Note>}
{isAdmin && (
<div style={{ display: "flex", gap: "var(--space-2)", marginTop: "var(--space-3)", flexWrap: "wrap", alignItems: "flex-end" }}>
<Field label="New location" style={{ flex: 1, minWidth: 160 }}>{(c) => <input {...c} className="input" value={nl.name} onChange={(e) => setNl({ ...nl, name: e.target.value })} placeholder="e.g. Shelf B" />}</Field>
<Field label="Kind" style={{ width: 130 }}>{(c) => <select {...c} className="input" value={nl.kind} onChange={(e) => setNl({ ...nl, kind: e.target.value })}>{LOCATION_KINDS.map((k) => <option key={k}>{k}</option>)}</select>}</Field>
<Field label="Inside" style={{ width: 200 }}>{(c) => <select {...c} className="input" value={nl.parentId} onChange={(e) => setNl({ ...nl, parentId: e.target.value })}><option value=""> top level </option>{locTree(s, true).map(({ loc: o, depth: d }) => <option key={o.id} value={o.id}>{"\u00a0".repeat(d * 2)}{o.name}</option>)}</select>}</Field>
<button className="btn btn-secondary" disabled={!nl.name.trim()} onClick={async () => { const r = await mutate("location.save", nl); say("locs", r.ok ? "Added." : r.error); if (r.ok) setNl({ name: "", kind: nl.kind, parentId: nl.parentId }); }}>Add</button>
</div>
)}
<Msg text={msg.locs} />
</>
)}
{tab === "Departments" && (
<>
<H>Departments &amp; cost centres</H>
<div style={{ display: "flex", justifyContent: "flex-end", marginTop: "var(--space-3)" }}>
<button className="btn btn-ghost" onClick={exportDepts} disabled={s.depts.length === 0}>Export CSV</button>
</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 130px 90px 32px", gap: "var(--space-2)", padding: "var(--space-2) 0 var(--space-1)", fontSize: 11, letterSpacing: "0.06em", textTransform: "uppercase", color: "var(--color-neutral-600)", fontWeight: 600 }}><div>Department / ward</div><div>Cost centre</div><div style={{ textAlign: "right" }}>Staff</div><div></div></div>
{s.depts.map((d) => (
<div key={d.id} style={{ display: "grid", gridTemplateColumns: "1fr 130px 90px 32px", gap: "var(--space-2)", alignItems: "center", padding: "var(--space-1) 0", borderBottom: "1px solid var(--color-divider)", fontSize: 13 }}>
<input className="input" style={{ minHeight: 28, padding: "2px 8px", fontWeight: 600 }} aria-label={`Name of ${d.name}`} value={deptName(d)} onChange={(e) => renameDept(d, e.target.value)} disabled={!isAdmin} />
{/* Sends the name that is on screen, not the one the server still holds: this save
writes the whole row, so during the second a rename is settling it would otherwise
undo it. */}
<input className="input" style={{ minHeight: 28, padding: "2px 8px" }} aria-label={`Cost centre for ${d.name}`} value={deptCc(d)} onChange={(e) => debounced("dept:" + d.id, e.target.value, "dept.save", { id: d.id, name: deptSaveName(d), cc: e.target.value.trim() }, "depts")} disabled={!isAdmin} />
<div style={{ textAlign: "right", color: "var(--color-neutral-700)" }}>{deptStaff[d.name] || 0}</div>
{isAdmin && !(deptStaff[d.name] || 0) ? <button className="btn btn-ghost btn-icon" title="Remove — no staff assigned" aria-label={`Remove ${d.name} — no staff assigned`} onClick={async () => { const r = await mutate("dept.delete", { id: d.id }); say("depts", r.ok ? `${d.name} removed.` : r.error); }}>×</button> : <span />}
</div>
))}
{s.depts.length === 0 && <Note>No departments yet. Add wards below or import them in Data.</Note>}
{isAdmin && (
<div style={{ display: "flex", gap: "var(--space-2)", marginTop: "var(--space-3)", flexWrap: "wrap", alignItems: "flex-end" }}>
<Field label="New department / ward" style={{ flex: 1, minWidth: 160 }}>{(c) => <input {...c} className="input" value={nd.name} onChange={(e) => setNd({ ...nd, name: e.target.value })} placeholder="e.g. Ward 5C" />}</Field>
<Field label="Cost centre" style={{ width: 130 }}>{(c) => <input {...c} className="input" value={nd.cc} onChange={(e) => setNd({ ...nd, cc: e.target.value })} placeholder="e.g. RGH-5090" />}</Field>
<button className="btn btn-secondary" disabled={!nd.name.trim() || !nd.cc.trim()} onClick={async () => { const r = await mutate("dept.save", nd); say("depts", r.ok ? "Added." : r.error); if (r.ok) setNd({ name: "", cc: "" }); }}>Add</button>
</div>
)}
<Note>Wards with staff on them can&apos;t be removed.</Note>
<Msg text={msg.depts} />
</>
)}
{tab === "Suppliers" && (
<>
<H>Suppliers</H>
<div style={{ display: "flex", justifyContent: "flex-end", marginTop: "var(--space-3)" }}>
<button className="btn btn-ghost" onClick={exportSuppliers} disabled={s.supplierDir.length === 0}>Export CSV</button>
</div>
{s.supplierDir.map((sp) => {
const nItems = s.catalog.filter((it) => it.supplier === sp.name).length, nOrds = s.orders.filter((o) => o.supplier === sp.name).length;
return (
/* One supplier, one panel — the same bordered block with a named head that the rest
of the app puts a list in, rather than this screen's own thinner version of it. The
name keeps its own case: it is somebody's trading name, and the head's small caps
would shout it back at them. */
<div key={sp.id} className="tc-panel" style={{ marginTop: "var(--space-3)" }}>
<div className="tc-panel-head">
<span style={{ textTransform: "none", letterSpacing: 0, fontFamily: "var(--font-heading)", fontSize: 15, fontWeight: 800 }}>{sp.name}</span>
<span className="tc-panel-aside" style={{ display: "flex", alignItems: "center", gap: "var(--space-2)", flex: "none" }}>
{nItems} product{nItems === 1 ? "" : "s"} · {nOrds} order{nOrds === 1 ? "" : "s"}
{isAdmin && nItems + nOrds === 0 && <button className="btn btn-ghost btn-icon" title="Remove — no products or orders use this supplier" aria-label={`Remove ${sp.name} — no products or orders use this supplier`} onClick={async () => { const r = await mutate("supplier.remove", { id: sp.id }); say("sup", r.ok ? `${sp.name} removed.` : r.error); }}>×</button>}
</span>
</div>
<div className="tc-grid tc-panel-body" style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "var(--space-3)" }}>
{([["contact", "Contact person", "e.g. Dana R."], ["phone", "Phone", "e.g. 07 3xxx xxxx"], ["account", "Account no.", "e.g. ACC-2201"], ["lead", "Lead time (days)", "e.g. 14"]] as const).map(([k, lbl, ph]) => (
<Field key={k} label={`${sp.name}${lbl}`}>{(c) => <input {...c} className="input" placeholder={ph} value={supField(sp, k)} onChange={(e) => { const v = k === "lead" ? e.target.value.replace(/[^0-9]/g, "") : e.target.value; debounced(`sup:${sp.id}:${k}`, v, "supplier.update", { id: sp.id, [k]: v }, "sup"); }} disabled={!isAdmin} />}</Field>
))}
</div>
</div>
);
})}
{isAdmin && (
<div style={{ display: "flex", gap: "var(--space-2)", marginTop: "var(--space-3)", alignItems: "flex-end", flexWrap: "wrap" }}>
<Field label="New supplier" style={{ flex: 1, minWidth: 200 }}>{(c) => <input {...c} className="input" value={ns} onChange={(e) => setNs(e.target.value)} placeholder="e.g. Scrubs Direct" />}</Field>
<button className="btn btn-secondary" disabled={!ns.trim()} onClick={async () => { const r = await mutate("supplier.add", { name: ns }); say("sup", r.ok ? "Added." : r.error); if (r.ok) setNs(""); }}>Add supplier</button>
</div>
)}
<Note>Lead time auto-fills the expected delivery date on new orders; contact and account number print on purchase orders. Suppliers with products or orders can&apos;t be removed.</Note>
<Msg text={msg.sup} />
</>
)}
{tab === "Sign-in" && (
<>
<H>Single sign-on</H>
<SsoSettings isAdmin={isAdmin} demo={!!s.demo} sso={s.settings.sso} users={s.users} onChanged={() => router.refresh()} mutate={mutate} />
</>
)}
{tab === "Plan" && planShown && <PlanTab />}
{tab === "Account" && (
<>
<H>Account</H>
<div style={{ fontSize: 13, marginTop: "var(--space-3)", lineHeight: 1.7 }}>Signed in as <b>{s.session.name}</b> ({s.session.role}, {s.session.email}).</div>
{/* Sits at the top of Account because it is the one setting on this page that protects
every other one. */}
<TwoFactor isAdmin={isAdmin} />
<Note>Your name and title stamp every issue, stocktake and slip you record.</Note>
<div className="tc-grid" style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: "var(--space-3)", marginTop: "var(--space-3)", maxWidth: 640 }}>
<Field label="First name">{(c) => <input {...c} className="input" value={me.first} onChange={(e) => setMe({ ...me, first: e.target.value })} disabled={!!s.demo} />}</Field>
<Field label="Last name">{(c) => <input {...c} className="input" value={me.last} onChange={(e) => setMe({ ...me, last: e.target.value })} disabled={!!s.demo} />}</Field>
<Field label="Title">{(c) => <input {...c} className="input" value={me.title} onChange={(e) => setMe({ ...me, title: e.target.value })} placeholder="e.g. Uniform Coordinator" disabled={!!s.demo} />}</Field>
</div>
<div style={{ display: "flex", gap: "var(--space-2)", marginTop: "var(--space-3)", alignItems: "center", flexWrap: "wrap" }}>
<button className="btn btn-secondary" disabled={!meDirty || !me.first.trim() || !me.last.trim()} onClick={async () => { const r = await mutate("me.profile", me); say("me", r.ok ? "Saved." : r.error); }}>Save my details</button>
</div>
<Msg text={msg.me} />
<H>Password</H>
<div className="tc-grid" style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: "var(--space-3)", marginTop: "var(--space-3)", maxWidth: 640 }}>
<Field label="Current password">{(c) => <input {...c} className="input" type="password" autoComplete="current-password" value={pw.current} onChange={(e) => setPw({ ...pw, current: e.target.value })} />}</Field>
<Field label="New password" hint="At least 8 characters.">{(c) => <input {...c} className="input" type="password" autoComplete="new-password" value={pw.next} onChange={(e) => setPw({ ...pw, next: e.target.value })} />}</Field>
<Field label="Confirm" error={pw.again && pw.next !== pw.again ? "The two new passwords don\u2019t match." : undefined}>{(c) => <input {...c} className="input" type="password" autoComplete="new-password" value={pw.again} onChange={(e) => setPw({ ...pw, again: e.target.value })} />}</Field>
</div>
<div style={{ display: "flex", gap: "var(--space-2)", marginTop: "var(--space-3)", alignItems: "center", flexWrap: "wrap" }}>
<button className="btn btn-secondary" disabled={!pw.current || pw.next.length < 8 || pw.next !== pw.again} onClick={async () => { const r = await mutate("me.password", { current: pw.current, next: pw.next }); say("pw", r.ok ? "Password changed." : r.error); if (r.ok) setPw({ current: "", next: "", again: "" }); }}>Change password</button>
<button className="btn btn-ghost" onClick={signOut}>Sign out</button>
</div>
<Msg text={msg.pw} />
{isAdmin && (
<>
<H>Users</H>
<Note>People who can sign in to {s.settings.facility}. Passwords set here aren&apos;t emailed hand them over yourself.</Note>
{s.users.filter((u) => !u.inactive).map((u) => (
<div key={u.id} style={{ display: "flex", alignItems: "center", gap: "var(--space-3)", padding: "var(--space-2) 0", borderBottom: "1px solid var(--color-divider)", fontSize: 13, flexWrap: "wrap" }}>
<div style={{ flex: 1, minWidth: 200 }}><b>{u.first} {u.last}</b> <span style={{ color: "var(--color-neutral-700)" }}>{u.title}</span><div style={{ fontSize: 12, color: "var(--color-neutral-700)" }}>{u.email}</div></div>
<span className={u.role === "ADMIN" ? "tag tag-accent" : "tag tag-neutral"}>{u.role === "ADMIN" ? "Admin" : "Issuer"}</span>
<button className="btn btn-ghost" aria-label={`Edit ${u.first} ${u.last}`} onClick={() => setUserDlg(u)}>Edit</button>
</div>
))}
<button className="btn btn-secondary" style={{ marginTop: "var(--space-3)" }} onClick={() => setUserDlg(null)}>Add user</button>
{s.users.some((u) => u.inactive) && (
<>
<H>Deactivated users</H>
<Note>Reactivate to let them sign in again.</Note>
{s.users.filter((u) => u.inactive).map((u) => (
<div key={u.id} style={{ display: "flex", alignItems: "center", gap: "var(--space-3)", padding: "var(--space-2) 0", borderBottom: "1px solid var(--color-divider)", fontSize: 13, flexWrap: "wrap", color: "var(--color-neutral-700)" }}>
<div style={{ flex: 1, minWidth: 200 }}><b>{u.first} {u.last}</b> {u.title}<div style={{ fontSize: 12 }}>{u.email}</div></div>
<span className="tag tag-outline">{u.role === "ADMIN" ? "Admin" : "Issuer"}</span>
<button className="btn btn-ghost" aria-label={`Let ${u.first} ${u.last} sign in again`} onClick={async () => { const r = await mutate("users.update", { id: u.id, inactive: false }); say("users", r.ok ? `${u.first} reactivated.` : r.error); }}>Reactivate</button>
</div>
))}
<Msg text={msg.users} />
</>
)}
</>
)}
<H>Delete my account</H>
{(() => {
// Whether this account leaving takes the facility with it decides what the warning has to say.
// The user list is only in the snapshot for admins, and there is always at least one active
// admin, so a non-admin is never the last person standing — say so rather than guess from an
// empty list.
const othersLeft = s.users.filter((u) => !u.inactive && u.id !== s.session.userId).length;
const last = isAdmin && othersLeft === 0;
return (
<>
<Note>
{last
? <>You are the only person who can sign in to <b>{s.settings.facility}</b>, so this deletes the facility and everything in it. It can&apos;t be undone.</>
: <>This removes your login from <b>{s.settings.facility}</b>. The facility and its records stay.</>}
</Note>
{/* The same fetch as the Data tab's export rather than a plain link, for the same
reason and with more riding on it: this is the last copy this facility will ever
have, and whether some photos were left out of it is not something to find out
after the delete. */}
{last && <><Note><button disabled={bkBusy} onClick={exportBackup} style={{ background: "none", border: 0, padding: 0, font: "inherit", fontWeight: 700, color: "var(--color-accent-700)", textDecoration: "underline", textUnderlineOffset: 3, cursor: "pointer" }}>Download a backup first</button> it can be restored into a new facility later.</Note><Msg text={msg.backup} /></>}
{!del.open ? (
<button className="btn btn-secondary" style={{ marginTop: "var(--space-3)", borderColor: "var(--color-accent)", color: "var(--color-accent-700)" }} onClick={() => setDel({ open: true, password: "", confirm: "", busy: false, err: "" })} disabled={!!s.demo}>
Delete my account{last ? " and this facility" : ""}
</button>
) : (
<div className="tc-flag" style={{ borderTop: "2px solid var(--color-text)", borderRight: "2px solid var(--color-text)", borderBottom: "2px solid var(--color-text)", padding: "var(--space-4)", marginTop: "var(--space-3)", maxWidth: 520 }}>
<div style={{ fontWeight: 800, fontSize: 14, color: "var(--color-accent-700)" }}><span className="tc-mark" aria-hidden="true" />{last ? `Delete ${s.settings.facility} and everything in it?` : "Delete your login?"}</div>
<Field label="Your password" style={{ marginTop: "var(--space-3)" }} error={del.err || undefined}>{(c) => <input {...c} className="input" type="password" autoComplete="current-password" value={del.password} onChange={(e) => setDel({ ...del, password: e.target.value, err: "" })} />}</Field>
{last && <Field label="Type the facility name to confirm" style={{ marginTop: "var(--space-2)" }}>{(c) => <input {...c} className="input" value={del.confirm} placeholder={s.settings.facility} onChange={(e) => setDel({ ...del, confirm: e.target.value, err: "" })} />}</Field>}
<div style={{ display: "flex", gap: "var(--space-2)", marginTop: "var(--space-3)" }}>
<button className="btn btn-ghost" onClick={() => setDel({ open: false, password: "", confirm: "", busy: false, err: "" })}>Cancel</button>
<button className="btn btn-primary" disabled={del.busy || !del.password || (last && del.confirm.trim() !== s.settings.facility)}
onClick={async () => {
setDel((d) => ({ ...d, busy: true, err: "" }));
const r = await mutate("me.deleteAccount", { password: del.password, confirm: del.confirm });
if (!r.ok) { setDel((d) => ({ ...d, busy: false, err: r.error })); return; }
// The session now points at a row that is gone; drop the cookie rather than leave it.
await fetch("/api/auth/logout", { method: "POST" });
window.location.assign("/?deleted=1");
}}>
{del.busy ? "Deleting…" : last ? "Delete everything" : "Delete my login"}
</button>
</div>
</div>
)}
</>
);
})()}
</>
)}
{tab === "Data" && (
<>
<H>Data &amp; backup</H>
{/* What a backup would actually be carrying, in the same tiles the rest of the app counts
things in — rather than a line of numbers run together, which is what this was. */}
<div className="tc-tiles" style={{ marginTop: "var(--space-3)" }}>
{[[s.staff.filter((x) => !x.inactive).length, "active staff"], [s.catalog.filter((x) => !x.archived).length, "catalogue items"], [s.issues.length, "issues"], [s.orders.length, "orders"], [s.stocktakes.length, "recent stocktakes"], [s.approvals.length, "manager's approvals"]].map(([n, l]) => (
<div key={String(l)} className="tc-tile">
<span className="tc-figure">{n}</span>
<span className="tc-tile-label">{l}</span>
</div>
))}
</div>
{/* Overdue is marked, not merely reddened: the Export backup button a few lines down is
the same red, and the whole point of this line is to be noticed before somebody scrolls
past it. */}
<div className={bkStale ? "tc-flag" : undefined} style={{ fontSize: 13, marginTop: "var(--space-4)", fontWeight: bkStale ? 700 : 600, paddingLeft: bkStale ? "var(--space-3)" : 0, color: bkStale ? "var(--color-accent-700)" : "var(--color-text)" }}>
{bkStale && <span className="tc-mark" aria-hidden="true" />}
{lastBk ? `Last backup: ${fmtDate(lastBk)}${bkDays ? ` (${bkDays} day${bkDays === 1 ? "" : "s"} ago)` : " (today)"}` : "No backup taken yet."}
</div>
<Note>Export saves everything in this facility to one file; importing a backup replaces this facility&apos;s data.</Note>
{isAdmin && (
<div style={{ display: "flex", gap: "var(--space-2)", alignItems: "center", marginTop: "var(--space-3)", flexWrap: "wrap" }}>
<button className="btn btn-secondary" disabled={bkBusy} onClick={exportBackup}>{bkBusy ? "Preparing…" : "Export backup"}</button>
<label className="btn btn-ghost" style={{ cursor: "pointer" }}>Import backup<input type="file" accept="application/json,.json" aria-label="Choose a ThreadCount backup file to restore" style={{ display: "none" }} onChange={(e) => { const f = e.target.files?.[0]; if (f) restore(f); e.target.value = ""; }} /></label>
</div>
)}
<Msg text={msg.backup} />
{isAdmin && (
<>
<H>Barcode product lookup</H>
<Note>Only the barcode number is sent, and most uniform barcodes aren&apos;t publicly listed.</Note>
<label style={{ display: "flex", gap: "var(--space-2)", alignItems: "center", marginTop: "var(--space-3)", fontSize: 13, cursor: "pointer" }}>
<input type="checkbox" style={{ width: 16, height: 16, accentColor: "var(--color-accent)" }} checked={lookupOn ?? s.settings.barcodeLookup}
onChange={async (e) => { const v = e.target.checked; setLookupOn(v); const r = await mutate("settings.update", { barcodeLookup: v }); if (!r.ok) setLookupOn(!v); say("lookup", r.ok ? (v ? "Lookup on — unknown barcodes are checked against the public databases." : "Lookup off — nothing leaves the server.") : r.error); }} />
Look up unknown barcodes in public databases
</label>
<Msg text={msg.lookup} />
<H>Import from CSV</H>
<Note>Download a template, fill it in, save it as CSV and import it; re-importing updates matching rows.</Note>
<div style={{ display: "flex", gap: "var(--space-2)", alignItems: "center", marginTop: "var(--space-3)", flexWrap: "wrap" }}>
<select className="input" aria-label="What kind of CSV to import" value={impKind} onChange={(e) => setImpKind(e.target.value)}>{Object.entries(CSV_TEMPLATES).map(([k, t]) => <option key={k} value={k}>{t.name}</option>)}</select>
<button className="btn btn-ghost" onClick={() => template(impKind)}>Download template</button>
<label className="btn btn-primary" style={{ cursor: impBusy ? "wait" : "pointer" }}>Import CSV<input type="file" accept=".csv,text/csv" aria-label="Choose a CSV file to import" style={{ display: "none" }} disabled={impBusy} onChange={(e) => { const f = e.target.files?.[0]; if (f) void importFile(f); e.target.value = ""; }} /></label>
</div>
<Msg text={msg.import} />
<div style={{ marginTop: "var(--space-8)", border: "2px solid var(--color-divider)", padding: "var(--space-4)" }}>
<div style={{ fontWeight: 700, fontSize: 13 }}>Wipe recorded activity</div>
<div style={{ fontSize: 12, color: "var(--color-neutral-700)", margin: "var(--space-2) 0" }}>Removes all recorded activity, staff-app requests and the ward notice included; keeps the catalogue, staff, departments, suppliers, barcodes and opening balances. Type WIPE to confirm.</div>
<div style={{ display: "flex", gap: "var(--space-2)", alignItems: "center" }}>
<input className="input" style={{ width: 120 }} aria-label="Type WIPE to confirm wiping recorded activity" value={wipe} onChange={(e) => setWipe(e.target.value)} placeholder="WIPE" />
<button className="btn btn-secondary" disabled={wipe !== "WIPE"} onClick={async () => { const r = await mutate("data.wipeActivity", { confirm: wipe }); say("wipe", r.ok ? "Activity wiped." : r.error); setWipe(""); }}>Wipe activity</button>
</div>
<Msg text={msg.wipe} />
</div>
{/* The one irreversible thing on this page. A red outline is not enough on its own —
the Import CSV button above it is the same red — so it takes the left rule and the
mark as well. */}
<div className="tc-flag" style={{ marginTop: "var(--space-4)", borderTop: "2px solid var(--color-text)", borderRight: "2px solid var(--color-text)", borderBottom: "2px solid var(--color-text)", padding: "var(--space-4)" }}>
<div style={{ fontWeight: 800, fontSize: 13, color: "var(--color-accent-700)" }}><span className="tc-mark" aria-hidden="true" />Start fresh</div>
<div style={{ fontSize: 12, color: "var(--color-neutral-700)", margin: "var(--space-2) 0" }}>Empties this facility completely; your logins, facility name and settings stay. <b>Export a backup first</b> this can&apos;t be undone. Type RESET to confirm.</div>
<div style={{ display: "flex", gap: "var(--space-2)", alignItems: "center", flexWrap: "wrap" }}>
<input className="input" style={{ width: 120 }} aria-label="Type RESET to confirm emptying this facility" value={reset} onChange={(e) => setReset(e.target.value)} placeholder="RESET" />
<button className="btn btn-primary" disabled={reset !== "RESET" || resetBusy} onClick={async () => {
if (!confirm("Delete everything in this facility and start fresh? Logins stay; all data goes.")) return;
setResetBusy(true);
const r = await mutate("data.reset", { confirm: reset });
setResetBusy(false);
say("reset", r.ok ? "Facility emptied — youre starting fresh." : r.error);
setReset("");
}}>{resetBusy ? "Emptying…" : "Empty this facility"}</button>
</div>
<Msg text={msg.reset} />
</div>
</>
)}
</>
)}
</div>
{userDlg !== false && <UserDialog user={userDlg} onClose={() => setUserDlg(false)} />}
{renaming !== null && <RenameGroupDialog from={renaming} onClose={() => setRenaming(null)} onDone={(m) => { setRenaming(null); say("groups", m); }} />}
</section>
);
}
/* A staff group renamed everywhere its name is held — the list, its route, and every staff record
filed under it — in one go, by settings.renameGroup. Its own step rather than typing over the name
in the row, because the routes are lists of names: a name edited and saved as one group removed and
another added would take the group off its route, and a team's first kit would change because a
label was tidied up. Being a dialog also means nothing else on the page can be pressed while the
rename is on its way, and be worked out from lists that still carry the old name. */
function RenameGroupDialog({ from, onClose, onDone }: { from: string; onClose: () => void; onDone: (msg: string) => void }) {
const { mutate } = useSnap();
const [to, setTo] = useState(from);
const [err, setErr] = useState("");
const [saving, setSaving] = useState(false);
const name = to.trim();
const unchanged = name === from;
async function save() {
if (!name || unchanged || saving) return;
setSaving(true);
const r = await mutate<{ staff: number }>("settings.renameGroup", { from, to: name });
setSaving(false);
// A name already in use is the server's to refuse — it also knows the names only staff records
// carry — so its words go under the box rather than being second-guessed here.
if (!r.ok) { setErr(r.error); return; }
const n = r.result.staff;
onDone(`${from} is now ${name}, on the same route as before.${n ? ` ${n} staff record${n === 1 ? "" : "s"} moved with it.` : ""}`);
}
return (
<Dialog title={`Rename ${from}`} width={460} onClose={onClose}>
<div style={{ fontSize: 13, lineHeight: 1.6, marginTop: "var(--space-3)" }}>Everybody filed under {from} moves to the new name and keeps the same route.</div>
<Field label="New name" style={{ marginTop: "var(--space-3)" }}>{(c) => <input {...c} className="input" autoFocus maxLength={80} value={to} onChange={(e) => { setTo(e.target.value); setErr(""); }} onKeyDown={(e) => { if (e.key === "Enter") void save(); }} />}</Field>
<ErrorLine msg={err} />
<div style={{ display: "flex", justifyContent: "flex-end", gap: "var(--space-2)", marginTop: "var(--space-4)" }}>
<button className="btn btn-ghost" onClick={onClose}>Cancel</button>
<button className="btn btn-primary" onClick={() => void save()} disabled={!name || unchanged || saving}>{saving ? "Renaming…" : "Rename"}</button>
</div>
</Dialog>
);
}
function UserDialog({ user, onClose }: { user: UserRec | null; onClose: () => void }) {
const { s, mutate } = useSnap();
const [f, setF] = useState({ first: user?.first || "", last: user?.last || "", title: user?.title || "", email: user?.email || "", role: user?.role || "ISSUER", password: "" });
const [err, setErr] = useState("");
const invalid = !f.first.trim() || !f.last.trim() || (!user && (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(f.email) || f.password.length < 8)) || (!!user && f.password !== "" && f.password.length < 8);
async function save() {
if (invalid) return;
const r = user ? await mutate("users.update", { id: user.id, first: f.first, last: f.last, title: f.title, role: f.role, password: f.password }) : await mutate("users.add", f);
if (!r.ok) { setErr(r.error); return; }
onClose();
}
async function remove() {
if (!user || !confirm(`Deactivate ${user.first} ${user.last}'s login? They can be reactivated later.`)) return;
const r = await mutate("users.remove", { id: user.id });
if (!r.ok) { setErr(r.error); return; }
onClose();
}
return (
<Dialog title={user ? "Edit user" : "Add user"} width={520} onClose={onClose}>
<div className="tc-grid" style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "var(--space-3)", marginTop: "var(--space-4)" }}>
<Field label="First name">{(c) => <input {...c} className="input" value={f.first} onChange={(e) => setF({ ...f, first: e.target.value })} />}</Field>
<Field label="Last name">{(c) => <input {...c} className="input" value={f.last} onChange={(e) => setF({ ...f, last: e.target.value })} />}</Field>
<Field label="Title">{(c) => <input {...c} className="input" value={f.title} onChange={(e) => setF({ ...f, title: e.target.value })} placeholder="e.g. Linen Room Assistant" />}</Field>
<Field label="Role">{(c) => <select {...c} className="input" value={f.role} onChange={(e) => setF({ ...f, role: e.target.value as "ADMIN" | "ISSUER" })}><option value="ISSUER">Issuer</option><option value="ADMIN">Admin</option></select>}</Field>
<Field label="Work email" style={{ gridColumn: "1 / -1" }} hint={user ? "Can\u2019t be changed." : undefined}>{(c) => <input {...c} className="input" type="email" value={f.email} onChange={(e) => setF({ ...f, email: e.target.value })} disabled={!!user} />}</Field>
<Field label={user ? "New password (leave blank to keep)" : "Password"} style={{ gridColumn: "1 / -1" }} hint="At least 8 characters.">{(c) => <input {...c} className="input" type="password" autoComplete="new-password" value={f.password} onChange={(e) => setF({ ...f, password: e.target.value })} />}</Field>
</div>
<ErrorLine msg={err} />
<div style={{ display: "flex", justifyContent: "space-between", gap: "var(--space-2)", marginTop: "var(--space-4)" }}>
<div>{user && user.id !== s.session.userId && <button className="btn btn-ghost" onClick={remove}>Remove</button>}</div>
<div style={{ display: "flex", gap: "var(--space-2)" }}><button className="btn btn-ghost" onClick={onClose}>Cancel</button><button className="btn btn-primary" onClick={save} disabled={invalid}>Save</button></div>
</div>
</Dialog>
);
}