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