"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; listOpenRef: React.RefObject; }) { 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(null); const wrapRef = useRef(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 = {}; const latest: Record = {}; 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) { if (e.key === "Escape" && listShown) { e.preventDefault(); setOpen(false); return; } if (e.key === "ArrowDown" && listShown) { e.preventDefault(); popRef.current?.querySelector("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) => (
{label(it)}{it.sku && {it.sku}}
add(it.id, si)} />
); 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 ( Repeat last · {dayMonth(repeatDate)} : undefined}>
{ if (!wrapRef.current?.contains(e.relatedTarget as Node | null)) setOpen(false); }}>
{ setQ(e.target.value); setOpen(true); setErr(""); }} onFocus={() => setOpen(true)} onKeyDown={onKey} />
{listShown && ( )}
{err &&
}
{pr.poss} usual · one tap adds it
{usual.map(({ it, si }) => ( ))} {!st.top && !st.pants && ( No usual sizes recorded · Record them )}
Holding now · {holdingQty} {holdingQty === 1 ? "garment" : "garments"}
{groups.length === 0 ? (
Nothing out.
) : (
{shownGroups.map((g) => { const it = byId[g.itemId]; return ( ); })}
{label(it)} · {sizeOf(it, g.si)} ×{g.qty} last {dayMonth(g.last)}
)} {(groups.length > HOLD_SHOWN || owed > 0) && (
{groups.length > HOLD_SHOWN && } {owed > 0 && +{owed} on order or waiting}
)}
); }