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

105 lines
6.2 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";
/* 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 />
</>
);
}