Files
threadcount-community/components/counter/AddGarments.tsx
T
ThreadCount 0910bc32c1 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 38e16eb on 2026-09-15. Licensed under the Functional Source License (FSL-1.1-ALv2).
2026-09-16 07:57:54 +10:00

192 lines
9.6 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";
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>
);
}