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 5470a36 on 2026-09-13. Licensed under the Functional Source License (FSL-1.1-ALv2).
This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
"use client";
|
||||
/* Home — today. Four figures, what’s just happened, and a way into a count. */
|
||||
import Link from "next/link";
|
||||
import { useMemo } from "react";
|
||||
import { useDerived, useSnap } from "@/lib/client";
|
||||
import { countsAsIssued, daysBetween, label, longLabel, onhand, touched, reorderAt, staffName, variantName } from "@/lib/compute";
|
||||
import { INK, IconRight, MBody, MNav, MRow, MRule, MSection, MTopBrand } from "@/components/m";
|
||||
|
||||
function Stat({ n, l, hot }: { n: string; l: string; hot?: boolean }) {
|
||||
return (
|
||||
<div style={{ padding: "18px 16px 16px", borderRight: "1px solid var(--color-divider)", borderBottom: "1px solid var(--color-divider)" }}>
|
||||
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 44, lineHeight: 1, letterSpacing: "-0.03em", fontVariantNumeric: "tabular-nums", color: hot ? "var(--color-accent-700)" : INK }}>{n}</div>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, letterSpacing: "0.12em", textTransform: "uppercase", color: "var(--color-neutral-600)", marginTop: 8 }}>{l}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function MHome() {
|
||||
const { s } = useSnap();
|
||||
const { L, byId, staffById, variants } = useDerived();
|
||||
|
||||
const d = useMemo(() => {
|
||||
const today = s.today;
|
||||
let issued = 0, returned = 0;
|
||||
for (const i of s.issues) {
|
||||
if (i.date === today) issued += i.qty;
|
||||
if (i.returned?.date === today) returned += i.qty;
|
||||
}
|
||||
const low = variants.filter((v) => touched(s, L, v.key) && onhand(s, L, v.key) <= reorderAt(s, v.key));
|
||||
const lastCount = s.stocktakes.find((t) => t.mode !== "preloved");
|
||||
const since = lastCount ? daysBetween(lastCount.date, today) : null;
|
||||
// Recent activity, newest first. One line per person per day per kind — four garments handed
|
||||
// to the same nurse in one go is one thing that happened, not four.
|
||||
const grouped: Record<string, { staffId: string; kind: "Issued" | "Returned"; at: string; qty: number }> = {};
|
||||
for (const i of s.issues.slice(-120)) {
|
||||
const add = (kind: "Issued" | "Returned", at: string) => {
|
||||
const k = `${i.staffId}|${kind}|${at}`;
|
||||
(grouped[k] ||= { staffId: i.staffId, kind, at, qty: 0 }).qty += i.qty;
|
||||
};
|
||||
add("Issued", i.date);
|
||||
if (i.returned) add("Returned", i.returned.date);
|
||||
}
|
||||
const recent = Object.values(grouped)
|
||||
.sort((a, b) => (a.at < b.at ? 1 : a.at > b.at ? -1 : 0))
|
||||
.slice(0, 3)
|
||||
.map((g) => ({
|
||||
title: `${staffName(staffById[g.staffId], "Staff")} — ${g.qty} item${g.qty === 1 ? "" : "s"}`,
|
||||
sub: `${g.kind} · ${g.at === today ? "today" : g.at}`,
|
||||
mark: "ink" as const, href: `/m/person/${g.staffId}`, at: g.at,
|
||||
})) as { title: string; sub: string; mark: "ink" | "accent"; href?: string; at: string }[];
|
||||
// A line AT its reorder level is in `low` on purpose (reorder now, not once it's short), but
|
||||
// "Below par · 3 of 3" reads as a contradiction on the phone, so name the two states apart.
|
||||
for (const v of low.slice(0, 2)) {
|
||||
const oh = onhand(s, L, v.key), par = reorderAt(s, v.key);
|
||||
recent.push({ title: `${variantName(byId[v.itemId], v.size)}`, sub: `${oh < par ? "Below par" : "At par"} · ${oh} of ${par}`, mark: "accent", href: `/m/stock`, at: "" });
|
||||
}
|
||||
return { issued, returned, low: low.length, since, recent };
|
||||
}, [s, L, byId, staffById, variants]);
|
||||
|
||||
const fac = [s.settings.facility, s.settings.location].filter(Boolean).join(" · ");
|
||||
const dateLine = new Date(+s.today.slice(0, 4), +s.today.slice(5, 7) - 1, +s.today.slice(8, 10))
|
||||
.toLocaleDateString("en-AU", { weekday: "long", day: "numeric", month: "long" });
|
||||
|
||||
return (
|
||||
<>
|
||||
<MTopBrand facility={fac} />
|
||||
<MRule />
|
||||
<MBody>
|
||||
<div style={{ padding: "20px 16px 24px", borderBottom: "2px solid " + INK }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, letterSpacing: "0.12em", textTransform: "uppercase", color: "var(--color-neutral-600)" }}>{dateLine}</div>
|
||||
<h2 style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 44, letterSpacing: "-0.03em", lineHeight: 1, marginTop: 10 }}>Today</h2>
|
||||
</div>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr" }}>
|
||||
<Stat n={String(d.issued)} l="Issued" />
|
||||
<Stat n={String(d.returned)} l="Returned" />
|
||||
<Stat n={String(d.low)} l="Below par" hot={d.low > 0} />
|
||||
<Stat n={d.since === null ? "—" : `${d.since}d`} l="Since count" hot={d.since !== null && d.since > 30} />
|
||||
</div>
|
||||
|
||||
<MSection label="Recent" />
|
||||
{d.recent.length === 0
|
||||
? <div style={{ padding: "28px 16px", fontSize: 14, color: "var(--color-neutral-600)" }}>Nothing has moved yet today.</div>
|
||||
: d.recent.map((r, i) => (
|
||||
<MRow key={i} mark={r.mark} attention={r.mark === "accent"} href={r.href}
|
||||
title={r.title}
|
||||
sub={<span style={{ color: r.mark === "accent" ? "var(--color-accent-700)" : undefined }}>{r.sub}</span>} />
|
||||
))}
|
||||
|
||||
<div style={{ padding: 16, display: "grid", gap: 12 }}>
|
||||
<Link href="/m/count" style={{ display: "flex", alignItems: "center", gap: 12, minHeight: 64, padding: "0 20px", border: "2px solid " + INK, color: INK, textDecoration: "none", fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 14, letterSpacing: "0.08em", textTransform: "uppercase" }}>
|
||||
<span style={{ flex: 1 }}>Start a count</span><IconRight />
|
||||
</Link>
|
||||
<Link href="/m/issue" style={{ display: "flex", alignItems: "center", gap: 12, minHeight: 64, padding: "0 20px", border: "2px solid " + INK, color: INK, textDecoration: "none", fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 14, letterSpacing: "0.08em", textTransform: "uppercase" }}>
|
||||
<span style={{ flex: 1 }}>Issue to someone</span><IconRight />
|
||||
</Link>
|
||||
<Link href="/m/more" style={{ display: "flex", alignItems: "center", gap: 12, minHeight: 52, padding: "0 20px", color: "var(--color-neutral-700)", textDecoration: "none", fontSize: 13, fontWeight: 600, letterSpacing: "0.06em", textTransform: "uppercase" }}>
|
||||
<span style={{ flex: 1 }}>Deliveries, pickups, rounds and more</span><IconRight size={18} />
|
||||
</Link>
|
||||
</div>
|
||||
</MBody>
|
||||
<MNav />
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user