ThreadCount Community edition

Uniform stock management for healthcare linen rooms: the coordinator app, the phone counter and the staff app, for your own server. Built from 2d04e45 on 2026-09-15. Licensed under the Functional Source License (FSL-1.1-ALv2).
This commit is contained in:
ThreadCount
2026-09-16 04:04:31 +10:00
commit 8c86c988c1
424 changed files with 53598 additions and 0 deletions
+47
View File
@@ -0,0 +1,47 @@
"use client";
/* Damage reported from the staff app that has not come back to the counter yet. */
import Link from "next/link";
import { useSnap } from "@/lib/client";
import { Empty } from "@/components/ui";
import { MonoNum, Panel, Tag } from "@/components/portal";
import { formatInZone } from "@/lib/compute";
import type { DamageRow } from "./RequestList";
export default function Damage({ rows, act }: { rows: DamageRow[]; act: (op: "damage.handedIn", payload: Record<string, unknown>) => Promise<boolean> }) {
const { s } = useSnap();
return (
<Panel title="Damage" count={rows.length}>
{rows.length === 0 ? (
<div style={{ padding: "0 16px" }}><Empty pad={3}>Nothing reported damaged that hasn&apos;t come back yet.</Empty></div>
) : rows.map((d) => (
<div key={d.id} className="tc-req-row">
<div className="tc-req-head" style={{ alignItems: "flex-start" }}>
{d.photoId && (
<a href={`/api/photo/${d.photoId}`} target="_blank" rel="noopener" style={{ flex: "none" }} aria-label={`Photo of the damage to ${d.staffName}s ${d.item || "garment"}`}>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={`/api/photo/${d.photoId}`} alt="" width={56} height={56} style={{ width: 56, height: 56, objectFit: "cover", border: "2px solid var(--color-text)", display: "block" }} />
</a>
)}
<div style={{ flex: 1, minWidth: 180 }}>
<div>
<b>{d.item ? d.item : "Garment no longer on file"}</b>
{d.item && d.size && <> <MonoNum>{d.size}</MonoNum></>}
<span style={{ color: "var(--color-neutral-700)" }}> · {d.staffName}{d.ward ? ` (${d.ward})` : ""}</span>
{" "}<Tag tone="accent">{d.kind}</Tag>
</div>
<div className="tc-meta-line" style={{ marginTop: 3 }}>
{formatInZone(d.at, s.tz)}
{" · "}{d.requestCode ? <>replacement <MonoNum size={12}>{d.requestCode}</MonoNum></> : "no replacement asked for"}
</div>
{d.note && <p style={{ fontSize: 13.5, lineHeight: 1.5, margin: "6px 0 0", maxWidth: "70ch" }}>&ldquo;{d.note}&rdquo;</p>}
</div>
<div className="tc-req-actions">
<button type="button" className="btn btn-secondary" onClick={() => void act("damage.handedIn", { id: d.id })}>Handed in at the counter</button>
<Link className="btn btn-ghost" href={`/app/staff/${d.staffId}`}>Open record</Link>
</div>
</div>
</div>
))}
</Panel>
);
}
+104
View File
@@ -0,0 +1,104 @@
"use client";
/* Kit check (a round asking everyone to confirm what they hold), its shortfalls, and the size
* waitlist. Starting and closing a round is admin only on the server, so it is hidden here too. */
import { useState } from "react";
import Link from "next/link";
import { useSnap } from "@/lib/client";
import { Empty, Field } from "@/components/ui";
import { MonoNum, Panel, Tag } from "@/components/portal";
import { fmtDate, formatInZone } from "@/lib/compute";
import { WAITLIST_HOLD_HOURS, holdEndsAt, holdExpired } from "@/lib/staffreq";
import type { CycleRow, ShortfallRow, WaitingRow } from "./RequestList";
type Op = "kitcheck.open" | "kitcheck.close" | "waitlist.offer";
export default function KitCheck({ cycle, shortfalls, waiting, act }: {
cycle: CycleRow | null; shortfalls: ShortfallRow[]; waiting: WaitingRow[];
act: (op: Op, payload: Record<string, unknown>) => Promise<boolean>;
}) {
const { s, isAdmin } = useSnap();
const [dueBy, setDueBy] = useState("");
return (
<div style={{ display: "flex", flexDirection: "column", gap: 18 }}>
<Panel title="Kit check" aside={cycle ? "Running" : undefined}>
<div style={{ padding: "12px 16px" }}>
{cycle ? (
<div className="tc-req-actions" style={{ justifyContent: "space-between" }}>
<span style={{ fontSize: 13.5 }}>
Due by <b>{fmtDate(cycle.dueBy)}</b>
<span className="tc-meta-line"> · <MonoNum size={12}>{cycle.answers}</MonoNum> answer{cycle.answers === 1 ? "" : "s"} in · opened by {cycle.openedBy || "—"}</span>
</span>
{isAdmin && <button type="button" className="btn btn-secondary" onClick={() => void act("kitcheck.close", { id: cycle.id })}>Close the round</button>}
</div>
) : isAdmin ? (
<div className="tc-req-actions" style={{ alignItems: "flex-end" }}>
<Field label="Due by" style={{ width: 200 }}>{(c) => <input {...c} className="input" type="date" value={dueBy} onChange={(e) => setDueBy(e.target.value)} />}</Field>
<button type="button" className="btn btn-primary" onClick={async () => { if (await act("kitcheck.open", { dueBy })) setDueBy(""); }}>Start a kit check</button>
</div>
) : (
<span className="tc-meta-line">No kit check running.</span>
)}
</div>
</Panel>
<Panel title="Couldnt account for" count={cycle ? shortfalls.length : undefined}>
{!cycle ? (
<div style={{ padding: "0 16px" }}><Empty pad={3}>No kit check is running.</Empty></div>
) : shortfalls.length === 0 ? (
<div style={{ padding: "0 16px" }}>
<Empty pad={3}>{cycle.answers === 0 ? "Nobody has answered yet." : "Every answer so far matched the record."}</Empty>
</div>
) : (
<div className="tc-req-scroll">
<table className="tc-table">
<thead><tr>
<th>Who</th><th>Garment</th><th>Size</th>
<th className="num">On record</th><th className="num">Confirmed</th><th className="num">Short</th>
<th>Answered</th><th><span className="sr-only">Record</span></th>
</tr></thead>
<tbody>
{shortfalls.map((f) => (
<tr key={f.id}>
<td>{f.staffName} <MonoNum size={12} tone="muted">{f.staffNum}</MonoNum>{f.ward && <span className="tc-meta-line"> · {f.ward}</span>}</td>
<td>{f.item}</td>
<td className="tc-mono">{f.size}</td>
<td className="num">{f.onRecord}</td>
<td className="num">{f.confirmed}</td>
<td className="num" style={{ color: "var(--color-accent-700)", fontWeight: 600, whiteSpace: "nowrap" }}><span className="tc-mark" aria-hidden="true" />{f.short}</td>
<td className="tc-mono" style={{ whiteSpace: "nowrap", fontSize: 12 }}>{formatInZone(f.at, s.tz)}</td>
<td style={{ textAlign: "right", whiteSpace: "nowrap" }}><Link href={`/app/staff/${f.staffId}`}>Open record</Link></td>
</tr>
))}
</tbody>
</table>
</div>
)}
</Panel>
<Panel title="Waiting for a size" count={waiting.length}>
{waiting.length === 0 ? (
<div style={{ padding: "0 16px" }}><Empty pad={3}>Nobody is waiting on a size.</Empty></div>
) : waiting.map((w) => {
const ends = holdEndsAt(w.offeredAt);
return (
<div key={w.id} className="tc-req-row">
<div className="tc-req-head">
<span style={{ flex: 1, minWidth: 180 }}>
<b>{w.item}</b> <MonoNum>{w.size}</MonoNum>
<span style={{ color: "var(--color-neutral-700)" }}> · {w.staffName}{w.ward ? ` (${w.ward})` : ""}</span>
<span className="tc-meta-line"> · since {formatInZone(w.since, s.tz)}</span>
</span>
{!w.offeredAt
? <button type="button" className="btn btn-secondary" title={`Tells them and holds it for ${WAITLIST_HOLD_HOURS} hours`} onClick={() => void act("waitlist.offer", { id: w.id })}>Its in offer it</button>
: holdExpired(w.offeredAt)
? <Tag tone="quiet">Hold lapsed offer to the next person</Tag>
: <Tag tone="accent">Held until {ends ? formatInZone(ends, s.tz, { day: "numeric", month: "short", hour: "numeric", minute: "2-digit" }) : "—"}</Tag>}
</div>
</div>
);
})}
</Panel>
</div>
);
}
+38
View File
@@ -0,0 +1,38 @@
"use client";
/* Record queries: somebody says their staff record is wrong. */
import Link from "next/link";
import { useSnap } from "@/lib/client";
import { Empty } from "@/components/ui";
import { MonoNum, Panel } from "@/components/portal";
import { formatInZone } from "@/lib/compute";
import type { DisputeRow } from "./RequestList";
export default function Queries({ rows, act }: { rows: DisputeRow[]; act: (op: "dispute.resolve", payload: Record<string, unknown>) => Promise<boolean> }) {
const { s } = useSnap();
// A query carries the staff number, not the record id, so the link is found on the register.
const idByNum = new Map(s.staff.map((st) => [st.num, st.id]));
return (
<Panel title="Record queries" count={rows.length}>
{rows.length === 0 ? (
<div style={{ padding: "0 16px" }}><Empty pad={3}>Nobody has queried their record.</Empty></div>
) : rows.map((d) => {
const sid = idByNum.get(d.staffNum);
return (
<div key={d.id} className="tc-req-row">
<div className="tc-req-head">
<span style={{ flex: 1, minWidth: 180 }}>
<b>{d.staffName}</b> <MonoNum size={12} tone="muted">{d.staffNum}</MonoNum>
<span className="tc-meta-line">{d.ward ? ` · ${d.ward}` : ""} · {formatInZone(d.at, s.tz)}</span>
</span>
<div className="tc-req-actions">
<button type="button" className="btn btn-secondary" onClick={() => void act("dispute.resolve", { id: d.id })}>Mark sorted</button>
{sid && <Link className="btn btn-ghost" href={`/app/staff/${sid}`}>Open record</Link>}
</div>
</div>
<p style={{ fontSize: 14, lineHeight: 1.5, margin: "6px 0 0", maxWidth: "70ch" }}>{d.body}</p>
</div>
);
})}
</Panel>
);
}
+521
View File
@@ -0,0 +1,521 @@
"use client";
/* The request queue, as one reusable list.
*
* Imported by /app/requests (the full queue), the staff record's Requests tab and Today's Pick
* group; the command panel reads useRequests(). `lines` is what was asked, `bag` is what is
* picked: the pick, the slip and the collection code are always built from `bag`, so a garment
* the ward declined never goes in somebody's hands. */
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import Link from "next/link";
import { useSnap } from "@/lib/client";
import { openSlip } from "@/components/dialogs";
import { Empty, ErrorLine } from "@/components/ui";
import { Panel, Tag } from "@/components/portal";
import { formatInZone, genderLabel, key, onhand, slipLive, type Ledger, type Snapshot } from "@/lib/compute";
import type { ReqLine } from "@/lib/staffdata";
import { NEEDS_STAFF, OPEN_REQUEST, statusText } from "@/lib/staffreq";
export type RequestMessage = { id: string; fromStaff: boolean; authorName: string; body: string; at: string };
export type RequestEvent = { id: string; label: string; meta: string; actorName: string; at: string };
/** One request exactly as GET /api/requests returns it (the API does not change). */
export type RequestRow = {
id: string; code: string; status: string; staffId: string; staffName: string; staffNum: string; ward: string;
lines: ReqLine[]; bag: ReqLine[];
summary: string; garments: number; lineCount: number; decision: string | null;
reason: string; note: string;
managerName: string; managerId: string | null;
declineReason: string | null; route: string | null;
collectCode: string | null; holdUntil: string; signerName: string | null; signerRole: string | null;
signedAt: string | null; claimedAt: string | null;
raisedById: string | null; raisedByName: string;
createdAt: string; decidedAt: string | null;
messages: RequestMessage[]; events: RequestEvent[];
};
export type DisputeRow = { id: string; body: string; staffName: string; staffNum: string; ward: string; at: string };
export type CycleRow = { id: string; dueBy: string; openedBy: string; openedAt: string; answers: number };
export type WaitingRow = { id: string; staffName: string; staffNum: string; ward: string; item: string; size: string; since: string; offeredAt: string | null };
export type DamageRow = { id: string; kind: string; note: string; photoId: string | null; staffId: string; staffName: string; staffNum: string; ward: string; item: string; size: string; requestCode: string; at: string };
export type ShortfallRow = { id: string; staffId: string; staffName: string; staffNum: string; ward: string; item: string; size: string; onRecord: number; confirmed: number; short: number; at: string };
export type RequestsPayload = {
requests: RequestRow[]; requestLimit: number; moreRequests: boolean;
/** Absent when fetched with ?staff= */
disputes?: DisputeRow[]; cycle?: CycleRow | null; shortfalls?: ShortfallRow[]; waiting?: WaitingRow[]; damage?: DamageRow[];
};
export const REQUEST_FILTERS = ["todo", "noapprover", "open", "all", "queries", "damage", "cycles"] as const;
export type RequestFilter = (typeof REQUEST_FILTERS)[number];
export type RequestRowFilter = Extract<RequestFilter, "todo" | "noapprover" | "open" | "all">;
export const REQUEST_FILTER_LABEL: Record<RequestFilter, string> = {
todo: "To do", noapprover: "Needs an approver", open: "Open", all: "All",
queries: "Record queries", damage: "Damage", cycles: "Kit check & waitlist",
};
export const isRowFilter = (f: RequestFilter): f is RequestRowFilter => f === "todo" || f === "noapprover" || f === "open" || f === "all";
/* ---------- status predicates: the only definitions screens may use ---------- */
const TODO = new Set(["accepted", "picking", "ready", "round"]);
export function isTodo(r: RequestRow): boolean { return TODO.has(r.status); }
/** Nobody was ever asked to approve it, so nobody on the ward can move it. */
export function isStranded(r: RequestRow): boolean { return r.status === "awaiting" && !r.managerName; }
export function isOpenRequest(r: RequestRow): boolean { return OPEN_REQUEST.has(r.status as never); }
export function isPickable(r: RequestRow): boolean { return r.status === "accepted"; }
type Scope = { staffId?: string; ward?: string };
const inScope = (r: { staffId?: string; ward: string }, scope?: Scope) =>
(!scope?.staffId || r.staffId === scope.staffId) && (!scope?.ward || r.ward === scope.ward);
export function requestsFor(rows: readonly RequestRow[], f: RequestRowFilter, scope?: Scope): RequestRow[] {
const pick = f === "todo" ? isTodo : f === "noapprover" ? isStranded : f === "open" ? isOpenRequest : () => true;
return rows.filter((r) => inScope(r, scope) && pick(r));
}
/** The payload narrowed to one person or one ward. Record queries and the waitlist carry no staff
* id, so a person is matched on their staff number there: pass it when you have the snapshot,
* otherwise it is read off that person's own requests, damage or shortfall rows. */
export function scopePayload(p: RequestsPayload, scope?: Scope, staffNum?: string): RequestsPayload {
if (!scope?.staffId && !scope?.ward) return p;
const num = scope.staffId
? staffNum ?? [...p.requests, ...(p.damage ?? []), ...(p.shortfalls ?? [])].find((r) => r.staffId === scope.staffId)?.staffNum
: undefined;
const byNum = (r: { staffNum: string; ward: string }) =>
(!scope.staffId || (!!num && r.staffNum === num)) && (!scope.ward || r.ward === scope.ward);
return {
...p,
requests: p.requests.filter((r) => inScope(r, scope)),
disputes: p.disputes?.filter(byNum),
waiting: p.waiting?.filter(byNum),
damage: p.damage?.filter((r) => inScope(r, scope)),
shortfalls: p.shortfalls?.filter((r) => inScope(r, scope)),
};
}
export function requestCounts(p: RequestsPayload, scope?: Scope): Record<RequestFilter, number> {
const q = scopePayload(p, scope);
return {
todo: q.requests.filter(isTodo).length,
noapprover: q.requests.filter(isStranded).length,
open: q.requests.filter(isOpenRequest).length,
all: q.requests.length,
queries: q.disputes?.length ?? 0,
damage: q.damage?.length ?? 0,
cycles: q.waiting?.length ?? 0,
};
}
/** Shelf check of a request's bag: inStock when every bag line has onhand(itemId:si) >= qty. */
export function bagStock(s: Snapshot, L: Ledger, r: RequestRow): { inStock: boolean; shortLines: ReqLine[] } {
// Two lines for the same garment and size draw on the same shelf, so they are summed first.
const want: Record<string, number> = {};
for (const l of r.bag) want[key(l.itemId, l.si)] = (want[key(l.itemId, l.si)] || 0) + l.qty;
const shortLines = r.bag.filter((l) => onhand(s, L, key(l.itemId, l.si)) < want[key(l.itemId, l.si)]);
return { inStock: shortLines.length === 0, shortLines };
}
const lineText = (l: ReqLine) => `${l.qty} × ${l.item}${l.gender && l.gender !== "Unisex" ? ` (${genderLabel(l.gender)})` : ""}${l.size}`;
/** The slip payload for openSlip() (bag lines only, collection code, cut). */
export function requestSlip(s: Snapshot, r: RequestRow): Record<string, string | number> {
return {
staffName: r.staffName, dept: r.ward, deliverTo: r.ward,
sets: r.garments, po: r.code, code: r.collectCode || "",
lines: r.bag.map(lineText).join("\n"),
dateReceived: s.today, requestedBy: r.staffNum,
deliveredBy: s.settings.coordinator, dateTime: s.today,
};
}
/* ---------- fetching: one module-level cache per key, shared by every screen ---------- */
type Entry = { at: number; data: RequestsPayload | null; error: string; loading: boolean; inflight: Promise<void> | null };
const TTL = 60_000;
const cache = new Map<string, Entry>();
const listeners = new Map<string, Set<() => void>>();
const entryOf = (k: string): Entry => {
let e = cache.get(k);
if (!e) { e = { at: 0, data: null, error: "", loading: false, inflight: null }; cache.set(k, e); }
return e;
};
const notify = (k: string) => listeners.get(k)?.forEach((fn) => fn());
function load(k: string, force: boolean): Promise<void> {
const e = entryOf(k);
if (e.inflight) return e.inflight;
if (!force && e.data && Date.now() - e.at < TTL) return Promise.resolve();
e.loading = true;
notify(k);
e.inflight = (async () => {
try {
const r = await fetch(k ? `/api/requests?staff=${encodeURIComponent(k)}` : "/api/requests");
if (!r.ok) {
const j = await r.json().catch(() => ({}));
e.error = (j && j.error) || "Couldnt load the requests.";
} else {
e.data = await r.json();
e.error = "";
e.at = Date.now();
}
} catch {
e.error = "Couldnt reach the server — requests arent loaded.";
} finally {
e.loading = false;
e.inflight = null;
notify(k);
}
})();
return e.inflight;
}
/** Fetch hook. Unscoped = the whole queue plus disputes/cycle/waiting/damage; staffId = GET /api/requests?staff=<id>.
* Results are cached per key at module level for 60s; reload() refetches. Errors are returned, never thrown. */
export function useRequests(opts?: { staffId?: string; enabled?: boolean }): { data: RequestsPayload | null; error: string; loading: boolean; reload: () => Promise<void> } {
const k = opts?.staffId || "";
const enabled = opts?.enabled !== false;
const [, bump] = useState(0);
useEffect(() => {
if (!enabled) return;
let set = listeners.get(k);
if (!set) { set = new Set(); listeners.set(k, set); }
const subs = set;
const fn = () => bump((n) => n + 1);
subs.add(fn);
void load(k, false);
return () => { subs.delete(fn); };
}, [k, enabled]);
const reload = useCallback(() => load(k, true), [k]);
if (!enabled) return { data: null, error: "", loading: false, reload };
const e = entryOf(k);
return { data: e.data, error: e.error, loading: e.loading || (!e.data && !e.error), reload };
}
type QueueOp = "request.pick" | "request.hold" | "request.round" | "request.collected" | "request.reply" | "request.reassign" | "request.withdraw" | "damage.handedIn" | "dispute.resolve" | "kitcheck.open" | "kitcheck.close" | "waitlist.offer";
/** Runs a request-queue op through mutate(), then reload(). Returns ok. */
export function useRequestActions(reload: () => Promise<void>): { act: (op: QueueOp, payload: Record<string, unknown>) => Promise<boolean>; error: string; clearError: () => void } {
const { mutate } = useSnap();
const [error, setError] = useState("");
const act = useCallback(async (op: QueueOp, payload: Record<string, unknown>) => {
setError("");
const r = await mutate(op, payload);
if (!r.ok) { setError(r.error); return false; }
// Every screen sharing this cache key sees the change, not just the one that made it.
await reload();
return true;
}, [mutate, reload]);
const clearError = useCallback(() => setError(""), []);
return { act, error, clearError };
}
/* ---------- the list ---------- */
export type RequestListProps = {
/** Scope. staffId fetches ?staff=<id> unless `data` is passed; ward filters client-side on r.ward. */
staffId?: string;
ward?: string;
/** Which rows. Default "open". */
filter?: RequestRowFilter;
/** Row expanded on first render (from ?open=). */
openId?: string | null;
/** Hide the person/ward text on each row (every row is the same person). */
hidePerson?: boolean;
/** Panel title; null renders rows without panel chrome. Default REQUEST_FILTER_LABEL[filter]. */
title?: React.ReactNode | null;
/** One-sentence empty state. Default per filter. */
emptyText?: string;
/** Called whenever data loads or changes. */
onCounts?: (counts: Record<RequestFilter, number>) => void;
/** Use an already-loaded payload (the /app/requests page shares one hook between its segment and the list). */
data?: RequestsPayload | null;
reload?: () => Promise<void>;
};
const EMPTY: Record<RequestRowFilter, string> = {
todo: "Nothing approved and waiting.",
noapprover: "Every waiting request has somebody to approve it.",
open: "No open requests.",
all: "No requests yet.",
};
const plural = (n: number, one: string, many: string) => `${n} ${n === 1 ? one : many}`;
/* Row layout rules. Kept with the component (and hoisted once by React) because only the list
* wears them; the mobile rule stretches an expanded row's actions to full width. */
/** The row styles, rendered once however many lists are on screen. */
export function RequestStyles() {
return null; // the rules are in app/globals.css under portal redesign
}
export default function RequestList({ staffId, ward, filter = "open", openId, hidePerson, title, emptyText, onCounts, data: given, reload: givenReload }: RequestListProps) {
const external = given !== undefined;
const own = useRequests({ staffId, enabled: !external });
const data = external ? given : own.data;
const reload = external ? givenReload ?? own.reload : own.reload;
const { act, error } = useRequestActions(reload);
const [openRow, setOpenRow] = useState<string | null>(openId ?? null);
useEffect(() => { if (openId) setOpenRow(openId); }, [openId]);
// Bring a deep-linked row into view once it is on screen.
const scrolled = useRef<string | null>(null);
useEffect(() => {
if (!openId || scrolled.current === openId || !data) return;
const el = document.getElementById(`req-${openId}`);
if (el) { scrolled.current = openId; el.scrollIntoView({ block: "center" }); }
}, [openId, data, filter]);
const scope = useMemo(() => ({ staffId, ward }), [staffId, ward]);
const counts = useMemo(() => (data ? requestCounts(data, scope) : null), [data, scope]);
const countsRef = useRef(onCounts);
useEffect(() => { countsRef.current = onCounts; }, [onCounts]);
useEffect(() => { if (counts) countsRef.current?.(counts); }, [counts]);
const rows = useMemo(() => (data ? requestsFor(data.requests, filter, scope) : []), [data, filter, scope]);
const pad = title === null ? 0 : "0 16px";
let body: React.ReactNode;
if (!data) {
const loadErr = external ? "" : own.error;
body = loadErr ? (
<div style={{ padding: title === null ? 0 : "0 16px 12px" }}>
<ErrorLine msg={loadErr} />
<div style={{ marginTop: 12 }}><button type="button" className="btn btn-secondary" onClick={() => void own.reload()}>Try again</button></div>
</div>
) : <div style={{ padding: "0 16px" }}><Empty pad={3}>Loading</Empty></div>;
} else if (rows.length === 0) {
// The loading and empty lines always keep side padding: embedded with no title (the staff
// record's Requests tab) the list sits directly inside a bordered panel, and a sentence flush
// against that border reads as broken. Request rows lay themselves out and keep `pad`.
body = <div style={{ padding: "0 16px" }}><Empty pad={3}>{emptyText ?? EMPTY[filter]}</Empty></div>;
} else {
body = rows.map((r) => (
<RequestRowView key={r.id} r={r} open={openRow === r.id} hidePerson={hidePerson}
onToggle={() => setOpenRow((o) => (o === r.id ? null : r.id))} act={act} />
));
}
return (
<div>
<RequestStyles />
<ErrorLine msg={error} />
{title === null
? <div>{body}</div>
: (
<div style={{ marginTop: error ? 12 : 0 }}>
<Panel title={title ?? REQUEST_FILTER_LABEL[filter]} count={data ? rows.length : undefined}>{body}</Panel>
</div>
)}
{data?.moreRequests && (
<p className="tc-meta-line" style={{ margin: "12px 0 0" }}>Only the latest {data.requestLimit} are loaded older ones are on the staff record.</p>
)}
</div>
);
}
/* Who a waiting request can be handed to. The wearer may approve their own (marked as a
* self-approval); the person who raised it never can, and the counter refuses that on the id.
* A manager with no staff-app account cannot be asked, so they are grouped apart. */
type ApproverChoice = { id: string; reachable: boolean; label: string };
function approverChoices(s: Snapshot, r: RequestRow): ApproverChoice[] {
return s.staff
.filter((x) => !x.inactive && x.first && x.id !== r.raisedById && (x.id !== r.staffId || x.managerId === x.id))
.map((x) => ({
id: x.id,
reachable: !!x.selfEmail,
label: `${`${x.first} ${x.last}`.trim()}${x.dept ? ` · ${x.dept}` : ""}`
+ (x.id === r.staffId ? " · this request is theirs — self-approval" : "")
+ (x.selfEmail ? "" : x.selfCode && slipLive(x.selfCodeAt, s.today, s.tz) ? " · code printed, not used yet" : " · no staff-app account"),
}));
}
function RequestRowView({ r, open, hidePerson, onToggle, act }: {
r: RequestRow; open: boolean; hidePerson?: boolean; onToggle: () => void;
act: (op: QueueOp, payload: Record<string, unknown>) => Promise<boolean>;
}) {
const { s } = useSnap();
const [reply, setReply] = useState("");
const [hold, setHold] = useState("");
const [reassign, setReassign] = useState("");
const st = statusText(r, { mine: false, first: r.staffName.split(" ")[0] });
const awaiting = r.status === "awaiting";
const orphan = isStranded(r);
const onRound = r.status === "round" || r.status === "delivered";
const wearerApproves = !!r.managerId && r.managerId === r.staffId;
const approval = awaiting
? (r.managerName ? `with ${r.managerName}${wearerApproves ? " · self-approval" : ""}` : "nobody asked yet")
: r.status === "declined"
? (r.decision || "declined")
: `${r.decision || "Approved"} by ${r.managerName}${wearerApproves ? " · self-approved" : ""}`;
const choices = useMemo(() => (open && awaiting ? approverChoices(s, r) : []), [open, awaiting, s, r]);
const reachable = choices.filter((c) => c.reachable);
const unreachable = choices.filter((c) => !c.reachable);
const picked = open && reassign ? s.staff.find((x) => x.id === reassign) ?? null : null;
const refused = r.lines.filter((l) => l.status === "declined").length;
function toggle() {
setReply(""); setHold(""); setReassign("");
onToggle();
}
async function send() {
if (reply.trim() && await act("request.reply", { id: r.id, body: reply })) setReply("");
}
const orderForm = (
<button type="button" className="btn btn-secondary" aria-label={`Print order form for ${r.code}`}
title={awaiting ? "Everything asked for, managers block blank" : "The approved lines only"}
onClick={() => window.open(`/print/order-form?request=${encodeURIComponent(r.id)}`, "_blank", "noopener")}>
Print order form
</button>
);
return (
<div id={`req-${r.id}`} className={"tc-req-row" + (orphan ? " tc-flag" : "")} style={{ opacity: awaiting && !orphan && !open ? 0.6 : 1 }}>
<div className="tc-req-head">
<span className="tc-mono" style={{ fontSize: 12, fontWeight: 500, width: 74, flex: "none" }}>{r.code}</span>
<span style={{ flex: 1, minWidth: 160 }}>
<b>{r.summary}</b>
{!hidePerson && <span style={{ color: "var(--color-neutral-700)" }}> · {r.staffName}{r.ward ? ` (${r.ward})` : ""}</span>}
</span>
{orphan && <Tag tone="accent">No approver</Tag>}
<Tag tone={NEEDS_STAFF.has(r.status as never) ? "accent" : "quiet"}>{st.label}</Tag>
<button type="button" className="btn btn-ghost" style={{ minHeight: 30, padding: "2px 8px" }} aria-expanded={open} onClick={toggle}
aria-label={open ? `Close ${r.code}` : `Open ${r.code}`}>
{open ? "Close" : `Open${r.lineCount > 1 ? ` · ${r.lineCount} lines` : ""}`}
</button>
</div>
<div className="tc-meta-line tc-req-meta">
{[r.reason, approval, r.raisedByName ? `raised by ${r.raisedByName}` : "", formatInZone(r.createdAt, s.tz)].filter(Boolean).join(" · ")}
</div>
{open && (
<div className="tc-req-body">
<div>
{r.lines.map((l) => {
const off = l.status === "declined";
return (
<div key={l.id} className="tc-req-item">
<span style={{ flex: 1, minWidth: 160, fontWeight: off ? 400 : 600, textDecoration: off ? "line-through" : "none", color: off ? "var(--color-neutral-700)" : undefined }}>
{l.qty} × {l.item}{l.gender && l.gender !== "Unisex" ? ` (${genderLabel(l.gender)})` : ""} <span className="tc-mono">{l.size}</span>
</span>
<Tag tone={l.status === "approved" ? "quiet" : off ? "outline" : "low"}>{l.statusLabel}</Tag>
{off && l.declineReason && <span className="tc-meta-line">{l.declineReason}</span>}
</div>
);
})}
<div className="tc-meta-line" style={{ marginTop: 4 }}>
{r.status === "declined" ? "Nothing to pick"
: awaiting ? `${plural(r.garments, "garment", "garments")} asked for`
: `In the bag: ${plural(r.garments, "garment", "garments")}${refused ? ` · ${plural(refused, "declined line", "declined lines")} not picked` : ""}`}
</div>
</div>
{r.note && <p style={{ fontSize: 13.5, lineHeight: 1.5, margin: "10px 0 0", maxWidth: "70ch" }}>&ldquo;{r.note}&rdquo;</p>}
<div className="tc-req-actions" style={{ marginTop: 12 }}>
{awaiting && (
<>
<select className="input" style={{ width: 280, maxWidth: "100%" }} value={reassign} onChange={(e) => setReassign(e.target.value)}
aria-label={orphan ? `Choose who approves ${r.code}` : `Send ${r.code} to a different approver`}>
<option value="">{orphan ? "Choose an approver…" : "Send it to somebody else…"}</option>
{unreachable.length === 0
? reachable.map((c) => <option key={c.id} value={c.id}>{c.label}</option>)
: (
<>
{reachable.length > 0 && (
<optgroup label="Can decide it today">
{reachable.map((c) => <option key={c.id} value={c.id}>{c.label}</option>)}
</optgroup>
)}
<optgroup label="Cant be asked — no staff-app account">
{unreachable.map((c) => <option key={c.id} value={c.id}>{c.label}</option>)}
</optgroup>
</>
)}
</select>
<button type="button" className="btn btn-primary"
onClick={async () => { if (reassign && await act("request.reassign", { id: r.id, managerId: reassign })) setReassign(""); }}>
{orphan ? "Ask them" : "Re-address"}
</button>
<button type="button" className="btn btn-secondary"
onClick={() => { if (confirm(`Withdraw ${r.code}? ${r.staffName} is told it was declined by the linen room.`)) void act("request.withdraw", { id: r.id, reason: "Withdrawn — no approver available" }); }}>
Withdraw it
</button>
{orderForm}
</>
)}
{r.status === "accepted" && <button type="button" className="btn btn-primary" onClick={() => void act("request.pick", { id: r.id })}>Start picking</button>}
{r.status === "picking" && (
<>
<input className="input" style={{ width: 200 }} aria-label={`Held until, for ${r.code}`} placeholder="Held until — e.g. Fri 6pm" value={hold} onChange={(e) => setHold(e.target.value)} />
<button type="button" className="btn btn-primary" onClick={async () => { if (await act("request.hold", { id: r.id, holdUntil: hold })) setHold(""); }}>Hold at the counter</button>
<button type="button" className="btn btn-secondary" onClick={() => void act("request.round", { id: r.id })}>Send on the ward round</button>
</>
)}
{r.status === "ready" && (
<>
<span style={{ fontSize: 13 }}>
Code <span className="tc-mono" style={{ fontSize: 15, fontWeight: 600 }}>{r.collectCode}</span> · {plural(r.garments, "garment", "garments")}{r.holdUntil ? ` · until ${r.holdUntil}` : ""}
</span>
<button type="button" className="btn btn-primary" onClick={() => void act("request.collected", { id: r.id })}>Collected</button>
</>
)}
{r.status === "round" && <span className="tc-meta-line" style={{ fontSize: 13 }}>On the round to {r.ward || "the ward"} · {plural(r.garments, "garment", "garments")}</span>}
{r.status === "delivered" && <span className="tc-meta-line" style={{ fontSize: 13 }}>Signed by {r.signerName}{r.signerRole ? `, ${r.signerRole}` : ""}{r.claimedAt ? " · collected" : " · not yet collected from the ward"}</span>}
{r.status === "collected" && <span className="tc-meta-line" style={{ fontSize: 13 }}>Handed over at the counter</span>}
{r.status === "declined" && <span className="tc-meta-line" style={{ fontSize: 13 }}>Declined {r.declineReason || "no reason recorded"}</span>}
{!awaiting && r.status !== "declined" && (
<>
<button type="button" className="btn btn-secondary" onClick={() => openSlip(onRound ? "delivery" : "collection", requestSlip(s, r))}>
{onRound ? "Delivery slip" : "Collection slip"}
</button>
{orderForm}
</>
)}
</div>
{awaiting && choices.length === 0 && (
<p className="tc-meta-line" style={{ margin: "8px 0 0" }}>Nobody on the register can approve this one <Link href="/app/staff">add a manager</Link> or withdraw it.</p>
)}
{picked && picked.id === r.staffId && (
<p className="tc-meta-line" style={{ margin: "8px 0 0" }}>{picked.first}&apos;s own request sending it to them is a self-approval.</p>
)}
{picked && !picked.selfEmail && (
<p className="tc-meta-line" style={{ margin: "8px 0 0" }}>
{picked.first} can&apos;t be asked:{" "}
{picked.selfCode && slipLive(picked.selfCodeAt, s.today, s.tz)
? <>the code on <Link href={`/app/staff/${picked.id}`}>their record</Link> isn&apos;t used yet.</>
: picked.selfCode
? <>the code on <Link href={`/app/staff/${picked.id}`}>their record</Link> has expired.</>
: <>no staff-app account <Link href={`/app/staff/${picked.id}`}>give them a code</Link>.</>}
</p>
)}
<h3 className="tc-lbl tc-req-sub">Messages</h3>
{r.messages.length === 0 && <div className="tc-meta-line" style={{ padding: "4px 0" }}>No messages.</div>}
{r.messages.map((m) => (
<div key={m.id} className="tc-req-item" style={{ display: "block" }}>
<b>{m.fromStaff ? m.authorName : `${m.authorName} (linen room)`}</b>
<span className="tc-meta-line" style={{ marginLeft: 8 }}>{formatInZone(m.at, s.tz)}</span>
<div style={{ marginTop: 3, lineHeight: 1.5 }}>{m.body}</div>
</div>
))}
<div className="tc-req-actions" style={{ marginTop: 8 }}>
<input className="input" style={{ flex: 1, minWidth: 200 }} aria-label={`Reply about ${r.code}`} placeholder="Reply to this order" value={reply}
onChange={(e) => setReply(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") void send(); }} />
<button type="button" className="btn btn-secondary" onClick={() => void send()}>Send</button>
</div>
<h3 className="tc-lbl tc-req-sub">History</h3>
{r.events.map((e) => (
<div key={e.id} className="tc-req-item">
<span style={{ flex: 1, minWidth: 160 }}>{e.label}{e.meta ? `${e.meta}` : ""}</span>
<span className="tc-meta-line">{e.actorName} · {formatInZone(e.at, s.tz)}</span>
</div>
))}
</div>
)}
</div>
);
}
+94
View File
@@ -0,0 +1,94 @@
/* The request queue's CSV exports, one file per filter, exactly the files the queue wrote before
* the redesign. Times are full dates in the facility's zone with the zone named in the preamble,
* because a spreadsheet is re-sorted the moment it lands. Request rows are one per garment line,
* so a declined fleece is one filter away. */
import { csvEsc, csvOf, facilityDate, formatInZone, genderLabel, type Snapshot } from "@/lib/compute";
import { downloadCsv } from "@/lib/print";
import type { ReqLine } from "@/lib/staffdata";
import { holdEndsAt, holdExpired, statusText } from "@/lib/staffreq";
import { REQUEST_FILTER_LABEL, requestsFor, type RequestFilter, type RequestsPayload } from "./RequestList";
/** How many rows the export for this filter writes (what "{n} shown" counts). */
export function exportCount(f: RequestFilter, p: RequestsPayload): number {
if (f === "queries") return p.disputes?.length ?? 0;
if (f === "damage") return p.damage?.length ?? 0;
if (f === "cycles") return (p.shortfalls?.length ?? 0) + (p.waiting?.length ?? 0);
return requestsFor(p.requests, f).length;
}
/** Writes the file for filter `f` from an already-scoped payload. `showing` names the scope. */
export function exportRequestsCsv(s: Snapshot, f: RequestFilter, p: RequestsPayload, showing?: string) {
const when = (iso: string | null) =>
iso ? `${facilityDate(iso, s.tz)} ${formatInZone(iso, s.tz, { hour: "2-digit", minute: "2-digit", hour12: false, hourCycle: "h23" })}` : "";
const preamble = (facts: [string, string | number][]) =>
[...facts, ...(showing ? ([["Showing", showing]] as [string, string][]) : [])]
.map(([k, v]) => `${csvEsc(k)},${typeof v === "number" ? v : csvEsc(v)}`).join("\n") + "\n\n";
if (f === "queries") {
const rows = p.disputes ?? [];
downloadCsv(`threadcount-record-queries-${s.today}.csv`,
preamble([["Record queries", "Raised against a staff record, not yet sorted"], ["Exported", s.today], ["Times shown in", s.tz], ["Queries in this file", rows.length]])
+ csvOf(["Raised", "Staff no.", "Staff member", "Ward", "What they say is wrong"],
rows.map((d) => [when(d.at), d.staffNum, d.staffName, d.ward, d.body])));
return;
}
if (f === "damage") {
const rows = p.damage ?? [];
downloadCsv(`threadcount-damage-${s.today}.csv`,
preamble([["Damage reported", "Not yet handed in at the counter"], ["Exported", s.today], ["Times shown in", s.tz], ["Reports in this file", rows.length]])
+ csvOf(["Reported", "Staff no.", "Staff member", "Ward", "Garment", "Size", "Damage", "What they said", "Replacement requested", "Photo"],
rows.map((d) => [when(d.at), d.staffNum, d.staffName, d.ward, d.item || "Garment no longer on file", d.size, d.kind, d.note, d.requestCode, d.photoId ? "Yes" : "No"])));
return;
}
if (f === "cycles") {
const c = p.cycle ?? null;
downloadCsv(`threadcount-kit-check-${s.today}.csv`,
preamble([
["Kit check and waitlist", c ? `Running — due by ${c.dueBy}` : "No kit check running"],
["Opened by", c ? c.openedBy || "—" : ""],
["Answers in", c ? c.answers : 0],
["Exported", s.today],
["Times shown in", s.tz],
])
+ "What people couldn't account for\n"
+ csvOf(["Staff no.", "Staff member", "Ward", "Garment", "Size", "On record", "Confirmed", "Short", "Answered"],
(p.shortfalls ?? []).map((x) => [x.staffNum, x.staffName, x.ward, x.item, x.size, x.onRecord, x.confirmed, x.short, when(x.at)]))
+ "\nWaiting for a size\n"
+ csvOf(["Staff no.", "Staff member", "Ward", "Garment", "Size", "Waiting since", "Offered", "Held until", "Hold"],
(p.waiting ?? []).map((w) => {
const ends = holdEndsAt(w.offeredAt);
return [w.staffNum, w.staffName, w.ward, w.item, w.size, when(w.since), when(w.offeredAt),
ends ? when(ends.toISOString()) : "",
!w.offeredAt ? "Not offered yet" : holdExpired(w.offeredAt) ? "Lapsed — offer to the next person" : "Held"];
})));
return;
}
const rows = requestsFor(p.requests, f);
downloadCsv(`threadcount-requests-${f === "noapprover" ? "needs-an-approver" : f}-${s.today}.csv`,
preamble([
["Ward requests", REQUEST_FILTER_LABEL[f]],
["Exported", s.today],
["Times shown in", s.tz],
["Requests in this file", rows.length],
...(p.moreRequests
? ([["Older requests not in this file", `The screen holds the most recent ${p.requestLimit} requests and there are older ones than those`]] as [string, string][])
: []),
["Rows", "One per line on the request — a request for a tunic and two pairs of trousers is two rows, and the pairs are a Qty of 2 on the second"],
])
+ csvOf(["Request", "Raised", "Staff no.", "Staff member", "Ward", "Raised by", "Reason", "Note", "Request status", "Approver", "Approver is the wearer", "Decision summary", "Decided", "Request decline reason", "Collection code", "Garment", "Cut", "Size", "Qty", "Line decision", "Line decline reason"],
rows.flatMap((r) => {
const req: (string | number)[] = [
r.code, when(r.createdAt), r.staffNum, r.staffName, r.ward, r.raisedByName, r.reason, r.note,
statusText(r).label, r.managerName, r.managerId && r.managerId === r.staffId ? "Yes" : "",
r.decision ?? "", when(r.decidedAt), r.declineReason ?? "", r.collectCode ?? "",
];
// A request with no lines still appears: it is sitting in somebody's queue.
const lines: (ReqLine | null)[] = r.lines.length ? r.lines : [null];
return lines.map((l) => [...req,
l ? l.item : "", l ? genderLabel(l.gender) : "", l ? l.size : "", l ? l.qty : "",
l ? l.statusLabel : "", l ? l.declineReason ?? "" : ""]);
})));
}