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
+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>
);
}