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 8685140 on 2026-09-13. Licensed under the Functional Source License (FSL-1.1-ALv2).
This commit is contained in:
@@ -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} />;
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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)} />;
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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="There’s no screen behind that link at the moment — a kit check that isn’t open, a list with nothing on it, or something that isn’t 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} />;
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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"}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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’re no longer on the register at this facility, or the person who asked isn’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,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
"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("");
|
||||
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(false);
|
||||
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 didn’t 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 you’ll sign in from now on."
|
||||
: "What you have out, what you’re still owed and what’s 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. */}
|
||||
<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 <MExternalLink href={TERMS_URL}>Terms of use</MExternalLink> and
|
||||
the <MExternalLink href={PRIVACY_URL}>Privacy policy</MExternalLink>.
|
||||
</span>
|
||||
</label>
|
||||
{turnstileOn() && <Turnstile onToken={setCfToken} action={activating ? "staff-activate" : "staff-login"} quiet />}
|
||||
{/* Submitting with the keyboard’s 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user