ThreadCount Community edition
Uniform stock management for healthcare linen rooms: the coordinator app, the phone counter and the staff app, for your own server. Built from 2d04e45 on 2026-09-15. Licensed under the Functional Source License (FSL-1.1-ALv2).
This commit is contained in:
@@ -0,0 +1,107 @@
|
||||
import { prisma } from "./db";
|
||||
import { availability } from "./staffdata";
|
||||
import { facilityDate } from "./compute";
|
||||
import { holdEndsAt, holdExpired } from "./staffreq";
|
||||
import type { StaffSession } from "./staffsession";
|
||||
|
||||
/* The two periodic surfaces: the kit check cycle and the waitlist. */
|
||||
|
||||
/** 2A. Null when there is no cycle open — the screen shouldn't exist between rounds. */
|
||||
export async function kitCheckData(sess: StaffSession) {
|
||||
const cycle = await prisma.kitCheck.findFirst({
|
||||
where: { facilityId: sess.facilityId, closedAt: null },
|
||||
orderBy: { openedAt: "desc" },
|
||||
select: { id: true, dueBy: true, facility: { select: { timezone: true } } },
|
||||
});
|
||||
if (!cycle) return null;
|
||||
|
||||
const [issues, answers, previous] = await Promise.all([
|
||||
// handedIn, not just returnedDate: a garment handed back at the counter joins the pre-loved
|
||||
// pool without ever being marked returned, and asking someone to confirm they still hold it
|
||||
// is how a kit check teaches people the record is wrong.
|
||||
prisma.issue.findMany({
|
||||
where: { staffId: sess.staffId, returnedDate: null, handedIn: null },
|
||||
select: { qty: true, sizeIndex: true, item: { select: { id: true, item: true, sizes: true } } },
|
||||
}),
|
||||
prisma.kitCheckAnswer.findMany({
|
||||
where: { kitCheckId: cycle.id, staffId: sess.staffId },
|
||||
select: { itemId: true, sizeIndex: true, confirmed: true },
|
||||
}),
|
||||
// "Last confirmed" comes from the previous cycle they actually answered, not from the cycle
|
||||
// before this one — somebody who missed the last round should be told the truth.
|
||||
prisma.kitCheckAnswer.findFirst({
|
||||
where: { staffId: sess.staffId, kitCheckId: { not: cycle.id } },
|
||||
orderBy: { answeredAt: "desc" },
|
||||
select: { answeredAt: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
const byKey = new Map<string, { itemId: string; item: string; size: string; si: number; onRecord: number }>();
|
||||
for (const i of issues) {
|
||||
const k = `${i.item.id}:${i.sizeIndex}`;
|
||||
const cur = byKey.get(k) || { itemId: i.item.id, item: i.item.item, size: String(i.item.sizes[i.sizeIndex] ?? i.sizeIndex), si: i.sizeIndex, onRecord: 0 };
|
||||
cur.onRecord += i.qty;
|
||||
byKey.set(k, cur);
|
||||
}
|
||||
const answered = new Map(answers.map((a) => [`${a.itemId}:${a.sizeIndex}`, a.confirmed]));
|
||||
|
||||
return {
|
||||
dueBy: cycle.dueBy,
|
||||
lastConfirmed: previous ? facilityDate(previous.answeredAt, cycle.facility.timezone) : "",
|
||||
rows: [...byKey.values()]
|
||||
.sort((a, b) => a.item.localeCompare(b.item) || a.size.localeCompare(b.size))
|
||||
.map((r) => ({ ...r, answered: answered.has(`${r.itemId}:${r.si}`) ? answered.get(`${r.itemId}:${r.si}`)! : null })),
|
||||
};
|
||||
}
|
||||
|
||||
/** 2B. Position is FIFO on join time, counting only people still waiting. */
|
||||
export async function waitlistData(sess: StaffSession, itemId: string, si: number) {
|
||||
const item = await prisma.catalogItem.findFirst({
|
||||
where: { id: itemId, facilityId: sess.facilityId },
|
||||
select: { id: true, item: true, sizes: true },
|
||||
});
|
||||
if (!item || si < 0 || si >= item.sizes.length) return null;
|
||||
|
||||
const [queue, mine, avail] = await Promise.all([
|
||||
prisma.waitlistEntry.findMany({
|
||||
where: { facilityId: sess.facilityId, itemId, sizeIndex: si, leftAt: null, acceptedAt: null },
|
||||
orderBy: { createdAt: "asc" },
|
||||
select: { id: true, staffId: true, createdAt: true, offeredAt: true },
|
||||
}),
|
||||
prisma.waitlistEntry.findFirst({ where: { staffId: sess.staffId, itemId, sizeIndex: si, leftAt: null }, select: { id: true, offeredAt: true, acceptedAt: true } }),
|
||||
availability(sess.facilityId, [itemId]),
|
||||
]);
|
||||
|
||||
const sizes = avail[itemId] || [];
|
||||
const mineIndex = queue.findIndex((q) => q.staffId === sess.staffId);
|
||||
const joined = mineIndex >= 0;
|
||||
|
||||
return {
|
||||
itemId: item.id,
|
||||
item: item.item,
|
||||
size: String(item.sizes[si]),
|
||||
si,
|
||||
lastRestocked: sizes.find((s) => s.si === si)?.countedOn || "",
|
||||
// Already on the list: their real place. Not on it: the place they would take.
|
||||
position: joined ? mineIndex + 1 : queue.length + 1,
|
||||
ahead: joined ? mineIndex : queue.length,
|
||||
joined,
|
||||
entryId: mine?.id ?? null,
|
||||
offeredAt: mine?.offeredAt?.toISOString() ?? null,
|
||||
// The screen and the offer email both promise the garment is held for 48 hours, so the
|
||||
// deadline is computed rather than implied, and the screen is told when it has passed —
|
||||
// an offer bar that can no longer be accepted is worse than none.
|
||||
holdUntil: holdEndsAt(mine?.offeredAt ?? null)?.toISOString() ?? null,
|
||||
offerExpired: holdExpired(mine?.offeredAt ?? null),
|
||||
// Accepting raises a request and the entry stays put, so without this the screen keeps
|
||||
// offering "Accept it" for something already accepted and every tap is refused.
|
||||
acceptedAt: mine?.acceptedAt?.toISOString() ?? null,
|
||||
// Nearest sizes either way that are actually on the shelf — most people would rather have
|
||||
// something that fits approximately today.
|
||||
alternatives: sizes
|
||||
.filter((s) => s.si !== si && s.word !== "none")
|
||||
.sort((a, b) => Math.abs(a.si - si) - Math.abs(b.si - si))
|
||||
.slice(0, 3)
|
||||
.map((s) => ({ si: s.si, size: s.size, word: s.word })),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user