96d5c10537
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).
998 lines
83 KiB
TypeScript
998 lines
83 KiB
TypeScript
"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 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 <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} 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. */
|
||
<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 & 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 & 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 & 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 doesn’t 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 & 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'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'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'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'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 & 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'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'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'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 — you’re 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>
|
||
);
|
||
}
|