Files
threadcount-community/app/app/page.tsx
T
ThreadCount d9b8d80b11 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 65e298f on 2026-09-13. Licensed under the Functional Source License (FSL-1.1-ALv2).
2026-09-13 11:20:54 +10:00

179 lines
13 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";
import Link from "next/link";
import { useEffect, useMemo, useState } from "react";
import { useDerived, useSnap } from "@/lib/client";
import { PageHead, Empty, ErrorLine, KpiStrip } from "@/components/ui";
import { NewOrderDialog, openSlip } from "@/components/dialogs";
import { capState, daysBetween, fyStart, heldByStaff, isOpen, isOverdue, label, money, onhand, orderTotal, reorderAt, setsCap, staffName, touched, telHref, type GarmentCounts } from "@/lib/compute";
/** Somebody with nothing out and nothing owed. heldByStaff only lists people holding something, and
* they have to be read as holding none rather than skipped. */
const NOTHING: GarmentCounts = { tops: 0, pants: 0, other: 0, sets: 0 };
export default function Dashboard() {
const { s, mutate } = useSnap();
const { L, byId, staffById, variants } = useDerived();
const [newOrder, setNewOrder] = useState(false);
const [welcome, setWelcome] = useState(false);
const [err, setErr] = useState("");
useEffect(() => { if (new URLSearchParams(window.location.search).get("welcome") === "1") setWelcome(true); }, []);
// The call list is the one place two people work the same rows at once — one marks a bag
// collected at the counter while somebody else is still on the phone about it. A refusal there
// has to be said out loud, or the second click reads as the first one not having taken.
async function act(op: string, payload: unknown) { setErr(""); const r = await mutate(op, payload); if (!r.ok) setErr(r.error); }
const d = useMemo(() => {
const openOrders = s.orders.filter(isOpen);
const overdue = s.orders.filter((o) => isOverdue(o, s.today));
const pickupQ = s.pickups.filter((p) => !p.pickedUp);
const wait14 = pickupQ.filter((p) => daysBetween(p.received, s.today) >= 14);
// Spend = orders actually placed this month (drafts, incl. auto-replenishment, are not spend yet).
const mtd = s.orders.filter((o) => o.date.slice(0, 7) === s.today.slice(0, 7) && o.status !== "Cancelled" && o.status !== "Draft" && !o.parentId).reduce((t, o) => t + orderTotal(o, byId), 0);
const lowRows: { label: string; size: string; onhand: number; reorder: number }[] = [];
for (const v of variants) { const oh = onhand(s, L, v.key), ro = reorderAt(s, v.key); if (touched(s, L, v.key) && oh <= ro) lowRows.push({ label: label(v.item), size: v.size, onhand: oh, reorder: ro }); }
// Who holds more than one person holds: what is out with them and what is owed to them, against
// six sets at any time. Asked exactly the way the staff register asks it, so the two screens
// always give the same count. It is not what anybody drew this year. By that measure a new
// starter handed three sets on Monday has had a year's worth and is nowhere near the ceiling, and
// a tile calling her over sends a coordinator after somebody the counter would serve without a
// second look.
//
// There is no "nearly there" count beside it. Holding the full six is where somebody fully kitted
// is meant to be, not a warning, so on a settled ward it would be most of the ward.
const held = heldByStaff(s);
const over = s.staff.filter((st) => !st.inactive && capState({ held: held[st.id] || NOTHING, capSets: s.settings.capSets }).over).length;
const cap = setsCap(s.settings.capSets);
const fy = fyStart(s.today);
return { openOrders, overdue, pickupQ, wait14, mtd, lowRows, over, cap, fy };
}, [s, L, byId, variants]);
/* The tiles are the read from the doorway: four things somebody has to act on today. Each one is
either quiet or flagged, and a flagged tile says so three ways — a rule down its edge, a mark
against the figure, and a note in plain words — because the vermilion is already the brand
colour on the rail and on every primary button, and a second red across the room is a guess
rather than a signal.
Nothing on a tile is repeated in the registers below it. A figure printed twice on one screen is
two chances to disagree, and the linen room reads whichever one it happened to land on. */
const tiles = [
{ label: "Awaiting pickup", val: d.pickupQ.length, note: d.wait14.length ? `${d.wait14.length} waiting a fortnight or more` : "bags received and not collected", flag: d.wait14.length > 0 },
{ label: "Overdue for delivery", val: d.overdue.length, note: d.overdue.length ? "past the date the supplier gave" : "nothing past its delivery date", flag: d.overdue.length > 0 },
{ label: "Lines at reorder", val: d.lowRows.length, note: d.lowRows.length ? "at or below their reorder level" : "every line above its reorder level", flag: d.lowRows.length > 0 },
{ label: "Over the ceiling", val: d.over, note: d.over ? `past the ${d.cap} sets one person holds` : `nobody past the ${d.cap} sets one person holds`, flag: d.over > 0 },
];
// The facts that are not a task: true of the facility, worth a glance, never the reason somebody
// walks to the counter. Three registers, one per part of the job.
const registers: { title: string; rows: [string, React.ReactNode][] }[] = [
{ title: "Orders", rows: [["Open orders", d.openOrders.length], ["Month-to-date spend", money(d.mtd)]] },
{ title: "Stock", rows: [
["Pre-loved pool", (() => { let u = 0, sz = 0; for (const k in s.stock) if (s.stock[k].preloved > 0) { u += s.stock[k].preloved; sz++; } return u ? `${u} across ${sz} size${sz === 1 ? "" : "s"}` : "0"; })()],
["Issues recorded (FY)", s.issues.filter((i) => i.date >= d.fy).length],
["Stocktake adjustments", Object.values(s.stock).filter((x) => x.adj).length],
] },
// "Receipts not yet signed" reads handedIn as well as returned: a garment handed back at the
// counter is stamped handedIn and never gets a returned date, so it can never be signed for.
// Counting those made the figure a queue that only ever grew, which is how a number the linen
// room is meant to work down stops being read at all.
{ title: "Staff", rows: [["On the register", s.staff.filter((st) => !st.inactive).length], ["Receipts not yet signed", s.issues.filter((i) => !i.receipt && !i.returned && !i.handedIn).length]] },
];
const tasks = d.pickupQ.map((p) => ({ p, st: staffById[p.staffId], days: daysBetween(p.received, s.today) })).sort((a, b) => b.days - a.days);
const setupNeeded = s.catalog.length === 0 || s.staff.length === 0;
return (
<section>
<PageHead eyebrow="Overview" title="Dashboard">
<Link href="/app/issue" className="btn btn-primary">Issue stock</Link>
<button className="btn btn-secondary" onClick={() => setNewOrder(true)}>New order</button>
</PageHead>
{(welcome || setupNeeded) && (
<div className="tc-panel" style={{ marginBottom: "var(--space-6)" }}>
<div className="tc-panel-body" style={{ display: "flex", gap: "var(--space-4)", alignItems: "center", flexWrap: "wrap" }}>
<div style={{ flex: 1, minWidth: 240 }}>
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 18 }}>{welcome ? "Welcome to ThreadCount" : "Finish setting up"}</div>
<div style={{ fontSize: 13, color: "var(--color-neutral-800)", marginTop: 4, lineHeight: 1.6 }}>
{s.catalog.length === 0 ? "Your catalogue is empty — import it from a CSV in Settings → Data. " : ""}
{s.staff.length === 0 ? "The staff register is empty — add staff or import a CSV. " : ""}
{!setupNeeded && "Your facility is set up. Set reorder levels on the Inventory screen and start issuing."}
</div>
</div>
<Link href="/app/settings?tab=data" className="btn btn-primary">Open Settings Data</Link>
{welcome && <button className="btn btn-ghost" onClick={() => setWelcome(false)}>Dismiss</button>}
</div>
</div>
)}
<KpiStrip items={tiles} />
<div className="tc-grid" style={{ display: "grid", gridTemplateColumns: "3fr 2fr", gap: "var(--space-6)", marginTop: "var(--space-6)" }}>
<div className="tc-panel">
<div className="tc-panel-head">
<div>Awaiting pickup call list</div>
<div className="tc-panel-aside">sorted by days waiting</div>
</div>
{/* ErrorLine draws nothing when there is nothing to say, so this wrapper collapses with it
rather than opening a gap above the first row. */}
<div style={{ padding: "0 var(--space-4)" }}><ErrorLine msg={err} /></div>
{tasks.length === 0 && <div className="tc-panel-body"><Empty pad={2}>Nothing waiting to be collected.</Empty></div>}
<div className="tc-panel-list">
{tasks.map(({ p, st, days }) => {
const ord = s.orders.find((o) => o.id === p.orderId);
const late = days >= 14;
return (
<div key={p.id} className={"tc-row" + (late ? " tc-flag" : "")} style={{ flexWrap: "wrap" }}>
<div className="tc-row-fig" style={{ width: 48, flex: "none" }}>{days}d</div>
<div className="tc-row-main">
<div className="tc-row-name">{staffName(st, "Staff")} {telHref(st?.phone) ? <a href={telHref(st?.phone)} style={{ fontWeight: 400, color: "var(--color-neutral-700)" }}>{st?.phone}</a> : <span style={{ fontWeight: 400, color: "var(--color-neutral-700)" }}>{st?.phone}</span>}</div>
<div className="tc-row-meta">{late && <span className="tc-mark" aria-hidden="true" />}{late ? "Waiting a fortnight or more · " : ""}{p.lines.map((l) => `${label(byId[l.itemId])} ${l.size} ×${l.qty}`).join(", ")} · {p.orderCode}</div>
</div>
{p.contacted ? <span className="tag tag-neutral">Contacted</span> : <button className="btn btn-ghost" onClick={() => act("pickup.contacted", { id: p.id })}>Mark contacted</button>}
<button className="btn btn-ghost" onClick={() => openSlip("collection", { staffName: staffName(st), dept: st?.dept, sets: p.lines.reduce((t, l) => t + l.qty, 0), po: ord?.ref || ord?.code || "", dateReceived: p.received, notifiedPhone: p.contacted, dateNotified: "" })}>Collection slip</button>
<button className="btn btn-secondary" onClick={() => act("pickup.pickedUp", { id: p.id })}>Picked up</button>
</div>
);
})}
</div>
</div>
<div className="tc-panel">
{/* Every row in here is by definition at its reorder level, so a rule down all of them
would mark nothing. The rule is kept for the sizes that are actually empty — a nurse
at the counter can be handed a low size and cannot be handed none. */}
<div className="tc-panel-head">
<div>Reorder flags</div>
{d.lowRows.length > 0 && <div className="tc-panel-aside">{d.lowRows.length} line{d.lowRows.length === 1 ? "" : "s"}</div>}
</div>
{d.lowRows.length === 0 && <div className="tc-panel-body"><Empty pad={2}>No stock lines at or below reorder level.</Empty></div>}
<div className="tc-panel-list">
{d.lowRows.slice(0, 12).map((r, i) => (
<div key={i} className={"tc-row" + (r.onhand <= 0 ? " tc-flag" : "")}>
<div className="tc-row-main">
<div className="tc-row-name" style={{ whiteSpace: "nowrap" }}>{r.label} · {r.size}</div>
<div className="tc-row-meta">{r.onhand <= 0 && <span className="tc-mark" aria-hidden="true" />}{r.onhand <= 0 ? "None on the shelf · " : ""}re-order at {r.reorder}</div>
</div>
<div className="tc-row-fig">{r.onhand}</div>
</div>
))}
</div>
{d.lowRows.length > 12 && <div className="tc-panel-foot" style={{ fontSize: 12, color: "var(--color-neutral-700)" }}>+{d.lowRows.length - 12} more use <Link href="/app/stock">Order flagged</Link> on Inventory.</div>}
</div>
</div>
<div className="tc-grid" style={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: "var(--space-6)", marginTop: "var(--space-6)" }}>
{registers.map((g) => (
<div key={g.title} className="tc-panel">
<div className="tc-panel-head">{g.title}</div>
<div className="tc-panel-list">
{g.rows.map(([lbl, val]) => (
<div key={lbl} className="tc-row">
<div className="tc-row-main"><div className="tc-row-name" style={{ fontWeight: 500 }}>{lbl}</div></div>
<div className="tc-row-fig">{val}</div>
</div>
))}
</div>
</div>
))}
</div>
{newOrder && <NewOrderDialog onClose={() => setNewOrder(false)} />}
</section>
);
}