ThreadCount Community edition

Uniform stock management for healthcare linen rooms: the coordinator app, the phone counter and the staff app, for your own server. Built from 2d04e45 on 2026-09-15. Licensed under the Functional Source License (FSL-1.1-ALv2).
This commit is contained in:
ThreadCount
2026-09-16 04:04:31 +10:00
commit 8c86c988c1
424 changed files with 53598 additions and 0 deletions
+114
View File
@@ -0,0 +1,114 @@
"use client";
/* The audit log, moved from /app/activity into Data & audit log. Reads /api/activity page by page
* (the trail is not in the snapshot) and names each record from the snapshot instead of showing ids. */
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useSnap } from "@/lib/client";
import { LiveRegion } from "@/components/ui";
import { Panel } from "@/components/portal";
import { csvEsc, csvOf, facilityDate, formatInZone } from "@/lib/compute";
import { downloadCsv } from "@/lib/print";
import { LABELS, NOTABLE } from "./auditLabels";
import { buildNameIndex, recordParts, recordText } from "./names";
type Event = { id: string; at: string; who: string; op: string; target: string };
/* In the facility's zone, so the times agree with the linen-room clock wherever the log is read. */
function when(iso: string, tz: string) {
return formatInZone(iso, tz, { day: "numeric", month: "short", hour: "2-digit", minute: "2-digit", hour12: false });
}
export default function AuditLog({ scrollTo }: { scrollTo?: boolean }) {
const { s } = useSnap();
const [events, setEvents] = useState<Event[]>([]);
const [before, setBefore] = useState<string | null>(null);
const [more, setMore] = useState(false);
const [loading, setLoading] = useState(true);
const [err, setErr] = useState("");
const scrolled = useRef(false);
const ix = useMemo(() => buildNameIndex(s), [s]);
const load = useCallback(async (cursor: string | null) => {
setLoading(true);
try {
const r = await fetch("/api/activity" + (cursor ? `?before=${encodeURIComponent(cursor)}` : ""));
const j = await r.json();
if (!r.ok) { setErr(j.error || "Couldnt load the log."); return; }
setErr("");
setEvents((prev) => (cursor ? [...prev, ...j.events] : j.events));
setBefore(j.nextBefore);
setMore(!!j.nextBefore);
} catch {
setErr("Couldnt load the log.");
} finally {
setLoading(false);
}
}, []);
useEffect(() => { void load(null); }, [load]);
// Old /app/activity links land here: bring the log into view once its first page is in.
useEffect(() => {
if (!scrollTo || loading || scrolled.current) return;
scrolled.current = true;
requestAnimationFrame(() => document.getElementById("audit")?.scrollIntoView({ block: "start" }));
}, [scrollTo, loading]);
/* What is loaded, and no more: the file says on its face how far back it reaches. */
function exportCsv() {
const stamp = (iso: string) =>
`${facilityDate(iso, s.tz)} ${formatInZone(iso, s.tz, { hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false, hourCycle: "h23" })}`;
const reach = more ? `${events.length} (older events not loaded)` : `${events.length} (the whole log)`;
downloadCsv(`threadcount-activity-${s.today}.csv`,
`Audit log,${csvEsc(s.today)}\nTimes shown in,${csvEsc(s.tz)}\nEvents in this file,${csvEsc(reach)}\n\n`
+ csvOf(["When", "Who", "What", "Record", "Record id"], events.map((e) => [stamp(e.at), e.who, LABELS[e.op] || e.op, recordText(recordParts(ix, e.target)), e.target])));
}
return (
<Panel id="audit" title="Audit log"
aside={<>
<span>{events.length} shown{more ? " · older not loaded" : ""}</span>
<button className="btn btn-ghost" style={{ marginLeft: 12 }} onClick={exportCsv} disabled={events.length === 0}>Export CSV</button>
</>}
foot={more ? <button className="btn btn-secondary" onClick={() => load(before)} disabled={loading}>{loading ? "Loading…" : "Load older"}</button> : undefined}>
<LiveRegion tone="alert" msg={err} className="tc-flag" style={{ margin: "12px 16px 0", padding: "8px 12px", fontWeight: 700, color: "var(--color-accent-700)" }} />
<div className="table-wrap">
<table className="tc-table" style={{ minWidth: 640 }}>
<thead>
<tr><th style={{ width: 130 }}>When</th><th style={{ width: 170 }}>Who</th><th>What</th><th style={{ width: 260 }}>Record</th></tr>
</thead>
<tbody>
{events.map((e) => {
const notable = NOTABLE.has(e.op);
const parts = recordParts(ix, e.target);
return (
<tr key={e.id}>
<td className="tc-mono" style={{ whiteSpace: "nowrap", fontSize: 12 }}>{when(e.at, s.tz)}</td>
<td>{e.who}</td>
<td style={{ fontWeight: notable ? 700 : 400, color: notable ? "var(--color-accent-700)" : undefined }}>
{notable && <span className="tc-mark" aria-hidden="true" />}
{LABELS[e.op] || e.op}
</td>
<td style={{ overflowWrap: "anywhere" }}>
{!parts.length && <span style={{ color: "var(--color-neutral-700)" }}></span>}
{parts.map((p, i) => (
<span key={i}>
{i > 0 && " · "}
{p.missing ? <span className="tc-mono" style={{ fontSize: 12, color: "var(--color-neutral-700)" }}>{p.text}</span> : p.text}
</span>
))}
</td>
</tr>
);
})}
{!events.length && !loading && (
<tr><td colSpan={4} style={{ color: "var(--color-neutral-700)" }}>Nothing recorded yet.</td></tr>
)}
{!events.length && loading && (
<tr><td colSpan={4} style={{ color: "var(--color-neutral-700)" }}>Loading</td></tr>
)}
</tbody>
</table>
</div>
</Panel>
);
}
+80
View File
@@ -0,0 +1,80 @@
"use client";
/* Catalogue & suppliers: default reorder level, barcode lookup, and the supplier directory. */
import { useState } from "react";
import { useSnap } from "@/lib/client";
import { Field } from "@/components/ui";
import { Panel } from "@/components/portal";
import { csvOf, type SupplierRec } from "@/lib/compute";
import { downloadCsv } from "@/lib/print";
import { Msg, SectionHead, TextField, useSaver, useSettingsFields } from "./common";
type SupKey = "contact" | "phone" | "account" | "email" | "lead";
const SUP_FIELDS: readonly [SupKey, string, string][] = [["contact", "Contact", "e.g. Dana R."], ["phone", "Phone", "e.g. 07 3xxx xxxx"], ["account", "Account no.", "e.g. ACC-2201"], ["email", "Order email", "e.g. orders@example.com"], ["lead", "Lead time (days)", "e.g. 14"]];
export default function CatalogueSection() {
const { s, isAdmin, mutate } = useSnap();
const saver = useSaver();
const { msg, say, draft, debounced } = saver;
const { val, setField } = useSettingsFields(saver);
const [lookupOn, setLookupOn] = useState<boolean | null>(null);
const [ns, setNs] = useState("");
const supField = (sup: SupplierRec, k: SupKey) => draft[`sup:${sup.id}:${k}`] ?? (sup[k] === null ? "" : String(sup[k]));
function exportSuppliers() {
const rows = s.supplierDir.map((sup) => [sup.name, supField(sup, "contact"), supField(sup, "phone"), supField(sup, "account"), supField(sup, "email"), 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.", "Order email", "Lead time (days)", "Products", "Orders"], rows));
}
return (
<>
<SectionHead divider={false}>Catalogue</SectionHead>
<div className="tc-set-grid">
<TextField label="Default reorder level" hint="For sizes without their own." value={val("defaultReorder")} disabled={!isAdmin} onChange={(v) => setField("defaultReorder", v.replace(/[^0-9]/g, ""))} />
</div>
{isAdmin && (
<label style={{ display: "flex", gap: 10, alignItems: "center", fontSize: 14, 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("fields", r.ok ? (v ? "Barcode lookup on." : "Barcode lookup off.") : r.error);
}} />
Look up unknown barcodes in public databases
</label>
)}
<Msg text={msg.fields} />
<SectionHead meta="Suppliers with products or orders cant be removed."
right={<button className="btn btn-ghost" onClick={exportSuppliers} disabled={s.supplierDir.length === 0}>Export CSV</button>}>
Suppliers
</SectionHead>
{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 (
<Panel key={sp.id} headingLevel={3}
title={<span style={{ textTransform: "none", letterSpacing: 0, fontFamily: "var(--font-heading)", fontSize: 15, fontWeight: 800 }}>{sp.name}</span>}
aside={<span style={{ display: "inline-flex", alignItems: "center", gap: 8 }}>
<span className="tc-mono">{nItems}</span> product{nItems === 1 ? "" : "s"} · <span className="tc-mono">{nOrds}</span> order{nOrds === 1 ? "" : "s"}
{isAdmin && nItems + nOrds === 0 && <button className="btn btn-ghost btn-icon" title="Remove this supplier" aria-label={`Remove ${sp.name}`} onClick={async () => { const r = await mutate("supplier.remove", { id: sp.id }); say("sup", r.ok ? `${sp.name} removed.` : r.error); }}>×</button>}
</span>}>
<div className="tc-set-grid" style={{ padding: "12px 16px" }}>
{SUP_FIELDS.map(([k, lbl, ph]) => (
<Field key={k} label={<>{lbl}<span className="sr-only"> for {sp.name}</span></>}>{(c) => <input {...c} className={"input" + (k === "lead" || k === "account" ? " tc-mono" : "")} placeholder={ph} value={supField(sp, k)} disabled={!isAdmin}
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"); }} />}</Field>
))}
</div>
</Panel>
);
})}
{!s.supplierDir.length && <div className="tc-meta-line">No suppliers yet.</div>}
{isAdmin && (
<div style={{ display: "flex", gap: 10, alignItems: "flex-end", flexWrap: "wrap" }}>
<Field label="New supplier" style={{ width: 280, maxWidth: "100%" }}>{(c) => <input {...c} className="input" value={ns} onChange={(e) => setNs(e.target.value)} placeholder="e.g. Northline Workwear" onKeyDown={async (e) => { if (e.key === "Enter" && ns.trim()) { const r = await mutate("supplier.add", { name: ns }); say("sup", r.ok ? "Added." : r.error); if (r.ok) setNs(""); } }} />}</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>
)}
<Msg text={msg.sup} />
</>
);
}
+133
View File
@@ -0,0 +1,133 @@
"use client";
/* Data & audit log: backups, CSV import, wipe, start fresh, and the audit log. */
import { useEffect, useState } from "react";
import { useSnap } from "@/lib/client";
import { MonoNum } from "@/components/portal";
import { CSV_TEMPLATES, parseCsv } from "@/lib/csv";
import { daysBetween, fmtDate } from "@/lib/compute";
import { Msg, SectionHead, useSaver } from "./common";
import { useBackupExport } from "./backup";
import AuditLog from "./AuditLog";
export default function DataAudit({ importKind, scrollAudit }: { importKind?: string; scrollAudit: boolean }) {
const { s, isAdmin, mutate } = useSnap();
const { msg, say } = useSaver();
const { bkBusy, exportBackup } = useBackupExport(say);
const [impKind, setImpKind] = useState(importKind && CSV_TEMPLATES[importKind] ? importKind : "catalog");
const [impBusy, setImpBusy] = useState(false);
const [wipe, setWipe] = useState("");
const [reset, setReset] = useState("");
const [resetBusy, setResetBusy] = useState(false);
useEffect(() => {
if (importKind && CSV_TEMPLATES[importKind]) {
setImpKind(importKind);
requestAnimationFrame(() => document.getElementById("import")?.scrollIntoView({ block: "start" }));
}
}, [importKind]);
const lastBk = s.settings.lastBackup;
const bkDays = lastBk ? daysBetween(lastBk, s.today) : null;
const bkStale = !lastBk || (bkDays ?? 0) > 7;
/* parseCsv throws on a malformed file to stop a half-import; its words are what the admin needs. */
async function importFile(file: File) {
setImpBusy(true); say("import", "Importing…");
try {
const rows = parseCsv(await file.text());
if (!rows.length) { say("import", "No rows found — check the header row."); return; }
const r = await mutate<{ created: number; updated: number; skipped: number; styles?: number; errors: string[] }>("import.rows", { kind: impKind, rows });
if (!r.ok) { say("import", r.error); return; }
const x = r.result;
say("import", `${CSV_TEMPLATES[impKind].name}: ${x.created} created, ${x.updated} updated, ${x.skipped} skipped.${x.styles ? ` ${x.styles} uniform ${x.styles === 1 ? "style" : "styles"} set.` : ""}` + (x.errors.length ? "\n" + x.errors.join("\n") : ""));
}
catch (e) { say("import", (e as Error)?.message || "That file couldnt be read as a CSV. Nothing was imported."); }
finally { setImpBusy(false); }
}
async function restore(file: File) {
if (!confirm("Restore this backup? It replaces ALL data in this facility (catalogue, staff, orders, issues, stock, approvals). Users are kept.")) return;
say("backup", "Restoring…");
try {
const data = JSON.parse(await file.text());
const r = await mutate<{ photosSkipped: number }>("backup.restore", data);
if (!r.ok) { say("backup", r.error); return; }
const skipped = r.result?.photosSkipped || 0;
say("backup", skipped ? `Backup restored. ${skipped} photo${skipped === 1 ? "" : "s"} in the file did not come back; keep the file.` : "Backup restored.");
}
catch (e) { say("backup", "Import failed — " + (e as Error).message); }
}
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();
}
const backupLine = (
<div className={bkStale ? "tc-flag" : undefined} style={{ fontSize: 13, fontWeight: bkStale ? 700 : 600, paddingLeft: bkStale ? 12 : 0, color: bkStale ? "var(--color-accent-700)" : "var(--color-text)" }}>
{bkStale && <span className="tc-mark" aria-hidden="true" />}
{lastBk ? <>Last backup <MonoNum>{fmtDate(lastBk)}</MonoNum>{bkDays ? ` · ${bkDays} day${bkDays === 1 ? "" : "s"} ago` : " · today"}</> : "No backup taken yet."}
</div>
);
if (!isAdmin) {
return (
<>
<SectionHead divider={false}>Backup</SectionHead>
{backupLine}
<div className="tc-meta-line">Only an admin can read the audit log.</div>
</>
);
}
const active = s.staff.filter((x) => !x.inactive).length;
const items = s.catalog.filter((x) => !x.archived).length;
return (
<>
<div className="tc-meta-line">
<MonoNum>{active}</MonoNum> active staff · <MonoNum>{items}</MonoNum> garments · <MonoNum>{s.issues.length}</MonoNum> issues · <MonoNum>{s.orders.length}</MonoNum> orders
</div>
<SectionHead>Backup</SectionHead>
{backupLine}
<div style={{ display: "flex", gap: 10, alignItems: "center", 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" className="sr-only" onChange={(e) => { const f = e.target.files?.[0]; if (f) void restore(f); e.target.value = ""; }} /></label>
</div>
<Msg text={msg.backup} />
<SectionHead id="import" meta="re-importing updates matching rows">Import from CSV</SectionHead>
<div style={{ display: "flex", gap: 10, alignItems: "center", 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-secondary" style={{ cursor: impBusy ? "wait" : "pointer" }}>{impBusy ? "Importing…" : "Import CSV"}<input type="file" accept=".csv,text/csv" aria-label="Choose a CSV file to import" className="sr-only" disabled={impBusy} onChange={(e) => { const f = e.target.files?.[0]; if (f) void importFile(f); e.target.value = ""; }} /></label>
</div>
<Msg text={msg.import} />
<SectionHead meta="keeps catalogue, staff, departments and suppliers">Wipe recorded activity</SectionHead>
<div style={{ display: "flex", gap: 10, alignItems: "center", flexWrap: "wrap" }}>
<input className="input tc-mono" 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 className="tc-flag" style={{ border: "2px solid var(--color-text)", borderLeft: "4px solid var(--color-accent)", padding: "12px 16px", display: "flex", flexDirection: "column", gap: 10 }}>
<h2 style={{ margin: 0, fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 18, color: "var(--color-accent-700)" }}><span className="tc-mark" aria-hidden="true" />Start fresh</h2>
<div className="tc-meta-line">Empties this facility; logins and settings stay. Export a backup first.</div>
<div style={{ display: "flex", gap: 10, alignItems: "center", flexWrap: "wrap" }}>
<input className="input tc-mono" style={{ width: 120 }} aria-label="Type RESET to confirm emptying this facility" value={reset} onChange={(e) => setReset(e.target.value)} placeholder="RESET" />
<button className="btn btn-primary" disabled={reset !== "RESET" || resetBusy} onClick={async () => {
if (!confirm("Delete everything in this facility and start fresh? Logins stay; all data goes.")) return;
setResetBusy(true);
const r = await mutate("data.reset", { confirm: reset });
setResetBusy(false);
say("reset", r.ok ? "Facility emptied — youre starting fresh." : r.error);
setReset("");
}}>{resetBusy ? "Emptying…" : "Empty this facility"}</button>
</div>
<Msg text={msg.reset} />
</div>
<AuditLog scrollTo={scrollAudit} />
</>
);
}
+137
View File
@@ -0,0 +1,137 @@
"use client";
/* Facility: details and time zone, slips and logo, finance and reports, and the ward notice. */
import { useMemo, useState } from "react";
import { useSnap } from "@/lib/client";
import { Field } from "@/components/ui";
import { fmtDate, type Settings, type Snapshot } from "@/lib/compute";
import { Msg, SectionHead, TextField, useSaver, useSettingsFields } from "./common";
// Only for a browser without Intl.supportedValuesOf, so the picker is never empty.
const FALLBACK_ZONES = ["Australia/Brisbane", "Australia/Sydney", "Australia/Melbourne", "Australia/Hobart", "Australia/Adelaide", "Australia/Darwin", "Australia/Perth", "Australia/Broken_Hill", "Australia/Lord_Howe"];
type LiveNotice = { body: string; endsAt: string } | null;
const dayMonth = (d: string) => {
const t = new Date(d + "T00:00:00Z");
return isNaN(t.getTime()) ? d : t.toLocaleDateString("en-AU", { day: "numeric", month: "short", timeZone: "UTC" });
};
export default function FacilitySection() {
const { s, isAdmin, mutate } = useSnap();
const saver = useSaver();
const { msg, say } = saver;
const { val, setField } = useSettingsFields(saver);
const [tzPick, setTzPick] = useState<string | null>(null);
const [tzErr, setTzErr] = useState("");
const [logoV, setLogoV] = useState(0);
const [notice, setNotice] = useState({ body: "", endsAt: "" });
const [noticeBusy, setNoticeBusy] = useState(false);
/* The live notice, once the snapshot carries it (spec S1). Undefined means this build's snapshot
cannot see the board, which is different from an empty board, so nothing is claimed. */
const live = (s as Snapshot & { notice?: LiveNotice }).notice;
const zones = useMemo(() => {
const all = Intl.supportedValuesOf?.("timeZone") || FALLBACK_ZONES;
return all.includes(s.settings.timezone) ? all : [s.settings.timezone, ...all];
}, [s.settings.timezone]);
const F = (k: keyof Settings, label: string, opts: { ph?: string; hint?: string; numeric?: boolean; demoFixed?: boolean; style?: React.CSSProperties } = {}) =>
<TextField label={label} hint={opts.hint} ph={opts.ph} value={val(k)} style={opts.style} disabled={!isAdmin || (!!opts.demoFixed && !!s.demo)} onChange={(v) => setField(k, opts.numeric ? v.replace(/[^0-9]/g, "") : v)} />;
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." : res.error); };
r.readAsDataURL(file);
}
async function postNotice() {
const body = notice.body.trim(), endsAt = notice.endsAt.trim();
// The staff app only shows a notice whose end date is today or later.
if (body && endsAt && endsAt < s.today) { say("notice", `${fmtDate(endsAt)} has already gone. Pick today or later, or leave it blank.`); return; }
setNoticeBusy(true);
const r = await mutate<{ cleared: boolean }>("notice.set", { body, endsAt });
setNoticeBusy(false);
if (!r.ok) { say("notice", r.error); return; }
if (!r.result.cleared) setNotice({ body: "", endsAt: "" });
say("notice", r.result.cleared ? "Notice taken down." : `Posted${endsAt ? ` until ${dayMonth(endsAt)}` : ""}.`);
}
return (
<>
<SectionHead divider={false}>Facility</SectionHead>
<div className="tc-set-grid">
{F("facility", "Facility")}
{F("location", "Stock location")}
{F("coordinator", "Coordinator name")}
{/* Fixed in the demo: everyone shares that facility, and these print on order forms. */}
{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 })}
<Field label="Time zone" 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 });
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>
<Msg text={msg.fields} />
<SectionHead>Slips &amp; logo</SectionHead>
<div className="tc-set-grid">
{F("slipOrg", "Organisation name on slips", { ph: "Printed when there is no logo" })}
{/* A named group, not a Field: a preview, a picker and a remove button share one label. */}
<div className="field" role="group" aria-label="Logo, top right on slips">
<span aria-hidden="true" className="tc-lbl">Logo</span>
<div style={{ display: "flex", gap: 10, alignItems: "center", flexWrap: "wrap" }}>
{s.settings.hasLogo && <img src={`/api/logo?v=${logoV}`} alt="The logo printed on slips" style={{ height: 34, maxWidth: 140, objectFit: "contain", border: "1px solid var(--color-divider)", background: "#fff", padding: 2 }} />}
{!s.settings.hasLogo && !isAdmin && <span className="tc-meta-line">No logo</span>}
{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"} className="sr-only" 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>
{F("slipCollectionFooter", "Collection slip footer", { style: { gridColumn: "1 / -1" } })}
{F("slipDeliveryFooter", "Delivery slip footer", { style: { gridColumn: "1 / -1" } })}
</div>
<Msg text={msg.logo} />
<SectionHead>Finance &amp; reports</SectionHead>
<div className="tc-set-grid">
{F("glAccount", "GL account", { ph: "e.g. 631020" })}
{F("journalDesc", "Journal description prefix", { ph: "e.g. Uniform issues" })}
{F("exceptionHigh", "Exception threshold (items/month)", { numeric: true })}
</div>
<SectionHead meta="on the staff app home screen">Ward notice</SectionHead>
{live !== undefined && (
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
<span className="tc-lbl">Currently posted</span>
{live
? <>
<blockquote className="tc-notice-quote" style={{ margin: 0 }}>{live.body}</blockquote>
<span className="tc-meta-line">{live.endsAt ? <>until <span className="tc-mono">{dayMonth(live.endsAt)}</span></> : "no end date"}</span>
</>
: <span className="tc-meta-line">Nothing posted</span>}
</div>
)}
{isAdmin && (
<>
<Field label="Message" hint="Replaces the notice that is up now.">{(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-set-grid">
<Field label="Last day shown" hint="Optional. Blank 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>
<div style={{ display: "flex", gap: 10, flexWrap: "wrap" }}>
<button className="btn btn-secondary" disabled={noticeBusy || (!notice.body.trim() && live === null)} onClick={postNotice}>
{noticeBusy ? "Saving…" : notice.body.trim() ? "Post this notice" : "Take the notice down"}
</button>
</div>
</>
)}
<Msg text={msg.notice} />
</>
);
}
+36
View File
@@ -0,0 +1,36 @@
"use client";
/* Issuing rules: the ceiling, the starting kit and the count-reason threshold, then the staff groups
* on their three routes. capCheck() and allowance() in lib/compute.ts still decide; this screen
* only edits the figures and lists they read. */
import { useSnap } from "@/lib/client";
import { allowance, setsOnStart } from "@/lib/compute";
import { InlineNumber, Msg, SectionHead, useSaver, useSettingsFields } from "./common";
import RouteBoard from "./RouteBoard";
export default function IssuingRules() {
const { s, isAdmin } = useSnap();
const saver = useSaver();
const { val, setField } = useSettingsFields(saver);
/* Read off the boxes, so the route lines describe what leaving now would put in force. An empty box
saves nothing, so the stored figure stands for it. */
const typed = (k: "initialSets" | "capSets") => { const t = val(k).trim(); return t === "" ? s.settings[k] : Number(t); };
const shape = allowance({ held: 0, kit: true, startingSets: typed("initialSets"), capSets: typed("capSets") });
const ceiling = shape.max, kitStart = shape.start ?? 0;
const kitOverCeiling = setsOnStart(typed("initialSets")) > ceiling;
return (
<>
<div style={{ display: "flex", alignItems: "flex-end", gap: 24, flexWrap: "wrap" }}>
<InlineNumber label="Most anyone holds" unit="sets, every group" value={val("capSets")} onChange={(v) => setField("capSets", v)} disabled={!isAdmin} />
<InlineNumber label="Starting kit" unit="sets on day one" value={val("initialSets")} onChange={(v) => setField("initialSets", v)} disabled={!isAdmin} />
<InlineNumber label="A count gap needs a reason at" unit="garments" value={val("varianceReason")} onChange={(v) => setField("varianceReason", v)} disabled={!isAdmin} />
</div>
{kitOverCeiling && <div className="tc-meta-line">Nobody is handed more than the ceiling, so the starting kit stops at {kitStart} sets.</div>}
<Msg text={saver.msg.fields} />
<SectionHead meta={isAdmin ? "drag a group to change its route · rename in place" : undefined}>Staff groups and how they get uniform</SectionHead>
<RouteBoard ceiling={ceiling} kitStart={kitStart} />
</>
);
}
+130
View File
@@ -0,0 +1,130 @@
"use client";
/* People & sign-in: your account, password, two-factor, users, deactivated users, single sign-on,
* and deleting your own account. Sign out lives in the rail, and in the More sheet on a phone. */
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useSnap } from "@/lib/client";
import { Field } from "@/components/ui";
import { Tag } from "@/components/portal";
import TwoFactor from "@/components/TwoFactor";
import SsoSettings from "@/components/SsoSettings";
import type { UserRec } from "@/lib/compute";
import { Msg, SectionHead, useSaver } from "./common";
import { useBackupExport } from "./backup";
import UserDialog from "./UserDialog";
const row: React.CSSProperties = { display: "flex", alignItems: "center", gap: 14, padding: "11px 16px", borderTop: "1px solid #cfcccb", fontSize: 14, flexWrap: "wrap" };
export default function PeopleSignIn() {
const { s, isAdmin, mutate } = useSnap();
const router = useRouter();
const { msg, say } = useSaver();
const { bkBusy, exportBackup } = useBackupExport(say);
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 [pw, setPw] = useState({ current: "", next: "", again: "" });
const [userDlg, setUserDlg] = useState<UserRec | null | false>(false);
const [del, setDel] = useState({ open: false, password: "", confirm: "", busy: false, err: "" });
const activeUsers = s.users.filter((u) => !u.inactive);
const inactiveUsers = s.users.filter((u) => u.inactive);
// The user list reaches admins only, and there is always an active admin, so an issuer is never last.
const othersLeft = s.users.filter((u) => !u.inactive && u.id !== s.session.userId).length;
const last = isAdmin && othersLeft === 0;
return (
<>
<SectionHead divider={false}>Your account</SectionHead>
<div style={{ fontSize: 14 }}>Signed in as <b>{s.session.name}</b> · {s.session.role} · {s.session.email}</div>
<div className="tc-grid" style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: 16, maxWidth: 720 }}>
<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><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} />
<SectionHead>Password</SectionHead>
<div className="tc-grid" style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: 16, maxWidth: 720 }}>
<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 dont 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><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></div>
<Msg text={msg.pw} />
<SectionHead>Two-factor</SectionHead>
<TwoFactor isAdmin={isAdmin} />
{isAdmin && (
<>
<SectionHead meta={<><span className="tc-mono">{activeUsers.length}</span> can sign in</>} right={<button className="btn btn-secondary" onClick={() => setUserDlg(null)}>Add user</button>}>Users</SectionHead>
<div style={{ border: "2px solid var(--color-text)" }}>
{activeUsers.map((u, i) => (
<div key={u.id} style={{ ...row, borderTop: i ? row.borderTop : 0 }}>
<div style={{ flex: 1, minWidth: 200 }}><b>{u.first} {u.last}</b> <span className="tc-meta-line">{u.title}</span><div className="tc-meta-line">{u.email}</div></div>
<Tag tone={u.role === "ADMIN" ? "ink" : "quiet"}>{u.role === "ADMIN" ? "Admin" : "Issuer"}</Tag>
<button className="btn btn-ghost" aria-label={`Edit ${u.first} ${u.last}`} onClick={() => setUserDlg(u)}>Edit</button>
</div>
))}
</div>
{inactiveUsers.length > 0 && (
<>
<SectionHead>Deactivated users</SectionHead>
<div style={{ border: "2px solid var(--color-text)" }}>
{inactiveUsers.map((u, i) => (
<div key={u.id} style={{ ...row, borderTop: i ? row.borderTop : 0, color: "var(--color-neutral-700)" }}>
<div style={{ flex: 1, minWidth: 200 }}><b>{u.first} {u.last}</b> {u.title}<div className="tc-meta-line">{u.email}</div></div>
<Tag tone="quiet">{u.role === "ADMIN" ? "Admin" : "Issuer"}</Tag>
<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>
))}
</div>
<Msg text={msg.users} />
</>
)}
</>
)}
<SectionHead>Single sign-on</SectionHead>
<SsoSettings isAdmin={isAdmin} demo={!!s.demo} sso={s.settings.sso} users={s.users} onChanged={() => router.refresh()} mutate={mutate} />
<SectionHead meta={last ? "deletes the facility and everything in it" : "the facility and its records stay"}>Delete my account</SectionHead>
{last && (
<div style={{ fontSize: 13 }}>
<button className="btn btn-ghost" disabled={bkBusy} onClick={exportBackup} style={{ color: "var(--color-accent-700)", fontWeight: 700, paddingLeft: 0 }}>Download a backup first</button>
<Msg text={msg.backup} />
</div>
)}
{!del.open ? (
<div>
<button className="btn btn-secondary" style={{ 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>
) : (
<div className="tc-flag" style={{ border: "2px solid var(--color-text)", borderLeft: "4px solid var(--color-accent)", padding: 16, 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: 12 }} 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: 8 }}>{(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: 10, marginTop: 12 }}>
<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 points at a row that is gone; drop the cookie.
await fetch("/api/auth/logout", { method: "POST" });
window.location.assign("/?deleted=1");
}}>
{del.busy ? "Deleting…" : last ? "Delete everything" : "Delete my login"}
</button>
</div>
</div>
)}
{userDlg !== false && <UserDialog user={userDlg} onClose={() => setUserDlg(false)} />}
</>
);
}
+95
View File
@@ -0,0 +1,95 @@
"use client";
/* Places & cost centres: a link to the locations editor (now in Stock) and the departments editor. */
import { useState } from "react";
import Link from "next/link";
import { useSnap } from "@/lib/client";
import { Field } from "@/components/ui";
import { Panel } from "@/components/portal";
import { csvOf, type DeptRec } from "@/lib/compute";
import { downloadCsv } from "@/lib/print";
import { Msg, SectionHead, useSaver } from "./common";
export default function PlacesSection() {
const { s, isAdmin, mutate } = useSnap();
const { msg, say, draft, setDraft, draftNow, forgetDraft, debounced, schedule } = useSaver();
const [nd, setNd] = useState({ name: "", cc: "" });
const nLocs = s.locations.filter((l) => !l.archived).length;
const deptStaff: Record<string, number> = {};
for (const st of s.staff) deptStaff[st.dept] = (deptStaff[st.dept] || 0) + 1;
/* The row's cost centre and name as typed, so one box saving the row never undoes the other. */
const deptCc = (d: DeptRec, from = draft) => from["dept:" + d.id] ?? d.cc;
const deptName = (d: DeptRec) => draft["deptname:" + d.id] ?? d.name;
function deptNameRefusal(d: DeptRec, name: string) {
if (!name) return `A ward needs a name — ${d.name} hasnt been changed.`;
const clash = s.depts.find((o) => o.id !== d.id && o.name.trim().toLowerCase() === name.toLowerCase());
return clash ? `${clash.name} is already on the list.` : "";
}
const deptSaveName = (d: DeptRec) => { const n = deptName(d).trim(); return deptNameRefusal(d, n) ? d.name : n; };
/* dept.save carries the old name forward, so staff and orders filed under it move with it. The
check waits for the typing to stop; half a name is not a refusal. */
function renameDept(d: DeptRec, v: string) {
const k = "deptname:" + d.id;
setDraft((x) => ({ ...x, [k]: v }));
schedule(k, async () => {
const name = v.trim();
const no = deptNameRefusal(d, name);
if (no) { forgetDraft(k, v); say("depts", no); return; }
if (name === d.name) 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}; its staff and orders moved with it.`);
}, 600);
}
// The name the register holds (not a rename still settling) and the cost centre as typed.
function exportDepts() {
downloadCsv(`threadcount-departments-${s.today}.csv`, csvOf(["dept", "cc", "staff"], s.depts.map((d) => [d.name, deptCc(d).trim(), deptStaff[d.name] || 0])));
}
return (
<>
<Panel title="Locations" aside={<><span className="tc-mono">{nLocs}</span> location{nLocs === 1 ? "" : "s"}</>}>
<div style={{ padding: "12px 16px", display: "flex", alignItems: "center", gap: 12, flexWrap: "wrap" }}>
<span className="tc-meta-line">Rooms, shelves and bays are kept in Stock.</span>
<Link href="/app/stock?tab=locations" className="btn btn-secondary" style={{ marginLeft: "auto" }}>Open Stock Locations</Link>
</div>
</Panel>
<SectionHead meta="Wards with staff on them cant be removed."
right={<button className="btn btn-ghost" onClick={exportDepts} disabled={s.depts.length === 0}>Export CSV</button>}>
Departments &amp; cost centres
</SectionHead>
{s.depts.length > 0 && (
<div className="table-wrap" style={{ border: "2px solid var(--color-text)" }}>
<table className="tc-table" style={{ minWidth: 520 }}>
<thead><tr><th>Department / ward</th><th style={{ width: 180 }}>Cost centre</th><th className="num" style={{ width: 80 }}>Staff</th><th style={{ width: 44 }}><span className="sr-only">Remove</span></th></tr></thead>
<tbody>
{s.depts.map((d) => {
const n = deptStaff[d.name] || 0;
return (
<tr key={d.id}>
<td><input className="input" style={{ width: "100%", fontWeight: 600 }} aria-label={`Name of ${d.name}`} value={deptName(d)} onChange={(e) => renameDept(d, e.target.value)} disabled={!isAdmin} /></td>
<td><input className="input tc-mono" style={{ width: "100%" }} aria-label={`Cost centre for ${d.name}`} value={deptCc(d)} disabled={!isAdmin}
onChange={(e) => debounced("dept:" + d.id, e.target.value, "dept.save", { id: d.id, name: deptSaveName(d), cc: e.target.value.trim() }, "depts")} /></td>
<td className="num">{n}</td>
<td>{isAdmin && !n && <button className="btn btn-ghost btn-icon" title="Remove — no staff assigned" aria-label={`Remove ${d.name}`} onClick={async () => { const r = await mutate("dept.delete", { id: d.id }); say("depts", r.ok ? `${d.name} removed.` : r.error); }}>×</button>}</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
{s.depts.length === 0 && <div className="tc-meta-line">No departments yet.</div>}
{isAdmin && (
<div style={{ display: "flex", gap: 10, flexWrap: "wrap", alignItems: "flex-end" }}>
<Field label="New department / ward" style={{ width: 280, maxWidth: "100%" }}>{(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: 180 }}>{(c) => <input {...c} className="input tc-mono" 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>
)}
<Msg text={msg.depts} />
</>
);
}
+303
View File
@@ -0,0 +1,303 @@
"use client";
/* Staff groups on their three routes, as drag targets.
*
* A route saves by sending both route lists whole (nursingGroups for the FTE table, kitGroups for the
* starting kit, neither for manager approval) in one settings.update, because the server refuses any
* save that leaves a group on two. Every chip action also has a keyboard path: the chip is a menu
* button (Move to…, Rename, Remove), and Alt+← / Alt+→ moves it to the neighbouring route. */
import { useEffect, useRef, useState } from "react";
import { useSnap } from "@/lib/client";
import { ErrorLine } from "@/components/ui";
import { Panel, Tag, Icon } from "@/components/portal";
import { allowanceRoute, groupKey, isKitGroup, isNursingGroup, kitGroupsOf, nursingGroupsOf, type AllowanceRoute } from "@/lib/compute";
import { Msg } from "./common";
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" },
];
const routeNow = (r: AllowanceRoute) => ROUTES.find((x) => x.id === r)?.now ?? "";
export default function RouteBoard({ ceiling, kitStart }: { ceiling: number; kitStart: number }) {
const { s, isAdmin, busy, mutate } = useSnap();
const [msg, setMsg] = useState("");
const [newGroup, setNewGroup] = useState("");
const [renaming, setRenaming] = useState<string | null>(null);
const [hover, setHover] = useState<AllowanceRoute | null>(null);
/* Where keyboard focus goes after a chip moves, is renamed or removed. A chip remounts (new column,
new name) or goes away, and the snapshot refresh lands after the mutation returns, so the request
waits across renders until focus has dropped to the page and a named chip (or the fallback, the
new-group box) is there to take it. */
const [focusReq, setFocusReq] = useState<{ names: string[]; fallback: boolean; until: number } | null>(null);
const routesRef = useRef<HTMLDivElement>(null);
const newGroupRef = useRef<HTMLInputElement>(null);
const wantFocus = (names: string[], fallback = false) => setFocusReq({ names, fallback, until: Date.now() + 5000 });
useEffect(() => {
if (!focusReq) return;
if (Date.now() > focusReq.until) { setFocusReq(null); return; }
const a = document.activeElement;
if (a && a !== document.body && a.isConnected) return;
const chips = Array.from(routesRef.current?.querySelectorAll<HTMLElement>("button[data-group]") || []);
for (const n of focusReq.names) {
const el = chips.find((c) => c.dataset.group === n);
if (el) { el.focus(); setFocusReq(null); return; }
}
if (focusReq.fallback) { newGroupRef.current?.focus(); setFocusReq(null); }
});
/* Each group's route as just chosen, held only while the snapshot still carries the lists it was
worked out from, so the chip does not spring back before the refresh lands. */
const [routePick, setRoutePick] = useState<{ base: string; nursing: string[]; kit: string[] } | null>(null);
const stored = { nursing: nursingGroupsOf(s), kit: kitGroupsOf(s) };
const listSig = JSON.stringify([stored.nursing, stored.kit]);
const lists = routePick && routePick.base === listSig ? routePick : stored;
const routeOf = (g: string) => allowanceRoute({ nursing: isNursingGroup(lists.nursing, g), kit: isKitGroup(lists.kit, g) });
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 listed groups, then any name still on a route but no longer on the list: its people are still
on that route, so it stays in sight. */
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 }))];
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);
const desc: Record<AllowanceRoute, string> = {
fte: `Hours proposes the kit; the manager signs up to ${ceiling}.`,
kit: `${kitStart} sets on day one, more as needed up to ${ceiling}.`,
approval: "Nothing until a manager approves it.",
};
/* One change to the groups at a time: each sends whole lists worked out from the screen. */
const settled = () => { if (!busy) return true; setMsg("Still saving the last change — try again in a moment."); return false; };
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] });
setMsg(r.ok ? `${g} added, ${routeNow(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 after: taking a group off the list takes it off its route too.
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 col = groupRows.filter((x) => routeOf(x.g) === route).map((x) => x.g);
const at = col.indexOf(g);
const r = await mutate("settings.update", { staffGroups: s.settings.staffGroups.filter((x) => x !== g) });
if (r.ok) wantFocus([col[at + 1], col[at - 1]].filter((x): x is string => !!x), true);
setMsg(!r.ok ? r.error
: n ? `${g} removed. Its ${n} staff member${n === 1 ? " is" : "s are"} on manager approval.`
: `${g} removed.`);
}
async function setRoute(g: string, to: AllowanceRoute, keepFocus = true) {
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; clearing it off kit lets the save through.
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 });
if (keepFocus) wantFocus([g]);
const r = await mutate("settings.update", { nursingGroups: nursing, kitGroups: kit });
// Refused: the chip goes back to its old column as a new element, so focus follows it there.
if (!r.ok) { setRoutePick(null); if (keepFocus) wantFocus([g]); setMsg(r.error); return; }
setMsg(`${g} is ${routeNow(to)}.`);
}
function moveBy(g: string, dir: -1 | 1) {
const i = ROUTES.findIndex((r) => r.id === routeOf(g));
const to = ROUTES[i + dir];
if (!to) return;
void setRoute(g, to.id);
}
return (
<>
{!groupRows.length ? (
<div className="tc-flag" style={{ fontSize: 14, paddingLeft: 12 }}>
<span className="tc-mark" aria-hidden="true" />No staff groups yet everyone is on manager approval.
</div>
) : (
<div className="tc-routes" ref={routesRef}>
{ROUTES.map((r) => {
const chips = groupRows.filter(({ g }) => routeOf(g) === r.id);
return (
<div key={r.id} className={"tc-route" + (hover === r.id ? " drop-on" : "")}
onDragOver={isAdmin ? (e) => { if (!e.dataTransfer.types.includes("text/x-tc-group")) return; e.preventDefault(); e.dataTransfer.dropEffect = "move"; if (hover !== r.id) setHover(r.id); } : undefined}
onDragLeave={isAdmin ? (e) => { if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setHover((h) => (h === r.id ? null : h)); } : undefined}
onDrop={isAdmin ? (e) => {
e.preventDefault(); setHover(null);
const g = e.dataTransfer.getData("text/x-tc-group");
if (g && groupRows.some((x) => x.g === g)) void setRoute(g, r.id, false);
} : undefined}>
<Panel title={r.label} headingLevel={3}>
<div className="tc-route-desc">{desc[r.id]}</div>
<div className="tc-route-body">
{chips.map(({ g, listed }) => (
<GroupChip key={g} g={g} n={staffCount(g)} listed={listed} route={r.id} admin={isAdmin}
editing={renaming === g}
onEdit={() => { if (settled()) setRenaming(g); }}
onEditDone={(m, focusName) => { setRenaming(null); wantFocus([focusName]); if (m) setMsg(m); }}
onMove={(to) => { void setRoute(g, to); }}
onMoveBy={(d) => moveBy(g, d)}
onRemove={() => void removeGroup(g)}
onAdd={() => void addGroup(g)} />
))}
{isAdmin && <div className="tc-drop" aria-hidden="true">Drop a group here</div>}
{!isAdmin && !chips.length && <div className="tc-meta-line">No groups</div>}
</div>
</Panel>
</div>
);
})}
</div>
)}
<Msg text={msg} />
{!!unlisted.length && (
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
<span className="tc-lbl">On the register, not on the list</span>
<div style={{ display: "flex", gap: 10, flexWrap: "wrap" }}>
{unlisted.map(({ g, n }) => isAdmin
? <button key={g} className="btn btn-secondary" aria-label={`Add ${g}${n} staff member${n === 1 ? " is" : "s are"} filed under it`} onClick={() => void addGroup(g)}>Add {g} · <span className="tc-mono">{n}</span></button>
: <Tag key={g} tone="quiet">{g} · {n}</Tag>)}
</div>
</div>
)}
{isAdmin && (
<div style={{ display: "flex", gap: 10, flexWrap: "wrap" }}>
<input ref={newGroupRef} className="input" style={{ width: 280, maxWidth: "100%" }} aria-label="New group name" placeholder="New group name" maxLength={80} value={newGroup}
onChange={(e) => setNewGroup(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter" && newGroup.trim()) void addGroup(); }} />
<button className="btn btn-secondary" disabled={!newGroup.trim()} onClick={() => void addGroup()}><Icon name="plus" size={16} /> Add group</button>
</div>
)}
</>
);
}
type ChipProps = {
g: string; n: number; listed: boolean; route: AllowanceRoute; admin: boolean; editing: boolean;
onEdit: () => void; onEditDone: (msg: string | null, focusName: string) => void;
onMove: (to: AllowanceRoute) => void; onMoveBy: (dir: -1 | 1) => void; onRemove: () => void; onAdd: () => void;
};
/* Module scope so a chip keeps its identity (and its menu and rename box) across board renders. */
function GroupChip({ g, n, listed, route, admin, editing, onEdit, onEditDone, onMove, onMoveBy, onRemove, onAdd }: ChipProps) {
const { mutate } = useSnap();
const [open, setOpen] = useState(false);
const [name, setName] = useState(g);
const [err, setErr] = useState("");
const [saving, setSaving] = useState(false);
const wrap = useRef<HTMLDivElement>(null);
const btn = useRef<HTMLButtonElement>(null);
const count = <span className="tc-mono tc-chip-count">{n} {n === 1 ? "person" : "people"}</span>;
useEffect(() => { if (editing) { setName(g); setErr(""); } }, [editing, g]);
useEffect(() => {
if (!open) return;
const down = (e: MouseEvent) => { if (wrap.current && !wrap.current.contains(e.target as Node)) setOpen(false); };
document.addEventListener("mousedown", down);
requestAnimationFrame(() => wrap.current?.querySelector<HTMLElement>('[role="menuitem"]')?.focus());
return () => document.removeEventListener("mousedown", down);
}, [open]);
if (!admin) {
return (
<div className="tc-chip">
<span className="tc-chip-name">{g}</span>
{!listed && <Tag tone="quiet">not on the list</Tag>}
{count}
</div>
);
}
if (editing) {
const save = async () => {
const to = name.trim();
if (!to || saving) return;
if (to === g) { onEditDone(null, g); return; }
setSaving(true);
const r = await mutate<{ staff: number }>("settings.renameGroup", { from: g, to });
setSaving(false);
if (!r.ok) { setErr(r.error); return; }
const moved = r.result.staff;
onEditDone(`${g} is now ${to}, on the same route as before.${moved ? ` ${moved} staff record${moved === 1 ? "" : "s"} moved with it.` : ""}`, to);
};
return (
<div>
<div className="tc-chip">
<span className="tc-chip-handle" aria-hidden="true"></span>
<input className="input" autoFocus aria-label={`New name for ${g}`} maxLength={80} value={name} disabled={saving} style={{ flex: 1, minWidth: 0, minHeight: 30, padding: "2px 8px" }}
onChange={(e) => { setName(e.target.value); setErr(""); }}
onKeyDown={(e) => {
if (e.key === "Enter") { e.preventDefault(); void save(); }
else if (e.key === "Escape") { e.preventDefault(); onEditDone(null, g); }
}} />
{count}
</div>
<ErrorLine msg={err} />
</div>
);
}
const items: { label: string; run: () => void; danger?: boolean }[] = [
...ROUTES.filter((r) => r.id !== route).map((r) => ({ label: `Move to ${r.label}`, run: () => onMove(r.id) })),
...(listed ? [{ label: "Rename", run: onEdit }, { label: "Remove", run: onRemove, danger: true }] : [{ label: "Add to list", run: onAdd }]),
];
function menuKey(e: React.KeyboardEvent) {
const els = Array.from(wrap.current?.querySelectorAll<HTMLElement>('[role="menuitem"]') || []);
const at = els.indexOf(document.activeElement as HTMLElement);
const to = e.key === "ArrowDown" ? at + 1 : e.key === "ArrowUp" ? at - 1 : e.key === "Home" ? 0 : e.key === "End" ? els.length - 1 : null;
if (to !== null) { e.preventDefault(); els[(to + els.length) % els.length]?.focus(); return; }
if (e.key === "Escape") { e.preventDefault(); setOpen(false); btn.current?.focus(); }
if (e.key === "Tab") setOpen(false);
}
return (
<div className="tc-chipwrap tc-more" ref={wrap} style={{ display: "block" }} onKeyDown={open ? menuKey : undefined}>
<button ref={btn} type="button" className="tc-chip" data-group={g} draggable aria-haspopup="menu" aria-expanded={open}
aria-label={`${g}, ${n} ${n === 1 ? "person" : "people"}, ${ROUTES.find((r) => r.id === route)?.label}${listed ? "" : ", not on the list"}. Alt+arrow keys move it.`}
onClick={() => setOpen((o) => !o)}
onDoubleClick={(e) => { if (listed && (e.target as HTMLElement).closest(".tc-chip-name")) { setOpen(false); onEdit(); } }}
onDragStart={(e) => { setOpen(false); e.dataTransfer.setData("text/x-tc-group", g); e.dataTransfer.setData("text/plain", g); e.dataTransfer.effectAllowed = "move"; }}
onKeyDown={(e) => {
if (e.altKey && (e.key === "ArrowLeft" || e.key === "ArrowRight")) { e.preventDefault(); setOpen(false); onMoveBy(e.key === "ArrowLeft" ? -1 : 1); }
}}>
<span className="tc-chip-handle" aria-hidden="true"></span>
<span className="tc-chip-name">{g}</span>
{!listed && <Tag tone="quiet">not on the list</Tag>}
{count}
</button>
{open && (
<div className="tc-more-menu tc-chip-menu" role="menu" aria-label={`${g} actions`}>
{items.map((it) => (
<button key={it.label} type="button" role="menuitem" tabIndex={-1} className={"tc-more-item" + (it.danger ? " danger" : "")}
onClick={() => { setOpen(false); btn.current?.focus(); it.run(); }}>{it.label}</button>
))}
</div>
)}
</div>
);
}
+42
View File
@@ -0,0 +1,42 @@
"use client";
import { useState } from "react";
import { useSnap } from "@/lib/client";
import { Dialog, ErrorLine, Field } from "@/components/ui";
import type { UserRec } from "@/lib/compute";
/** Add a user, or edit one (role, name, new password, deactivate). */
export default 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 ? "Cant 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. Not emailed; hand it over yourself.">{(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}>Deactivate</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>
);
}
+132
View File
@@ -0,0 +1,132 @@
/* Plain-English names for audit ops, and the ops worth a mark in a list of hundreds. Moved here from
* the old /app/activity page. */
export const LABELS: Record<string, string> = {
"issue.create": "Issued garments",
"issue.return": "Recorded a return",
"issue.exchange": "Exchanged a size",
"issue.delete": "Deleted an issue",
"issue.receipt": "Attached a signed receipt",
"stocktake.apply": "Committed a stocktake",
"stock.reorder": "Changed a par level",
"stock.moves": "Adjusted stock",
"stock.orderFlagged": "Raised an order from low stock",
"catalog.add": "Added a garment",
"catalog.update": "Edited a garment",
"catalog.delete": "Deleted a garment",
"catalog.duplicate": "Duplicated a garment",
"catalog.bulk": "Bulk-changed the catalogue",
"catalog.variantAdd": "Added a size",
"catalog.removeSize": "Removed a size",
"barcode.bind": "Bound a barcode",
"barcode.unbind": "Unbound a barcode",
"order.create": "Created an order",
"order.receive": "Received an order",
"order.status": "Changed an orders status",
"order.update": "Edited an order",
"order.duplicate": "Duplicated an order",
"order.lineAdd": "Added an order line",
"order.lineQty": "Changed an order quantity",
"order.lineRemove": "Removed an order line",
"staff.save": "Added or edited a staff record",
"staff.patch": "Edited a staff record",
"staff.delete": "Deleted a staff record",
"dept.save": "Edited a department",
"dept.delete": "Deleted a department",
"supplier.add": "Added a supplier",
"supplier.update": "Edited a supplier",
"supplier.remove": "Removed a supplier",
"location.save": "Added or edited a location",
"location.delete": "Deleted a location",
"location.place": "Placed stock on a shelf",
"approval.add": "Recorded a manager's approval",
"approval.remove": "Removed a manager's approval",
"alteration.add": "Logged an alteration",
"alteration.advance": "Advanced an alteration",
"alteration.remove": "Removed an alteration",
"handin.add": "Recorded a hand-in",
"pickup.contacted": "Marked a pickup contacted",
"pickup.pickedUp": "Marked a pickup collected",
"pickup.deliver": "Delivered to a ward",
"request.raise": "Raised a request for somebody",
"request.pick": "Started picking a request",
"request.hold": "Held a request at the counter",
"request.round": "Put a request on the ward round",
"request.collected": "Handed a request over",
"request.reply": "Wrote back about a request",
"request.reassign": "Sent a request to a different approver",
"request.withdraw": "Withdrew a request",
"damage.handedIn": "Took a damaged garment back",
"dispute.resolve": "Closed a record query",
"notice.set": "Changed the ward notice",
"kitcheck.open": "Started a kit check",
"kitcheck.close": "Closed a kit check",
"waitlist.offer": "Offered a waiting size",
"staff.selfCode": "Made a staff-app activation code",
"staff.selfClear": "Cancelled an activation code",
"staff.selfUnlink": "Removed somebodys staff-app access",
"users.add": "Invited a user",
"users.update": "Changed a user",
"users.remove": "Removed a user",
"settings.update": "Changed settings",
"import.rows": "Imported data",
"backup.restore": "Restored a backup",
"data.reset": "Reset facility data",
"data.wipeActivity": "Wiped activity history",
"me.password": "Changed their own password",
"me.profile": "Edited their own profile",
"me.deleteAccount": "Deleted their own account",
/* Signing in and out, and the second factor.
*
* The page promises "every change made in this facility", and who reached the account is part of
* that — a stock adjustment nobody disputes reads differently next to a run of failed sign-ins
* from an address nobody recognises. Without these lines the trail rendered the raw op names. */
"auth:signin": "Signed in",
"auth:signin.failed": "A failed sign-in",
"auth:signin.refused": "Sign-in refused (deactivated)",
"auth:signout": "Signed out",
"auth:signup": "Created the facility",
"auth:password.reset": "Set a new password from a reset link",
"2fa:setup": "Started two-factor setup",
"2fa:enable": "Turned two-factor on",
"2fa:disable": "Turned two-factor OFF",
"2fa:regenerate": "Made new recovery codes",
/* The staff app. Every one of these is somebody on a ward changing something the linen room has
* to live with, so they belong in the same trail rather than a second one nobody opens. */
"staff:signin": "Signed in to the staff app",
"staff:signin.failed": "A failed staff-app sign-in",
"staff:signin.refused": "Staff-app sign-in refused (deactivated)",
"staff:signout": "Signed out of the staff app",
"staff:activate": "Claimed their own record",
"staff:request.create": "Raised a uniform request",
"staff:request.approve": "Approved a request (in the app)",
"staff:request.decline": "Declined a request (in the app)",
"staff:request.approve.email": "Approved a request (email link)",
"staff:request.decline.email": "Declined a request (email link)",
"staff:request.message": "Wrote about a request",
"staff:round.sign": "Signed for a ward delivery",
"staff:round.claim": "Confirmed a ward bag was collected",
"staff:damage.report": "Reported damage",
"staff:dispute.raise": "Said their record is wrong",
"staff:waitlist.join": "Joined a waiting list",
"staff:waitlist.leave": "Left a waiting list",
"staff:waitlist.accept": "Took up a waitlist offer",
"staff:kit.answer": "Answered a kit check",
"staff:account.password": "Changed their own staff-app password",
};
/** Operations worth noticing in a list of hundreds. */
export const NOTABLE = new Set([
"catalog.delete", "catalog.removeSize", "staff.delete", "dept.delete", "location.delete", "supplier.remove",
"users.add", "users.remove", "users.update", "settings.update", "backup.restore",
"data.reset", "data.wipeActivity", "me.deleteAccount", "catalog.bulk",
// Turning the second factor off weakens every account in the facility, and a refused sign-in is
// somebody with a password trying to get in after their access was taken away. Both are worth
// catching an eye in a list of hundreds.
"2fa:disable", "auth:signin.refused", "staff:signin.refused",
// The two ends of staff-app access: selfCode mints a credential that opens somebody's record,
// selfUnlink takes their account away. Both are the linen room reaching into a person's access
// rather than into stock, which is exactly what an admin is looking for when they open this.
"staff.selfCode", "staff.selfUnlink",
]);
+31
View File
@@ -0,0 +1,31 @@
"use client";
/* Export backup, shared by Data & audit log and the delete-my-account flow. Fetched rather than
* linked so the page can say how many photos the file left out, and so a lapsed session lands on
* sign-in instead of saving an error page as a backup. */
import { useState } from "react";
import { useRouter } from "next/navigation";
export function useBackupExport(say: (k: string, v: string) => void) {
const router = useRouter();
const [bkBusy, setBkBusy] = useState(false);
async function exportBackup() {
setBkBusy(true); say("backup", "Preparing the backup…");
try {
const res = await fetch("/api/backup");
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();
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; the images stay on the server.`
: `${name} downloaded.`);
router.refresh();
} catch (e) { say("backup", "Export failed — " + (e as Error).message); }
finally { setBkBusy(false); }
}
return { bkBusy, exportBackup };
}
+118
View File
@@ -0,0 +1,118 @@
"use client";
/* Pieces every Settings section shares: the result line, the debounced field saver, the section
* heading, and the few layout rules the side list and the route board need. */
import { useCallback, useEffect, useId, useRef, useState } from "react";
import { useSnap } from "@/lib/client";
import { Field, LiveRegion } from "@/components/ui";
import { LEGACY_SETTINGS_TAB } from "@/lib/manual-links";
import type { Settings } from "@/lib/compute";
export const SECTIONS = [
{ id: "facility", label: "Facility" },
{ id: "issuing", label: "Issuing rules" },
{ id: "catalogue", label: "Catalogue & suppliers" },
{ id: "places", label: "Places & cost centres" },
{ id: "people", label: "People & sign-in" },
{ id: "data", label: "Data & audit log" },
{ id: "plan", label: "Plan" },
] as const;
export type SectionId = (typeof SECTIONS)[number]["id"];
/** A ?tab= value (new id, old tab name, or #hash form) to the section it opens, and whether the
* audit log should be scrolled into view. Unknown values open Facility. */
export function resolveSection(raw: string | null | undefined): { section: SectionId; audit: boolean } {
const want = (raw || "").trim().toLowerCase();
const mapped = LEGACY_SETTINGS_TAB[want] || want;
if (mapped === "audit") return { section: "data", audit: true };
const hit = SECTIONS.find((x) => x.id === mapped);
return { section: hit ? hit.id : "facility", audit: false };
}
/* Module scope, so a live region is not rebuilt (and re-announced) on every keystroke. */
export function Msg({ text }: { text?: string }) {
return <LiveRegion msg={text} style={{ fontSize: 12, color: "var(--color-accent-700)", fontWeight: 600, whiteSpace: "pre-wrap" }} />;
}
export function TextField({ label, hint, ph, value, onChange, disabled, style }: { label: string; hint?: string; ph?: string; value: string; onChange: (v: string) => void; disabled: boolean; style?: React.CSSProperties }) {
return <Field label={label} hint={hint} style={style}>{(c) => <input {...c} className="input" placeholder={ph} value={value} onChange={(e) => onChange(e.target.value)} disabled={disabled} />}</Field>;
}
/** An h2 in the board's section style, with an optional meta line and right-hand content. */
export function SectionHead({ children, meta, right, divider = true, id }: { children: React.ReactNode; meta?: React.ReactNode; right?: React.ReactNode; divider?: boolean; id?: string }) {
return (
<div id={id} style={{ display: "flex", alignItems: "baseline", gap: 12, flexWrap: "wrap", borderTop: divider ? "2px solid var(--color-text)" : undefined, paddingTop: divider ? 16 : 0, scrollMarginTop: 72 }}>
<h2 style={{ margin: 0, fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 18, letterSpacing: "-0.01em" }}>{children}</h2>
{meta && <span className="tc-meta-line">{meta}</span>}
{right && <span style={{ marginLeft: "auto", display: "flex", gap: 10, alignItems: "center" }}>{right}</span>}
</div>
);
}
/* Debounced saves, one message map per section.
*
* Sections unmount when the side list moves on, so a save still waiting out its pause is sent
* straight away on unmount rather than dropped: typing a figure and clicking the next section must
* not lose it. */
export function useSaver() {
const { mutate } = useSnap();
const [msg, setMsg] = useState<Record<string, string>>({});
const say = useCallback((k: string, v: string) => setMsg((m) => ({ ...m, [k]: v })), []);
const [draft, setDraft] = useState<Record<string, string>>({});
const pending = useRef<Record<string, { t: ReturnType<typeof setTimeout>; run: () => void }>>({});
const draftNow = useRef(draft);
useEffect(() => { draftNow.current = draft; });
useEffect(() => {
const p = pending.current;
return () => { for (const k of Object.keys(p)) { clearTimeout(p[k].t); p[k].run(); } };
}, []);
const schedule = useCallback((k: string, run: () => void | Promise<void>, ms: number) => {
if (pending.current[k]) clearTimeout(pending.current[k].t);
const go = () => { delete pending.current[k]; void run(); };
pending.current[k] = { t: setTimeout(go, ms), run: go };
}, []);
/** Take a refused edit back out of the draft, but only if the box still says what was refused. */
const forgetDraft = useCallback((k: string, only?: string) => setDraft((d) => {
if (only !== undefined && d[k] !== only) return d;
const next = { ...d }; delete next[k]; return next;
}), []);
const debounced = useCallback((k: string, v: string, op: string, payload: Record<string, unknown>, msgKey = "fields") => {
setDraft((d) => ({ ...d, [k]: v }));
schedule(k, async () => { const r = await mutate(op, payload); say(msgKey, r.ok ? "Saved." : r.error); }, 500);
}, [mutate, say, schedule]);
return { msg, say, draft, setDraft, draftNow, forgetDraft, debounced, schedule };
}
const NUMERIC = new Set(["defaultEntitlement", "initialSets", "defaultReorder", "exceptionHigh", "capSets", "varianceReason"]);
/** Reading and saving facility settings fields through settings.update. */
export function useSettingsFields(saver: ReturnType<typeof useSaver>) {
const { s } = useSnap();
const { draft, setDraft, debounced } = saver;
const val = (k: keyof Settings) => (draft[k] !== undefined ? draft[k] : String(s.settings[k] ?? ""));
const setField = (k: keyof Settings, v: string) => {
// An emptied number box saves nothing; the stored figure stands until a number is typed.
if (NUMERIC.has(k) && v === "") { setDraft((d) => ({ ...d, [k]: v })); return; }
debounced(k, v, "settings.update", { [k]: v });
};
return { val, setField };
}
/** A small number box with a label above and a unit after, as on the Issuing rules board. */
export function InlineNumber({ label, unit, value, onChange, disabled }: { label: string; unit: string; value: string; onChange: (v: string) => void; disabled: boolean }) {
const id = useId();
return (
<div>
<label htmlFor={id} className="tc-lbl" style={{ display: "block" }}>{label}</label>
<div style={{ marginTop: 4, display: "flex", alignItems: "center", gap: 8 }}>
<input id={id} className="input tc-mono" inputMode="numeric" style={{ width: 70, textAlign: "center", fontWeight: 600 }} value={value} disabled={disabled}
onChange={(e) => onChange(e.target.value.replace(/[^0-9]/g, ""))} />
<span>{unit}</span>
</div>
</div>
);
}
/* Layout rules for this screen only. Scoped to class names nothing else wears. */
export function SettingsStyles() {
return null; // the rules are in app/globals.css under portal redesign
}
+93
View File
@@ -0,0 +1,93 @@
/* Audit targets to names.
*
* The trail stores record ids only (lib/audit.ts). Names are looked up here against the snapshot at
* read time, so the log never keeps a copy of the register. A target is the JSON the trail wrote
* ({"id":…,"staffId":…,"si":…}); anything else is treated as a bare id. */
import { fmtDate, locMap, locPath, type Snapshot } from "@/lib/compute";
export type RecordPart = { text: string; missing?: boolean };
export type NameIndex = ReturnType<typeof buildNameIndex>;
export function buildNameIndex(s: Snapshot) {
const by = <T extends { id: string }>(xs: T[]) => new Map(xs.map((x) => [x.id, x]));
return {
staff: by(s.staff), items: by(s.catalog), orders: by(s.orders), depts: by(s.depts), suppliers: by(s.supplierDir),
locations: locMap(s), users: by(s.users), issues: by(s.issues), approvals: by(s.approvals),
pickups: by(s.pickups), handins: by(s.handins), alterations: by(s.alterations), stocktakes: by(s.stocktakes),
};
}
function staffName(ix: NameIndex, id: string) {
const st = ix.staff.get(id);
return st ? `${st.first} ${st.last}${st.num ? ` (${st.num})` : ""}` : null;
}
function itemName(ix: NameIndex, id: string, si?: number) {
const it = ix.items.get(id);
if (!it) return null;
const size = si !== undefined ? it.sizes[si] : undefined;
return size ? `${it.item} · ${size}` : it.item;
}
/* Every kind a bare `id` might be, in the order the spec reads them. Kinds the snapshot holds in
full can say "removed" when an id is not there; windowed ones (uncollected pickups, recent hand-ins,
alterations and stock takes) cannot, so only the keyed ids for those kinds stay plain. */
function byAnyId(ix: NameIndex, id: string, si?: number): string | null {
return staffName(ix, id)
?? itemName(ix, id, si)
?? ix.orders.get(id)?.code
?? ix.depts.get(id)?.name
?? ix.suppliers.get(id)?.name
?? (ix.locations[id] ? locPath(ix.locations, id).map((l) => l.name).join(" · ") : null)
?? (ix.users.get(id) ? `${ix.users.get(id)!.first} ${ix.users.get(id)!.last}` : null)
?? issueName(ix, id)
?? (ix.approvals.get(id) ? `Approval · ${staffName(ix, ix.approvals.get(id)!.staffId) ?? "removed person"}` : null)
?? (ix.pickups.get(id) ? `${ix.pickups.get(id)!.orderCode} · ${staffName(ix, ix.pickups.get(id)!.staffId) ?? ""}`.replace(/ · $/, "") : null)
?? (ix.handins.get(id) ? `Hand-in · ${staffName(ix, ix.handins.get(id)!.staffId) ?? "removed person"}` : null)
?? (ix.alterations.get(id) ? `Alteration · ${staffName(ix, ix.alterations.get(id)!.staffId) ?? "removed person"}` : null)
?? (ix.stocktakes.get(id) ? `Stock take ${fmtDate(ix.stocktakes.get(id)!.date)}` : null);
}
function issueName(ix: NameIndex, id: string) {
const is = ix.issues.get(id);
if (!is) return null;
return [staffName(ix, is.staffId), itemName(ix, is.itemId, is.si)].filter(Boolean).join(" · ") || "Issue";
}
/** The parts of the Record cell for one audit target. Empty when the event names no record. */
export function recordParts(ix: NameIndex, target: string): RecordPart[] {
const t = (target || "").trim();
if (!t) return [];
let o: Record<string, unknown>;
try {
const parsed = JSON.parse(t);
o = parsed && typeof parsed === "object" ? parsed : { id: String(parsed) };
} catch { o = { id: t }; }
const si = typeof o.si === "number" ? o.si : undefined;
const str = (k: string) => (typeof o[k] === "string" && o[k] ? (o[k] as string) : null);
const parts: RecordPart[] = [];
const push = (name: string | null | undefined, id: string, canBeMissing = true) => {
if (name) { if (!parts.some((p) => p.text === name)) parts.push({ text: name }); return; }
parts.push({ text: canBeMissing ? `removed · ${id}` : id, missing: true });
};
let v: string | null;
if ((v = str("staffId"))) push(staffName(ix, v), v);
if ((v = str("subjectId"))) push(staffName(ix, v) ?? byAnyId(ix, v), v, false);
if ((v = str("itemId"))) push(itemName(ix, v, si), v);
if ((v = str("orderId"))) push(ix.orders.get(v)?.code, v);
if ((v = str("deptId"))) push(ix.depts.get(v)?.name, v);
if ((v = str("supplierId"))) push(ix.suppliers.get(v)?.name, v);
if ((v = str("locationId"))) push(ix.locations[v] ? locPath(ix.locations, v).map((l) => l.name).join(" · ") : null, v);
if ((v = str("userId"))) { const u = ix.users.get(v); push(u ? `${u.first} ${u.last}` : null, v); }
if ((v = str("issueId"))) push(issueName(ix, v), v);
if ((v = str("pickupId"))) { const p = ix.pickups.get(v); push(p ? p.orderCode : null, v, false); }
if ((v = str("stocktakeId"))) { const st = ix.stocktakes.get(v); push(st ? `Stock take ${fmtDate(st.date)}` : null, v, false); }
if ((v = str("id"))) {
const name = byAnyId(ix, v, str("itemId") ? undefined : si);
push(name, v, true);
}
return parts;
}
export function recordText(parts: RecordPart[]) {
return parts.map((p) => p.text).join(" · ");
}