ThreadCount Community edition

Uniform stock management for healthcare linen rooms: the coordinator app, the phone counter and the staff app, for your own server. Built from f976bd5 on 2026-09-15. Licensed under the Functional Source License (FSL-1.1-ALv2).
This commit is contained in:
ThreadCount
2026-09-15 23:18:05 +10:00
commit c89010a4f1
424 changed files with 53588 additions and 0 deletions
+11
View File
@@ -0,0 +1,11 @@
import { redirect } from "next/navigation";
import { currentStaff } from "@/lib/staffsession";
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");
return <AccountScreen email={sess.email} />;
}
+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} />;
}
+43
View File
@@ -0,0 +1,43 @@
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 and nobody reporting to them gets nothing, not 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 = await prisma.staff.count({ where: { managerId: sess.staffId } });
if (!reports) 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} />;
}
+13
View File
@@ -0,0 +1,13 @@
import { redirect } from "next/navigation";
import { currentStaff } from "@/lib/staffsession";
import { catalogueData, damageData } 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 }] = await Promise.all([damageData(sess), catalogueData(sess)]);
return <DamageScreen holdings={holdings} managerName={managerName} />;
}
+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)} />;
}
+16
View File
@@ -0,0 +1,16 @@
import { notFound, 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 there is no screen. A kit check that was always reachable would be answered at
// random times and the cycle's numbers would mean nothing.
if (!data) notFound();
return <KitCheckScreen dueBy={data.dueBy} lastConfirmed={data.lastConfirmed} rows={data.rows} />;
}
+42
View File
@@ -0,0 +1,42 @@
import { redirect } from "next/navigation";
import { prisma } from "@/lib/db";
import { currentStaff } from "@/lib/staffsession";
import { StaffProvider, type StaffMe } from "@/lib/staffclient";
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.
*/
export default async function StaffAppLayout({ children }: { children: React.ReactNode }) {
const sess = await currentStaff();
if (!sess) redirect("/my/signin");
const [staff, reports] = await Promise.all([
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 } } },
}),
prisma.staff.count({ where: { managerId: sess.staffId, inactive: false } }),
]);
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,
};
return <StaffProvider me={me}>{children}</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>
</>
);
}
+28
View File
@@ -0,0 +1,28 @@
import { MBar, MBody, MEmpty, MRule, MTop } from "@/components/m";
/* What a staff screen shows when there is nothing behind it.
*
* Nine routes under /my call notFound(): a kit check between rounds, the waitlist with nothing
* offered, the ward and desk screens for somebody without that role, an order that isn't theirs.
* Without this file every one of them rendered the WEBSITE's 404 — marketing nav, "Open the demo",
* a footer — inside the app, over the top of the tab bar, with the hardware back button as the
* only way home. Seen on a Pixel 8 Pro on 2026-09-12 by opening Kit check with no check open.
*
* 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.
*/
export default function StaffNotFound() {
return (
<>
<MTop title="Nothing here" back />
<MRule />
<MBody>
<MEmpty
title="Nothing to show right now"
sub="Theres no screen behind that link at the moment — a kit check that isnt open, a list with nothing on it, or something that isnt yours to see. Nothing on your record has changed."
/>
</MBody>
<MBar label="Back to home" href="/my" />
</>
);
}
@@ -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);
// Landing from the Messages tab with nothing open should still show the Open tab and its empty
// state, rather than a list of finished orders nobody asked for. `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"}
/>
);
}
+67
View File
@@ -0,0 +1,67 @@
import { redirect } from "next/navigation";
import { prisma } from "@/lib/db";
import { currentStaff } from "@/lib/staffsession";
import { homeData } from "@/lib/staffdata";
import { fmtDate } from "@/lib/compute";
import { ROUTED_TO_ROUND, dueOnWard } from "@/lib/staffreq";
import HomeScreen from "@/components/screens/Home";
export const dynamic = "force-dynamic";
export default async function MyHome() {
const sess = await currentStaff();
if (!sess) redirect("/my/signin");
const data = await homeData(sess);
// A manager's queue and a clerk's trolley are counts here, not lists: Home answers "is anything
// waiting for me?" and then gets out of the way. Both are skipped entirely for the people the
// flags don't apply to, which is nearly everyone.
const [approvals, reports, roundBags, cycle] = await Promise.all([
prisma.request.count({ where: { managerId: sess.staffId, status: "awaiting" } }),
/* Whether anybody reports to them, which is the same question /my/raise answers with a 404.
* Not the same as having approvals waiting: a manager whose team has asked for nothing this
* month still needs the door, and this is the only way in to it. */
prisma.staff.count({ where: { managerId: sess.staffId, inactive: false } }),
data.wardDesk && data.ward
// The ward the trolley left the bag on, off the timeline — the same fence /my/round and
// round.sign use (see roundWard() in lib/staffreq.ts). Counting on the wearer's current ward
// made this badge disagree with the screen it opens the moment anybody transferred wards
// mid-round: a bag counted here and missing from the round, or the other way about.
? prisma.request.count({
where: {
facilityId: sess.facilityId, status: "round",
events: { some: { label: ROUTED_TO_ROUND, meta: dueOnWard(data.ward) } },
},
})
: Promise.resolve(0),
prisma.kitCheck.findFirst({
where: { facilityId: sess.facilityId, closedAt: null },
orderBy: { openedAt: "desc" },
select: { id: true, dueBy: true },
}),
]);
// Only prompt for a cycle they still owe answers to — someone who finished last week should not
// be nagged for the rest of the month.
let kitCheckDue: string | null = null;
if (cycle) {
const [held, answered] = await Promise.all([
// handedIn as well as returnedDate: a garment handed back at the counter never gets marked
// returned, so counting on returnedDate alone nags somebody for a kit check about uniform
// they gave back months ago. Every equivalent query in lib/ reads both.
prisma.issue.count({ where: { staffId: sess.staffId, returnedDate: null, handedIn: null } }),
prisma.kitCheckAnswer.count({ where: { kitCheckId: cycle.id, staffId: sess.staffId } }),
]);
if (held > 0 && answered === 0) kitCheckDue = fmtDate(cycle.dueBy);
}
return (
<HomeScreen
data={data}
approvals={approvals}
roundBags={roundBags}
kitCheckDue={kitCheckDue}
canRaiseForTeam={reports > 0}
/>
);
}
+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}
/>
);
}
+47
View File
@@ -0,0 +1,47 @@
import { redirect } from "next/navigation";
import { prisma } from "@/lib/db";
import { currentStaff } from "@/lib/staffsession";
import { catalogueData } from "@/lib/staffdata";
import { REQUEST_MAX_LINES, REQUEST_MAX_QTY } from "@/lib/ops";
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 }> }) {
const sess = await currentStaff();
if (!sess) redirect("/my/signin");
const { swap, item, si } = await searchParams;
const [{ items, managerName, holding, allowance }, held] = 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"],
}),
]);
// 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;
return (
<RequestScreen
items={items}
managerName={managerName}
swap={swap === "1"}
heldItemIds={held.map((h) => h.itemId)}
preItemId={pre?.id ?? null}
preSi={preSi}
holding={holding}
allowance={allowance}
// 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} />;
}
+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} />;
}
+16
View File
@@ -0,0 +1,16 @@
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");
const reports = await prisma.staff.count({ where: { managerId: sess.staffId } });
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>
</>
);
}