Files
threadcount-community/app/m/(app)/issue/[staffId]/page.tsx
T
ThreadCount 96d5c10537 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 49da3a4 on 2026-09-13. Licensed under the Functional Source License (FSL-1.1-ALv2).
2026-09-13 11:38:24 +10:00

221 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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 managers 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 groups 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()} isnt 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 managers 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 cant tell a new starter collecting the kit theyre 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)} />}
</>
);
}