"use client";
import { useCallback, useId, useMemo, useState, type CSSProperties } from "react";
import { useRouter } from "next/navigation";
import { useDerived, useSnap } from "@/lib/client";
import { Dialog, ErrorLine, Field, ItemSizePicker, Stepper } from "@/components/ui";
import Camera from "@/components/Camera";
import { addDays, ALL_GROUPS, bcParse, ccBudgetNote, facilityToday, FTE_OPTIONS, PRODUCT_TYPES, UNIFORM_STYLES, ccOf, fmtDate, garmentGroups, groupKey, isNursing, key, label, leadDaysOf, money, onhand, plOf, setHalf, staffName, type ApprovalRec, type HandInRec, type IssueRec, type Item, type OrderRec, type StaffRec, SIZE_SETS } from "@/lib/compute";
import { gtinInfo, gtinNote } from "@/lib/compute";
import { esc, openPrintWindow } from "@/lib/print";
import { takePhoto, uploadPhoto, viewPhoto } from "@/lib/photo";
import { useEffect, useRef } from "react";
import { daysBetween, telHref, type PickupRec } from "@/lib/compute";
// ---------------------------------------------------------------- Adjust quantity
export function AdjustDialog({ init, onClose }: { init?: { itemId: string; si: number; preloved?: boolean } | null; onClose: () => void }) {
const { s, isAdmin, mutate } = useSnap();
const { L, byId } = useDerived();
// "Set on hand" is what someone almost always means by adjusting a line, so it leads for admins;
// issuers can't correct counts (same rule as Adjust) and start on Receive.
const [mode, setMode] = useState<"Set" | "Pre-loved" | "Receive" | "Adjust" | "Opening">(init?.preloved ? "Pre-loved" : isAdmin ? "Set" : "Receive");
const [pick, setPick] = useState(init?.itemId || "");
const [lines, setLines] = useState<{ itemId: string; si: number; qty: string }[]>(init?.itemId ? [{ itemId: init.itemId, si: init.si, qty: "" }] : []);
const [reason, setReason] = useState("Correction");
const [err, setErr] = useState("");
const [busy, setBusy] = useState(false);
const MODES: [typeof mode, string, string][] = [
["Set", "Set on hand", "Enter what you actually counted and ThreadCount works out the difference."],
["Receive", "Receive", "Stock that arrived without an order. Adds to what’s already there."],
["Adjust", "Adjust", "Add or subtract a number with a reason. Negative quantities write stock off."],
["Pre-loved", "Pre-loved", "Add garments to the pre-loved pool. Negative quantities correct it."],
["Opening", "Opening balance", "Overwrite a line’s opening balance — start-up only."],
];
const now = (l: { itemId: string; si: number }) => (mode === "Pre-loved" ? plOf(s, key(l.itemId, l.si)) : onhand(s, L, key(l.itemId, l.si)));
/** What the line will read once this is saved — shown per line so no mode can surprise anyone. */
const after = (l: { itemId: string; si: number; qty: string }) => {
const q = parseInt(l.qty, 10);
if (!Number.isFinite(q)) return null;
if (mode === "Set") return Math.max(0, q);
if (mode === "Opening") return null; // opening is one component of on-hand, not the total
if (mode === "Receive") return now(l) + Math.abs(q);
if (mode === "Pre-loved") return Math.max(0, now(l) + q);
return now(l) + q;
};
/** A line nobody has finished filling in — blank, a lone minus sign, or a counted figure below
* zero. Nothing can be filed from one of these, so they hold the save back. */
const unusable = (l: { itemId: string; si: number; qty: string }) => {
if (l.qty === "" || l.qty === "-") return true;
const q = parseInt(l.qty, 10);
if (!Number.isFinite(q)) return true;
if (mode === "Set") return q < 0;
return q === 0 && mode !== "Opening";
};
/** A counted figure that already agrees with the shelf is not a mistake — it is a line with
* nothing to file, and stock.moves skips a zero delta at the other end anyway. Treating it as
* invalid used to kill the whole dialog: count three sizes after a delivery, get one of them
* right, and the two genuine corrections could not be saved at all until the coordinator guessed
* that the *correct* line was the obstacle and removed it. Only a set of lines where nothing
* whatsoever would change still blocks the save. */
const noChange = (l: { itemId: string; si: number; qty: string }) => mode === "Set" && parseInt(l.qty, 10) === now(l);
const allMatch = lines.length > 0 && lines.every(noChange);
const invalid = lines.length === 0 || lines.some(unusable) || allMatch;
async function save() {
if (invalid) return;
setBusy(true); setErr("");
const r = await mutate("stock.moves", { mode, reason: mode === "Set" ? "Counted correction" : reason, lines: lines.map((l) => ({ itemId: l.itemId, si: l.si, qty: parseInt(l.qty, 10) || 0 })) });
setBusy(false);
if (!r.ok) { setErr(r.error); return; }
onClose();
}
const cta = mode === "Set" ? "Save counted quantity" : mode === "Pre-loved" ? "Add to pre-loved pool" : mode === "Receive" ? "Receive into stock" : mode === "Adjust" ? "Apply adjustment" : "Set opening balance";
return (
);
}
// ---------------------------------------------------------------- A garment's staff groups
/** The staff groups a garment is for, as a tick-list of the facility's groups. EMPTY MEANS EVERY
* GROUP, and "All groups" is its own tick, so there is one way to say it. A group the garment is
* tagged with that is no longer on the facility's list still shows, ticked, so it can come off.
* Not a Field: the .field label style would turn every tick's label into a column heading. */
export const GROUPS_HINT = "Other groups can't request it, and need the override at the counter.";
export function GroupsPicker({ value, onChange, groups, hint, style }: { value: string[]; onChange: (groups: string[]) => void; groups: string[]; hint?: string; style?: CSSProperties }) {
const hintId = useId();
const on = (g: string) => value.some((x) => groupKey(x) === groupKey(g));
// "All" on the facility's list would make garmentGroups() read the whole list as every group.
const opts = garmentGroups([...groups, ...value].filter((g) => groupKey(g) !== "all" && groupKey(g) !== groupKey(ALL_GROUPS)));
const toggle = (g: string) => onChange(on(g) ? value.filter((x) => groupKey(x) !== groupKey(g)) : [...value, g]);
const tick: CSSProperties = { display: "flex", gap: 6, alignItems: "center", fontSize: 13, cursor: "pointer", minHeight: 28 };
const box: CSSProperties = { width: 15, height: 15, accentColor: "var(--color-accent)", margin: 0 };
return (
);
}
/** Two group lists hold the same groups, in any order — the test catalog.duplicate applies. */
const sameGroups = (a: readonly string[], b: readonly string[]) => {
const k = (l: readonly string[]) => garmentGroups(l).map(groupKey).sort().join("\n");
return k(a) === k(b);
};
// ---------------------------------------------------------------- Catalogue item (add / edit)
export function ItemDialog({ item, onClose, onSaved, barcode = "", initName = "" }: { item?: Item | null; onClose: () => void; onSaved?: (id: string, name?: string) => void; barcode?: string; initName?: string }) {
const { s, mutate } = useSnap();
const editing = !!item;
const [f, setF] = useState({
name: item?.item || initName, gender: item ? (item.gender === "Male" ? "Men's" : item.gender === "Female" ? "Women's" : "Unisex") : "Unisex",
type: item?.type || "", groups: item ? garmentGroups(item.groups) : [] as string[], sku: item?.sku || "", supplier: item?.supplier || s.settings.suppliers[0] || "",
cost: item ? String(item.cost) : "", notes: item?.notes || "", sizeSet: Object.keys(SIZE_SETS)[0], sizes: item ? [...item.sizes] : [] as string[], custom: "",
});
// One row per size on a new item: the barcode printed on that size's label, and what's on the shelf now.
const [rows, setRows] = useState>(() => (barcode ? {} : {}));
const [scanAt, setScanAt] = useState(null);
const [cam, setCam] = useState(false);
const [camMsg, setCamMsg] = useState("");
const [err, setErr] = useState("");
const [busy, setBusy] = useState(false);
const set = (p: Partial) => setF({ ...f, ...p });
const row = (sz: string) => rows[sz] || { code: "", opening: "" };
const setRow = (sz: string, p: Partial<{ code: string; opening: string }>) => setRows((r) => ({ ...r, [sz]: { ...(r[sz] || { code: "", opening: "" }), ...p } }));
// A barcode arriving from "Scan to add" belongs to whichever size the coordinator picks below.
const scannedSizes = f.sizes.filter((sz) => row(sz).code === barcode && !!barcode);
const bcPick = barcode && !editing ? (scannedSizes[0] || "") : "";
const dupCode = (() => {
const seen: Record = {};
for (const sz of f.sizes) { const c = row(sz).code.trim(); if (!c) continue; if (seen[c]) return c; seen[c] = sz; }
return "";
})();
const invalid = !f.name.trim() || f.sizes.length === 0 || !(parseFloat(f.cost) >= 0) || f.cost === "" || (!!barcode && !editing && !bcPick) || !!dupCode;
// The type is what the ceiling reads, and for every staff group, not only nursing: a top or a pair
// of trousers counts toward the sets one person holds, and anything else toward a ceiling of its
// own, counted in garments. It is a free-text field, and a type typed by hand that isn't on the
// list matches neither half — a scrub top saved as "scrubs" quietly stops counting toward
// anybody's six, and nothing at the counter would ever say so. So the form says which it will be,
// and warns when the list doesn't know the type.
const typeHalf = f.type ? setHalf({ type: f.type, item: f.name }) : null;
const typeListed = PRODUCT_TYPES.some((t) => t.toLowerCase() === f.type.toLowerCase());
async function save() {
if (invalid) return;
setBusy(true); setErr("");
const gender = f.gender === "Men's" ? "Male" : f.gender === "Women's" ? "Female" : "Unisex";
const payload = { item: f.name, gender, type: f.type, groups: f.groups, sku: f.sku, supplier: f.supplier, cost: parseFloat(f.cost), notes: f.notes, sizes: f.sizes };
const barcodes = f.sizes.map((sz, si) => ({ si, code: row(sz).code.trim() })).filter((b) => b.code);
const opening = f.sizes.map((sz, si) => ({ si, qty: parseInt(row(sz).opening, 10) || 0 })).filter((o) => o.qty > 0);
const r = editing
? await mutate("catalog.update", { id: item!.id, ...payload })
: await mutate<{ id: string }>("catalog.add", { ...payload, barcodes, opening });
setBusy(false);
if (!r.ok) { setErr(r.error); return; }
onSaved?.(editing ? item!.id : (r.result as { id: string }).id, f.name.trim());
onClose();
}
const addSize = (sz: string) => {
const v = sz.trim(); if (!v) return;
if (f.sizes.some((x) => x.toLowerCase() === v.toLowerCase())) return;
const next = [...f.sizes, v];
set({ sizes: next, custom: "" });
};
const dropSize = (sz: string) => { set({ sizes: f.sizes.filter((x) => x !== sz) }); setRows((r) => { const n = { ...r }; delete n[sz]; return n; }); };
/** Scan straight down the size list: fill the targeted row, then jump to the next one still empty. */
function camHit(raw: string) {
const code = String(raw).trim().slice(0, 64);
if (!code || !scanAt) return;
if (f.sizes.some((sz) => sz !== scanAt && row(sz).code.trim() === code)) { setCamMsg(`${code} is already on size ${f.sizes.find((sz) => row(sz).code.trim() === code)} — scan a different label`); return; }
setRow(scanAt, { code });
const next = f.sizes.find((sz) => sz !== scanAt && !row(sz).code.trim());
setScanAt(next || null);
setCamMsg(next ? `${code} → size ${scanAt}. Next: size ${next}` : `${code} → size ${scanAt}. Every size has a barcode.`);
}
return (
<>
{cam && setCam(false)} />}
>
);
}
// ---------------------------------------------------------------- New order
export function NewOrderDialog({ onClose, initLines, initNotes, initOrderFor, initStaffId, initSupplier }: { onClose: () => void; initLines?: { itemId: string; size: string; qty: number }[]; initNotes?: string; initOrderFor?: "Stock" | "Staff Member"; initStaffId?: string; initSupplier?: string }) {
const { s, mutate } = useSnap();
const { byId, staffById } = useDerived();
const router = useRouter();
const [orderFor, setOrderFor] = useState<"Stock" | "Staff Member">(initOrderFor || "Stock");
const [staffId, setStaffId] = useState(initStaffId || "");
const firstSupplier = initSupplier || s.settings.suppliers[0] || "";
const [supplier, setSupplierRaw] = useState(firstSupplier);
const [expected, setExpected] = useState(addDays(s.today, leadDaysOf(s, firstSupplier) || 14));
const setSupplier = (sup: string) => { setSupplierRaw(sup); const ld = leadDaysOf(s, sup); if (ld > 0) setExpected(addDays(s.today, ld)); };
const budgetNote = staffId && staffById[staffId] ? ccBudgetNote(s, byId, staffById, ccOf(s, staffById[staffId])) : "";
const [pick, setPick] = useState("");
const [lines, setLines] = useState(initLines || []);
const [err, setErr] = useState("");
const [busy, setBusy] = useState(false);
const total = lines.reduce((t, l) => t + l.qty * (byId[l.itemId]?.cost || 0), 0);
const invalid = lines.length === 0 || (orderFor === "Staff Member" && !staffId) || !supplier;
async function create() {
if (invalid) return;
setBusy(true); setErr("");
const r = await mutate<{ id: string }>("order.create", { orderFor, staffId, supplier, expected, lines, notes: initNotes || "" });
setBusy(false);
if (!r.ok) { setErr(r.error); return; }
onClose();
router.push(`/app/orders/${r.result.id}`);
}
// The running total belongs beside the button that commits it, so it goes in the foot, on the
// left. marginRight:auto rather than space-between: the foot is a right-aligned row, and one item
// opting out of that is cheaper than giving this one dialog a second layout.
return (
);
}
// ---------------------------------------------------------------- Receive delivery
export function ReceiveDialog({ order, onClose }: { order: OrderRec; onClose: () => void }) {
const { s, isAdmin, mutate } = useSnap();
const { byId, staffById } = useDerived();
const st = order.staffId ? staffById[order.staffId] : undefined;
const received = (itemId: string, size: string) => order.receipts.reduce((t, r) => t + r.lines.filter((x) => x.itemId === itemId && x.size === size).reduce((a, x) => a + x.qty, 0), 0);
const [invoice, setInvoice] = useState(order.invoice);
// Read off the clock as the dialog opens, not out of the snapshot. The linen-room PC stays signed
// in overnight and s.today only moves when something else in the facility does, so the morning's
// first delivery was pre-filled with yesterday — and over a month boundary that stamps the
// receipt, the order and the pickup into the previous month's supplier report and spend.
const [date, setDate] = useState(() => facilityToday(s.tz));
const [note, setNote] = useState("");
const [cam, setCam] = useState(false);
const [camMsg, setCamMsg] = useState("");
const [photoId, setPhotoId] = useState(null);
const [lines, setLines] = useState(order.lines.map((l) => { const it = byId[l.itemId]; const outstanding = Math.max(0, l.qty - received(l.itemId, l.size)); return { lineId: l.id, itemId: l.itemId, size: l.size, ordered: outstanding, arrived: String(outstanding), dest: order.orderFor === "Staff Member" ? "pickup" : "shelf", cost: String(it ? it.cost : 0), exp: it ? it.cost : 0, priceAction: "keep" as "keep" | "update" }; }));
const [err, setErr] = useState("");
const [busy, setBusy] = useState(false);
const setL = (i: number, p: Partial<(typeof lines)[number]>) => setLines(lines.map((l, j) => j === i ? { ...l, ...p } : l));
const invalid = lines.every((l) => (parseInt(l.arrived, 10) || 0) === 0);
async function save() {
if (invalid) return;
setBusy(true); setErr("");
const r = await mutate("order.receive", { id: order.id, invoice, date, note, photoId, lines: lines.map((l) => ({ lineId: l.lineId, itemId: l.itemId, size: l.size, arrived: parseInt(l.arrived, 10) || 0, dest: l.dest, cost: l.cost, priceAction: l.priceAction })) });
setBusy(false);
if (!r.ok) { setErr(r.error); return; }
onClose();
}
function camHit(raw: string) {
const p = bcParse(s, raw);
if (!p) { setCamMsg("Unknown barcode " + raw); return; }
const it = byId[p.itemId]; const size = String(it.sizes[p.si]);
const i = lines.findIndex((l) => l.itemId === p.itemId && l.size === size);
if (i < 0) { setCamMsg(label(it) + " · " + size + " isn’t on this order"); return; }
const a = (parseInt(lines[i].arrived, 10) || 0) + 1;
setLines(lines.map((l, j) => j === i ? { ...l, arrived: String(a) } : l));
setCamMsg(label(it) + " · " + size + " → " + a);
}
const cols = "1fr 60px 80px 170px 130px";
return (
<>
{cam && setCam(false)} />}
>
);
}
// ---------------------------------------------------------------- Bind unknown barcode
export function BindDialog({ code, onClose, onBound }: { code: string; onClose: () => void; onBound?: (itemId: string, si: number) => void }) {
const { s, isAdmin, mutate } = useSnap();
const [pick, setPick] = useState("");
const [err, setErr] = useState("");
const [newItem, setNewItem] = useState(false);
const it = s.catalog.find((x) => x.id === pick);
const g = gtinInfo(code);
async function bind(si: number) {
const r = await mutate("barcode.bind", { code, itemId: pick, si });
if (!r.ok) { setErr(r.error); return; }
onBound?.(pick, si);
onClose();
}
return (
<>
{newItem && setNewItem(false)} onSaved={() => { setNewItem(false); onClose(); }} />}
>
);
}
// ---------------------------------------------------------------- Return item
export function ReturnDialog({ issue, onClose }: { issue: IssueRec; onClose: () => void }) {
const { mutate } = useSnap();
const { byId } = useDerived();
const [err, setErr] = useState("");
const [photoId, setPhotoId] = useState(null);
// An issue line can be three garments and they don't all come back on the same day. Returning
// the whole line when one pair is on the counter credits two garments that are still on a ward
// back onto the shelf, so the count says how many actually came back.
const [qty, setQty] = useState(issue.qty);
const it = byId[issue.itemId];
async function pick(cond: string) {
const r = await mutate("issue.return", { id: issue.id, cond, qty, photoId });
if (!r.ok) { setErr(r.error); return; }
onClose();
}
return (
// No primary here on purpose: the four condition buttons are the action, and a Save beside them
// would be a fifth thing to press that does nothing.
);
}
// ---------------------------------------------------------------- Staff add / edit
export function StaffDialog({ staff, onClose }: { staff?: StaffRec | null; onClose: () => void }) {
const { s, mutate } = useSnap();
// fte is seeded from the record so the picker shows what is on file, but it is sent only when
// somebody changes it here. The profile has its own FTE control, which saves the moment it
// changes, and this form open beside it would otherwise write the figure it was opened with back
// over the one just chosen — and the nurse's proposed initial kit would go back with it.
const fte0 = staff?.fte || "";
const [f, setF] = useState({ num: staff?.num || "", first: staff?.first || "", last: staff?.last || "", phone: staff?.phone || "", top: staff?.top || "", pants: staff?.pants || "", ent: staff ? (staff.ent === null ? "" : String(staff.ent)) : "", fte: fte0, group: staff?.group || s.settings.staffGroups[0] || "", dept: staff?.dept || s.depts[0]?.name || "", ccOverride: staff?.ccOverride || "", start: staff?.start || s.today, notes: staff?.notes || "", uniformStyle: staff?.uniformStyle || "" });
const [err, setErr] = useState("");
const [busy, setBusy] = useState(false);
const set = (k: keyof typeof f, v: string) => setF({ ...f, [k]: v });
const invalid = !f.num.trim() || !f.first.trim() || !f.last.trim();
const nursing = isNursing(s, { group: f.group });
const FIELDS: [keyof typeof f, string][] = [["num", "Staff number"], ["first", "First name"], ["last", "Last name"], ["phone", "Phone"], ["top", "Top size"], ["pants", "Pants size"]];
const ccCodes = [...new Set(s.depts.map((d) => d.cc).filter(Boolean))];
async function save() {
if (invalid) return;
setBusy(true); setErr("");
const { fte, ...rest } = f;
const r = await mutate("staff.save", { id: staff?.id, ...rest, ent: f.ent === "" ? null : parseInt(f.ent, 10) || 0, ...(fte !== fte0 ? { fte } : {}) });
setBusy(false);
if (!r.ok) { setErr(r.error); return; }
onClose();
}
async function remove() {
if (!staff || !confirm(`Remove ${staff.first} ${staff.last} from the register?`)) return;
const r = await mutate("staff.delete", { id: staff.id });
if (!r.ok) { setErr(r.error); return; }
onClose();
}
return (
);
}
/** Opens the slip print page in a new window.
*
* Everything the slip prints travels as query parameters, so callers with a garment list — a ward
* request covers several under one collection code — pass `lines` as one garment per newline and
* `code` as the collection code. Both are optional: the counter's own issue slip sends neither and
* the slip prints as it always did. */
export function openSlip(type: "collection" | "delivery", v: Record) {
const q = new URLSearchParams({ type });
for (const k in v) { const val = v[k]; if (val === undefined || val === "" || val === false) continue; q.set(k, val === true ? "1" : String(val)); }
window.open("/print?" + q.toString(), "_blank", "width=900,height=760");
}
export function useStaffPickList() {
const { s } = useSnap();
return useMemo(() => s.staff.map((st) => ({ v: st.id, label: `${st.first} ${st.last} (${st.num})` })), [s.staff]);
}
/** A5 landscape credit slip for a manager’s approval: "X of Y sets remaining". */
export function printCreditSlip(s: { settings: { facility: string; coordinator: string }; today: string }, st: StaffRec, ap: ApprovalRec) {
const remaining = ap.sets - ap.used;
const css = ".slip{border:2px solid #201e1d;padding:8mm;height:100%;box-sizing:border-box;font-size:13px}.hd{display:flex;align-items:center;gap:8px;border-bottom:2px solid #201e1d;padding-bottom:6px;font-size:15px}.hd .sq{width:10px;height:10px}.big{font-size:34px;font-weight:800;margin:10px 0 2px}.meta{font-size:12px;line-height:1.9;margin-top:6px}.ft{font-size:11px;color:#555;margin-top:10px;border-top:1px solid #999;padding-top:6px}";
const body = `
Bring this slip (or just your payroll number) to the linen room to collect the remaining sets — no new form needed. Printed ${esc(fmtDate(s.today))}${s.settings.coordinator ? " · " + esc(s.settings.coordinator) : ""}
`;
openPrintWindow("Uniform credit", body, { page: "size:A5 landscape;margin:10mm", css, width: 700, height: 560 });
}
/** The slip a coordinator hands over with a self-service code.
*
* Printed rather than emailed: the linen room holds payroll numbers and phone numbers, not personal
* email addresses, and the person is standing at the counter anyway. The code is on paper for
* exactly as long as it takes them to use it — it is spent on first use, so a slip left on a desk
* afterwards is worthless. */
export function printAccessSlip(s: { settings: { facility: string } }, st: StaffRec, code: string) {
const url = (typeof window === "undefined" ? "https://threadcount.tech" : window.location.origin) + "/my";
const css = ".slip{border:2px solid #201e1d;padding:8mm;height:100%;box-sizing:border-box;font-size:13px}.hd{display:flex;align-items:center;gap:8px;border-bottom:2px solid #201e1d;padding-bottom:6px;font-size:15px}.hd .sq{width:10px;height:10px}.code{font-family:ui-monospace,Menlo,Consolas,monospace;font-size:30px;font-weight:800;letter-spacing:0.06em;margin:10px 0 2px}.meta{font-size:12px;line-height:1.9;margin-top:6px}.steps{font-size:12px;line-height:1.7;margin-top:8px;padding-left:16px}.ft{font-size:11px;color:#555;margin-top:10px;border-top:1px solid #999;padding-top:6px}";
const body = `
Your uniform record — ${esc(s.settings.facility || "Linen Room")}
`
+ `
For ${esc(st.first + " " + st.last)} (${esc(st.num)})
`
+ `
${esc(code)}
`
+ `
Go to ${esc(url)} on your phone.
Choose I have a code and type the code above.
Set an email and password — that’s how you get back in.
`
+ `
The code works once and only for you. It shows what you have out, what you’re still owed and what’s on order — you can’t change anything from there. Lost it? Ask for a new one.
`;
openPrintWindow("Uniform record access", body, { page: "size:A5 landscape;margin:10mm", css, width: 700, height: 560 });
}
// ---------------------------------------------------------------- Uniform hand-in (pre-loved pool)
type HiLine = { itemId: string; si: number; qty: number; cond: "Good" | "Rag"; laundered: boolean };
export function HandInDialog({ staff, onClose, onDone }: { staff: StaffRec; onClose: () => void; onDone?: (msg: string) => void }) {
const { s, mutate } = useSnap();
const { byId } = useDerived();
const [pick, setPick] = useState("");
const [lines, setLines] = useState([]);
const [credit, setCredit] = useState(false);
const [err, setErr] = useState("");
const [busy, setBusy] = useState(false);
const setL = (i: number, p: Partial) => setLines(lines.map((l, j) => (j === i ? { ...l, ...p } : l)));
async function save(print: boolean) {
if (!lines.length || busy) return;
setBusy(true); setErr("");
const r = await mutate<{ id: string; message: string }>("handin.add", { staffId: staff.id, credit, lines });
setBusy(false);
if (!r.ok) { setErr(r.error); return; }
// The same stale-snapshot trap as the arrival date: on a tab left open overnight the receipt
// printed yesterday while the HandIn row the server had just written was dated today.
if (print) printHandInReceipt(s, staff, { id: r.result.id, date: facilityToday(s.tz), staffId: staff.id, by: s.session.name, credit, lines: lines.map((l) => ({ ...l, credited: 0 })) }, byId);
onDone?.(r.result.message);
onClose();
}
return (
);
}
/** A5 landscape hand-in receipt: what came in, condition, and whether the allowance was credited. */
export function printHandInReceipt(s: { settings: { facility: string; coordinator: string }; today: string }, st: StaffRec, h: HandInRec, byId: Record) {
const rows = h.lines.map((l) => { const it = byId[l.itemId]; return `