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 f976bd5 on 2026-09-15. Licensed under the Functional Source License (FSL-1.1-ALv2).
This commit is contained in:
ThreadCount
2026-09-15 23:18:05 +10:00
commit c89010a4f1
424 changed files with 53588 additions and 0 deletions
+102
View File
@@ -0,0 +1,102 @@
"use client";
/* The pickup being put together at the counter, and every rule it is checked against before it can
* be recorded. The rules are asked of lib/compute (capCheck, garmentForGroup, garmentForStyle) so the
* screen and the server's refusal can never disagree. */
import { useMemo, useState } from "react";
import { useDerived, useSnap } from "@/lib/client";
import { openSlip } from "@/components/dialogs";
import { approvalRemaining, capCheck, garmentForGroup, garmentForStyle, isPantItem, isTopItem, key, longLabel, onhand, openApproval, plOf, staffName, type StaffRec } from "@/lib/compute";
import { plural, type CartLine } from "./lib";
export function useCounterCart(sel: StaffRec | undefined) {
const { s, mutate } = useSnap();
const { L, byId } = useDerived();
const selId = sel?.id || "";
const [cart, setCart] = useState<CartLine[]>([]);
const [override, setOverride] = useState(false);
const [apDeduct, setApDeduct] = useState<number | null>(null);
const [busy, setBusy] = useState(false);
// A new person starts with an empty bag and no deduction.
const [owner, setOwner] = useState(selId);
if (owner !== selId) { setOwner(selId); setCart([]); setApDeduct(null); setOverride(false); }
// An override is about one person and one bag: the tick goes the moment either changes, cleared
// as the page draws so the new bag is never on screen with the old tick behind it.
const bagKey = selId ? `${selId}|${cart.map((c) => `${c.itemId}:${c.si}:${c.qty}:${c.src}`).join(",")}` : "";
const [tickedFor, setTickedFor] = useState(bagKey);
if (tickedFor !== bagKey) { setTickedFor(bagKey); setOverride(false); }
function add(itemId: string, si: number) {
setCart((c) => {
const f = c.find((x) => x.itemId === itemId && x.si === si);
if (f) return c.map((x) => (x === f ? { ...x, qty: x.qty + 1 } : x));
const oh = onhand(s, L, key(itemId, si)), pl = plOf(s, key(itemId, si));
return [...c, { itemId, si, qty: 1, src: pl > 0 && oh >= 1 ? null : pl > 0 ? "preloved" : oh >= 1 ? "stock" : "order" }];
});
}
const setLine = (i: number, p: Partial<CartLine>) => setCart((c) => c.map((x, j) => (j === i ? { ...x, ...p } : x)));
const setQty = (i: number, n: number) => setCart((c) => (n < 1 ? c.filter((_, j) => j !== i) : c.map((x, j) => (j === i ? { ...x, qty: n } : x))));
// The whole cart goes to the ceiling, pre-loved and ordered-in lines included.
const cap = useMemo(() => (sel ? capCheck(s, sel, cart.map((c) => ({ itemId: c.itemId, qty: c.qty }))) : null), [s, sel, cart]);
const cartItems = useMemo(() => [...new Set(cart.map((c) => c.itemId))].map((id) => byId[id]).filter((it): it is NonNullable<typeof it> => !!it), [cart, byId]);
const offItems = sel ? cartItems.filter((it) => !garmentForGroup(it, sel.group)) : [];
const offStyleItems = sel ? cartItems.filter((it) => !garmentForStyle(it, sel.uniformStyle)) : [];
const overCap = !!cap && cap.over;
const needsTick = overCap || offItems.length > 0 || offStyleItems.length > 0;
const anyShort = cart.some((c) => (c.src === "stock" && c.qty > onhand(s, L, key(c.itemId, c.si))) || (c.src === "preloved" && c.qty > plOf(s, key(c.itemId, c.si))));
const anyUnpicked = cart.some((c) => c.src === null);
const inactive = !!sel?.inactive;
// Pre-loved is free: out of the charge and out of what a signed form pays for.
const cartVal = cart.filter((c) => c.src !== "preloved").reduce((t, c) => t + c.qty * (byId[c.itemId]?.cost || 0), 0);
const garments = cart.reduce((t, c) => t + c.qty, 0);
const ap = sel ? openApproval(s, sel.id) : undefined;
const apRem = sel ? approvalRemaining(s, sel.id) : 0;
const cartTops = cart.reduce((t, c) => t + (isTopItem(byId[c.itemId]) && c.src !== "preloved" ? c.qty : 0), 0);
const cartPants = cart.reduce((t, c) => t + (isPantItem(byId[c.itemId]) && c.src !== "preloved" ? c.qty : 0), 0);
const apDefault = ap ? Math.min(apRem, Math.max(cartTops, cartPants)) : 0;
const apN = ap ? (apDeduct === null ? apDefault : Math.min(apDeduct, apRem)) : 0;
const cannot = !sel || inactive || cart.length === 0 || anyShort || anyUnpicked || (needsTick && !override) || busy;
async function record(): Promise<{ ok: true; msg: string } | { ok: false; error: string } | null> {
if (cannot || !sel) return null;
setBusy(true);
// Only the ticked box, and only while the box is on the screen.
const r = await mutate<{ stock: number; ordered: number; preloved: number; apDeducted: number; apRemaining: number }>("issue.create", { staffId: sel.id, override: needsTick && override, apDeduct: ap ? apN : 0, lines: cart });
setBusy(false);
if (!r.ok) return { ok: false, error: r.error };
const parts = [
r.result.stock ? `${r.result.stock} from stock` : "",
r.result.ordered ? `${r.result.ordered} ordered in` : "",
r.result.preloved ? `${r.result.preloved} pre-loved` : "",
r.result.apDeducted ? `${r.result.apDeducted} off the signed form` : "",
].filter(Boolean);
setCart([]); setOverride(false); setApDeduct(null);
return { ok: true, msg: `Recorded for ${staffName(sel)}: ${parts.length ? parts.join(" · ") : plural(garments, "garment", "garments")}.` };
}
// The collection slip covers what crosses the counter today: shelf and pre-loved lines.
const handed = cart.filter((c) => c.src === "stock" || c.src === "preloved");
function printSlip() {
if (!sel) return;
openSlip("collection", {
staffName: staffName(sel), dept: sel.dept, sets: handed.reduce((t, c) => t + c.qty, 0), po: "",
lines: handed.map((c) => `${c.qty} × ${longLabel(byId[c.itemId])}${byId[c.itemId]?.sizes[c.si] ?? "?"}${c.src === "preloved" ? " (pre-loved)" : ""}`).join("\n"),
dateReceived: s.today, requestedBy: sel.num, deliveredBy: s.settings.coordinator, dateTime: s.today,
});
}
return {
cart, add, setLine, setQty, replace: setCart,
cap, overCap, offItems, offStyleItems, needsTick, override, setOverride,
anyShort, anyUnpicked, inactive, cartVal, garments,
ap, apRem, apN, setApDeduct,
busy, cannot, record, handed, printSlip,
};
}
export type CounterCart = ReturnType<typeof useCounterCart>;