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
+137
View File
@@ -0,0 +1,137 @@
/* Display rows for the Today queue (/app) and the delivery rounds sheet (/app/rounds).
*
* Membership comes only from lib/portalcounts.ts: this file shapes the rows those selectors return
* into the words and figures a row prints, and adds no rule of its own about what belongs where. */
import {
addDays, ccOf, facilityDate, formatInZone, label, leadDaysOf, locTrail, staffName, supplierInfo, telHref,
type Item, type Ledger, type LocationRec, type OrderRec, type PickupRec, type Snapshot, type StaffRec,
} from "./compute";
import { PICKUP_LATE_DAYS, countsDue, monthEnd, receiveOrders, roundWards, runsOut, waitingPickups } from "./portalcounts";
/** "15 Sep" */
export function dayMonth(iso: string, tz: string): string {
return iso ? formatInZone(iso, tz, { day: "numeric", month: "short" }) : "";
}
/** "Fri 11 Sep" */
export function weekdayDayMonth(iso: string, tz: string): string {
return iso ? formatInZone(iso, tz, { weekday: "short", day: "numeric", month: "short" }) : "";
}
/** "today", "yesterday" or "9 Sep" for an instant or a date, in the facility's zone. */
export function relativeDay(iso: string | null | undefined, s: Snapshot): string {
if (!iso) return "";
const d = facilityDate(iso, s.tz);
if (!d) return "";
if (d === s.today) return "today";
if (d === addDays(s.today, -1)) return "yesterday";
return dayMonth(d, s.tz);
}
/** The head's count line. */
export function todayHeadLine(total: number, overdue: number): string {
const base = total === 0 ? "Nothing needs a person" : total === 1 ? "1 thing needs a person" : `${total} things need a person`;
return overdue > 0 ? `${base} · ${overdue} overdue` : base;
}
export const plural = (n: number, one: string, many = one + "s") => `${n} ${n === 1 ? one : many}`;
export type PickupLine = { garment: string; size: string; qty: number };
export function pickupLines(p: PickupRec, byId: Record<string, Item>): PickupLine[] {
return p.lines.map((l) => ({ garment: label(byId[l.itemId]), size: l.size, qty: l.qty }));
}
/** The collection slip payload, exactly as the dashboard built it. */
export function collectionSlip(s: Snapshot, p: PickupRec, st: StaffRec | undefined): Record<string, string | number | boolean | undefined> {
const ord = s.orders.find((o) => o.id === p.orderId);
return { staffName: staffName(st), dept: st?.dept, sets: p.lines.reduce((t, l) => t + l.qty, 0), po: ord?.ref || ord?.code || "", dateReceived: p.received, notifiedPhone: p.contacted, dateNotified: "" };
}
export type CollectRow = { p: PickupRec; st: StaffRec | undefined; days: number; late: boolean; name: string; phone: string; tel: string; lines: PickupLine[] };
export function collectRows(s: Snapshot, byId: Record<string, Item>, staffById: Record<string, StaffRec>): CollectRow[] {
return waitingPickups(s, staffById).map(({ p, days, late }) => {
const st = staffById[p.staffId];
return { p, st, days, late, name: staffName(st, "Staff"), phone: st?.phone || "", tel: telHref(st?.phone), lines: pickupLines(p, byId) };
});
}
export type RoundRow = { ward: string; bags: number; garments: number; names: string[]; moreNames: number; desk: boolean; href: string };
export function roundRows(s: Snapshot, staffById: Record<string, StaffRec>): RoundRow[] {
return roundWards(s, staffById).map((w) => ({
ward: w.ward,
bags: w.pickups.length,
garments: w.garments,
names: w.people.slice(0, 3).map((p) => staffName(p)),
moreNames: Math.max(0, w.people.length - 3),
desk: w.desk.length > 0,
href: `/app/rounds?ward=${encodeURIComponent(w.ward)}`,
}));
}
export type ReceiveRow = { o: OrderRec; overdue: boolean; age: string; ageLabel: string; lines: number; expected: string; forName: string; chase: string };
export function receiveRows(s: Snapshot, staffById: Record<string, StaffRec>): ReceiveRow[] {
return receiveOrders(s).map(({ o, overdueDays, dueInDays }) => {
const overdue = dueInDays === null;
const sup = supplierInfo(s, o.supplier);
const chase = sup?.email ? `mailto:${sup.email}?subject=${encodeURIComponent(o.code)}` : telHref(sup?.phone);
return {
o, overdue,
age: overdue ? `${overdueDays}d` : dueInDays === 0 ? "today" : `${dueInDays}d`,
ageLabel: overdue ? "overdue" : "due",
lines: o.lines.length,
expected: weekdayDayMonth(o.expected, s.tz),
forName: o.staffId ? staffName(staffById[o.staffId]) : "",
chase,
};
});
}
export type CountRow = { loc: LocationRec; trail: string; age: string; ageLabel: string; garments: string[]; last: string; sizes: number; href: string };
export function countRows(s: Snapshot, byId: Record<string, Item>): CountRow[] {
const locById: Record<string, LocationRec> = Object.fromEntries(s.locations.map((l) => [l.id, l]));
return countsDue(s).map(({ loc, lastCounted, days, sizes, itemIds }) => ({
loc,
trail: locTrail(locById, loc.id, 0) || loc.name,
age: days === null ? "—" : `${days}d`,
ageLabel: days === null ? "never" : "since",
garments: itemIds.slice(0, 3).map((id) => label(byId[id])),
last: lastCounted ? `last counted ${dayMonth(lastCounted, s.tz)}` : "never counted",
sizes,
href: `/app/stock?tab=count&location=${encodeURIComponent(loc.id)}`,
}));
}
export type RunsOutRow = { key: string; itemId: string; name: string; size: string; oh: number; ro: number; leadDays: number; coverDays: number | null; out: boolean; short: boolean };
export function runsOutRows(s: Snapshot, L: Ledger, byId: Record<string, Item>): RunsOutRow[] {
return runsOut(s, L, byId).map(({ v, oh, ro, f, coverDays }) => {
const leadDays = leadDaysOf(s, v.item.supplier) || Math.round(f.leadWeeks * 7);
return {
key: v.key, itemId: v.itemId, name: label(v.item), size: v.size, oh, ro, leadDays, coverDays,
out: oh <= 0,
short: coverDays !== null && coverDays < leadDays,
};
});
}
export type MonthEndView = ReturnType<typeof monthEnd> & { month: string; monthName: string; stocktakeDate: string };
export function monthEndView(s: Snapshot, L: Ledger, byId: Record<string, Item>, staffById: Record<string, StaffRec>): MonthEndView {
const month = s.today.slice(0, 7);
const m = monthEnd(s, L, byId, staffById, month);
return { ...m, month, monthName: formatInZone(`${month}-01`, s.tz, { month: "long" }), stocktakeDate: m.stocktakeFiled ? dayMonth(m.stocktakeFiled.date, s.tz) : "" };
}
/** The delivery rounds sheet: every uncollected pickup (round or not), by ward, wards AZ. */
export type RoundSheetRow = { p: PickupRec; st: StaffRec | undefined; name: string; phone: string; tel: string; days: number; late: boolean; lines: PickupLine[] };
export type RoundSheetWard = { ward: string; cc: string; rows: RoundSheetRow[]; garments: number };
export function roundSheet(s: Snapshot, byId: Record<string, Item>, staffById: Record<string, StaffRec>): RoundSheetWard[] {
const wards = new Map<string, RoundSheetRow[]>();
for (const { p, days } of waitingPickups(s, staffById, { includeRound: true })) {
const st = staffById[p.staffId];
const w = st?.dept || "Unknown";
const list = wards.get(w) || [];
list.push({ p, st, name: staffName(st, "Staff"), phone: st?.phone || "", tel: telHref(st?.phone), days, late: days >= PICKUP_LATE_DAYS, lines: pickupLines(p, byId) });
wards.set(w, list);
}
return [...wards.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([ward, rows]) => {
const st0 = rows.find((r) => r.st)?.st;
return { ward, rows, cc: st0 ? ccOf(s, st0) : "", garments: rows.reduce((t, r) => t + r.lines.reduce((u, l) => u + l.qty, 0), 0) };
});
}