ThreadCount Community edition
Uniform stock management for healthcare linen rooms: the coordinator app, the phone counter and the staff app, for your own server. Built from 2d04e45 on 2026-09-15. Licensed under the Functional Source License (FSL-1.1-ALv2).
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { currentStaff } from "@/lib/staffsession";
|
||||
import AccountScreen from "@/components/screens/Account";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function MyAccount() {
|
||||
const sess = await currentStaff();
|
||||
if (!sess) redirect("/my/signin");
|
||||
return <AccountScreen email={sess.email} />;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { currentStaff } from "@/lib/staffsession";
|
||||
import { reviewData } from "@/lib/managerdata";
|
||||
import ReviewScreen from "@/components/screens/Review";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function MyReview({ params }: { params: Promise<{ id: string }> }) {
|
||||
const sess = await currentStaff();
|
||||
if (!sess) redirect("/my/signin");
|
||||
const { id } = await params;
|
||||
// reviewData starts from `managerId: sess.staffId`, so a request addressed to another manager
|
||||
// is not filtered out afterwards — it is never selected.
|
||||
const data = await reviewData(sess, id);
|
||||
if (!data) notFound();
|
||||
return <ReviewScreen data={data} />;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { currentStaff } from "@/lib/staffsession";
|
||||
import { approvalQueue } from "@/lib/managerdata";
|
||||
import ApprovalsScreen from "@/components/screens/Approvals";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function MyApprovals() {
|
||||
const sess = await currentStaff();
|
||||
if (!sess) redirect("/my/signin");
|
||||
// Whoever a request names decides it, team or no team. A request reaches somebody who manages
|
||||
// nobody more than one way: the linen room re-addresses one that arrived without an approver
|
||||
// (request.reassign only checks the person is on the register, not that anybody reports to
|
||||
// them), or a manager's last report is moved to somebody else while their request is still
|
||||
// awaiting. This is the same query Home counts for its "waiting on you" banner, and the banner
|
||||
// is the only door — the staff nav has no approvals tab — so turning these people away left a
|
||||
// colleague blocked behind a 404 nobody could clear.
|
||||
const rows = await approvalQueue(sess);
|
||||
if (!rows.length) {
|
||||
// Nothing waiting and nobody reporting to them gets nothing, not an empty queue: an empty
|
||||
// approvals screen implies they might one day have a team, which is a question for the linen
|
||||
// room and not something this app should imply an answer to.
|
||||
const reports = await prisma.staff.count({ where: { managerId: sess.staffId } });
|
||||
if (!reports) notFound();
|
||||
}
|
||||
// Which of the waiting requests are for the manager themselves. Some of these now are — the
|
||||
// rule against approving your own uniform has been relaxed for the case the owner named — and
|
||||
// the screen sets those apart so nobody approves their own by accident and works out later that
|
||||
// they did. Whether a self-approval is allowed at all is the server's call and is not re-tested
|
||||
// here; this only asks the database which of the rows it already let through are the reader's
|
||||
// own, by the request's subject, which is the same fact the record is written from. The rows are
|
||||
// scoped to this manager and to `awaiting` already, so the lookup is over a handful of ids.
|
||||
const ownIds = rows.length
|
||||
? (
|
||||
await prisma.request.findMany({
|
||||
where: { id: { in: rows.map((r) => r.id) }, subjectId: sess.staffId },
|
||||
select: { id: true },
|
||||
})
|
||||
).map((r) => r.id)
|
||||
: [];
|
||||
return <ApprovalsScreen rows={rows} ownIds={ownIds} />;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { currentStaff } from "@/lib/staffsession";
|
||||
import { catalogueData, damageData } from "@/lib/staffdata";
|
||||
import DamageScreen from "@/components/screens/Damage";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function MyDamage() {
|
||||
const sess = await currentStaff();
|
||||
if (!sess) redirect("/my/signin");
|
||||
const [{ holdings }, { managerName }] = await Promise.all([damageData(sess), catalogueData(sess)]);
|
||||
return <DamageScreen holdings={holdings} managerName={managerName} />;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { currentStaff } from "@/lib/staffsession";
|
||||
import { kitData } from "@/lib/staffdata";
|
||||
import KitScreen from "@/components/screens/Kit";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function MyKit() {
|
||||
const sess = await currentStaff();
|
||||
if (!sess) redirect("/my/signin");
|
||||
return <KitScreen data={await kitData(sess)} />;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { currentStaff } from "@/lib/staffsession";
|
||||
import { kitCheckData } from "@/lib/cycledata";
|
||||
import KitCheckScreen from "@/components/screens/KitCheck";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function MyKitCheck() {
|
||||
const sess = await currentStaff();
|
||||
if (!sess) redirect("/my/signin");
|
||||
const data = await kitCheckData(sess);
|
||||
// Between rounds there is no screen. A kit check that was always reachable would be answered at
|
||||
// random times and the cycle's numbers would mean nothing.
|
||||
if (!data) notFound();
|
||||
return <KitCheckScreen dueBy={data.dueBy} lastConfirmed={data.lastConfirmed} rows={data.rows} />;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { currentStaff } from "@/lib/staffsession";
|
||||
import { StaffProvider, type StaffMe } from "@/lib/staffclient";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/* Everything that needs a signed-in staff member.
|
||||
*
|
||||
* The role flags are resolved here, once, from the database rather than trusted from the client:
|
||||
* "am I a manager" is the answer to "does anybody name me as theirs", and a screen that asked the
|
||||
* browser that question would be asking the wrong party.
|
||||
*/
|
||||
export default async function StaffAppLayout({ children }: { children: React.ReactNode }) {
|
||||
const sess = await currentStaff();
|
||||
if (!sess) redirect("/my/signin");
|
||||
|
||||
const [staff, reports] = await Promise.all([
|
||||
prisma.staff.findUniqueOrThrow({
|
||||
where: { id: sess.staffId },
|
||||
select: { first: true, last: true, num: true, dept: true, wardDesk: true, managerId: true, facility: { select: { name: true, timezone: true } } },
|
||||
}),
|
||||
prisma.staff.count({ where: { managerId: sess.staffId, inactive: false } }),
|
||||
]);
|
||||
|
||||
const me: StaffMe = {
|
||||
staffId: sess.staffId,
|
||||
name: `${staff.first} ${staff.last}`.trim(),
|
||||
first: staff.first,
|
||||
num: staff.num,
|
||||
ward: staff.dept,
|
||||
facility: staff.facility.name,
|
||||
// Resolved here for the same reason the role flags are: it is the facility's answer, not the
|
||||
// phone's, and a device set to the wrong zone must not change what a ward round is told.
|
||||
tz: staff.facility.timezone,
|
||||
isManager: reports > 0,
|
||||
wardDesk: staff.wardDesk,
|
||||
hasManager: !!staff.managerId,
|
||||
};
|
||||
|
||||
return <StaffProvider me={me}>{children}</StaffProvider>;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/* What a tap looks like before the server answers.
|
||||
*
|
||||
* Every screen under /my is `force-dynamic` and rendered from its own database query, and App
|
||||
* Router keeps the previous screen fully painted until that query comes back. On ward wifi that is
|
||||
* two or three seconds in which nothing at all acknowledges the tap — so people tap again, and the
|
||||
* app reads as frozen. This is the route-level fallback the framework wants for exactly that: it
|
||||
* replaces the body the moment a navigation starts, keeping the app's own chrome so the change
|
||||
* reads as "loading" rather than "gone".
|
||||
*
|
||||
* Deliberately not the tab bar: the nav belongs to the four screens that draw it, and painting one
|
||||
* here would make it flash into existence on the way to a detail screen that has none. The top bar
|
||||
* has no title for the same reason — this fallback covers every route in the group, and inventing a
|
||||
* title would mean printing the wrong one somewhere.
|
||||
*/
|
||||
const INK = "#201e1d";
|
||||
const GROUND = "#f3f2f2";
|
||||
|
||||
/** A grey block standing in for a line of text. Sized in the same 2px system as everything else. */
|
||||
function Bar({ w, h = 16 }: { w: string; h?: number }) {
|
||||
return <div style={{ width: w, height: h, background: "var(--color-neutral-200)" }} />;
|
||||
}
|
||||
|
||||
export default function StaffLoading() {
|
||||
return (
|
||||
<>
|
||||
<header className="tcx-topbar" style={{
|
||||
height: 56, flex: "0 0 56px", background: INK, color: GROUND, display: "flex", alignItems: "center",
|
||||
paddingLeft: 16, paddingRight: 16,
|
||||
backgroundImage: "linear-gradient(to bottom, rgba(243,242,242,0.16) 0 1px, transparent 1px)",
|
||||
backgroundPosition: "0 env(safe-area-inset-top, 0px)", backgroundRepeat: "no-repeat", backgroundSize: "100% 1px",
|
||||
}}>
|
||||
<span style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 15, letterSpacing: "0.06em", textTransform: "uppercase" }}>
|
||||
One moment
|
||||
</span>
|
||||
</header>
|
||||
<div style={{ height: 4, flex: "0 0 4px", background: "var(--color-accent)" }} />
|
||||
<div style={{ flex: 1, overflow: "hidden", background: GROUND }} aria-busy="true">
|
||||
{/* Announced once, quietly. The blocks below are decoration and say nothing. */}
|
||||
<div role="status" style={{ padding: "20px 16px 0", fontSize: 13, color: "var(--color-neutral-600)" }}>Loading…</div>
|
||||
<div style={{ padding: "16px 16px 0", display: "grid", gap: 10 }} aria-hidden="true">
|
||||
<Bar w="60%" h={22} />
|
||||
<Bar w="40%" />
|
||||
</div>
|
||||
<div style={{ marginTop: 24, display: "grid", gap: 2 }} aria-hidden="true">
|
||||
{[0, 1, 2, 3].map((i) => (
|
||||
<div key={i} style={{ background: "#fff", padding: "18px 16px", display: "grid", gap: 8 }}>
|
||||
<Bar w="55%" h={18} />
|
||||
<Bar w="35%" h={12} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { MBar, MBody, MEmpty, MRule, MTop } from "@/components/m";
|
||||
|
||||
/* What a staff screen shows when there is nothing behind it.
|
||||
*
|
||||
* Nine routes under /my call notFound(): a kit check between rounds, the waitlist with nothing
|
||||
* offered, the ward and desk screens for somebody without that role, an order that isn't theirs.
|
||||
* Without this file every one of them rendered the WEBSITE's 404 — marketing nav, "Open the demo",
|
||||
* a footer — inside the app, over the top of the tab bar, with the hardware back button as the
|
||||
* only way home. Seen on a Pixel 8 Pro on 2026-09-12 by opening Kit check with no check open.
|
||||
*
|
||||
* Next renders the nearest not-found.tsx, so this one stays inside the signed-in layout: same
|
||||
* chrome, same provider, and a bar that goes home.
|
||||
*/
|
||||
export default function StaffNotFound() {
|
||||
return (
|
||||
<>
|
||||
<MTop title="Nothing here" back />
|
||||
<MRule />
|
||||
<MBody>
|
||||
<MEmpty
|
||||
title="Nothing to show right now"
|
||||
sub="There’s no screen behind that link at the moment — a kit check that isn’t open, a list with nothing on it, or something that isn’t yours to see. Nothing on your record has changed."
|
||||
/>
|
||||
</MBody>
|
||||
<MBar label="Back to home" href="/my" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { currentStaff } from "@/lib/staffsession";
|
||||
import { requestData } from "@/lib/staffdata";
|
||||
import ThreadScreen from "@/components/screens/Thread";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function MyOrderThread({ params }: { params: Promise<{ id: string }> }) {
|
||||
const sess = await currentStaff();
|
||||
if (!sess) redirect("/my/signin");
|
||||
const { id } = await params;
|
||||
const data = await requestData(sess, id);
|
||||
if (!data) notFound();
|
||||
return <ThreadScreen data={data} />;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { currentStaff } from "@/lib/staffsession";
|
||||
import { requestData } from "@/lib/staffdata";
|
||||
import OrderScreen from "@/components/screens/Order";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function MyOrder({ params }: { params: Promise<{ id: string }> }) {
|
||||
const sess = await currentStaff();
|
||||
if (!sess) redirect("/my/signin");
|
||||
const { id } = await params;
|
||||
const data = await requestData(sess, id);
|
||||
// A request that isn't theirs, their team's, or one they raised is a 404 rather than a 403 —
|
||||
// "you may not see this" confirms it exists.
|
||||
if (!data) notFound();
|
||||
return <OrderScreen data={data} />;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { currentStaff } from "@/lib/staffsession";
|
||||
import { ordersData } from "@/lib/staffdata";
|
||||
import OrdersScreen from "@/components/screens/Orders";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function MyOrders({ searchParams }: { searchParams: Promise<{ tab?: string }> }) {
|
||||
const sess = await currentStaff();
|
||||
if (!sess) redirect("/my/signin");
|
||||
const { tab } = await searchParams;
|
||||
const { open, done, raised } = await ordersData(sess);
|
||||
// Landing from the Messages tab with nothing open should still show the Open tab and its empty
|
||||
// state, rather than a list of finished orders nobody asked for. `raised` is what this person
|
||||
// typed in for somebody else — the desk's whole day, and now a manager's too.
|
||||
return (
|
||||
<OrdersScreen
|
||||
open={open}
|
||||
done={done}
|
||||
raised={raised}
|
||||
initialTab={tab === "done" ? "done" : tab === "raised" ? "raised" : "open"}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { currentStaff } from "@/lib/staffsession";
|
||||
import { homeData } from "@/lib/staffdata";
|
||||
import { fmtDate } from "@/lib/compute";
|
||||
import { ROUTED_TO_ROUND, dueOnWard } from "@/lib/staffreq";
|
||||
import HomeScreen from "@/components/screens/Home";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function MyHome() {
|
||||
const sess = await currentStaff();
|
||||
if (!sess) redirect("/my/signin");
|
||||
|
||||
const data = await homeData(sess);
|
||||
// A manager's queue and a clerk's trolley are counts here, not lists: Home answers "is anything
|
||||
// waiting for me?" and then gets out of the way. Both are skipped entirely for the people the
|
||||
// flags don't apply to, which is nearly everyone.
|
||||
const [approvals, reports, roundBags, cycle] = await Promise.all([
|
||||
prisma.request.count({ where: { managerId: sess.staffId, status: "awaiting" } }),
|
||||
/* Whether anybody reports to them, which is the same question /my/raise answers with a 404.
|
||||
* Not the same as having approvals waiting: a manager whose team has asked for nothing this
|
||||
* month still needs the door, and this is the only way in to it. */
|
||||
prisma.staff.count({ where: { managerId: sess.staffId, inactive: false } }),
|
||||
data.wardDesk && data.ward
|
||||
// The ward the trolley left the bag on, off the timeline — the same fence /my/round and
|
||||
// round.sign use (see roundWard() in lib/staffreq.ts). Counting on the wearer's current ward
|
||||
// made this badge disagree with the screen it opens the moment anybody transferred wards
|
||||
// mid-round: a bag counted here and missing from the round, or the other way about.
|
||||
? prisma.request.count({
|
||||
where: {
|
||||
facilityId: sess.facilityId, status: "round",
|
||||
events: { some: { label: ROUTED_TO_ROUND, meta: dueOnWard(data.ward) } },
|
||||
},
|
||||
})
|
||||
: Promise.resolve(0),
|
||||
prisma.kitCheck.findFirst({
|
||||
where: { facilityId: sess.facilityId, closedAt: null },
|
||||
orderBy: { openedAt: "desc" },
|
||||
select: { id: true, dueBy: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
// Only prompt for a cycle they still owe answers to — someone who finished last week should not
|
||||
// be nagged for the rest of the month.
|
||||
let kitCheckDue: string | null = null;
|
||||
if (cycle) {
|
||||
const [held, answered] = await Promise.all([
|
||||
// handedIn as well as returnedDate: a garment handed back at the counter never gets marked
|
||||
// returned, so counting on returnedDate alone nags somebody for a kit check about uniform
|
||||
// they gave back months ago. Every equivalent query in lib/ reads both.
|
||||
prisma.issue.count({ where: { staffId: sess.staffId, returnedDate: null, handedIn: null } }),
|
||||
prisma.kitCheckAnswer.count({ where: { kitCheckId: cycle.id, staffId: sess.staffId } }),
|
||||
]);
|
||||
if (held > 0 && answered === 0) kitCheckDue = fmtDate(cycle.dueBy);
|
||||
}
|
||||
|
||||
return (
|
||||
<HomeScreen
|
||||
data={data}
|
||||
approvals={approvals}
|
||||
roundBags={roundBags}
|
||||
kitCheckDue={kitCheckDue}
|
||||
canRaiseForTeam={reports > 0}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { currentStaff } from "@/lib/staffsession";
|
||||
import { deskCatalogue, teamPeople } from "@/lib/deskdata";
|
||||
import { ordersData } from "@/lib/staffdata";
|
||||
import { REQUEST_MAX_LINES, REQUEST_MAX_QTY } from "@/lib/ops";
|
||||
import DeskScreen from "@/components/screens/Desk";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/* A manager raising for one of their own reports — the only way one person types a request in
|
||||
* somebody else's name in this app. A ward clerk on the desk used to have a screen of its own for
|
||||
* anyone on their ward; that is gone, and the person who would have asked the clerk asks the
|
||||
* manager who approves it anyway.
|
||||
*
|
||||
* The scope here is the same relationship the server enforces on `request.create`: the people who
|
||||
* name this person as their manager. Nothing on this page decides who may be raised for — it asks
|
||||
* teamPeople() for exactly the set the op would accept, and a request for anybody else is refused
|
||||
* there.
|
||||
*/
|
||||
export default async function MyRaise() {
|
||||
const sess = await currentStaff();
|
||||
if (!sess) redirect("/my/signin");
|
||||
|
||||
const [people, items, orders] = await Promise.all([
|
||||
teamPeople(sess),
|
||||
deskCatalogue(sess),
|
||||
ordersData(sess),
|
||||
]);
|
||||
// Nobody reporting to them means no screen, the same way the approvals queue works: an empty one
|
||||
// implies they might one day have a team, which is a question for the linen room.
|
||||
if (!people.length) notFound();
|
||||
|
||||
return (
|
||||
<DeskScreen
|
||||
// Every one of them names this manager, which is exactly why the request cannot stay with
|
||||
// them: the screen says whose name is on it, and the server sends it up a level.
|
||||
people={people}
|
||||
items={items}
|
||||
raised={orders.raised.open}
|
||||
maxLines={REQUEST_MAX_LINES}
|
||||
maxQty={REQUEST_MAX_QTY}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { currentStaff } from "@/lib/staffsession";
|
||||
import { catalogueData } from "@/lib/staffdata";
|
||||
import { REQUEST_MAX_LINES, REQUEST_MAX_QTY } from "@/lib/ops";
|
||||
import RequestScreen from "@/components/screens/Request";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function MyRequest({ searchParams }: { searchParams: Promise<{ swap?: string; item?: string; si?: string }> }) {
|
||||
const sess = await currentStaff();
|
||||
if (!sess) redirect("/my/signin");
|
||||
const { swap, item, si } = await searchParams;
|
||||
|
||||
const [{ items, managerName, holding, allowance }, held] = await Promise.all([
|
||||
catalogueData(sess),
|
||||
// handedIn as well as returnedDate, for the same reason the kit check reads both: a garment
|
||||
// handed back at the counter is off the person without ever being marked returned, and the
|
||||
// swap flow would otherwise offer to exchange something they no longer hold.
|
||||
prisma.issue.findMany({
|
||||
where: { staffId: sess.staffId, returnedDate: null, handedIn: null },
|
||||
select: { itemId: true }, distinct: ["itemId"],
|
||||
}),
|
||||
]);
|
||||
|
||||
// Only honour a pre-selection that actually exists in this facility's catalogue — the ids come
|
||||
// off a query string.
|
||||
const pre = items.find((i) => i.id === item) || null;
|
||||
const preSi = pre ? pre.sizes.find((s) => s.si === parseInt(String(si ?? ""), 10))?.si ?? null : null;
|
||||
|
||||
return (
|
||||
<RequestScreen
|
||||
items={items}
|
||||
managerName={managerName}
|
||||
swap={swap === "1"}
|
||||
heldItemIds={held.map((h) => h.itemId)}
|
||||
preItemId={pre?.id ?? null}
|
||||
preSi={preSi}
|
||||
holding={holding}
|
||||
allowance={allowance}
|
||||
// The ceilings are the server's, handed down rather than restated in the browser: a screen
|
||||
// that let somebody build an eleventh line would only be showing them a refusal.
|
||||
maxLines={REQUEST_MAX_LINES}
|
||||
maxQty={REQUEST_MAX_QTY}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { currentStaff } from "@/lib/staffsession";
|
||||
import { roundData } from "@/lib/deskdata";
|
||||
import { ordersData } from "@/lib/staffdata";
|
||||
import RoundScreen from "@/components/screens/Round";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function MyRound() {
|
||||
const sess = await currentStaff();
|
||||
if (!sess) redirect("/my/signin");
|
||||
const data = await roundData(sess);
|
||||
if (!data) notFound();
|
||||
/* What this person raised for other people and hasn't seen the end of — as a manager for one
|
||||
* of their team, or, on an older request, from the desk route that no longer exists.
|
||||
*
|
||||
* The three lists above it are only ever about bags arriving today, so a request typed in on
|
||||
* Tuesday and approved on Thursday appears on no screen the raiser can reach until it turns up
|
||||
* on a trolley — which is why they rang the linen room to ask. It is deliberately the open ones
|
||||
* only: the round screen is a day's work, not an archive, and everything that has finished is
|
||||
* under Raised in Orders. */
|
||||
const { raised } = await ordersData(sess);
|
||||
return (
|
||||
<RoundScreen
|
||||
ward={data.ward}
|
||||
toSign={data.toSign}
|
||||
unclaimed={data.unclaimed}
|
||||
signedToday={data.signedToday}
|
||||
raised={raised.open}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { currentStaff } from "@/lib/staffsession";
|
||||
import { catalogueData } from "@/lib/staffdata";
|
||||
import ShelfScreen from "@/components/screens/Shelf";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function MyShelf() {
|
||||
const sess = await currentStaff();
|
||||
if (!sess) redirect("/my/signin");
|
||||
const { items } = await catalogueData(sess);
|
||||
return <ShelfScreen items={items} />;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { currentStaff } from "@/lib/staffsession";
|
||||
import { waitlistData } from "@/lib/cycledata";
|
||||
import WaitlistScreen from "@/components/screens/Waitlist";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function MyWaitlist({ searchParams }: { searchParams: Promise<{ item?: string; si?: string }> }) {
|
||||
const sess = await currentStaff();
|
||||
if (!sess) redirect("/my/signin");
|
||||
const { item, si } = await searchParams;
|
||||
const data = await waitlistData(sess, String(item || ""), parseInt(String(si ?? "-1"), 10));
|
||||
if (!data) notFound();
|
||||
return <WaitlistScreen data={data} />;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { currentStaff } from "@/lib/staffsession";
|
||||
import { wardData } from "@/lib/managerdata";
|
||||
import WardScreen from "@/components/screens/Ward";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function MyWard() {
|
||||
const sess = await currentStaff();
|
||||
if (!sess) redirect("/my/signin");
|
||||
const reports = await prisma.staff.count({ where: { managerId: sess.staffId } });
|
||||
if (!reports) notFound();
|
||||
const { ward, rows, anyCapped } = await wardData(sess);
|
||||
return <WardScreen ward={ward} rows={rows} anyCapped={anyCapped} />;
|
||||
}
|
||||
Reference in New Issue
Block a user