"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" && (