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:
@@ -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()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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,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} />;
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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,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} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,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} />;
|
||||
}
|
||||
@@ -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 isn’t here." />
|
||||
</MBody>
|
||||
<MBar label="Back to home" href="/my" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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);
|
||||
// `?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"}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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,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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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,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);
|
||||
}
|
||||
@@ -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,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} />;
|
||||
}
|
||||
@@ -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,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 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. */}
|
||||
{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 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