ThreadCount Community edition
Release / release (push) Has been skipped

Uniform stock management for healthcare linen rooms: the coordinator app, the phone counter and the staff app, for your own server. Built from 794bab5 on 2026-09-16. Licensed under the Functional Source License (FSL-1.1-ALv2).
This commit is contained in:
ThreadCount
2026-09-17 05:49:16 +10:00
commit f72f0626b0
481 changed files with 59411 additions and 0 deletions
+33
View File
@@ -0,0 +1,33 @@
import { redirect } from "next/navigation";
import { prisma } from "@/lib/db";
import { currentStaff } from "@/lib/staffsession";
import { pushConfigured } from "@/lib/push";
import AccountScreen from "@/components/screens/Account";
export const dynamic = "force-dynamic";
export default async function MyAccount() {
const sess = await currentStaff();
if (!sess) redirect("/my/signin");
// No row means every default, which is what the schema says and what the sender assumes. The
// switches are read for this session's own staff member and for nobody else — notify.prefs
// writes the same way, and takes no staffId at all.
const pref = await prisma.staffNotifyPref.findUnique({ where: { staffId: sess.staffId } });
return (
<AccountScreen
email={sess.email}
prefs={{
approved: pref?.approved ?? true,
ready: pref?.ready ?? true,
round: pref?.round ?? true,
kitcheck: pref?.kitcheck ?? true,
waiting: pref?.waiting ?? true,
}}
// Nothing can be sent at all without a key on the server, and the screen says so rather than
// offering switches that would do nothing.
pushReady={pushConfigured()}
/>
);
}
+17
View File
@@ -0,0 +1,17 @@
import { notFound, redirect } from "next/navigation";
import { currentStaff } from "@/lib/staffsession";
import { reviewData } from "@/lib/managerdata";
import ReviewScreen from "@/components/screens/Review";
export const dynamic = "force-dynamic";
export default async function MyReview({ params }: { params: Promise<{ id: string }> }) {
const sess = await currentStaff();
if (!sess) redirect("/my/signin");
const { id } = await params;
// reviewData starts from `managerId: sess.staffId`, so a request addressed to another manager
// is not filtered out afterwards — it is never selected.
const data = await reviewData(sess, id);
if (!data) notFound();
return <ReviewScreen data={data} />;
}
+55
View File
@@ -0,0 +1,55 @@
import { notFound, redirect } from "next/navigation";
import { prisma } from "@/lib/db";
import { currentStaff } from "@/lib/staffsession";
import { approvalQueue } from "@/lib/managerdata";
import ApprovalsScreen from "@/components/screens/Approvals";
export const dynamic = "force-dynamic";
export default async function MyApprovals() {
const sess = await currentStaff();
if (!sess) redirect("/my/signin");
// Whoever a request names decides it, team or no team. A request reaches somebody who manages
// nobody more than one way: the linen room re-addresses one that arrived without an approver
// (request.reassign only checks the person is on the register, not that anybody reports to
// them), or a manager's last report is moved to somebody else while their request is still
// awaiting. This is the same query Home counts for its "waiting on you" banner, and the banner
// is the only door — the staff nav has no approvals tab — so turning these people away left a
// colleague blocked behind a 404 nobody could clear.
const rows = await approvalQueue(sess);
if (!rows.length) {
/* Nothing waiting. Two questions, not one.
*
* Does anybody active report to them — filtered exactly as the layout, teamTabs() and
* wardData() filter it, so the screen and the tab cannot disagree — and has anything ever been
* addressed to them. The second question is what keeps somebody on the screen they are
* standing on: approving from the queue refreshes this page, so an approver with no reports
* who cleared their last request was thrown onto "That page isn't here." by the very tap that
* emptied it. Having once been an approver, an empty queue is a state for them.
*
* Somebody who is neither still gets nothing rather than an empty queue: an empty approvals
* screen implies they might one day have a team, which is a question for the linen room and
* not something this app should imply an answer to. */
const [reports, everAddressed] = await Promise.all([
prisma.staff.count({ where: { facilityId: sess.facilityId, managerId: sess.staffId, inactive: false } }),
prisma.request.count({ where: { facilityId: sess.facilityId, managerId: sess.staffId } }),
]);
if (!reports && !everAddressed) notFound();
}
// Which of the waiting requests are for the manager themselves. Some of these now are — the
// rule against approving your own uniform has been relaxed for the case the owner named — and
// the screen sets those apart so nobody approves their own by accident and works out later that
// they did. Whether a self-approval is allowed at all is the server's call and is not re-tested
// here; this only asks the database which of the rows it already let through are the reader's
// own, by the request's subject, which is the same fact the record is written from. The rows are
// scoped to this manager and to `awaiting` already, so the lookup is over a handful of ids.
const ownIds = rows.length
? (
await prisma.request.findMany({
where: { id: { in: rows.map((r) => r.id) }, subjectId: sess.staffId },
select: { id: true },
})
).map((r) => r.id)
: [];
return <ApprovalsScreen rows={rows} ownIds={ownIds} />;
}
+19
View File
@@ -0,0 +1,19 @@
import { redirect } from "next/navigation";
import { currentStaff } from "@/lib/staffsession";
import { catalogueData, damageData, notifyWays } from "@/lib/staffdata";
import DamageScreen from "@/components/screens/Damage";
export const dynamic = "force-dynamic";
export default async function MyDamage() {
const sess = await currentStaff();
if (!sess) redirect("/my/signin");
const [{ holdings }, { managerName }, ways] = await Promise.all([
damageData(sess),
catalogueData(sess),
// The replacement half ends on the same Sent screen a request does, and that screen's one line
// says how this person will actually hear — a phone, an email, or neither.
notifyWays(sess),
]);
return <DamageScreen holdings={holdings} managerName={managerName} notifyWays={ways} />;
}
+12
View File
@@ -0,0 +1,12 @@
import { redirect } from "next/navigation";
import { currentStaff } from "@/lib/staffsession";
import { kitData } from "@/lib/staffdata";
import KitScreen from "@/components/screens/Kit";
export const dynamic = "force-dynamic";
export default async function MyKit() {
const sess = await currentStaff();
if (!sess) redirect("/my/signin");
return <KitScreen data={await kitData(sess)} />;
}
+18
View File
@@ -0,0 +1,18 @@
import { redirect } from "next/navigation";
import { currentStaff } from "@/lib/staffsession";
import { kitCheckData } from "@/lib/cycledata";
import KitCheckScreen from "@/components/screens/KitCheck";
export const dynamic = "force-dynamic";
export default async function MyKitCheck() {
const sess = await currentStaff();
if (!sess) redirect("/my/signin");
const data = await kitCheckData(sess);
/* Between rounds this is a state, not a refusal. It used to be a 404, which told somebody who
* had tapped a notification from the last round that the page did not exist — a refusal is for a
* record that is not yours, and "no round is open right now" is neither secret nor their mistake.
* The screen says so and offers the way back. */
if (!data) return <KitCheckScreen closed />;
return <KitCheckScreen dueBy={data.dueBy} lastConfirmed={data.lastConfirmed} rows={data.rows} />;
}
+108
View File
@@ -0,0 +1,108 @@
import { redirect } from "next/navigation";
import { prisma } from "@/lib/db";
import { addDays, facilityDate, facilityToday } from "@/lib/compute";
import { currentStaff } from "@/lib/staffsession";
import { ROUTED_TO_ROUND, dueOnWard } from "@/lib/staffreq";
import { DECLINE_HEADLINE_DAYS } from "@/lib/staffdata";
import { StaffProvider, type StaffCounts, type StaffMe } from "@/lib/staffclient";
import { OfflineBar } from "@/components/staffui";
import { MToastProvider } from "@/components/m";
export const dynamic = "force-dynamic";
/* Everything that needs a signed-in staff member.
*
* The role flags are resolved here, once, from the database rather than trusted from the client:
* "am I a manager" is the answer to "does anybody name me as theirs", and a screen that asked the
* browser that question would be asking the wrong party.
*
* The two badge counts are resolved here too, for the same reason and one more: they are read by
* the tab bar, which is drawn on five screens, and a count per screen is five queries and five
* chances to disagree with the list it opens.
*/
export default async function StaffAppLayout({ children }: { children: React.ReactNode }) {
const sess = await currentStaff();
if (!sess) redirect("/my/signin");
const staff = await prisma.staff.findUniqueOrThrow({
where: { id: sess.staffId },
select: { first: true, last: true, num: true, dept: true, wardDesk: true, managerId: true, facility: { select: { name: true, timezone: true } } },
});
const today = facilityToday(staff.facility.timezone);
const declinedFrom = addDays(today, -DECLINE_HEADLINE_DAYS);
const [reports, needing, declined, approvals, roundBags] = await Promise.all([
prisma.staff.count({ where: { managerId: sess.staffId, inactive: false } }),
/* The Orders badge: their own requests that need them.
*
* ⛔ Deliberately NOT `NEEDS_STAFF` from lib/staffreq.ts, which also contains `declined`.
* `declined` is terminal — nothing the person does clears it — so a badge built on that set
* never goes back to nothing: one refusal in March and the tab wears a number for ever, with
* no way to put it down. NEEDS_STAFF keeps its own job (the accent edge on a row, where a
* decline genuinely is something to look at); it is the wrong set for a badge. */
prisma.request.count({ where: { subjectId: sess.staffId, status: { in: ["awaiting", "ready", "round"] } } }),
// A decline is news for a week, exactly as long as Home's live card keeps it — the same window
// from the same constant, so the badge and the card cannot tell two stories.
prisma.request.findMany({
where: { subjectId: sess.staffId, status: "declined" },
orderBy: { decidedAt: "desc" }, take: 10,
select: { decidedAt: true, createdAt: true },
}),
/* The Team badge, half one: requests ADDRESSED to this person, whether or not anybody reports
* to them. The same predicate approvalQueue() uses, so the badge and the list cannot drift.
*
* ⛔ Never gated on "is a manager". A request reaches somebody who manages nobody two ordinary
* ways — the linen room re-addresses one that arrived without an approver, or a manager's last
* report moves away while their request is still awaiting — and gating here would take away
* their badge, their tab and their banner at once, leaving a colleague blocked behind a screen
* nobody can reach. app/my/(app)/approvals/page.tsx carries the same note. */
prisma.request.count({ where: { managerId: sess.staffId, status: "awaiting" } }),
// Half two, for a ward desk: the bags sitting on their ward waiting to be signed for. The ward
// the trolley left them on, off the timeline — the same fence /my/round and round.sign use.
staff.wardDesk && staff.dept
? prisma.request.count({
where: {
facilityId: sess.facilityId, status: "round",
events: { some: { label: ROUTED_TO_ROUND, meta: dueOnWard(staff.dept) } },
},
})
: Promise.resolve(0),
]);
const freshDeclines = declined.filter((r) => facilityDate(r.decidedAt ?? r.createdAt, staff.facility.timezone) >= declinedFrom).length;
const me: StaffMe = {
staffId: sess.staffId,
name: `${staff.first} ${staff.last}`.trim(),
first: staff.first,
num: staff.num,
ward: staff.dept,
facility: staff.facility.name,
// Resolved here for the same reason the role flags are: it is the facility's answer, not the
// phone's, and a device set to the wrong zone must not change what a ward round is told.
tz: staff.facility.timezone,
isManager: reports > 0,
wardDesk: staff.wardDesk,
hasManager: !!staff.managerId,
};
const counts: StaffCounts = {
orders: needing + freshDeclines,
team: approvals + roundBags,
approvals,
round: roundBags,
};
return (
<StaffProvider me={me} counts={counts}>
{/* Drawn once, above every screen's own app bar. The mockup puts it under the bar; doing
that would mean threading it through sixteen screens that each compose their own. */}
<OfflineBar />
{/* The toast host. `useToast()` falls back to a no-op with no provider above it, which is
what /my had: a disabled 64px bar carries `offReason` — "Pick an item, a size and a
reason" — and tapping it said nothing at all. The counter app mounts the same provider
for the same purpose; this is a read-only use of components/m.tsx. */}
<MToastProvider>{children}</MToastProvider>
</StaffProvider>
);
}
+55
View File
@@ -0,0 +1,55 @@
/* What a tap looks like before the server answers.
*
* Every screen under /my is `force-dynamic` and rendered from its own database query, and App
* Router keeps the previous screen fully painted until that query comes back. On ward wifi that is
* two or three seconds in which nothing at all acknowledges the tap — so people tap again, and the
* app reads as frozen. This is the route-level fallback the framework wants for exactly that: it
* replaces the body the moment a navigation starts, keeping the app's own chrome so the change
* reads as "loading" rather than "gone".
*
* Deliberately not the tab bar: the nav belongs to the four screens that draw it, and painting one
* here would make it flash into existence on the way to a detail screen that has none. The top bar
* has no title for the same reason — this fallback covers every route in the group, and inventing a
* title would mean printing the wrong one somewhere.
*/
const INK = "#201e1d";
const GROUND = "#f3f2f2";
/** A grey block standing in for a line of text. Sized in the same 2px system as everything else. */
function Bar({ w, h = 16 }: { w: string; h?: number }) {
return <div style={{ width: w, height: h, background: "var(--color-neutral-200)" }} />;
}
export default function StaffLoading() {
return (
<>
<header className="tcx-topbar" style={{
height: 56, flex: "0 0 56px", background: INK, color: GROUND, display: "flex", alignItems: "center",
paddingLeft: 16, paddingRight: 16,
backgroundImage: "linear-gradient(to bottom, rgba(243,242,242,0.16) 0 1px, transparent 1px)",
backgroundPosition: "0 env(safe-area-inset-top, 0px)", backgroundRepeat: "no-repeat", backgroundSize: "100% 1px",
}}>
<span style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 15, letterSpacing: "0.06em", textTransform: "uppercase" }}>
One moment
</span>
</header>
<div style={{ height: 4, flex: "0 0 4px", background: "var(--color-accent)" }} />
<div style={{ flex: 1, overflow: "hidden", background: GROUND }} aria-busy="true">
{/* Announced once, quietly. The blocks below are decoration and say nothing. */}
<div role="status" style={{ padding: "20px 16px 0", fontSize: 13, color: "var(--color-neutral-600)" }}>Loading</div>
<div style={{ padding: "16px 16px 0", display: "grid", gap: 10 }} aria-hidden="true">
<Bar w="60%" h={22} />
<Bar w="40%" />
</div>
<div style={{ marginTop: 24, display: "grid", gap: 2 }} aria-hidden="true">
{[0, 1, 2, 3].map((i) => (
<div key={i} style={{ background: "#fff", padding: "18px 16px", display: "grid", gap: 8 }}>
<Bar w="55%" h={18} />
<Bar w="35%" h={12} />
</div>
))}
</div>
</div>
</>
);
}
+15
View File
@@ -0,0 +1,15 @@
import { redirect } from "next/navigation";
import { currentStaff } from "@/lib/staffsession";
import { messagesData } from "@/lib/staffdata";
import MessagesScreen from "@/components/screens/Messages";
export const dynamic = "force-dynamic";
/** The screen behind the Messages tab. Empty is a state, not a refusal — see the (b4) table: there
* is nothing private about having said nothing yet, and the screen can say where a thread starts. */
export default async function MyMessages() {
const sess = await currentStaff();
if (!sess) redirect("/my/signin");
const { threads, startable } = await messagesData(sess);
return <MessagesScreen threads={threads} startable={startable} />;
}
+29
View File
@@ -0,0 +1,29 @@
import { MBar, MBody, MEmpty, MRule, MTop } from "@/components/m";
/* What a staff screen shows when a link leads nowhere it is allowed to go.
*
* Two different things are told apart on purpose, and only one of them lands here. A REFUSAL — the
* record is not yours, the role is not yours — is this page, and it explains nothing: a refusal
* that gave its reason would confirm the thing exists and say something about somebody else's
* record. "Nothing to show" — a kit check between rounds, an empty queue, a day with no bags — is
* the screen itself with a short state on it and a way out, which is where those cases now go.
*
* Next renders the nearest not-found.tsx, so this one stays inside the signed-in layout: same
* chrome, same provider, and a bar that goes home rather than the website's 404 with its marketing
* nav landing on top of the tab bar (seen on a Pixel 8 Pro, 2026-09-12).
*
* The words are load-bearing: scripts/e2e-staffapp.sh greps every refusal for "That page isn" and
* then checks that none of the refused screen's own words came with it.
*/
export default function StaffNotFound() {
return (
<>
<MTop title="Not here" back />
<MRule />
<MBody>
<MEmpty title="That page isnt here." />
</MBody>
<MBar label="Back to home" href="/my" />
</>
);
}
+28
View File
@@ -0,0 +1,28 @@
import { notFound, redirect } from "next/navigation";
import { currentStaff } from "@/lib/staffsession";
import { bagLines, requestData } from "@/lib/staffdata";
import CodeFullScreen from "@/components/screens/CodeFull";
export const dynamic = "force-dynamic";
/* The full-screen collection code.
*
* Fenced more narrowly than the order it belongs to, on purpose. requestData() lets the wearer's
* manager, the clerk who raised it and the ward desk read an order — none of them collect the bag,
* and the code is what a bag is handed over against, so this screen is the wearer's alone. A
* request that is no longer `ready` has no code to show either: it has been collected, or it never
* reached the counter. Both are refusals rather than an empty screen, for the reason every refusal
* under /my is one — explaining would confirm what exists.
*/
export default async function MyOrderCode({ params }: { params: Promise<{ id: string }> }) {
const sess = await currentStaff();
if (!sess) redirect("/my/signin");
const { id } = await params;
const data = await requestData(sess, id);
if (!data) notFound();
if (!data.mine || data.status !== "ready" || !data.collectCode) notFound();
// What is actually in the bag: a declined line is not in it, and naming it at the counter starts
// an argument with a clerk who cannot settle it.
const lines = bagLines(data.lines).map((l) => ({ item: l.item, size: l.size, qty: l.qty }));
return <CodeFullScreen id={data.id} code={data.collectCode} name={data.subjectName} lines={lines} />;
}
@@ -0,0 +1,15 @@
import { notFound, redirect } from "next/navigation";
import { currentStaff } from "@/lib/staffsession";
import { requestData } from "@/lib/staffdata";
import ThreadScreen from "@/components/screens/Thread";
export const dynamic = "force-dynamic";
export default async function MyOrderThread({ params }: { params: Promise<{ id: string }> }) {
const sess = await currentStaff();
if (!sess) redirect("/my/signin");
const { id } = await params;
const data = await requestData(sess, id);
if (!data) notFound();
return <ThreadScreen data={data} />;
}
+17
View File
@@ -0,0 +1,17 @@
import { notFound, redirect } from "next/navigation";
import { currentStaff } from "@/lib/staffsession";
import { requestData } from "@/lib/staffdata";
import OrderScreen from "@/components/screens/Order";
export const dynamic = "force-dynamic";
export default async function MyOrder({ params }: { params: Promise<{ id: string }> }) {
const sess = await currentStaff();
if (!sess) redirect("/my/signin");
const { id } = await params;
const data = await requestData(sess, id);
// A request that isn't theirs, their team's, or one they raised is a 404 rather than a 403 —
// "you may not see this" confirms it exists.
if (!data) notFound();
return <OrderScreen data={data} />;
}
+24
View File
@@ -0,0 +1,24 @@
import { redirect } from "next/navigation";
import { currentStaff } from "@/lib/staffsession";
import { ordersData } from "@/lib/staffdata";
import OrdersScreen from "@/components/screens/Orders";
export const dynamic = "force-dynamic";
export default async function MyOrders({ searchParams }: { searchParams: Promise<{ tab?: string }> }) {
const sess = await currentStaff();
if (!sess) redirect("/my/signin");
const { tab } = await searchParams;
const { open, done, raised } = await ordersData(sess);
// `?tab=` is honoured because other screens link straight to a view; anything else lands on Open,
// which is the question this screen answers. `raised` is what this person typed in for somebody
// else — the desk's whole day, and now a manager's too.
return (
<OrdersScreen
open={open}
done={done}
raised={raised}
initialTab={tab === "done" ? "done" : tab === "raised" ? "raised" : "open"}
/>
);
}
+86
View File
@@ -0,0 +1,86 @@
import { redirect } from "next/navigation";
import { prisma } from "@/lib/db";
import { currentStaff } from "@/lib/staffsession";
import { fmtDate, homeData } from "@/lib/staffdata";
import HomeScreen from "@/components/screens/Home";
export const dynamic = "force-dynamic";
/* Home asks two questions and then gets out of the way: what is on the way, and what you hold.
*
* The badges are NOT re-counted here. The (app) layout resolves them once for the tab bar, and the
* banner at the top of this screen reads the same numbers off the context — a count per screen is
* a second query and a second chance to disagree with the list it opens.
*/
export default async function MyHome() {
const sess = await currentStaff();
if (!sess) redirect("/my/signin");
const data = await homeData(sess);
const [staff, issues, cycle, lastItem] = await Promise.all([
prisma.staff.findUniqueOrThrow({ where: { id: sess.staffId }, select: { managerId: true } }),
/* What they hold, grouped the way kitData() groups it — by garment and size, which is how
* somebody thinks about their own uniform rather than as a list of issuing events.
*
* handedIn as well as returnedDate: a garment handed back at the counter never gets marked
* returned, so fencing on returnedDate alone leaves it on the record for good. Every
* equivalent query in lib/ reads both.
*
* ⛔ `cost` sits on this row and is never selected. No money reaches a wearer's screen. */
prisma.issue.findMany({
where: { staffId: sess.staffId, returnedDate: null, handedIn: null },
orderBy: { date: "desc" },
select: { date: true, qty: true, sizeIndex: true, item: { select: { id: true, item: true, sizes: true } } },
}),
prisma.kitCheck.findFirst({
where: { facilityId: sess.facilityId, closedAt: null },
orderBy: { openedAt: "desc" },
select: { id: true },
}),
// The garment behind "Same again", for the tile's caption. The request screen re-checks it
// against the catalogue before filling anything in; this is only the name to put on the tile.
data.lastRequest
? prisma.catalogItem.findUnique({ where: { id: data.lastRequest.itemId }, select: { item: true } })
: Promise.resolve(null),
]);
// Who the ask would go to, by name. The empty state says it plainly, because "your manager" is
// no help to somebody who has never been told who that is.
const manager = staff.managerId
? await prisma.staff.findUnique({ where: { id: staff.managerId }, select: { first: true, last: true } })
: null;
const held = new Map<string, { item: string; size: string; qty: number; last: string }>();
for (const i of issues) {
const k = `${i.item.id}:${i.sizeIndex}`;
const cur = held.get(k) || { item: i.item.item, size: String(i.item.sizes[i.sizeIndex] ?? i.sizeIndex), qty: 0, last: "" };
cur.qty += i.qty;
if (i.date > cur.last) cur.last = i.date;
held.set(k, cur);
}
// Most recently issued first, which is the order the wearer last saw them in. Only three are
// drawn: the section note carries the total, and My kit is where the whole record lives.
const holdRows = [...held.values()]
.sort((a, b) => b.last.localeCompare(a.last) || a.item.localeCompare(b.item))
.slice(0, 3)
.map((h) => ({ item: h.item, size: h.size, qty: h.qty, last: fmtDate(h.last) }));
/* Only prompt for a cycle they still owe an answer to — somebody who finished last week should
* not be nagged for the rest of the month, and somebody holding nothing has nothing to check. */
let kitCheckOpen = false;
if (cycle) {
const answered = await prisma.kitCheckAnswer.count({ where: { kitCheckId: cycle.id, staffId: sess.staffId } });
kitCheckOpen = issues.length > 0 && answered === 0;
}
return (
<HomeScreen
data={data}
held={holdRows}
lastItem={lastItem?.item || null}
managerName={manager ? `${manager.first} ${manager.last}`.trim() : ""}
kitCheckOpen={kitCheckOpen}
/>
);
}
+44
View File
@@ -0,0 +1,44 @@
import { notFound, redirect } from "next/navigation";
import { currentStaff } from "@/lib/staffsession";
import { deskCatalogue, teamPeople } from "@/lib/deskdata";
import { ordersData } from "@/lib/staffdata";
import { REQUEST_MAX_LINES, REQUEST_MAX_QTY } from "@/lib/ops";
import DeskScreen from "@/components/screens/Desk";
export const dynamic = "force-dynamic";
/* A manager raising for one of their own reports — the only way one person types a request in
* somebody else's name in this app. A ward clerk on the desk used to have a screen of its own for
* anyone on their ward; that is gone, and the person who would have asked the clerk asks the
* manager who approves it anyway.
*
* The scope here is the same relationship the server enforces on `request.create`: the people who
* name this person as their manager. Nothing on this page decides who may be raised for — it asks
* teamPeople() for exactly the set the op would accept, and a request for anybody else is refused
* there.
*/
export default async function MyRaise() {
const sess = await currentStaff();
if (!sess) redirect("/my/signin");
const [people, items, orders] = await Promise.all([
teamPeople(sess),
deskCatalogue(sess),
ordersData(sess),
]);
// Nobody reporting to them means no screen, the same way the approvals queue works: an empty one
// implies they might one day have a team, which is a question for the linen room.
if (!people.length) notFound();
return (
<DeskScreen
// Every one of them names this manager, which is exactly why the request cannot stay with
// them: the screen says whose name is on it, and the server sends it up a level.
people={people}
items={items}
raised={orders.raised.open}
maxLines={REQUEST_MAX_LINES}
maxQty={REQUEST_MAX_QTY}
/>
);
}
+71
View File
@@ -0,0 +1,71 @@
import { redirect } from "next/navigation";
import { prisma } from "@/lib/db";
import { currentStaff } from "@/lib/staffsession";
import { catalogueData, homeData, notifyWays } from "@/lib/staffdata";
import { REQUEST_MAX_LINES, REQUEST_MAX_QTY } from "@/lib/ops";
import { REQUEST_REASONS } from "@/lib/staffreq";
import RequestScreen from "@/components/screens/Request";
export const dynamic = "force-dynamic";
export default async function MyRequest({ searchParams }: {
searchParams: Promise<{ swap?: string; item?: string; si?: string; again?: string }>;
}) {
const sess = await currentStaff();
if (!sess) redirect("/my/signin");
const { swap, item, si, again } = await searchParams;
const [{ items, managerName, holding, allowance }, held, ways, last] = await Promise.all([
catalogueData(sess),
// handedIn as well as returnedDate, for the same reason the kit check reads both: a garment
// handed back at the counter is off the person without ever being marked returned, and the
// swap flow would otherwise offer to exchange something they no longer hold.
prisma.issue.findMany({
where: { staffId: sess.staffId, returnedDate: null, handedIn: null },
select: { itemId: true }, distinct: ["itemId"],
}),
// What this server can really do, so the line after a send says what is true here rather than
// what is usually true elsewhere.
notifyWays(sess),
// Same again: the garment, size and reason of their most recent ask. Read only when asked for.
again === "1" ? homeData(sess).then((h) => h.lastRequest) : Promise.resolve(null),
]);
// Only honour a pre-selection that actually exists in this facility's catalogue — the ids come
// off a query string.
const pre = items.find((i) => i.id === item) || null;
const preSi = pre ? pre.sizes.find((s) => s.si === parseInt(String(si ?? ""), 10))?.si ?? null : null;
/* A last request can name a garment this person may no longer ask for. catalogueData() filters by
* staff group and uniform cut, either of which can have changed since, and the garment can have
* been archived or lost that size. So the pre-fill is re-checked against the list this screen is
* actually offering, and anything stale opens the screen empty with no line claiming it was
* filled in: filling in something the server will then refuse is worse than filling in nothing. */
const againItem = last ? items.find((i) => i.id === last.itemId) || null : null;
const againSize = againItem && last ? againItem.sizes.find((s) => s.si === last.si) || null : null;
const fresh = againItem && againSize ? { item: againItem, size: againSize, code: last!.code, reason: last!.reason } : null;
const chosen = pre && preSi !== null ? { id: pre.id, si: preSi } : fresh ? { id: fresh.item.id, si: fresh.size.si } : null;
return (
<RequestScreen
items={items}
managerName={managerName}
swap={swap === "1"}
heldItemIds={held.map((h) => h.itemId)}
preItemId={chosen?.id ?? null}
preSi={chosen?.si ?? null}
// A reason is only carried over from Same again, and only one of the four this screen offers:
// the stored string is whatever the reason list said when that request was raised.
preReason={!pre && fresh && REQUEST_REASONS.includes(fresh.reason as never) ? fresh.reason : null}
filledFrom={!pre && fresh ? fresh.code : null}
holding={holding}
allowance={allowance}
notifyWays={ways}
// The ceilings are the server's, handed down rather than restated in the browser: a screen
// that let somebody build an eleventh line would only be showing them a refusal.
maxLines={REQUEST_MAX_LINES}
maxQty={REQUEST_MAX_QTY}
/>
);
}
+32
View File
@@ -0,0 +1,32 @@
import { notFound, redirect } from "next/navigation";
import { currentStaff } from "@/lib/staffsession";
import { roundData } from "@/lib/deskdata";
import { ordersData } from "@/lib/staffdata";
import RoundScreen from "@/components/screens/Round";
export const dynamic = "force-dynamic";
export default async function MyRound() {
const sess = await currentStaff();
if (!sess) redirect("/my/signin");
const data = await roundData(sess);
if (!data) notFound();
/* What this person raised for other people and hasn't seen the end of — as a manager for one
* of their team, or, on an older request, from the desk route that no longer exists.
*
* The three lists above it are only ever about bags arriving today, so a request typed in on
* Tuesday and approved on Thursday appears on no screen the raiser can reach until it turns up
* on a trolley — which is why they rang the linen room to ask. It is deliberately the open ones
* only: the round screen is a day's work, not an archive, and everything that has finished is
* under Raised in Orders. */
const { raised } = await ordersData(sess);
return (
<RoundScreen
ward={data.ward}
toSign={data.toSign}
unclaimed={data.unclaimed}
signedToday={data.signedToday}
raised={raised.open}
/>
);
}
+13
View File
@@ -0,0 +1,13 @@
import { redirect } from "next/navigation";
import { currentStaff } from "@/lib/staffsession";
import { catalogueData } from "@/lib/staffdata";
import ShelfScreen from "@/components/screens/Shelf";
export const dynamic = "force-dynamic";
export default async function MyShelf() {
const sess = await currentStaff();
if (!sess) redirect("/my/signin");
const { items } = await catalogueData(sess);
return <ShelfScreen items={items} />;
}
+55
View File
@@ -0,0 +1,55 @@
import { notFound, redirect } from "next/navigation";
import { prisma } from "@/lib/db";
import { currentStaff } from "@/lib/staffsession";
import { ROUTED_TO_ROUND, dueOnWard, teamTabs } from "@/lib/staffreq";
export const dynamic = "force-dynamic";
/* The Team tab's own address. It has no screen: it works out which tab this reader has and sends
* them to the first one, so the tab bar can point at one URL for a manager, a ward desk, and
* somebody who is both.
*
* The three facts are read here rather than passed from the client for the same reason the layout
* reads them: "am I a manager" is the answer to "does anybody name me as theirs", and the browser
* is the wrong party to ask. They are the same three the layout counts, through the same
* teamTabs() the shell draws its tabs with — a tab that appeared here and was refused by its own
* route would be worse than no tab at all.
*
* A refusal, not an explanation, for somebody who is none of the three: a dead end that says why
* confirms the screen exists.
*/
export default async function MyTeam() {
const sess = await currentStaff();
if (!sess) redirect("/my/signin");
const staff = await prisma.staff.findUniqueOrThrow({
where: { id: sess.staffId },
select: { dept: true, wardDesk: true },
});
const [reports, approvals, roundBags] = await Promise.all([
prisma.staff.count({ where: { managerId: sess.staffId, inactive: false } }),
// Requests ADDRESSED to them, whether or not anybody reports to them: the linen room can
// re-address one to somebody who manages nobody, and that person has a queue and so has a tab.
// Same predicate as approvalQueue() and as the layout's badge.
prisma.request.count({ where: { managerId: sess.staffId, status: "awaiting" } }),
// A blank ward is not a ward — the round and round.sign are fenced the same way.
staff.wardDesk && staff.dept
? prisma.request.count({
where: {
facilityId: sess.facilityId, status: "round",
events: { some: { label: ROUTED_TO_ROUND, meta: dueOnWard(staff.dept) } },
},
})
: Promise.resolve(0),
]);
const tabs = teamTabs(
// The ward travels with the desk flag, because the round is one ward's bags and a blank ward is
// not a ward — the same fence roundData() and round.sign apply.
{ isManager: reports > 0, wardDesk: staff.wardDesk, ward: staff.dept },
{ approvals, round: roundBags },
);
if (!tabs.length) notFound();
redirect(tabs[0].href);
}
+15
View File
@@ -0,0 +1,15 @@
import { notFound, redirect } from "next/navigation";
import { currentStaff } from "@/lib/staffsession";
import { waitlistData } from "@/lib/cycledata";
import WaitlistScreen from "@/components/screens/Waitlist";
export const dynamic = "force-dynamic";
export default async function MyWaitlist({ searchParams }: { searchParams: Promise<{ item?: string; si?: string }> }) {
const sess = await currentStaff();
if (!sess) redirect("/my/signin");
const { item, si } = await searchParams;
const data = await waitlistData(sess, String(item || ""), parseInt(String(si ?? "-1"), 10));
if (!data) notFound();
return <WaitlistScreen data={data} />;
}
+25
View File
@@ -0,0 +1,25 @@
import { notFound, redirect } from "next/navigation";
import { prisma } from "@/lib/db";
import { currentStaff } from "@/lib/staffsession";
import { wardData } from "@/lib/managerdata";
import WardScreen from "@/components/screens/Ward";
export const dynamic = "force-dynamic";
export default async function MyWard() {
const sess = await currentStaff();
if (!sess) redirect("/my/signin");
/* The same fact, read the same way it is read everywhere else: active reports, in this facility.
*
* Counting deactivated people let this page render for somebody the rest of the app had decided
* manages nobody — the layout's `isManager`, teamTabs(), wardData() and reportsOf() all filter
* them out — so the Team shell drew itself with no tabs above an empty roster while the Team
* item was missing from the bar. Two answers to "does this person manage anybody" inside one
* screen is the defect; this is the answer the others give. */
const reports = await prisma.staff.count({
where: { facilityId: sess.facilityId, managerId: sess.staffId, inactive: false },
});
if (!reports) notFound();
const { ward, rows, anyCapped } = await wardData(sess);
return <WardScreen ward={ward} rows={rows} anyCapped={anyCapped} />;
}