Files
threadcount-community/components/screens/Home.tsx
T
ThreadCount f72f0626b0
Release / release (push) Has been skipped
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 794bab5 on 2026-09-16. Licensed under the Functional Source License (FSL-1.1-ALv2).
2026-09-17 05:49:16 +10:00

183 lines
8.7 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";
/* 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 />
</>
);
}