d9b8d80b11
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).
179 lines
13 KiB
TypeScript
179 lines
13 KiB
TypeScript
"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>
|
||
);
|
||
}
|