Files
threadcount-community/app/app/activity/page.tsx
T
ThreadCount fe246ac2ef 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 b78dc63 on 2026-09-15. Licensed under the Functional Source License (FSL-1.1-ALv2).
2026-09-15 18:39:08 +10:00

278 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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 orders status",
"order.update": "Edited an order",
"order.duplicate": "Duplicated an order",
"order.lineAdd": "Added an order line",
"order.lineQty": "Changed an order quantity",
"order.lineRemove": "Removed an order line",
"staff.save": "Added or edited a staff record",
"staff.patch": "Edited a staff record",
"staff.delete": "Deleted a staff record",
"dept.save": "Edited a department",
"dept.delete": "Deleted a department",
"supplier.add": "Added a supplier",
"supplier.update": "Edited a supplier",
"supplier.remove": "Removed a supplier",
"location.save": "Added or edited a location",
"location.delete": "Deleted a location",
"location.place": "Placed stock on a shelf",
"approval.add": "Recorded a manager's approval",
"approval.remove": "Removed a manager's approval",
"alteration.add": "Logged an alteration",
"alteration.advance": "Advanced an alteration",
"alteration.remove": "Removed an alteration",
"handin.add": "Recorded a hand-in",
"pickup.contacted": "Marked a pickup contacted",
"pickup.pickedUp": "Marked a pickup collected",
"pickup.deliver": "Delivered to a ward",
"request.raise": "Raised a request for somebody",
"request.pick": "Started picking a request",
"request.hold": "Held a request at the counter",
"request.round": "Put a request on the ward round",
"request.collected": "Handed a request over",
"request.reply": "Wrote back about a request",
"request.reassign": "Sent a request to a different approver",
"request.withdraw": "Withdrew a request",
"damage.handedIn": "Took a damaged garment back",
"dispute.resolve": "Closed a record query",
"notice.set": "Changed the ward notice",
"kitcheck.open": "Started a kit check",
"kitcheck.close": "Closed a kit check",
"waitlist.offer": "Offered a waiting size",
"staff.selfCode": "Made a staff-app activation code",
"staff.selfClear": "Cancelled an activation code",
"staff.selfUnlink": "Removed somebodys staff-app access",
"users.add": "Invited a user",
"users.update": "Changed a user",
"users.remove": "Removed a user",
"settings.update": "Changed settings",
"import.rows": "Imported data",
"backup.restore": "Restored a backup",
"data.reset": "Reset facility data",
"data.wipeActivity": "Wiped activity history",
"me.password": "Changed their own password",
"me.profile": "Edited their own profile",
"me.deleteAccount": "Deleted their own account",
/* Signing in and out, and the second factor.
*
* The page promises "every change made in this facility", and who reached the account is part of
* that — a stock adjustment nobody disputes reads differently next to a run of failed sign-ins
* from an address nobody recognises. Without these lines the trail rendered the raw op names. */
"auth:signin": "Signed in",
"auth:signin.failed": "A failed sign-in",
"auth:signin.refused": "Sign-in refused (deactivated)",
"auth:signout": "Signed out",
"auth:signup": "Created the facility",
"auth:password.reset": "Set a new password from a reset link",
"2fa:setup": "Started two-factor setup",
"2fa:enable": "Turned two-factor on",
"2fa:disable": "Turned two-factor OFF",
"2fa:regenerate": "Made new recovery codes",
/* The staff app. Every one of these is somebody on a ward changing something the linen room has
* to live with, so they belong in the same trail rather than a second one nobody opens. */
"staff:signin": "Signed in to the staff app",
"staff:signin.failed": "A failed staff-app sign-in",
"staff:signin.refused": "Staff-app sign-in refused (deactivated)",
"staff:signout": "Signed out of the staff app",
"staff:activate": "Claimed their own record",
"staff:request.create": "Raised a uniform request",
"staff:request.approve": "Approved a request (in the app)",
"staff:request.decline": "Declined a request (in the app)",
"staff:request.approve.email": "Approved a request (email link)",
"staff:request.decline.email": "Declined a request (email link)",
"staff:request.message": "Wrote about a request",
"staff:round.sign": "Signed for a ward delivery",
"staff:round.claim": "Confirmed a ward bag was collected",
"staff:damage.report": "Reported damage",
"staff:dispute.raise": "Said their record is wrong",
"staff:waitlist.join": "Joined a waiting list",
"staff:waitlist.leave": "Left a waiting list",
"staff:waitlist.accept": "Took up a waitlist offer",
"staff:kit.answer": "Answered a kit check",
"staff:account.password": "Changed their own staff-app password",
};
/** Operations worth noticing in a list of hundreds. */
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 || "Couldnt load the log."); return; }
setEvents((prev) => (cursor ? [...prev, ...j.events] : j.events));
setBefore(j.nextBefore);
setMore(!!j.nextBefore);
} catch {
setErr("Couldnt 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>
);
}