Files
threadcount-community/components/screens/Home.tsx
T
ThreadCount 1bc2de655a ThreadCount Community edition
Uniform stock management for healthcare linen rooms. Licensed under the GNU AGPL v3.
2026-09-13 08:45:19 +10:00

216 lines
11 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.
*
* Answer "is anything waiting for me?" in one glance, then get out of the way. One live thing at
* the top — the request furthest along — and everything else is a shortcut. There is no list of
* orders here on purpose: that is what the Orders tab is for, and a home screen that tried to be
* both would be neither.
*/
import { useState } from "react";
import { MBody, MTopBrand } from "@/components/m";
import {
Banner, DarkCard, DarkRow, EdgeRow, IdentityBlock, Kicker, N600, Notice, QuickGrid, SecondaryBar,
} from "@/components/staffui";
import StaffNav from "@/components/staffnav";
import { statusText } from "@/lib/staffreq";
import type { ReqRow } from "@/lib/staffdata";
type Data = {
name: string; num: string; ward: 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[];
};
const ic = (d: React.ReactNode) => (
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="square" aria-hidden>{d}</svg>
);
export default function HomeScreen({ data, approvals, roundBags, kitCheckDue, canRaiseForTeam }: {
data: Data; approvals: number; roundBags: number; kitCheckDue: string | null;
/** Does anybody name this person as their manager? If so they may raise for them. */
canRaiseForTeam: boolean;
}) {
const [leaving, setLeaving] = useState(false);
const live = data.live;
const st = live ? statusText(live) : null;
return (
<>
<MTopBrand
facility={data.facility}
right={
<button
onClick={async () => {
setLeaving(true);
await fetch("/api/staff/logout", {
method: "POST", headers: { "content-type": "application/json" }, body: "{}",
}).catch(() => {});
// A full navigation: the cookie has just been cleared and every screen behind it is
// server-rendered.
window.location.replace("/my/signin");
}}
style={{
background: "none", border: "1px solid rgba(243,242,242,0.4)", color: "var(--color-neutral-400)",
font: "inherit", fontSize: 11, fontWeight: 800, letterSpacing: "0.08em",
textTransform: "uppercase", padding: "6px 10px", borderRadius: 0,
cursor: leaving ? "wait" : "pointer", flex: "0 0 auto", minHeight: 30,
}}
>{leaving ? "…" : "Sign out"}</button>
}
/>
<MBody>
{/* A manager with people waiting on them sees it before anything of their own. The queue
is the one thing in this app where somebody else is blocked until they act. */}
{approvals > 0 && (
<Banner
title={`${approvals} request${approvals === 1 ? "" : "s"} waiting on you`}
body={approvals === 1 ? "Someone on your team is waiting to be approved." : "People on your team are waiting to be approved."}
onOpen={() => { window.location.href = "/my/approvals"; }}
/>
)}
<IdentityBlock ward={data.ward} num={data.num} name={data.name} />
<div style={{ padding: 16 }}>
{live && st ? (
/* One request covers as many garments as the person needed, so the card leads with the
* summary — "5 garments · Tunic, Trousers, Fleece" — and the list itself is a tap away
* on the order. This screen answers "is anything waiting for me?" and then gets out of
* the way; a home screen that unpacked every request would be the Orders tab.
*
* The decision rides alongside the status because on a split one the status word is a
* half-truth: "Approved — with linen room" over an ask where the fleece was knocked
* back has somebody expecting three garments in a bag that holds two. */
<DarkCard
href={`/my/orders/${live.id}`}
kicker={st.label}
title={live.summary}
meta={[live.decision && live.lineCount > 1 ? live.decision : "", st.note].filter(Boolean).join(" · ")}
>
{live.collectCode && <DarkRow label="Collection code" value={live.collectCode.split("").join(" ")} />}
</DarkCard>
) : (
<DarkCard
kicker={data.holding > 0 ? "Nothing on the way" : "Nothing yet"}
title={data.holding > 0 ? `${data.holding} garment${data.holding === 1 ? "" : "s"} with you` : "No uniform on your record"}
meta={
data.hasManager
? "Ask for something and it goes to your manager first."
: "Your manager isnt recorded yet — the linen room has to set who approves your requests before you can ask for anything."
}
/>
)}
</div>
<div style={{ padding: "0 16px 8px" }}><Kicker>Quick actions</Kicker></div>
<QuickGrid
items={[
{ label: "Request an item", href: "/my/request", icon: ic(<><path d="M12 5v14" /><path d="M5 12h14" /></>) },
{ label: "Swap a size", href: "/my/request?swap=1", icon: ic(<><path d="M3 7V3h4" /><path d="M17 3h4v4" /><path d="M21 17v4h-4" /><path d="M7 21H3v-4" /><path d="M8 12h8" /></>) },
{ label: "Report damage", href: "/my/damage", icon: ic(<><path d="M12 3 2 20h20z" /><path d="M12 10v4" /><path d="M12 17h.01" /></>) },
{ label: "Whats on the shelf", href: "/my/shelf", icon: ic(<><circle cx="11" cy="11" r="7" /><path d="m20 20-4-4" /></>) },
]}
/>
{/* The desks own work, only for the person on it: signing the trolley in.
The round is listed by ward, so a clerk whose ward was never filled in has no round to
open — /my/round refuses a blank ward, and signing is fenced the same way on the
server. The card still appears, because the desk work is real and hiding it would tell
her nothing; it just stops being a link and names the missing ward. Reading "nothing on
the round right now" and tapping through to a not-found page was the worst of both:
indistinguishable from a quiet day, and no clue the linen room had to fix anything. */}
{data.wardDesk && (
<div style={{ padding: "16px 16px 0" }}>
{data.ward ? (
<DarkCard
href="/my/round"
kicker="Ward desk"
title={roundBags > 0 ? `${roundBags} bag${roundBags === 1 ? "" : "s"} to sign` : "Ward round"}
meta={roundBags > 0 ? "Arriving on your ward today." : "Nothing on the round for your ward right now."}
/>
) : (
<DarkCard
kicker="Ward desk"
title="No ward on your record"
meta="The round is listed by ward, so there is nothing to show you until the linen room records which ward you are on. Ask them to set it."
/>
)}
</div>
)}
{/* Somebody who will not type a request themselves asks the person who approves it, which
is the one door left for raising on another person's behalf. The server sends anything
a manager raises up a level, which is why the card can say so plainly — approving your
own raise is the one thing this must never let happen. */}
{canRaiseForTeam && (
<div style={{ padding: "16px 16px 0" }}>
<DarkCard
href="/my/raise"
kicker="Your team"
title="Raise for someone you manage"
meta="Goes to your own manager for approval, not to you."
/>
</div>
)}
{/* What they have raised for other people, as one row rather than a list.
Every list in this app starts from the wearer, so until Orders grew a Raised tab a
request somebody typed in for a colleague vanished the moment it was sent — and the
raise screen promises they will see the outcome. Home is not the place for the list
itself (that rule is the whole shape of this screen), but it is the place to say the
requests exist and where they went. */}
{data.raisedOpen.length > 0 && (
<div style={{ padding: "16px 0 0" }}>
<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: 4, lineHeight: 1.45 }}>
Not yours to collect this is where they got to.
</div>
</EdgeRow>
</div>
)}
{/* An open cycle they havent finished. Not a banner — this isnt urgent, its a chore
with a deadline, and dressing it as an alert would devalue the ones that are.
The title is word for word the heading on the screen it opens, and it no longer asks
about a locker. Wearers take their uniform home and wash it themselves; there is no
locker to stand in front of, so the only answerable question is what they still have,
wherever it happens to be that day. */}
{kitCheckDue && (
<div style={{ padding: "16px 16px 0" }}>
<DarkCard
href="/my/kitcheck"
kicker="Kit check"
title="Have you still got everything on your record?"
meta={`Due by ${kitCheckDue}. Count whats in the wash too. Nothing here is chargeable.`}
/>
</div>
)}
{data.notice && <div style={{ padding: "0 16px" }}><Notice>{data.notice}</Notice></div>}
{/* Sitting under everything else because it is the least often needed thing here — but it
is the only way a wearer can end a session on a phone they no longer have, so it has to
be somewhere they can find without asking. The label names privacy too: the policy and
the answer about deleting an account live behind this row, and somebody looking for
either would never guess that "your sign-in" was where they were kept. */}
<div style={{ padding: "16px 16px 0" }}>
<SecondaryBar label="Your sign-in and privacy" href="/my/account" />
</div>
<div style={{ height: 20 }} />
</MBody>
<StaffNav />
</>
);
}