fe246ac2ef
Uniform stock management for healthcare linen rooms: the coordinator app, the phone counter and the staff app, for your own server. Built from b78dc63 on 2026-09-15. Licensed under the Functional Source License (FSL-1.1-ALv2).
278 lines
14 KiB
TypeScript
278 lines
14 KiB
TypeScript
"use client";
|
||
/* Who changed what.
|
||
*
|
||
* Reads from its own endpoint rather than the snapshot: the trail grows without limit and putting
|
||
* it in the snapshot would make every page in the app heavier forever, to serve a screen almost
|
||
* nobody opens on an ordinary day.
|
||
*
|
||
* It shows ids rather than names on purpose — see lib/audit.ts. The id is the handle for going and
|
||
* looking at the record; copying its contents in here would quietly build a second, unmanaged copy
|
||
* of the staff register. */
|
||
import { useCallback, useEffect, useState } from "react";
|
||
import { useSnap } from "@/lib/client";
|
||
import { Empty, LiveRegion, PageHead } from "@/components/ui";
|
||
import { csvEsc, csvOf, facilityDate, formatInZone } from "@/lib/compute";
|
||
import { downloadCsv } from "@/lib/print";
|
||
|
||
type Event = { id: string; at: string; who: string; op: string; target: string };
|
||
|
||
/* Operation names are written for the code. These are written for whoever is reading the log at
|
||
the point somebody asks what happened. */
|
||
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. */
|
||
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",
|
||
]);
|
||
|
||
/* Stamped in the facility's own zone, not the browser's. An audit trail read on a laptop that is
|
||
travelling, or served by a machine set to UTC, has to agree with the clock on the linen-room wall
|
||
or the times are worse than useless in a dispute. */
|
||
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 Activity() {
|
||
const { s, isAdmin } = 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 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; }
|
||
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(() => { if (isAdmin) void load(null); else setLoading(false); }, [isAdmin, load]);
|
||
|
||
/* The file is what is on screen, and nothing more — in two senses.
|
||
*
|
||
* The log is paged, so what comes out is what has been loaded. If the question reaches further
|
||
* back than the screen does, press Load older first and export again; the file states on its
|
||
* face how far it goes, so a first page can never be handed over as though it were the whole
|
||
* trail. And no column appears that the screen does not show: the trail also records the address
|
||
* each change came from, which is why the endpoint never sends it to this page, and a file that
|
||
* leaves the building by email is the last place to start handing that around. */
|
||
function exportCsv() {
|
||
/* Full date, and seconds — neither of which the table needs, because you read it in order.
|
||
A spreadsheet gets re-sorted the moment it lands: "9 Sep, 14:32" sorts as text into nonsense
|
||
and carries no year at all, and two changes inside the same minute would lose the order they
|
||
happened in, which is the whole question when a figure is disputed. The zone is the
|
||
facility's, the same as the screen, and it is named at the top of the file so a copy opened
|
||
in another state is not quietly read as local time. */
|
||
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" })}`;
|
||
/* The four headings are the table's, and mean the same four things: What is the plain-English
|
||
label the screen shows rather than the op name behind it, and Record is the identifier
|
||
exactly as shown — left blank rather than carrying the screen's dash, which in a spreadsheet
|
||
cell is only noise. */
|
||
const reach = more ? `${events.length} (older events not loaded)` : `${events.length} (the whole log)`;
|
||
downloadCsv(`threadcount-activity-${s.today}.csv`,
|
||
`Activity log,${csvEsc(s.today)}\nTimes shown in,${csvEsc(s.tz)}\nEvents in this file,${csvEsc(reach)}\n\n`
|
||
+ csvOf(["When", "Who", "What", "Record"], events.map((e) => [stamp(e.at), e.who, LABELS[e.op] || e.op, e.target])));
|
||
}
|
||
|
||
if (!isAdmin) {
|
||
return (
|
||
<section>
|
||
<PageHead eyebrow="Admin" title="Activity" />
|
||
<Empty>Only an admin can read the change log.</Empty>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<section>
|
||
<PageHead eyebrow="Admin" title="Activity" sub="Every change made in this facility, newest first — who made it and when.">
|
||
<button className="btn btn-ghost" onClick={exportCsv} disabled={events.length === 0} title="Downloads the events shown.">
|
||
{more ? `Export CSV (${events.length} shown)` : "Export CSV"}
|
||
</button>
|
||
</PageHead>
|
||
|
||
<LiveRegion tone="alert" msg={err} style={{ marginTop: 16, background: "var(--color-accent-600)", color: "#fff", padding: "10px 12px", fontWeight: 600 }} />
|
||
|
||
{/* The log is one block with its own head and foot rather than a table adrift on the page:
|
||
how far back it reaches is the first thing anybody asks of it, so the count sits on the
|
||
block itself and Load older sits under the same border as the rows it extends. */}
|
||
<div className="tc-panel" style={{ marginTop: "var(--space-5)" }}>
|
||
<div className="tc-panel-head">
|
||
<span>Change log</span>
|
||
<span className="tc-panel-aside">{events.length} shown{more ? " · older events not loaded" : ""}</span>
|
||
</div>
|
||
<div className="table-wrap">
|
||
<table className="table" style={{ minWidth: 620 }}>
|
||
<thead>
|
||
<tr><th style={{ width: 150 }}>When</th><th style={{ width: 190 }}>Who</th><th>What</th><th style={{ width: 220 }}>Record</th></tr>
|
||
</thead>
|
||
<tbody>
|
||
{events.map((e) => {
|
||
const notable = NOTABLE.has(e.op);
|
||
return (
|
||
<tr key={e.id}>
|
||
<td style={{ whiteSpace: "nowrap", fontVariantNumeric: "tabular-nums" }}>{when(e.at, s.tz)}</td>
|
||
<td>{e.who}</td>
|
||
{/* A line worth stopping on is marked as well as coloured. The accent is the
|
||
brand — it is the primary button and the current menu item — so a second red
|
||
in a list of hundreds is a guess; the mark beside the words is what actually
|
||
says "this one". Decoration, so it is hidden from a screen reader: the
|
||
wording of the line is the message. */}
|
||
<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={{ fontFamily: "monospace", fontSize: 11.5, color: "var(--color-neutral-700)", wordBreak: "break-all" }}>{e.target || "—"}</td>
|
||
</tr>
|
||
);
|
||
})}
|
||
{!events.length && !loading && (
|
||
<tr><td colSpan={4} style={{ color: "var(--color-neutral-700)" }}>Nothing recorded yet.</td></tr>
|
||
)}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
<div className="tc-panel-foot" style={{ display: "flex", gap: "var(--space-3)", alignItems: "center", flexWrap: "wrap" }}>
|
||
{more && <button className="btn btn-secondary" onClick={() => load(before)} disabled={loading}>{loading ? "Loading…" : "Load older"}</button>}
|
||
<span style={{ fontSize: 12.5, color: "var(--color-neutral-700)" }}>{events.length} shown{more ? " — Export CSV writes these, so load the older events first if the file has to reach further back." : ". Export CSV writes the whole log."}</span>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|