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
+33
View File
@@ -0,0 +1,33 @@
import { redirect } from "next/navigation";
import { prisma } from "@/lib/db";
import { currentStaff } from "@/lib/staffsession";
import { pushConfigured } from "@/lib/push";
import AccountScreen from "@/components/screens/Account";
export const dynamic = "force-dynamic";
export default async function MyAccount() {
const sess = await currentStaff();
if (!sess) redirect("/my/signin");
// No row means every default, which is what the schema says and what the sender assumes. The
// switches are read for this session's own staff member and for nobody else — notify.prefs
// writes the same way, and takes no staffId at all.
const pref = await prisma.staffNotifyPref.findUnique({ where: { staffId: sess.staffId } });
return (
<AccountScreen
email={sess.email}
prefs={{
approved: pref?.approved ?? true,
ready: pref?.ready ?? true,
round: pref?.round ?? true,
kitcheck: pref?.kitcheck ?? true,
waiting: pref?.waiting ?? true,
}}
// Nothing can be sent at all without a key on the server, and the screen says so rather than
// offering switches that would do nothing.
pushReady={pushConfigured()}
/>
);
}
+17
View File
@@ -0,0 +1,17 @@
import { notFound, redirect } from "next/navigation";
import { currentStaff } from "@/lib/staffsession";
import { reviewData } from "@/lib/managerdata";
import ReviewScreen from "@/components/screens/Review";
export const dynamic = "force-dynamic";
export default async function MyReview({ params }: { params: Promise<{ id: string }> }) {
const sess = await currentStaff();
if (!sess) redirect("/my/signin");
const { id } = await params;
// reviewData starts from `managerId: sess.staffId`, so a request addressed to another manager
// is not filtered out afterwards — it is never selected.
const data = await reviewData(sess, id);
if (!data) notFound();
return <ReviewScreen data={data} />;
}
+55
View File
@@ -0,0 +1,55 @@
import { notFound, redirect } from "next/navigation";
import { prisma } from "@/lib/db";
import { currentStaff } from "@/lib/staffsession";
import { approvalQueue } from "@/lib/managerdata";
import ApprovalsScreen from "@/components/screens/Approvals";
export const dynamic = "force-dynamic";
export default async function MyApprovals() {
const sess = await currentStaff();
if (!sess) redirect("/my/signin");
// Whoever a request names decides it, team or no team. A request reaches somebody who manages
// nobody more than one way: the linen room re-addresses one that arrived without an approver
// (request.reassign only checks the person is on the register, not that anybody reports to
// them), or a manager's last report is moved to somebody else while their request is still
// awaiting. This is the same query Home counts for its "waiting on you" banner, and the banner
// is the only door — the staff nav has no approvals tab — so turning these people away left a
// colleague blocked behind a 404 nobody could clear.
const rows = await approvalQueue(sess);
if (!rows.length) {
/* Nothing waiting. Two questions, not one.
*
* Does anybody active report to them — filtered exactly as the layout, teamTabs() and
* wardData() filter it, so the screen and the tab cannot disagree — and has anything ever been
* addressed to them. The second question is what keeps somebody on the screen they are
* standing on: approving from the queue refreshes this page, so an approver with no reports
* who cleared their last request was thrown onto "That page isn't here." by the very tap that
* emptied it. Having once been an approver, an empty queue is a state for them.
*
* Somebody who is neither still gets nothing rather than an empty queue: an empty approvals
* screen implies they might one day have a team, which is a question for the linen room and
* not something this app should imply an answer to. */
const [reports, everAddressed] = await Promise.all([
prisma.staff.count({ where: { facilityId: sess.facilityId, managerId: sess.staffId, inactive: false } }),
prisma.request.count({ where: { facilityId: sess.facilityId, managerId: sess.staffId } }),
]);
if (!reports && !everAddressed) notFound();
}
// Which of the waiting requests are for the manager themselves. Some of these now are — the
// rule against approving your own uniform has been relaxed for the case the owner named — and
// the screen sets those apart so nobody approves their own by accident and works out later that
// they did. Whether a self-approval is allowed at all is the server's call and is not re-tested
// here; this only asks the database which of the rows it already let through are the reader's
// own, by the request's subject, which is the same fact the record is written from. The rows are
// scoped to this manager and to `awaiting` already, so the lookup is over a handful of ids.
const ownIds = rows.length
? (
await prisma.request.findMany({
where: { id: { in: rows.map((r) => r.id) }, subjectId: sess.staffId },
select: { id: true },
})
).map((r) => r.id)
: [];
return <ApprovalsScreen rows={rows} ownIds={ownIds} />;
}
+19
View File
@@ -0,0 +1,19 @@
import { redirect } from "next/navigation";
import { currentStaff } from "@/lib/staffsession";
import { catalogueData, damageData, notifyWays } from "@/lib/staffdata";
import DamageScreen from "@/components/screens/Damage";
export const dynamic = "force-dynamic";
export default async function MyDamage() {
const sess = await currentStaff();
if (!sess) redirect("/my/signin");
const [{ holdings }, { managerName }, ways] = await Promise.all([
damageData(sess),
catalogueData(sess),
// The replacement half ends on the same Sent screen a request does, and that screen's one line
// says how this person will actually hear — a phone, an email, or neither.
notifyWays(sess),
]);
return <DamageScreen holdings={holdings} managerName={managerName} notifyWays={ways} />;
}
+12
View File
@@ -0,0 +1,12 @@
import { redirect } from "next/navigation";
import { currentStaff } from "@/lib/staffsession";
import { kitData } from "@/lib/staffdata";
import KitScreen from "@/components/screens/Kit";
export const dynamic = "force-dynamic";
export default async function MyKit() {
const sess = await currentStaff();
if (!sess) redirect("/my/signin");
return <KitScreen data={await kitData(sess)} />;
}
+18
View File
@@ -0,0 +1,18 @@
import { redirect } from "next/navigation";
import { currentStaff } from "@/lib/staffsession";
import { kitCheckData } from "@/lib/cycledata";
import KitCheckScreen from "@/components/screens/KitCheck";
export const dynamic = "force-dynamic";
export default async function MyKitCheck() {
const sess = await currentStaff();
if (!sess) redirect("/my/signin");
const data = await kitCheckData(sess);
/* Between rounds this is a state, not a refusal. It used to be a 404, which told somebody who
* had tapped a notification from the last round that the page did not exist — a refusal is for a
* record that is not yours, and "no round is open right now" is neither secret nor their mistake.
* The screen says so and offers the way back. */
if (!data) return <KitCheckScreen closed />;
return <KitCheckScreen dueBy={data.dueBy} lastConfirmed={data.lastConfirmed} rows={data.rows} />;
}
+108
View File
@@ -0,0 +1,108 @@
import { redirect } from "next/navigation";
import { prisma } from "@/lib/db";
import { addDays, facilityDate, facilityToday } from "@/lib/compute";
import { currentStaff } from "@/lib/staffsession";
import { ROUTED_TO_ROUND, dueOnWard } from "@/lib/staffreq";
import { DECLINE_HEADLINE_DAYS } from "@/lib/staffdata";
import { StaffProvider, type StaffCounts, type StaffMe } from "@/lib/staffclient";
import { OfflineBar } from "@/components/staffui";
import { MToastProvider } from "@/components/m";
export const dynamic = "force-dynamic";
/* Everything that needs a signed-in staff member.
*
* The role flags are resolved here, once, from the database rather than trusted from the client:
* "am I a manager" is the answer to "does anybody name me as theirs", and a screen that asked the
* browser that question would be asking the wrong party.
*
* The two badge counts are resolved here too, for the same reason and one more: they are read by
* the tab bar, which is drawn on five screens, and a count per screen is five queries and five
* chances to disagree with the list it opens.
*/
export default async function StaffAppLayout({ children }: { children: React.ReactNode }) {
const sess = await currentStaff();
if (!sess) redirect("/my/signin");
const staff = await prisma.staff.findUniqueOrThrow({
where: { id: sess.staffId },
select: { first: true, last: true, num: true, dept: true, wardDesk: true, managerId: true, facility: { select: { name: true, timezone: true } } },
});
const today = facilityToday(staff.facility.timezone);
const declinedFrom = addDays(today, -DECLINE_HEADLINE_DAYS);
const [reports, needing, declined, approvals, roundBags] = await Promise.all([
prisma.staff.count({ where: { managerId: sess.staffId, inactive: false } }),
/* The Orders badge: their own requests that need them.
*
* ⛔ Deliberately NOT `NEEDS_STAFF` from lib/staffreq.ts, which also contains `declined`.
* `declined` is terminal — nothing the person does clears it — so a badge built on that set
* never goes back to nothing: one refusal in March and the tab wears a number for ever, with
* no way to put it down. NEEDS_STAFF keeps its own job (the accent edge on a row, where a
* decline genuinely is something to look at); it is the wrong set for a badge. */
prisma.request.count({ where: { subjectId: sess.staffId, status: { in: ["awaiting", "ready", "round"] } } }),
// A decline is news for a week, exactly as long as Home's live card keeps it — the same window
// from the same constant, so the badge and the card cannot tell two stories.
prisma.request.findMany({
where: { subjectId: sess.staffId, status: "declined" },
orderBy: { decidedAt: "desc" }, take: 10,
select: { decidedAt: true, createdAt: true },
}),
/* The Team badge, half one: requests ADDRESSED to this person, whether or not anybody reports
* to them. The same predicate approvalQueue() uses, so the badge and the list cannot drift.
*
* ⛔ Never gated on "is a manager". A request reaches somebody who manages nobody two ordinary
* ways — the linen room re-addresses one that arrived without an approver, or a manager's last
* report moves away while their request is still awaiting — and gating here would take away
* their badge, their tab and their banner at once, leaving a colleague blocked behind a screen
* nobody can reach. app/my/(app)/approvals/page.tsx carries the same note. */
prisma.request.count({ where: { managerId: sess.staffId, status: "awaiting" } }),
// Half two, for a ward desk: the bags sitting on their ward waiting to be signed for. The ward
// the trolley left them on, off the timeline — the same fence /my/round and round.sign use.
staff.wardDesk && staff.dept
? prisma.request.count({
where: {
facilityId: sess.facilityId, status: "round",
events: { some: { label: ROUTED_TO_ROUND, meta: dueOnWard(staff.dept) } },
},
})
: Promise.resolve(0),
]);
const freshDeclines = declined.filter((r) => facilityDate(r.decidedAt ?? r.createdAt, staff.facility.timezone) >= declinedFrom).length;
const me: StaffMe = {
staffId: sess.staffId,
name: `${staff.first} ${staff.last}`.trim(),
first: staff.first,
num: staff.num,
ward: staff.dept,
facility: staff.facility.name,
// Resolved here for the same reason the role flags are: it is the facility's answer, not the
// phone's, and a device set to the wrong zone must not change what a ward round is told.
tz: staff.facility.timezone,
isManager: reports > 0,
wardDesk: staff.wardDesk,
hasManager: !!staff.managerId,
};
const counts: StaffCounts = {
orders: needing + freshDeclines,
team: approvals + roundBags,
approvals,
round: roundBags,
};
return (
<StaffProvider me={me} counts={counts}>
{/* Drawn once, above every screen's own app bar. The mockup puts it under the bar; doing
that would mean threading it through sixteen screens that each compose their own. */}
<OfflineBar />
{/* The toast host. `useToast()` falls back to a no-op with no provider above it, which is
what /my had: a disabled 64px bar carries `offReason` — "Pick an item, a size and a
reason" — and tapping it said nothing at all. The counter app mounts the same provider
for the same purpose; this is a read-only use of components/m.tsx. */}
<MToastProvider>{children}</MToastProvider>
</StaffProvider>
);
}
+55
View File
@@ -0,0 +1,55 @@
/* What a tap looks like before the server answers.
*
* Every screen under /my is `force-dynamic` and rendered from its own database query, and App
* Router keeps the previous screen fully painted until that query comes back. On ward wifi that is
* two or three seconds in which nothing at all acknowledges the tap — so people tap again, and the
* app reads as frozen. This is the route-level fallback the framework wants for exactly that: it
* replaces the body the moment a navigation starts, keeping the app's own chrome so the change
* reads as "loading" rather than "gone".
*
* Deliberately not the tab bar: the nav belongs to the four screens that draw it, and painting one
* here would make it flash into existence on the way to a detail screen that has none. The top bar
* has no title for the same reason — this fallback covers every route in the group, and inventing a
* title would mean printing the wrong one somewhere.
*/
const INK = "#201e1d";
const GROUND = "#f3f2f2";
/** A grey block standing in for a line of text. Sized in the same 2px system as everything else. */
function Bar({ w, h = 16 }: { w: string; h?: number }) {
return <div style={{ width: w, height: h, background: "var(--color-neutral-200)" }} />;
}
export default function StaffLoading() {
return (
<>
<header className="tcx-topbar" style={{
height: 56, flex: "0 0 56px", background: INK, color: GROUND, display: "flex", alignItems: "center",
paddingLeft: 16, paddingRight: 16,
backgroundImage: "linear-gradient(to bottom, rgba(243,242,242,0.16) 0 1px, transparent 1px)",
backgroundPosition: "0 env(safe-area-inset-top, 0px)", backgroundRepeat: "no-repeat", backgroundSize: "100% 1px",
}}>
<span style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 15, letterSpacing: "0.06em", textTransform: "uppercase" }}>
One moment
</span>
</header>
<div style={{ height: 4, flex: "0 0 4px", background: "var(--color-accent)" }} />
<div style={{ flex: 1, overflow: "hidden", background: GROUND }} aria-busy="true">
{/* Announced once, quietly. The blocks below are decoration and say nothing. */}
<div role="status" style={{ padding: "20px 16px 0", fontSize: 13, color: "var(--color-neutral-600)" }}>Loading</div>
<div style={{ padding: "16px 16px 0", display: "grid", gap: 10 }} aria-hidden="true">
<Bar w="60%" h={22} />
<Bar w="40%" />
</div>
<div style={{ marginTop: 24, display: "grid", gap: 2 }} aria-hidden="true">
{[0, 1, 2, 3].map((i) => (
<div key={i} style={{ background: "#fff", padding: "18px 16px", display: "grid", gap: 8 }}>
<Bar w="55%" h={18} />
<Bar w="35%" h={12} />
</div>
))}
</div>
</div>
</>
);
}
+15
View File
@@ -0,0 +1,15 @@
import { redirect } from "next/navigation";
import { currentStaff } from "@/lib/staffsession";
import { messagesData } from "@/lib/staffdata";
import MessagesScreen from "@/components/screens/Messages";
export const dynamic = "force-dynamic";
/** The screen behind the Messages tab. Empty is a state, not a refusal — see the (b4) table: there
* is nothing private about having said nothing yet, and the screen can say where a thread starts. */
export default async function MyMessages() {
const sess = await currentStaff();
if (!sess) redirect("/my/signin");
const { threads, startable } = await messagesData(sess);
return <MessagesScreen threads={threads} startable={startable} />;
}
+29
View File
@@ -0,0 +1,29 @@
import { MBar, MBody, MEmpty, MRule, MTop } from "@/components/m";
/* What a staff screen shows when a link leads nowhere it is allowed to go.
*
* Two different things are told apart on purpose, and only one of them lands here. A REFUSAL — the
* record is not yours, the role is not yours — is this page, and it explains nothing: a refusal
* that gave its reason would confirm the thing exists and say something about somebody else's
* record. "Nothing to show" — a kit check between rounds, an empty queue, a day with no bags — is
* the screen itself with a short state on it and a way out, which is where those cases now go.
*
* Next renders the nearest not-found.tsx, so this one stays inside the signed-in layout: same
* chrome, same provider, and a bar that goes home rather than the website's 404 with its marketing
* nav landing on top of the tab bar (seen on a Pixel 8 Pro, 2026-09-12).
*
* The words are load-bearing: scripts/e2e-staffapp.sh greps every refusal for "That page isn" and
* then checks that none of the refused screen's own words came with it.
*/
export default function StaffNotFound() {
return (
<>
<MTop title="Not here" back />
<MRule />
<MBody>
<MEmpty title="That page isnt here." />
</MBody>
<MBar label="Back to home" href="/my" />
</>
);
}
+28
View File
@@ -0,0 +1,28 @@
import { notFound, redirect } from "next/navigation";
import { currentStaff } from "@/lib/staffsession";
import { bagLines, requestData } from "@/lib/staffdata";
import CodeFullScreen from "@/components/screens/CodeFull";
export const dynamic = "force-dynamic";
/* The full-screen collection code.
*
* Fenced more narrowly than the order it belongs to, on purpose. requestData() lets the wearer's
* manager, the clerk who raised it and the ward desk read an order — none of them collect the bag,
* and the code is what a bag is handed over against, so this screen is the wearer's alone. A
* request that is no longer `ready` has no code to show either: it has been collected, or it never
* reached the counter. Both are refusals rather than an empty screen, for the reason every refusal
* under /my is one — explaining would confirm what exists.
*/
export default async function MyOrderCode({ params }: { params: Promise<{ id: string }> }) {
const sess = await currentStaff();
if (!sess) redirect("/my/signin");
const { id } = await params;
const data = await requestData(sess, id);
if (!data) notFound();
if (!data.mine || data.status !== "ready" || !data.collectCode) notFound();
// What is actually in the bag: a declined line is not in it, and naming it at the counter starts
// an argument with a clerk who cannot settle it.
const lines = bagLines(data.lines).map((l) => ({ item: l.item, size: l.size, qty: l.qty }));
return <CodeFullScreen id={data.id} code={data.collectCode} name={data.subjectName} lines={lines} />;
}
@@ -0,0 +1,15 @@
import { notFound, redirect } from "next/navigation";
import { currentStaff } from "@/lib/staffsession";
import { requestData } from "@/lib/staffdata";
import ThreadScreen from "@/components/screens/Thread";
export const dynamic = "force-dynamic";
export default async function MyOrderThread({ params }: { params: Promise<{ id: string }> }) {
const sess = await currentStaff();
if (!sess) redirect("/my/signin");
const { id } = await params;
const data = await requestData(sess, id);
if (!data) notFound();
return <ThreadScreen data={data} />;
}
+17
View File
@@ -0,0 +1,17 @@
import { notFound, redirect } from "next/navigation";
import { currentStaff } from "@/lib/staffsession";
import { requestData } from "@/lib/staffdata";
import OrderScreen from "@/components/screens/Order";
export const dynamic = "force-dynamic";
export default async function MyOrder({ params }: { params: Promise<{ id: string }> }) {
const sess = await currentStaff();
if (!sess) redirect("/my/signin");
const { id } = await params;
const data = await requestData(sess, id);
// A request that isn't theirs, their team's, or one they raised is a 404 rather than a 403 —
// "you may not see this" confirms it exists.
if (!data) notFound();
return <OrderScreen data={data} />;
}
+24
View File
@@ -0,0 +1,24 @@
import { redirect } from "next/navigation";
import { currentStaff } from "@/lib/staffsession";
import { ordersData } from "@/lib/staffdata";
import OrdersScreen from "@/components/screens/Orders";
export const dynamic = "force-dynamic";
export default async function MyOrders({ searchParams }: { searchParams: Promise<{ tab?: string }> }) {
const sess = await currentStaff();
if (!sess) redirect("/my/signin");
const { tab } = await searchParams;
const { open, done, raised } = await ordersData(sess);
// `?tab=` is honoured because other screens link straight to a view; anything else lands on Open,
// which is the question this screen answers. `raised` is what this person typed in for somebody
// else — the desk's whole day, and now a manager's too.
return (
<OrdersScreen
open={open}
done={done}
raised={raised}
initialTab={tab === "done" ? "done" : tab === "raised" ? "raised" : "open"}
/>
);
}
+86
View File
@@ -0,0 +1,86 @@
import { redirect } from "next/navigation";
import { prisma } from "@/lib/db";
import { currentStaff } from "@/lib/staffsession";
import { fmtDate, homeData } from "@/lib/staffdata";
import HomeScreen from "@/components/screens/Home";
export const dynamic = "force-dynamic";
/* Home asks two questions and then gets out of the way: what is on the way, and what you hold.
*
* The badges are NOT re-counted here. The (app) layout resolves them once for the tab bar, and the
* banner at the top of this screen reads the same numbers off the context — a count per screen is
* a second query and a second chance to disagree with the list it opens.
*/
export default async function MyHome() {
const sess = await currentStaff();
if (!sess) redirect("/my/signin");
const data = await homeData(sess);
const [staff, issues, cycle, lastItem] = await Promise.all([
prisma.staff.findUniqueOrThrow({ where: { id: sess.staffId }, select: { managerId: true } }),
/* What they hold, grouped the way kitData() groups it — by garment and size, which is how
* somebody thinks about their own uniform rather than as a list of issuing events.
*
* handedIn as well as returnedDate: a garment handed back at the counter never gets marked
* returned, so fencing on returnedDate alone leaves it on the record for good. Every
* equivalent query in lib/ reads both.
*
* ⛔ `cost` sits on this row and is never selected. No money reaches a wearer's screen. */
prisma.issue.findMany({
where: { staffId: sess.staffId, returnedDate: null, handedIn: null },
orderBy: { date: "desc" },
select: { date: true, qty: true, sizeIndex: true, item: { select: { id: true, item: true, sizes: true } } },
}),
prisma.kitCheck.findFirst({
where: { facilityId: sess.facilityId, closedAt: null },
orderBy: { openedAt: "desc" },
select: { id: true },
}),
// The garment behind "Same again", for the tile's caption. The request screen re-checks it
// against the catalogue before filling anything in; this is only the name to put on the tile.
data.lastRequest
? prisma.catalogItem.findUnique({ where: { id: data.lastRequest.itemId }, select: { item: true } })
: Promise.resolve(null),
]);
// Who the ask would go to, by name. The empty state says it plainly, because "your manager" is
// no help to somebody who has never been told who that is.
const manager = staff.managerId
? await prisma.staff.findUnique({ where: { id: staff.managerId }, select: { first: true, last: true } })
: null;
const held = new Map<string, { item: string; size: string; qty: number; last: string }>();
for (const i of issues) {
const k = `${i.item.id}:${i.sizeIndex}`;
const cur = held.get(k) || { item: i.item.item, size: String(i.item.sizes[i.sizeIndex] ?? i.sizeIndex), qty: 0, last: "" };
cur.qty += i.qty;
if (i.date > cur.last) cur.last = i.date;
held.set(k, cur);
}
// Most recently issued first, which is the order the wearer last saw them in. Only three are
// drawn: the section note carries the total, and My kit is where the whole record lives.
const holdRows = [...held.values()]
.sort((a, b) => b.last.localeCompare(a.last) || a.item.localeCompare(b.item))
.slice(0, 3)
.map((h) => ({ item: h.item, size: h.size, qty: h.qty, last: fmtDate(h.last) }));
/* Only prompt for a cycle they still owe an answer to — somebody who finished last week should
* not be nagged for the rest of the month, and somebody holding nothing has nothing to check. */
let kitCheckOpen = false;
if (cycle) {
const answered = await prisma.kitCheckAnswer.count({ where: { kitCheckId: cycle.id, staffId: sess.staffId } });
kitCheckOpen = issues.length > 0 && answered === 0;
}
return (
<HomeScreen
data={data}
held={holdRows}
lastItem={lastItem?.item || null}
managerName={manager ? `${manager.first} ${manager.last}`.trim() : ""}
kitCheckOpen={kitCheckOpen}
/>
);
}
+44
View File
@@ -0,0 +1,44 @@
import { notFound, redirect } from "next/navigation";
import { currentStaff } from "@/lib/staffsession";
import { deskCatalogue, teamPeople } from "@/lib/deskdata";
import { ordersData } from "@/lib/staffdata";
import { REQUEST_MAX_LINES, REQUEST_MAX_QTY } from "@/lib/ops";
import DeskScreen from "@/components/screens/Desk";
export const dynamic = "force-dynamic";
/* A manager raising for one of their own reports — the only way one person types a request in
* somebody else's name in this app. A ward clerk on the desk used to have a screen of its own for
* anyone on their ward; that is gone, and the person who would have asked the clerk asks the
* manager who approves it anyway.
*
* The scope here is the same relationship the server enforces on `request.create`: the people who
* name this person as their manager. Nothing on this page decides who may be raised for — it asks
* teamPeople() for exactly the set the op would accept, and a request for anybody else is refused
* there.
*/
export default async function MyRaise() {
const sess = await currentStaff();
if (!sess) redirect("/my/signin");
const [people, items, orders] = await Promise.all([
teamPeople(sess),
deskCatalogue(sess),
ordersData(sess),
]);
// Nobody reporting to them means no screen, the same way the approvals queue works: an empty one
// implies they might one day have a team, which is a question for the linen room.
if (!people.length) notFound();
return (
<DeskScreen
// Every one of them names this manager, which is exactly why the request cannot stay with
// them: the screen says whose name is on it, and the server sends it up a level.
people={people}
items={items}
raised={orders.raised.open}
maxLines={REQUEST_MAX_LINES}
maxQty={REQUEST_MAX_QTY}
/>
);
}
+71
View File
@@ -0,0 +1,71 @@
import { redirect } from "next/navigation";
import { prisma } from "@/lib/db";
import { currentStaff } from "@/lib/staffsession";
import { catalogueData, homeData, notifyWays } from "@/lib/staffdata";
import { REQUEST_MAX_LINES, REQUEST_MAX_QTY } from "@/lib/ops";
import { REQUEST_REASONS } from "@/lib/staffreq";
import RequestScreen from "@/components/screens/Request";
export const dynamic = "force-dynamic";
export default async function MyRequest({ searchParams }: {
searchParams: Promise<{ swap?: string; item?: string; si?: string; again?: string }>;
}) {
const sess = await currentStaff();
if (!sess) redirect("/my/signin");
const { swap, item, si, again } = await searchParams;
const [{ items, managerName, holding, allowance }, held, ways, last] = await Promise.all([
catalogueData(sess),
// handedIn as well as returnedDate, for the same reason the kit check reads both: a garment
// handed back at the counter is off the person without ever being marked returned, and the
// swap flow would otherwise offer to exchange something they no longer hold.
prisma.issue.findMany({
where: { staffId: sess.staffId, returnedDate: null, handedIn: null },
select: { itemId: true }, distinct: ["itemId"],
}),
// What this server can really do, so the line after a send says what is true here rather than
// what is usually true elsewhere.
notifyWays(sess),
// Same again: the garment, size and reason of their most recent ask. Read only when asked for.
again === "1" ? homeData(sess).then((h) => h.lastRequest) : Promise.resolve(null),
]);
// Only honour a pre-selection that actually exists in this facility's catalogue — the ids come
// off a query string.
const pre = items.find((i) => i.id === item) || null;
const preSi = pre ? pre.sizes.find((s) => s.si === parseInt(String(si ?? ""), 10))?.si ?? null : null;
/* A last request can name a garment this person may no longer ask for. catalogueData() filters by
* staff group and uniform cut, either of which can have changed since, and the garment can have
* been archived or lost that size. So the pre-fill is re-checked against the list this screen is
* actually offering, and anything stale opens the screen empty with no line claiming it was
* filled in: filling in something the server will then refuse is worse than filling in nothing. */
const againItem = last ? items.find((i) => i.id === last.itemId) || null : null;
const againSize = againItem && last ? againItem.sizes.find((s) => s.si === last.si) || null : null;
const fresh = againItem && againSize ? { item: againItem, size: againSize, code: last!.code, reason: last!.reason } : null;
const chosen = pre && preSi !== null ? { id: pre.id, si: preSi } : fresh ? { id: fresh.item.id, si: fresh.size.si } : null;
return (
<RequestScreen
items={items}
managerName={managerName}
swap={swap === "1"}
heldItemIds={held.map((h) => h.itemId)}
preItemId={chosen?.id ?? null}
preSi={chosen?.si ?? null}
// A reason is only carried over from Same again, and only one of the four this screen offers:
// the stored string is whatever the reason list said when that request was raised.
preReason={!pre && fresh && REQUEST_REASONS.includes(fresh.reason as never) ? fresh.reason : null}
filledFrom={!pre && fresh ? fresh.code : null}
holding={holding}
allowance={allowance}
notifyWays={ways}
// The ceilings are the server's, handed down rather than restated in the browser: a screen
// that let somebody build an eleventh line would only be showing them a refusal.
maxLines={REQUEST_MAX_LINES}
maxQty={REQUEST_MAX_QTY}
/>
);
}
+32
View File
@@ -0,0 +1,32 @@
import { notFound, redirect } from "next/navigation";
import { currentStaff } from "@/lib/staffsession";
import { roundData } from "@/lib/deskdata";
import { ordersData } from "@/lib/staffdata";
import RoundScreen from "@/components/screens/Round";
export const dynamic = "force-dynamic";
export default async function MyRound() {
const sess = await currentStaff();
if (!sess) redirect("/my/signin");
const data = await roundData(sess);
if (!data) notFound();
/* What this person raised for other people and hasn't seen the end of — as a manager for one
* of their team, or, on an older request, from the desk route that no longer exists.
*
* The three lists above it are only ever about bags arriving today, so a request typed in on
* Tuesday and approved on Thursday appears on no screen the raiser can reach until it turns up
* on a trolley — which is why they rang the linen room to ask. It is deliberately the open ones
* only: the round screen is a day's work, not an archive, and everything that has finished is
* under Raised in Orders. */
const { raised } = await ordersData(sess);
return (
<RoundScreen
ward={data.ward}
toSign={data.toSign}
unclaimed={data.unclaimed}
signedToday={data.signedToday}
raised={raised.open}
/>
);
}
+13
View File
@@ -0,0 +1,13 @@
import { redirect } from "next/navigation";
import { currentStaff } from "@/lib/staffsession";
import { catalogueData } from "@/lib/staffdata";
import ShelfScreen from "@/components/screens/Shelf";
export const dynamic = "force-dynamic";
export default async function MyShelf() {
const sess = await currentStaff();
if (!sess) redirect("/my/signin");
const { items } = await catalogueData(sess);
return <ShelfScreen items={items} />;
}
+55
View File
@@ -0,0 +1,55 @@
import { notFound, redirect } from "next/navigation";
import { prisma } from "@/lib/db";
import { currentStaff } from "@/lib/staffsession";
import { ROUTED_TO_ROUND, dueOnWard, teamTabs } from "@/lib/staffreq";
export const dynamic = "force-dynamic";
/* The Team tab's own address. It has no screen: it works out which tab this reader has and sends
* them to the first one, so the tab bar can point at one URL for a manager, a ward desk, and
* somebody who is both.
*
* The three facts are read here rather than passed from the client for the same reason the layout
* reads them: "am I a manager" is the answer to "does anybody name me as theirs", and the browser
* is the wrong party to ask. They are the same three the layout counts, through the same
* teamTabs() the shell draws its tabs with — a tab that appeared here and was refused by its own
* route would be worse than no tab at all.
*
* A refusal, not an explanation, for somebody who is none of the three: a dead end that says why
* confirms the screen exists.
*/
export default async function MyTeam() {
const sess = await currentStaff();
if (!sess) redirect("/my/signin");
const staff = await prisma.staff.findUniqueOrThrow({
where: { id: sess.staffId },
select: { dept: true, wardDesk: true },
});
const [reports, approvals, roundBags] = await Promise.all([
prisma.staff.count({ where: { managerId: sess.staffId, inactive: false } }),
// Requests ADDRESSED to them, whether or not anybody reports to them: the linen room can
// re-address one to somebody who manages nobody, and that person has a queue and so has a tab.
// Same predicate as approvalQueue() and as the layout's badge.
prisma.request.count({ where: { managerId: sess.staffId, status: "awaiting" } }),
// A blank ward is not a ward — the round and round.sign are fenced the same way.
staff.wardDesk && staff.dept
? prisma.request.count({
where: {
facilityId: sess.facilityId, status: "round",
events: { some: { label: ROUTED_TO_ROUND, meta: dueOnWard(staff.dept) } },
},
})
: Promise.resolve(0),
]);
const tabs = teamTabs(
// The ward travels with the desk flag, because the round is one ward's bags and a blank ward is
// not a ward — the same fence roundData() and round.sign apply.
{ isManager: reports > 0, wardDesk: staff.wardDesk, ward: staff.dept },
{ approvals, round: roundBags },
);
if (!tabs.length) notFound();
redirect(tabs[0].href);
}
+15
View File
@@ -0,0 +1,15 @@
import { notFound, redirect } from "next/navigation";
import { currentStaff } from "@/lib/staffsession";
import { waitlistData } from "@/lib/cycledata";
import WaitlistScreen from "@/components/screens/Waitlist";
export const dynamic = "force-dynamic";
export default async function MyWaitlist({ searchParams }: { searchParams: Promise<{ item?: string; si?: string }> }) {
const sess = await currentStaff();
if (!sess) redirect("/my/signin");
const { item, si } = await searchParams;
const data = await waitlistData(sess, String(item || ""), parseInt(String(si ?? "-1"), 10));
if (!data) notFound();
return <WaitlistScreen data={data} />;
}
+25
View File
@@ -0,0 +1,25 @@
import { notFound, redirect } from "next/navigation";
import { prisma } from "@/lib/db";
import { currentStaff } from "@/lib/staffsession";
import { wardData } from "@/lib/managerdata";
import WardScreen from "@/components/screens/Ward";
export const dynamic = "force-dynamic";
export default async function MyWard() {
const sess = await currentStaff();
if (!sess) redirect("/my/signin");
/* The same fact, read the same way it is read everywhere else: active reports, in this facility.
*
* Counting deactivated people let this page render for somebody the rest of the app had decided
* manages nobody — the layout's `isManager`, teamTabs(), wardData() and reportsOf() all filter
* them out — so the Team shell drew itself with no tabs above an empty roster while the Team
* item was missing from the bar. Two answers to "does this person manage anybody" inside one
* screen is the defect; this is the answer the others give. */
const reports = await prisma.staff.count({
where: { facilityId: sess.facilityId, managerId: sess.staffId, inactive: false },
});
if (!reports) notFound();
const { ward, rows, anyCapped } = await wardData(sess);
return <WardScreen ward={ward} rows={rows} anyCapped={anyCapped} />;
}
+116
View File
@@ -0,0 +1,116 @@
import { prisma } from "@/lib/db";
import { readApprovalToken } from "@/lib/approvallink";
import { linesSummary, reqLines } from "@/lib/staffdata";
import ApproveByLink from "@/components/screens/ApproveByLink";
import { MyShell, h1, kicker, lead } from "@/components/my";
export const dynamic = "force-dynamic";
export const metadata = { title: "Approve a uniform request", robots: { index: false, follow: false } };
/* The page an emailed approve/decline link opens.
*
* Deliberately outside (app): a manager standing in a corridor with an email open should not have
* to sign in to unblock somebody. The token is the authorisation, and rendering the request is all
* this page does — the decision is a POST from here, never from the link itself.
*/
export default async function ApprovePage({ searchParams }: { searchParams: Promise<{ t?: string }> }) {
const { t } = await searchParams;
const claim = readApprovalToken(t);
if (!claim) {
return (
<MyShell>
<div style={kicker}>ThreadCount</div>
<h1 style={h1}>That link has expired.</h1>
<p style={lead}>
Approval links last a fortnight and stop working once a request has been decided. Open the
app and use your approvals queue instead.
</p>
</MyShell>
);
}
const r = await prisma.request.findFirst({
where: { id: claim.rid, managerId: claim.mid },
include: {
lines: { include: { item: { select: { item: true, gender: true, sizes: true } } }, orderBy: { sort: "asc" } },
subject: { select: { first: true, last: true, group: true, num: true, dept: true, inactive: true } },
facility: { select: { name: true } },
},
});
if (!r) {
return (
<MyShell>
<div style={kicker}>ThreadCount</div>
<h1 style={h1}>That request is gone.</h1>
<p style={lead}>It may have been withdrawn, or the staff member removed from the register.</p>
</MyShell>
);
}
/* The same re-check the POST does, and for the same reason — but this page has its own thing to
* protect. It renders somebody's name, staff number, ward and role, and a token lives for a
* fortnight in a mailbox that may since have been closed or handed on. Refusing the decision but
* still showing the personal details behind it would be a refusal in name only. */
const mgr = await prisma.staff.findFirst({
where: { id: claim.mid, facilityId: r.facilityId, inactive: false },
select: { id: true },
});
if (!mgr || r.subject.inactive) {
return (
<MyShell>
<div style={kicker}>ThreadCount</div>
<h1 style={h1}>That link has expired.</h1>
<p style={lead}>
You&rsquo;re no longer on the register at this facility, or the person who asked isn&rsquo;t.
Ask the linen room.
</p>
</MyShell>
);
}
/* Whose uniform this is.
*
* A manager may now decide a request they are the subject of. The approvals queue sets those
* apart under their own heading, and this page is the one door that never goes through it: a
* link from a mailbox is the only way a manager can approve their own uniform with nothing on
* the screen telling them that is what they are doing. Whether a self-approval is allowed at
* all is the server's call and is not re-tested here — this only asks who the request is for,
* by its own subject, which is the fact the timeline row is written from.
*
* It rides on the line under the heading because that line is the only copy on this screen the
* page itself writes, and the heading above it — a person's own name, needing their approval —
* is precisely what needs answering. The queue says it twice, the second time in the record's
* words; saying that here as well needs the screen, not this file. */
const own = r.subjectId === claim.mid;
/* The whole ask, in the order it was entered, shaped exactly as every other screen shapes it.
*
* A request covers as many garments as the person needed, and this page is now the only place a
* manager might meet one without the app in front of them. It shows all of them, declines
* included when they come back to a settled one — the link itself can only settle the request in
* one direction, which is a limit the screen has to state rather than hide. */
const lines = reqLines(r.lines);
return (
<ApproveByLink
token={t!}
decided={r.status !== "awaiting"}
status={r.status}
declineReason={r.declineReason}
data={{
code: r.code,
subjectName: `${r.subject.first} ${r.subject.last}`.trim(),
subjectMeta: [own ? "Your own uniform" : "", r.subject.dept, r.subject.num, r.subject.group]
.filter(Boolean).join(" · "),
lines,
summary: linesSummary(lines),
reason: r.reason,
note: r.note,
raisedByName: r.raisedByName,
facility: r.facility.name,
}}
/>
);
}
+34
View File
@@ -0,0 +1,34 @@
import type { Metadata } from "next";
import Analytics from "@/components/Analytics";
export const dynamic = "force-dynamic";
/* Nothing under /my is public. The sign-in page is reached from a printed slip handed over at the
* counter, so there is no reason for it to be in an index either. */
export const metadata: Metadata = {
title: "Your uniform record",
robots: { index: false, follow: false },
};
/* A fixed-height column, like the counter app: the bars don't scroll, only the body does.
*
* No maximumScale, for the same reason it is gone there: pinning the zoom takes pinch-to-zoom away
* from everyone on every screen of the staff app, which is WCAG 1.4.4. */
export const viewport = {
width: "device-width", initialScale: 1,
viewportFit: "cover" as const, themeColor: "#201e1d",
};
/* Only the shell. Sign in and the emailed approval link live under /my but must be reachable
* without a session — one is how you get a session, and the other is deliberately for a manager
* who is standing in a corridor with an email open and no intention of signing in. */
export default function MyLayout({ children }: { children: React.ReactNode }) {
return (
<div className="tcx-app" role="main">
{/* The shell is the main landmark, as on the counter app, and for the same reason it is a
role rather than a wrapping element. */}
{children}
<Analytics site="app" />
</div>
);
}
+231
View File
@@ -0,0 +1,231 @@
"use client";
/* Signing in, in the app's own chrome.
*
* This screen used to be a centred web page dropped between two app screens: light ground where
* the welcome and the app are ink, no app bar, a different type scale, and — worst — it asked the
* same question the bundled welcome had just asked. Tapping "Sign in" there appeared to do
* nothing, because you arrived at two buttons saying "Sign in" and "I have a code" again.
*
* So: one decision, made once. The welcome sends you here already in a mode, and the other way in
* is a quiet line of text rather than a second pair of buttons. Everything else is the app's own
* vocabulary — ink bar, accent rule, 64px flush-left action — so the seam disappears.
*/
import { PRIVACY_URL, TERMS_URL } from "@/lib/links";
import { Suspense, useId, useState } from "react";
import { useSearchParams } from "next/navigation";
import { MBar, MBody, MError, MExternalLink, MRule, MTop, inputStyle } from "@/components/m";
import { ACCENT_700, INK, N600, N700 } from "@/components/staffui";
import Turnstile, { awaitTurnstile, resetTurnstile, turnstileOn } from "@/components/Turnstile";
import { track } from "@/lib/analytics";
type Mode = "signin" | "activate";
const label: React.CSSProperties = {
display: "block", fontSize: 11, fontWeight: 800, letterSpacing: "0.12em",
textTransform: "uppercase", color: N600, marginBottom: 8,
};
function SignInForm() {
// ?code=1 arrives from the app's "I have a code" button, and from the printed slip's link.
const sp = useSearchParams();
/* Real htmlFor/id pairs, the way components/MAuth.tsx does it for the counter app.
*
* These three boxes were wrapped in their <label>, which associates — but the password one wraps
* the Show/Hide button too, and a label may only name one control: the toggle's words were being
* read out as part of the password field's name, and a tap anywhere in the label pulled focus off
* the button. Naming each control explicitly puts the toggle outside the label where it belongs.
* This is the sign-in screen of a Play-shipped app, so it is the first thing a screen reader
* meets. */
const codeId = useId();
const emailId = useId();
const pwId = useId();
const [mode, setMode] = useState<Mode>(sp.get("code") === "1" ? "activate" : "signin");
const [code, setCode] = useState("");
// A Community instance whose operator has not set NEXT_PUBLIC_TERMS_URL / PRIVACY_URL has
// nothing to agree to, so the line is not shown and consent is not asked for.
const legal = !!(TERMS_URL || PRIVACY_URL);
const [email, setEmail] = useState("");
const [pw, setPw] = useState("");
const [show, setShow] = useState(false);
/* The terms tick. Kyle's ask (2026-09-12): sign-in carries an explicit agreement to the terms and
* the privacy policy, the way the coordinator's sign-up does. It gates both doors — setting up is
* where the account is created, and signing in is what the app does every other day — and the
* activation route refuses without it, so the box can't be talked past by a client that skips it. */
const [agree, setAgree] = useState(!legal);
const agreeId = useId();
const [busy, setBusy] = useState(false);
const [err, setErr] = useState("");
/* Both staff doors sit behind Turnstile on the server, and in production the check is required
* rather than advisory — so a screen that never obtains a token gets a flat 400 "Please complete
* the security check" and no way past it. The widget runs the way the counter app's sign-in runs
* it, quiet: nothing is drawn unless Cloudflare actually wants an interaction, which matters here
* because this screen is inside a WebView with no browser chrome to explain a card that appeared
* from nowhere. */
const [cfToken, setCfToken] = useState("");
const activating = mode === "activate";
async function submit(e?: React.FormEvent) {
e?.preventDefault();
setErr("");
setBusy(true);
const url = activating ? "/api/staff/activate" : "/api/staff/login";
// Waited for rather than read: the token usually lands long before anyone has finished typing a
// password, but occasionally a second or two later, and posting the empty string blames the
// person for a check they were never shown.
const token = cfToken || (turnstileOn() ? await awaitTurnstile() : "");
const body = activating
? { code, email, password: pw, cfToken: token, agreed: agree }
: { email, password: pw, cfToken: token, agreed: agree };
const r = await fetch(url, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) })
.catch(() => null);
const j = await r?.json().catch(() => ({}));
setBusy(false);
if (!r || !r.ok) {
// Which door and which kind of refusal — never the server's words. Together with
// staff_code_issued on the counter side this says how many printed codes become accounts.
track(activating ? "staff_activation_failed" : "staff_signin_failed", {
reason: !r ? "network" : r.status === 429 ? "throttled" : r.status === 400 && /security check/i.test(String(j?.error || "")) ? "security_check" : activating ? "code_or_details" : "credentials",
});
setErr(j?.error || "That didnt work. Try again.");
setCfToken(""); resetTurnstile();
return;
}
track(activating ? "staff_activated" : "staff_signin");
// A full navigation: the cookie was just set and /my is server-rendered.
window.location.replace("/my");
}
const ready = (activating ? !!code.trim() && !!email.trim() && !!pw : !!email.trim() && !!pw) && agree;
return (
<>
<MBody>
<div style={{ padding: "24px 16px 20px", borderBottom: "2px solid " + INK, background: "var(--color-bg)" }}>
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 28, letterSpacing: "-0.02em", lineHeight: 1.1 }}>
{activating ? "Set up your sign-in." : "Your uniform record."}
</div>
<p style={{ fontSize: 14.5, lineHeight: 1.55, color: N700, margin: "12px 0 0", maxWidth: "42ch" }}>
{activating
? "The linen room gives you a twelve-character code. Use it once, and pick how youll sign in from now on."
: "What you have out, what youre still owed and whats on order — the same record the linen room sees."}
</p>
</div>
<form onSubmit={submit} style={{ padding: 16, display: "grid", gap: 18 }}>
{activating && (
<div>
<label htmlFor={codeId} style={label}>Your code</label>
<input
id={codeId}
value={code} autoFocus autoCapitalize="characters" autoComplete="off" spellCheck={false}
placeholder="XXXX-XXXX-XXXX"
onChange={(e) => { setCode(e.target.value); setErr(""); }}
style={{ ...inputStyle, fontFamily: "ui-monospace, Menlo, Consolas, monospace", letterSpacing: "0.08em" }}
/>
</div>
)}
<div>
<label htmlFor={emailId} style={label}>Email</label>
<input
id={emailId}
type="email" value={email} autoComplete="email" inputMode="email" autoFocus={!activating}
onChange={(e) => { setEmail(e.target.value); setErr(""); }} style={inputStyle}
/>
</div>
<div>
<label htmlFor={pwId} style={label}>{activating ? "Choose a password" : "Password"}</label>
<input
id={pwId}
type={show ? "text" : "password"} value={pw}
autoComplete={activating ? "new-password" : "current-password"}
onChange={(e) => { setPw(e.target.value); setErr(""); }} style={inputStyle}
/>
{/* aria-pressed rather than a changing label alone: read on its own, "Show" says
nothing about what it shows. */}
<button type="button" onClick={() => setShow(!show)} aria-pressed={show} style={{
marginTop: 10, background: "none", border: 0, padding: 0, font: "inherit",
fontSize: 13, fontWeight: 800, color: ACCENT_700, cursor: "pointer",
}}>{show ? "Hide" : "Show"} password</button>
</div>
{/* The links open in the phone's browser inside the shell, and in a new tab on the web,
so ticking never means leaving a half-typed password behind. */}
{legal && <label htmlFor={agreeId} style={{ display: "flex", gap: 12, alignItems: "flex-start", fontSize: 13.5, lineHeight: 1.55, color: N700, cursor: "pointer" }}>
<input
id={agreeId} type="checkbox" checked={agree}
onChange={(e) => { setAgree(e.target.checked); setErr(""); }}
style={{ width: 22, height: 22, flex: "0 0 22px", marginTop: 1, accentColor: ACCENT_700 }}
/>
<span>
I agree to the {TERMS_URL ? <MExternalLink href={TERMS_URL}>Terms of use</MExternalLink> : "Terms of use"}{TERMS_URL && PRIVACY_URL ? " and the " : ""}{PRIVACY_URL ? <MExternalLink href={PRIVACY_URL}>Privacy policy</MExternalLink> : null}{!PRIVACY_URL ? "" : ""}.
</span>
</label>}
{turnstileOn() && <Turnstile onToken={setCfToken} action={activating ? "staff-activate" : "staff-login"} quiet />}
{/* Submitting with the keyboards Go key, without a visible second button. */}
<button type="submit" disabled={!ready || busy} style={{ display: "none" }} aria-hidden />
</form>
<MError msg={err} onDismiss={() => setErr("")} />
{/* The other way in — a line of text, not a second pair of buttons. The welcome screen
has already asked once, and asking again is what made the old screen feel broken. */}
<div style={{ padding: "4px 16px 0" }}>
<button
onClick={() => { setMode(activating ? "signin" : "activate"); setErr(""); }}
style={{ background: "none", border: 0, padding: 0, font: "inherit", fontSize: 14, fontWeight: 800, color: ACCENT_700, cursor: "pointer", textAlign: "left" }}
>
{activating ? "Already set up? Sign in instead" : "First time? I have a code"}
</button>
</div>
<p style={{ fontSize: 13, lineHeight: 1.6, color: N600, padding: "20px 16px 0", margin: 0 }}>
{activating
? "Your code works once. If it has already been used, ask the linen room for a new one."
: "Forgotten your password? The linen room can clear your access and hand you a fresh code."}
</p>
<p style={{ fontSize: 13, lineHeight: 1.6, color: N600, padding: "12px 16px 0", margin: 0 }}>
This is for people who wear the uniform.
</p>
{/* The policy line, and it is on the activation branch for a reason: that branch is where an
email address and a password are collected, so it is the point of collection, and Play's
review looks for a policy reachable from inside the app rather than only from the store
listing. */}
{activating && (
<p style={{ fontSize: 12.5, lineHeight: 1.6, color: N600, padding: "12px 16px 0", margin: 0 }}>
Setting this up stores your email address and a password so you can sign in. Your name,
ward and uniform record belong to the linen room.
</p>
)}
{/* There used to be an "On the website" list here — the coordinator's sign-in, the privacy
policy, the terms, account deletion. Removed at Kyle's ask (2026-09-12): none of it is
something a wearer can act on from this screen, and the coordinator's door is a desktop
screen this app cannot open. The terms and the policy are now the two links in the
agreement tick above, and all three site pages remain on Account once signed in — which
is where Play's review looks for a policy reachable from inside the app. */}
<div style={{ height: 20 }} />
</MBody>
<MBar
label={busy ? "One moment…" : activating ? "Set up my sign-in" : "Sign in"}
disabled={!ready || busy}
onClick={() => submit()}
/>
</>
);
}
export default function StaffSignIn() {
// The bar and rule sit outside the Suspense boundary: useSearchParams suspends, and an app that
// opens on a bare white rectangle before hydrating looks broken on a ward phone.
return (
<>
<MTop title="ThreadCount" />
<MRule />
<Suspense fallback={<MBody><div style={{ padding: 24, fontSize: 14, color: N600 }}>One moment</div></MBody>}>
<SignInForm />
</Suspense>
</>
);
}