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 d947f89 on 2026-09-15. Licensed under the Functional Source License (FSL-1.1-ALv2).
This commit is contained in:
@@ -0,0 +1,220 @@
|
||||
"use client";
|
||||
/* Issue — 1B, person first. Their sizes are already known, so the list is what they'd normally take;
|
||||
scanning adds anything else. What they may hold and the manager’s approval are both checked before
|
||||
the bag is handed over, not after. */
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useDerived, useSnap } from "@/lib/client";
|
||||
import { approvalRemaining, capCheck, garmentForGroup, garmentForStyle, genderLabel, groupBucket, groupsLabel, inBucket, initialRemaining, isNursing, isPantItem, isTopItem, label, money, onhand, sizeIndexOf, splitKey, variantName } from "@/lib/compute";
|
||||
import MScan from "@/components/MScan";
|
||||
import { INK, MBar, MBody, MEmpty, MError, MRow, MRule, MSection, MTop, MStepper } from "@/components/m";
|
||||
import { MEntitlement, MPersonHead, useHeld } from "@/components/MPerson";
|
||||
|
||||
type Line = { key: string; itemId: string; si: number; size: string; name: string; qty: number; cost: number; onHand: number };
|
||||
|
||||
export default function MIssue() {
|
||||
const { s, mutate, busy } = useSnap();
|
||||
const { L, byId } = useDerived();
|
||||
const router = useRouter();
|
||||
const id = String(useParams().staffId || "");
|
||||
const st = s.staff.find((x) => x.id === id);
|
||||
const held = useHeld(s, id);
|
||||
|
||||
const [cart, setCart] = useState<Line[]>([]);
|
||||
const [scan, setScan] = useState(false);
|
||||
const [err, setErr] = useState("");
|
||||
const [override, setOverride] = useState(false);
|
||||
const [done, setDone] = useState<string | null>(null);
|
||||
|
||||
/* What this person would normally be handed: their group’s garments, in the cut they are offered,
|
||||
in their recorded size. Both questions are the server's own — a rule written again here would
|
||||
suggest a garment the counter then refuses. Blank and Either are offered every cut. */
|
||||
const suggested = useMemo(() => {
|
||||
if (!st) return [];
|
||||
const bucket = groupBucket(st.group);
|
||||
const out: Line[] = [];
|
||||
for (const it of s.catalog) {
|
||||
if (it.archived) continue;
|
||||
if (bucket && !inBucket(it, bucket)) continue;
|
||||
if (!garmentForStyle(it, st.uniformStyle)) continue;
|
||||
const want = isTopItem(it) ? st.top : isPantItem(it) ? st.pants : "";
|
||||
const si = want ? sizeIndexOf(it, want) : -1;
|
||||
if (si < 0) continue;
|
||||
const k = `${it.id}:${si}`;
|
||||
out.push({ key: k, itemId: it.id, si, size: String(it.sizes[si]), name: `${variantName(it, it.sizes[si])}`, qty: 1, cost: it.cost, onHand: onhand(s, L, k) });
|
||||
}
|
||||
return out;
|
||||
}, [s, st, L]);
|
||||
|
||||
const inCart = useCallback((k: string) => cart.find((c) => c.key === k), [cart]);
|
||||
const add = useCallback((l: Line) => {
|
||||
setErr("");
|
||||
setCart((c) => {
|
||||
const at = c.findIndex((x) => x.key === l.key);
|
||||
if (at < 0) return [...c, { ...l, qty: 1 }];
|
||||
const next = [...c]; next[at] = { ...next[at], qty: next[at].qty + 1 }; return next;
|
||||
});
|
||||
}, []);
|
||||
const setQty = useCallback((k: string, n: number) => setCart((c) => (n <= 0 ? c.filter((x) => x.key !== k) : c.map((x) => (x.key === k ? { ...x, qty: n } : x)))), []);
|
||||
|
||||
const onCode = useCallback((raw: string) => {
|
||||
const k = s.barcodes[raw.trim()];
|
||||
if (!k) { setErr(`${raw.trim()} isn’t a garment ThreadCount knows.`); return; }
|
||||
const { itemId, si } = splitKey(k);
|
||||
const it = byId[itemId];
|
||||
if (!it || it.archived) { setErr("That garment is discontinued."); return; }
|
||||
add({ key: k, itemId, si, size: String(it.sizes[si]), name: `${variantName(it, it.sizes[si])}`, qty: 1, cost: it.cost, onHand: onhand(s, L, k) });
|
||||
}, [s, byId, L, add]);
|
||||
|
||||
if (!st) return (<><MTop title="Issue" back /><MRule /><MBody><MEmpty title="No such staff member" /></MBody></>);
|
||||
|
||||
const cartQty = cart.reduce((t, c) => t + c.qty, 0);
|
||||
const heldQty = held.reduce((t, h) => t + h.qty, 0);
|
||||
const total = cart.reduce((t, c) => t + c.qty * c.cost, 0);
|
||||
const nursing = isNursing(s, st);
|
||||
/* The one question this screen asks: after this bag, is this person still inside the six sets one
|
||||
person holds? Six at any time, every group, nursing included — so the sum is what they have out
|
||||
now plus what is on the counter, and nothing in it starts again in July. It is the server's own
|
||||
function, so the warning here and the refusal there cannot drift apart; the last time this screen
|
||||
kept a private copy of the sum it demanded a tick the server never wanted. */
|
||||
const cap = capCheck(s, st, cart);
|
||||
const over = cap.over;
|
||||
/* Garments in the cart that are not for this person's staff group, and garments that are not the
|
||||
cut they are offered. The server refuses either without the coordinator override, and records
|
||||
them as outside the group or outside the style rather than as over the ceiling, so the same tick
|
||||
is offered for any of the three reasons. garmentForGroup() and garmentForStyle() are the
|
||||
server's own questions, asked here so the screen and the refusal cannot drift apart. */
|
||||
const cartItems = [...new Set(cart.map((c) => c.itemId))].map((iid) => byId[iid])
|
||||
.filter((it): it is NonNullable<typeof it> => !!it);
|
||||
const offGroup = cartItems.filter((it) => !garmentForGroup(it, st.group));
|
||||
const offStyle = cartItems.filter((it) => !garmentForStyle(it, st.uniformStyle));
|
||||
/* One refusal naming every reason that applies, composed as the server composes it: a clause per
|
||||
reason, the ceiling among them, and the sentence about the tick once at the end, because one
|
||||
tick answers all of them. A message that named the first and stopped would have the coordinator
|
||||
tick for that and wave the rest through without anybody having been told about them. The count
|
||||
is of distinct garments across both lists — one garment wrong on both counts is still "it". */
|
||||
const wrongCount = new Set([...offGroup, ...offStyle].map((it) => it.id)).size;
|
||||
const wrongNote = wrongCount
|
||||
? `${[
|
||||
offGroup.length ? `${offGroup.map((it) => `${it.item} is for ${groupsLabel(it.groups)}`).join("; ")} — ${(st.group || "").trim() ? `${st.first} ${st.last} is in ${st.group.trim()}` : `${st.first} ${st.last} has no staff group recorded`}` : "",
|
||||
offStyle.length ? `${offStyle.map((it) => `${it.item} is the ${genderLabel(it.gender)} cut`).join("; ")} — ${st.first} ${st.last} is set to ${st.uniformStyle}` : "",
|
||||
over ? `It would also take them past what one person holds: ${cap.note}` : "",
|
||||
].filter(Boolean).join(". ")}. Tick the coordinator override to issue ${wrongCount === 1 ? "it" : "them"} anyway.`
|
||||
: "";
|
||||
const overrideWhy = [offGroup.length ? "outside their staff group" : "", offStyle.length ? "outside their uniform style" : "", over ? "above what one person holds" : ""]
|
||||
.filter(Boolean).reduce((a, b, i, all) => (i === 0 ? b : i === all.length - 1 ? `${a} and ${b}` : `${a}, ${b}`), "");
|
||||
/* Garments of the starting kit this record still owes. What they are owed on starting, said on the
|
||||
shelf list below — never a term in whether this collection is allowed. A new starter holds
|
||||
nothing and takes three sets, and three is inside six, so the kit that used to need a coordinator
|
||||
override to hand over now goes through as the ordinary first issue it always was. */
|
||||
const kitLeft = initialRemaining(s, st) ?? 0;
|
||||
const sets = approvalRemaining(s, st.id);
|
||||
/* A manager’s approval is counted in SETS — one top and one pair of trousers — so a set is spent per top
|
||||
or per pair of trousers, whichever side of the pair is bigger, and never by anything else. A
|
||||
jacket, a vest or maternity wear is neither half of a set and costs the ward nothing off the
|
||||
approval. This must stay identical to the desktop Issue screen: counting garments instead of
|
||||
sets here quietly spent a whole approved set on a single fleece, and spent only half of what
|
||||
the manager signed for when someone took four tops. It is a separate control from the six sets
|
||||
anybody may hold: the approval is what pays for the garments, the ceiling is how much uniform one
|
||||
person walks around with, and a nurse has to satisfy both. */
|
||||
const cartTops = cart.reduce((t, c) => t + (isTopItem(byId[c.itemId]) ? c.qty : 0), 0);
|
||||
const cartPants = cart.reduce((t, c) => t + (isPantItem(byId[c.itemId]) ? c.qty : 0), 0);
|
||||
const short = cart.find((c) => c.qty > c.onHand);
|
||||
/* What they hold against the ceiling, said in the section headers that are already on the screen.
|
||||
Without it the counter can’t tell a new starter collecting the kit they’re owed from somebody
|
||||
drawing a seventh set. */
|
||||
const holdsRight = `${cap.sets}/${cap.cap} sets · ${heldQty} item${heldQty === 1 ? "" : "s"}`;
|
||||
const notYetLabel = kitLeft > 0 ? `Starting kit — ${kitLeft} still to issue`
|
||||
: "Their size, not yet issued";
|
||||
|
||||
const commit = async () => {
|
||||
if (!cart.length) return;
|
||||
if (short) { setErr(`Only ${short.onHand} of ${short.name} on the shelf.`); return; }
|
||||
// The reason comes from the same function the server refuses with, so nobody is told one thing
|
||||
// here and another when they press the button.
|
||||
if (wrongCount && !override) { setErr(wrongNote); return; }
|
||||
if (over && !override) { setErr(cap.note); return; }
|
||||
const r = await mutate<{ stock: number; apDeducted: number; apRemaining: number }>("issue.create", {
|
||||
// The tick and nothing else. An override is a record that somebody knowingly bent a rule, so
|
||||
// only somebody may set it: a new starter collecting the kit they are owed has bent nothing,
|
||||
// and it now goes through on its own merits.
|
||||
staffId: st.id, override, apDeduct: nursing ? Math.min(sets, Math.max(cartTops, cartPants)) : 0,
|
||||
lines: cart.map((c) => ({ itemId: c.itemId, si: c.si, qty: c.qty, src: "stock" })),
|
||||
});
|
||||
if (!r.ok) { setErr(r.error); return; }
|
||||
setDone(`${cartQty} item${cartQty === 1 ? "" : "s"} issued to ${st.first} ${st.last}.`);
|
||||
setCart([]);
|
||||
};
|
||||
|
||||
if (done) {
|
||||
return (
|
||||
<>
|
||||
<MTop title="Issued" />
|
||||
<MRule />
|
||||
<MBody>
|
||||
<MEmpty title={done} sub="A replenishment draft has been topped up on Ordering. Nothing is sent to a supplier without approval." />
|
||||
</MBody>
|
||||
<MBar label="Back to the person" href={`/m/person/${st.id}`} glyph="arrow" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<MTop title="Issue" back right={cartQty ? `${cartQty} to issue` : undefined} />
|
||||
<MRule />
|
||||
<MError msg={err} onDismiss={() => setErr("")} />
|
||||
<MBody>
|
||||
<MPersonHead s={s} st={st} sub={<MEntitlement s={s} st={st} cart={cart} />} />
|
||||
|
||||
{cart.length > 0 && (
|
||||
<>
|
||||
<MSection label="Issuing now" right={money(total)} />
|
||||
{cart.map((c) => (
|
||||
<MRow key={c.key} mark="accent" attention title={c.name} sub={`${money(c.cost)} · ${c.onHand} on the shelf`}
|
||||
right={<MStepper n={c.qty} onChange={(n) => setQty(c.key, n)} max={Math.max(1, c.onHand)} />} />
|
||||
))}
|
||||
{(over || wrongCount > 0) && (
|
||||
<label style={{ display: "flex", alignItems: "center", gap: 10, padding: "14px 16px", background: "#fff", borderBottom: "1px solid var(--color-divider)", fontSize: 14 }}>
|
||||
<input type="checkbox" checked={override} onChange={(e) => setOverride(e.target.checked)} style={{ width: 22, height: 22 }} />
|
||||
<span>Coordinator override — record this {overrideWhy}</span>
|
||||
</label>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<MSection label="Currently holds" right={holdsRight} />
|
||||
{held.length === 0 && <div style={{ padding: "18px 16px", fontSize: 14, color: "var(--color-neutral-600)" }}>Nothing out at the moment.</div>}
|
||||
{held.map((h) => {
|
||||
const it = byId[h.itemId];
|
||||
const k = h.key;
|
||||
return (
|
||||
<MRow key={k} title={h.name} sub={`${h.qty} held`}
|
||||
right={<button onClick={() => add({ key: k, itemId: h.itemId, si: h.si, size: h.size, name: h.name, qty: 1, cost: it?.cost ?? 0, onHand: onhand(s, L, k) })}
|
||||
style={{ width: 56, height: 44, border: "2px solid " + INK, background: inCart(k) ? INK : "transparent", color: inCart(k) ? "var(--color-bg)" : INK, fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 14, cursor: "pointer" }}>+ 1</button>} />
|
||||
);
|
||||
})}
|
||||
|
||||
{suggested.filter((l) => !held.some((h) => h.key === l.key)).length > 0 && (
|
||||
<>
|
||||
<MSection label={notYetLabel} />
|
||||
{suggested.filter((l) => !held.some((h) => h.key === l.key)).map((l) => (
|
||||
<MRow key={l.key} attention mark="accent" title={l.name}
|
||||
sub={<span style={{ color: l.onHand > 0 ? "var(--color-accent-700)" : "var(--color-neutral-600)" }}>{l.onHand > 0 ? "Not yet issued" : "None on the shelf"}</span>}
|
||||
right={<button onClick={() => add(l)} disabled={l.onHand <= 0}
|
||||
style={{ width: 56, height: 44, border: "2px solid " + INK, background: inCart(l.key) ? INK : "transparent", color: inCart(l.key) ? "var(--color-bg)" : INK, fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 14, cursor: l.onHand > 0 ? "pointer" : "not-allowed", opacity: l.onHand > 0 ? 1 : 0.4 }}>+ 1</button>} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div style={{ padding: "18px 16px 24px", fontSize: 14, color: "var(--color-neutral-700)" }}>Scan to add anything not on this list.</div>
|
||||
</MBody>
|
||||
|
||||
{cart.length === 0
|
||||
? <MBar label="Scan to add" glyph="scan" onClick={() => setScan(true)} />
|
||||
: <MBar label={busy ? "Recording…" : `Issue ${cartQty} item${cartQty === 1 ? "" : "s"}`} glyph="check" onClick={commit} disabled={busy} sub={money(total)} />}
|
||||
|
||||
{scan && <MScan title="Scan a garment" onHit={(r) => { onCode(r); setScan(false); }} onClose={() => setScan(false)} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user