ThreadCount Community edition
Release / release (push) Has been skipped

Uniform stock management for healthcare linen rooms: the coordinator app, the phone counter and the staff app, for your own server. Built from 794bab5 on 2026-09-16. Licensed under the Functional Source License (FSL-1.1-ALv2).
This commit is contained in:
ThreadCount
2026-09-17 05:49:16 +10:00
commit f72f0626b0
481 changed files with 59411 additions and 0 deletions
+182
View File
@@ -0,0 +1,182 @@
"use client";
/* 1A — Home.
*
* Two questions, in this order: what is on the way, and what you hold. Everything else is a
* shortcut one row lower. There is no list of orders here on purpose — that is the Orders tab, and
* a home screen that tried to be both would be neither.
*
* The one banner at the top is the only place on this screen where somebody else is blocked until
* this person acts. At most one is ever drawn: a screen with two things shouting at once has
* nothing at the top.
*/
import { MBody, MRow, MSection, MTopAction, MTopBrand } from "@/components/m";
import {
AlertBar, DarkButton, DarkCard, DarkRow, EdgeRow, IdentityBlock, Kicker, N600, Notice, QuickGrid,
} from "@/components/staffui";
import StaffNav from "@/components/staffnav";
import { useStaff } from "@/lib/staffclient";
import { statusText } from "@/lib/staffreq";
import type { ReqRow } from "@/lib/staffdata";
type Data = {
name: string; num: string; ward: string; group: string; facility: string;
hasManager: boolean; wardDesk: boolean;
holding: number; live: ReqRow | null; openCount: number; notice: string;
/** Open requests this person raised for somebody else — never their own. */
raisedOpen: ReqRow[];
lastRequest: { itemId: string; si: number; reason: string; code: string } | null;
};
export type HoldRow = { item: string; size: string; qty: number; last: string };
const plural = (n: number, w: string) => `${n} ${n === 1 ? w : w + "s"}`;
export default function HomeScreen({ data, held, lastItem, managerName, kitCheckOpen }: {
data: Data;
/** Up to three, most recently issued first. The total is the section's own note. */
held: HoldRow[];
/** The garment on the last request, for the "Same again" tile. Null when they never asked. */
lastItem: string | null;
managerName: string;
kitCheckOpen: boolean;
}) {
const { me, counts } = useStaff();
const live = data.live;
const st = live ? statusText(live) : null;
/* At most one banner, approvals first.
*
* Driven by the count of requests ADDRESSED to this person, never by "is a manager": the linen
* room can re-address a request to somebody who manages nobody, and a manager's last report can
* move away while their request is still waiting. Gating on the role would take this banner away
* from the one person who has to act on it. A desk that is also an approver keeps its bags on
* the Team tab's badge. */
const banner = counts.approvals > 0
? { title: `${plural(counts.approvals, "request")} waiting on you`, href: "/my/approvals" }
: me.wardDesk && counts.round > 0 && data.ward
? { title: `${plural(counts.round, "bag")} to sign on ${data.ward}`, href: "/my/round" }
: null;
return (
<>
{/* Sign out has left this screen. It lives on Account, with the password — where people look
for it, and where the one thing a wearer can do to a phone they no longer have belongs. */}
<MTopBrand facility={data.facility} right={<MTopAction label="Account" href="/my/account" />} />
<MBody>
{banner && <AlertBar title={banner.title} href={banner.href} />}
<IdentityBlock ward={data.ward} num={data.num} name={data.name} group={data.group} />
<div style={{ padding: 16 }}>
<MSection label="On the way" />
{live && st ? (
/* The bag furthest along — ready beats out on the round beats waiting on a manager.
* One request covers as many garments as the person needed, so the card leads with the
* summary and the list itself is a tap away on the order. */
<div style={{ marginTop: 12 }}>
<DarkCard kicker={st.label} title={live.summary} meta={[st.note, live.code].filter(Boolean).join(" · ")}>
{live.status === "ready" && live.collectCode ? (
<>
<DarkRow label="Collection code" value={live.collectCode} />
<DarkButton label="Show at the counter" href={`/my/orders/${live.id}/code`} />
</>
) : (
<DarkButton label="Open this order" href={`/my/orders/${live.id}`} />
)}
</DarkCard>
</div>
) : (
<div style={{ padding: "26px 0" }}>
<div style={{ fontSize: 18, fontWeight: 800 }}>Nothing on the way</div>
<div style={{ fontSize: 14, color: N600, marginTop: 2, lineHeight: 1.45 }}>
{data.hasManager && managerName
? `Ask for something and it goes to ${managerName} first.`
: "Your manager isnt recorded yet — the linen room has to set who approves your requests."}
</div>
</div>
)}
<MSection label="You hold" right={plural(data.holding, "garment")} />
{held.length > 0 ? held.map((h) => (
<MRow
key={`${h.item}:${h.size}`}
title={`${h.item}${h.size}`}
sub={`Last issued ${h.last}`}
right={`×${h.qty}`}
/>
)) : (
<div style={{ padding: "18px 0", fontSize: 14, color: N600 }}>Nothing on your record yet.</div>
)}
{/* 2×2. "Same again" is drawn only when there is a last request to repeat: a shortcut
that opens an empty screen is worse than no shortcut. Swapping a size has moved to
My kit, which is where a wearer reads the size that is wrong. */}
<div style={{ marginTop: 12 }}>
<QuickGrid
items={[
{ label: "Request an item", caption: "Ask for", href: "/my/request" },
...(data.lastRequest && lastItem
? [{ label: "Same again", caption: `Last: ${lastItem}`, href: "/my/request?again=1" }]
: []),
{ label: "Report damage", caption: "Torn, stained, worn", href: "/my/damage" },
{ label: "What is on the shelf", caption: "Words, not counts", href: "/my/shelf" },
]}
/>
</div>
{/* A chore with a deadline, not an alert: dressing it as one would devalue the banner.
The title is word for word the heading on the screen it opens. */}
{kitCheckOpen && (
<div style={{ marginTop: 12 }}>
<EdgeRow tone="accent" href="/my/kitcheck">
<div style={{ fontSize: 15, fontWeight: 800 }}>Kit check is open</div>
<div style={{ fontSize: 13, color: N600, marginTop: 2 }}>Have you still got everything on your record?</div>
</EdgeRow>
</div>
)}
{/* What they raised for other people, as one row rather than a list. Until Orders grew a
Raised tab a request typed in for a colleague vanished the moment it was sent. */}
{data.raisedOpen.length > 0 && (
<div style={{ marginTop: 12 }}>
<EdgeRow tone="ink" href="/my/orders?tab=raised">
<Kicker>Raised by you</Kicker>
<div style={{ fontSize: 16, fontWeight: 800, marginTop: 6, lineHeight: 1.3 }}>
{data.raisedOpen.length === 1
? `One request open for ${data.raisedOpen[0].subjectName}`
: `${data.raisedOpen.length} requests open for other people`}
</div>
<div style={{ fontSize: 13, color: N600, marginTop: 2 }}>Not yours to collect this is where they got to.</div>
</EdgeRow>
</div>
)}
{/* Somebody who will not type a request themselves asks the person who approves it. The
server sends anything a manager raises up a level, which is why the row can say so
plainly — approving your own raise is the one thing this must never allow. */}
{me.isManager && (
<div style={{ marginTop: 12 }}>
<EdgeRow tone="ink" href="/my/raise">
<div style={{ fontSize: 15, fontWeight: 800 }}>Raise for someone you manage</div>
<div style={{ fontSize: 13, color: N600, marginTop: 2 }}>Goes to your own manager, not to you.</div>
</EdgeRow>
</div>
)}
{/* The round is listed by ward, so a clerk whose ward was never filled in has no round to
open. Saying so beats a banner that never appears and a tap that 404s. */}
{data.wardDesk && !data.ward && (
<div style={{ marginTop: 12 }}>
<DarkCard kicker="Ward desk" title="No ward on your record" meta="Ask the linen room to record which ward you are on." />
</div>
)}
{data.notice && <Notice>{data.notice}</Notice>}
</div>
<div style={{ height: 20 }} />
</MBody>
<StaffNav />
</>
);
}