Files
threadcount-community/lib/compute.ts
T
ThreadCount 96d5c10537 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 49da3a4 on 2026-09-13. Licensed under the Functional Source License (FSL-1.1-ALv2).
2026-09-13 11:38:24 +10:00

1244 lines
76 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Pure business logic shared by server and client. No DB imports here.
// The set rule lives in ./sets, which is where the whole of it is — six sets held at any one time,
// and what counts as a top and what counts as a pair of trousers, because that is what a set is made
// of. The import only goes this way: sets answers the allowance questions without knowing anything
// about a snapshot, so nothing there may reach back into this file. What this file adds is the
// snapshot — who is holding what — and it is where a screen or the server asks, through capCheck().
import { PANT_TYPES, SET_GARMENTS, TOP_TYPES, allowanceRoute, capState, garmentCounts, isPantItem, isTopItem, isTypeIn, setHalf, setsOnStart, type AllowanceRoute, type CapState, type Garment, type GarmentCounts } from "./sets";
export {
PANT_TYPES, SETS_CAP, SETS_ON_START, SET_GARMENTS, TOP_TYPES, allowance, allowanceRoute, capState,
garmentCounts, isPantItem, isTopItem, looseGarments, setHalf, setsCap, setsHeld,
setsOnStart, type AllowanceRoute, type CapState, type Garment, type GarmentCounts,
} from "./sets";
export type Item = {
id: string; sort: number; item: string; gender: string; type: string; sku: string; supplier: string;
cost: number;
/** The staff groups this garment is for. EMPTY MEANS EVERY GROUP. Ask garmentForGroup(). */
groups: string[];
/** groups for display only — "All" when empty, the name when one, the names joined with ", " when
* several (groupsLabel). The phone counter's catalogue screens read and send it back; never decide
* anything by comparing it. */
group: string;
notes: string; sizes: string[]; archived: boolean;
};
export type StockRec = { opening: number; adj: number; reorder: number | null; preloved: number };
export type CostRec = { id: string; itemId: string; cost: number; previous: number | null; at: string; byName: string };
export type MoveRec = { id: string; date: string; type: string; itemId: string; si: number; qty: number; reason: string; byName: string };
export type DeptRec = { id: string; name: string; cc: string };
export type SupplierRec = { id: string; name: string; contact: string; phone: string; account: string; lead: number | null };
export type StaffRec = {
id: string; num: string; first: string; last: string; phone: string; group: string; dept: string;
top: string; pants: string; ccOverride: string; inactive: boolean; ent: number | null; start: string; notes: string;
/** Combined FTE as the order form writes it — "1.0" … "0.1", or "Casual" — and "" when nobody has
* recorded one. A string because Casual is not a number. Only the initial kit reads it. */
fte: string;
/** Which cut of uniform this person is offered — "Men's", "Women's", "Either", or "" when nobody
* has said yet. Blank offers every style, exactly as "Either" does; the two are kept apart only
* so the register can list who is still undecided. Ask through garmentForStyle(). */
uniformStyle: string;
/** An activation code is outstanding — never the code itself. See buildSnapshot. */
selfCode: boolean;
/** When that code was printed, ISO, or null if none is outstanding. The date isn't a secret and a
* screen needs it: slips expire, so without it a coordinator can't tell a slip the nurse will use
* tomorrow from one that will be turned away at the activation counter. */
selfCodeAt: string | null;
/** Email on their claimed self-service account, or "" if they haven't claimed one. */
selfEmail: string;
/** The one person who approves their staff-app requests. Without it they can't raise anything. */
managerId: string | null;
/** They are on the ward desk, so they sign for their own ward's bags on the round. */
wardDesk: boolean;
};
/** `by` is the approver's name as it was signed; `byStaffId` is which person on the register that
* signature belongs to, or null for an approval whose approver was typed rather than picked, and
* for one whose manager has since been removed from the register. Screens print `by` and follow
* `byStaffId` — never the other way round, or a renamed manager rewrites a signed form. */
export type ApprovalRec = { id: string; staffId: string; date: string; by: string; byStaffId: string | null; sets: number; fte: string; notes: string; used: number; photoId: string | null };
export type AlterationRec = { id: string; staffId: string; date: string; garment: string; desc: string; status: string };
export type IssueRec = {
id: string; date: string; staffId: string; itemId: string; si: number; qty: number; cond: string; cost: number; orderCode: string;
receipt: boolean; returned: { date: string; cond: string; photoId: string | null } | null; override: boolean; direct: boolean; preloved: boolean; handedIn: string | null; createdAt: string;
/** Issued outside the person's staff group on the coordinator's override — not the six-set one. */
offGroup: boolean;
/** Issued outside the person's uniform style on the coordinator's override — a men's cut to
* somebody set to Women's, or the other way about. Its own flag beside offGroup. */
offStyle: boolean;
};
export type HandInLineRec = { itemId: string; si: number; qty: number; cond: string; laundered: boolean; credited: number };
export type HandInRec = { id: string; date: string; staffId: string; by: string; credit: boolean; lines: HandInLineRec[] };
export type OrderLineRec = { id: string; itemId: string; size: string; qty: number };
export type ReceiptLineRec = { itemId: string; size: string; qty: number; dest: string; cost: number };
export type ReceiptRec = { id: string; date: string; invoice: string; note: string; photoId: string | null; lines: ReceiptLineRec[] };
export type OrderRec = {
id: string; code: string; date: string; source: string; orderFor: string; staffId: string | null; supplier: string; status: string;
ref: string; invoice: string; tracking: string; expected: string; received: string; cc: string; notes: string; replenish: boolean; parentId: string | null;
createdAt: string; lines: OrderLineRec[]; receipts: ReceiptRec[];
};
export type PickupRec = {
id: string; orderId: string; orderCode: string; staffId: string; received: string; contacted: boolean; pickedUp: string | null;
deliveredTo: string; sigId: string | null; proofId: string | null; deliveredRound: boolean;
lines: { itemId: string; size: string; qty: number }[];
};
export type StocktakeRec = {
id: string; date: string; by: string; counted: number; variances: number; mode: string; locationId: string | null;
lines: { itemId: string; si: number; sys: number; counted: number; reason: string }[];
};
export type LocationRec = { id: string; name: string; kind: string; parentId: string | null; sort: number; archived: boolean };
export type UserRec = { id: string; email: string; first: string; last: string; title: string; role: "ADMIN" | "ISSUER"; inactive: boolean; ssoBreakGlass: boolean };
export type Settings = {
facility: string; location: string; coordinator: string; defaultReorder: number;
/** Garments per financial year, per person — the figure the reports, the register and the CSV
* quote as "drawn this year". It is not what the counter measures anybody against: what may be
* held at once is six sets, capSets below, counted at the moment somebody is standing there and
* with no year in it at all. Kept because "how much has this ward drawn since July" is a real
* question that a linen room really asks; it simply stopped being the thing that refuses a
* garment. */
defaultEntitlement: number;
/** The linen room's own e-mail address and phone number, printed in the footer of the order form.
* They live in settings and nowhere else: a customer's contact details are the customer's, and
* putting one in source would ship it to every other facility. */
coordinatorEmail: string; coordinatorPhone: string;
/** The starting kit, in sets, for the groups on the starting-kit route (kitGroups). The FTE table
* proposes its own number instead, and manager approval starts on none. Not a yearly allowance —
* see initialSets().
*
* This is the facility's answer to a question ./sets asks, not a second rule: read it through
* setsOnStart(), which is also what fills the gap when nobody has set one. */
initialSets: number;
/** The staff groups this facility puts on the FTE table. Empty means none of them are; there is no
* fallback. Ask through isNursing() or isNursingGroup(), never by comparing names yourself. */
nursingGroups: string[];
/** The staff groups this facility starts on a fixed kit (initialSets). Empty means none of them
* do. A group on neither this list nor nursingGroups is on manager approval. Ask through isKit()
* or isKitGroup(). */
kitGroups: string[];
/** The ceiling, in sets held at any one time, for everybody on the register, whichever route their
* group is on (Facility.capSets). Six sets: what a person has on their back and in their locker,
* not an allowance that starts again in July.
*
* Like initialSets this is the facility's answer to a question ./sets asks, not a second rule:
* read it through setsCap(), which stands in when nobody has set one. It is declared here as
* well as emitted because a field the snapshot carried but the type never mentioned is exactly
* how a setting gets quietly dropped on the way to a screen. */
capSets: number;
exceptionHigh: number; varianceReason: number; glAccount: string; journalDesc: string; lastBackup: string; hasLogo: boolean;
suppliers: string[]; staffGroups: string[]; slipCollectionFooter: string; slipDeliveryFooter: string; slipOrg: string;
barcodeLookup: boolean;
/** Single sign-on switches; the IdP itself is configured through /api/sso. */
sso: { enabled: boolean; required: boolean; staff: boolean; domains: string[] };
/** The facility's IANA zone (Facility.timezone). Every "today" on both the server and the client
* is measured in it — see facilityToday. */
timezone: string;
};
export type Snapshot = {
session: { userId: string; name: string; first: string; last: string; title: string; role: "Admin" | "Issuer"; email: string };
demo: { resetAt: string | null } | null;
/** The plan, worked out from the facility's plan columns by lib/plan.ts. `live` is the platform
* switch; until it is on, the screens show nothing about plans at all. */
plan: {
code: "hosted_small" | "hosted_facility" | "health_service" | "private";
label: string;
state: "grandfathered" | "free" | "trial" | "active" | "grace" | "read_only";
readOnly: boolean;
maxStaff: number | null; staff: number; backupDays: number;
endsAt: string | null; daysLeft: number | null;
graceEndsAt: string | null; graceDaysLeft: number | null;
grandfathered: boolean;
/** Admins only; blank for everyone else. */
billingEmail: string;
live: boolean;
/** Stripe is configured on this instance; a subscription exists for this facility. */
cardsOn: boolean;
card: boolean;
/** The health service this facility belongs to, if any. */
org: { name: string } | null;
/** The invoicing entity and ABN, when configured on the instance. */
invoicer: string;
};
settings: Settings;
catalog: Item[];
barcodes: Record<string, string>; // code -> "itemId:si"
stock: Record<string, StockRec>; // "itemId:si" -> levels
locations: LocationRec[];
placed: Record<string, string>; // "itemId:si" -> locationId (a variant's home shelf)
moves: MoveRec[];
costs: CostRec[];
depts: DeptRec[];
supplierDir: SupplierRec[];
staff: StaffRec[];
approvals: ApprovalRec[];
alterations: AlterationRec[];
issues: IssueRec[];
orders: OrderRec[];
pickups: PickupRec[];
stocktakes: StocktakeRec[];
handins: HandInRec[];
users: UserRec[];
/** Garments a manager has approved on a staff-app request that nobody has collected yet, a row per
* request line. They count towards the six a person holds (see heldGarments). buildSnapshot loads
* them for everybody, so every screen counts what the counter counts; the server re-reads one
* person's under its lock when it decides. Optional only because a snapshot built anywhere else
* (the demo seed, a test) may not carry them, and then this kind of owed garment is left out. */
owedRequestLines?: { staffId: string; itemId: string; qty: number }[];
today: string;
/** The facility's zone, repeated out of settings so that client components rendering a date have
* it to hand without reaching into settings. `today` is this zone's today. */
tz: string;
};
export const ORDER_STATUSES = ["Draft", "Ordered", "Shipped", "Back Order", "Received", "Cancelled"];
export const OPEN_STATUSES = ["Draft", "Ordered", "Shipped", "Back Order"];
/** Shortcuts for inserting a whole run of sizes at once. Named after the run itself, never after a
* garment: the same top can be stocked in XS5XL or in 624, so a run says nothing about what the
* garment is — that's what the product type field is for. */
export const SIZE_SETS: Record<string, string[]> = {
"XS 5XL": ["XS", "S", "M", "L", "XL", "2XL", "3XL", "4XL", "5XL"],
"6 24": ["6", "8", "10", "12", "14", "16", "18", "20", "22", "24"],
"72 117": ["72", "77", "82", "87", "92", "97", "102", "107", "112", "117"],
};
/** The first entry of every staff-group filter: the whole catalogue, whatever group it is marked for.
* The rest of the list is the facility's own staff groups. */
export const ALL_GROUPS = "All groups";
export const ALT_FLOW = ["Requested", "At tailor", "Returned to staff"];
export const key = (itemId: string, si: number) => `${itemId}:${si}`;
export const splitKey = (k: string) => { const i = k.lastIndexOf(":"); return { itemId: k.slice(0, i), si: +k.slice(i + 1) }; };
/** The zone every date falls back to when a facility has not been given one, and the zone the
* product was first written for. Only ever a fallback — see facilityToday. */
const FALLBACK_ZONE = "Australia/Brisbane";
/** Building an Intl.DateTimeFormat costs roughly what formatting a hundred dates costs, and these
* helpers get called once per row on lists that run to thousands of issues, so the formatters are
* cached per zone (and per option set, for display). A facility has one timezone that almost never
* changes, so the cache stays tiny. */
const zoneFmts = new Map<string, Intl.DateTimeFormat>();
function zoneFmt(tz: string, locale: string, opts: Intl.DateTimeFormatOptions, cacheKey: string): Intl.DateTimeFormat {
let f = zoneFmts.get(cacheKey);
if (!f) {
// A bad zone string reaching here would otherwise throw on every render for the whole facility,
// so an unrecognised zone quietly falls back rather than taking the screen down. Settings is
// where a typo gets rejected; this is the last line of defence, not the validation.
try { f = new Intl.DateTimeFormat(locale, { ...opts, timeZone: tz }); }
catch { f = new Intl.DateTimeFormat(locale, { ...opts, timeZone: FALLBACK_ZONE }); }
zoneFmts.set(cacheKey, f);
}
return f;
}
const ISO_PARTS: Intl.DateTimeFormatOptions = { year: "numeric", month: "2-digit", day: "2-digit" };
/** Assembled from parts rather than trusting a locale to print YYYY-MM-DD, because every date-only
* column in the database is compared and sorted as a plain string. */
function ymdIn(tz: string, at: Date): string {
const parts = zoneFmt(tz, "en-CA", ISO_PARTS, "iso|" + tz).formatToParts(at);
const get = (t: string) => parts.find((p) => p.type === t)?.value ?? "";
return `${get("year")}-${get("month")}-${get("day")}`;
}
const DATE_ONLY = /^\d{4}-\d{2}-\d{2}$/;
/** Today's date in the facility's own zone, as YYYY-MM-DD.
*
* Pass the facility's `timezone` column — never a literal. "Today" decides which month an issue
* lands in on the cost-centre journal and what date is printed on a credit slip, so a Perth linen
* room counting at 22:30 must not be filing tomorrow's paperwork, and a Sydney one counting at
* 00:30 on daylight saving must not be filing yesterday's. */
export function facilityToday(tz: string): string {
return ymdIn(tz || FALLBACK_ZONE, new Date());
}
/** The date an instant fell on in the facility's zone, as YYYY-MM-DD.
*
* This replaces `toISOString().slice(0, 10)`, which answers with the UTC date and so is wrong for
* the ten hours a day that Australia is already into tomorrow. A value that is already date-only
* is handed straight back: a bare date carries no instant, so re-zoning it could only invent an
* error. Returns "" for anything unparseable, so the `|| today` fallbacks read naturally. */
export function facilityDate(iso: string | Date, tz: string): string {
if (typeof iso === "string") {
if (!iso) return "";
if (DATE_ONLY.test(iso)) return iso;
}
const at = iso instanceof Date ? iso : new Date(iso);
if (Number.isNaN(at.getTime())) return "";
return ymdIn(tz || FALLBACK_ZONE, at);
}
/** An instant formatted for display in the facility's zone. Defaults to the same short Australian
* date fmtDate() prints; pass opts to add a time. The zone always wins over anything in opts. */
export function formatInZone(iso: string | Date, tz: string, opts: Intl.DateTimeFormatOptions = { day: "numeric", month: "short", year: "numeric" }): string {
if (!iso) return "—";
// A date-only string is a calendar date, not an instant. Its digits are printed as they stand,
// pinned to UTC so nothing is converted: shifting it into a zone could only move it a day, and
// there is no true instant to move it towards.
if (typeof iso === "string" && DATE_ONLY.test(iso)) {
const day = new Date(Date.UTC(+iso.slice(0, 4), +iso.slice(5, 7) - 1, +iso.slice(8, 10)));
return zoneFmt("UTC", "en-AU", opts, "disp|UTC|" + JSON.stringify(opts)).format(day);
}
const at = iso instanceof Date ? iso : new Date(iso);
if (Number.isNaN(at.getTime())) return "—";
const zone = tz || FALLBACK_ZONE;
return zoneFmt(zone, "en-AU", opts, "disp|" + zone + "|" + JSON.stringify(opts)).format(at);
}
/** Date-only arithmetic, deliberately done in UTC on the Y/M/D digits alone: it never reads a
* clock, so a day that is 23 or 25 hours long where the facility sits cannot shift the answer. */
export function addDays(iso: string, n: number): string {
const [y, m, d] = iso.split("-").map(Number);
const t = new Date(Date.UTC(y, m - 1, d + n));
return t.toISOString().slice(0, 10);
}
export function daysBetween(fromIso: string, toIso: string): number {
const a = Date.UTC(+fromIso.slice(0, 4), +fromIso.slice(5, 7) - 1, +fromIso.slice(8, 10));
const b = Date.UTC(+toIso.slice(0, 4), +toIso.slice(5, 7) - 1, +toIso.slice(8, 10));
return Math.max(0, Math.floor((b - a) / 86400000));
}
/** How long a printed staff-app slip is good for, in days. One number for the activation route that
* refuses an old slip and for every screen that tells a coordinator whether a slip is still worth
* chasing: when those were separate copies, the register could call a slip live that the counter
* would then turn away, and the nurse found out by being refused. */
export const SLIP_DAYS = 14;
/** Would a slip printed at `printedAt` still be accepted today?
*
* A slip is a bearer token on paper — whoever finds one in a folder months later could claim this
* person's record — so it dies at a fortnight. A slip with no print date is dead as well: an age
* nobody knows has to be read as an old one.
*
* Counted in whole days on the facility's own calendar rather than to the millisecond, so the route
* and the screens give the same answer on the same day. A slip printed at four in the afternoon
* is turned away from the first minute of the fourteenth day after rather than from four that
* afternoon; that is the harmless way to be wrong, because the fix is another slip, not a nurse standing at the counter being refused. */
export function slipLive(printedAt: string | Date | null | undefined, today: string, tz: string): boolean {
if (!printedAt) return false;
const printed = facilityDate(printedAt, tz);
return !!printed && daysBetween(printed, today) < SLIP_DAYS;
}
export function fyStart(today: string): string {
const y = +today.slice(0, 4), m = +today.slice(5, 7);
return (m >= 7 ? y : y - 1) + "-07-01";
}
export function money(n: number): string {
const v = Math.round((n || 0) * 100) / 100;
return "$" + v.toLocaleString("en-AU", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
/** Signed money with a leading +/ (empty sign for zero). */
export function signedMoney(n: number): string {
return (n < 0 ? "" : n > 0 ? "+" : "") + money(Math.abs(n));
}
export function signedInt(n: number): string { return (n > 0 ? "+" : "") + n; }
export function fmtDate(iso: string): string {
if (!iso || iso.length < 10) return iso || "—";
const d = new Date(+iso.slice(0, 4), +iso.slice(5, 7) - 1, +iso.slice(8, 10));
// Ten characters of anything is not ten characters of a date. Nothing writes "11/03/2024" into a
// date column any more, but rows stored before that was tightened still hold values whose digits
// come out NaN here, and the browser prints those as the literal words "Invalid Date" — on a slip,
// in a report, next to a real one. The em dash formatInZone already uses is the honest answer.
if (Number.isNaN(d.getTime())) return "—";
return d.toLocaleDateString("en-AU", { day: "numeric", month: "short", year: "numeric" });
}
export function monthLabel(ym: string, opts: Intl.DateTimeFormatOptions = { month: "long", year: "numeric" }): string {
const d = new Date(+ym.slice(0, 4), +ym.slice(5, 7) - 1, 15);
return d.toLocaleDateString("en-AU", opts);
}
export function prevMonth(ym: string): string { return shiftMonth(ym, -1); }
export function shiftMonth(ym: string, n: number): string {
const y = +ym.slice(0, 4), m = +ym.slice(5, 7);
const d = new Date(y, m - 1 + n, 15);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
}
export function label(it: Item | undefined): string {
if (!it) return "(removed item)";
const g = it.gender === "Male" ? "M" : it.gender === "Female" ? "W" : it.gender === "Men's" ? "M" : it.gender === "Women's" ? "W" : "";
return it.item + (g ? ` (${g})` : "");
}
/** "Fleece — Navy, L" — the garment and its size, spelled the same everywhere. */
export function variantName(it: Item | undefined, size: string | number): string {
return `${label(it)}, ${size}`;
}
export function genderLabel(g: string): string {
if (g === "Male" || g === "Men's" || g === "Men") return "Men's";
if (g === "Female" || g === "Women's" || g === "Women") return "Women's";
return "Unisex";
}
/** Item name with a long gender suffix, e.g. "Cargo Pant — Men's". */
export function longLabel(it: Item | undefined): string {
if (!it) return "(removed item)";
return it.item + (it.gender !== "Unisex" ? " — " + genderLabel(it.gender) : "");
}
/** The garments somebody is normally handed are their own staff group's, plus anything marked for all
* groups. The group is the facility's own name for it and nothing is read into its spelling: sorting
* names into buckets by their letters handed every customer one hospital's categories, and filed a
* kitchen hand's aprons under a security officer's suggestions because both were "Operational". */
export function groupBucket(g: string): string {
return (g || "").trim();
}
/** A garment's staff groups as they are stored: trimmed, blanks dropped, one entry per group as
* groupKey() compares them, keeping the first spelling. "All" anywhere in it means every group,
* which is the empty list, so there is only one way to say it. */
export function garmentGroups(list: readonly unknown[] | null | undefined): string[] {
const seen = new Set<string>(), out: string[] = [];
for (const x of list || []) {
const g = String(x ?? "").trim().slice(0, 80), k = groupKey(g);
if (!k) continue;
if (k === "all" || k === groupKey(ALL_GROUPS)) return [];
if (!seen.has(k)) { seen.add(k); out.push(g); }
}
return out;
}
/** A garment's groups as one label: "All", the one name, or the names joined with ", ". */
export function groupsLabel(groups: readonly string[] | null | undefined): string {
const g = garmentGroups(groups);
return g.length ? g.join(", ") : "All";
}
/** Is this garment for this staff group? A garment for every group (no groups) is for everybody;
* otherwise the group has to be one of its groups, compared through groupKey(). The one question
* the counter's suggestions, the staff app and the server's refusals all ask. */
export function garmentForGroup(it: { groups?: readonly string[] | null }, group: string | null | undefined): boolean {
const list = garmentGroups(it.groups);
return !list.length || onGroupList(list, group);
}
/** Does a catalogue item belong in this group's view? Items marked for all groups always do. */
export function inBucket(it: Item, bucket: string): boolean {
return bucket === ALL_GROUPS || garmentForGroup(it, bucket);
}
// ---------- uniform style
/** The uniform styles a staff record can be set to, in the order a picker offers them.
*
* Blank is the fourth state and is deliberately not on this list: it is what every record starts
* as, it means nobody has said yet, and it offers every style exactly as "Either" does. A picker
* offers these three beside its own word for blank. */
export const UNIFORM_STYLES = ["Men's", "Women's", "Either"];
/** The style that sees both cuts, spelled once so no screen retypes it. */
export const UNIFORM_STYLE_EITHER = "Either";
/** A uniform style off a picker, a CSV cell or an old gender label: "" when blank, one of
* UNIFORM_STYLES when the word is recognised, and null when it is not — so a caller can refuse it
* rather than store a value nothing reads.
*
* m / male / man / men / mens / men's are the men's cut; f / w / female / woman / women / womens /
* women's / ladies the women's; either / both / any / unisex the one that sees both. A register
* imported from somewhere else writes this column as a gender, which is why those spellings land
* on a style rather than being turned away. */
export function normalUniformStyle(v: unknown): string | null {
const s = String(v ?? "").trim().toLowerCase().replace(/[']/g, "");
if (!s) return "";
if (["m", "male", "males", "man", "men", "mens", "gents"].includes(s)) return "Men's";
if (["f", "w", "female", "females", "woman", "women", "womens", "ladies"].includes(s)) return "Women's";
if (["either", "both", "any", "all", "unisex"].includes(s)) return UNIFORM_STYLE_EITHER;
return null;
}
/** Is this garment the cut this person is offered? The one question the counter's suggestions, the
* staff app and the server's refusals ask about style — the same shape as garmentForGroup(), and
* asked the same way.
*
* True when nobody has said (blank) or they are set to Either; true for a unisex garment, and for
* one whose gender is blank or a word we don't recognise, because a garment nobody has classified
* is not evidence that somebody may not wear it. Otherwise the garment's own label has to be their
* style. A style stored as something this build doesn't know reads as blank, so the worst a bad
* value can do is offer somebody the whole catalogue, which is what they are offered today. */
export function garmentForStyle(it: { gender?: string | null }, style: string | null | undefined): boolean {
const s = normalUniformStyle(style) ?? "";
if (!s || s === UNIFORM_STYLE_EITHER) return true;
// genderLabel() answers "Unisex" for blank and for anything it doesn't recognise, which is the
// reading we want here as well — one function deciding what a garment's gender word means.
const g = genderLabel(String(it.gender ?? "").trim());
return g === "Unisex" || g === s;
}
// ---------- locations
/** The kinds a location can be. Laundry and External hold garments that have left the shelf but
* not the building, so a variant parked there still counts as somewhere rather than nowhere. */
export const LOCATION_KINDS = ["Room", "Shelf", "Bay", "Laundry", "External"];
export const UNPLACED = "unplaced";
export function locMap(s: Snapshot): Record<string, LocationRec> {
const m: Record<string, LocationRec> = {};
for (const l of s.locations) m[l.id] = l;
return m;
}
/** A location's ancestors, outermost first — ["Linen Room", "Shelf B"] for bay B3. */
export function locPath(byId: Record<string, LocationRec>, id: string | null | undefined): LocationRec[] {
const out: LocationRec[] = [];
let cur = id ? byId[id] : undefined;
const seen = new Set<string>();
while (cur && !seen.has(cur.id)) { seen.add(cur.id); out.unshift(cur); cur = cur.parentId ? byId[cur.parentId] : undefined; }
return out;
}
/** "Shelf B · Bay B3" — the trail below the top-level room, for a line row. */
export function locTrail(byId: Record<string, LocationRec>, id: string | null | undefined, from = 1): string {
return locPath(byId, id).slice(from).map((l) => l.name).join(" · ");
}
/** The trail below `rootId` — counting Shelf B, a line in its bay reads just "Bay B3". */
export function locUnder(byId: Record<string, LocationRec>, id: string | null | undefined, rootId: string): string {
const path = locPath(byId, id);
const at = path.findIndex((l) => l.id === rootId);
return (at < 0 ? path.slice(1) : path.slice(at + 1)).map((l) => l.name).join(" · ");
}
/** Every location id at or under `id`, so counting a shelf counts its bays too. */
export function locSubtree(s: Snapshot, id: string): Set<string> {
const kids: Record<string, string[]> = {};
for (const l of s.locations) if (l.parentId) (kids[l.parentId] ||= []).push(l.id);
const out = new Set<string>([id]);
const walk = (n: string) => { for (const c of kids[n] || []) if (!out.has(c)) { out.add(c); walk(c); } };
walk(id);
return out;
}
/** Locations in tree order with their depth, for indented pickers and the settings list. */
export function locTree(s: Snapshot, includeArchived = false): { loc: LocationRec; depth: number }[] {
const list = s.locations.filter((l) => includeArchived || !l.archived);
const kids: Record<string, LocationRec[]> = {};
const roots: LocationRec[] = [];
const ids = new Set(list.map((l) => l.id));
for (const l of list) (l.parentId && ids.has(l.parentId) ? (kids[l.parentId] ||= []) : roots).push(l);
const cmp = (a: LocationRec, b: LocationRec) => a.sort - b.sort || a.name.localeCompare(b.name);
const out: { loc: LocationRec; depth: number }[] = [];
const walk = (l: LocationRec, depth: number) => {
out.push({ loc: l, depth });
for (const c of (kids[l.id] || []).sort(cmp)) walk(c, depth + 1);
};
for (const r of roots.sort(cmp)) walk(r, 0);
return out;
}
/** Would making `id` a child of `parentId` create a cycle? */
export function locWouldLoop(s: Snapshot, id: string, parentId: string | null): boolean {
if (!parentId) return false;
if (parentId === id) return true;
return locSubtree(s, id).has(parentId);
}
/** A staff group's name the way two of them are compared: ignoring case and stray spaces at either
* end. The register is typed by hand and imported from roster spreadsheets, and "kitchen " off an
* export is the same group as "Kitchen" in settings. Every question below about which route a group
* is on compares through this, and so does lib/ops when it renames a group or refuses one on two
* routes at once — two ways of comparing names is how a group once fell off its route. */
export function groupKey(name: string | null | undefined): string {
return (name || "").trim().toLowerCase();
}
function onGroupList(groups: string[] | null | undefined, group: string | null | undefined): boolean {
const g = groupKey(group);
if (!g) return false;
return (groups || []).some((x) => groupKey(x) === g);
}
/** A facility's FTE-table groups as they should be read, given whatever is stored on the row. An
* empty or missing list means exactly that: none of this facility's groups are on the FTE table.
* There is no fallback to a list of our own. Any list we could ship would be one employer's job
* titles, and a hotel that has never named a nursing group has no nurses to put on the table. */
export function nursingGroupsFrom(groups: string[] | null | undefined): string[] {
return groups ?? [];
}
/** The groups this facility puts on the FTE table, off a snapshot. */
export function nursingGroupsOf(s: Snapshot): string[] {
return nursingGroupsFrom(s.settings.nursingGroups);
}
/** The groups this facility starts on a fixed kit, off a snapshot. Empty means none of them. */
export function kitGroupsOf(s: Snapshot): string[] {
return s.settings.kitGroups ?? [];
}
/** Is this group on the FTE table, asked of the facility's own list of group names?
*
* This is the one test for it, and it takes the list rather than a snapshot so that the server-side
* screens which hold no snapshot — the manager's review and team screens, the wearer's own app — can
* select Facility.nursingGroups themselves and ask exactly this question. It no longer decides
* whether somebody has a ceiling — six sets held is everybody's — only which words describe the way
* up to it, and which figure the order form proposes.
*
* Called "nursing" because nurses are who the FTE table on the signed form was drawn up for, but the
* list is whatever groups the facility puts on it, and a group with no "nurs" in its name can be
* there. Compared through groupKey(). Renaming a group renames it in this list too (settings.renameGroup
* in lib/ops), which is the point — nobody's allowance may change because a label was tidied up. */
export function isNursingGroup(groups: string[] | null | undefined, group: string | null | undefined): boolean {
return onGroupList(nursingGroupsFrom(groups), group);
}
/** Is this group on the starting-kit list, asked of the facility's own list of names? The same
* question as isNursingGroup(), asked of Facility.kitGroups and compared the same way, for the same
* reason.
*
* Membership only. A group somehow on both lists is on the FTE table — allowanceRoute() in ./sets
* decides that — so a caller holding the raw columns passes both answers to allowance() and lets it
* choose, rather than choosing itself. */
export function isKitGroup(groups: string[] | null | undefined, group: string | null | undefined): boolean {
return onGroupList(groups, group);
}
/** The FTE-table route: a starting kit proposed by the hours somebody works rather than the
* facility's flat number, and no yearly figure in the reports — their replacements are governed by
* the manager's signed form. Their ceiling is the same six sets held as everybody else's; the route
* buys no exemption from it, and the one place that is asked is capCheck().
*
* The snapshot's way of asking isNursingGroup(), and nothing more than that. Two ways of deciding
* who is on the table is how a group fell off it the first time, so this one hands the question
* straight on rather than repeating the comparison beside it. */
export function isNursing(s: Snapshot, st: { group: string } | undefined): boolean {
return isNursingGroup(s.settings.nursingGroups, st?.group);
}
/** The starting-kit route: a fixed number of sets on the first day (Settings.initialSets), then more
* as needed up to the ceiling, with nothing to hand back first.
*
* True only when the kit is the route this person is actually on. A group caught on both lists
* answers false here and true from isNursing(), so no screen can show somebody on two routes at
* once. */
export function isKit(s: Snapshot, st: { group: string } | undefined): boolean {
return allowanceRouteOf(s, st) === "kit";
}
/** Which of the three routes this person's group is on, off a snapshot: "fte", "kit" or
* "approval". The same answer allowance() in ./sets reaches for the screens that hold no snapshot. */
export function allowanceRouteOf(s: Snapshot, st: { group: string } | undefined): AllowanceRoute {
return allowanceRoute({ nursing: isNursing(s, st), kit: isKitGroup(s.settings.kitGroups, st?.group) });
}
/** Maternity wear, kept as its own block rather than folded into tops and pants. It is what a
* pregnant wearer is hunting for and what everybody else wants out of the way, and a maternity
* tunic is never swapped for a standard one, so the two are not interchangeable in any list.
* Like a dress it counts as neither half of a set: a set is the two-piece uniform, and maternity
* wear is issued as whatever fits rather than as a top-and-pants pair. */
export const MATERNITY_TYPES = ["Maternity top", "Maternity tunic", "Maternity dress", "Maternity pants"];
export const OTHER_TYPES = ["Dress", "Jacket", "Vest", "Fleece", "Apron", "Hat", "Footwear", "Other"];
/** Every garment type the item form offers, in the order it offers them. Tops and pants come from
* ./sets because they are the two halves of a set and the allowance rule has to agree with the
* picker about which is which; the blocks after them count as neither half, which is how a jacket
* or a vest has always behaved. */
export const PRODUCT_TYPES = [...TOP_TYPES, ...PANT_TYPES, ...MATERNITY_TYPES, ...OTHER_TYPES];
/** The warm layers already inside OTHER_TYPES, named so a picker can put them under one heading.
* Not a fourth block of PRODUCT_TYPES — each of these is offered there once already. */
export const OUTERWEAR_TYPES = ["Jacket", "Vest", "Fleece"];
// An explicit type wins; items saved before the field existed keep the old name-based guess. Takes
// only the two fields it reads, so callers holding a narrow select (the staff app reads the
// catalogue without costs) don't have to fake a whole Item to ask the question — the same shape the
// set rule reads, which is why it is that type.
//
// The trap worth knowing: type is a free-text field with a datalist behind it, not a closed list,
// and every one of these reads it as an exact (case-insensitive) match against the vocabulary
// above. A hand-typed "scrubs" or "Scrub Tops" therefore answers false everywhere — worse than
// leaving type blank, which at least falls back to the garment name. Ask through these helpers
// rather than comparing `it.type` yourself, or one typo in the catalogue quietly stops a garment
// counting as a top.
/** The name fallback is the whole word "maternity" and nothing shorter: "mat" on its own would
* claim a floor mat, and no catalogue writes a maternity garment down without the word in it. */
export const isMaternityItem = (it: Garment | undefined) =>
it?.type ? isTypeIn(MATERNITY_TYPES, it.type) : /maternity/i.test(it?.item || "");
export type GarmentCategory = "tops" | "bottoms" | "maternity" | "outerwear" | "other";
/** The headings a catalogue is broken into when somebody is choosing a garment, in the order they
* are shown. Only the vocabulary lives here; anything showing these leaves out the ones nothing
* falls in, so a facility that stocks no maternity wear never sees the word. */
export const GARMENT_CATEGORIES: { key: GarmentCategory; label: string }[] = [
{ key: "tops", label: "Tops" },
{ key: "bottoms", label: "Bottoms" },
{ key: "maternity", label: "Maternity" },
{ key: "outerwear", label: "Outerwear" },
{ key: "other", label: "Everything else" },
];
/** The one heading a garment belongs under. First match wins, so nothing is offered twice in the
* same picker.
*
* The order the questions are asked in is the point. Maternity comes first because a maternity
* tunic is a maternity garment before it is a top, and sitting it under Tops next to the standard
* tunics puts it back in the pile it was pulled out of. Outerwear comes before tops for the same
* reason one step down: an untyped "Fleece top" belongs with the fleeces, not with the polos. */
export function garmentCategory(it: Garment | undefined): GarmentCategory {
if (isMaternityItem(it)) return "maternity";
if (it?.type ? isTypeIn(OUTERWEAR_TYPES, it.type) : /jacket|vest|fleece|jumper|cardigan|coat|softshell/i.test(it?.item || "")) return "outerwear";
if (isTopItem(it)) return "tops";
if (isPantItem(it)) return "bottoms";
return "other";
}
export function sizeIndexOf(it: Item | undefined, size: string): number {
if (!it) return -1;
return it.sizes.map(String).indexOf(String(size));
}
export type Ledger = Record<string, { recv: number; issued: number; ret: number }>;
export function ledger(s: Snapshot): Ledger {
const L: Ledger = {};
const get = (k: string) => L[k] || (L[k] = { recv: 0, issued: 0, ret: 0 });
const byId = itemMap(s);
for (const o of s.orders) {
for (const rc of o.receipts) for (const l of rc.lines) {
if (l.dest !== "shelf") continue;
const si = sizeIndexOf(byId[l.itemId], l.size);
if (si >= 0) get(key(l.itemId, si)).recv += l.qty;
}
}
for (const i of s.issues) {
if (i.preloved) continue; // pool issues never touch the shelf ledger
if (!i.direct) get(key(i.itemId, i.si)).issued += i.qty;
if (i.returned && i.returned.cond === "Returned - Good") get(key(i.itemId, i.si)).ret += i.qty;
}
for (const m of s.moves) get(key(m.itemId, m.si)).recv += m.qty;
return L;
}
export function itemMap(s: Snapshot): Record<string, Item> {
const m: Record<string, Item> = {};
for (const it of s.catalog) m[it.id] = it;
return m;
}
export function staffMap(s: Snapshot): Record<string, StaffRec> {
const m: Record<string, StaffRec> = {};
for (const st of s.staff) m[st.id] = st;
return m;
}
export function onhand(s: Snapshot, L: Ledger, k: string): number {
const st = s.stock[k];
const o = (st ? st.opening + st.adj : 0);
const l = L[k];
return o + (l ? l.recv - l.issued + l.ret : 0);
}
/** Pre-loved pool on hand for a variant (handed-in / seconds, reissued free). */
export function plOf(s: Snapshot, k: string): number { return s.stock[k]?.preloved || 0; }
export function reorderAt(s: Snapshot, k: string): number {
const st = s.stock[k];
return st && st.reorder !== null && st.reorder !== undefined ? st.reorder : s.settings.defaultReorder;
}
export function touched(s: Snapshot, L: Ledger, k: string): boolean {
const st = s.stock[k];
return !!((st && (st.opening || st.adj || st.reorder !== null)) || L[k]);
}
/** Units on open orders per variant key (ordered minus already receipted), plus the total. */
export function onOrderMap(s: Snapshot, byId: Record<string, Item>): { byKey: Record<string, number>; total: number } {
const byKey: Record<string, number> = {}; let total = 0;
for (const o of s.orders) {
if (o.status === "Received" || o.status === "Cancelled") continue;
const recvd: Record<string, number> = {};
for (const rc of o.receipts) for (const l of rc.lines) recvd[l.itemId + "|" + l.size] = (recvd[l.itemId + "|" + l.size] || 0) + l.qty;
for (const l of o.lines) {
const si = sizeIndexOf(byId[l.itemId], l.size); if (si < 0) continue;
const rem = Math.max(0, l.qty - (recvd[l.itemId + "|" + l.size] || 0));
if (rem > 0) { const k = key(l.itemId, si); byKey[k] = (byKey[k] || 0) + rem; total += rem; }
}
}
return { byKey, total };
}
/** Most recent stocktake date per variant key (stocktakes are newest-first in the snapshot). */
export function lastCountMap(s: Snapshot): Record<string, string> {
const m: Record<string, string> = {};
for (const t of s.stocktakes) for (const l of t.lines) { const k = key(l.itemId, l.si); if (!m[k]) m[k] = t.date; }
return m;
}
/** How many garments a year this person's record is measured against in the reports: Infinity for
* the nursing stream, else their own figure or the facility default.
*
* A reporting figure, and only that. It is not what the counter refuses on — what may be held at
* once is six sets, and capCheck() above is where that is asked — so nothing that decides whether a
* garment is handed over should be reading this or entUsed(). The exceptions list, the register,
* the monthly report and the CSV all still want it: "drawn since July" is a question a linen room
* genuinely asks, and answering it was never the same thing as refusing somebody at the counter. */
export function entOf(s: Snapshot, st: StaffRec): number {
if (isNursing(s, st)) return Infinity;
return st.ent ?? s.settings.defaultEntitlement;
}
export function entOfLabel(of: number): string { return Number.isFinite(of) ? String(of) : "no limit"; }
/** An issue still counts (entitlement, cost-centre charge) unless the garment came back in good condition. Lost / damaged / written off stay charged. */
export function countsAsIssued(i: IssueRec): boolean { return !i.returned || i.returned.cond !== "Returned - Good"; }
/** Unit cost for an issue: the cost recorded at issue time, falling back to the catalogue for legacy rows. */
export function issueCost(i: IssueRec, byId: Record<string, Item>): number { return i.cost > 0 ? i.cost : byId[i.itemId]?.cost || 0; }
/** Garments drawn since 1 July, hand-ins credited back — the year's tally the reports print beside
* entOf(). The pair of them answer "what has this person drawn this year" and nothing else: what
* somebody may hold is capCheck(), which has no date in it. */
export function entUsed(s: Snapshot, staffId: string): number {
const fy = fyStart(s.today);
let n = 0;
for (const i of s.issues) if (i.staffId === staffId && i.date >= fy && !i.preloved && countsAsIssued(i)) n += i.qty;
// A credited hand-in gives the good garments back to the allowance.
for (const h of s.handins) if (h.staffId === staffId && h.credit && h.date >= fy) for (const l of h.lines) n -= l.credited;
return Math.max(0, n);
}
export function entState(used: number, of: number): "OVER" | "AT LIMIT" | "NEAR" | "OK" {
if (!Number.isFinite(of)) return "OK";
return used > of ? "OVER" : used >= of ? "AT LIMIT" : of - used <= 2 ? "NEAR" : "OK";
}
export function entTag(st: string): string {
return st === "OVER" ? "tag tag-accent" : st === "OK" ? "tag tag-neutral" : "tag tag-outline";
}
export function ccFor(s: Snapshot, dept: string): string {
const e = s.depts.find((x) => x.name === dept);
return e ? e.cc : "";
}
/** Cost centre code for a staff member: explicit override, else derived from their department. */
export function ccOf(s: Snapshot, st: StaffRec | undefined): string {
if (!st) return "";
return st.ccOverride || ccFor(s, st.dept);
}
/** Cost centre code for an order: its cc field is a department name (or raw code), else the staff member's. */
export function ccOfOrder(s: Snapshot, o: OrderRec, staffById: Record<string, StaffRec>): string {
if (o.cc) return ccFor(s, o.cc) || o.cc;
return o.staffId ? ccOf(s, staffById[o.staffId]) : "";
}
export function staffName(st: StaffRec | undefined, fallback = ""): string {
return st ? `${st.first} ${st.last}` : fallback;
}
export function supplierInfo(s: Snapshot, name: string): SupplierRec | undefined {
return s.supplierDir.find((x) => x.name === name);
}
export function leadDaysOf(s: Snapshot, name: string): number {
return supplierInfo(s, name)?.lead || 0;
}
/** Manager approvals with sets remaining for a staff member, oldest first (snapshot order is createdAt asc, so same-day ties keep entry order). */
export function openApprovals(s: Snapshot, staffId: string): ApprovalRec[] {
return s.approvals.filter((a) => a.staffId === staffId && a.sets - a.used > 0).sort((a, b) => (a.date < b.date ? -1 : a.date > b.date ? 1 : 0));
}
/** Oldest manager approval with sets remaining for a staff member. */
export function openApproval(s: Snapshot, staffId: string): ApprovalRec | undefined { return openApprovals(s, staffId)[0]; }
/** Total sets remaining across all of a staff member's open approvals. */
export function approvalRemaining(s: Snapshot, staffId: string): number { return openApprovals(s, staffId).reduce((t, a) => t + a.sets - a.used, 0); }
/** "Casual" exactly as the FTE field spells it. It is not a number, which is why Staff.fte and
* Approval.fte are strings. */
export const FTE_CASUAL = "Casual";
/** What an FTE picker offers, in the order the form lists it. */
export const FTE_OPTIONS = ["1.0", "0.9", "0.8", "0.7", "0.6", "0.5", "0.4", "0.3", "0.2", "0.1", FTE_CASUAL];
/** The set counts the form names for a casual. It leaves the choice to the manager, so these are
* offered, never assumed. */
export const CASUAL_SETS = [1, 2, 3];
/** The FTE table off the signed order form: combined FTE → uniform sets.
*
* Casual is null because the form hands the manager discretion there and the app must not invent a
* number on their behalf. Everything here is a proposal: a manager may write a larger number, and when
* they do the deviation is recorded rather than refused. What it is not is a second ceiling — a
* part-timer simply starts on less than a full-timer, and both end at the six sets anybody may hold,
* which is why a 0.5 FTE signed for four sets is a decision and not an error. */
export const FTE_SETS: Record<string, number | null> = {
"1.0": 5, "0.9": 5,
"0.8": 4, "0.7": 4,
"0.6": 3, "0.5": 3,
"0.4": 2, "0.3": 2,
"0.2": 1, "0.1": 1,
[FTE_CASUAL]: null,
};
/** What the table proposes for an FTE, or null where it proposes nothing — a casual, or somebody
* with no FTE recorded at all.
*
* A figure the table has no row for is read as the band it falls in rather than thrown away: the
* number is copied off a paper form, and somebody who writes "1" or "0.75" has told us plainly
* enough what they meant. Only a blank or something that isn't a fraction at all gives up. */
export function setsForFte(fte: string): number | null {
const raw = (fte || "").trim();
if (!raw) return null;
if (raw.toLowerCase() === FTE_CASUAL.toLowerCase()) return null;
const listed = FTE_SETS[raw];
if (listed !== undefined) return listed;
const n = Number(raw);
if (!Number.isFinite(n) || n <= 0) return null;
return n >= 0.9 ? 5 : n >= 0.7 ? 4 : n >= 0.5 ? 3 : n >= 0.3 ? 2 : 1;
}
/** The set counts the form names for a casual, written out the way a note has to say them — "1, 2
* or 3". Exported so that a screen quoting the form uses the same words as the note it previews. */
export const CASUAL_ALLOWED = CASUAL_SETS.length > 1
? `${CASUAL_SETS.slice(0, -1).join(", ")} or ${CASUAL_SETS[CASUAL_SETS.length - 1]}`
: String(CASUAL_SETS[0]);
const CASUAL_MAX = Math.max(...CASUAL_SETS);
/** Where a manager's signature goes past the paper it was signed against, as the sentence recorded
* on the approval — or null when it doesn't.
*
* Two papers to depart from: the FTE table for anybody with a fraction against them, and the form's
* own one-to-three for a casual, whose number the form hands to the manager outright. Neither is a
* refusal. The signature stands; the sentence is there so that an extra set reads, months later, as
* a decision with a name on it rather than as a typo nobody can account for.
*
* One function because the same sentence is read twice in a row: previewed on the staff record
* while the approval is typed in, then written onto the row by lib/ops when it is saved. As two
* copies they had already drifted into saying the same thing two ways on the same row, and a
* preview that differs from what gets recorded is a promise the save then breaks. */
export function approvalDeparture(a: { sets: number; fte: string; by: string }): string | null {
const proposed = setsForFte(a.fte);
if (proposed !== null) {
return a.sets > proposed ? `Above the FTE table: ${a.sets} sets at ${a.fte} FTE, where the table proposes ${proposed}. Approved by ${a.by}.` : null;
}
return a.fte.trim().toLowerCase() === FTE_CASUAL.toLowerCase() && a.sets > CASUAL_MAX
? `Above what the form names for a casual: ${a.sets} sets, where the form names ${CASUAL_ALLOWED}. Approved by ${a.by}.`
: null;
}
/** The initial kit, in sets — what this person is handed on starting, and nothing beyond it.
*
* Three routes, three answers. The FTE table's groups take its proposal. The starting-kit groups
* take the facility's configured starting allocation, asked of ./sets — which owns that rule — so
* the coordinator's setting is read the same way on the order form as on the wearer's own screen.
* Every group on manager approval starts with no kit at all: their sets come one at a time on their
* manager's approval, up to the facility's ceiling, so there is no number anybody can name before
* that first signature.
*
* A group on both lists — the server refuses one, but a restored file could still carry it — is on
* the FTE table, for the reason allowanceRoute() gives. This asks allowanceRouteOf() rather than
* putting the two tests in an order of its own, so the order form and the wearer's app cannot
* settle it differently.
*
* Only the *starting* allocation. What comes after it is issued as it is needed, up to the ceiling,
* and never counts as more kit: a kit that grew would have the counter chasing people for a first
* issue they already had.
*
* null means nobody can say yet — a casual, somebody on the FTE table whose FTE has never been
* recorded, or somebody on manager approval — and it is not the same as zero, which reads as a
* refusal somebody made. Anything showing this has to say so in words. Handing the starting kit's 3
* to somebody on manager approval was the worse failure of the two: their record totted up "6 still
* to come" and invited them to a counter where the printed form, their own app and the settings
* note all agree they are owed nothing until their manager signs for it. */
export function initialSets(s: Snapshot, st: StaffRec | undefined): number | null {
if (!st) return null;
switch (allowanceRouteOf(s, st)) {
case "fte": return setsForFte(st.fte);
case "kit": return setsOnStart(s.settings.initialSets);
default: return null;
}
}
/** The same kit counted in garments, which is the unit issues are counted in. */
export function initialGarments(s: Snapshot, st: StaffRec | undefined): number | null {
const sets = initialSets(s, st);
return sets === null ? null : sets * SET_GARMENTS;
}
/** How many garments of the initial kit this person has ever had. A lifetime count, and deliberately
* not entUsed().
*
* entUsed() counts one financial year and credits hand-ins back against it. That is the right
* answer to "what have they drawn this year" and it is the number every report quotes, so it stays
* exactly as it is. The initial kit asks a different question — has this person been kitted out yet
* — and there is no year in that one: somebody issued five sets in 2019 has had their kit whether
* or not they have drawn a thing since.
*
* A hand-in doesn't give the kit back for the same reason. Handing in a worn-out tunic doesn't
* un-issue it, and what comes the other way is a replacement — approved by the manager, or overridden
* by the coordinator — not a fresh first issue. What is left out is the pre-loved pool, which is
* free and never draws an approval down, and a garment returned in good condition, which was a swap
* rather than an issue. */
export function initialUsed(s: Snapshot, staffId: string): number {
let n = 0;
for (const i of s.issues) if (i.staffId === staffId && !i.preloved && countsAsIssued(i)) n += i.qty;
return n;
}
/** Garments of the initial kit still owing, or null when there is no agreed number to count against.
* Never negative: past the kit the control is an approval or a coordinator override, not a running
* deficit.
*
* What is owed on starting, and nothing to do with what the counter refuses. It was once added to
* the year's figure to make head-room, because a 3-set kit is 6 garments and a 5-garment year
* figure turned a new starter's own record into an override; under a ceiling on what is held that
* problem is gone — a new starter holds nothing, takes three sets, and three is inside six. So this
* belongs to the order form and the staff record, which say what somebody is owed, and never to
* capCheck(), which says what they may hold. */
export function initialRemaining(s: Snapshot, st: StaffRec | undefined): number | null {
const of = initialGarments(s, st);
if (of === null || !st) return null;
return Math.max(0, of - initialUsed(s, st.id));
}
/** Everything this person has, or has coming to them, a line at a time: `owed` is false for what is
* on their back and in their locker, true for what the linen room has committed to hand them and has
* not handed over yet.
*
* Out means issued and not since handed in or returned. A hand-in takes a garment off somebody
* without ever marking it returned, so leaving either test out has a nurse who did exactly what she
* was asked to still holding what she gave back. A partial hand-in splits the issue row rather than
* stamping the whole of it — lib/ops's doing — so somebody who brought back one of two sets holds
* one, and the two halves are told apart by that same stamp.
*
* Owed is three things, each counted from the moment it is agreed until the moment it is handed over:
*
* - An order placed for them — a draft included, because a draft becomes an order without anybody
* asking the ceiling again, and it was drafted for them. Whatever of it has already arrived is
* left out: it went to the shelf, where it belongs to nobody, or to a pickup, counted next.
* - A pickup waiting for them: the order came in and is sitting at the counter with their name on it.
* - Lines a manager has approved on a request nobody has collected yet, when the snapshot carries
* them (Snapshot.owedRequestLines).
*
* None of those is measured against the ceiling a second time when it is handed over, and none of
* them needs to be: collecting it moves the garment from owed to out and the total stays where it
* was. Counted only on the day it arrived, somebody holding nothing could order six sets in today
* and six more tomorrow, and both would pass. */
function eachHolding(s: Snapshot, staffId: string | null, visit: (staffId: string, itemId: string, qty: number, owed: boolean) => void): void {
const mine = (id: string) => staffId === null || id === staffId;
for (const i of s.issues) if (mine(i.staffId) && !i.returned && !i.handedIn) visit(i.staffId, i.itemId, i.qty, false);
for (const o of s.orders) {
const sid = o.staffId;
if (!sid || !mine(sid) || !isOpen(o)) continue;
// Drawn down line by line, as orderTotal() does, so two lines for one size can't both claim a
// single delivery.
const got: Record<string, number> = {};
for (const rc of o.receipts) for (const l of rc.lines) got[l.itemId + "|" + l.size] = (got[l.itemId + "|" + l.size] || 0) + l.qty;
for (const l of o.lines) {
const k = l.itemId + "|" + l.size, done = Math.min(l.qty, got[k] || 0);
got[k] = (got[k] || 0) - done;
if (l.qty > done) visit(sid, l.itemId, l.qty - done, true);
}
}
for (const pu of s.pickups) if (mine(pu.staffId) && !pu.pickedUp) for (const l of pu.lines) visit(pu.staffId, l.itemId, l.qty, true);
for (const r of s.owedRequestLines || []) if (mine(r.staffId)) visit(r.staffId, r.itemId, r.qty, true);
}
/** What this person holds as far as the ceiling is concerned: tops, trousers, whatever belongs to no
* set, and the complete sets the first two make between them — what is out with them and what is
* owed to them alike (eachHolding above says what each of those is).
*
* Deliberately more than the counter's own list of garments in components/MPerson's useHeld(),
* which lists what is physically out. That list says what somebody can hand back; this says how
* near they are to six, and a garment on order is not something anybody can hand back. Where a
* screen needs to tell the two apart, owedGarments() is the difference.
*
* Pre-loved garments are counted like any other. They are free and they draw no approval, but the
* ceiling is not about money: it is about how much uniform one person is walking around with, and
* six pre-loved tops fill a locker exactly as six new ones do. */
export function heldGarments(s: Snapshot, staffId: string, byId = itemMap(s)): GarmentCounts {
const holdings: { item: Garment; qty: number }[] = [];
eachHolding(s, staffId, (_, itemId, qty) => { holdings.push({ item: byId[itemId] || {}, qty }); });
return garmentCounts(holdings);
}
/** The part of heldGarments() that has not reached them yet — on order, waiting at the counter, or
* approved in a bag nobody has collected. A screen telling somebody they hold four tops when two
* of those are on order owes them the other half of the sentence, and this is it. */
export function owedGarments(s: Snapshot, staffId: string, byId = itemMap(s)): GarmentCounts {
const holdings: { item: Garment; qty: number }[] = [];
eachHolding(s, staffId, (_, itemId, qty, owed) => { if (owed) holdings.push({ item: byId[itemId] || {}, qty }); });
return garmentCounts(holdings);
}
/** The same count for everybody at once, keyed by staff id — owed garments included, so a register
* that flags somebody as over agrees with the counter that would refuse them. One walk instead of
* one per person: a register of six hundred staff asking heldGarments() a row at a time walks every
* issue in the facility six hundred times, which is what makes a list screen crawl on a big site.
* Somebody with nothing out and nothing owed is absent from the map, so read it with a zeroed
* fallback. */
export function heldByStaff(s: Snapshot): Record<string, GarmentCounts> {
const byId = itemMap(s);
const m: Record<string, GarmentCounts> = {};
eachHolding(s, null, (staffId, itemId, qty) => {
const c = (m[staffId] ||= { tops: 0, pants: 0, other: 0, sets: 0 });
const half = setHalf(byId[itemId] || {});
if (half === "top") c.tops += qty;
else if (half === "pants") c.pants += qty;
else c.other += qty;
});
for (const id in m) m[id].sets = Math.min(m[id].tops, m[id].pants);
return m;
}
/** The one question the counter asks: after this hand-over, is this person still inside the six sets
* one person holds?
*
* Six sets at any time, for every group — nursing included. Not six a year: there is no financial
* year in this, nothing resets in July, and the only way past a full six is to hand something in or
* to have a coordinator record an override. What the three streams differ in is how somebody gets
* up to six — the FTE table proposes it, a starting kit opens it, a manager signs for it — and none
* of that is a second refusal for the counter to make on top of this one.
*
* `cart` is what is about to be handed over, as garments rather than sets: the ceiling bites on each
* half, so a bag of six tops has to be measurable against six tops held. Pre-loved lines belong in
* it like any other — they are free, not invisible — which is why this takes the cart whole rather
* than the filtered quantity the costing uses. Lines whose garment is no longer in the catalogue
* count as belonging to no set, which is where anything unrecognised lands elsewhere too.
*
* The answer comes back in parts rather than as a verdict. A coordinator asked to tick an override
* is owed a reason they can check against the person in front of them — "holds six tops and six
* pairs, and six sets is the most anyone holds" — and the screens need the same parts to say why
* before anybody clicks anything.
*
* What they hold is heldGarments(): what is out with them and what is owed to them, together. The
* answer also carries `owed`, the part of that still to come, because "holding six tops" said to
* somebody who can count four in their locker needs the rest of the sentence before anyone will
* believe it. */
export type CapCheck = CapState & { owed: GarmentCounts };
export function capCheck(s: Snapshot, st: StaffRec, cart: { itemId: string; qty: number }[] = []): CapCheck {
const byId = itemMap(s);
const adding = garmentCounts(cart.map((c) => ({ item: byId[c.itemId] || {}, qty: c.qty })));
return { ...capState({ held: heldGarments(s, st.id, byId), adding, capSets: s.settings.capSets }), owed: owedGarments(s, st.id, byId) };
}
export type Variant = { itemId: string; si: number; size: string; key: string; item: Item };
export function variantList(s: Snapshot): Variant[] {
const out: Variant[] = [];
for (const it of s.catalog) if (!it.archived) it.sizes.forEach((size, si) => out.push({ itemId: it.id, si, size: String(size), key: key(it.id, si), item: it }));
return out;
}
// Barcodes: supplier/bound codes from the map, else generated 93XXXXXXX from the item's sort number.
export function bcFor(s: Snapshot, it: Item, si: number): string {
return bcBound(s, it, si) || String(930000000 + it.sort * 100 + si);
}
/** The supplier barcode actually bound to this size, or "" when none has been scanned in yet.
* Prefer this anywhere a code is SHOWN or PRINTED: the generated 93XXXXXXX fallback from bcFor()
* is a ThreadCount-internal id that appears nowhere on the garment, so printing it invites someone
* to try to scan a number that doesn't exist. bcFor() stays for resolution — bcParse still accepts
* the generated form, so anything already relying on it keeps working. */
export function bcBound(s: Snapshot, it: Item, si: number): string {
const k = key(it.id, si);
for (const code in s.barcodes) if (s.barcodes[code] === k) return code;
return "";
}
export function bcParse(s: Snapshot, raw: string): { itemId: string; si: number } | null {
const code = String(raw).trim();
if (!code) return null;
const bound = s.barcodes[code];
const byId = itemMap(s);
if (bound) {
const { itemId, si } = splitKey(bound);
const it = byId[itemId];
if (it && !it.archived && si < it.sizes.length) return { itemId, si };
}
if (!/^93\d{7}$/.test(code)) return null;
const v = +code - 930000000, sort = Math.floor(v / 100), si = v % 100;
const it = s.catalog.find((x) => x.sort === sort);
if (!it || it.archived || si >= it.sizes.length) return null;
return { itemId: it.id, si };
}
/** What an order is worth: delivered units priced at the cost the delivery was invoiced at, anything
* still outstanding at today's catalogue price.
*
* The catalogue cost is a live figure, so pricing the whole order from it meant an admin editing a
* price silently restated every month-end pack already printed — two prints of the same closed
* month disagreed, and neither matched the invoice. ReceiptLine.cost is the figure the coordinator
* is prompted for on every delivery (and it holds the catalogue price when they keep it), so once a
* line has arrived that is the money that actually left the facility. A legacy receipt line stored
* as $0 has no figure to trust and falls back to the catalogue, the same way issueCost() does. */
export function orderTotal(o: OrderRec, byId: Record<string, Item>): number {
// Delivered units per item+size and what they cost. Averaged, because one size can arrive across
// several deliveries at different prices.
const recv: Record<string, { qty: number; amt: number; left: number }> = {};
for (const rc of o.receipts) for (const l of rc.lines) {
const k = l.itemId + "|" + l.size;
const a = recv[k] || (recv[k] = { qty: 0, amt: 0, left: 0 });
a.qty += l.qty; a.left += l.qty;
a.amt += l.qty * (l.cost > 0 ? l.cost : byId[l.itemId]?.cost || 0);
}
return o.lines.reduce((t, l) => {
const cat = byId[l.itemId] ? byId[l.itemId].cost : 0;
const a = recv[l.itemId + "|" + l.size];
if (!a || a.left <= 0) return t + l.qty * cat;
// Drawn down as it's used, so two order lines for the same size can't both claim one delivery.
const done = Math.min(l.qty, a.left);
a.left -= done;
return t + done * (a.amt / a.qty) + (l.qty - done) * cat;
}, 0);
}
export function isOpen(o: OrderRec) { return OPEN_STATUSES.includes(o.status); }
/** Open = placed with the supplier and not yet closed (excludes drafts). */
export function isPlacedOpen(o: OrderRec) { return !["Received", "Cancelled", "Draft"].includes(o.status); }
/** Overdue only applies to orders actually placed with the supplier — drafts can't be late. */
export function isOverdue(o: OrderRec, today: string) { return isPlacedOpen(o) && !!o.expected && o.expected < today; }
export function statusTag(st: string): string {
return st === "Received" ? "tag tag-neutral" : st === "Cancelled" ? "tag tag-outline" : "tag tag-accent";
}
/** "Cost centre X — $ issued this month · $ on open orders" budget note for a cost-centre code. */
export function ccBudgetNote(s: Snapshot, byId: Record<string, Item>, staffById: Record<string, StaffRec>, code: string, suffix = ""): string {
if (!code) return "";
const mon = s.today.slice(0, 7);
let iss = 0;
// Pre-loved issues are free and are excluded here for the same reason the Reports tabs exclude
// them: they are stored with cost 0, so issueCost() would fall back to the catalogue and charge a
// ward full price for garments it was given from the pool. A coordinator deciding whether a cost
// centre can afford another order would be reading an invented figure the month-end pack denies.
for (const i of s.issues) { if (!countsAsIssued(i) || i.preloved || i.date.slice(0, 7) !== mon) continue; const st = staffById[i.staffId]; if (st && ccOf(s, st) === code) iss += i.qty * issueCost(i, byId); }
let onOrd = 0;
for (const o of s.orders) { if (!isOpen(o)) continue; if (ccOfOrder(s, o, staffById) === code) onOrd += orderTotal(o, byId); }
return `Cost centre ${code}${money(iss)} issued this month · ${money(onOrd)} on open orders${suffix}`;
}
/** Lines needed to bring every flagged variant back to 2× reorder, netting off what's already on order. */
export function flaggedNeeds(s: Snapshot, L: Ledger, byId: Record<string, Item>): { itemId: string; si: number; size: string; qty: number; supplier: string }[] {
// Net off stock on placed orders and non-replenish drafts; the replenish draft itself is what we are topping up (max-merge), so exclude it.
const oo = onOrderMap({ ...s, orders: s.orders.filter((o) => !(o.replenish && o.status === "Draft")) }, byId).byKey;
const out: { itemId: string; si: number; size: string; qty: number; supplier: string }[] = [];
for (const v of variantList(s)) {
if (!touched(s, L, v.key)) continue;
const oh = onhand(s, L, v.key), ro = reorderAt(s, v.key);
if (oh > ro) continue;
const need = Math.max(ro * 2 - oh - (oo[v.key] || 0), 0);
if (need > 0) out.push({ itemId: v.itemId, si: v.si, size: v.size, qty: need, supplier: v.item.supplier || s.settings.suppliers[0] || "Supplier" });
}
return out;
}
/** CSV cell: quoted, doubled quotes, and a leading apostrophe on =,+,-,@ so spreadsheets don't evaluate it. */
export const csvEsc = (v: unknown) => { const t = String(v ?? ""); return '"' + (/^[\s=+\-@]/.test(t) ? "'" + t : t).replace(/"/g, '""') + '"'; };
export function csvOf(cols: string[], rows: (string | number)[][]): string {
return cols.map(csvEsc).join(",") + "\n" + rows.map((r) => r.map((c) => (typeof c === "number" ? c : csvEsc(c))).join(",")).join("\n") + "\n";
}
export const unMoney = (s: string) => String(s).replace(/[$,]/g, "").replace("", "-");
/** tel: href for a phone number (digits and + only); empty when there is no usable number. */
export function telHref(phone: string | undefined | null): string { const d = String(phone || "").replace(/[^0-9+]/g, ""); return d ? "tel:" + d : ""; }
// ---------------------------------------------------------------- GTIN / barcode decoding
// Pure, offline. Used to sanity-check a scan before it becomes a catalogue item: a mis-read barcode
// almost always fails the check digit, so we can say "that didn't read cleanly, scan it again"
// instead of silently binding a wrong number to a garment.
const GS1_PREFIXES: [number, number, string][] = [
[0, 19, "United States / Canada"], [20, 29, "In-store / restricted"], [30, 39, "United States"],
[40, 49, "In-store / restricted"], [50, 59, "Coupon"], [60, 139, "United States / Canada"],
[200, 299, "In-store / restricted"], [300, 379, "France"], [380, 380, "Bulgaria"], [383, 383, "Slovenia"],
[385, 385, "Croatia"], [387, 387, "Bosnia & Herzegovina"], [389, 389, "Montenegro"], [390, 390, "Kosovo"],
[400, 440, "Germany"], [450, 459, "Japan"], [460, 469, "Russia"], [470, 470, "Kyrgyzstan"], [471, 471, "Taiwan"],
[474, 474, "Estonia"], [475, 475, "Latvia"], [476, 476, "Azerbaijan"], [477, 477, "Lithuania"], [478, 478, "Uzbekistan"],
[479, 479, "Sri Lanka"], [480, 480, "Philippines"], [481, 481, "Belarus"], [482, 482, "Ukraine"], [483, 483, "Turkmenistan"],
[484, 484, "Moldova"], [485, 485, "Armenia"], [486, 486, "Georgia"], [487, 487, "Kazakhstan"], [488, 488, "Tajikistan"],
[489, 489, "Hong Kong"], [490, 499, "Japan"], [500, 509, "United Kingdom"], [520, 521, "Greece"], [528, 528, "Lebanon"],
[529, 529, "Cyprus"], [530, 530, "Albania"], [531, 531, "North Macedonia"], [535, 535, "Malta"], [539, 539, "Ireland"],
[540, 549, "Belgium / Luxembourg"], [560, 560, "Portugal"], [569, 569, "Iceland"], [570, 579, "Denmark"],
[590, 590, "Poland"], [594, 594, "Romania"], [599, 599, "Hungary"], [600, 601, "South Africa"], [603, 603, "Ghana"],
[608, 608, "Bahrain"], [609, 609, "Mauritius"], [611, 611, "Morocco"], [613, 613, "Algeria"], [615, 615, "Nigeria"],
[616, 616, "Kenya"], [618, 618, "Ivory Coast"], [619, 619, "Tunisia"], [620, 620, "Tanzania"], [621, 621, "Syria"],
[622, 622, "Egypt"], [623, 623, "Brunei"], [624, 624, "Libya"], [625, 625, "Jordan"], [626, 626, "Iran"],
[627, 627, "Kuwait"], [628, 628, "Saudi Arabia"], [629, 629, "United Arab Emirates"], [630, 630, "Qatar"],
[640, 649, "Finland"], [690, 699, "China"], [700, 709, "Norway"], [729, 729, "Israel"], [730, 739, "Sweden"],
[740, 745, "Central America"], [746, 746, "Dominican Republic"], [750, 750, "Mexico"], [754, 755, "Canada"],
[759, 759, "Venezuela"], [760, 769, "Switzerland"], [770, 771, "Colombia"], [773, 773, "Uruguay"], [775, 775, "Peru"],
[777, 777, "Bolivia"], [778, 779, "Argentina"], [780, 780, "Chile"], [784, 784, "Paraguay"], [786, 786, "Ecuador"],
[789, 790, "Brazil"], [800, 839, "Italy"], [840, 849, "Spain"], [850, 850, "Cuba"], [858, 858, "Slovakia"],
[859, 859, "Czechia"], [860, 860, "Serbia"], [865, 865, "Mongolia"], [867, 867, "North Korea"], [868, 869, "Turkey"],
[870, 879, "Netherlands"], [880, 880, "South Korea"], [883, 883, "Myanmar"], [884, 884, "Cambodia"],
[885, 885, "Thailand"], [888, 888, "Singapore"], [890, 890, "India"], [893, 893, "Vietnam"], [896, 896, "Pakistan"],
[899, 899, "Indonesia"], [900, 919, "Austria"], [930, 939, "Australia"], [940, 949, "New Zealand"],
[950, 951, "GS1 Global Office"], [955, 955, "Malaysia"], [958, 958, "Macau"],
[960, 969, "GS1 Global Office"], [977, 977, "Periodical (ISSN)"], [978, 979, "Book (ISBN)"], [980, 980, "Refund receipt"],
[981, 984, "Coupon"], [990, 999, "Coupon"],
];
/** Digits only, capped so a rogue scan can't blow up a field. */
export function gtinDigits(raw: string): string { return String(raw || "").replace(/\D/g, "").slice(0, 18); }
/** Standard GS1 mod-10 check digit over the code's leading digits. */
export function gtinCheckDigit(body: string): number {
let sum = 0;
for (let i = body.length - 1, w = 3; i >= 0; i--, w = w === 3 ? 1 : 3) sum += w * (body.charCodeAt(i) - 48);
return (10 - (sum % 10)) % 10;
}
export type GtinInfo = { code: string; digits: string; kind: string; valid: boolean; origin: string; company: string };
/** What we can tell about a scanned number without asking anybody: format, check digit, GS1 prefix. */
export function gtinInfo(raw: string): GtinInfo {
const digits = gtinDigits(raw);
const kind = digits.length === 13 ? "EAN-13" : digits.length === 12 ? "UPC-A" : digits.length === 8 ? "EAN-8" : digits.length === 14 ? "GTIN-14" : "";
const valid = !!kind && gtinCheckDigit(digits.slice(0, -1)) === digits.charCodeAt(digits.length - 1) - 48;
// Prefix lookup runs on the 13-digit form (UPC-A is an EAN-13 with a leading zero).
const ean = digits.length === 12 ? "0" + digits : digits.length === 14 ? digits.slice(1) : digits;
let origin = "";
if (ean.length === 13) { const p = parseInt(ean.slice(0, 3), 10); for (const [lo, hi, name] of GS1_PREFIXES) if (p >= lo && p <= hi) { origin = name; break; } }
const company = ean.length === 13 ? ean.slice(0, 7) : "";
return { code: String(raw || "").trim(), digits, kind, valid, origin, company };
}
/** One-line human summary of a scanned code for the quick-add screens. */
export function gtinNote(g: GtinInfo): string {
if (!g.kind) return g.digits.length ? `${g.digits.length}-digit code — not a standard retail barcode. That's fine, it can still be bound.` : "";
if (!g.valid) return `${g.kind} check digit doesn't match — the scan may have mis-read. Scan it again to be sure.`;
return `Valid ${g.kind}${g.origin ? " · issued in " + g.origin : ""}`;
}