import { prisma } from "./db"; import { addDays, facilityDate, facilityToday, fmtDate, garmentForGroup, garmentForStyle, isKitGroup, isNursingGroup, isPantItem, isTopItem, key, ledger, onhand, reorderAt, type Snapshot, } from "./compute"; import { allowance, capState, garmentCounts, setsHeld } from "./sets"; import { OPEN_REQUEST, approvedLines, decisionSummary, garmentCount, lineStatusLabel, roundWard, type StockWord, stockWord, } from "./staffreq"; import type { StaffSession } from "./staffsession"; /* Everything the staff screens read. * * No facility snapshot, anywhere. A wearer's screen has no business * holding the register, and the availability queries below are the one place that touches * facility-wide data — deliberately, narrowed to the garments being asked about, and only ever to * turn a count into a word. * * The one other reach across is four columns of settings — the site's two lists of which groups * take the FTE table and which the starting kit, its ceiling, and how many sets that kit is — * selected alongside the wearer's own row where an allowance is being worked out. Without them this * screen quotes the standing 3 at a site that issues 4, and cannot tell which route the wearer is * on, so it tells somebody owed a starting kit that they start on nothing. */ export type Availability = { size: string; si: number; word: StockWord; countedOn: string }; /** Stock as a ward is allowed to see it: words, never numbers. * * The count itself never leaves this function. That is the product rule and it is also the only * honest position — the linen room's count is the audited one, and a number on a ward screen just * starts an argument at the counter about whether the shelf really holds four. */ export async function availability(facilityId: string, itemIds: string[]): Promise> { if (!itemIds.length) return {}; const [fac, items, levels, issues, receiptLines, moves, takes] = await Promise.all([ prisma.facility.findUniqueOrThrow({ where: { id: facilityId }, select: { defaultReorder: true } }), prisma.catalogItem.findMany({ where: { id: { in: itemIds }, facilityId }, select: { id: true, sizes: true } }), prisma.stockLevel.findMany({ where: { facilityId, itemId: { in: itemIds } }, select: { itemId: true, sizeIndex: true, opening: true, adj: true, reorder: true, preloved: true } }), prisma.issue.findMany({ where: { facilityId, itemId: { in: itemIds } }, select: { itemId: true, sizeIndex: true, qty: true, direct: true, preloved: true, returnedDate: true, returnedCond: true } }), prisma.receiptLine.findMany({ where: { itemId: { in: itemIds }, receipt: { order: { facilityId } } }, select: { itemId: true, size: true, qty: true, dest: true } }), prisma.stockMove.findMany({ where: { facilityId, itemId: { in: itemIds } }, select: { itemId: true, sizeIndex: true, qty: true } }), prisma.stocktake.findMany({ where: { facilityId }, orderBy: { date: "desc" }, take: 40, select: { date: true, lines: { select: { itemId: true, sizeIndex: true } } } }), ]); const counted = new Map(); for (const t of takes) for (const l of t.lines) { const k = key(l.itemId, l.sizeIndex); if (!counted.has(k)) counted.set(k, t.date); // takes are newest-first, so the first wins } /* On hand is the linen room's number or it is a fiction. * * There is one on-hand expression in ThreadCount — ledger() and onhand() in lib/compute.ts: * opening plus adjustments, plus what shelf receipts and stock moves brought in, less what has * been issued, plus what came back in good condition. The word a ward is shown has to come from * that same arithmetic and not from a cheaper sum that happens to be easy to write here. A * second formula does not drift a little: this one told wards "None on shelf" for full bays, * because stock arriving against a supplier order never touches StockLevel at all. * * So rather than restate the sum, this loads the four things ledger() actually reads — the * catalogue, receipt lines, issues and stock moves — narrowed to the garments being asked * about, and hands them to the coordinator's own function. The cast is what that costs: * ledger() and onhand() take a whole Snapshot, and a wearer's screen has no business building * one. Neither function reads a field outside the ones set below. */ const stock: Snapshot["stock"] = {}; for (const l of levels) stock[key(l.itemId, l.sizeIndex)] = { opening: l.opening, adj: l.adj, reorder: l.reorder, preloved: l.preloved }; const scoped = { settings: { defaultReorder: fac.defaultReorder }, catalog: items.map((i) => ({ id: i.id, sizes: i.sizes })), stock, issues: issues.map((i) => ({ itemId: i.itemId, si: i.sizeIndex, qty: i.qty, direct: i.direct, preloved: i.preloved, returned: i.returnedDate ? { cond: i.returnedCond || "" } : null, })), orders: [{ receipts: [{ lines: receiptLines }] }], moves: moves.map((m) => ({ itemId: m.itemId, si: m.sizeIndex, qty: m.qty })), } as unknown as Snapshot; const L = ledger(scoped); const out: Record = {}; for (const it of items) { out[it.id] = it.sizes.map((size, si) => { const k = key(it.id, si); // reorderAt() falls back to the facility default, exactly as the linen room's own screens do. // Without that fallback a size with no per-size reorder level goes straight from "In stock" // to "None on shelf", and "Low" is a word the ward never once sees. return { size: String(size), si, word: stockWord(onhand(scoped, L, k), reorderAt(scoped, k)), countedOn: counted.get(k) || "" }; }); } return out; } /* ---------- a request, as a screen wants it ---------- * * A request covers as many garments as the person needs, one line each, so every screen that used * to print "2 × Tunic — 16" off the request itself now walks a list. The shaping is here rather * than in each caller because the manager's queue, the wearer's orders, the ward round and the * linen room's queue all have to describe the same bag the same way — the day they disagree is the * day somebody signs for two garments and goes looking for a third. */ export type ReqLine = { id: string; itemId: string; item: string; size: string; si: number; qty: number; /** The cut, so a slip or a screen can tell two garments of the same name apart. Male | Female | * Unisex, as stored — call genderLabel() for the words. */ gender: string; /** awaiting | approved | declined, and the word for it. */ status: string; statusLabel: string; declineReason: string | null; }; type LineRecord = { id: string; itemId: string; sizeIndex: number; qty: number; status: string; declineReason: string | null; item: { item: string; gender: string; sizes: string[] }; }; export function reqLines(lines: readonly LineRecord[]): ReqLine[] { return lines.map((l) => ({ id: l.id, itemId: l.itemId, item: l.item.item, gender: l.item.gender, size: String(l.item.sizes[l.sizeIndex] ?? l.sizeIndex), si: l.sizeIndex, qty: l.qty, status: l.status, statusLabel: lineStatusLabel(l.status), declineReason: l.declineReason, })); } /** The lines a row is actually about. * * Once the manager has left something to pick, that is the bag, and a declined fleece has no * business padding out the linen room's pick list or the wearer's "ready to collect" card. Before * a decision — and when every line was refused — there is no bag, so the whole ask is the subject. * The refusals are never hidden: they stay in `lines` with their own word and reason. */ export function bagLines(lines: readonly ReqLine[]): ReqLine[] { const picked = approvedLines(lines); return picked.length ? picked : [...lines]; } /** The one-liner for a collapsed row. A single-garment request reads exactly as it always did; a * longer one leads with the total, because "5 garments" is the thing somebody picking or carrying * a bag needs before the names. */ export function linesSummary(lines: readonly ReqLine[]): string { const bag = bagLines(lines); if (!bag.length) return ""; if (bag.length === 1) return `${bag[0].qty} × ${bag[0].item} — ${bag[0].size}`; const names = bag.slice(0, 3).map((l) => l.item); const rest = bag.length - names.length; return `${garmentCount(bag)} garments · ${names.join(", ")}${rest ? ` +${rest} more` : ""}`; } export type ReqRow = { id: string; code: string; status: string; lines: ReqLine[]; /** The collapsed one-liner, and the totals behind it — all three about the bag, per bagLines(). */ summary: string; garments: number; lineCount: number; /** "2 of 3 approved", or null while it is still with the manager. */ decision: string | null; reason: string; note: string; managerName: string; declineReason: string | null; collectCode: string | null; holdUntil: string; route: string | null; signerName: string | null; signerRole: string | null; subjectName: string; raisedByName: string; /** The ward the bag was sent out to, per roundWard() — blank until it goes on a round. Never the * wearer's current ward: that is what made a transferred nurse's order name the wrong desk. */ ward: string; /** Is the viewer the wearer? False on a request they raised for somebody else. */ mine: boolean; createdAt: string; }; function reqRow(r: { id: string; code: string; status: string; reason: string; note: string; subjectId: string; managerName: string; declineReason: string | null; collectCode: string | null; holdUntil: string; route: string | null; signerName: string | null; signerRole: string | null; raisedByName: string; createdAt: Date; lines: LineRecord[]; events: readonly { label: string; meta: string }[]; subject: { first: string; last: string }; }, viewerId: string): ReqRow { const lines = reqLines(r.lines); const bag = bagLines(lines); return { id: r.id, code: r.code, status: r.status, lines, summary: linesSummary(lines), garments: garmentCount(bag), lineCount: lines.length, decision: decisionSummary(lines), reason: r.reason, note: r.note, managerName: r.managerName, declineReason: r.declineReason, collectCode: r.collectCode, holdUntil: r.holdUntil, route: r.route, signerName: r.signerName, signerRole: r.signerRole, subjectName: `${r.subject.first} ${r.subject.last}`.trim(), raisedByName: r.raisedByName, ward: roundWard(r.events), mine: r.subjectId === viewerId, createdAt: r.createdAt.toISOString(), }; } const REQ_INCLUDE = { lines: { include: { item: { select: { item: true, gender: true, sizes: true } } }, orderBy: { sort: "asc" } }, subject: { select: { first: true, last: true } }, // The timeline comes along on every order row because it is the only record of which ward a bag // was actually delivered to — see roundWard(). events: { select: { label: true, meta: true } }, } as const; /** How long a decline stays eligible for the home screen's one live card. */ const DECLINE_HEADLINE_DAYS = 7; /** 1A Home. One live thing at the top, then shortcuts. */ export async function homeData(sess: StaffSession) { // The facility's own zone decides what "today" is here — which notice is still running, and how // recent a decline still counts as. The server's ambient zone gets no say in either. const fac = await prisma.facility.findUniqueOrThrow({ where: { id: sess.facilityId }, select: { name: true, timezone: true } }); const today = facilityToday(fac.timezone); const [staff, reqs, raised, notice, holdings] = await Promise.all([ prisma.staff.findUniqueOrThrow({ where: { id: sess.staffId }, select: { num: true, first: true, last: true, dept: true, top: true, pants: true, managerId: true, wardDesk: true }, }), prisma.request.findMany({ where: { subjectId: sess.staffId }, orderBy: { createdAt: "desc" }, take: 25, include: REQ_INCLUDE }), /* What they raised for somebody else — a manager, for one of their own reports. * * Every list on this app starts from `subjectId`, so a request they raised appeared on no * screen they could reach. Keyed on `raisedByStaffId` instead, which also keeps the requests * raised from the old desk route in front of the person who typed them. It is not theirs to * collect, so it does not compete for the live card; it sits in its own list with the * wearer's name on it. */ prisma.request.findMany({ where: { facilityId: sess.facilityId, raisedByStaffId: sess.staffId, subjectId: { not: sess.staffId }, status: { in: [...OPEN_REQUEST] }, }, orderBy: { createdAt: "desc" }, take: 25, include: REQ_INCLUDE, }), prisma.linenNotice.findFirst({ where: { facilityId: sess.facilityId, OR: [{ endsAt: "" }, { endsAt: { gte: today } }] }, orderBy: { createdAt: "desc" }, }), prisma.issue.aggregate({ where: { staffId: sess.staffId, returnedDate: null, handedIn: null }, _sum: { qty: true } }), ]); // "Furthest along" — the one the person most likely wants to act on. Ready to collect beats a // bag still out on the round, which beats something waiting on a manager. const RANK: Record = { ready: 6, round: 5, picking: 4, accepted: 3, awaiting: 2, declined: 1 }; // A decline is worth the top of the screen while it is news. Kept in the ranking forever it // becomes the permanent headline the moment every later request has finished — a refusal from // March greeting someone in September, with no way to dismiss it. After a week it lives in // Orders with everything else that is over. const declinedFrom = addDays(today, -DECLINE_HEADLINE_DAYS); const open = reqs.filter((r) => ( OPEN_REQUEST.has(r.status as never) || (r.status === "declined" && facilityDate(r.decidedAt ?? r.createdAt, fac.timezone) >= declinedFrom) )); const live = open.sort((a, b) => (RANK[b.status] || 0) - (RANK[a.status] || 0))[0]; return { name: `${staff.first} ${staff.last}`.trim(), num: staff.num, ward: staff.dept, facility: fac.name, hasManager: !!staff.managerId, wardDesk: staff.wardDesk, holding: holdings._sum.qty || 0, live: live ? reqRow(live, sess.staffId) : null, openCount: open.filter((r) => OPEN_REQUEST.has(r.status as never)).length, raisedOpen: raised.map((r) => reqRow(r, sess.staffId)), notice: notice?.body || "", }; } /** 1B My kit. */ export async function kitData(sess: StaffSession) { const staff = await prisma.staff.findUniqueOrThrow({ where: { id: sess.staffId }, select: { top: true, pants: true, facility: { select: { timezone: true } } }, }); const today = facilityToday(staff.facility.timezone); const fyFrom = (+today.slice(0, 4) - (+today.slice(5, 7) >= 7 ? 0 : 1)) + "-07-01"; const issues = await prisma.issue.findMany({ where: { staffId: sess.staffId }, orderBy: [{ date: "desc" }], select: { id: true, date: true, qty: true, sizeIndex: true, returnedDate: true, returnedCond: true, handedIn: true, item: { select: { id: true, item: true, sizes: true } } }, }); // Grouped by garment and size, which is how someone thinks about what they have — not as a // list of issuing events. const held = new Map(); for (const i of issues) { // A hand-in takes the garment off the person without ever setting returnedDate — it joins the // pre-loved pool rather than coming back as a return — so skipping only returns left handed-in // garments on someone's record here for good, and offered them up to Report damage. if (i.returnedDate || i.handedIn) continue; const k = `${i.item.id}:${i.sizeIndex}`; const cur = held.get(k) || { itemId: i.item.id, item: i.item.item, size: String(i.item.sizes[i.sizeIndex] ?? i.sizeIndex), si: i.sizeIndex, qty: 0, last: "", labelIds: [] }; cur.qty += i.qty; if (i.date > cur.last) cur.last = i.date; cur.labelIds.push(i.id); held.set(k, cur); } /* Handed back is both routes off a person's record, and is neither of the two write-offs. * * A garment brought to the counter is booked as a return and stamped returnedDate; a hand-in is * stamped handedIn and never returnedDate, because it joins the pre-loved pool rather than coming * back as a return. Counting returnedDate alone therefore told somebody who had carried five * garments in "Nothing handed back since 1 July" — the flat contradiction of what they had just * done at the counter, on the one screen they would check before arguing about it. * * Lost and Written Off also stamp returnedDate, and neither is a garment anybody handed back: one * never came home and the other was condemned. Crediting a person for them under that word would * be the same untruth in the other direction, so they are left out here. Nothing is hidden by it * — the write-off is on the linen room's record of the issue either way. */ const handedBackThisYear = issues.filter((i) => ( (i.handedIn && i.handedIn >= fyFrom) || (i.returnedDate && i.returnedDate >= fyFrom && i.returnedCond !== "Lost" && i.returnedCond !== "Written Off") )).reduce((n, i) => n + i.qty, 0); return { held: [...held.values()].sort((a, b) => a.item.localeCompare(b.item) || a.size.localeCompare(b.size)), total: [...held.values()].reduce((n, h) => n + h.qty, 0), handedBackThisYear, fyFrom, sizes: { top: staff.top, pants: staff.pants }, }; } /** 1C Orders. */ export async function ordersData(sess: StaffSession) { const [reqs, raised] = await Promise.all([ prisma.request.findMany({ where: { subjectId: sess.staffId }, orderBy: { createdAt: "desc" }, include: REQ_INCLUDE, }), /* The requests this person raised for other people: a manager, for their own team, plus * anything still on the record from the old desk route. Keying on `raisedByStaffId` rather * than on a flag is what keeps those older ones reachable. They are deliberately not folded * into `open`/`done`: what a wearer does with their own order (collect it, chase it, confirm * they picked it up) is not what the person who typed it in does with it, and mixing the two * lists is how somebody collects a bag that is not theirs. Every row carries `subjectName`, so * the screen can say who it is for. */ prisma.request.findMany({ where: { facilityId: sess.facilityId, raisedByStaffId: sess.staffId, subjectId: { not: sess.staffId } }, orderBy: { createdAt: "desc" }, include: REQ_INCLUDE, }), ]); const rows = reqs.map((r) => reqRow(r, sess.staffId)); const raisedRows = raised.map((r) => reqRow(r, sess.staffId)); const isOpen = (r: ReqRow) => OPEN_REQUEST.has(r.status as never); return { open: rows.filter(isOpen), done: rows.filter((r) => !isOpen(r)), raised: { open: raisedRows.filter(isOpen), done: raisedRows.filter((r) => !isOpen(r)) }, }; } /** 1D Order detail + 1F thread. Visible to the subject, their manager, or the clerk who raised it. */ export async function requestData(sess: StaffSession, id: string) { const [r, me] = await Promise.all([ prisma.request.findFirst({ where: { id, facilityId: sess.facilityId }, include: { ...REQ_INCLUDE, events: { orderBy: { at: "asc" } }, messages: { orderBy: { createdAt: "asc" } } }, }), prisma.staff.findUnique({ where: { id: sess.staffId }, select: { dept: true, wardDesk: true } }), ]); if (!r) return null; /* The subject, their manager and whoever raised it — plus the desk that is physically holding * the bag. /my/round lists a delivered bag to the ward the trolley left it on and offers * "Nudge", which opens this order's thread; without this clause that desk got the 404 and the * nudge went nowhere. Same fence as round.sign and round.claim: the ward on the timeline, and a * blank ward matches nobody. */ const party = r.subjectId === sess.staffId || r.managerId === sess.staffId || r.raisedByStaffId === sess.staffId; const onMyDesk = !!me?.wardDesk && !!me.dept.trim() && (r.status === "round" || r.status === "delivered") && roundWard(r.events) === me.dept; if (!party && !onMyDesk) return null; return { ...reqRow(r, sess.staffId), // Only this screen needs it: it is what decides whether the requester is still being asked to // confirm they picked the bag up off the ward desk, and the desk's unclaimed list is exactly // the delivered requests where it is still null. claimedAt: r.claimedAt?.toISOString() ?? null, events: r.events.map((e) => ({ id: e.id, label: e.label, meta: e.meta, actorName: e.actorName, at: e.at.toISOString() })), messages: r.messages.map((m) => ({ id: m.id, fromStaff: m.fromStaff, authorName: m.authorName, body: m.body, at: m.createdAt.toISOString() })), }; } /** 1E New request, and 1H Shelf check — both need the catalogue with words against it, and the * request screen also needs to tell the person what they already hold and what they are allowed. */ export async function catalogueData(sess: StaffSession) { const [catalog, staff, issues] = await Promise.all([ prisma.catalogItem.findMany({ where: { facilityId: sess.facilityId, archived: false }, orderBy: { sort: "asc" }, select: { id: true, item: true, type: true, gender: true, sizes: true, groups: true }, }), prisma.staff.findUniqueOrThrow({ where: { id: sess.staffId }, select: { top: true, pants: true, group: true, uniformStyle: true, managerId: true, facility: { select: { nursingGroups: true, kitGroups: true, capSets: true, initialSets: true } } } }), /* Every issue, not just the ones still out. The live ones are what they hold; the returned and * handed-in ones are still the best evidence of what size fits them, which is the only thing * the record knows about a jacket or a fleece. Newest first so the first row wins per garment. */ prisma.issue.findMany({ where: { staffId: sess.staffId }, orderBy: { date: "desc" }, select: { itemId: true, sizeIndex: true, qty: true, returnedDate: true, handedIn: true, item: { select: { type: true, item: true, sizes: true } } }, }), ]); // Their own staff group's garments and those for every group, in the cut they are offered — the // same two questions request.create asks before it refuses. Somebody set to Men's sees the men's // range and the unisex one; somebody set to Either, or whom nobody has set, sees every cut, which // is what everybody sees today. Nothing else, not even another group's or another cut's garment // they are holding from before the rule: a request for it is refused, and a damage replacement is // raised from the issue itself, not from this list. const items = catalog.filter((i) => garmentForGroup(i, staff.group) && garmentForStyle(i, staff.uniformStyle)); const avail = await availability(sess.facilityId, items.map((i) => i.id)); const manager = staff.managerId ? await prisma.staff.findUnique({ where: { id: staff.managerId }, select: { first: true, last: true } }) : null; /* What they are holding, per garment and per size, and the size the record last saw them in. * * A hand-in takes a garment off somebody without ever marking it returned, so both have to be * excluded from the holdings or the screen tells a person they still have what they gave back. */ const held = new Map>(); const lastSize = new Map(); const holdings: { item: { type: string; item: string }; qty: number }[] = []; for (const i of issues) { if (!lastSize.has(i.itemId)) lastSize.set(i.itemId, String(i.item.sizes[i.sizeIndex] ?? "")); if (i.returnedDate || i.handedIn) continue; const bySize = held.get(i.itemId) || new Map(); bySize.set(i.sizeIndex, (bySize.get(i.sizeIndex) || 0) + i.qty); held.set(i.itemId, bySize); holdings.push({ item: i.item, qty: i.qty }); } /* The same allowance sum the manager sees on the review screen, from the same function. * * Somebody can be declined "Over allowance" against a number their own app has never shown them, * which is the sort of refusal that gets a linen room a phone call rather than an apology. The * two screens have to agree, so neither one gets its own arithmetic. */ const sets = setsHeld(holdings); const allow = allowance({ group: staff.group, held: sets, // The person reading this screen is the person being measured, so their own group answers it. // Both answers are passed rather than left off: without them this screen and the manager's // review describe two different allowances, and the wearer is declined against the one they // were never shown. Leaving the starting-kit one off tells somebody owed a kit they start on // nothing. nursing: isNursingGroup(staff.facility.nursingGroups, staff.group), kit: isKitGroup(staff.facility.kitGroups, staff.group), capSets: staff.facility.capSets, startingSets: staff.facility.initialSets, }); return { items: items.map((i) => { const bySize = held.get(i.id) || new Map(); /* Only tops and trousers have a size on the register, so a dress, a fleece or a vest has * always come up blank and the wearer guessed. The size of the last one they were issued is * what the record does know, and it is a better opening bid than nothing — `recordedSource` * says which it is so the screen can hint rather than assert. */ const fromRecord = isTopItem(i) ? staff.top : isPantItem(i) ? staff.pants : ""; const fromIssue = lastSize.get(i.id) || ""; const recorded = fromRecord || fromIssue; const sizes: Availability[] = avail[i.id] || i.sizes.map((s, si) => ({ size: String(s), si, word: "none" as StockWord, countedOn: "" })); return { id: i.id, item: i.item, type: i.type, gender: i.gender, sizes: sizes.map((s) => ({ ...s, held: bySize.get(s.si) || 0 })), // Selecting a garment resets the size to the person's recorded size for that garment type. recorded, recordedSource: recorded ? (fromRecord ? ("record" as const) : ("issued" as const)) : ("" as const), held: [...bySize.values()].reduce((n, q) => n + q, 0), }; }), managerName: manager ? `${manager.first} ${manager.last}`.trim() : "", // Deliberately the same two keys the manager's review screen returns, so the wearer reads the // sentence they will be judged on before they ask rather than in the decline. holding: { total: holdings.reduce((n, h) => n + h.qty, 0), sets }, allowance: { capped: allow.capped, label: allow.capped ? `${allow.used} of ${allow.cap} sets` : `${staff.group || "This role"} — no fixed cap. Your manager's approval is the control.`, note: allow.note, /* Sets alone under-warn: the ceiling the hand-over applies (lib/sets.ts capState, the same * one capCheck uses at the counter) bites per half, so six tops and two pairs is "2 of 6 * sets" here and yet the next top is refused or stamped as an override. Ask the ceiling's * own question as well, so the wearer is warned before they ask rather than after. */ over: capState({ held: garmentCounts(holdings), capSets: staff.facility.capSets }).over || (allow.capped && allow.cap !== null && allow.used >= allow.cap), }, }; } /** 1G Report damage — their own holdings, each with the issue id the linen room can trace. */ export async function damageData(sess: StaffSession) { const issues = await prisma.issue.findMany({ where: { staffId: sess.staffId, returnedDate: null, handedIn: null }, orderBy: { date: "desc" }, select: { id: true, date: true, qty: true, sizeIndex: true, item: { select: { id: true, item: true, sizes: true } } }, }); const staff = await prisma.staff.findUniqueOrThrow({ where: { id: sess.staffId }, select: { num: true } }); const avail = await availability(sess.facilityId, [...new Set(issues.map((i) => i.item.id))]); return { holdings: issues.map((i) => ({ issueId: i.id, itemId: i.item.id, item: i.item.item, size: String(i.item.sizes[i.sizeIndex] ?? i.sizeIndex), si: i.sizeIndex, qty: i.qty, // The label the linen room prints, so a garment in a hand can be matched to a row here. labelId: `TC-${staff.num}-${i.id.slice(-4).toUpperCase()}`, issued: fmtDate(i.date), replacement: (avail[i.item.id] || []).find((a) => a.si === i.sizeIndex)?.word ?? ("none" as StockWord), })), }; } export { fmtDate };