"use client"; /* Person › Hand back (HANDBACK workstream). One basket for everything a person brings to the window: * each line is one garment with its condition, and any line can swap for another size. The whole * list is recorded in one handback.commit, so a return and its swap never land half way. */ import { useCallback, useEffect, useMemo, useState } from "react"; import { useDerived, useSnap } from "@/lib/client"; import { bcParse, key, label, onhand, plOf, staffName } from "@/lib/compute"; import { DIVIDER, INK, MBar, MButton, MChipRow, MEmpty, MONO, MRow, MSection, MUTED, useScanFlash, useToast } from "@/components/m"; import { useHeld, type Held } from "@/components/MPerson"; import { useBasket, type BackLine } from "@/components/MBasket"; import MScan from "@/components/MScan"; import type { DoneProps } from "@/components/SignFlow"; export type PersonTabProps = { staffId: string; /** The tab calls it in an effect; the shell renders it docked below MBody (History sets null). */ setBar: (bar: React.ReactNode) => void; /** The shell swaps the whole screen for . */ onDone: (d: DoneProps) => void; /** The shell shows MError under the rule. */ onError: (msg: string) => void; }; type Cond = BackLine["cond"]; const CONDS: { value: Cond; label: string }[] = [ { value: "Good", label: "Good" }, { value: "Damaged", label: "Damaged" }, { value: "Condemn", label: "Condemn" }, { value: "Lost", label: "Lost" }, ]; /** The phone's four words for the record's four return conditions. */ const RECORDED: Record = { Good: "Returned - Good", Damaged: "Returned - Damaged", Condemn: "Written Off", Lost: "Lost", }; const plural = (n: number, w: string) => `${n} ${w}${n === 1 ? "" : "s"}`; let seq = 0; const newUid = () => `${Date.now().toString(36)}${(seq++).toString(36)}${Math.random().toString(36).slice(2, 6)}`; // The mockup's .chip and .x; m.tsx keeps its chip style private. const chipBtn: React.CSSProperties = { minHeight: 44, minWidth: 48, padding: "0 12px", border: "2px solid " + INK, background: "transparent", color: INK, fontFamily: "inherit", fontSize: 14, fontWeight: 700, borderRadius: 0, display: "inline-flex", alignItems: "center", justifyContent: "center", cursor: "pointer", flex: "none", }; const xBtn: React.CSSProperties = { border: 0, background: "none", width: 44, height: 44, fontSize: 22, color: MUTED, padding: 0, cursor: "pointer", flex: "none", fontFamily: "inherit", }; export default function HandBackTab({ staffId, setBar, onDone, onError }: PersonTabProps) { const { s, mutate } = useSnap(); const { L, byId } = useDerived(); const toast = useToast(); const flash = useScanFlash(); const basket = useBasket(); const { setBack, clear } = basket; const st = s.staff.find((x) => x.id === staffId); const held = useHeld(s, staffId); const lines = basket.back(staffId); const [scanning, setScanning] = useState(false); const [swapOpen, setSwapOpen] = useState(null); const [saving, setSaving] = useState(false); const heldBy = useMemo(() => { const m: Record = {}; for (const h of held) m[h.key] = h; return m; }, [held]); const usedOf = (k: string) => lines.filter((l) => key(l.itemId, l.si) === k).length; // The record moved under the basket (a garment taken back elsewhere): drop lines past what they hold. useEffect(() => { if (!lines.length) return; const seen: Record = {}; const kept = lines.filter((l) => { const k = key(l.itemId, l.si); seen[k] = (seen[k] || 0) + 1; return seen[k] <= (heldBy[k]?.qty || 0); }); if (kept.length !== lines.length) setBack(staffId, kept); }, [heldBy, lines, setBack, staffId]); /* The server takes new garments back before pre-loved ones, so the first N lines of a variant are * new (a swap comes off the shelf, a Good one goes back on it) and the rest pre-loved (the pool). */ const preloved = useMemo(() => { const out: Record = {}; const seen: Record = {}; for (const l of lines) { const k = key(l.itemId, l.si); const newUnits = (heldBy[k]?.issues || []).filter((i) => !i.preloved).reduce((t, i) => t + i.qty, 0); out[l.uid] = (seen[k] || 0) >= newUnits; seen[k] = (seen[k] || 0) + 1; } return out; }, [lines, heldBy]); /** What a swap to size `si` can still draw on for this line, after the other lines' swaps and * whatever the other lines put back in good condition. */ const swapStock = (line: BackLine, si: number) => { const k = key(line.itemId, si); const pl = preloved[line.uid]; let n = pl ? plOf(s, k) : onhand(s, L, k); for (const o of lines) { if (o.uid === line.uid || preloved[o.uid] !== pl || o.itemId !== line.itemId) continue; if (o.swapSi === si) n--; if (o.cond === "Good" && o.si === si) n++; } return Math.max(0, n); }; const add = (h: Held) => { setBack(staffId, [...lines, { uid: newUid(), itemId: h.itemId, si: h.si, cond: "Good", swapSi: null }]); }; const tapHeld = (h: Held) => { if (usedOf(h.key) >= h.qty) { toast(`They only hold ${h.qty}`); return; } add(h); }; const onScan = (raw: string) => { setScanning(false); const code = raw.trim(); const v = bcParse(s, code); const h = v ? heldBy[key(v.itemId, v.si)] : undefined; if (!h) { toast(`${code} isn’t something ${st?.first || "they"} holds`); return; } if (held.every((x) => usedOf(x.key) >= x.qty)) { toast("Everything they hold is already on the list"); return; } if (usedOf(h.key) >= h.qty) { toast(`They only hold ${h.qty}`); return; } flash("Garment", `${label(byId[h.itemId])} ${h.size}`, () => add(h)); }; const update = (uid: string, patch: Partial) => setBack(staffId, lines.map((l) => (l.uid === uid ? { ...l, ...patch } : l))); const remove = (uid: string) => { setBack(staffId, lines.filter((l) => l.uid !== uid)); setSwapOpen(null); }; const commit = useCallback(async () => { if (!lines.length || saving) return; setSaving(true); onError(""); const sent = lines; const r = await mutate<{ back: number; swaps: number }>("handback.commit", { staffId, lines: sent.map((l) => ({ itemId: l.itemId, si: l.si, cond: RECORDED[l.cond], swapSi: l.swapSi })), }); setSaving(false); if (!r.ok) { onError(r.error); return; } const shelfKeys: string[] = []; for (const l of sent) { if (l.cond === "Good" && !preloved[l.uid]) shelfKeys.push(key(l.itemId, l.si)); if (l.swapSi !== null) shelfKeys.push(key(l.itemId, l.swapSi)); } const n = r.result?.back ?? sent.length; const swaps = r.result?.swaps ?? sent.filter((l) => l.swapSi !== null).length; clear("back", staffId); onDone({ head: `${plural(n, "item")} handed back`, sub: staffName(st) + (swaps ? ` · ${plural(swaps, "size swap")} issued` : ""), shelfKeys: [...new Set(shelfKeys)], next: "scan", }); }, [lines, saving, onError, mutate, staffId, preloved, clear, onDone, st]); useEffect(() => { setBar(saving ? : lines.length ? : ); }, [setBar, saving, lines.length, commit]); return ( <> setScanning(true)} /> {lines.length > 0 && ( <> {lines.map((l) => { const it = byId[l.itemId]; const name = label(it); const size = String(it?.sizes[l.si] ?? l.si); const swapSize = l.swapSi === null ? null : String(it?.sizes[l.swapSi] ?? l.swapSi); const open = swapOpen === l.uid; const others = (it?.sizes || []).map((sz, i) => ({ sz: String(sz), i })).filter((x) => x.i !== l.si); const counts: Record = {}; for (const x of others) counts[String(x.i)] = swapStock(l, x.i); const cur = l.swapSi === null ? null : String(l.swapSi); return ( // The mockup's .cl: MLine's layout, with the swap note in ink rather than the flag's accent.
{name} {size} {swapSize &&
Swap for {swapSize}
}
{ // Only a garment going back on the shelf swaps; anything else is replaced by an issue. if (c === "Good") { update(l.uid, { cond: c }); return; } update(l.uid, { cond: c, swapSi: null }); if (open) setSwapOpen(null); }} /> {open && l.cond === "Good" && (
({ value: String(x.i), label: x.sz, n: counts[String(x.i)] }))} disabled={(v) => counts[v] <= 0 && cur !== v} onPick={(v) => { update(l.uid, { swapSi: cur === v ? null : Number(v) }); setSwapOpen(null); }} />
)}
); })} )} {held.length === 0 ? : held.map((h) => ( tapHeld(h)} /> ))} {scanning && setScanning(false)} />} ); }