"use client"; /* Variance — only the lines that don't match, what happens when the count commits, and the commit. A gap at or over the facility's threshold has to carry a reason before anything is filed. */ import { useCallback, useEffect, useMemo, useState } from "react"; import { useParams, useRouter } from "next/navigation"; import { useDerived, useSnap } from "@/lib/client"; import { UNPLACED, bcBound, formatInZone, locMap, locSubtree, locUnder, onhand, reorderAt, touched, variantName } from "@/lib/compute"; import { INK, MBar, MBody, MEmpty, MError, MRule, MTop, MPanel, MInkLink } from "@/components/m"; import { clearCount, readCount } from "@/lib/opencount"; const REASONS = ["At laundry", "Condemned", "Missing", "Other"]; export default function MVariance() { const { s, mutate, busy } = useSnap(); const { L, byId, variants } = useDerived(); const router = useRouter(); const locationId = String(useParams().id || ""); const locs = useMemo(() => locMap(s), [s]); const locName = locationId === UNPLACED ? "Not on a shelf" : locs[locationId]?.name || "Location"; const lines = useMemo(() => { const sub = locationId === UNPLACED ? null : locSubtree(s, locationId); // Exactly the set the counting screen lists, and it has to stay the same test. A placed size // counts even with no history, and so does an unplaced size with a barcode bound to it — // somebody stood at the counter and scanned that label onto that size, which is why the // counting screen lets you count it. Leave that arm off here and a size counted on the phone // has no row on this screen and no line in the payload: committing files a stocktake without // it, the garments found on the trolley are never counted in, and clearCount() then wipes the // tally that was the only record they had been found. return variants .filter((v) => (sub ? sub.has(s.placed[v.key] || "") : !s.placed[v.key] && (touched(s, L, v.key) || !!bcBound(s, v.item, v.si)))) .map((v) => ({ ...v, expected: onhand(s, L, v.key), code: bcBound(s, v.item, v.si), where: locUnder(locs, s.placed[v.key], locationId) })); }, [s, L, variants, locationId, locs]); const [counted, setCounted] = useState | null>(null); const [savedAt, setSavedAt] = useState(""); const [reason, setReason] = useState>({}); const [accepted, setAccepted] = useState>({}); const [err, setErr] = useState(""); // The tally belongs to the person who took it, so it is read back under their own key — the // counting screen writes it under theirs. When it was taken matters as much as what it says: // a count resumed the next morning has had a night of issuing against it, and the screen should // say when it was last touched rather than present a stale tally as if it were fresh. const me = s.session.userId; useEffect(() => { const open = readCount(me, locationId); setCounted(open?.n ?? {}); setSavedAt(open?.savedAt ?? ""); }, [me, locationId]); const gate = Math.max(1, s.settings.varianceReason); const off = useMemo(() => (counted ? lines.filter((l) => (counted[l.key] ?? 0) !== l.expected) : []), [counted, lines]); const totalCounted = counted ? lines.reduce((t, l) => t + (counted[l.key] ?? 0), 0) : 0; const totalExpected = lines.reduce((t, l) => t + l.expected, 0); const needsReason = off.filter((l) => Math.abs((counted?.[l.key] ?? 0) - l.expected) >= gate && !reason[l.key]); // What the shelf will look like once this commits — not what the commit does. Committing a count // writes stock adjustments and the stocktake itself and nothing else; the reorder draft is a // separate, deliberate step on Reorder, which is where the quantities can still be changed // before anything goes to a supplier. const willReorder = useMemo(() => { if (!counted) return { lines: 0, units: 0 }; let n = 0, units = 0; for (const l of lines) { const after = counted[l.key] ?? 0; const par = reorderAt(s, l.key); if (after <= par && l.expected > par) { n++; units += Math.max(0, par * 2 - after); } } return { lines: n, units }; }, [counted, lines, s]); const commit = useCallback(async () => { if (!counted) return; if (needsReason.length) { setErr(`A gap of ${gate} or more needs a reason — ${needsReason.length} line${needsReason.length === 1 ? "" : "s"} still to go.`); return; } const payload = lines.map((l) => ({ itemId: l.itemId, si: l.si, counted: counted[l.key] ?? 0, reason: reason[l.key] || "" })); const r = await mutate("stocktake.apply", { lines: payload, mode: "shelf", locationId: locationId === UNPLACED ? "" : locationId }); if (!r.ok) { setErr(r.error); return; } clearCount(me, locationId); // A count that leaves lines below par hands straight over to Reorder. Nothing is drafted by // the commit itself, and a count that ends on the home screen is a count whose shortfall // nobody ever goes back for. router.push(willReorder.lines > 0 ? "/m/reorder" : "/m?counted=1"); }, [counted, lines, reason, needsReason.length, gate, mutate, me, locationId, router, willReorder.lines]); if (!counted) return (<>); return ( <> setErr("")} />

{off.length === 0 ? "Everything matches" : `${off.length} line${off.length === 1 ? "" : "s"} don’t match`}

{locName} · counted {totalCounted} of {totalExpected} expected

{savedAt && (

Tallied {formatInZone(savedAt, s.tz, { day: "numeric", month: "short", hour: "numeric", minute: "2-digit" })}. {" "}Anything issued since then is already off the expected figure.

)}
{off.length === 0 ? ( ) : off.map((l) => { const n = counted[l.key] ?? 0; const d = n - l.expected; const big = Math.abs(d) >= gate; return (
{variantName(byId[l.itemId], l.size)}
{[l.code || (l.item.sku ? `SKU ${l.item.sku}` : "No barcode bound"), l.where].filter(Boolean).join(" · ")}
{d > 0 ? `+${d}` : `−${-d}`}
{n} of {l.expected}
{big && (
A gap of {gate} or more needs a reason
{REASONS.map((r) => { const on = reason[l.key] === r; return ( ); })}
)}
); })}

{willReorder.lines === 0 ? "Nothing falls below par when this commits, so there is nothing to reorder." : `${willReorder.lines} line${willReorder.lines === 1 ? "" : "s"} will be below par once this count commits — about ${willReorder.units} item${willReorder.units === 1 ? "" : "s"} to order. Committing orders nothing on its own: it takes you to Reorder, where you raise the draft.`}

Nothing is sent to a supplier without approval.

{willReorder.lines > 0 &&
}
0} sub={needsReason.length ? `${needsReason.length} gap${needsReason.length === 1 ? " still needs" : "s still need"} a reason` : undefined} /> ); }