Files
threadcount-community/lib/portalcounts.ts
T
ThreadCount a6f1059ddf 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 a113353 on 2026-09-15. Licensed under the Functional Source License (FSL-1.1-ALv2).
2026-09-15 22:54:09 +10:00

223 lines
11 KiB
TypeScript

/* The single source of queue membership for the coordinator portal (/app).
*
* Today's queue, the rail badges, the command panel and the Reports strip all read from here, so a
* number on the rail and the head count on the screen it opens are the same number. Pure functions
* over the snapshot, plus a small context the shell fills with the four counts only the server can
* make (requests live outside the snapshot). */
import { createContext, useContext, useMemo } from "react";
import {
addDays, ccOf, countsAsIssued, daysBetween, forecastFor, isOverdue, isPlacedOpen, locSubtree, onhand, reorderAt, splitKey, touched, variantList,
type Forecast, type Item, type Ledger, type LocationRec, type OrderRec, type PickupRec, type Snapshot, type StaffRec, type StocktakeRec, type Variant,
} from "./compute";
import { useDerived, useSnap } from "./client";
export const PICKUP_LATE_DAYS = 14;
export const RECEIVE_SOON_DAYS = 2;
export const COUNT_DUE_DAYS = 30;
export type ServerCounts = { pick: number; stranded: number; queries: number; damage: number };
export const EMPTY_SERVER_COUNTS: ServerCounts = { pick: 0, stranded: 0, queries: 0, damage: 0 };
/* Wards with at least one active ward-desk person, cached per snapshot object. */
const deskCache = new WeakMap<Snapshot, Set<string>>();
function deskWards(s: Snapshot): Set<string> {
let w = deskCache.get(s);
if (!w) {
w = new Set(s.staff.filter((x) => !x.inactive && x.wardDesk && x.dept).map((x) => x.dept));
deskCache.set(s, w);
}
return w;
}
/** C14: a bag goes on the round when nobody has called about it and its person's ward has a desk. */
export function isRoundPickup(s: Snapshot, p: PickupRec, staffById: Record<string, StaffRec>): boolean {
if (p.contacted) return false;
const st = staffById[p.staffId];
return !!st && !!st.dept && deskWards(s).has(st.dept);
}
export function waitingPickups(s: Snapshot, staffById: Record<string, StaffRec>, opts?: { includeRound?: boolean }): { p: PickupRec; days: number; late: boolean }[] {
return s.pickups
.filter((p) => !p.pickedUp && (opts?.includeRound || !isRoundPickup(s, p, staffById)))
.map((p) => {
const days = p.received ? daysBetween(p.received, s.today) : 0;
return { p, days, late: days >= PICKUP_LATE_DAYS };
})
.sort((a, b) => b.days - a.days);
}
export function roundWards(s: Snapshot, staffById: Record<string, StaffRec>): { ward: string; pickups: PickupRec[]; garments: number; people: StaffRec[]; desk: StaffRec[] }[] {
const by = new Map<string, PickupRec[]>();
for (const p of s.pickups) {
if (p.pickedUp || !isRoundPickup(s, p, staffById)) continue;
const ward = staffById[p.staffId].dept;
const list = by.get(ward) || [];
list.push(p);
by.set(ward, list);
}
return [...by.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([ward, pickups]) => {
const people: StaffRec[] = [];
for (const p of pickups) { const st = staffById[p.staffId]; if (st && !people.includes(st)) people.push(st); }
return {
ward, pickups, people,
garments: pickups.reduce((t, p) => t + p.lines.reduce((u, l) => u + l.qty, 0), 0),
desk: s.staff.filter((x) => !x.inactive && x.wardDesk && x.dept === ward),
};
});
}
export function receiveOrders(s: Snapshot): { o: OrderRec; overdueDays: number; dueInDays: number | null }[] {
const soon = addDays(s.today, RECEIVE_SOON_DAYS);
return s.orders
.filter((o) => isPlacedOpen(o) && !!o.expected && o.expected <= soon)
.map((o) => {
const overdue = o.expected < s.today;
return { o, overdueDays: overdue ? daysBetween(o.expected, s.today) : 0, dueInDays: overdue ? null : daysBetween(s.today, o.expected) };
})
.sort((a, b) => {
const ao = a.dueInDays === null, bo = b.dueInDays === null;
if (ao !== bo) return ao ? -1 : 1;
if (ao) return b.overdueDays - a.overdueDays;
return (a.dueInDays ?? 0) - (b.dueInDays ?? 0);
});
}
/** Locations due a shelf count (A2). A whole-room shelf count (no location) counts every location. */
export function countsDue(s: Snapshot): { loc: LocationRec; lastCounted: string | null; days: number | null; sizes: number; itemIds: string[] }[] {
const byId: Record<string, LocationRec> = Object.fromEntries(s.locations.map((l) => [l.id, l]));
const liveItems = new Set(s.catalog.filter((i) => !i.archived).map((i) => i.id));
const shelf = s.stocktakes.filter((t) => t.mode !== "preloved");
const ancestors = (id: string): Set<string> => {
const out = new Set<string>();
for (let cur: LocationRec | undefined = byId[id]; cur && !out.has(cur.id); cur = cur.parentId ? byId[cur.parentId] : undefined) out.add(cur.id);
return out;
};
const rows = s.locations.filter((l) => !l.archived).map((loc) => {
const sub = locSubtree(s, loc.id);
const itemIds = new Set<string>();
let sizes = 0;
for (const [k, locId] of Object.entries(s.placed)) {
if (!sub.has(locId)) continue;
const { itemId } = splitKey(k);
if (!liveItems.has(itemId)) continue;
sizes++;
itemIds.add(itemId);
}
const up = ancestors(loc.id);
let lastCounted: string | null = null;
for (const t of shelf) if ((t.locationId === null || up.has(t.locationId)) && (!lastCounted || t.date > lastCounted)) lastCounted = t.date;
const days = lastCounted ? daysBetween(lastCounted, s.today) : null;
return { loc, lastCounted, days, sizes, itemIds: [...itemIds], due: sizes > 0 && (lastCounted === null || (days ?? 0) >= COUNT_DUE_DAYS) };
});
const dueIds = new Set(rows.filter((r) => r.due).map((r) => r.loc.id));
return rows
.filter((r) => r.due && ![...ancestors(r.loc.id)].some((a) => a !== r.loc.id && dueIds.has(a)))
.sort((a, b) => {
if (a.lastCounted === null && b.lastCounted !== null) return -1;
if (b.lastCounted === null && a.lastCounted !== null) return 1;
return (b.days ?? 0) - (a.days ?? 0);
})
.map(({ loc, lastCounted, days, sizes, itemIds }) => ({ loc, lastCounted, days, sizes, itemIds }));
}
/** The only "at reorder" rule: live, touched, on hand at or below its reorder level. */
export function atReorderVariants(s: Snapshot, L: Ledger): Variant[] {
return variantList(s).filter((v) => touched(s, L, v.key) && onhand(s, L, v.key) <= reorderAt(s, v.key));
}
export function runsOut(s: Snapshot, L: Ledger, byId: Record<string, Item>): { v: Variant; oh: number; ro: number; f: Forecast; coverDays: number | null }[] {
const out: { v: Variant; oh: number; ro: number; f: Forecast; coverDays: number | null }[] = [];
for (const v of atReorderVariants(s, L)) {
const oh = onhand(s, L, v.key), ro = reorderAt(s, v.key);
const f = forecastFor(s, L, byId, v.key);
if (!(oh <= 0 || f.runsOutBeforeDelivery)) continue;
out.push({ v, oh, ro, f, coverDays: f.weeksOfCover === null ? null : Math.round(f.weeksOfCover * 7) });
}
return out.sort((a, b) => {
const ao = a.oh <= 0 ? 0 : 1, bo = b.oh <= 0 ? 0 : 1;
if (ao !== bo) return ao - bo;
return (a.coverDays ?? Infinity) - (b.coverDays ?? Infinity);
});
}
function lastDayOfMonth(month: string): string {
const [y, m] = month.split("-").map(Number);
return new Date(Date.UTC(y, m, 0)).toISOString().slice(0, 10);
}
export function monthEnd(s: Snapshot, L: Ledger, byId: Record<string, Item>, staffById: Record<string, StaffRec>, month: string): {
deliveriesOverdue: number;
stocktakeFiled: StocktakeRec | null;
unsignedReceipts: number;
unallocated: number;
journalReady: boolean;
daysLeft: number;
} {
const current = s.today.slice(0, 7) === month;
const inMonth = (d: string) => !!d && d.slice(0, 7) === month;
const deliveriesOverdue = current
? s.orders.filter((o) => isOverdue(o, s.today)).length
: s.orders.filter((o) => isPlacedOpen(o) && inMonth(o.date)).length;
let stocktakeFiled: StocktakeRec | null = null;
for (const t of s.stocktakes) if (t.mode !== "preloved" && inMonth(t.date) && (!stocktakeFiled || t.date > stocktakeFiled.date)) stocktakeFiled = t;
const unsignedReceipts = s.issues.filter((i) => !i.receipt && !i.returned && !i.handedIn).length;
const unallocated = s.issues.filter((i) => inMonth(i.date) && countsAsIssued(i) && !i.preloved && ccOf(s, staffById[i.staffId]) === "").length;
const end = lastDayOfMonth(month);
void L; void byId;
return {
deliveriesOverdue, stocktakeFiled, unsignedReceipts, unallocated,
journalReady: !!stocktakeFiled && unallocated === 0,
daysLeft: end < s.today ? 0 : daysBetween(s.today, end),
};
}
export type PortalCounts = {
today: { total: number; overdue: number; groups: { collect: number; round: number; pick: number; receive: number; counts: number } };
stock: { garmentsAtReorder: number; out: number; onOrder: number; noBarcode: number };
orders: { overdue: number; toOrderLines: number };
people: { attention: number; stranded: number };
};
export function portalCounts(s: Snapshot, d: { L: Ledger; byId: Record<string, Item>; staffById: Record<string, StaffRec> }, server: ServerCounts, isAdmin: boolean): PortalCounts {
const { L, staffById } = d;
const collect = waitingPickups(s, staffById).length;
const round = roundWards(s, staffById).reduce((t, w) => t + w.pickups.length, 0);
const receive = receiveOrders(s).length;
const counts = countsDue(s).length;
const groups = { collect, round, pick: server.pick, receive, counts };
const overdueOrders = s.orders.filter((o) => isOverdue(o, s.today)).length;
const latePickups = waitingPickups(s, staffById, { includeRound: true }).filter((w) => w.late).length;
const reorder = atReorderVariants(s, L);
const bound = new Set(Object.values(s.barcodes));
let out = 0, noBarcode = 0;
for (const v of variantList(s)) {
if (!touched(s, L, v.key)) continue;
if (onhand(s, L, v.key) <= 0) out++;
if (!bound.has(v.key)) noBarcode++;
}
let onOrder = 0;
for (const o of s.orders) {
if (!isPlacedOpen(o)) continue;
const got = o.receipts.reduce((t, r) => t + r.lines.reduce((u, l) => u + l.qty, 0), 0);
onOrder += Math.max(0, o.lines.reduce((t, l) => t + l.qty, 0) - got);
}
return {
today: { total: collect + round + server.pick + receive + counts, overdue: latePickups + overdueOrders, groups },
stock: { garmentsAtReorder: new Set(reorder.map((v) => v.itemId)).size, out, onOrder, noBarcode },
orders: { overdue: overdueOrders, toOrderLines: isAdmin ? reorder.length : 0 },
people: { attention: server.stranded + server.queries + server.damage, stranded: server.stranded },
};
}
export const PortalCountsContext = createContext<ServerCounts>(EMPTY_SERVER_COUNTS);
export function useServerCounts(): ServerCounts { return useContext(PortalCountsContext); }
export function usePortalCounts(): PortalCounts {
const { s, isAdmin } = useSnap();
const d = useDerived();
const server = useServerCounts();
return useMemo(() => portalCounts(s, d, server, isAdmin), [s, d, server, isAdmin]);
}