"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" | "email" | "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 {(c) => onChange(e.target.value)} disabled={disabled} />};
}
/* 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 ;
}
// useSearchParams needs a Suspense boundary for static rendering.
export default function SettingsPage() {
return ;
}
function SettingsInner() {
const { s, isAdmin, busy, mutate } = useSnap();
const router = useRouter();
const [tab, setTab] = useState("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>({});
const say = (k: string, v: string) => setMsg((m) => ({ ...m, [k]: v }));
const [draft, setDraft] = useState>({});
const timers = useRef>>({});
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, 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(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(null);
const [tzPick, setTzPick] = useState(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(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 = {};
const spelt: Record = {};
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 = {
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 couldn’t 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 , 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 = {};
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 }) =>