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 f976bd5 on 2026-09-15. Licensed under the Functional Source License (FSL-1.1-ALv2).
This commit is contained in:
@@ -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 || "Couldn’t load the log."); return; }
|
||||
setErr("");
|
||||
setEvents((prev) => (cursor ? [...prev, ...j.events] : j.events));
|
||||
setBefore(j.nextBefore);
|
||||
setMore(!!j.nextBefore);
|
||||
} catch {
|
||||
setErr("Couldn’t 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>
|
||||
);
|
||||
}
|
||||
@@ -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 can’t 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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 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; }
|
||||
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 — you’re starting fresh." : r.error);
|
||||
setReset("");
|
||||
}}>{resetBusy ? "Emptying…" : "Empty this facility"}</button>
|
||||
</div>
|
||||
<Msg text={msg.reset} />
|
||||
</div>
|
||||
|
||||
<AuditLog scrollTo={scrollAudit} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 & 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 & 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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 don’t 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)} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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} 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.` : "";
|
||||
}
|
||||
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 can’t be removed."
|
||||
right={<button className="btn btn-ghost" onClick={exportDepts} disabled={s.depts.length === 0}>Export CSV</button>}>
|
||||
Departments & 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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 ? "Can’t 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>
|
||||
);
|
||||
}
|
||||
@@ -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 order’s 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 somebody’s 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",
|
||||
]);
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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(" · ");
|
||||
}
|
||||
Reference in New Issue
Block a user