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
+189
View File
@@ -0,0 +1,189 @@
"use client";
/* 1J — Account.
*
* What a wearer can do to their own sign-in, and one thing they deliberately cannot.
*
* Changing the password is the only revocation they have: a staff token carries a fingerprint of
* the password hash, so setting a new one ends every session signed against the old one at once —
* a phone left on a ward, a cookie copied off it, a password read over somebody's shoulder. The
* copy says so plainly, because the consequence is the feature and somebody who does not know it
* happens will not reach for this when they most need it.
*
* Deleting the account is not here, and is not an oversight. Access is the linen room's to grant
* and theirs to remove: a wearer who could delete their own account would take the record of what
* they were issued with it.
*/
import { DELETE_ACCOUNT_URL, PRIVACY_EMAIL, PRIVACY_URL, TERMS_URL } from "@/lib/links";
import { useState } from "react";
import { MBar, MBody, MError, MRow, MRule, MSection, MTop } from "@/components/m";
import { INK, N600, N700 } from "@/components/staffui";
import { useStaff } from "@/lib/staffclient";
import { forgetPush } from "@/lib/staffpush";
import NotificationSettings, { type NotifyPrefs } from "@/components/screens/NotificationSettings";
const field: React.CSSProperties = {
width: "100%", minHeight: 52, padding: "0 14px", border: "2px solid var(--color-divider)",
borderRadius: 0, font: "inherit", fontSize: 16, background: "#fff", color: "var(--color-text)",
};
const label: React.CSSProperties = {
display: "block", fontSize: 12.5, fontWeight: 800, letterSpacing: "0.06em",
textTransform: "uppercase", color: N600,
};
export default function AccountScreen({ email, prefs, pushReady }: {
email: string; prefs: NotifyPrefs; pushReady: boolean;
}) {
const { me, mutate, busy } = useStaff();
const [open, setOpen] = useState(false);
const [current, setCurrent] = useState("");
const [next, setNext] = useState("");
const [err, setErr] = useState("");
const [done, setDone] = useState(false);
const [leaving, setLeaving] = useState(false);
// The server enforces the same floor; checking it here only saves a round trip and a refusal.
const ready = current.length > 0 && next.length >= 8;
/* ⛔ Signing out never waits on push.forget succeeding, and never fails because it didn't.
* Somebody on a ward with no signal still has to be able to leave a phone they are handing on.
* An orphaned token is reclaimed three other ways — the next registration re-points it, FCM
* reports it gone, and a password change clears the lot. */
async function signOut() {
setLeaving(true);
const token = forgetPush();
if (token) void mutate("push.forget", { token });
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");
}
return (
<>
<MTop title="Account" back backHref="/my" />
<MRule />
<MBody>
<MError msg={err} onDismiss={() => setErr("")} />
<div style={{ padding: "20px 16px 18px", borderBottom: `2px solid ${INK}`, background: "var(--color-bg)" }}>
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 900, fontSize: 26, letterSpacing: "-0.02em", lineHeight: 1.05 }}>
{me.name}
</div>
<div style={{ fontSize: 13, color: N600, marginTop: 4 }}>{email}</div>
</div>
<div style={{ padding: "0 16px" }}>
<MSection label="Notifications" />
<NotificationSettings prefs={prefs} configured={pushReady} />
<MSection label="Sign-in" />
{done ? (
<div style={{ background: "#fff", borderLeft: `6px solid ${INK}`, padding: "14px 16px", marginTop: 12 }}>
<div style={{ fontSize: 16, fontWeight: 800 }}>Password changed</div>
<p style={{ fontSize: 13.5, lineHeight: 1.6, color: N700, margin: "8px 0 0" }}>
Every other device signed in as you has been signed out, and any phone of yours set
up for notifications has been unregistered. This one stays signed in.
</p>
</div>
) : (
<>
{/* A disclosure, not a link: the form is on this screen, so the row says so in words
a screen reader is given rather than only by what appears underneath it. */}
<button
type="button"
aria-expanded={open}
onClick={() => setOpen((v) => !v)}
style={{
display: "flex", alignItems: "center", gap: 12, width: "100%", minHeight: 60,
padding: "9px 0", background: "none", border: 0,
borderBottom: "1px solid var(--color-divider)", font: "inherit", color: "inherit",
textAlign: "left", cursor: "pointer",
}}
>
<span style={{ flex: 1, minWidth: 0 }}>
<span style={{ display: "block", fontSize: 15, fontWeight: 700 }}>Change your password</span>
<span style={{ display: "block", fontSize: 13, color: N600, marginTop: 1 }}>Signs you out on other devices</span>
</span>
<span aria-hidden="true" style={{ color: N600, fontSize: 18 }}>{open ? "" : ""}</span>
</button>
{open && (
<div style={{ padding: "16px 0 4px" }}>
{/* The words wrap the box rather than sitting beside it: on the one screen where
typing in the wrong one of two password fields is silent, both must announce
which they are. */}
<label style={{ display: "block" }}>
<span style={label}>Current password</span>
<input
type="password" autoComplete="current-password" value={current}
onChange={(e) => { setCurrent(e.target.value); setErr(""); }}
style={{ ...field, marginTop: 8 }}
/>
</label>
<label style={{ display: "block", marginTop: 16 }}>
<span style={label}>New password</span>
<input
type="password" autoComplete="new-password" value={next}
onChange={(e) => { setNext(e.target.value); setErr(""); }}
style={{ ...field, marginTop: 8 }}
/>
</label>
<p style={{ fontSize: 13, lineHeight: 1.6, color: N700, margin: "12px 0 0" }}>
At least 8 characters. Changing it signs you out everywhere else straight away.
This device stays signed in.
</p>
</div>
)}
</>
)}
<MSection label="Privacy" />
<p style={{ fontSize: 13.5, lineHeight: 1.6, color: N700, margin: "12px 0 4px" }}>
ThreadCount holds your sign-in and the linen room&rsquo;s record of what you have been
issued. You can&rsquo;t delete this account from here ask your uniform coordinator and
they can remove it{PRIVACY_EMAIL ? <>, or write to {PRIVACY_EMAIL}</> : null}.
</p>
</div>
{PRIVACY_URL && <MRow href={PRIVACY_URL} external mark="ink" title="Privacy policy" sub="What ThreadCount stores, and what it never does" />}
{DELETE_ACCOUNT_URL && <MRow href={DELETE_ACCOUNT_URL} external mark="ink" title="Deleting your account" sub="How it is done, and what goes with it" />}
{TERMS_URL && <MRow href={TERMS_URL} external mark="ink" title="Terms of use" sub="What you and ThreadCount each agree to" />}
{/* The one thing a wearer can do to a phone they no longer have, so it is findable without
asking — and at the foot, because it is the last thing anybody comes here to do. */}
<div style={{ padding: "22px 16px 0" }}>
<button
type="button"
onClick={() => void signOut()}
style={{
minHeight: 52, width: "100%", border: `2px solid ${INK}`, borderRadius: 0,
background: "transparent", color: INK, font: "inherit",
fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 14,
letterSpacing: "0.05em", textTransform: "uppercase", display: "flex",
alignItems: "center", justifyContent: "center", padding: "0 14px",
cursor: leaving ? "wait" : "pointer",
}}
>{leaving ? "Signing out…" : "Sign out"}</button>
</div>
<div style={{ height: 24 }} />
</MBody>
{open && !done && (
<MBar
label={busy ? "Saving…" : "Change my password"}
glyph="check"
disabled={!ready || busy}
onClick={async () => {
const r = await mutate("account.password", { current, next });
if (!r.ok) { setErr(r.error); return; }
setCurrent(""); setNext(""); setOpen(false); setDone(true);
}}
/>
)}
</>
);
}
+153
View File
@@ -0,0 +1,153 @@
"use client";
/* Team ▸ Approvals — the queue, and the decision, on one screen.
*
* Oldest first, deliberately. The queue's job is to surface the person who has been waiting
* longest, and a newest-first list quietly buries them — which is the failure mode this whole
* flow exists to fix.
*
* Approving now happens in the list: the commonest decision by far is "yes, all of it", and making
* somebody open a screen to say so cost a tap on every request and taught them to open and approve
* without reading either. Open is still there for the decision that needs looking at — a single
* garment knocked back, which only the review screen can do.
*
* A request the manager is the wearer of can reach this queue. Whoever a request names is who
* decides it, and the server has been allowed to let that be the wearer themselves in the one case
* the owner named. The server owns that decision and this screen never re-tests it. What the screen
* owns is the honesty: an approval somebody gives themselves is set apart from the ones they give
* on other people's behalf, and says what the record will call it afterwards.
*
* This queue also reaches people who manage nobody — the linen room re-addresses a request that
* arrived without an approver, or somebody's last report moves away while their own request is
* still waiting. They have a queue, so they have a Team tab (lib/staffreq.ts teamTabs).
*/
import { useState } from "react";
import { MEmpty, MError } from "@/components/m";
import { EdgeRow, N600, N700 } from "@/components/staffui";
import Team, { Band, ChipAction, ChipRow } from "./Team";
import { useStaff } from "@/lib/staffclient";
import { daysBetween, facilityDate, facilityToday } from "@/lib/compute";
import type { QueueRow } from "@/lib/managerdata";
/* Calendar days on the ward, not elapsed 24-hour blocks.
*
* Dividing the milliseconds understated every overnight wait: a request raised at 18:00 on Monday
* still read "today" at 09:00 on Tuesday and only became "since yesterday" that evening, by which
* point it had spanned two working days. This queue exists to surface the person who has been
* waiting longest, so the label counts the way they do, in the facility's own zone. */
function waited(iso: string, tz: string) {
const raised = facilityDate(iso, tz);
if (!raised) return ""; // the row's meta line filters empties out
const days = daysBetween(raised, facilityToday(tz));
if (days <= 0) return "today";
if (days === 1) return "since yesterday";
return `waiting ${days} days`;
}
export default function ApprovalsScreen({ rows, ownIds = [] }: { rows: QueueRow[]; ownIds?: string[] }) {
const { me, mutate, busy } = useStaff();
const [err, setErr] = useState("");
/* What just happened, for the person who pressed the button. The row itself leaves the list on
* the refresh, and a list that silently gets shorter is not an answer. There is no toast in this
* app — components/m.tsx's useToast is a documented no-op outside the counter's provider — so it
* is said here, in a live region. */
const [done, setDone] = useState("");
/* Which of these are the manager's own is settled on the server, from the request's own subject.
Working it out here by matching a name would start calling a stranger's request yours the day
two people on the register share one — and the thing being labelled is an audit fact. */
const own = new Set(ownIds);
const mine = rows.filter((r) => own.has(r.id));
const theirs = rows.filter((r) => !own.has(r.id));
async function approveAll(r: QueueRow) {
setErr("");
setDone("");
// The op decides garment by garment and wants a call for every line by id — approving from the
// queue is approving all of them, said explicitly rather than by omission.
const res = await mutate("request.approve", {
id: r.id,
lines: r.lines.map((l) => ({ id: l.id, decision: "approved", reason: "" })),
});
if (!res.ok) { setErr(res.error); return; }
setDone(`Approved · ${r.subjectName.split(" ")[0]} has been told`);
}
function Row({ r, own: isOwn }: { r: QueueRow; own: boolean }) {
return (
/* An ink edge rather than the accent one every other row carries. Accent means somebody else
is waiting on you; your own uniform is not that, and it must not be able to pass for it at
a glance on a phone held in one hand halfway down a ward. */
<EdgeRow tone={isOwn ? "ink" : "accent"}>
{/* The person, and nothing beside them. The code belongs to the request rather than to the
decision, and it is on the review screen's own title where somebody who has come from
the approval e-mail will look for it. */}
<div style={{ fontSize: 16, fontWeight: 800, lineHeight: 1.3 }}>{r.subjectName}</div>
{/* The whole ask in one line — "3 garments · Tunic, Trousers". The garments themselves are
on the review screen, which is where a line is refused; a queue that listed every one
would bury the person who has been waiting longest under somebody else's four. */}
<div style={{ fontSize: 14.5, color: N600, marginTop: 5, lineHeight: 1.4 }}>{r.summary}</div>
<div style={{ fontSize: 13, color: N600, marginTop: 3, lineHeight: 1.4 }}>
{[r.reason, r.subjectGroup, waited(r.createdAt, me.tz)].filter(Boolean).join(" · ")}
{r.raisedByName ? ` · raised by ${r.raisedByName}` : ""}
</div>
<ChipRow>
<ChipAction
label={`Approve ${r.garments}`}
disabled={busy}
onClick={() => void approveAll(r)}
/>
<ChipAction label="Open" href={`/my/approvals/${r.id}`} />
</ChipRow>
</EdgeRow>
);
}
return (
<Team active="/my/approvals">
<div role="status" aria-live="polite">
{done && (
<div style={{ padding: "12px 16px", background: "#fff", borderBottom: "1px solid var(--color-divider)", fontSize: 14, fontWeight: 800 }}>
{done}
</div>
)}
</div>
<MError msg={err} onDismiss={() => setErr("")} />
{rows.length === 0 ? (
<div style={{ padding: "0 16px" }}>
<MEmpty
title="Nothing waiting on you"
sub="Requests from your team arrive by notification and email, and land here."
/>
</div>
) : (
<>
<div style={{ display: "grid", gap: 2, padding: "12px 0" }}>
{theirs.map((r) => <Row key={r.id} r={r} own={false} />)}
</div>
{/* The manager's own go last, whatever date they were raised. Oldest first is a promise to
the colleague who has been waiting longest, and a request for your own uniform does not
step in front of her — and a group at the foot, under its own heading, is not somewhere
a thumb arrives by accident on the way down the list of your team's. */}
{mine.length > 0 && (
<>
<Band tone="attention" label={mine.length === 1 ? "Your own request" : "Your own requests"} />
<p style={{ fontSize: 13, lineHeight: 1.6, color: N700, padding: "12px 16px 0", margin: 0 }}>
Approving {mine.length === 1 ? "it" : "them"} is recorded as your own approval.
</p>
<div style={{ display: "grid", gap: 2, padding: "12px 0" }}>
{mine.map((r) => <Row key={r.id} r={r} own />)}
</div>
</>
)}
<p style={{ fontSize: 13, lineHeight: 1.6, color: N700, padding: 16, margin: 0 }}>
Nothing reaches the linen room until you approve it.
</p>
</>
)}
<div style={{ height: 12 }} />
</Team>
);
}
+196
View File
@@ -0,0 +1,196 @@
"use client";
/* Approving or declining straight from the email, on whatever device opened it.
*
* This is the centred page shell rather than the phone column: a manager reaches it from a mail
* client, as often on a desktop as on a ward phone, and dropping them into an app chrome they
* never signed into would be a strange thing to meet.
*
* Nothing has happened when this page loads. That is the point of the design: the link renders,
* the button decides.
*/
import { useState } from "react";
import { Err, MyShell, h1, kicker, lead, primary } from "@/components/my";
import { DECLINE_REASONS } from "@/lib/staffreq";
import type { ReqLine } from "@/lib/staffdata";
type Data = {
code: string; subjectName: string; subjectMeta: string;
/** The whole ask, in the order it was entered. */
lines: ReqLine[];
/** linesSummary() — the one-liner every other screen leads with. */
summary: string;
reason: string; note: string;
raisedByName: string; facility: string;
};
const INK = "#201e1d";
const N600 = "var(--color-neutral-600)";
const N700 = "var(--color-neutral-700)";
const ACCENT_700 = "var(--color-accent-700)";
/** The garments, one row each. Deliberately drawn here rather than borrowed from the staff app's
* own list: this page is the centred desktop shell, not the phone column, and its type sizes and
* rules are a size up from everything in components/staffui. What it must match is the *content* —
* the same order, the same strike-through on a refusal, the same reason against it. */
function Lines({ lines }: { lines: readonly ReqLine[] }) {
return (
<div style={{ display: "grid", gap: 1, background: "var(--color-divider)", marginTop: 16 }}>
{lines.map((l) => {
const off = l.status === "declined";
return (
<div key={l.id} style={{ background: "#fff", padding: "14px 16px" }}>
<div style={{
fontSize: 17, fontWeight: 800, lineHeight: 1.3,
textDecoration: off ? "line-through" : "none", color: off ? N600 : INK,
}}>{l.qty} × {l.item} {l.size}</div>
{off && (
<div style={{ fontSize: 11, fontWeight: 800, letterSpacing: "0.12em", textTransform: "uppercase", color: ACCENT_700, marginTop: 6 }}>
Declined{l.declineReason ? `${l.declineReason}` : ""}
</div>
)}
</div>
);
})}
</div>
);
}
export default function ApproveByLink({ token, data, decided, status, declineReason }: {
token: string; data: Data; decided: boolean; status: string; declineReason: string | null;
}) {
const [done, setDone] = useState<null | { approved: boolean; reason?: string; notified: boolean }>(null);
const [declining, setDeclining] = useState(false);
const [busy, setBusy] = useState(false);
const [err, setErr] = useState("");
async function decide(action: "approve" | "decline", reason?: string) {
setBusy(true); setErr("");
const r = await fetch("/api/staff/decide", {
method: "POST", headers: { "content-type": "application/json" },
body: JSON.stringify({ token, action, reason }),
}).catch(() => null);
const j = await r?.json().catch(() => ({}));
setBusy(false);
// Already decided — the other link in the email was used, or this one twice. The route says
// so with `already`, and the server render of this page shows the decision that stands, so
// reload into that rather than sit on a red error with two live buttons under it.
if (j?.already) { window.location.reload(); return; }
if (!r || !r.ok) { setErr(j?.error || "That didnt work."); return; }
setDone({ approved: action === "approve", reason, notified: !!j?.notified });
}
if (done) {
return (
<MyShell>
<div style={kicker}>ThreadCount</div>
<h1 style={h1}>{done.approved ? "Approved." : "Declined."}</h1>
<p style={lead}>
{/* "Has been told" only when an email actually left — a wearer with no account or a site
with no mail hears nothing from this, and saying otherwise is how they wait a fortnight. */}
{done.approved
? `${done.notified ? `${data.subjectName.split(" ")[0]} has been told, and` : `${data.subjectName.split(" ")[0]} hasnt been emailed — its on their record in the app — and`} ${data.lines.length > 1 ? "all of it is" : "its"} with the linen room now.`
: `${done.notified ? `${data.subjectName.split(" ")[0]} has been told` : `${data.subjectName.split(" ")[0]} hasnt been emailed, so mention it to them`}${(done.reason || "").toLowerCase()}.`}
</p>
<p style={{ ...lead, fontSize: 13.5, color: N600 }}>
You can close this. Nothing else is waiting on you here.
</p>
</MyShell>
);
}
if (decided) {
return (
<MyShell>
<div style={kicker}>{data.facility}</div>
<h1 style={h1}>Already decided.</h1>
<p style={lead}>
{status === "declined"
? `This request was declined${declineReason ? `${declineReason.toLowerCase()}` : ""}.`
: "This request has already been approved and is with the linen room."}
</p>
{/* Which garments went and which didn't. A manager coming back to a request they settled
on their phone deserves the same answer here as the wearer gets on their order — the
alternative is a page that says "approved" over an ask where a third of it was refused. */}
<Lines lines={data.lines} />
<p style={{ ...lead, fontSize: 13.5, color: N600 }}>
Approval links work once. Open the app if you need to look at it again.
</p>
</MyShell>
);
}
return (
<MyShell>
<div style={kicker}>{data.facility} · {data.code}</div>
<h1 style={h1}>{data.subjectName} needs your approval.</h1>
{data.subjectMeta && <p style={{ ...lead, marginTop: 8, fontSize: 13.5, color: N600 }}>{data.subjectMeta}</p>}
<div style={{ border: `2px solid ${INK}`, padding: 18, marginTop: 24, background: "#fff" }}>
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 22, letterSpacing: "-0.01em", lineHeight: 1.2 }}>
{data.summary}
</div>
{data.reason && <div style={{ fontSize: 14, color: N700, marginTop: 8 }}>{data.reason}</div>}
{data.note && <p style={{ fontSize: 14, lineHeight: 1.55, color: N700, margin: "10px 0 0" }}>{data.note}</p>}
{data.raisedByName && (
<p style={{ fontSize: 13, color: N600, margin: "10px 0 0" }}>Raised for them by {data.raisedByName}.</p>
)}
</div>
{/* The summary above is a count and three names; a manager about to approve four garments
needs to see the four. One garment needs no list — the heading already is one. */}
{data.lines.length > 1 && <Lines lines={data.lines} />}
{err && <div style={{ marginTop: 16 }}><Err>{err}</Err></div>}
{declining ? (
<div style={{ marginTop: 24 }}>
<div style={{ fontSize: 11, fontWeight: 800, letterSpacing: "0.12em", textTransform: "uppercase", color: ACCENT_700 }}>
Why are you declining?
</div>
<p style={{ ...lead, marginTop: 8, fontSize: 13.5 }}>
{data.lines.length > 1
? `This turns down all ${data.lines.length} garments. ${data.subjectName.split(" ")[0]} is told which reason you picked.`
: `${data.subjectName.split(" ")[0]} is told which one you picked.`}
</p>
<div style={{ display: "grid", gap: 2, marginTop: 14 }}>
{DECLINE_REASONS.map((r) => (
<button key={r} disabled={busy} onClick={() => decide("decline", r)} style={{
minHeight: 56, background: "#fff", color: INK, border: `2px solid ${INK}`, borderRadius: 0,
textAlign: "left", padding: "0 16px", font: "inherit", fontSize: 15.5, fontWeight: 800,
cursor: busy ? "wait" : "pointer", opacity: busy ? 0.6 : 1,
}}>{r}</button>
))}
</div>
<button onClick={() => { setDeclining(false); setErr(""); }} style={{
marginTop: 16, minHeight: 48, padding: "0 20px", background: "transparent", color: INK,
border: `2px solid ${INK}`, borderRadius: 0, font: "inherit", fontWeight: 800, fontSize: 13,
letterSpacing: "0.06em", textTransform: "uppercase", cursor: "pointer",
}}>Back</button>
</div>
) : (
<div style={{ display: "grid", gap: 2, marginTop: 24 }}>
<button disabled={busy} onClick={() => decide("approve")} style={primary(busy)}>
{busy ? "Working…" : "Approve"}
</button>
<button disabled={busy} onClick={() => setDeclining(true)} style={{
minHeight: 56, background: "transparent", color: INK, border: `2px solid ${INK}`, borderRadius: 0,
font: "inherit", fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 14,
letterSpacing: "0.08em", textTransform: "uppercase", textAlign: "left", padding: "0 20px",
cursor: busy ? "wait" : "pointer",
}}>Decline</button>
</div>
)}
{/* The one thing this page cannot do. Signed in, a manager approves the tunic and turns down
the fleece on the same request; from an email link there is no signed-in person to check
a per-garment decision against, so it is deliberately the whole ask either way. A manager
who wants part of it has to be told where that lives rather than left approving three
garments to get one of them through. */}
<p style={{ ...lead, fontSize: 13, color: N600, marginTop: 24 }}>
Nothing has been decided yet this page just shows you the request. The link works once,
and it settles {data.lines.length > 1 ? "the whole request" : "it"} one way or the other.
{data.lines.length > 1 && " To approve some garments and not others, open the app."}
</p>
</MyShell>
);
}
+43
View File
@@ -0,0 +1,43 @@
"use client";
/* 1G — the collection code, full screen and nothing else.
*
* This is held up across a counter, often at arm's length, so everything the order screen carries
* is something to read past: no app bar, no accent rule, no tab bar. The wearer's name and what is
* in the bag sit under the digits, because a clerk hands a bag to a person, not to a number.
*
* Done is a real link back to the order, never history.back() — the screen is opened cold at least
* three ways (a notification tap, a refresh on a ward phone, an app link) and in each of them there
* is no history to pop, so a back-button Done walks somebody out of the app holding an unread code.
* FullCode takes the href for exactly that reason.
*/
import { MStyles } from "@/components/m";
import { FullCode, lineText } from "@/components/staffui";
import { useKeepAwake } from "@/lib/wakelock";
export default function CodeFullScreen({ id, code, name, lines }: {
id: string; code: string; name: string; lines: { item: string; size: string; qty: number }[];
}) {
/* Keep the screen alight while the code is up.
*
* There is no brightness control on this platform — neither the WebView nor the Capacitor shell
* offers one — so the honest version of "make it readable across a counter" is the Screen Wake
* Lock API: it is what stops Android dimming and then locking the phone in the time it takes to
* reach the front of the queue. A no-op where it isn't supported, and the digits are already
* drawn as large as the screen allows.
*/
useKeepAwake(true);
return (
<>
{/* The furniture styles are normally injected by MTop, which this screen deliberately does
not have; without them the Done link loses its focus ring. */}
<MStyles />
<FullCode
code={code}
name={name}
lines={lines.map(lineText)}
backHref={`/my/orders/${id}`}
/>
</>
);
}
+189
View File
@@ -0,0 +1,189 @@
"use client";
/* Report damage. Two jobs on one screen: tell the linen room, and start the replacement.
*
* The two are deliberately separate acts. Reporting damage does not issue anything and does not
* silently remove the garment — it comes off the record when it is handed in at the counter. A
* screen that wrote off a garment on somebody's say-so would be a screen the linen room stops
* trusting, and a screen that quietly issued a replacement would route around the manager.
*
* `Contaminated` is not in the list. Clinically it is a different pathway — red bag, no return to
* the counter — and telling someone to carry a contaminated garment to the linen room would be
* worse than saying nothing. Wards use the route they already have.
*/
import { useState } from "react";
import { MBar, MBody, MChipRow, MError, MRule, MSwitchRow, MTop } from "@/components/m";
import { DarkCard, N600, N700, NumberedField, OptionList } from "@/components/staffui";
import Sent from "@/components/screens/Sent";
import { useStaff } from "@/lib/staffclient";
import { DAMAGE_KINDS } from "@/lib/staffreq";
type Holding = {
issueId: string; itemId: string; item: string; size: string; si: number; qty: number;
labelId: string; issued: string; replacement: string;
};
export default function DamageScreen({ holdings, managerName, notifyWays }: {
holdings: Holding[]; managerName: string; notifyWays: { email: boolean; push: boolean };
}) {
const { mutate, busy } = useStaff();
const [issueId, setIssueId] = useState<string | null>(null);
const [kind, setKind] = useState<string | null>(null);
const [note, setNote] = useState("");
/* The switch defaults on, because asking for a replacement is what almost everybody reporting a
* torn tunic actually wants — but only when there is somebody to ask. With no manager recorded
* the request half cannot be raised at all, so defaulting it on left people tapping "Report and
* request" and getting an error they could do nothing about. */
const canRequest = !!managerName;
const [replace, setReplace] = useState(canRequest);
const [err, setErr] = useState("");
/* The server's sentence for a report it saved without a replacement behind it — almost always a
* garment whose range has been withdrawn, which cannot be ordered but is still on somebody's
* back. Nothing failed, so it is not an error; but the screen used to go straight to the kit
* list on a plain success and the nurse walked away expecting a replacement nobody had ordered. */
const [noReplacement, setNoReplacement] = useState("");
/* Where a finished report lands. A replacement was raised, so this is the request screen's own
* Sent — same approver, same "what happens next" — or, with no replacement asked for, the same
* screen saying the one thing that is still true: it comes off the record at the counter. */
const [done, setDone] = useState<{ what: string; order: { id: string; code: string; manager: string; notified: boolean } | null } | null>(null);
const held = holdings.find((h) => h.issueId === issueId) || null;
const ready = !!held && !!kind;
if (noReplacement) {
return (
<>
<MTop title="Reported" />
<MRule />
<MBody>
<div style={{ padding: 16 }}>
<DarkCard kicker="Reported" title="No replacement has been ordered" meta={noReplacement} />
</div>
</MBody>
<MBar label="Back to your kit" href="/my/kit" />
</>
);
}
if (done) {
const o = done.order;
const who = o?.manager || managerName || "your manager";
return o ? (
<Sent
headline={`Sent to ${who}`}
sub={`${done.what} · ${o.code}`}
next={
notifyWays.push
? "You get a notification when it is approved, and again when it is ready."
: o.notified
? `${who} has been emailed.`
: `It is waiting with ${who}.`
}
actions={[
{ label: "Open the order", href: `/my/orders/${o.id}` },
{ label: "Back to home", href: "/my" },
]}
bar={{ label: "Back to home", href: "/my" }}
/>
) : (
<Sent
title="Reported"
headline="Reported to the linen room"
sub={done.what}
next="It comes off your record when you hand it in at the counter."
actions={[{ label: "Back to your kit", href: "/my/kit" }]}
bar={{ label: "Back to home", href: "/my" }}
/>
);
}
return (
<>
<MTop title="Report damage" back backHref="/my/kit" />
<MRule />
<MBody>
<NumberedField n={1} label="Which item" first>
{holdings.length === 0 ? (
<p style={{ fontSize: 14, color: N600, lineHeight: 1.6, margin: 0 }}>
Nothing on your record to report.
</p>
) : (
<OptionList
value={issueId}
onPick={(k) => { setIssueId(k); setErr(""); }}
options={holdings.map((h) => ({
key: h.issueId,
label: `${h.item}${h.size}`,
// The label id is what the linen room reads off the garment in their hand, so a
// row here can be matched to a physical thing.
meta: `${h.labelId} · issued ${h.issued}`,
}))}
/>
)}
</NumberedField>
{/* Both steps stay on screen from the start, as the mockup draws them. Revealing "what
happened" only after a garment is chosen hid half the job from somebody deciding whether
this screen was the one they wanted. */}
<NumberedField n={2} label="What happened">
<MChipRow
label="What happened"
value={kind}
onPick={(k) => { setKind(k); setErr(""); }}
options={DAMAGE_KINDS.map((d) => ({ value: d, label: d }))}
/>
<textarea
value={note} onChange={(e) => setNote(e.target.value)} rows={3}
aria-label="Anything the linen room should know (optional)"
placeholder="Anything the linen room should know (optional)"
style={{ width: "100%", minHeight: 84, marginTop: 14, padding: 12, border: "2px solid var(--color-divider)", borderRadius: 0, font: "inherit", fontSize: 16, resize: "none", background: "#fff", color: "var(--color-text)" }}
/>
</NumberedField>
<div style={{ padding: "16px 16px 0" }}>
<MSwitchRow
title="Ask for a replacement too"
sub={canRequest
? `Goes to ${managerName} with the report`
: "Nobody is recorded as your approver yet — ask the linen room to set your manager"}
on={replace && canRequest}
onToggle={() => setReplace((v) => !v)}
disabled={!canRequest}
/>
<p style={{ fontSize: 13, lineHeight: 1.6, color: N700, margin: "12px 0 0" }}>
The damaged item comes off your record when you hand it in at the counter.
</p>
</div>
<MError msg={err} onDismiss={() => setErr("")} />
<div style={{ height: 12 }} />
</MBody>
<MBar
label={busy ? "Sending…" : replace && canRequest ? "Report and request" : "Report it"}
disabled={!ready || busy}
offReason="Pick the item and what happened"
onClick={async () => {
if (!held || !kind) return;
/* `replacement` is the whole request.create result at run time — id, code, manager and
* whether an email actually left the server — because damage.report raises the
* replacement through that op and hands back what it returned. Every field past the id
* is optional here: the app in somebody's pocket can be older or newer than the server
* it is talking to, and a missing one only costs a line of the confirmation. */
const r = await mutate<{
replacement: { id: string; code?: string; manager?: string; notified?: boolean } | null;
replacementNote?: string;
}>("damage.report", { issueId: held.issueId, kind, note, replace: replace && canRequest });
if (!r.ok) { setErr(r.error); return; }
const why = (r.result.replacementNote || "").trim();
if (!r.result.replacement && why) { setNoReplacement(why); return; }
const rep = r.result.replacement;
setDone({
what: `${held.item}${held.size}`,
order: rep ? {
id: rep.id, code: rep.code || "", manager: rep.manager || managerName, notified: !!rep.notified,
} : null,
});
}}
/>
</>
);
}
+340
View File
@@ -0,0 +1,340 @@
"use client";
/* 2C — Raise for someone you manage.
*
* A manager standing next to somebody who will not type it in themselves: they pick the person,
* build the list, and the record says both names. The ward desk once had a screen of its own for
* the same job, on the grounds that half a ward would never install anything; it is gone, because
* the manager is already the person that ward would have asked.
*
* The rule that shapes it: **nobody approves their own raise.** The approver is the subject's
* manager — which here is the person typing — so the server sends it a level up, and this screen
* says so by name before the button is pressed. A manager who could approve a request they typed
* themselves would be no approval at all.
*/
import { useMemo, useRef, useState } from "react";
import { MBar, MBody, MError, MRule, MTop, inputStyle } from "@/components/m";
import {
ACCENT_300, DarkCard, DraftLineList, EdgeRow, GarmentPicker, GROUND, INK, N500, N600,
NumberedField, OptionList, type DraftLine,
} from "@/components/staffui";
import Team, { Band } from "./Team";
import { useStaff } from "@/lib/staffclient";
import { REQUEST_REASONS, statusText } from "@/lib/staffreq";
import type { ReqRow } from "@/lib/staffdata";
type Person = {
id: string; name: string; num: string; group: string; hasApp: boolean;
recordedTop: string; recordedPants: string;
held: Record<string, number>; lastSizes: Record<string, string>;
};
type Size = { size: string; si: number; word: string; countedOn: string };
type Item = { id: string; item: string; type: string; gender: string; sizes: Size[]; recorded: string; isTop: boolean; isPant: boolean };
export default function DeskScreen({ people, items, raised, maxLines, maxQty }: {
/** The people who report to whoever is raising, which is exactly the set the server accepts. */
people: Person[]; items: Item[];
/** What they have raised for other people and not yet seen the end of. */
raised: ReqRow[];
maxLines: number; maxQty: number;
}) {
const { mutate, busy } = useStaff();
const [q, setQ] = useState("");
const [personId, setPersonId] = useState<string | null>(null);
const [lines, setLines] = useState<DraftLine[]>([]);
const [adding, setAdding] = useState(false);
const [reason, setReason] = useState<string | null>(null);
const [note, setNote] = useState("");
const [err, setErr] = useState("");
const [sent, setSent] = useState<{ id: string; code: string; manager: string; escalated: boolean } | null>(null);
const person = people.find((p) => p.id === personId) || null;
const garments = lines.reduce((n, l) => n + l.qty, 0);
const full = lines.length >= maxLines;
const picking = adding || lines.length === 0;
const matches = useMemo(() => {
const needle = q.trim().toLowerCase();
if (!needle) return people.slice(0, 8);
return people.filter((p) => p.name.toLowerCase().includes(needle) || p.num.toLowerCase().includes(needle)).slice(0, 12);
}, [people, q]);
function pick(id: string) {
setPersonId(id);
setErr("");
setLines([]);
setAdding(false);
}
/* The result list is a radio group, so it has to answer the arrow keys.
*
* A screen reader in forms mode announces it as "radio group, 1 of 8", and a manager standing
* next to the nurse they are raising for presses Down to reach her — having been told it is a
* radio group, they have no reason to try Tab. Without this they sit on the first name and give
* up. Two halves make it work: one tab stop into the group (the roving tabIndex below), and the
* arrows moving inside it. Moving also selects, the way a radio group does everywhere else,
* which clears any draft lines exactly as clicking a different name always has. */
const radios = useRef<(HTMLButtonElement | null)[]>([]);
const activeIdx = Math.max(0, matches.findIndex((p) => p.id === personId));
function moveTo(i: number) {
const next = matches[i];
if (!next) return;
pick(next.id);
radios.current[i]?.focus();
}
function onResultKey(e: React.KeyboardEvent) {
if (!matches.length) return;
const fwd = e.key === "ArrowDown" || e.key === "ArrowRight";
const back = e.key === "ArrowUp" || e.key === "ArrowLeft";
if (!fwd && !back && e.key !== "Home" && e.key !== "End") return;
e.preventDefault(); // otherwise the arrows scroll the list out from under the focused name
if (e.key === "Home") return moveTo(0);
if (e.key === "End") return moveTo(matches.length - 1);
// Nothing chosen yet — or the search has moved on past whoever was — so the first press lands
// on the first match rather than skipping it.
const at = matches.findIndex((p) => p.id === personId);
if (at < 0) return moveTo(0);
moveTo((at + (fwd ? 1 : -1) + matches.length) % matches.length);
}
/* Their recorded size, not the clerk's guess. Tops and trousers carry one on the register; for
* everything else — a fleece, a vest, a dress — the size of the last one they were issued is all
* the record knows, and it is a far better opening bid than an empty grid. */
function sizeFor(it: Item): string {
if (!person) return "";
if (it.isPant) return person.recordedPants;
if (it.isTop) return person.recordedTop;
return person.lastSizes[it.id] || "";
}
function add(l: { itemId: string; si: number; item: string; size: string; qty: number }) {
setErr("");
setLines((cur) => {
// The server sums duplicate lines before it writes them, so two identical rows on the screen
// would be showing something that cannot be saved.
const at = cur.findIndex((x) => x.itemId === l.itemId && x.si === l.si);
if (at >= 0) {
const next = [...cur];
next[at] = { ...next[at], qty: Math.min(maxQty, next[at].qty + l.qty) };
return next;
}
return [...cur, { ...l, key: `${l.itemId}:${l.si}:${cur.length}` }];
});
setAdding(false);
}
const ready = !!person && lines.length > 0;
/* Where it actually went.
*
* A manager raising for their own report is approving nothing: the server sends it up a level,
* or leaves it for the linen room to address when there is nobody above them. Either way the
* person who typed it needs to be told, by name, rather than dropped on an order screen that
* says "awaiting approval" and leaves them to work out whose. */
if (sent) {
return (
<>
<MTop title="Raised" />
<MRule />
<MBody>
<div style={{ padding: 16 }}>
<DarkCard
kicker={sent.code}
title={sent.manager ? `With ${sent.manager}` : "Nobody approves this yet"}
meta={sent.manager
? (sent.escalated
? "You approve their requests, so this went up a level — nobody approves their own raise."
: "They have been told, and it stays on your list until it is done.")
: "There is nobody above you on the register, so the linen room will address it to an approver."}
>
<div style={{ borderTop: "1px solid #4a4746", marginTop: 16, paddingTop: 14, fontSize: 13, lineHeight: 1.55, color: N500 }}>
You&rsquo;ll see the outcome here and under Raised in your orders.
</div>
</DarkCard>
</div>
</MBody>
<MBar label="See the order" glyph="arrow" href={`/my/orders/${sent.id}`} />
</>
);
}
/* The bar lives below the scrolling body, so the shell is handed it rather than it being drawn
inside the list, where it would scroll away with the garments it is about to send. */
const sendBar = (
<MBar
label={busy ? "Sending…" : "Send for approval"}
sub={person && lines.length ? `${garments} garment${garments === 1 ? "" : "s"} for ${person.name.split(" ")[0]}` : undefined}
disabled={!ready || busy}
onClick={async () => {
if (!person || !lines.length) return;
const r = await mutate<{ id: string; code: string; manager: string; escalated: boolean }>("request.create", {
subjectId: person.id,
lines: lines.map((l) => ({ itemId: l.itemId, si: l.si, qty: l.qty })),
reason: reason || "", note,
});
if (!r.ok) { setErr(r.error); return; }
// A raise that went somewhere other than the obvious place — up a level, or to nobody at
// all — is worth a screen of its own. Anything ordinary goes straight to the order, which
// is where the person who typed it will come looking for it.
if (r.result.escalated || !r.result.manager) {
setSent({ id: r.result.id, code: r.result.code, manager: r.result.manager, escalated: r.result.escalated });
return;
}
window.location.assign(`/my/orders/${r.result.id}`);
}}
/>
);
return (
<Team active="/my/raise" foot={sendBar}>
<div style={{ padding: "16px 16px 0" }}>
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 22, letterSpacing: "-0.01em", lineHeight: 1.2 }}>
Raise for your team
</div>
{/* The one rule that shapes this screen: a manager who could approve what they typed
themselves would be no approval at all. */}
<p style={{ fontSize: 14, lineHeight: 1.5, color: N600, margin: "4px 0 0" }}>
Goes above you, not to you your own manager approves it.
</p>
</div>
<NumberedField n={1} label="Who is it for" first>
{/* The NumberedField heading names the step, not this box, so the box says what it is
itself — a placeholder disappears the moment anyone types into it. */}
<input
value={q} onChange={(e) => { setQ(e.target.value); setErr(""); }}
aria-label="Search by name or staff number"
placeholder="Name or staff number" autoComplete="off"
style={{ ...inputStyle, width: "100%" }}
/>
<div role="radiogroup" aria-label="Search results" onKeyDown={onResultKey} style={{ display: "grid", gap: 2, marginTop: 12 }}>
{matches.map((p, i) => {
const on = p.id === personId;
return (
<button key={p.id} role="radio" aria-checked={on}
ref={(el) => { radios.current[i] = el; }}
// One tab stop for the whole group: Tab reaches the chosen name (or the first
// one), and the arrows move between them from there.
tabIndex={i === activeIdx ? 0 : -1}
onClick={() => pick(p.id)}
style={{
textAlign: "left", padding: "12px 14px", border: 0, borderRadius: 0, font: "inherit",
background: on ? INK : "#fff", color: on ? GROUND : INK, cursor: "pointer", minHeight: 48,
}}>
<div style={{ fontSize: 16, fontWeight: 800 }}>{p.name}</div>
<div style={{ fontSize: 12.5, marginTop: 3, opacity: 0.85 }}>{[p.num, p.group].filter(Boolean).join(" · ")}</div>
{on && !p.hasApp && (
<div style={{ fontSize: 11, fontWeight: 800, letterSpacing: "0.12em", textTransform: "uppercase", color: ACCENT_300, marginTop: 6 }}>
No app you&rsquo;ll have to pass the outcome on
</div>
)}
</button>
);
})}
{matches.length === 0 && (
<p style={{ fontSize: 14, color: N600, lineHeight: 1.6, margin: 0 }}>Nobody on your team matches that.</p>
)}
</div>
</NumberedField>
{person && (
<NumberedField n={2} label={`What ${person.name.split(" ")[0]} needs`}>
{lines.length > 0 && (
<div style={{ marginBottom: picking ? 14 : 0 }}>
<DraftLineList
lines={lines}
maxQty={maxQty}
onQty={(k, q2) => setLines((cur) => cur.map((l) => (l.key === k ? { ...l, qty: q2 } : l)))}
onRemove={(k) => setLines((cur) => cur.filter((l) => l.key !== k))}
/>
</div>
)}
{picking ? (
<GarmentPicker
items={items}
maxQty={maxQty}
addLabel={lines.length ? "Add it" : "Add to the request"}
onCancel={lines.length ? () => setAdding(false) : undefined}
defaultSi={(it) => {
const want = sizeFor(it);
return it.sizes.find((s) => String(s.size) === String(want))?.si ?? null;
}}
note={(it) => {
const rec = sizeFor(it);
const holds = person.held[it.id] || 0;
return [
rec ? `Recorded size ${rec}` : "No size on record for this one",
holds ? `holds ${holds}` : "",
].filter(Boolean).join(" · ");
}}
onAdd={add}
/>
) : full ? (
<p style={{ fontSize: 13, color: N600, lineHeight: 1.55, margin: "12px 0 0" }}>
That is {maxLines} lines as much as one request carries. Send this one and raise
another for anything else.
</p>
) : (
<button
onClick={() => { setAdding(true); setErr(""); }}
style={{
width: "100%", minHeight: 52, marginTop: 2, border: `2px solid ${INK}`, borderRadius: 0,
background: "transparent", color: INK, font: "inherit", fontWeight: 800, fontSize: 13,
letterSpacing: "0.06em", textTransform: "uppercase", textAlign: "left", padding: "0 16px", cursor: "pointer",
}}
>Add another garment</button>
)}
{lines.length > 0 && !picking && (
<div style={{ marginTop: 14 }}>
<OptionList
label="Why"
columns={2}
value={reason}
onPick={setReason}
options={REQUEST_REASONS.map((r) => ({ key: r, label: r }))}
/>
<textarea
value={note} onChange={(e) => setNote(e.target.value)} rows={2}
aria-label="Anything the linen room should know (optional)"
placeholder="Anything the linen room should know (optional)"
style={{ width: "100%", minHeight: 68, marginTop: 14, padding: 12, border: "2px solid var(--color-divider)", borderRadius: 0, font: "inherit", fontSize: 15, resize: "none", background: "#fff", color: "var(--color-text)" }}
/>
</div>
)}
</NumberedField>
)}
{/* What they have already raised for other people. Without this the manager who was told
"you'll see the outcome" had nowhere to see it: every list in this app starts from the
wearer, and a request raised for somebody else belongs to none of them. */}
{raised.length > 0 && (
<>
<Band label="Raised by you · still open" />
<div style={{ display: "grid", gap: 2, padding: "12px 0" }}>
{raised.map((r) => {
const st = statusText(r, { mine: false, first: r.subjectName?.split(" ")[0] });
return (
<EdgeRow key={r.id} tone={st.ink === "attention" ? "accent" : "divider"} href={`/my/orders/${r.id}`}>
<div style={{ display: "flex", gap: 12, alignItems: "baseline" }}>
<span style={{ flex: 1, fontSize: 16, fontWeight: 800 }}>{r.subjectName}</span>
<span style={{ fontSize: 12, color: N600 }}>{r.code}</span>
</div>
<div style={{ fontSize: 14.5, fontWeight: 600, marginTop: 5, lineHeight: 1.35 }}>{r.summary}</div>
<div style={{ fontSize: 12.5, color: N600, marginTop: 4 }}>
{[st.label, st.note].filter(Boolean).join(" · ")}
</div>
</EdgeRow>
);
})}
</div>
</>
)}
<MError msg={err} onDismiss={() => setErr("")} />
<div style={{ height: 12 }} />
</Team>
);
}
+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 />
</>
);
}
+147
View File
@@ -0,0 +1,147 @@
"use client";
/* 1B — My kit.
*
* The linen room's record of what this person holds, shown to the person it is about, and a way
* to say it is wrong. "This isn't right" is deliberately blunt and deliberately last: the record
* is usually correct, and a dispute button placed first would invite one before anybody had read
* the list.
*
* The bar is a screen-level one, drawn on ALL THREE segments rather than only on Holding. A wrong
* recorded *size* is one of the commonest things anybody queries, and that is read on My sizes —
* where the screen literally says "tell them below", a promise only kept while the bar is there.
*/
import { useState } from "react";
import { useRouter } from "next/navigation";
import { MBody, MEmpty, MError, MRow, MRule, MTop } from "@/components/m";
import { INK, N600, SecondaryBar, Segments, SlipCard } from "@/components/staffui";
import StaffNav from "@/components/staffnav";
import { useDraft, useStaff } from "@/lib/staffclient";
import { fmtDate } from "@/lib/compute";
type Held = { itemId: string; item: string; size: string; si: number; qty: number; last: string };
/* One row per garment and size with its quantity — the slip as it was signed, not one repeated row
* per garment. The grouping is done in kitData(); the screen only draws it. */
type Slip = { id: string; date: string; lines: { item: string; size: string; qty: number }[]; signed: boolean };
type Data = { held: Held[]; total: number; handedBackThisYear: number; fyFrom: string; sizes: { top: string; pants: string }; slips?: Slip[] };
export default function KitScreen({ data }: { data: Data }) {
const { mutate, busy } = useStaff();
const router = useRouter();
const [tab, setTab] = useState<"holding" | "sizes" | "slips">("holding");
const [disputing, setDisputing] = useState(false);
// Kept across a dropped send and a screen change: ward wifi drops mid-sentence, and the one thing
// worse than a complaint that did not send is a complaint that did not send and is gone.
const draft = useDraft("dispute");
const [err, setErr] = useState("");
const [sent, setSent] = useState(false);
const slips = data.slips || [];
return (
<>
<MTop title="My kit" />
<MRule />
<MBody>
<Segments
label="What to show"
value={tab}
onPick={setTab}
options={[{ key: "holding" as const, label: "Holding" }, { key: "sizes" as const, label: "My sizes" }, { key: "slips" as const, label: "Slips" }]}
/>
{tab === "holding" && (
<div style={{ padding: "0 16px" }}>
{data.held.length === 0 ? (
<MEmpty title="Nothing on your record" sub="Anything the linen room issues you shows up here." />
) : (
data.held.map((h) => (
<MRow
key={`${h.itemId}:${h.si}`}
title={`${h.item}${h.size}`}
sub={`Last issued ${fmtDate(h.last)}`}
right={`×${h.qty}`}
/>
))
)}
{/* A row, not the paragraph this used to be: it is one figure about their own record,
and it reads as one line beside the garments it belongs with. */}
<MRow title="Handed back this year" right={String(data.handedBackThisYear)} />
</div>
)}
{tab === "sizes" && (
<div style={{ padding: "0 16px" }}>
<MRow title="Top" right={data.sizes.top || "not recorded"} />
<MRow title="Trouser" right={data.sizes.pants || "not recorded"} />
<div style={{ padding: "18px 0 6px", fontSize: 14, color: N600, lineHeight: 1.5 }}>
The linen room records these. Tell them below if they are wrong.
</div>
{/* Swapping a size is a request like any other, so it goes to the request screen rather
than living as its own flow. This is the door it is reached by. */}
<MRow title="Swap a size" sub="Hand one back, ask for another" href="/my/request?swap=1" chev />
</div>
)}
{tab === "slips" && (
slips.length === 0 ? (
<div style={{ padding: "0 16px" }}>
<MEmpty title="No signed slips" sub="A slip arrives when you sign for a hand-over at the counter." />
</div>
) : (
slips.map((sl) => (
<div key={sl.id}>
<SlipCard
date={fmtDate(sl.date)}
lines={sl.lines}
sigSrc={sl.signed ? `/api/staff/slip/${encodeURIComponent(sl.id)}/sig` : undefined}
/>
{/* A slip with no signature stored is not a broken screen, and saying so is kinder
than an empty space where a signature obviously belongs. */}
{!sl.signed && (
<div style={{ padding: "0 16px 14px", background: "#fff", fontSize: 12.5, color: N600 }}>
Handed over at the counter · no signature on file
</div>
)}
</div>
))
)
)}
{sent && (
<div style={{ margin: 16, background: INK, color: "var(--color-bg)", padding: 16, fontSize: 14, lineHeight: 1.5 }}>
Sent. The linen room will look at your record and come back to you.
</div>
)}
{disputing && !sent && (
<div style={{ padding: 16, borderTop: "2px solid " + INK }}>
<label htmlFor="tc-dispute" style={{ display: "block", fontSize: 13, fontWeight: 800, letterSpacing: "0.06em", textTransform: "uppercase" }}>What doesnt look right?</label>
<textarea
id="tc-dispute"
value={draft.value} onChange={(e) => { draft.set(e.target.value); setErr(""); }} rows={4}
placeholder="e.g. I handed two tunics back in August but theyre still on here"
style={{ width: "100%", minHeight: 84, marginTop: 12, padding: 12, border: "2px solid var(--color-divider)", borderRadius: 0, font: "inherit", fontSize: 15, resize: "none", background: "#fff", color: INK }}
/>
<MError msg={err} onDismiss={() => setErr("")} />
<div style={{ display: "flex", gap: 10, marginTop: 12, flexWrap: "wrap" }}>
<button
disabled={busy || !draft.value.trim()}
onClick={async () => {
const r = await mutate("dispute.raise", { body: draft.value });
if (!r.ok) { setErr(r.error); return; }
setSent(true); setDisputing(false); draft.clear(); router.refresh();
}}
style={{ minHeight: 48, padding: "0 20px", background: "var(--color-accent)", color: "#fff", border: 0, borderRadius: 0, font: "inherit", fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 13, letterSpacing: "0.08em", textTransform: "uppercase", cursor: busy ? "wait" : "pointer", opacity: busy || !draft.value.trim() ? 0.45 : 1 }}
>{busy ? "Sending…" : "Send to the linen room"}</button>
<button onClick={() => { setDisputing(false); setErr(""); }}
style={{ minHeight: 48, padding: "0 20px", background: "transparent", color: INK, border: "2px solid " + INK, borderRadius: 0, font: "inherit", fontWeight: 800, fontSize: 13, letterSpacing: "0.06em", textTransform: "uppercase", cursor: "pointer" }}>Cancel</button>
</div>
</div>
)}
<div style={{ height: 12 }} />
</MBody>
{!disputing && !sent && <SecondaryBar label="This isnt right" onClick={() => setDisputing(true)} />}
<StaffNav />
</>
);
}
+186
View File
@@ -0,0 +1,186 @@
"use client";
/* 2A — Kit check. Every few months, reconcile the record with reality, item by item.
*
* "Nothing here is chargeable" is not reassurance for its own sake — a check that felt like an
* audit would be answered with whatever number keeps somebody out of trouble, and the resulting
* data would be worse than no data.
*
* Writing off and re-requesting stay separate acts. Saying a garment is missing does not quietly
* order another one: that would turn an honest answer into a request somebody's manager has to
* decline, which is exactly how you teach a ward to stop answering honestly.
*
* Between rounds this is a STATE, not a refusal. A refusal explains nothing by design, and "no kit
* check is open" is not a secret — it is the answer somebody who tapped a notification from last
* autumn needs, with the one way onward under it.
*/
import { useState } from "react";
import { MBar, MBody, MEmpty, MError, MONO, MRule, MTop } from "@/components/m";
import { ACCENT_300, DIVIDER, GROUND, INK, N300, N600, N700 } from "@/components/staffui";
import { useStaff } from "@/lib/staffclient";
import { fmtDate } from "@/lib/compute";
type Row = { itemId: string; item: string; size: string; si: number; onRecord: number; answered: number | null };
export default function KitCheckScreen({ closed, dueBy = "", lastConfirmed = "", rows = [] }: {
/** No cycle open. The screen still exists; it just has one thing to say. */
closed?: boolean;
dueBy?: string; lastConfirmed?: string; rows?: Row[];
}) {
const { mutate, busy } = useStaff();
const [answers, setAnswers] = useState<Record<string, number>>(
Object.fromEntries(rows.filter((r) => r.answered !== null).map((r) => [`${r.itemId}:${r.si}`, r.answered as number])),
);
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
const [err, setErr] = useState("");
const [done, setDone] = useState(false);
const answeredCount = rows.filter((r) => answers[`${r.itemId}:${r.si}`] !== undefined).length;
async function answer(r: Row, confirmed: number) {
const k = `${r.itemId}:${r.si}`;
setAnswers((a) => ({ ...a, [k]: confirmed }));
setErr("");
const res = await mutate("kit.answer", { itemId: r.itemId, si: r.si, onRecord: r.onRecord, confirmed });
if (!res.ok) {
setErr(res.error);
setAnswers((a) => { const n = { ...a }; delete n[k]; return n; });
}
}
// aria-pressed, because the only other thing separating the chosen answer from the unchosen one
// is an ink fill: nothing a screen reader can hear, and nothing at all in high contrast.
const pick = (on: boolean, label: string, onClick: () => void): React.ReactNode => (
<button onClick={onClick} aria-pressed={on} style={{
flex: 1, minWidth: 0, minHeight: 44, borderRadius: 0, font: "inherit",
border: `2px solid ${on ? INK : DIVIDER}`,
background: on ? INK : "#fff", color: on ? GROUND : N700,
fontWeight: 700, fontSize: 14, cursor: "pointer",
}}>{label}</button>
);
if (closed) {
return (
<>
<MTop title="Kit check" back backHref="/my" />
<MRule />
<MBody>
<div style={{ padding: "0 16px" }}>
<MEmpty title="No kit check is open" sub="The linen room opens one every few months. You get a notification." />
</div>
</MBody>
<MBar label="Back to home" href="/my" />
</>
);
}
if (done) {
return (
<>
<MTop title="Kit check" back backHref="/my" />
<MRule />
<MBody>
<div style={{ background: INK, color: GROUND, padding: 16 }}>
<div style={{ fontSize: 12, fontWeight: 800, letterSpacing: "0.09em", textTransform: "uppercase", color: ACCENT_300 }}>Thanks</div>
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 900, fontSize: 22, lineHeight: 1.15, marginTop: 4 }}>
Thats your record confirmed.
</div>
<div style={{ fontSize: 13, lineHeight: 1.5, color: N300, marginTop: 4 }}>
The linen room will square anything short. Nothing is charged.
</div>
</div>
</MBody>
<MBar label="Back to home" href="/my" />
</>
);
}
return (
<>
<MTop title="Kit check" back backHref="/my" right={dueBy ? `Due ${fmtDate(dueBy)}` : undefined} />
<MRule />
<MBody>
{/* The ink header carries the question and the one rule that decides how honestly it gets
answered. Nobody keeps their uniform at work — it is at home, in the wash, in a bag in
the boot — so the ask is to count what they still have, wherever it is. */}
<div style={{ background: INK, color: GROUND, padding: "14px 16px 16px" }}>
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 900, fontSize: 22, letterSpacing: "-0.015em", lineHeight: 1.05 }}>
Have you still got everything on your record?
</div>
<div style={{ fontSize: 13, color: "#d6d3d2", marginTop: 4, lineHeight: 1.45 }}>
Count everything you still have, wherever it is. Nothing here is chargeable.
{lastConfirmed ? ` Last confirmed ${fmtDate(lastConfirmed)}.` : ""}
</div>
</div>
<MError msg={err} onDismiss={() => setErr("")} />
{rows.length === 0 ? (
<div style={{ padding: "0 16px" }}>
<MEmpty title="Nothing on your record to check." sub="Anything the linen room issues you shows up here." />
</div>
) : (
<div style={{ padding: "0 16px" }}>
{rows.map((r) => {
const k = `${r.itemId}:${r.si}`;
const a = answers[k];
const short = a !== undefined && a < r.onRecord;
const open = expanded[k];
return (
<div key={k} style={{
borderBottom: `1px solid ${DIVIDER}`, padding: "10px 0 12px",
...(short ? { borderLeft: "6px solid var(--color-accent)", paddingLeft: 12, marginLeft: -12 } : null),
}}>
<div style={{ display: "flex", justifyContent: "space-between", gap: 10, alignItems: "baseline" }}>
<span style={{ fontSize: 15, fontWeight: 700 }}>{r.item} {r.size}</span>
<span style={{ fontFamily: MONO, fontSize: 14 }} aria-label={`${r.onRecord} on record`}>×{r.onRecord}</span>
</div>
<div style={{ display: "flex", gap: 6, marginTop: 6 }}>
{/* "All 3 here" was an answer only somebody standing in front of the garments
could give, and nobody is: they are at home, half of them in the wash. */}
{pick(a === r.onRecord, `Got all ${r.onRecord}`, () => { setExpanded((e) => ({ ...e, [k]: false })); void answer(r, r.onRecord); })}
{pick(short || !!open, short ? (a === 0 ? "None left" : `Only ${a}`) : "Fewer", () => setExpanded((e) => ({ ...e, [k]: !e[k] })))}
</div>
{open && (
<div style={{ display: "flex", flexWrap: "wrap", gap: 8, marginTop: 8 }}>
{Array.from({ length: r.onRecord }, (_, i) => i).map((n) => (
<button key={n} aria-pressed={a === n} onClick={() => { void answer(r, n); setExpanded((e) => ({ ...e, [k]: false })); }} style={{
minHeight: 46, minWidth: 50, padding: "0 12px", borderRadius: 0, font: "inherit",
border: `2px solid ${a === n ? INK : DIVIDER}`,
background: a === n ? INK : "#fff", color: a === n ? GROUND : N700,
fontWeight: 700, fontSize: 14, cursor: "pointer",
}}>{n === 0 ? "None left" : `Only ${n}`}</button>
))}
</div>
)}
{short && (
/* This used to say the missing ones "come off your record". They don't — the
answer is written down and that is all it does; only the linen room can
change the record. Telling somebody it has already been corrected, when it
hasn't, is how they stop believing the next thing this screen says. */
<div style={{ fontSize: 13, lineHeight: 1.5, color: N700, marginTop: 8 }}>
{r.onRecord - (a as number)} unaccounted for. The linen room will square your record.
</div>
)}
</div>
);
})}
</div>
)}
<div style={{ height: 12 }} />
</MBody>
{rows.length === 0 ? (
<MBar label="Back to home" href="/my" />
) : (
<MBar
label={busy ? "Saving…" : "Confirm"}
small={`${answeredCount} of ${rows.length} answered`}
disabled={busy || answeredCount < rows.length}
onClick={() => setDone(true)}
/>
)}
</>
);
}
+111
View File
@@ -0,0 +1,111 @@
"use client";
/* 1F — Messages. Every conversation with the linen room, newest word first.
*
* There is no inbox and no direct messaging in this product: a message always hangs off the request
* it is about, which is what stops it becoming a chat app nobody staffs. So this screen is a list of
* requests that have been talked about — not a mailbox — and under it the orders that could be
* talked about but haven't been yet.
*
* The tab that opens this used to be a button that pushed /my/orders: a control among links, absent
* from any list of the app's navigation, and a tab that lied about where it went. It is a link now
* (components/staffnav.tsx) and this is the screen behind it.
*/
import { MBody, MRow, MRule, MSection, MTop } from "@/components/m";
import { EdgeRow, N600 } from "@/components/staffui";
import StaffNav from "@/components/staffnav";
import { useStaff } from "@/lib/staffclient";
import { statusText } from "@/lib/staffreq";
import { addDays, facilityDate, facilityToday, formatInZone } from "@/lib/compute";
import type { ReqRow, ThreadRow } from "@/lib/staffdata";
/* How long a line of somebody else's message is allowed to be before the row stops being a row.
* The mockup's 54 characters, kept as a number so the truncation and the ellipsis agree. */
const PREVIEW = 54;
const preview = (body: string) => {
const one = body.replace(/\s+/g, " ").trim();
return one.length > PREVIEW ? one.slice(0, PREVIEW) + "…" : one;
};
/* The right-hand stamp: a time today, a weekday this week, a date before that.
*
* Formatted in the facility's zone on both sides of hydration for the same reason the thread's own
* separators are — this screen is server-rendered and then hydrated, and a stamp read off the
* server's ambient zone is a different string in the browser, which React logs and a nurse sees
* flicker. */
function stamp(iso: string, tz: string): string {
const day = facilityDate(iso, tz);
const today = facilityToday(tz);
if (!day) return "";
if (day === today) return formatInZone(iso, tz, { hour: "2-digit", minute: "2-digit", hour12: false });
if (day >= addDays(today, -6)) return formatInZone(iso, tz, { weekday: "short" });
return formatInZone(iso, tz, { day: "numeric", month: "short" });
}
export default function MessagesScreen({ threads, startable }: { threads: ThreadRow[]; startable: ReqRow[] }) {
const { me } = useStaff();
return (
<>
<MTop title="Messages" />
<MRule />
<MBody>
{threads.length === 0 ? (
/* Nothing to show, not a refusal: there is a way out of it and the screen says what it
is. A dead end with no explanation is reserved for a record that isn't yours. */
<div style={{ padding: "26px 16px" }}>
<div style={{ fontSize: 18, fontWeight: 800 }}>No messages yet</div>
<div style={{ fontSize: 14, color: N600, marginTop: 2 }}>Ask about any order and the thread starts here.</div>
</div>
) : (
<div style={{ display: "grid", gap: 2, padding: "12px 0" }}>
{threads.map((t) => {
const st = statusText({ status: t.status }, { mine: t.mine, first: t.subjectName.split(" ")[0] });
return (
<EdgeRow
key={t.id}
/* Accent only where the linen room has said something nobody here has opened.
Everything else keeps the ink edge, so the colour means one thing. */
tone={t.unread ? "accent" : "ink"}
href={`/my/orders/${t.id}/messages`}
>
<div style={{ display: "flex", gap: 12, alignItems: "flex-start" }}>
<span style={{ flex: 1, minWidth: 0 }}>
<span style={{ display: "block", fontSize: 15, fontWeight: 800 }}>
{/* Whose uniform it is only needs saying when it isn't yours — a manager or
a clerk reading a thread on a request they typed in for somebody else. */}
{t.mine ? "Linen room" : `${t.subjectName} · you raised this`}
</span>
<span style={{ display: "block", fontSize: 13, color: N600, marginTop: 2, lineHeight: 1.45 }}>
{preview(t.last.body)}
</span>
<span style={{ display: "block", fontSize: 13, color: N600, marginTop: 2, lineHeight: 1.45 }}>
{t.code} · {st.label}
</span>
</span>
<span style={{ flex: "0 0 auto", fontFamily: "var(--font-plex-mono), 'IBM Plex Mono', ui-monospace, monospace", fontSize: 13, color: N600, whiteSpace: "nowrap" }}>
{stamp(t.last.at, me.tz)}
</span>
</div>
</EdgeRow>
);
})}
</div>
)}
{/* Open orders only. A question about a bag collected in March is a question for the
counter, and listing every order this person has ever had would bury the three that are
actually moving. */}
{startable.length > 0 && (
<div style={{ padding: "0 16px" }}>
<MSection label="Start one" />
{startable.map((r) => (
<MRow key={r.id} title={r.code} sub={r.summary} chev href={`/my/orders/${r.id}/messages`} />
))}
</div>
)}
<div style={{ height: 12 }} />
</MBody>
<StaffNav />
</>
);
}
+113
View File
@@ -0,0 +1,113 @@
"use client";
/* The Notifications section of Account: one switch per kind of thing this person can be told about.
*
* Two rules shape the whole thing.
*
* The phone is asked for permission when somebody turns the FIRST switch on, never at launch —
* Android 13+ policy, and plain manners. A wearer who never wants to be told is never asked.
*
* "Something waiting on you" is offered to anybody a request can be addressed to, which is not the
* same as "managers": the linen room can re-address one to somebody who manages nobody, and that
* person most needs telling — and, having been told, is entitled to silence it.
*/
import { useEffect, useState } from "react";
import { MError, MSwitchRow } from "@/components/m";
import { N600 } from "@/components/staffui";
import { useStaff } from "@/lib/staffclient";
import { askForPush, pushShell, rememberedToken, type PushShell } from "@/lib/staffpush";
export type NotifyPrefs = { approved: boolean; ready: boolean; round: boolean; kitcheck: boolean; waiting: boolean };
type Key = keyof NotifyPrefs;
const KINDS: { key: Key; title: string }[] = [
{ key: "approved", title: "Approved or declined" },
{ key: "ready", title: "Ready to collect" },
{ key: "round", title: "On the ward round" },
{ key: "kitcheck", title: "Kit check opens" },
];
export default function NotificationSettings({ prefs, configured }: { prefs: NotifyPrefs; configured: boolean }) {
const { me, counts, mutate } = useStaff();
const [on, setOn] = useState<NotifyPrefs>(prefs);
const [err, setErr] = useState("");
const [busy, setBusy] = useState<Key | null>(null);
/* Settled after mount: the server cannot know whether this is the app or a browser, and a line
* about where notifications arrive that changed on hydration would be worse than one that waits
* a beat. "old" is a shell from before the notification bridge existed — see lib/staffpush.ts. */
const [shell, setShell] = useState<PushShell>("none");
useEffect(() => { setShell(pushShell()); }, []);
const bridge = shell === "ready";
/* The waiting switch belongs to whoever a request can be ADDRESSED to, which is what the queue,
* the badge and the Team tab all key on. `counts.team` is the wrong number: it adds the bags on
* a ward desk, so a clerk who manages nobody was offered a switch for a notification the sender
* only ever writes to `r.managerId` — it could never fire, and it vanished off their Account
* screen the moment they signed for the bags. */
const rows = me.isManager || counts.approvals > 0
? [...KINDS, { key: "waiting" as Key, title: "Something waiting on you" }]
: KINDS;
async function toggle(k: Key) {
if (busy) return;
const before = on;
const next = { ...on, [k]: !on[k] };
setOn(next);
setErr("");
setBusy(k);
const r = await mutate("notify.prefs", { [k]: next[k] });
if (!r.ok) {
setOn(before);
setErr(r.error);
setBusy(null);
return;
}
/* Ask the phone after the preference is saved, not before: somebody who says no to Android
* still meant to turn the switch on, and their answer is kept. In a browser there is no bridge
* and nothing is asked — the preference is the server's either way, and it governs the phone
* whenever they next open the app.
*
* The question is "is this phone registered", not "is this the first switch". The old test —
* nothing on before this tap — could never be true: every switch defaults to ON, so a fresh
* account arrives here with all of them set, and for a wearer `waiting` is on with no row to
* turn it off. Android was therefore never asked, no device row was ever written, and every
* send found nobody, on a server with a key installed and every switch saying yes. A remembered
* token is the honest answer to whether this phone has ever handed one back. */
if (next[k] && bridge && configured && !rememberedToken()) {
const ask = await askForPush();
if (ask.ok) await mutate("push.register", { token: ask.token, platform: "android" });
}
setBusy(null);
}
return (
<>
<MError msg={err} onDismiss={() => setErr("")} />
{rows.map((r) => (
<MSwitchRow
key={r.key}
title={r.title}
on={on[r.key]}
disabled={!configured || busy !== null}
onToggle={() => void toggle(r.key)}
/>
))}
{!configured ? (
<div style={{ fontSize: 13, color: N600, padding: "12px 0 0", lineHeight: 1.5 }}>
Notifications arent set up on this server.
</div>
) : shell === "old" ? (
// In the app, on a build from before notifications existed. Saying "they arrive in the app"
// to somebody who is standing in it would be a line that explains nothing.
<div style={{ fontSize: 13, color: N600, padding: "12px 0 0", lineHeight: 1.5 }}>
Update the ThreadCount Staff app to turn these on.
</div>
) : !bridge ? (
<div style={{ fontSize: 13, color: N600, padding: "12px 0 0", lineHeight: 1.5 }}>
Notifications come to the ThreadCount Staff app on your phone.
</div>
) : null}
</>
);
}
+239
View File
@@ -0,0 +1,239 @@
"use client";
/* 1D — Order detail. The tracking screen, and the screen someone holds up at the counter.
*
* It leads with what to do next. The old order of this page was a history lesson with the action
* buried under it: the code somebody was walking to the counter to show sat below four timeline
* rows they had already read. So the top of the screen is now the status word and the bag, then
* the one thing there is to do — show the code, tell the desk you have it, read why it was
* refused — and the history follows underneath, where it belongs.
*
* The timeline still always shows the step that hasn't happened yet, as an outlined dot. Half the
* point of a progress list is what is still to come: one that only listed what had already
* happened left "so when do I get it?" unanswered, which is the question that sends people to the
* counter.
*/
import { useState } from "react";
import { CodeBlock, Kicker, LineList, N600, N700, OutlineButton, SecondaryBar, type Step, Timeline } from "@/components/staffui";
import { MBar, MBody, MError, MRule, MSection, MTop } from "@/components/m";
import { useStaff } from "@/lib/staffclient";
import { statusText } from "@/lib/staffreq";
import type { ReqStatus } from "@/lib/staffreq";
import type { ReqLine } from "@/lib/staffdata";
import { formatInZone } from "@/lib/compute";
type Ev = { id: string; label: string; meta: string; actorName: string; at: string };
type Data = {
id: string; code: string; status: string;
lines: ReqLine[]; summary: string; garments: number; lineCount: number; decision: string | null;
reason: string; note: string; managerName: string; declineReason: string | null;
collectCode: string | null; holdUntil: string; route: string | null;
signerName: string | null; signerRole: string | null; ward: string;
subjectName: string; raisedByName: string; mine: boolean; claimedAt: string | null;
createdAt: string;
events: Ev[];
};
/* The zone is the facility's, not the device's and not the server's.
*
* This screen is server-rendered and then hydrated, so a timeline stamp built without a zone was
* printed in whatever zone the host sits in and then quietly replaced with the phone's — a step
* taken at 08:00 in the linen room read "7 Sep, 22:00" until React caught up. */
function stamp(iso: string, tz: string) {
return formatInZone(iso, tz, { day: "numeric", month: "short" }) + ", " +
formatInZone(iso, tz, { hour: "2-digit", minute: "2-digit", hour12: false });
}
/** The step after the last one that happened, so the person can see where it goes next. */
function nextStep(status: string, holdUntil: string, route: string | null): { label: string; meta?: string } | null {
switch (status as ReqStatus) {
case "awaiting": return { label: "Approved", meta: "Then it goes to the linen room" };
case "accepted": return { label: "Picked from the shelf" };
case "picking": return { label: route === "ward_round" ? "Out on the ward round" : "Ready at the counter" };
case "ready": return { label: "Collected", meta: holdUntil ? `Held until ${holdUntil}` : undefined };
case "round": return { label: "Signed for on the ward" };
default: return null; // declined, collected and delivered are endings
}
}
export default function OrderScreen({ data }: { data: Data }) {
const { mutate, busy, me } = useStaff();
const [err, setErr] = useState("");
// The wearer's first name, for the third-person reading a manager or the desk gets of this page.
const first = (data.subjectName || "").split(" ")[0];
const st = statusText(data, { mine: data.mine, first });
// A request the linen room withdrew was never decided by the manager named on it; the timeline
// row it writes is the only record of that, so this is where the page finds out.
const withdrawn = data.status === "declined" && data.events.some((e) => e.label === "Withdrawn by the linen room");
/* The one thing only the requester can settle: whether the bag actually reached them.
*
* A ward clerk signs for the round, which is where the linen room's job ends — but the bag then
* sits on the desk, and until somebody says it was picked up the desk's unclaimed list only
* grows. This is that confirmation, and it is why round.claim exists; without a caller it never
* ran and the list never emptied.
*
* The mockup offers it while the bag is still `round`. The op does not, and should not: a bag
* still on the trolley has not been signed for by anybody, and claiming it would clear a desk
* list that has nothing on it yet. `delivered` — signed for on the ward, not yet picked up off
* the desk — is the state this question belongs to. */
const canClaim = data.status === "delivered" && data.mine && !data.claimedAt;
/* ⛔ The code is the wearer's alone.
*
* Four people can open this order — the wearer, the manager it was addressed to, whoever raised
* it and the ward desk holding the bag — and only one of them collects the bag. Without the
* `mine` test an approver read the four digits off their own approvals history and could walk to
* the counter with them, and was offered a "Show at the counter" button that /my/orders/[id]/code
* then refused. reqRow() no longer hands them the code at all; this is the same rule said on the
* screen, so neither end can drift. */
const showCode = data.mine && data.status === "ready" && !!data.collectCode;
const steps: Step[] = data.events.map((e, i) => ({
label: e.label,
meta: [e.actorName, e.meta, stamp(e.at, me.tz)].filter(Boolean).join(" · "),
state: i === data.events.length - 1 ? "current" : "done",
}));
const next = nextStep(data.status, data.holdUntil, data.route);
if (next) steps.push({ label: next.label, meta: next.meta, state: "future" });
return (
<>
{/* A real destination behind the chevron: this screen is what a tapped notification opens, and
on a cold start there is no history to pop, so a bare router.back() is a dead control. */}
<MTop title={data.code} back backHref="/my/orders" right={<span style={{ fontSize: 12, color: "var(--color-neutral-400)" }}>{data.mine ? "" : data.subjectName}</span>} />
<MRule />
<MBody>
<MError msg={err} onDismiss={() => setErr("")} />
{/* The headline: which order this is, then the one word that says where it has got to. */}
<div style={{ padding: "16px 16px 0", background: "var(--color-bg)" }}>
<Kicker tone={st.ink === "attention" ? "attention" : "quiet"}>
{data.code} · raised {formatInZone(data.createdAt, me.tz)}
</Kicker>
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 900, fontSize: 24, letterSpacing: "-0.02em", lineHeight: 1.1, marginTop: 4 }}>
{st.label}
</div>
</div>
{/* The bag and why it was asked for, in one bordered row under the headline. */}
<div style={{ margin: "12px 16px 0", padding: "10px 0 12px", borderBottom: "2px solid var(--color-text)" }}>
<div style={{ fontSize: 16, fontWeight: 800, lineHeight: 1.35 }}>{data.summary}</div>
<div style={{ fontSize: 13, color: N600, marginTop: 3 }}>
{/* `decision` is only there once the manager has been through it, and it is the honest
line when they didn't approve everything: "2 of 3 approved" above a list where the
fleece is struck out. */}
{[data.decision, data.reason].filter(Boolean).join(" · ")}
</div>
{!data.mine && data.subjectName && (
<div style={{ fontSize: 13, color: N600, marginTop: 6 }}>{`For ${data.subjectName}${data.raisedByName ? ` · raised by ${data.raisedByName}` : ""}`}</div>
)}
{data.mine && data.raisedByName && (
<div style={{ fontSize: 13, color: N600, marginTop: 6 }}>{`Raised for you by ${data.raisedByName}`}</div>
)}
</div>
{/* The whole ask, declines included. A request where the fleece was refused has to read
honestly on the wearer's own screen — the alternative is somebody collecting a bag,
counting two garments where they asked for three, and coming to the counter to find out
why. One line needs no list: the row above already is one. */}
{data.lineCount > 1 && (
<div style={{ padding: "16px 16px 0" }}>
<Kicker>{data.mine ? "What you asked for" : `What ${first || "they"} asked for`}</Kicker>
<div style={{ marginTop: 10 }}><LineList lines={data.lines} /></div>
</div>
)}
{/* ---- what to do next, before the history ---- */}
{showCode && (
<div style={{ padding: "16px 16px 0" }}>
<CodeBlock code={data.collectCode as string} kicker="Collection code" />
<div style={{ marginTop: 12 }}>
{/* A box in the flow of the screen, not the foot of it: the foot belongs to "Ask
about this order", and two flush-left bars with arrows one above the other read
as the same control twice. */}
<OutlineButton label="Show at the counter" href={`/my/orders/${data.id}/code`} />
</div>
{data.garments > 1 && (
<p style={{ fontSize: 13, color: N600, marginTop: 10, lineHeight: 1.55 }}>
All {data.garments} garments are in one bag under this code.
</p>
)}
{/* This used to say the hold lapses on its own and the garment goes back on the shelf.
Nothing does that: the hold is a note the linen room typed, there is no job that
reads it, and the only way out of `ready` is somebody collecting. So the copy says
what is true — it keeps waiting, and a late collection is a conversation rather than
a lost request. */}
{data.holdUntil && (
<p style={{ fontSize: 13, color: N600, marginTop: 10, lineHeight: 1.55 }}>
Held until {data.holdUntil}. It stays on the counter until you collect it if you
can&rsquo;t get there by then, say so on this order and the linen room will sort it out.
</p>
)}
</div>
)}
{canClaim && (
// In the flow rather than docked at the foot: it is the next thing to do, and the next
// thing to do lives at the top of this screen now. The screen updates in place when it
// lands — mutate() refreshes the route; nothing here reloads the app.
<div style={{ marginTop: 16 }}>
<MBar
label={busy ? "Working…" : "Ive got it"}
sub="Tells the desk the bag has been picked up"
glyph="check"
disabled={busy}
onClick={async () => {
const r = await mutate("round.claim", { id: data.id });
if (!r.ok) setErr(r.error);
}}
/>
</div>
)}
{data.status === "declined" && data.declineReason && (
<div style={{ background: "#fff", borderLeft: "6px solid var(--color-accent)", padding: "14px 16px", margin: "16px 16px 0" }}>
<Kicker tone="attention">Why</Kicker>
<div style={{ fontSize: 15, fontWeight: 800, marginTop: 6 }}>{data.declineReason}</div>
<p style={{ fontSize: 13.5, lineHeight: 1.55, color: N700, margin: "8px 0 0" }}>
{withdrawn
? "The linen room withdrew this. Ask at the counter if it needs another look."
: data.managerName
? `${data.managerName} decided this. Talk to them if it needs another look.`
: "Ask the linen room if it needs another look."}
</p>
</div>
)}
{data.status === "delivered" && data.signerName && (
<div style={{ margin: "16px 16px 0", background: "#fff", borderLeft: "6px solid var(--color-text)", padding: "14px 16px" }}>
<Kicker>Signed for</Kicker>
<div style={{ fontSize: 15, fontWeight: 800, marginTop: 6 }}>{data.signerName}{data.signerRole ? `, ${data.signerRole}` : ""}</div>
<p style={{ fontSize: 13.5, lineHeight: 1.55, color: N700, margin: "8px 0 0" }}>
{data.claimedAt
? `Picked up from the desk on ${data.ward || "your ward"}.`
: `Ask at the desk on ${data.ward || "your ward"} — whoever signed has it.`}
</p>
</div>
)}
{/* ---- then the history ---- */}
<div style={{ padding: "0 16px" }}><MSection label="Progress" /></div>
<Timeline steps={steps} />
{data.note && (
<div style={{ padding: "0 16px 16px" }}>
<Kicker>{data.mine ? "Your note" : `${first || "Their"}s note`}</Kicker>
<p style={{ fontSize: 14, lineHeight: 1.55, margin: "8px 0 0" }}>{data.note}</p>
</div>
)}
<div style={{ height: 12 }} />
</MBody>
{/* Docked at the foot, as the mockup has it, rather than sitting at the end of the scroll:
the one question somebody has about an order they are tracking is "can I ask about this?",
and on a long timeline that control was below everything they had already read. */}
<SecondaryBar label="Ask about this order" href={`/my/orders/${data.id}/messages`} />
</>
);
}
+108
View File
@@ -0,0 +1,108 @@
"use client";
/* 1C — Orders. Every request, newest first.
*
* The status *word* leads each row and the left border only reinforces it. Nothing here is
* distinguishable by colour alone, which matters on a ward phone in bad light as much as it does
* for anyone who can't tell the red from the grey.
*
* Three lists, not two. What somebody raised for another person — a manager for one of their team,
* plus anything still open from the desk route that used to exist — is deliberately kept out of
* Open and Done: what a wearer does with their own order (collect it, chase it, say they picked it
* up) is not what the person who typed it in does with it, and one mixed list is how somebody walks
* off with a bag that isn't theirs. Before this tab existed those requests appeared on no screen
* the raiser could reach at all.
*/
import { useState } from "react";
import { MBar, MBody, MEmpty, MRule, MTop, NOT_DOCKED } from "@/components/m";
import { DIVIDER, EdgeRow, INK, N600, Tabs } from "@/components/staffui";
import StaffNav from "@/components/staffnav";
import { useStaff } from "@/lib/staffclient";
import { NEEDS_STAFF, statusText } from "@/lib/staffreq";
import { formatInZone } from "@/lib/compute";
import type { ReqRow } from "@/lib/staffdata";
type Tab = "open" | "done" | "raised";
export default function OrdersScreen({ open, done, raised, initialTab }: {
open: ReqRow[]; done: ReqRow[]; raised: { open: ReqRow[]; done: ReqRow[] }; initialTab: Tab;
}) {
const { me } = useStaff();
// Open first with the ones still moving, then whatever has finished, so the tab reads top-down
// like the two it sits beside.
const forOthers = [...raised.open, ...raised.done];
const [tab, setTab] = useState<Tab>(initialTab === "raised" && !forOthers.length ? "open" : initialTab);
const rows = tab === "open" ? open : tab === "done" ? done : forOthers;
const tabs = [
{ key: "open" as const, label: "Open", count: open.length },
{ key: "done" as const, label: "Done", count: done.length },
...(forOthers.length ? [{ key: "raised" as const, label: "Raised", count: forOthers.length }] : []),
];
const empty = tab === "open"
? {
title: "Nothing open",
sub: forOthers.length
? "Anything you raise for somebody else is under Raised."
: "Anything you ask for shows here until you have it.",
}
: tab === "done"
? { title: "Nothing finished yet", sub: "Collected and declined orders stay here." }
: { title: "Nothing you raised for somebody else", sub: "What you type in for somebody else shows here." };
return (
<>
<MTop title="Orders" />
<MRule />
<MBody>
<Tabs label="Which orders" value={tab} onPick={setTab} options={tabs} />
{rows.length === 0 ? (
<div style={{ padding: "0 16px" }}><MEmpty title={empty.title} sub={empty.sub} /></div>
) : (
// The 1px rules between rows are the gap, not a border on each row: the last row then has
// no rule hanging under it, and every left edge still starts at the edge of the screen.
<div style={{ display: "grid", gap: 1, background: DIVIDER, borderBottom: `1px solid ${DIVIDER}` }}>
{rows.map((r) => {
const st = statusText(r, { mine: r.mine, first: r.subjectName.split(" ")[0] });
// The accent edge marks what is waiting on the wearer. An order somebody raised for
// a colleague is never waiting on the reader — they typed it in, they don't collect
// it — so it keeps the quiet edge whatever its status.
const attention = !r.mine ? false : NEEDS_STAFF.has(r.status as never);
return (
<EdgeRow key={r.id} tone={attention ? "accent" : "divider"} href={`/my/orders/${r.id}`}>
<div style={{ display: "flex", gap: 12, alignItems: "center" }}>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 16, fontWeight: 800, lineHeight: 1.3 }}>{st.label}</div>
<div style={{ fontSize: 13, color: N600, marginTop: 2, lineHeight: 1.4 }}>{r.summary}</div>
<div style={{ fontSize: 13, color: N600, marginTop: 1, lineHeight: 1.4 }}>
{/* createdAt is a UTC instant; slicing its first ten characters dated every
request raised before 10:00 to the previous day. */}
{[
r.mine ? "" : `for ${r.subjectName}`,
// Only worth saying when the manager didn't approve the lot — otherwise
// the status word above has already said it.
r.decision && r.lineCount > 1 ? r.decision : "",
`${r.code} · raised ${formatInZone(r.createdAt, me.tz)}`,
].filter(Boolean).join(" · ")}
</div>
</div>
<span aria-hidden="true" style={{ fontSize: 18, color: N600, flex: "0 0 auto" }}></span>
</div>
</EdgeRow>
);
})}
</div>
)}
<div style={{ height: 12 }} />
</MBody>
{/* The nav below is what the phone's gesture handle sits on, and it pads itself for it. The
bar is not at the foot of anything, so it says so: otherwise it reserves the home-indicator
band a second time and "New request" floats above an accent gap mid-screen. */}
<div style={{ ...NOT_DOCKED, borderTop: "2px solid " + INK }}>
<MBar label="New request" href="/my/request" />
</div>
<StaffNav />
</>
);
}
+360
View File
@@ -0,0 +1,360 @@
"use client";
/* Asking for a uniform, in three taps.
*
* 01 what you need — the things already on your record first, in the size the record has for them
* 02 the size, each one carrying the word the ward is allowed to see, and how many
* 03 why
*
* The old screen put a picker dialog between the person and all three, which is six or seven taps
* for the commonest ask in the product: another of the top I already wear. The picker is gone; the
* garments they hold are at the top of the list, choosing one fills in their size, and the bar
* names the person it is going to — "Send to" and the approver's own name, not "Submit" —
* because who has it is the single most asked question about a request.
*
* Two things the mockup's three taps do not cover are kept, because the product rests on them.
* One request may carry up to REQUEST_MAX_LINES garments — the manager's per-line decline, the one
* collection code and one bag all depend on it — so *Add another garment* appears once a line is
* complete and the chosen lines are listed above. And the allowance the manager will measure this
* against is on screen while they are still choosing, in one line, rather than arriving inside a
* refusal against a number they were never shown.
*
* No prices, no payment, no basket, and no count of what is on the shelf: a size carries a word.
*/
import { useState } from "react";
import Link from "next/link";
import { MBar, MBody, MChipRow, MError, MONO, MRule, MStepper, MTop, useToast } from "@/components/m";
import {
ACCENT, DraftLineList, INK, N200, N500, N600, N700, NumberedField, OptionList, SecondaryBar,
type DraftLine,
} from "@/components/staffui";
import Sent from "@/components/screens/Sent";
import { useStaff } from "@/lib/staffclient";
import { REQUEST_REASONS, stockLabel, type StockWord } from "@/lib/staffreq";
import { genderLabel } from "@/lib/compute";
type Size = { size: string; si: number; word: StockWord | string; countedOn: string; held: number };
type Item = {
id: string; item: string; type: string; gender: string; sizes: Size[];
recorded: string; recordedSource: "record" | "issued" | ""; held: number;
};
type Allowance = { capped: boolean; label: string; note: string; over: boolean };
/** The word under a size chip. The short lowercase forms are the mockup's; `stockLabel()` is what
* a screen reader is given, so the vocabulary the rest of the app uses is the one announced. */
function chipWord(w: string): string {
return w === "in_stock" ? "in stock" : w === "low" ? "low" : "none";
}
function lineOf(l: { qty: number; item: string; size: string }): string {
return l.qty > 1 ? `${l.qty} × ${l.item}${l.size}` : `${l.item}${l.size}`;
}
export default function RequestScreen({
items, managerName, swap, heldItemIds, preItemId, preSi, preReason, filledFrom,
holding, allowance, notifyWays, maxLines, maxQty,
}: {
items: Item[]; managerName: string; swap: boolean; heldItemIds: string[];
preItemId: string | null; preSi: number | null; preReason: string | null;
/** The code of the request *Same again* copied, for the one line that says so. */
filledFrom: string | null;
holding: { total: number; sets: number }; allowance: Allowance;
notifyWays: { email: boolean; push: boolean };
maxLines: number; maxQty: number;
}) {
const { mutate, busy } = useStaff();
const toast = useToast();
// Swapping a size is a request against something you already hold, so the list is the shorter
// one. Everything else about the screen is identical — a swap is not a different kind of ask.
const base = swap && heldItemIds.length ? items.filter((i) => heldItemIds.includes(i.id)) : items;
/* The things on your record first. That is the whole point of the redesign: the commonest ask is
* another of something you already wear, and it used to be somewhere down an alphabetical list
* of the entire catalogue. Within each half the linen room's own order is kept. */
const list = [...base.filter((i) => i.held > 0), ...base.filter((i) => i.held === 0)];
// Names that more than one garment on this list answers to (see the option label below).
const sharedNames = new Set(list.filter((i, _n, all) => all.some((o) => o.id !== i.id && o.item === i.item)).map((i) => i.item));
const [lines, setLines] = useState<DraftLine[]>([]);
/* A pre-selection only counts if it is on the list this screen is offering. `?swap=1` narrows the
* list to what they hold, and an `?item=` arriving from the shelf or a waitlist alternative can
* fall outside it — leaving a chosen garment nothing could resolve, and steps 02 and 03 never
* drawn. */
const [itemId, setItemId] = useState<string | null>(() => (preItemId && list.some((i) => i.id === preItemId) ? preItemId : null));
const [si, setSi] = useState<number | null>(preSi);
const [qty, setQty] = useState(1);
const [reason, setReason] = useState<string | null>(preReason);
const [err, setErr] = useState("");
const [sent, setSent] = useState<{ id: string; code: string; manager: string; notified: boolean; lines: string[] } | null>(null);
const item = list.find((i) => i.id === itemId) || null;
const size = item && si !== null ? item.sizes.find((s) => s.si === si) || null : null;
const current = item && size ? { itemId: item.id, si: size.si, item: item.item, size: String(size.size), qty } : null;
const all = current ? [...lines, current] : lines;
const garments = all.reduce((n, l) => n + l.qty, 0);
const full = all.length >= maxLines;
const ready = all.length > 0 && !!reason && !!managerName;
/* Which step the bar is still waiting for.
*
* The mockup draws "pick an item" under a disabled bar, which is right on an empty screen and
* wrong a moment later: with a garment and a size already chosen and only the reason outstanding,
* it tells somebody to do the one thing they have just done. So the note names what is actually
* missing, in the same order the screen asks for it. */
const missing = !itemId && all.length === 0
? { short: "pick an item", full: "Pick an item, a size and a reason" }
: itemId && si === null
? { short: "pick a size", full: "Pick a size" }
: !reason
? { short: "pick a reason", full: "Pick a reason" }
: { short: "pick an item", full: "Pick an item, a size and a reason" };
/** Choosing a garment opens on the size the record already has for it — the difference between
* three taps and five. Not when that size is the one that isn't there: pre-selecting a size the
* linen room cannot pick is worse than asking. */
function pickItem(id: string) {
setErr("");
setItemId(id);
setQty(1);
const it = list.find((i) => i.id === id);
const rec = it ? it.sizes.find((s) => String(s.size) === String(it.recorded)) : null;
setSi(rec && rec.word !== "none" ? rec.si : null);
}
/** Put the garment being chosen on the list and start the next one. */
function addAnother() {
if (!current) return;
setLines((cur) => {
// The same garment in the same size twice is one line of two: the server sums duplicates
// before it writes them, so two identical rows would be showing something that cannot save.
const at = cur.findIndex((x) => x.itemId === current.itemId && x.si === current.si);
if (at >= 0) {
const next = [...cur];
next[at] = { ...next[at], qty: Math.min(maxQty, next[at].qty + current.qty) };
return next;
}
return [...cur, { ...current, key: `${current.itemId}:${current.si}:${cur.length}` }];
});
setItemId(null);
setSi(null);
setQty(1);
}
if (sent) {
/* What happens next, answered by what this server can actually do rather than by what is
* usually true: a registered phone, an email that really went out, or neither. */
const who = sent.manager || "your manager";
return (
<Sent
headline={`Sent to ${who}`}
sub={`${sent.lines.join(", ")} · ${sent.code}`}
next={
notifyWays.push
? "You get a notification when it is approved, and again when it is ready."
: sent.notified
? `${who} has been emailed.`
: `It is waiting with ${who}.`
}
actions={[
{ label: "Open the order", href: `/my/orders/${sent.id}` },
{ label: "Back to home", href: "/my" },
]}
bar={{ label: "Back to home", href: "/my" }}
/>
);
}
return (
<>
<MTop title={swap ? "Swap a size" : "Request an item"} back backHref="/my" />
<MRule />
<MBody>
{/* The mockup toasts "Filled in from R-0042". Kept as a line on the screen rather than a
toast: the toast host is mounted, but this screen is server-rendered and arrives with
the garment already chosen, so a 2.2-second toast is gone — or never announced — before
somebody has looked up from the list. A line a screen reader is told about survives. */}
{filledFrom && (
<div role="status" style={{
background: INK, color: "var(--color-bg)", borderLeft: `6px solid ${ACCENT}`,
padding: "12px 14px", fontSize: 14, fontWeight: 800,
}}>Filled in from {filledFrom}</div>
)}
{lines.length > 0 && (
<div style={{ padding: "16px 16px 0" }}>
<DraftLineList
lines={lines}
maxQty={maxQty}
onQty={(k, q) => setLines((cur) => cur.map((l) => (l.key === k ? { ...l, qty: q } : l)))}
onRemove={(k) => setLines((cur) => cur.filter((l) => l.key !== k))}
/>
</div>
)}
<NumberedField n={1} label={swap ? "What youre swapping" : "What you need"} first>
{list.length === 0 ? (
<p style={{ fontSize: 14, color: N600, lineHeight: 1.6, margin: 0 }}>
{swap
? "Nothing on your record to swap. Ask for an item instead."
: "The linen room hasnt listed any garments yet."}
</p>
) : (
<OptionList
value={itemId}
onPick={pickItem}
options={list.map((i) => {
const held = i.sizes.find((s) => s.held > 0);
return {
key: i.id,
/* Two garments can carry the same name in different cuts — a women's and a
unisex scrub top are two rows on the register, and somebody set to "either"
is offered both. Named identically they read as the list repeating itself, so
the cut is added to whichever names are shared, and to nothing else. */
label: sharedNames.has(i.item) ? `${i.item} · ${genderLabel(i.gender)}` : i.item,
meta: i.held > 0 && held
? `You hold ${i.held === 1 ? "one" : i.held} in ${held.size}`
: "Not on your record",
};
})}
/>
)}
</NumberedField>
{item && (
<>
<NumberedField n={2} label="Size">
{/* Every size is shown, including the ones that aren't there: "it isn't there" is
information somebody came for. A none size is a link to the queue for it —
⛔ never a `disabled` button, which takes no tap and no focus, so the one route
that answers their problem would be unreachable by touch and by keyboard alike. */}
<div role="group" aria-label="Size" style={{ display: "flex", flexWrap: "wrap", gap: 8 }}>
{item.sizes.map((s) => {
const on = si === s.si;
const none = s.word === "none";
const st: React.CSSProperties = {
minHeight: 46, minWidth: 50, padding: "0 12px", borderRadius: 0,
border: `2px solid ${none ? "var(--color-divider)" : INK}`,
background: none ? "transparent" : on ? INK : "#fff",
color: none ? N500 : on ? "var(--color-bg)" : INK,
font: "inherit", fontSize: 14, fontWeight: 700, textDecoration: "none",
display: "inline-flex", alignItems: "center", justifyContent: "center", gap: 6,
cursor: "pointer",
};
const inner = (
<>
{s.size}
<small style={{ fontFamily: MONO, fontSize: 11, fontWeight: 500, color: on ? N200 : N600 }}>
{chipWord(String(s.word))}
</small>
</>
);
return none ? (
<Link
key={s.si}
href={`/my/waitlist?item=${encodeURIComponent(item.id)}&si=${s.si}`}
aria-disabled="true"
aria-label={`${s.size}, ${stockLabel("none")} — join the waitlist`}
style={st}
>{inner}</Link>
) : (
<button
key={s.si}
type="button"
onClick={() => { setSi(s.si); setErr(""); }}
aria-pressed={on}
aria-label={`${s.size}, ${stockLabel(s.word as StockWord)}`}
style={st}
>{inner}</button>
);
})}
</div>
{size && (
<div style={{
display: "flex", alignItems: "center", gap: 12, minHeight: 60, marginTop: 8,
borderTop: "1px solid var(--color-divider)", paddingTop: 10,
}}>
<span style={{ flex: 1, minWidth: 0, fontWeight: 700 }}>How many</span>
<MStepper n={qty} onChange={setQty} min={1} max={maxQty} label="garments" />
</div>
)}
</NumberedField>
</>
)}
{/* One reason covers the whole request, so it stays put once anything is on the list.
Gated on the garment being chosen, *Add another garment* took the only control that sets
a reason off the screen — and the bar then asked for one that could no longer be picked. */}
{(item || lines.length > 0) && (
<NumberedField n={3} label="Why">
{/* Chips, as the mockup draws them. Four short words do not need four 58px boxes with a
chevron each: the reason is the last of the three taps, and at option-list size it
pushed the bar that sends the request off the bottom of the screen. */}
<MChipRow
label="Why"
value={reason}
onPick={(r) => { setReason(r); setErr(""); }}
options={REQUEST_REASONS.map((r) => ({ value: r as string, label: r }))}
/>
</NumberedField>
)}
{/* What they hold and what they are measured against, in one line, while they are still
choosing — not inside the decline. Same sum the manager's review screen uses. */}
<div style={{
margin: "0 16px", background: "#fff", padding: "14px 16px",
borderLeft: `6px solid ${allowance.over ? ACCENT : INK}`,
fontSize: 14, lineHeight: 1.5, color: N700,
}}>
{holding.total === 0 ? "Nothing on your record yet" : `${holding.total} garment${holding.total === 1 ? "" : "s"} on your record`}
{" · "}{allowance.label}
</div>
{current && !full && (
<div style={{ padding: "16px 16px 0" }}>
<SecondaryBar label="Add another garment" onClick={addAnother} />
</div>
)}
{full && (
<p style={{ fontSize: 13, color: N600, lineHeight: 1.55, margin: "12px 16px 0" }}>
That is {maxLines} garments as much as one request carries.
</p>
)}
<MError msg={err} onDismiss={() => setErr("")} />
{!managerName && (
<p style={{ fontSize: 13.5, lineHeight: 1.6, color: N700, padding: "16px 16px 0", margin: 0 }}>
Nobody is recorded as your approver yet, so this can&rsquo;t be sent. Ask the linen room
to set your manager on your staff record.
</p>
)}
<div style={{ height: 12 }} />
</MBody>
{/* What the bar is still waiting for. A single "pick an item" was what the mockup drew on an
empty screen, but it stayed there with a garment and a size already chosen and only the
reason outstanding — telling somebody to do the one thing they have just done. */}
<MBar
label={busy ? "Sending…" : managerName ? `Send to ${managerName}` : "Send for approval"}
small={ready ? `${garments} garment${garments === 1 ? "" : "s"}` : managerName ? missing.short : "no approver"}
disabled={!ready || busy}
offReason={managerName ? missing.full : "Nobody is recorded as your approver yet"}
onClick={async () => {
if (!ready) return;
const r = await mutate<{ id: string; code: string; manager: string; notified: boolean }>("request.create", {
lines: all.map((l) => ({ itemId: l.itemId, si: l.si, qty: l.qty })),
reason: reason || "",
// The one thing the swap route has always told the linen room, kept now that the free
// note has gone: this is an exchange, not another garment on top.
note: swap ? "Swapping a size." : "",
});
if (!r.ok) { setErr(r.error); return; }
toast("Sent");
setSent({
id: r.result.id, code: r.result.code, manager: r.result.manager || managerName,
notified: r.result.notified, lines: all.map(lineOf),
});
}}
/>
</>
);
}
+219
View File
@@ -0,0 +1,219 @@
"use client";
/* Reviewing one request: approve it, or knock back the garments that shouldn't go.
*
* One request covers everything the person asked for, so this screen shows the whole ask and
* settles it in one action. A manager can still refuse part of it — the tunic and the trousers
* yes, the fleece no — and each line carries its own reasons, in the list, where the garment is.
* Everything not knocked back is approved when the bar is pressed, and the bar says how many that
* is, because "Approve" over a list of three with one struck out has to be unambiguous.
*
* The decline reason is compulsory and comes from a fixed list of three, and it is always shown to
* the staff member — per garment now, rather than for the request as a whole. That is the point:
* the thing this replaces is a request that goes quiet, and a refusal nobody can explain is the
* same failure with an extra step.
*
* A manager can be the person the request is for. Two ward managers commonly name each other as
* approver — that is how the top of the tree gets one at all — and the server lets the wearer
* settle their own. The queue sets those apart; so does the line above the bar here, because
* having been told on a list you scrolled past is not the same as being told as you sign.
*
* Not inside the Team shell: this is a detail screen with a back chevron, like the order and the
* thread. A tab row on a decision screen invites somebody off it mid-decision.
*/
import { useState } from "react";
import { useRouter } from "next/navigation";
import { MBar, MBody, MError, MRule, MTop } from "@/components/m";
import { ACCENT_700, IdentityBlock, INK, Kicker, LineList, N600, N700, lineText } from "@/components/staffui";
import { Band, ChipAction } from "./Team";
import { useStaff } from "@/lib/staffclient";
import { DECLINE_REASONS } from "@/lib/staffreq";
import type { ReviewLine } from "@/lib/managerdata";
type Data = {
id: string; code: string; status: string;
subject: { id: string; name: string; num: string; group: string; ward: string; held: number; sets: number; approvedThisYear: number };
lines: ReviewLine[]; summary: string; garments: number; lineCount: number; decision: string | null;
reason: string; note: string; raisedByName: string;
allowance: { capped: boolean; label: string; note: string; over: boolean };
};
/** What the manager has pencilled against each line before they press the bar. */
type Call = { decision: "approved" | "declined"; reason: string };
/** The shelf word, in a box beside the garment. Words, never a count. */
function StockBox({ word, struck }: { word: string; struck: boolean }) {
const none = word.startsWith("none");
return (
<span style={{
flex: "0 0 auto", fontSize: 12, fontWeight: 800, letterSpacing: "0.05em", textTransform: "uppercase",
background: none ? "var(--color-accent)" : "var(--color-divider)", color: none ? "#fff" : INK,
padding: "5px 8px", whiteSpace: "nowrap", textDecoration: struck ? "line-through" : "none",
}}>{word}</span>
);
}
export default function ReviewScreen({ data }: { data: Data }) {
const router = useRouter();
const { me, mutate, busy } = useStaff();
/* Everything starts approved. That is not a default in the lazy sense — it is what the button at
* the bottom will do, spelled out on every line before it is pressed, so the manager is choosing
* what to refuse rather than ticking off what to allow. */
const [calls, setCalls] = useState<Record<string, Call>>(
() => Object.fromEntries(data.lines.map((l) => [l.id, { decision: "approved" as const, reason: "" }])),
);
const [err, setErr] = useState("");
const decided = data.status !== "awaiting";
/* Whether the person deciding this is the person it is for. Matched on the id, which the register
* keeps unique; a name would start calling a stranger's request yours the day two people on the
* ward share one, and what is being marked here is an audit fact. */
const mine = data.subject.id === me.staffId;
const yes = data.lines.filter((l) => calls[l.id]?.decision === "approved").length;
const total = data.lines.length;
function decline(lineId: string, reason: string) {
setErr("");
setCalls((c) => ({ ...c, [lineId]: { decision: "declined", reason } }));
}
async function send() {
const lines = data.lines.map((l) => ({
id: l.id,
decision: calls[l.id]?.decision ?? "approved",
reason: calls[l.id]?.reason ?? "",
}));
const every = lines.every((l) => l.decision === "declined");
// The op name matches the outcome the manager can see on the button; `lines` is what actually
// decides, garment by garment, and it has to name every one of them exactly once.
const r = await mutate(every ? "request.decline" : "request.approve", {
id: data.id,
lines,
// When the whole request went, and every line went for the same reason, that reason is the
// request's reason too — it is what the wearer's order and the decision email lead with.
reason: every && new Set(lines.map((l) => l.reason)).size === 1 ? lines[0].reason : undefined,
});
if (!r.ok) { setErr(r.error); return; }
router.push("/my/approvals");
}
const barLabel = busy ? "Working…"
: yes === 0 ? (total === 1 ? "Decline" : `Decline all ${total}`)
: yes === total ? (total === 1 ? "Approve" : `Approve all ${total}`)
: `Approve ${yes} of ${total}`;
return (
<>
{/* A real destination, not history.back(): this screen opens cold from the approval email. */}
<MTop title={`Review ${data.code}`} back onBack={() => router.push("/my/approvals")} />
<MRule />
<MBody>
<IdentityBlock
ward={data.subject.ward}
num={data.subject.num}
name={data.subject.name}
group={data.subject.group}
/>
{/* No count at the right. Every garment is listed under this band with its own quantity,
and a total beside the heading only invites the manager to decide against the number
rather than against the list. */}
<Band label="Asking for" />
{decided ? (
<>
<div style={{ margin: "12px 16px 0" }}><LineList lines={data.lines} /></div>
<p style={{ fontSize: 14, lineHeight: 1.6, color: N700, padding: "12px 16px 0", margin: 0 }}>
{data.decision ? `${data.decision}. ` : ""}Nothing here is waiting on you.
</p>
</>
) : (
<div style={{ display: "grid", gap: 2, margin: "12px 0 0" }}>
{data.lines.map((l) => {
const call = calls[l.id] ?? { decision: "approved" as const, reason: "" };
const off = call.decision === "declined";
return (
<div key={l.id} style={{ background: "#fff", padding: "14px 16px" }}>
<div style={{ display: "flex", gap: 12, alignItems: "center" }}>
<div style={{
flex: 1, minWidth: 0, fontSize: 16, fontWeight: 800, lineHeight: 1.3,
textDecoration: off ? "line-through" : "none", color: off ? N600 : INK,
}}>{lineText(l)}</div>
<StockBox word={l.stock} struck={off} />
</div>
{off ? (
<div style={{ display: "flex", gap: 10, alignItems: "center", marginTop: 10 }}>
<span style={{
flex: 1, minWidth: 0, fontSize: 13, fontWeight: 800, color: ACCENT_700,
textDecoration: "line-through",
}}>{call.reason}</span>
<button
type="button"
onClick={() => setCalls((c) => ({ ...c, [l.id]: { decision: "approved", reason: "" } }))}
style={{
minHeight: 44, padding: "0 14px", border: `2px solid ${INK}`, borderRadius: 0,
background: "#fff", color: ACCENT_700, font: "inherit", fontSize: 14, fontWeight: 800,
cursor: "pointer", flex: "0 0 auto",
}}
>Undo</button>
</div>
) : (
/* The reasons are the control. A Decline button that opens them was a tap that
told nobody anything, and the three of them fit on the line they belong to. */
<div role="group" aria-label={`Decline ${lineText(l)}`} style={{ display: "flex", gap: 6, marginTop: 10 }}>
{DECLINE_REASONS.map((r) => (
<ChipAction key={r} small label={r} onClick={() => decline(l.id, r)} />
))}
</div>
)}
</div>
);
})}
</div>
)}
{(data.reason || data.note || data.raisedByName) && (
<div style={{ margin: "12px 16px 0", background: "#fff", border: `2px solid ${INK}`, padding: "12px 14px" }}>
<Kicker>Why they asked</Kicker>
<div style={{ fontSize: 15, lineHeight: 1.5, marginTop: 6 }}>
{[data.reason, data.note].filter(Boolean).join(" — ")}
</div>
{data.raisedByName && (
<div style={{ fontSize: 13, color: N600, marginTop: 8 }}>Raised for them by {data.raisedByName}.</div>
)}
</div>
)}
<div style={{ margin: "12px 16px 0", background: "#fff", borderLeft: `6px solid ${data.allowance.over ? "var(--color-accent)" : INK}`, padding: "14px 16px" }}>
<Kicker tone={data.allowance.over ? "attention" : "quiet"}>Allowance</Kicker>
<div style={{ fontSize: 15, lineHeight: 1.5, marginTop: 6 }}>{data.allowance.label}</div>
<p style={{ fontSize: 13, lineHeight: 1.55, color: N600, margin: "6px 0 0" }}>{data.allowance.note}</p>
</div>
{mine && !decided && (
/* Last thing above the bar, because the bar is what does it. The reader finds out here
rather than from an auditor months later. */
<p style={{ fontSize: 13, lineHeight: 1.6, color: N700, padding: "12px 16px 0", margin: 0 }}>
Approving it is recorded as your own approval.
</p>
)}
<MError msg={err} onDismiss={() => setErr("")} />
<div style={{ height: 12 }} />
</MBody>
{!decided && (
/* `small`, not `sub`: the count of what is about to be refused is mono at the right of the
bar, reading against the outcome rather than as a subtitle underneath it. */
<MBar
label={barLabel}
small={yes < total ? `${total - yes} declined` : undefined}
glyph={yes > 0 ? "check" : "arrow"}
tone={yes > 0 ? "accent" : "ink"}
disabled={busy}
onClick={send}
/>
)}
</>
);
}
+222
View File
@@ -0,0 +1,222 @@
"use client";
/* Team ▸ Round — what the desk sees when the trolley arrives.
*
* Unclaimed bags from previous rounds sit **above** today's work. They are the linen room's
* biggest waste — a bag signed for on the ward and never collected is a garment out of stock and
* off the record — so the screen refuses to bury them under whatever arrived this morning.
*
* Anyone on the ward can sign, and whoever does is named on the requester's order. That is the
* whole audit story: a missing bag has a name against it.
*
* Signing for the lot now reports itself. It used to loop silently: on a ward where the fourth bag
* was refused, the clerk saw an unchanged list and no idea which three had gone through, so the
* honest thing — a count that climbs, and the name of whatever stopped it — is on the screen.
*/
import { useState } from "react";
import { MEmpty, MError } from "@/components/m";
import {
ACCENT_300, DoneRow, EdgeRow, GROUND, INK, Kicker, N300, N600, Progress, SecondaryBar, lineText,
} from "@/components/staffui";
import Team, { Band, ChipAction, ChipRow } from "./Team";
import { useStaff } from "@/lib/staffclient";
import { statusText } from "@/lib/staffreq";
import type { ReqLine, ReqRow } from "@/lib/staffdata";
import { formatInZone } from "@/lib/compute";
type Bag = {
id: string; code: string; subjectName: string;
/** What is actually in the bag: the approved lines, and nothing the manager knocked back. */
lines: ReqLine[]; summary: string; garments: number; lineCount: number;
status: string; signerName: string | null; signedAt: string | null; claimedAt: string | null;
since: string;
};
export default function RoundScreen({ ward, toSign, unclaimed, signedToday, raised }: {
ward: string; toSign: Bag[]; unclaimed: Bag[]; signedToday: Bag[];
/** Open requests this person raised for someone else, however they came to raise them. */
raised: ReqRow[];
}) {
const { mutate, busy, me } = useStaff();
/* `signedAt` arrives as a UTC instant, and both of the places it is shown used to read it as
* local text. The ward round happens in the morning, which is exactly when the UTC date is still
* yesterday's, so the desk was routinely told a bag it signed for an hour ago went out the day
* before. The facility's zone answers both. */
const tz = me.tz;
const [err, setErr] = useState("");
const [prog, setProg] = useState<{ done: number; total: number } | null>(null);
async function sign(id: string) {
setErr("");
const r = await mutate("round.sign", { id });
if (!r.ok) setErr(r.error);
}
async function claim(id: string) {
setErr("");
const r = await mutate("round.claim", { id });
if (!r.ok) setErr(r.error);
}
/* One call per bag, because that is what signing is — a signature against one hand-over — and the
* count climbs after each. It stops at the first refusal and says which bag it stopped on: the
* ones already signed are done and must not be sent again (round.sign is not idempotent), and
* the rest are left exactly as they were for the clerk to deal with. */
async function signAll() {
const list = toSign;
if (!list.length) return;
setErr("");
setProg({ done: 0, total: list.length });
let done = 0;
for (const b of list) {
const r = await mutate("round.sign", { id: b.id });
if (!r.ok) {
setErr(`Signed ${done} of ${list.length}${b.subjectName}: ${r.error}`);
setProg(null);
return;
}
done += 1;
setProg({ done, total: list.length });
}
setProg(null);
}
const nothingAtAll = toSign.length === 0 && signedToday.length === 0 && unclaimed.length === 0;
return (
<Team
active="/my/round"
foot={toSign.length > 1 ? (
<div style={{ borderTop: "2px solid " + INK }}>
{/* Bulk signing is allowed but not encouraged — a secondary action, never the red one. */}
<SecondaryBar
label={prog ? "Signing…" : `Sign for all ${toSign.length} remaining`}
disabled={busy || !!prog}
onClick={() => void signAll()}
/>
</div>
) : undefined}
>
<div style={{ padding: "4px 16px 0" }}><Kicker>Ward round</Kicker></div>
<div style={{ background: INK, color: GROUND, padding: 18, margin: "8px 0 0", display: "flex", alignItems: "center", gap: 16 }}>
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 34, letterSpacing: "-0.03em", lineHeight: 1 }}>
{toSign.length}
</div>
<div style={{ flex: 1, fontSize: 14, lineHeight: 1.4, color: N300 }}>
<div>{toSign.length === 1 ? "bag to sign" : "bags to sign"}</div>
<div>{toSign.length === 0 ? "nothing waiting" : "arriving today"}</div>
</div>
{signedToday.length > 0 && (
<div style={{ fontSize: 11, fontWeight: 800, letterSpacing: "0.12em", textTransform: "uppercase", color: ACCENT_300 }}>
{signedToday.length} signed
</div>
)}
</div>
{prog && <Progress label="Signing" done={prog.done} total={prog.total} unit="signed" />}
<MError msg={err} onDismiss={() => setErr("")} />
{unclaimed.length > 0 && (
<>
<Band tone="attention" label="Unclaimed from earlier rounds" />
<div style={{ display: "grid", gap: 2, padding: "12px 0" }}>
{unclaimed.map((b) => (
<EdgeRow key={b.id} tone="accent">
<div style={{ fontSize: 16, fontWeight: 800, lineHeight: 1.3 }}>{b.subjectName}</div>
<div style={{ fontSize: 13, color: N600, marginTop: 4, lineHeight: 1.4 }}>
{[b.lines.map(lineText).join(", "), b.signedAt ? `since ${formatInZone(b.signedAt, tz, { weekday: "long" })}` : ""].filter(Boolean).join(" · ")}
</div>
<ChipRow>
{/* Nudging only works if the requester eventually opens the app; often the bag
went days ago and the person who knows that is the clerk standing where it
used to be, which is what Collected is for. */}
<ChipAction label="Nudge" href={`/my/orders/${b.id}/messages`} />
<ChipAction label="Collected" disabled={busy} onClick={() => void claim(b.id)} />
</ChipRow>
</EdgeRow>
))}
</div>
</>
)}
{/* The count stays on the band when it reaches nought: a heading that drops its note the
moment the work is done reads as a screen that has lost its place. */}
<Band label="Arriving today" right={`${toSign.length} to sign`} />
{nothingAtAll ? (
<div style={{ padding: "0 16px" }}>
<MEmpty title={`Nothing on the round for ${ward || "your ward"} right now.`} />
</div>
) : (
<>
{toSign.map((b) => (
<EdgeRow key={b.id} tone="ink">
<div style={{ fontSize: 16, fontWeight: 800, lineHeight: 1.3 }}>{b.subjectName}</div>
{/* Signing is a signature: whoever puts their name to a bag of four garments should be
able to read the four before they do, rather than a count of them and a code. That
is why the garments themselves are the row's second line. Nothing declined is
listed — a knocked-back garment never reaches the trolley. */}
<div style={{ fontSize: 13, color: N600, marginTop: 4, lineHeight: 1.4 }}>
{b.lines.map(lineText).join(", ")}
</div>
<ChipRow>
<ChipAction label="Sign" disabled={busy || !!prog} onClick={() => void sign(b.id)} />
</ChipRow>
</EdgeRow>
))}
{signedToday.map((b) => (
<DoneRow key={b.id}>
<div style={{ display: "flex", gap: 12, alignItems: "center" }}>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 16, fontWeight: 800, lineHeight: 1.3 }}>{b.subjectName}</div>
<div style={{ fontSize: 13, marginTop: 3 }}>
Signed {b.signedAt ? formatInZone(b.signedAt, tz, { hour: "2-digit", minute: "2-digit", hour12: false }) : ""} by {b.signerName}
</div>
</div>
<span style={{
fontSize: 12, fontWeight: 800, letterSpacing: "0.05em", textTransform: "uppercase",
background: "var(--color-divider)", color: INK, padding: "5px 8px", whiteSpace: "nowrap",
}}>Done</span>
</div>
</DoneRow>
))}
{/* Under the signed rows rather than instead of them: the desk wants this morning's work
still on the screen AND to be told it is finished. Drawn whenever nothing is left to
sign, which is what the mockup does once the last bag has a name against it. */}
{toSign.length === 0 && (
<div style={{ padding: "0 16px" }}>
<MEmpty title="Every bag is signed" sub="Whoever signs appears on the requesters order." />
</div>
)}
</>
)}
{/* What this person raised in somebody else's name, still on its way. The bags above are only
the ones arriving today; a request typed in on Tuesday and approved on Thursday is invisible
there until it turns up on a trolley, so without this the only way to find out where it had
got to was to ring the linen room. */}
{raised.length > 0 && (
<>
<Band label="Raised by you · still open" />
<div style={{ display: "grid", gap: 2, padding: "12px 0" }}>
{raised.map((r) => {
const st = statusText(r, { mine: false, first: r.subjectName?.split(" ")[0] });
return (
<EdgeRow key={r.id} tone="divider" href={`/my/orders/${r.id}`}>
<div style={{ display: "flex", gap: 12, alignItems: "baseline" }}>
<span style={{ flex: 1, fontSize: 16, fontWeight: 800 }}>{r.subjectName}</span>
<span style={{ fontSize: 12, color: N600 }}>{r.code}</span>
</div>
<div style={{ fontSize: 14.5, fontWeight: 600, marginTop: 5, lineHeight: 1.35 }}>{r.summary}</div>
<div style={{ fontSize: 13, color: N600, marginTop: 4 }}>
{[st.label, st.note].filter(Boolean).join(" · ")}
</div>
</EdgeRow>
);
})}
</div>
</>
)}
<div style={{ height: 12 }} />
</Team>
);
}
+81
View File
@@ -0,0 +1,81 @@
"use client";
/* The screen you land on when something has gone to your approver.
*
* It exists because the old flow jumped straight to the order, which opens on "Awaiting approval"
* and nothing else: the one question somebody has the second after tapping Send — has anyone
* actually been told? — was answered by a status word that says only that nobody has decided yet.
*
* So the sentence under "What happens next" is computed from what this server can really do
* (`notifyWays()` — a registered phone, a working mail server, or neither), not from what is
* usually true elsewhere. A screen that promises a notification nobody configured is how a request
* sits for three weeks.
*
* No back chevron. Behind this screen is a half-filled form for a request that has already been
* raised, and the one thing worse than losing a draft is sending the same ask twice.
*/
import Link from "next/link";
import { MBar, MBody, MRule, MTop } from "@/components/m";
import { INK, N600 } from "@/components/staffui";
export type SentAction = { label: string; href: string };
export default function Sent({ title = "Sent", headline, sub, next, actions, bar }: {
/** App-bar title. */
title?: string;
/** The 26/900 line: "Sent to" and whoever the approver is. */
headline: string;
/** What was asked for, and the code it was given. */
sub?: string;
/** One line under "What happens next" — never a paragraph. */
next?: string;
/** The outlined buttons, in order. */
actions?: SentAction[];
/** The 64px bar at the foot. */
bar: SentAction;
}) {
return (
<>
<MTop title={title} />
<MRule />
<MBody>
<div style={{ padding: "26px 16px 0" }}>
<h2 style={{
fontFamily: "var(--font-heading)", fontWeight: 900, fontSize: 26, letterSpacing: "-0.015em",
lineHeight: 1.1, margin: 0,
}}>{headline}</h2>
{sub && <div style={{ fontSize: 14, color: N600, marginTop: 6, lineHeight: 1.5 }}>{sub}</div>}
{next && (
/* The mockup's `.notice`: a full 2px ink border rather than staffui's accent edge,
which is the linen room's voice and this is not from them. */
<div style={{ border: `2px solid ${INK}`, background: "#fff", padding: "12px 14px", marginTop: 18 }}>
<div style={{ fontSize: 11, fontWeight: 800, letterSpacing: "0.09em", textTransform: "uppercase", color: N600 }}>
What happens next
</div>
<div style={{ fontSize: 14, lineHeight: 1.5, marginTop: 6 }}>{next}</div>
</div>
)}
{actions && actions.length > 0 && (
<div style={{ display: "grid", gap: 10, marginTop: 14 }}>
{actions.map((a) => (
<Link
key={a.href + a.label}
href={a.href}
className="tcx-bar"
style={{
minHeight: 52, width: "100%", border: `2px solid ${INK}`, background: "transparent",
color: INK, textDecoration: "none", font: "inherit", fontFamily: "var(--font-heading)",
fontWeight: 800, fontSize: 14, letterSpacing: "0.05em", textTransform: "uppercase",
display: "flex", alignItems: "center", justifyContent: "center", padding: "0 14px",
}}
>{a.label}</Link>
))}
</div>
)}
</div>
</MBody>
<MBar label={bar.label} href={bar.href} />
</>
);
}
+101
View File
@@ -0,0 +1,101 @@
"use client";
/* 1H — Shelf check. Kill the "have you got any size 12s?" message before it is sent.
*
* Words, never counts. Wards see In stock / Low / None on shelf; the number stays in the linen
* room. That is partly the product rule and partly honesty — the word here is as fresh as the last
* stocktake of that garment, which is what the one closing line says and why no row implies a live
* figure.
*
* Every row is a way on. A size that is not there leads to the waitlist; a size that is leads to
* the request screen with the garment and size already chosen. A row that did neither left somebody
* who had just found their size with nothing to tap.
*/
import Link from "next/link";
import { useMemo, useState } from "react";
import { MBody, MRule, MSection, MTop, inputStyle } from "@/components/m";
import { DIVIDER, INK, Kicker, N600, N700, StockTag } from "@/components/staffui";
import { fmtDate } from "@/lib/compute";
type Size = { size: string; si: number; word: string; countedOn: string };
type Item = { id: string; item: string; type: string; gender: string; sizes: Size[]; recorded: string };
export default function ShelfScreen({ items }: { items: Item[] }) {
const [q, setQ] = useState("");
const shown = useMemo(() => {
const needle = q.trim().toLowerCase();
if (!needle) return items;
return items.filter((i) =>
i.item.toLowerCase().includes(needle) ||
i.type.toLowerCase().includes(needle) ||
i.sizes.some((s) => String(s.size).toLowerCase() === needle));
}, [items, q]);
// The most recent stocktake anywhere in what is on screen: one date, in one line, at the foot.
const counted = shown.flatMap((i) => i.sizes).map((s) => s.countedOn).filter(Boolean).sort().reverse()[0] || "";
return (
<>
{/* A detail screen: a chevron, and no tab bar. The bar belongs to the five tab roots, and on
this screen it marked none of them as current — five unselected tabs and no aria-current
tell a screen reader the app is on no tab at all, and eat 66px of a shelf list. */}
<MTop title="On the shelf" back backHref="/my" />
<MRule />
<MBody>
<div style={{ padding: "16px 16px 0" }}>
<Kicker>Wards see words, not counts</Kicker>
{/* Named, not just placeheld: the placeholder is gone as soon as anyone types, and a
box that then announces itself as "edit, blank" is a box nobody can come back to. */}
<input
value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search garments or a size"
aria-label="Search garments or a size"
autoComplete="off" style={{ ...inputStyle, width: "100%", marginTop: 12 }}
/>
</div>
<div style={{ padding: "0 16px" }}>
{shown.length === 0 && (
<div style={{ padding: "26px 0", fontSize: 14, color: N600, lineHeight: 1.6 }}>
Nothing matches {q}.
</div>
)}
{shown.map((it) => (
<div key={it.id}>
<MSection label={`${it.item}${it.gender && it.gender !== "Unisex" ? ` · ${it.gender}` : ""}`} />
{it.sizes.map((s) => {
const mine = !!it.recorded && String(it.recorded) === String(s.size);
const none = s.word === "none";
const href = none
? `/my/waitlist?item=${encodeURIComponent(it.id)}&si=${s.si}`
: `/my/request?item=${encodeURIComponent(it.id)}&si=${s.si}`;
return (
<Link
key={s.si}
href={href}
className="tcx-bar"
aria-label={`${it.item}, size ${s.size}${mine ? ", your size" : ""}. ${none ? "None on shelf — join the waitlist" : "Ask for one"}`}
style={{
display: "flex", alignItems: "center", gap: 12, minHeight: 60, padding: "9px 0",
width: "100%", borderBottom: `1px solid ${DIVIDER}`, color: INK,
textDecoration: "none", font: "inherit", background: "none",
}}
>
<span style={{ flex: 1, minWidth: 0, fontSize: 15, fontWeight: 700 }}>
{s.size}{mine ? " · your size" : ""}
</span>
<StockTag word={s.word} />
</Link>
);
})}
</div>
))}
<p style={{ fontSize: 13, lineHeight: 1.6, color: N700, padding: "16px 0 0", margin: 0 }}>
Availability comes from the last linen-room stocktake{counted ? ` — most recently ${fmtDate(counted)}` : ""}.
</p>
</div>
<div style={{ height: 12 }} />
</MBody>
</>
);
}
+109
View File
@@ -0,0 +1,109 @@
"use client";
/* The Team shell: one app bar, one tab row, one nav, four screens inside it.
*
* Approvals, Round, Ward and Raise are four views of the same job, and before this they were four
* separate screens with a back chevron and a two-item nav of their own — a manager with an empty
* queue could not reach any of them without typing the address. The tabs are real links to the
* routes that already existed, so the back button, a screen reader and the emailed approval link
* all keep working.
*
* Which tabs a reader gets comes from teamTabs() in lib/staffreq.ts, off the same two counts the
* tab badge is built from. That is the point of it living there: a tab drawn from one fact and a
* route fenced on another is a tab that 404s, which is exactly what this shell is here to stop.
*
* Review is deliberately NOT in here. It is a detail screen — it gets a back chevron, like the
* order, the thread and the request — because a decision screen with a tab row invites somebody to
* wander off it mid-decision.
*/
import Link from "next/link";
import { MBody, MONO, MRule, MTop } from "@/components/m";
import { INK, Kicker, SegmentLinks } from "@/components/staffui";
import StaffNav from "@/components/staffnav";
import { useStaff } from "@/lib/staffclient";
import { teamTabs, teamTitle } from "@/lib/staffreq";
/** A section band: the heading a list hangs off, with an optional mono note at the right.
*
* The four screens in this shell each had their own copy of this div. One of them is enough, and
* it is exported because the bands are the shell's own furniture as much as the tab row is. */
export function Band({ label, right, tone = "quiet" }: {
label: string; right?: React.ReactNode; tone?: "quiet" | "attention";
}) {
return (
<div style={{
display: "flex", alignItems: "baseline", gap: 10,
padding: "18px 16px 8px", borderBottom: "2px solid " + INK, background: "var(--color-bg)",
}}>
<span style={{ flex: 1, minWidth: 0 }}><Kicker tone={tone}>{label}</Kicker></span>
{right !== undefined && right !== null && (
<span style={{ fontFamily: MONO, fontSize: 13, fontWeight: 500, color: "var(--color-neutral-600)", whiteSpace: "nowrap" }}>
{right}
</span>
)}
</div>
);
}
/** The decision buttons that sit inside a row — Approve 3 / Open, Nudge / Collected, Sign.
*
* Not CompactAction: these share the width of the row between them (the mockup's `.decide .chip`,
* flex:1), where CompactAction is a fixed-width control sitting beside a block of text. Same 2px
* ink border, same uppercase label, 46px rather than the 44px floor. The small variant is the
* decline reasons — three across a phone, directly under the garment line they decline — and it
* sat at 42 until somebody measured it. Nothing anyone taps in this app goes under 44. */
export function ChipAction({ label, onClick, href, disabled, small }: {
label: string; onClick?: () => void; href?: string; disabled?: boolean;
/** Three across rather than two — the decline reasons, which are phrases rather than words. */
small?: boolean;
}) {
const st: React.CSSProperties = {
flex: 1, minWidth: 0, minHeight: small ? 44 : 46, padding: "0 10px", border: `2px solid ${INK}`, borderRadius: 0,
background: "#fff", color: INK, font: "inherit", fontSize: small ? 13 : 15, fontWeight: 800,
lineHeight: 1.25,
display: "flex", alignItems: "center", justifyContent: "center", textAlign: "center",
textDecoration: "none", cursor: disabled ? "not-allowed" : "pointer", opacity: disabled ? 0.45 : 1,
};
// A Link, not an anchor: a full page load on "Open" or "Nudge" throws away the list the reader
// is standing in and re-fetches the whole screen on ward wifi.
if (href && !disabled) return <Link href={href} style={st}>{label}</Link>;
return <button type="button" onClick={onClick} disabled={disabled} style={st}>{label}</button>;
}
/** The row of decision buttons. */
export function ChipRow({ children }: { children: React.ReactNode }) {
return <div style={{ display: "flex", gap: 8, marginTop: 10 }}>{children}</div>;
}
export default function Team({ active, children, foot }: {
/** The href of the tab being shown, so the segment row can mark it. */
active: string;
children: React.ReactNode;
/** Anything that belongs below the scrolling body and above the nav — Round's "sign for all",
* Raise's send bar. Bars cannot live inside MBody or they scroll away with the list. */
foot?: React.ReactNode;
}) {
const { me, counts } = useStaff();
const tabs = teamTabs(me, counts);
const single = tabs.length === 1 ? tabs[0] : null;
return (
<>
<MTop title={teamTitle(me, counts)} />
<MRule />
<MBody>
{/* One tab is not a choice, and a single inverted button that does nothing when pressed is
a control that lies. A clerk who only signs for the trolley gets the band instead — and
no tabs at all draws nothing, rather than an empty <nav>: an approver who has just
cleared the last request in their queue still stands on this screen. */}
{tabs.length === 0
? null
: single
? <Band label={single.label} right={single.count ? String(single.count) : undefined} />
: <SegmentLinks label="Team" options={tabs} active={active} />}
{children}
</MBody>
{foot}
<StaffNav />
</>
);
}
+135
View File
@@ -0,0 +1,135 @@
"use client";
/* 1F — Order messages. Ask about *this* order.
*
* One thread per order, and no general inbox. That is the rule that keeps this from becoming a
* chat app nobody staffs: every message arrives attached to the thing it is about, so whoever
* picks it up in the linen room already knows what is being asked.
*/
import { useEffect, useRef, useState } from "react";
import { Bubble, Composer, ContextStrip, DateSeparator, N600, N700 } from "@/components/staffui";
import { MBody, MError, MRule, MTop } from "@/components/m";
import { useDraft, useStaff } from "@/lib/staffclient";
import { statusText } from "@/lib/staffreq";
import { addDays, facilityDate, facilityToday, formatInZone } from "@/lib/compute";
type Msg = { id: string; fromStaff: boolean; authorName: string; body: string; at: string };
type Data = {
id: string; code: string; status: string; summary: string;
managerName: string; declineReason: string | null; holdUntil: string; ward: string;
signerName: string | null; signerRole: string | null;
messages: Msg[];
/** The linen room has said something on this thread that nobody here has opened. */
unreadRoom?: boolean;
};
/* Both of these run twice — once on the server rendering this screen, once in the browser hydrating
* it — so neither may read the ambient zone. `toDateString()` did exactly that: on a UTC host a
* message sent at 08:00 Brisbane was separated under "Yesterday" and stamped 22:30, then flipped to
* "Today" and 08:30 when React took over. Comparing calendar dates in the facility's zone gives the
* same answer in both places, and it is the ward's answer. */
const dayLabel = (iso: string, tz: string) => {
const day = facilityDate(iso, tz);
if (!day) return "";
const today = facilityToday(tz);
if (day === today) return "Today";
if (day === addDays(today, -1)) return "Yesterday";
return formatInZone(iso, tz, { day: "numeric", month: "long" });
};
const timeLabel = (iso: string, tz: string) => formatInZone(iso, tz, { hour: "2-digit", minute: "2-digit", hour12: false });
export default function ThreadScreen({ data }: { data: Data }) {
const { mutate, busy, me } = useStaff();
/* The box keeps what was typed, per order, through a dropped send and a walk to another screen.
*
* Ward wifi drops mid-sentence, and the one thing worse than a message that did not send is a
* message that did not send and is gone. sessionStorage, so it is the tab's unfinished business
* and not a record. */
const draft = useDraft(`msg.${data.id}`);
const [err, setErr] = useState("");
const [sent, setSent] = useState<Msg[]>([]);
const endRef = useRef<HTMLDivElement>(null);
/* Opening the thread is what marks it read — once, and only when there is something to mark.
*
* `mutate` refreshes this screen, so the flag comes back false and the effect does not fire
* again. A screen that called this on every mount would refresh the page every time anybody
* glanced at a thread. */
const marked = useRef(false);
useEffect(() => {
if (!data.unreadRoom || marked.current) return;
marked.current = true;
void mutate("request.read", { id: data.id });
}, [data.unreadRoom, data.id, mutate]);
/* Anything the server has already told us about wins: `mutate` refreshes this screen, so a message
* we optimistically appended comes back in `data.messages` under the same id a moment later. Without
* this the nurse sees her own question twice, once from each list, for as long as she stays on the
* thread — and the clerk reading the same order sees the doubled conversation too. */
const confirmed = new Set(data.messages.map((m) => m.id));
const all = [...data.messages, ...sent.filter((m) => !confirmed.has(m.id))];
useEffect(() => { endRef.current?.scrollIntoView({ block: "end" }); }, [all.length]);
const st = statusText(data);
let lastDay = "";
return (
<>
<MTop title={`Ask about ${data.code}`} back backHref={`/my/orders/${data.id}`} />
<MRule />
<ContextStrip>
{/* Which order this thread belongs to: the code and the status word, then the summary
rather than the lines — a request for four garments would push the first message off
the screen, and whoever needs the detail is one tap away on the order itself. */}
<span style={{ display: "block", fontSize: 11, fontWeight: 800, letterSpacing: "0.09em", textTransform: "uppercase", color: N700 }}>
{data.code} · {st.label}
</span>
<span style={{ display: "block", fontSize: 14, color: "var(--color-text)", marginTop: 3 }}>
{data.summary}
</span>
</ContextStrip>
<MBody>
{all.length === 0 && (
<div style={{ padding: "26px 16px", fontSize: 14, color: N600, lineHeight: 1.6 }}>
Nothing yet. Ask anything about this order.
</div>
)}
{all.map((m) => {
const day = dayLabel(m.at, me.tz);
const sep = day !== lastDay;
lastDay = day;
return (
<div key={m.id}>
{sep && <DateSeparator>{day}</DateSeparator>}
<Bubble
mine={m.fromStaff && m.authorName === me.name}
author={m.fromStaff ? m.authorName : "Linen room"}
body={m.body}
stamp={timeLabel(m.at, me.tz)}
/>
</div>
);
})}
<MError msg={err} onDismiss={() => setErr("")} />
<div ref={endRef} style={{ height: 8 }} />
</MBody>
<Composer
value={draft.value}
onChange={(v) => { draft.set(v); setErr(""); }}
busy={busy}
placeholder="Ask about this order"
onSend={async () => {
const text = draft.value.trim();
if (!text) return;
// Cleared optimistically so the box is empty the moment it is sent, and put back below if
// the send never left the building — which on ward wifi is the whole reason for the draft.
draft.clear();
const r = await mutate<{ id: string; at: string }>("request.message", { id: data.id, body: text });
if (!r.ok) { setErr(r.error); draft.set(text); return; }
// Shown immediately with the server's own stamp, so the thread doesn't jump when the
// page refreshes underneath it.
setSent((s) => [...s, { id: r.result.id, fromStaff: true, authorName: me.name, body: text, at: r.result.at }]);
}}
/>
</>
);
}
+189
View File
@@ -0,0 +1,189 @@
"use client";
/* 2B — Waitlist. What "none on the shelf" leads to instead of a dead end.
*
* Position is shown **before** joining, because "you are fourth" and "you are fortieth" are
* different decisions and only one of them is worth waiting for. The nearest stocked sizes sit
* right underneath for the same reason: most people would rather have something that fits
* approximately today than exactly in three weeks, and the screen should let them say so.
*
* Joining needs no approval — a queue is not a request. Approval happens if and when the item
* lands and they accept it.
*
* Joining and leaving redraw this screen in place (router.refresh) rather than reloading it: the
* whole point of the bar is that it answers immediately, and a full reload on ward wifi is a
* second or two of white while somebody wonders whether the tap landed.
*/
import { useEffect, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { MBar, MBody, MError, MRule, MSection, MTop } from "@/components/m";
import { DIVIDER, INK, Kicker, N600, N700, StockTag } from "@/components/staffui";
import { useStaff } from "@/lib/staffclient";
import { fmtDate, formatInZone } from "@/lib/compute";
type Alt = { si: number; size: string; word: string };
type Data = {
itemId: string; item: string; size: string; si: number;
lastRestocked: string; position: number; ahead: number;
joined: boolean; entryId: string | null; offeredAt: string | null;
holdUntil: string | null; offerExpired: boolean; acceptedAt: string | null;
alternatives: Alt[];
};
export default function WaitlistScreen({ data }: { data: Data }) {
const { mutate, busy, me } = useStaff();
const router = useRouter();
const [err, setErr] = useState("");
/* Optimistic, then corrected by the refresh that follows it. The server's answer always wins:
* without the effect a stale local flag would outlive the redraw and offer "Leave the list" to
* somebody who had already left. */
const [joined, setJoined] = useState(data.joined);
useEffect(() => { setJoined(data.joined); }, [data.joined]);
/* Four states, and the screen used to know about two of them.
*
* An offer is not a standing invitation: it has been accepted, or it is live, or the 48-hour
* hold has run out. Keying only off `offeredAt` meant somebody who had already accepted was
* shown "Accept it" again — every tap answered "Nothing to accept" — and somebody whose hold
* had lapsed was shown a bar the server now refuses, with no word about why. */
const acceptedAt = data.acceptedAt;
const accepted = !!acceptedAt;
const heldForMe = !accepted && !!data.offeredAt && !data.offerExpired;
const lapsed = !accepted && !!data.offeredAt && data.offerExpired;
/* The deadline, with the time on it, in the facility's zone.
*
* `holdUntil` is an instant 48 hours after the offer, and it was being shown by slicing the first
* ten characters of the UTC string — so an offer made at 09:00 Brisbane printed the previous day's
* date, and a nurse reading "held until the 9th" had until the 10th. The hour matters as much as
* the date here: a hold that runs out mid-afternoon is not the same as one that runs to midnight. */
const heldUntil = data.holdUntil
? formatInZone(data.holdUntil, me.tz, { day: "numeric", month: "short", hour: "numeric", minute: "2-digit" })
: "";
const headline = accepted ? "Its with your manager"
: heldForMe ? "Held for you"
: lapsed ? "The hold has run out"
: joined ? "You are on the list"
: "None on the shelf";
const noticeKicker = accepted ? "Accepted" : heldForMe ? "Its in" : lapsed ? "Your place" : joined ? "Your place" : "If you join";
const noticeLine = accepted
? `Accepted ${formatInZone(acceptedAt || "", me.tz)}. It goes to your manager for approval like any other request.`
: heldForMe
? `${heldUntil ? `Held for you until ${heldUntil}.` : "Held for you."} Accepting sends it to your manager like any other request.`
: lapsed
? `Nobody took it before the hold ran out, so you are ${ordinal(data.position)} in queue again and the linen room can offer it a second time.`
: joined
? `${ordinal(data.position)} in queue. The linen room tells you when one is held for you.`
: `You would be ${ordinal(data.position)} in queue. ${data.ahead === 0 ? "Nobody is waiting yet." : data.ahead === 1 ? "One person is already waiting." : `${data.ahead} people are already waiting.`}`;
return (
<>
<MTop title="Waitlist" back backHref="/my/shelf" />
<MRule />
<MBody>
<div style={{ padding: "16px 16px 0" }}>
<Kicker>{data.item} · {data.size}</Kicker>
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 900, fontSize: 24, letterSpacing: "-0.015em", lineHeight: 1.1, marginTop: 4 }}>
{headline}
</div>
</div>
<MError msg={err} onDismiss={() => setErr("")} />
<div style={{ padding: "0 16px" }}>
<div style={{ border: `2px solid ${INK}`, background: "#fff", padding: "12px 14px", marginTop: 12 }}>
<div style={{ fontSize: 11, fontWeight: 800, letterSpacing: "0.09em", textTransform: "uppercase", color: N600 }}>{noticeKicker}</div>
<div style={{ fontSize: 14, lineHeight: 1.5, marginTop: 4 }}>{noticeLine}</div>
</div>
{accepted && (
<div style={{ fontSize: 13, lineHeight: 1.6, color: N700, marginTop: 12 }}>
Follow it on <Link href="/my/orders" style={{ color: INK, fontWeight: 800 }}>your orders</Link>.
</div>
)}
{!accepted && !heldForMe && data.lastRestocked && (
<div style={{ fontSize: 13, lineHeight: 1.6, color: N700, marginTop: 12 }}>
Last counted {fmtDate(data.lastRestocked)}.
</div>
)}
{/* Most people would rather have a size that fits approximately today, so the stocked
sizes nearest theirs are the row above the wait, not a footnote under it. Tapping one
opens the request screen with that garment and size already chosen. */}
{data.alternatives.length > 0 && !heldForMe && !accepted && (
<>
<MSection label="Or take a stocked size" />
{data.alternatives.map((a) => (
<Link
key={a.si}
href={`/my/request?item=${encodeURIComponent(data.itemId)}&si=${a.si}`}
className="tcx-bar"
aria-label={`Ask for ${data.item}, size ${a.size}`}
style={{
display: "flex", alignItems: "center", gap: 12, minHeight: 60, padding: "9px 0",
width: "100%", borderBottom: `1px solid ${DIVIDER}`, color: INK,
textDecoration: "none", font: "inherit", background: "none",
}}
>
<span style={{ flex: 1, minWidth: 0, fontSize: 15, fontWeight: 700 }}>{a.size}</span>
<StockTag word={a.word} />
</Link>
))}
</>
)}
<p style={{ fontSize: 13, lineHeight: 1.6, color: N700, padding: "16px 0 0", margin: 0 }}>
Waiting doesnt need approval. Your manager only sees it if the item comes in and you
accept it.
</p>
</div>
<div style={{ height: 12 }} />
</MBody>
{accepted ? null : heldForMe ? (
<MBar
label={busy ? "Working…" : "Accept it"}
glyph="check"
disabled={busy}
onClick={async () => {
const r = await mutate<{ request: { id: string } }>("waitlist.accept", { id: data.entryId });
if (!r.ok) { setErr(r.error); return; }
router.push(`/my/orders/${r.result.request.id}`);
}}
/>
) : joined ? (
<MBar
label={busy ? "Working…" : "Leave the list"}
tone="ink"
disabled={busy}
onClick={async () => {
const r = await mutate("waitlist.leave", { id: data.entryId });
if (!r.ok) { setErr(r.error); return; }
setJoined(false);
router.refresh();
}}
/>
) : (
<MBar
label={busy ? "Joining…" : "Join the list"}
disabled={busy}
onClick={async () => {
const r = await mutate("waitlist.join", { itemId: data.itemId, si: data.si });
if (!r.ok) { setErr(r.error); return; }
setJoined(true);
router.refresh();
}}
/>
)}
</>
);
}
function ordinal(n: number) {
const s = ["th", "st", "nd", "rd"], v = n % 100;
return n + (s[(v - 20) % 10] || s[v] || s[0]);
}
+38
View File
@@ -0,0 +1,38 @@
"use client";
/* Team ▸ Ward — who on this manager's team holds what.
*
* A list, not a dashboard. The only computed thing on it is each person's set count, and the
* footnote exists because the first question a manager asks about "3 of 6 sets" is whether
* something has to come back before the next one can. Nothing does — the rule is six held at any
* time, reached without handing anything in — so the line is there only when somebody is capped.
*/
import { MRow } from "@/components/m";
import { N700 } from "@/components/staffui";
import Team, { Band } from "./Team";
type Row = { id: string; name: string; group: string; held: number; lastIssued: string; capped: boolean; setsLabel: string; isNewStarter: boolean };
export default function WardScreen({ ward, rows, anyCapped }: { ward: string; rows: Row[]; anyCapped: boolean }) {
return (
<Team active="/my/ward">
{/* The head-count and the column the numbers on the right belong to, on one line. */}
<Band label={ward || "Your team"} right={`${rows.length} · Items held`} />
<div style={{ padding: "0 16px" }}>
{rows.map((r) => (
<MRow
key={r.id}
title={r.name}
sub={[r.group, r.capped ? r.setsLabel : ""].filter(Boolean).join(" · ")}
right={`${r.held} item${r.held === 1 ? "" : "s"}`}
/>
))}
</div>
{anyCapped && (
<p style={{ fontSize: 13, lineHeight: 1.6, color: N700, padding: 16, margin: 0 }}>
Nothing has to be handed back before the next set is issued.
</p>
)}
<div style={{ height: 12 }} />
</Team>
);
}