"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 }) =>
{children}
; const Note = ({ children }: { children: React.ReactNode }) =>
{children}
; /* 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 . */ const F = (k: keyof typeof s.settings, label: string, opts: { ph?: string; hint?: string; numeric?: boolean; demoFixed?: boolean } = {}) => 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 = {}; 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} hasn’t 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 can’t 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. */
{/* 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. */} {tab === "General" && ( <> Facility
{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. */} {(c) => ( )}
These print on slips, purchase orders, reports and the uniform order form. Issuing & stock
{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 })}
{kitOverCeiling && Nobody is handed more than the ceiling, so the starting kit stops at {sets(kitStart)}.} {/* 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. */} Stock takes
{F("varianceReason", "Reason required at (garments)", { numeric: true, ph: "e.g. 5", hint: "Over or short, here and on the counter phone." })}
Finance & journal
{F("glAccount", "GL account", { ph: "e.g. 631020" })}{F("journalDesc", "Journal description prefix", { ph: "e.g. Uniform issues" })}
Used by the Reports journal export and month-end pack. Slips & logo
{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
Staff groups Each group takes one route; every route stops at the ceiling of {sets(ceiling)} held. {/* 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. */}
{ROUTES.map((r) =>
{r.label}. {routeSays[r.id]}
)}
{/* 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 && (
)} {!!groupRows.length && (
{groupRows.map(({ g, listed }) => { const route = routeOf(g), n = staffCount(g); return (
{g}
{n ? `${n} staff member${n === 1 ? "" : "s"}` : "Nobody filed under it"}{listed ? "" : " · not on the list"}
{/* 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. */}
{ROUTES.map((r) => )}
{isAdmin && (
{listed ? : } {/* 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 && }
)}
); })}
)} {isAdmin &&
setNewGroup(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter" && newGroup.trim()) addGroup(); }} />
} {!!offList.length && Groups not on the list keep their route but take nobody new — add one back to keep it.} {!!unlisted.length && (
{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"}.
{unlisted.map(({ g, n }) => isAdmin ? : {g} · {n})}
)} {isAdmin && ( <> Ward notice Shown on the home screen of the staff app. {(c) =>