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 f976bd5 on 2026-09-15. Licensed under the Functional Source License (FSL-1.1-ALv2).
This commit is contained in:
ThreadCount
2026-09-15 23:18:05 +10:00
commit c89010a4f1
424 changed files with 53588 additions and 0 deletions
+104
View File
@@ -0,0 +1,104 @@
"use client";
/* Home — today. Four figures, whats 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 />
</>
);
}