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
+191
View File
@@ -0,0 +1,191 @@
"use client";
import Link from "next/link";
import { useEffect, useId, useMemo, useRef, useState } from "react";
import { useDerived, useSnap } from "@/lib/client";
import { Icon, Panel, SizeStrip, Tag, type SizeCell } from "@/components/portal";
import { ErrorLine } from "@/components/ui";
import { ALL_GROUPS, bcParse, garmentForStyle, groupBucket, inBucket, key, label, onhand, plOf, reorderAt, setHalf, type IssueRec, type Item, type StaffRec } from "@/lib/compute";
import { dayMonth, pronoun, sizeOf, type HoldGroup } from "./lib";
import styles from "./counter.module.css";
const HOLD_SHOWN = 8;
export default function AddGarments({ st, isAdmin, onAdd, onBind, onCamera, onReturn, onRepeat, repeatDate, groups, owed, inputRef, listOpenRef }: {
st: StaffRec;
isAdmin: boolean;
onAdd: (itemId: string, si: number) => void;
onBind: (code: string) => void;
onCamera: () => void;
onReturn: (issue: IssueRec) => void;
onRepeat: () => void;
repeatDate: string | null;
groups: HoldGroup[];
owed: number;
inputRef: React.RefObject<HTMLInputElement | null>;
listOpenRef: React.RefObject<boolean>;
}) {
const { s } = useSnap();
const { L, byId } = useDerived();
const [q, setQ] = useState("");
const [open, setOpen] = useState(false);
const [err, setErr] = useState("");
const [showAll, setShowAll] = useState(false);
const popId = useId();
const popRef = useRef<HTMLDivElement>(null);
const wrapRef = useRef<HTMLDivElement>(null);
const pr = pronoun(st);
// For this person's group and cut: the same test the old quick-add list used.
const bucket = groupBucket(st.group) || ALL_GROUPS;
const forThem = (it: Item) => inBucket(it, bucket) && garmentForStyle(it, st.uniformStyle);
const qq = q.trim().toLowerCase();
const found = useMemo(() => {
if (qq.length < 2) return { rel: [] as Item[], other: [] as Item[] };
const all = s.catalog
.filter((it) => !it.archived && (it.item.toLowerCase().includes(qq) || it.sku.toLowerCase().includes(qq) || label(it).toLowerCase().includes(qq)))
.sort((a, b) => a.sort - b.sort);
const rel = all.filter(forThem).slice(0, 8);
const other = all.filter((it) => !rel.includes(it) && !forThem(it)).slice(0, 8 - rel.length);
return { rel, other };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [s.catalog, qq, bucket, st.uniformStyle]);
const listShown = open && qq.length >= 2;
useEffect(() => { listOpenRef.current = listShown; }, [listShown, listOpenRef]);
// Usual chips: set garments at the recorded size, then anything outside a set they have had before,
// at the size of their latest issue of it. Most issued to them first.
const usual = useMemo(() => {
const issued: Record<string, number> = {};
const latest: Record<string, IssueRec> = {};
for (const i of s.issues) {
if (i.staffId !== st.id) continue;
issued[i.itemId] = (issued[i.itemId] || 0) + i.qty;
const l = latest[i.itemId];
if (!l || i.date > l.date || (i.date === l.date && i.createdAt > l.createdAt)) latest[i.itemId] = i;
}
const rank = (a: { it: Item }, b: { it: Item }) => (issued[b.it.id] || 0) - (issued[a.it.id] || 0) || a.it.sort - b.it.sort;
const sets: { it: Item; si: number }[] = [], others: { it: Item; si: number }[] = [];
for (const it of s.catalog) {
if (it.archived) continue;
const half = setHalf(it);
if (half) {
const want = half === "top" ? st.top : st.pants;
if (!want || !forThem(it)) continue;
const si = it.sizes.map(String).indexOf(String(want));
if (si >= 0) sets.push({ it, si });
} else if (latest[it.id] && latest[it.id].si < it.sizes.length) {
others.push({ it, si: latest[it.id].si });
}
}
return [...sets.sort(rank), ...others.sort(rank)].slice(0, 6);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [s.catalog, s.issues, st.id, st.top, st.pants, bucket, st.uniformStyle]);
const cells = (it: Item): SizeCell[] => it.sizes.map((sz, si) => {
const k = key(it.id, si), oh = onhand(s, L, k), pl = plOf(s, k), ro = reorderAt(s, k);
return { si, size: String(sz), count: oh, state: oh <= 0 ? "out" : ro > 0 && oh <= ro ? "low" : "ok", title: `${oh} on shelf · ${pl} pre-loved` };
});
function add(itemId: string, si: number) {
onAdd(itemId, si);
setErr("");
inputRef.current?.focus();
}
function onKey(e: React.KeyboardEvent<HTMLInputElement>) {
if (e.key === "Escape" && listShown) { e.preventDefault(); setOpen(false); return; }
if (e.key === "ArrowDown" && listShown) { e.preventDefault(); popRef.current?.querySelector<HTMLButtonElement>("button")?.focus(); return; }
if (e.key !== "Enter" || e.ctrlKey || e.metaKey) return;
const raw = q.trim();
if (!raw) return;
e.preventDefault();
const hit = bcParse(s, raw);
if (hit) { setQ(""); add(hit.itemId, hit.si); return; }
// A name with no size can't be added; a code nobody knows gets bound (admins) or reported.
if (/\d/.test(raw) && !/\s/.test(raw)) {
setQ("");
if (isAdmin) onBind(raw);
else setErr(`No garment has ${raw}.`);
}
}
const option = (it: Item) => (
<div key={it.id} className={styles.opt}>
<div className={styles.optHead}><span>{label(it)}</span>{it.sku && <span className="tc-mono" style={{ fontWeight: 400, fontSize: 12, color: "#57534f" }}>{it.sku}</span>}</div>
<SizeStrip itemLabel={label(it)} cells={cells(it)} action="Add" onCell={(si) => add(it.id, si)} />
</div>
);
const shownGroups = showAll ? groups : groups.slice(0, HOLD_SHOWN);
const holdingQty = groups.reduce((t, g) => t + g.qty, 0);
const recordHref = `/app/staff/${st.id}?tab=details${isAdmin ? "&edit=1" : ""}`;
return (
<Panel title="Add garments" aside={repeatDate ? <button type="button" className={`btn btn-ghost ${styles.rowGhost}`} onClick={onRepeat}>Repeat last · {dayMonth(repeatDate)}</button> : undefined}>
<div className={styles.scanWrap} ref={wrapRef}
onBlur={(e) => { if (!wrapRef.current?.contains(e.relatedTarget as Node | null)) setOpen(false); }}>
<div className={styles.scanBox}>
<Icon name="scan" size={16} />
<input ref={inputRef} className={styles.scanInput} role="combobox" aria-expanded={listShown} aria-controls={popId} aria-autocomplete="list" aria-haspopup="dialog"
aria-label="Scan a garment or type a garment name" placeholder="Scan a garment, or type a name" autoFocus autoComplete="off"
value={q} onChange={(e) => { setQ(e.target.value); setOpen(true); setErr(""); }} onFocus={() => setOpen(true)} onKeyDown={onKey} />
<button type="button" className={`btn btn-ghost ${styles.camBtn}`} onClick={onCamera} aria-label="Scan with the camera"><Icon name="camera" size={16} /></button>
</div>
{listShown && (
<div id={popId} ref={popRef} className={styles.pop} role="dialog" aria-label="Matching garments"
onKeyDown={(e) => { if (e.key === "Escape") { e.preventDefault(); setOpen(false); inputRef.current?.focus(); } }}>
{found.rel.length + found.other.length === 0 && <div className={styles.popEmpty}>No garment matches {q.trim()}.</div>}
{found.rel.map(option)}
{found.other.length > 0 && <div className={`tc-lbl ${styles.optDivider}`}>Other garments</div>}
{found.other.map(option)}
</div>
)}
</div>
{err && <div className={styles.scanErr}><ErrorLine msg={err} /></div>}
<div className={`tc-lbl ${styles.usualLbl}`}>{pr.poss} usual · one tap adds it</div>
<div className={styles.chips}>
{usual.map(({ it, si }) => (
<button type="button" key={it.id} className={`btn btn-secondary ${styles.chip}`} onClick={() => add(it.id, si)} aria-label={`Add ${label(it)} size ${sizeOf(it, si)}`}>
{label(it)} <Tag tone="ink" mono>{sizeOf(it, si)}</Tag>
</button>
))}
{!st.top && !st.pants && (
<span className={styles.meta}>No usual sizes recorded · <Link href={recordHref} className="btn btn-ghost" style={{ minHeight: 0, padding: 0, fontSize: 12 }}>Record them</Link></span>
)}
</div>
<div className={`tc-lbl ${styles.holdLbl}`}>Holding now · {holdingQty} {holdingQty === 1 ? "garment" : "garments"}</div>
{groups.length === 0 ? (
<div className={styles.empty} style={{ paddingTop: 0 }}>Nothing out.</div>
) : (
<div className={styles.tableWrap}>
<table className="tc-table">
<tbody>
{shownGroups.map((g) => {
const it = byId[g.itemId];
return (
<tr key={g.itemId + ":" + g.si}>
<td>{label(it)} · <span className="tc-mono">{sizeOf(it, g.si)}</span></td>
<td className="num">×{g.qty}</td>
<td className={styles.meta}>last {dayMonth(g.last)}</td>
<td style={{ textAlign: "right" }}>
<button type="button" className={`btn btn-ghost ${styles.rowGhost}`} aria-label={`Return ${label(it)} size ${sizeOf(it, g.si)}`} onClick={() => onReturn(g.latest)}>Return</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
{(groups.length > HOLD_SHOWN || owed > 0) && (
<div className={styles.holdFoot}>
{groups.length > HOLD_SHOWN && <button type="button" className={`btn btn-ghost ${styles.rowGhost}`} aria-expanded={showAll} onClick={() => setShowAll(!showAll)}>{showAll ? "Show fewer" : "Show all"}</button>}
{owed > 0 && <span className={styles.meta}>+{owed} on order or waiting</span>}
</div>
)}
</Panel>
);
}
+204
View File
@@ -0,0 +1,204 @@
"use client";
/* The Counter (/app/counter?staff=&mode=): person first, then Issue, Return, Hand in or Swap a size. */
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useDerived, useSnap } from "@/lib/client";
import { ErrorLine, LiveRegion, PageHead } from "@/components/ui";
import { Kbd, Seg } from "@/components/portal";
import { AdjustDialog, BindDialog, HandInDialog, ReturnDialog } from "@/components/dialogs";
import Camera from "@/components/Camera";
import { bcParse, capCheck, label, type IssueRec } from "@/lib/compute";
import { MODES, MODE_LABELS, holdingGroups, openIssuesOf, overlayOpen, sizeOf, type Mode } from "./lib";
import { useCounterCart } from "./useCounterCart";
import PersonPicker from "./PersonPicker";
import PersonPanel from "./PersonPanel";
import AddGarments from "./AddGarments";
import PickupCart from "./PickupCart";
import { HandInPanel, HoldingPanel, ReturnedToday, SwapPanel, SwappedToday } from "./Modes";
import styles from "./counter.module.css";
export default function Counter() {
const { s, isAdmin } = useSnap();
const { byId, staffById } = useDerived();
const router = useRouter();
const sp = useSearchParams();
const staffParam = sp.get("staff") || "";
const sel = staffParam ? staffById[staffParam] || s.staff.find((x) => x.num === staffParam) : undefined;
const modeParam = sp.get("mode") as Mode | null;
const mode: Mode = modeParam && MODES.includes(modeParam) ? modeParam : "issue";
const setUrl = useCallback((staff: string | null, m: Mode) => {
const q = new URLSearchParams();
if (staff) q.set("staff", staff);
if (staff && m !== "issue") q.set("mode", m);
const qs = q.toString();
router.replace(`/app/counter${qs ? `?${qs}` : ""}`, { scroll: false });
}, [router]);
const cart = useCounterCart(sel);
const [msg, setMsg] = useState("");
const [err, setErr] = useState("");
const [cam, setCam] = useState(false);
const [camMsg, setCamMsg] = useState("");
const [bind, setBind] = useState("");
const [ret, setRet] = useState<IssueRec | null>(null);
const [handin, setHandin] = useState(false);
const [adjust, setAdjust] = useState(false);
const [mac, setMac] = useState(false);
useEffect(() => { setMac(/Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent)); }, []);
// Messages belong to one person.
const [msgFor, setMsgFor] = useState(sel?.id || "");
if (msgFor !== (sel?.id || "")) { setMsgFor(sel?.id || ""); setMsg(""); setErr(""); }
const scanRef = useRef<HTMLInputElement>(null);
const listOpenRef = useRef(false);
const held = useMemo(() => (sel ? capCheck(s, sel, []) : null), [s, sel]);
const open = useMemo(() => (sel ? openIssuesOf(s, sel.id) : []), [s, sel]);
const groups = useMemo(() => holdingGroups(open), [open]);
const owed = held ? held.owed.tops + held.owed.pants + held.owed.other : 0;
// Repeat last issue: every unreturned line from their most recent issue date.
const lastSet = useMemo(() => {
if (!open.length) return [];
const latest = open.reduce((d, i) => (i.date > d ? i.date : d), "");
return open.filter((i) => i.date === latest && byId[i.itemId] && !byId[i.itemId].archived);
}, [open, byId]);
const pick = (id: string) => { setMsg(""); setErr(""); setUrl(id, mode); };
const changePerson = () => { setMsg(""); setErr(""); setCam(false); setUrl(null, "issue"); };
const setMode = (m: Mode) => { if (sel) setUrl(staffParam, m); };
const addGarment = (itemId: string, si: number) => { cart.add(itemId, si); setMsg(""); };
async function doIssue() {
setErr("");
const r = await cart.record();
if (!r) return;
if (r.ok) setMsg(r.msg); else setErr(r.error);
scanRef.current?.focus();
}
function camHit(raw: string) {
const code = raw.trim();
if (!sel) {
const st = s.staff.find((x) => x.num === code);
setCam(false);
if (st) pick(st.id); else setErr(`No one on the register has ${code}.`);
return;
}
const p = bcParse(s, code);
if (!p) {
setCam(false);
if (isAdmin) setBind(code); else setErr(`No garment has ${code}.`);
return;
}
if (mode !== "issue") setMode("issue");
addGarment(p.itemId, p.si);
setCamMsg(`Added ${label(byId[p.itemId])} · ${sizeOf(byId[p.itemId], p.si)}`);
}
// Window events and keys read the latest render through a ref.
const cartLines = cart.cart.length;
const latest = useRef({ sel, mode, cam, cartLines, setMode, addGarment, changePerson, doIssue });
useEffect(() => { latest.current = { sel, mode, cam, cartLines, setMode, addGarment, changePerson, doIssue }; });
useEffect(() => {
const onScan = () => { setCamMsg(""); setCam(true); };
const onGarment = (e: Event) => {
const d = (e as CustomEvent<{ itemId: string; si: number }>).detail;
const cur = latest.current;
if (!d || !cur.sel) return;
if (cur.mode !== "issue") cur.setMode("issue");
cur.addGarment(d.itemId, d.si);
};
const onKey = (e: KeyboardEvent) => {
const cur = latest.current;
if (e.defaultPrevented || !cur.sel || cur.cam || overlayOpen()) return;
if (e.key === "Escape") {
// A pickup being built is never thrown away by a stray Escape: change person from the panel.
if (listOpenRef.current || cur.cartLines > 0) return;
e.preventDefault();
cur.changePerson();
} else if (e.key === "Enter" && (e.ctrlKey || e.metaKey) && cur.mode === "issue") {
e.preventDefault();
void cur.doIssue();
}
};
window.addEventListener("tc-scan", onScan);
window.addEventListener("tc-scan-garment", onGarment);
window.addEventListener("keydown", onKey);
return () => {
window.removeEventListener("tc-scan", onScan);
window.removeEventListener("tc-scan-garment", onGarment);
window.removeEventListener("keydown", onKey);
};
}, []);
return (
<section>
<PageHead title="Counter">
<button type="button" className="btn btn-onink" onClick={() => setAdjust(true)}>Hand-in without a person</button>
</PageHead>
{!sel ? (
<div className={styles.stack}>
{staffParam && <ErrorLine msg="No one on the register matches that link or badge." />}
<PersonPicker onPick={pick} />
<ErrorLine msg={err} />
</div>
) : (
<div className={styles.stack}>
{held && <PersonPanel st={sel} held={held} onChange={changePerson} />}
<div className={styles.modeRow}>
<div className={styles.modeSeg}>
<Seg<Mode> label="Counter mode" opts={MODES} value={mode} labels={MODE_LABELS} onChange={setMode} />
</div>
{!cart.cart.length && <div className={styles.kbdHint}><Kbd>Esc</Kbd> change person</div>}
</div>
{mode === "issue" && (
<div className={`${styles.grid}${cart.cart.length ? " " + styles.cartFirst : ""}`}>
<div>
<AddGarments st={sel} isAdmin={isAdmin} onAdd={addGarment} onBind={setBind} onCamera={() => { setCamMsg(""); setCam(true); }}
onReturn={setRet} onRepeat={() => { cart.replace(lastSet.map((i) => ({ itemId: i.itemId, si: i.si, qty: i.qty, src: "stock" as const }))); setMsg(""); }}
repeatDate={lastSet.length ? lastSet[0].date : null} groups={groups} owed={owed} inputRef={scanRef} listOpenRef={listOpenRef} />
</div>
<div className={styles.pickup}>
<PickupCart st={sel} cart={cart} mac={mac} onRecord={doIssue} />
</div>
</div>
)}
{mode === "return" && (
<div className={styles.grid}>
<HoldingPanel st={sel} onReturn={setRet} onError={setErr} />
<ReturnedToday st={sel} />
</div>
)}
{mode === "handin" && (
<div className={styles.grid}>
<HoldingPanel st={sel} onError={setErr} />
<HandInPanel st={sel} onRecord={() => setHandin(true)} />
</div>
)}
{mode === "swap" && (
<div className={styles.grid}>
<SwapPanel st={sel} onDone={(m) => { setErr(""); setMsg(m); }} onError={(e) => { setMsg(""); setErr(e); }} />
<SwappedToday st={sel} />
</div>
)}
<LiveRegion msg={msg} className={styles.msg} />
<ErrorLine msg={err} />
</div>
)}
{cam && <Camera onHit={camHit} message={camMsg} onClose={() => { setCam(false); scanRef.current?.focus(); }} />}
{bind && <BindDialog code={bind} onClose={() => setBind("")} onBound={(itemId, si) => { if (mode !== "issue") setMode("issue"); addGarment(itemId, si); }} />}
{ret && <ReturnDialog issue={ret} onClose={() => setRet(null)} />}
{handin && sel && <HandInDialog staff={sel} onClose={() => setHandin(false)} onDone={(m) => { setErr(""); setMsg(m); }} />}
{adjust && <AdjustDialog init={{ itemId: "", si: 0, preloved: true }} onClose={() => setAdjust(false)} />}
</section>
);
}
+192
View File
@@ -0,0 +1,192 @@
"use client";
/* Return, Hand in and Swap a size: the counter's other three modes. */
import { useMemo, useState } from "react";
import { useDerived, useSnap } from "@/lib/client";
import { Panel, QtyStepper, Tag } from "@/components/portal";
import { printHandInReceipt } from "@/components/dialogs";
import { key, label, onhand, plOf, type IssueRec, type StaffRec } from "@/lib/compute";
import { dayMonth, openIssuesOf, owedLinesOf, plural, sizeOf } from "./lib";
import styles from "./counter.module.css";
/** Every open issue line, ungrouped, with the signed toggle; Return when `onReturn` is given. */
export function HoldingPanel({ st, onReturn, onError }: { st: StaffRec; onReturn?: (i: IssueRec) => void; onError: (e: string) => void }) {
const { s, mutate } = useSnap();
const { byId } = useDerived();
const open = useMemo(() => openIssuesOf(s, st.id), [s, st.id]);
const owed = useMemo(() => owedLinesOf(s, st.id), [s, st.id]);
const qty = open.reduce((t, i) => t + i.qty, 0);
async function sign(i: IssueRec) {
const r = await mutate("issue.receipt", { id: i.id, receipt: !i.receipt });
if (!r.ok) onError(r.error);
}
return (
<Panel title="Holding" aside={plural(qty, "garment", "garments")}>
{open.length === 0 ? <div className={styles.empty}>Nothing out.</div> : (
<div className={styles.tableWrap}>
<table className="tc-table">
<thead><tr><th>Garment</th><th>Size</th><th className="num">Qty</th><th>Issued</th><th>Signed</th>{onReturn && <th><span className="sr-only">Action</span></th>}</tr></thead>
<tbody>
{open.map((i) => {
const it = byId[i.itemId];
return (
<tr key={i.id}>
<td>{label(it)}{i.preloved ? <span className={styles.meta}> · pre-loved</span> : null}</td>
<td className="tc-mono">{sizeOf(it, i.si)}</td>
<td className="num">{i.qty}</td>
<td className="tc-mono" style={{ fontSize: 12 }}>{dayMonth(i.date)}</td>
<td>
<button type="button" className={`btn ${i.receipt ? "btn-secondary" : "btn-ghost"} ${styles.rowGhost}`} aria-pressed={i.receipt}
aria-label={`${label(it)} size ${sizeOf(it, i.si)} signed for`} onClick={() => sign(i)}>{i.receipt ? "Signed" : "Mark signed"}</button>
</td>
{onReturn && (
<td style={{ textAlign: "right" }}>
<button type="button" className={`btn btn-ghost ${styles.rowGhost}`} aria-label={`Return ${label(it)} size ${sizeOf(it, i.si)}`} onClick={() => onReturn(i)}>Return</button>
</td>
)}
</tr>
);
})}
</tbody>
</table>
</div>
)}
{owed.length > 0 && (
<>
<div className={`tc-lbl ${styles.subLbl}`}>On order or waiting</div>
<div className={styles.tableWrap}>
<table className="tc-table">
<tbody>
{owed.map((o) => (
<tr key={o.key}>
<td>{label(byId[o.itemId])}</td>
<td className="tc-mono">{o.size || ""}</td>
<td className="num">{o.qty}</td>
<td className={styles.meta}>{o.where}</td>
</tr>
))}
</tbody>
</table>
</div>
</>
)}
</Panel>
);
}
export function ReturnedToday({ st }: { st: StaffRec }) {
const { s } = useSnap();
const { byId } = useDerived();
const rows = s.issues.filter((i) => i.staffId === st.id && i.returned?.date === s.today);
return (
<Panel title="Returned today" aside={rows.length ? plural(rows.reduce((t, i) => t + i.qty, 0), "garment", "garments") : undefined}>
{rows.length === 0 ? <div className={styles.empty}>Nothing returned today.</div> : rows.map((i) => {
const it = byId[i.itemId];
const cond = i.returned!.cond;
return (
<div key={i.id} className={styles.listRow}>
<div className={styles.listMain}>{label(it)} · <span className="tc-mono">{sizeOf(it, i.si)}</span> <span className="tc-mono">×{i.qty}</span></div>
<Tag tone={cond === "Returned - Good" ? "outline" : "low"}>{cond.replace("Returned - ", "")}</Tag>
{i.returned!.photoId && <a className={`btn btn-ghost ${styles.rowGhost}`} href={`/api/photo/${i.returned!.photoId}`} target="_blank" rel="noopener">Photo</a>}
</div>
);
})}
</Panel>
);
}
export function HandInPanel({ st, onRecord }: { st: StaffRec; onRecord: () => void }) {
const { s } = useSnap();
const { byId } = useDerived();
const list = s.handins.filter((h) => h.staffId === st.id).sort((a, b) => (a.date < b.date ? 1 : a.date > b.date ? -1 : 0));
return (
<Panel title="Hand in">
<div className={styles.panelPad}>
<button type="button" className="btn btn-primary" onClick={onRecord}>Record a hand-in</button>
</div>
<div className={`tc-lbl ${styles.subLbl}`}>Hand-ins</div>
{list.length === 0 ? <div className={styles.empty}>No hand-ins on file.</div> : list.map((h) => {
const good = h.lines.filter((l) => l.cond === "Good").reduce((t, l) => t + l.qty, 0);
const rag = h.lines.filter((l) => l.cond === "Rag").reduce((t, l) => t + l.qty, 0);
return (
<div key={h.id} className={styles.listRow}>
<span className="tc-mono" style={{ fontSize: 12, width: 52 }}>{dayMonth(h.date)}</span>
<div className={styles.listMain}>{good} to pre-loved · {rag} rag</div>
{h.credit && <Tag>Credited</Tag>}
<button type="button" className={`btn btn-ghost ${styles.rowGhost}`} onClick={() => printHandInReceipt(s, st, h, byId)} aria-label={`Receipt for the hand-in on ${dayMonth(h.date)}`}>Receipt</button>
</div>
);
})}
</Panel>
);
}
function SwapRow({ issue, onDone, onError }: { issue: IssueRec; onDone: (msg: string) => void; onError: (e: string) => void }) {
const { s, mutate } = useSnap();
const { L, byId } = useDerived();
const it = byId[issue.itemId];
const [si, setSi] = useState(-1);
const [qty, setQty] = useState(1);
const [busy, setBusy] = useState(false);
const n = Math.min(Math.max(1, qty), issue.qty);
if (!it) return null;
async function swap() {
if (si < 0 || busy) return;
setBusy(true);
const r = await mutate<{ size: string; qty: number }>("issue.exchange", { id: issue.id, si, qty: n });
setBusy(false);
if (!r.ok) { onError(r.error); return; }
setSi(-1); setQty(1);
onDone(`Swapped ${label(it)} ${sizeOf(it, issue.si)} for ${r.result.size} ×${r.result.qty}.`);
}
return (
<div className={styles.listRow}>
<div className={styles.listMain}>{label(it)} · <span className="tc-mono">{sizeOf(it, issue.si)}</span> <span className="tc-mono">×{issue.qty}</span></div>
<select className={`input ${styles.swapSelect}`} aria-label={`New size for ${label(it)}`} value={si} onChange={(e) => setSi(+e.target.value)}>
<option value={-1}>New size</option>
{it.sizes.map((sz, j) => {
if (j === issue.si) return null;
const k = key(it.id, j);
return <option key={j} value={j}>{String(sz)} ({issue.preloved ? `${plOf(s, k)} pre-loved` : `${onhand(s, L, k)} on shelf`})</option>;
})}
</select>
{issue.qty > 1 && <QtyStepper size="sm" value={n} min={1} max={issue.qty} label={`${label(it)} to swap`} onChange={setQty} />}
<button type="button" className="btn btn-secondary" disabled={si < 0 || busy} onClick={swap}>Swap</button>
</div>
);
}
export function SwapPanel({ st, onDone, onError }: { st: StaffRec; onDone: (msg: string) => void; onError: (e: string) => void }) {
const { s } = useSnap();
const open = useMemo(() => openIssuesOf(s, st.id), [s, st.id]);
return (
<Panel title="Holding" aside={plural(open.reduce((t, i) => t + i.qty, 0), "garment", "garments")}>
{open.length === 0 ? <div className={styles.empty}>Nothing out.</div> : open.map((i) => <SwapRow key={i.id} issue={i} onDone={onDone} onError={onError} />)}
</Panel>
);
}
/** Best effort: today's good returns with a new issue of the same garment, another size, today. */
export function SwappedToday({ st }: { st: StaffRec }) {
const { s } = useSnap();
const { byId } = useDerived();
const mine = s.issues.filter((i) => i.staffId === st.id);
const rows = mine
.filter((i) => i.returned?.date === s.today && i.returned.cond === "Returned - Good")
.flatMap((old) => {
const nu = mine.find((n) => n.date === s.today && n.itemId === old.itemId && n.si !== old.si && n.qty === old.qty);
return nu ? [{ old, nu }] : [];
});
return (
<Panel title="Swapped today">
{rows.length === 0 ? <div className={styles.empty}>Nothing swapped today.</div> : rows.map(({ old, nu }) => {
const it = byId[old.itemId];
return (
<div key={old.id} className={styles.listRow}>
<div className={styles.listMain}>{label(it)} · <span className="tc-mono">{sizeOf(it, old.si)}</span> <span className="tc-mono">{sizeOf(it, nu.si)}</span></div>
<span className="tc-mono">×{old.qty}</span>
</div>
);
})}
</Panel>
);
}
+70
View File
@@ -0,0 +1,70 @@
"use client";
import Link from "next/link";
import { useSnap } from "@/lib/client";
import { Meter, Tag } from "@/components/portal";
import { printCreditSlip } from "@/components/dialogs";
import { allowanceRouteOf, approvalRemaining, ccOf, initialRemaining, initialSets, openApproval, setsForFte, type CapCheck, type StaffRec } from "@/lib/compute";
import { dayMonth } from "./lib";
import styles from "./counter.module.css";
const Mono = ({ children }: { children: React.ReactNode }) => <span className="tc-mono">{children}</span>;
/* Who is at the counter: details, their route and signed form, and the six-set meters. */
export default function PersonPanel({ st, held, onChange }: { st: StaffRec; held: CapCheck; onChange: () => void }) {
const { s } = useSnap();
const cc = ccOf(s, st);
const cut = st.uniformStyle === "Women's" ? "Womens cut" : st.uniformStyle === "Men's" ? "Mens cut" : "";
const details: React.ReactNode[] = [];
if (st.group) details.push(st.group);
if (st.dept) details.push(st.dept);
if (cc) details.push(<Mono key="cc">{cc}</Mono>);
if (cut) details.push(cut);
if (st.top || st.pants) details.push(<span key="usual">usual <Mono>{st.top || ""}</Mono> / <Mono>{st.pants || ""}</Mono></span>);
const route = allowanceRouteOf(s, st);
const ap = openApproval(s, st.id);
const apRem = approvalRemaining(s, st.id);
const signed = ap ? ` · manager signed ${ap.sets} on ${dayMonth(ap.date)}, ${apRem} left` : " · nothing signed";
let sentence: React.ReactNode;
if (route === "fte") {
const n = setsForFte(st.fte);
sentence = <>FTE table{st.fte ? <> · <Mono>{st.fte}</Mono>{n !== null ? ` proposes ${n} sets` : ""}</> : " · no FTE recorded"}{signed}</>;
} else if (route === "kit") {
const sets = initialSets(s, st);
const left = initialRemaining(s, st) ?? 0;
sentence = <>Starting kit{sets !== null ? ` · ${sets} sets` : ""}{left > 0 ? ` · ${left} garments still to issue` : ""}{ap ? signed : ""}</>;
} else {
sentence = <>Manager approval{signed}</>;
}
return (
<div className={styles.person}>
<div style={{ minWidth: 0 }}>
<div className={styles.line1}>
<span className={styles.name}>{st.first} {st.last}</span>
<span className={`tc-mono ${styles.meta}`}>{st.num}</span>
{st.selfEmail && <Tag>Staff app</Tag>}
{st.inactive && <Tag tone="accent">Inactive</Tag>}
</div>
{details.length > 0 && (
<div className={styles.details}>{details.map((d, i) => <span key={i}>{i > 0 ? " · " : ""}{d}</span>)}</div>
)}
<div className={styles.route}>
<span>{sentence}</span>
<button type="button" className={`btn btn-ghost ${styles.inlineGhost}`} onClick={() => window.open(`/print/order-form?staff=${encodeURIComponent(st.id)}`, "_blank")}>Order form</button>
{ap && <button type="button" className={`btn btn-ghost ${styles.inlineGhost}`} onClick={() => printCreditSlip(s, st, ap)}>Credit slip</button>}
</div>
</div>
<div className={styles.meters}>
<Meter label="Tops" value={held.tops} of={held.cap} />
<Meter label="Pants" value={held.pants} of={held.cap} />
{held.other > 0 && <span className={styles.meta}>+{held.other} outside a set</span>}
</div>
<div className={styles.personActions}>
<Link href={`/app/staff/${st.id}`} className="btn btn-ghost">Open record</Link>
<button type="button" className="btn btn-ghost" onClick={onChange}>Change person</button>
</div>
</div>
);
}
+60
View File
@@ -0,0 +1,60 @@
"use client";
import Link from "next/link";
import { useMemo, useState } from "react";
import { useSnap } from "@/lib/client";
import { Meter, Panel } from "@/components/portal";
import { heldByStaff, setsCap } from "@/lib/compute";
import styles from "./counter.module.css";
/* No person chosen yet: find one by name or staff number (a badge scan types the number). */
export default function PersonPicker({ onPick }: { onPick: (id: string) => void }) {
const { s } = useSnap();
const [q, setQ] = useState("");
// One walk of the issues for the whole register, not one per row.
const held = useMemo(() => heldByStaff(s), [s]);
const cap = setsCap(s.settings.capSets);
const qq = q.trim().toLowerCase();
const active = s.staff.filter((st) => !st.inactive);
const matches = active
.filter((st) => !qq || `${st.first} ${st.last}`.toLowerCase().includes(qq) || `${st.last} ${st.first}`.toLowerCase().includes(qq) || st.num.toLowerCase().includes(qq))
.slice(0, 8);
function onKey(e: React.KeyboardEvent<HTMLInputElement>) {
if (e.key !== "Enter") return;
const exact = qq ? s.staff.find((st) => st.num.toLowerCase() === qq) : undefined;
const first = exact || matches[0];
if (first) { e.preventDefault(); onPick(first.id); }
}
return (
<Panel title="Find a person" aside={s.staff.length ? `${active.length} on the register` : undefined}>
<div className={styles.search}>
<input className={`input ${styles.searchInput}`} aria-label="Search the register by name or staff number" placeholder="Name or staff number, or scan a badge"
value={q} onChange={(e) => setQ(e.target.value)} onKeyDown={onKey} autoFocus />
</div>
{s.staff.length === 0 ? (
<div className={styles.empty}>No staff on the register yet. <Link href="/app/staff">Add staff</Link></div>
) : matches.length === 0 ? (
<div className={styles.empty}>Nobody matches {q.trim()}.</div>
) : (
<div>
{matches.map((st) => {
const h = held[st.id] || { tops: 0, pants: 0, other: 0, sets: 0 };
return (
<button type="button" key={st.id} className={styles.pickRow} onClick={() => onPick(st.id)}>
<span className={styles.pickMain}>
<span style={{ display: "block", fontWeight: 700 }}>{st.first} {st.last} <span className="tc-mono" style={{ fontWeight: 400, color: "#57534f", fontSize: 12 }}>{st.num}</span></span>
<span className={styles.meta} style={{ display: "block" }}>{[st.group, st.dept].filter(Boolean).join(" · ") || "—"}</span>
</span>
<span className={styles.pickMeters}>
<Meter label="Tops" value={h.tops} of={cap} size="sm" />
<Meter label="Pants" value={h.pants} of={cap} size="sm" />
</span>
</button>
);
})}
</div>
)}
</Panel>
);
}
+110
View File
@@ -0,0 +1,110 @@
"use client";
import { useId } from "react";
import { useDerived, useSnap } from "@/lib/client";
import { Icon, Kbd, Panel, QtyStepper, Seg, Tag } from "@/components/portal";
import { ccOf, genderLabel, groupsLabel, key, label, money, onhand, plOf, setHalf, type StaffRec } from "@/lib/compute";
import { plural, pronoun, sizeOf, type Source } from "./lib";
import type { CounterCart } from "./useCounterCart";
import styles from "./counter.module.css";
export default function PickupCart({ st, cart: c, mac, onRecord }: { st: StaffRec; cart: CounterCart; mac: boolean; onRecord: () => void }) {
const { s } = useSnap();
const { L, byId } = useDerived();
const reasonsId = useId();
const pr = pronoun(st);
const cap = c.cap;
const costCentre = ccOf(s, st);
const apClause = c.apN > 0 ? (c.apN === c.apRem ? ` · uses the last ${c.apN} signed` : ` · uses ${c.apN} signed`) : "";
return (
<Panel title="This pickup" aside={c.cart.length ? `${plural(c.cart.length, "line", "lines")} · ${plural(c.garments, "garment", "garments")}` : undefined}>
{c.cart.length === 0 && <div className={styles.empty}>Scan, type or tap a usual to add garments.</div>}
{c.cart.length > 0 && (
<div>
{c.cart.map((line, i) => {
const it = byId[line.itemId];
const size = sizeOf(it, line.si);
const k = key(line.itemId, line.si);
const oh = onhand(s, L, k), pl = plOf(s, k);
const opts: Source[] = pl > 0 || line.src === "preloved" ? ["stock", "preloved", "order"] : ["stock", "order"];
const half = setHalf(it);
const want = half === "top" ? st.top : half === "pants" ? st.pants : "";
const offUsual = !!want && !!it && it.sizes.map(String).includes(String(want)) && size !== String(want);
const shortShelf = line.src === "stock" && line.qty > oh;
const shortPool = line.src === "preloved" && line.qty > pl;
return (
<div key={line.itemId + ":" + line.si} className={styles.cartLine}>
<div className={styles.lineMain}>
<div className={styles.lineTitle}>{label(it)} · <span className="tc-mono">{size}</span></div>
<div className={styles.lineSeg}>
<Seg<Source> size="sm" label={`Where ${label(it)} ${size} comes from`} opts={opts} value={(line.src ?? "") as Source}
onChange={(v) => c.setLine(i, { src: v })}
labels={{
stock: <>Shelf <b className="tc-mono" style={{ fontWeight: 500 }}>{oh}</b></>,
preloved: <>Pre-loved <b className="tc-mono" style={{ fontWeight: 500 }}>{pl}</b></>,
order: "Order in",
}} />
{line.src === null && <span className={styles.hint}>Pick a source</span>}
{shortShelf && <Tag tone="low">Not enough on the shelf</Tag>}
{shortPool && <Tag tone="low">Not enough pre-loved</Tag>}
</div>
{offUsual && <div className={styles.hint} style={{ marginTop: 4 }}>Usual size {want}</div>}
</div>
<QtyStepper value={line.qty} min={0} label={`${label(it)} ${size}`} onChange={(n) => c.setQty(i, n)} />
<div className={`tc-mono ${styles.cost}`}>{money(line.src === "preloved" ? 0 : line.qty * (it?.cost || 0))}</div>
</div>
);
})}
</div>
)}
{c.cart.length > 0 && cap && (
<div className={styles.after}>
<div className={styles.afterRow}>
{c.needsTick ? <span className="tc-mark" aria-hidden="true" style={{ marginRight: 6 }} /> : <Icon name="check" size={16} />}
<span>After this {pr.subj} {pr.holds} <b className="tc-mono">{cap.afterSets} of {cap.cap}</b> sets{apClause}</span>
</div>
{c.needsTick && (
<>
<div id={reasonsId}>
{c.overCap && (
<div className={styles.reason}>
{cap.breach === "other" ? `Past ${cap.otherCap} garments outside a set — holds ${cap.afterOther}` : `Past ${cap.cap} sets — holds ${cap.afterTops} tops and ${cap.afterPants} pairs`}
</div>
)}
{c.offItems.map((it) => <div key={"g" + it.id} className={styles.reason}>{it.item} is for {groupsLabel(it.groups)}</div>)}
{c.offStyleItems.map((it) => <div key={"c" + it.id} className={styles.reason}>{it.item} is the {genderLabel(it.gender)} cut</div>)}
</div>
<label className={styles.tick}>
<input type="checkbox" checked={c.override} onChange={() => c.setOverride(!c.override)} aria-describedby={reasonsId} />
Record as an override
</label>
</>
)}
{c.ap && (
<div className={styles.afterRow}>
<span>Sets off the signed form</span>
<QtyStepper size="sm" value={c.apN} min={0} max={c.apRem} label="set off the signed form" onChange={(n) => c.setApDeduct(n)} />
<span className={styles.meta}>{c.apRem} left</span>
</div>
)}
</div>
)}
<div className={styles.charge}>
<div>
<div className="tc-lbl">Charged to {costCentre || "no cost centre"}</div>
<div className={`tc-mono ${styles.total}`}>{money(c.cartVal)}</div>
</div>
<div className={styles.chargeBtns}>
<button type="button" className="btn btn-secondary" onClick={c.printSlip} disabled={c.cannot || c.handed.length === 0}><Icon name="print" size={16} /> Collection slip</button>
<button type="button" className="btn btn-primary" onClick={onRecord} disabled={c.cannot} aria-keyshortcuts="Control+Enter Meta+Enter">
Record issue <span className={styles.kbdHint}><Kbd onAccent>{mac ? "⌘↵" : "Ctrl↵"}</Kbd></span>
</button>
</div>
{c.inactive && <div className={styles.hint} style={{ width: "100%" }}>Inactive on the register: reactivate to issue</div>}
</div>
</Panel>
);
}
+94
View File
@@ -0,0 +1,94 @@
/* Counter (/app/counter). Layout only; the shared parts (Panel, Seg, Meter, QtyStepper, Tag) keep their own styles. */
.stack { display: flex; flex-direction: column; gap: 18px; }
/* Person panel */
.person { display: grid; grid-template-columns: minmax(0, 1.3fr) minmax(0, 1fr) auto; gap: 24px; align-items: center; padding: 16px 20px; border: 2px solid var(--color-text); background: var(--color-bg); }
.line1 { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; }
.name { font-family: var(--font-heading); font-weight: 800; font-size: 24px; letter-spacing: -0.01em; line-height: 1.15; }
.meta { font-size: 12px; color: #57534f; }
.details { margin-top: 4px; font-size: 13px; color: #57534f; }
.route { margin-top: 6px; font-size: 12px; color: #57534f; display: flex; flex-wrap: wrap; align-items: baseline; gap: 2px 14px; }
.inlineGhost { min-height: 0 !important; padding: 0 !important; font-size: 12px; }
.meters { display: flex; flex-direction: column; gap: 8px; }
.personActions { display: flex; flex-direction: column; gap: 8px; align-items: flex-end; }
/* Mode row and grid */
.modeRow { display: flex; justify-content: space-between; align-items: center; gap: 12px; }
.modeSeg { max-width: 100%; overflow-x: auto; }
.kbdHint { font-size: 12px; color: #57534f; white-space: nowrap; }
.grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 24px; align-items: start; }
.grid > * { min-width: 0; }
/* Person search */
.search { padding: 14px 16px; border-bottom: 1px solid #cfcccb; }
.searchInput { width: 100%; }
.pickRow { display: flex; align-items: center; gap: 14px; width: 100%; padding: 11px 16px; border: none; border-top: 1px solid #cfcccb; background: transparent; color: inherit; text-align: left; cursor: pointer; font: inherit; }
.pickRow:first-child { border-top: 0; }
.pickRow:hover { background: var(--color-neutral-200); }
.pickRow:focus-visible { outline: 2px solid var(--color-accent); outline-offset: -3px; }
.pickMain { flex: 1; min-width: 0; }
.pickMeters { flex: none; display: flex; flex-direction: column; gap: 4px; }
/* Add garments */
.scanWrap { position: relative; padding: 14px 16px; border-bottom: 1px solid #cfcccb; }
.scanBox { display: flex; align-items: center; gap: 8px; min-height: 36px; padding: 0 4px 0 10px; background: #fff; border: 2px solid var(--color-text); color: #57534f; }
.scanBox:focus-within { border-color: var(--color-accent); box-shadow: inset 0 0 0 1px var(--color-accent); }
.scanInput { flex: 1; min-width: 0; border: none; outline: none; background: transparent; padding: 6px 0; font-family: var(--font-body); font-size: 14px; color: var(--color-text); }
.scanInput::placeholder { color: #928d8a; }
.camBtn { min-height: 28px !important; padding: 0 8px !important; }
.pop { position: absolute; left: 16px; right: 16px; top: calc(100% - 12px); z-index: 30; max-height: 440px; overflow: auto; background: var(--color-bg); border: 2px solid var(--color-text); }
.opt { padding: 10px 14px; border-top: 1px solid #cfcccb; }
.opt:first-child { border-top: 0; }
.optHead { display: flex; gap: 8px; align-items: baseline; margin-bottom: 6px; font-weight: 700; }
.optDivider { padding: 8px 14px 4px; border-top: 2px solid var(--color-text); }
.popEmpty { padding: 12px 14px; font-size: 13px; color: #57534f; }
.scanErr { padding: 0 16px 12px; }
.usualLbl { padding: 12px 16px 4px; }
.chips { padding: 6px 16px 14px; display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
.chip { gap: 10px !important; }
.holdLbl { padding: 10px 16px; border-top: 2px solid var(--color-text); }
.holdFoot { padding: 8px 16px 12px; display: flex; gap: 14px; align-items: center; flex-wrap: wrap; }
.tableWrap { overflow-x: auto; }
.rowGhost { min-height: 0 !important; padding-top: 0 !important; padding-bottom: 0 !important; }
/* This pickup */
.cartLine { display: flex; align-items: center; gap: 14px; padding: 11px 16px; border-top: 1px solid #cfcccb; }
.cartLine:first-child { border-top: 0; }
.lineMain { flex: 1; min-width: 0; }
.lineTitle { font-weight: 700; }
.lineSeg { margin-top: 6px; display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.hint { font-size: 12px; font-weight: 600; color: var(--color-accent-700); }
.cost { width: 70px; flex: none; text-align: right; }
.empty { padding: 14px 16px; font-size: 13px; color: #57534f; }
.after { padding: 12px 16px; border-top: 1px solid #cfcccb; display: flex; flex-direction: column; gap: 8px; font-size: 14px; }
.afterRow { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.reason { font-size: 13px; font-weight: 600; color: var(--color-accent-700); padding-left: 26px; }
.tick { display: flex; align-items: center; gap: 10px; padding-left: 26px; font-weight: 600; cursor: pointer; }
.tick input { width: 16px; height: 16px; accent-color: var(--color-accent); }
.charge { padding: 14px 16px; border-top: 2px solid var(--color-text); display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
.total { font-size: 24px; font-weight: 600; line-height: 1.2; }
.chargeBtns { margin-left: auto; display: flex; gap: 12px; flex-wrap: wrap; align-items: center; }
.msg { font-size: 13px; font-weight: 600; border-top: 2px solid var(--color-text); padding-top: 8px; }
/* Return / Hand in / Swap */
.panelPad { padding: 14px 16px; }
.listRow { display: flex; align-items: center; gap: 12px; padding: 11px 16px; border-top: 1px solid #cfcccb; flex-wrap: wrap; }
.listRow:first-child { border-top: 0; }
.listMain { flex: 1; min-width: 0; }
.subLbl { padding: 10px 16px; border-top: 2px solid var(--color-text); }
.swapSelect { min-height: 32px; max-width: 220px; }
@media screen and (max-width: 780px) {
.person { grid-template-columns: minmax(0, 1fr); gap: 14px; padding: 14px 16px; }
.personActions { flex-direction: row; align-items: center; gap: 18px; flex-wrap: wrap; }
.modeSeg :global(.tc-seg) { flex-wrap: nowrap; }
.kbdHint { display: none; }
.grid { grid-template-columns: minmax(0, 1fr); }
.cartFirst > .pickup { order: -1; }
.cartLine { flex-wrap: wrap; }
.chargeBtns { margin-left: 0; width: 100%; flex-direction: column; align-items: stretch; }
.chargeBtns > :global(.btn) { width: 100%; justify-content: center; }
.scanInput { font-size: 16px; }
.route :global(.btn), .personActions :global(.btn), .rowGhost, .camBtn { min-height: 44px !important; }
.swapSelect { max-width: 100%; }
}
+74
View File
@@ -0,0 +1,74 @@
/* Small shared pieces for the person-first counter (/app/counter). */
import { isOpen, type IssueRec, type Item, type Snapshot, type StaffRec } from "@/lib/compute";
export type Source = "stock" | "preloved" | "order";
/** src null = both shelf and pre-loved stock exist, so the coordinator must pick one. */
export type CartLine = { itemId: string; si: number; qty: number; src: Source | null };
export type Mode = "issue" | "return" | "handin" | "swap";
export const MODES: readonly Mode[] = ["issue", "return", "handin", "swap"];
export const MODE_LABELS: Record<Mode, string> = { issue: "Issue", return: "Return", handin: "Hand in", swap: "Swap a size" };
export const plural = (n: number, one: string, many: string) => `${n} ${n === 1 ? one : many}`;
/** "2 Sep": the counter's short date. */
export function dayMonth(iso: string): string {
if (!iso || iso.length < 10) return "—";
const d = new Date(+iso.slice(0, 4), +iso.slice(5, 7) - 1, +iso.slice(8, 10));
if (Number.isNaN(d.getTime())) return "—";
return d.toLocaleDateString("en-AU", { day: "numeric", month: "short" }).replace("Sept", "Sep");
}
/** Words for the person, from the cut their record is set to. */
export function pronoun(st: StaffRec | undefined) {
if (st?.uniformStyle === "Women's") return { poss: "Her", subj: "she", holds: "holds" } as const;
if (st?.uniformStyle === "Men's") return { poss: "His", subj: "he", holds: "holds" } as const;
return { poss: "Their", subj: "they", holds: "hold" } as const;
}
/** Garments out with this person now: issued, not returned, not handed in. Newest first. */
export function openIssuesOf(s: Snapshot, staffId: string): IssueRec[] {
return s.issues
.filter((i) => i.staffId === staffId && !i.returned && !i.handedIn)
.sort((a, b) => (a.date === b.date ? (a.createdAt < b.createdAt ? 1 : -1) : a.date < b.date ? 1 : -1));
}
export type HoldGroup = { itemId: string; si: number; qty: number; last: string; latest: IssueRec };
/** Open issues grouped by garment and size, quantities summed, most recent first. */
export function holdingGroups(open: IssueRec[]): HoldGroup[] {
const m = new Map<string, HoldGroup>();
for (const i of open) {
const k = i.itemId + ":" + i.si;
const g = m.get(k);
if (!g) m.set(k, { itemId: i.itemId, si: i.si, qty: i.qty, last: i.date, latest: i });
else { g.qty += i.qty; if (i.date > g.last) { g.last = i.date; g.latest = i; } }
}
return [...m.values()].sort((a, b) => (a.last < b.last ? 1 : a.last > b.last ? -1 : 0));
}
export type OwedLine = { key: string; itemId: string; size: string; qty: number; where: string };
/** What is committed to this person and not handed over yet, line by line: open orders (less what
* has arrived), pickups waiting, approved request lines. The same three things capCheck counts. */
export function owedLinesOf(s: Snapshot, staffId: string): OwedLine[] {
const out: OwedLine[] = [];
for (const o of s.orders) {
if (o.staffId !== staffId || !isOpen(o)) continue;
const got: Record<string, number> = {};
for (const rc of o.receipts) for (const l of rc.lines) got[l.itemId + "|" + l.size] = (got[l.itemId + "|" + l.size] || 0) + l.qty;
for (const l of o.lines) {
const k = l.itemId + "|" + l.size, done = Math.min(l.qty, got[k] || 0);
got[k] = (got[k] || 0) - done;
if (l.qty > done) out.push({ key: "o" + l.id, itemId: l.itemId, size: l.size, qty: l.qty - done, where: `On order · ${o.code}` });
}
}
for (const p of s.pickups) if (p.staffId === staffId && !p.pickedUp) p.lines.forEach((l, i) => out.push({ key: "p" + p.id + i, itemId: l.itemId, size: l.size, qty: l.qty, where: "Waiting to collect" }));
(s.owedRequestLines || []).forEach((r, i) => { if (r.staffId === staffId) out.push({ key: "r" + i, itemId: r.itemId, size: "", qty: r.qty, where: "Approved request" }); });
return out;
}
export const sizeOf = (it: Item | undefined, si: number) => (it ? String(it.sizes[si] ?? "?") : "?");
/** A real dialog, the command panel or any overlay is up, so page shortcuts stand down. */
export function overlayOpen(): boolean {
return typeof document !== "undefined" && !!document.querySelector('[role="dialog"][aria-modal="true"], .overlay, .tc-cmd-overlay');
}
+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>;