// 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; // code -> "itemId:si" stock: Record; // "itemId:si" -> levels locations: LocationRec[]; placed: Record; // "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 XS–5XL or in 6–24, so a run says nothing about what the * garment is — that's what the product type field is for. */ export const SIZE_SETS: Record = { "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(); 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(), 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 { const m: Record = {}; 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, id: string | null | undefined): LocationRec[] { const out: LocationRec[] = []; let cur = id ? byId[id] : undefined; const seen = new Set(); 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, 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, 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 { const kids: Record = {}; for (const l of s.locations) if (l.parentId) (kids[l.parentId] ||= []).push(l.id); const out = new Set([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 = {}; 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; 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 { const m: Record = {}; for (const it of s.catalog) m[it.id] = it; return m; } export function staffMap(s: Snapshot): Record { const m: Record = {}; 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): { byKey: Record; total: number } { const byKey: Record = {}; let total = 0; for (const o of s.orders) { if (o.status === "Received" || o.status === "Cancelled") continue; const recvd: Record = {}; 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 { const m: Record = {}; 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): 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 { 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 = { "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 = {}; 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 { const byId = itemMap(s); const m: Record = {}; 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): 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 = {}; 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, staffById: Record, 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): { 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 : ""}`; }