"use client";
import * as React from "react";
import { AnimatePresence, motion, MotionConfig, useReducedMotion } from "motion/react";
import { ArrowLeft, CalendarDays, Check, ClipboardList, Truck, User } from "lucide-react";
import { cn } from "@/lib/utils";
import { ClaimPanel, IssueDialog, type IssueInput } from "./discrepancy-sheet";
import { SignDialog, SummaryCard } from "./claim-summary";
import { BASE_TIME, DELIVERIES, STORE_NAME } from "./data";
import { DeliveryList } from "./delivery-list";
import { blockers, claimFor, discrepancies, emptyState, orderValue } from "./logic";
import { ReceiveLines } from "./receive-view";
import { ScanBar } from "./scan-bar";
import type { Claim, Delivery, DeliveryStatus, Discrepancy, ReceiveState } from "./types";
import { CountUp, DockMark, focusRing, Kbd, ROOT_VARS, Sheet, StatusBadge, useIsDesktop } from "./ui";
export type { Claim, Delivery, Discrepancy, PoLine, ReceiveState } from "./types";
export type GoodsInReceivingAppProps = {
deliveries?: Delivery[];
storeName?: string;
currency?: string;
/** Locale for money. */
locale?: string;
/** Locale for dates and times. */
dateLocale?: string;
/** ISO date-time treated as "now" for the header. */
today?: string;
onComplete?: (delivery: Delivery, discrepancies: Discrepancy[], signature: string) => void;
onClaim?: (claim: Claim) => void;
onScan?: (ean: string, matched: boolean) => void;
className?: string;
};
let seq = 0;
const plural = (n: number, w: string) => `${n} ${w}${n === 1 ? "" : "s"}`;
export function GoodsInReceivingApp({
deliveries = DELIVERIES,
storeName = STORE_NAME,
currency = "PLN",
locale = "pl-PL",
dateLocale = "en-GB",
today = BASE_TIME,
onComplete,
onClaim,
onScan,
className,
}: GoodsInReceivingAppProps) {
const reduced = useReducedMotion() ?? false;
const desktop = useIsDesktop();
const moneyFmt = React.useMemo(() => new Intl.NumberFormat(locale, { style: "currency", currency }), [locale, currency]);
const money = React.useCallback((n: number) => moneyFmt.format(n), [moneyFmt]);
const timeFmt = React.useMemo(() => new Intl.DateTimeFormat(dateLocale, { hour: "2-digit", minute: "2-digit" }), [dateLocale]);
const time = React.useCallback((iso: string) => timeFmt.format(new Date(iso)), [timeFmt]);
const dayLabel = React.useMemo(() => new Intl.DateTimeFormat(dateLocale, { weekday: "short", day: "numeric", month: "short" }).format(new Date(today)), [dateLocale, today]);
const [states, setStates] = React.useState<Record<string, ReceiveState>>(() => Object.fromEntries(deliveries.map((d) => [d.id, emptyState()])));
const [statuses, setStatuses] = React.useState<Record<string, DeliveryStatus>>(() => Object.fromEntries(deliveries.map((d) => [d.id, d.status])));
const [activeId, setActiveId] = React.useState<string | null>(deliveries.find((d) => d.status !== "done")?.id ?? deliveries[0]?.id ?? null);
const [mobileView, setMobileView] = React.useState<"list" | "receive">("list");
const [selectedLine, setSelectedLine] = React.useState<Record<string, string>>({});
const [scan, setScan] = React.useState("");
const [unknown, setUnknown] = React.useState<{ ean: string; key: number } | null>(null);
const [flash, setFlash] = React.useState<{ lineId: string; key: number } | null>(null);
const [issueFor, setIssueFor] = React.useState<string | null>(null);
const [signing, setSigning] = React.useState(false);
const [claimOpen, setClaimOpen] = React.useState(false);
const [live, setLive] = React.useState("");
const [toast, setToast] = React.useState<{ id: number; text: string } | null>(null);
const rootRef = React.useRef<HTMLDivElement>(null);
const scanRef = React.useRef<HTMLInputElement>(null);
const delivery = deliveries.find((d) => d.id === activeId) ?? null;
const state = delivery ? (states[delivery.id] ?? emptyState()) : emptyState();
const status = delivery ? statuses[delivery.id] : "due";
const b = delivery ? blockers(delivery, state) : { open: 0, temps: 0, infos: [] };
const claim = delivery ? claimFor(delivery, state, currency) : null;
const canFinish = !!delivery && b.open === 0 && b.temps === 0 && status !== "done";
const lineId = delivery ? (selectedLine[delivery.id] ?? b.infos.find((i) => !i.resolved)?.line.id ?? delivery.lines[0]?.id ?? null) : null;
const showReceive = desktop || mobileView === "receive";
const dialogOpen = !!issueFor || signing || claimOpen;
const say = (text: string, toastToo = false) => {
setLive(text);
if (toastToo) setToast({ id: ++seq, text });
};
React.useEffect(() => {
if (!toast) return;
const t = window.setTimeout(() => setToast(null), 2600);
return () => window.clearTimeout(t);
}, [toast]);
const focusScan = React.useCallback(() => {
if (!desktop) return;
window.setTimeout(() => scanRef.current?.focus(), 20);
}, [desktop]);
React.useEffect(() => {
if (showReceive && status !== "done" && desktop) focusScan();
}, [activeId, showReceive, status, desktop, focusScan]);
/* -------------------------------- updates ------------------------------- */
const patch = (fn: (s: ReceiveState) => ReceiveState) => {
if (!delivery) return;
setStates((m) => ({ ...m, [delivery.id]: fn(m[delivery.id] ?? emptyState()) }));
if (statuses[delivery.id] === "due" || statuses[delivery.id] === "arrived") setStatuses((m) => ({ ...m, [delivery.id]: "receiving" }));
};
const adjust = (id: string, delta: number, via: "scan" | "manual" = "manual") => {
if (!delivery || status === "done") return;
const line = delivery.lines.find((l) => l.id === id);
if (!line) return;
const cur = state.received[id] ?? 0;
const next = Math.max(0, cur + delta);
if (next === cur) return;
patch((s) => ({ ...s, received: { ...s.received, [id]: next } }));
setSelectedLine((m) => ({ ...m, [delivery.id]: id }));
if (delta > 0) setFlash({ lineId: id, key: ++seq });
const done = next >= line.orderedCases;
say(`${via === "scan" ? "Scanned" : "Set"} ${line.name}: ${next} of ${line.orderedCases} cases${next > line.orderedCases ? ", over-delivered" : done ? ", line complete" : ""}.`);
};
const doScan = (raw: string) => {
if (!delivery || status === "done") return;
const code = raw.replace(/\D/g, "");
setScan("");
if (code.length < 8 || code.length > 14) {
setUnknown(null);
say(`${code || raw} is not a valid barcode.`, true);
return;
}
const line = delivery.lines.find((l) => l.ean === code);
onScan?.(code, !!line);
if (line) {
setUnknown(null);
adjust(line.id, 1, "scan");
return;
}
if (state.extras.some((e) => e.ean === code)) {
patch((s) => ({ ...s, extras: s.extras.map((e) => (e.ean === code ? { ...e, cases: e.cases + 1 } : e)) }));
say(`Extra ${code}: one more case.`);
return;
}
setUnknown({ ean: code, key: ++seq });
say(`${code} is not on ${delivery.po}. Add as extra?`);
};
const simulate = () => {
if (!delivery) return;
const pick = b.infos.find((i) => i.line.id === lineId && !i.resolved) ?? b.infos.find((i) => !i.resolved);
if (!pick) return;
setScan(pick.line.ean);
window.setTimeout(() => doScanRef.current(pick.line.ean), reduced ? 0 : 180);
};
const doScanRef = React.useRef(doScan);
React.useEffect(() => {
doScanRef.current = doScan;
});
const addExtra = () => {
if (!unknown) return;
const ean = unknown.ean;
patch((s) => ({ ...s, extras: [...s.extras, { ean, cases: 1 }] }));
setUnknown(null);
say(`Added ${ean} as an extra item.`, true);
focusScan();
};
const moveSelection = (dir: 1 | -1) => {
if (!delivery) return;
const order = [...b.infos.filter((i) => !i.resolved), ...b.infos.filter((i) => i.resolved)].map((i) => i.line.id);
const idx = Math.max(0, order.indexOf(lineId ?? ""));
const next = order[(idx + dir + order.length) % order.length];
setSelectedLine((m) => ({ ...m, [delivery.id]: next }));
const l = delivery.lines.find((x) => x.id === next);
if (l) say(`Selected ${l.name}.`);
window.setTimeout(() => rootRef.current?.querySelector(`[data-line-anchor="${next}"]`)?.scrollIntoView({ block: "nearest" }), 30);
};
const recordIssue = (v: IssueInput) => {
if (!issueFor || !delivery) return;
const line = delivery.lines.find((l) => l.id === issueFor);
patch((s) => ({
...s,
reports:
v.kind === "short"
? [...s.reports.filter((r) => !(r.lineId === issueFor && r.kind === "short")), { lineId: issueFor, kind: "short", qty: v.qty, note: v.note || undefined }]
: [...s.reports, { lineId: issueFor, kind: "damaged", qty: v.qty, note: v.note || undefined }],
}));
setIssueFor(null);
say(`${v.kind === "short" ? "Shortage" : "Damage"} recorded for ${line?.name}: ${plural(v.qty, v.kind === "short" ? "case" : "unit")}.`, true);
focusScan();
};
const removeReport = (id: string, kind: "short" | "damaged") => {
patch((s) => ({ ...s, reports: s.reports.filter((r) => !(r.lineId === id && r.kind === kind)) }));
};
const complete = ({ signature, signedBy }: { signature: string; signedBy: string }) => {
if (!delivery || !claim) return;
const finalState: ReceiveState = { ...state, signature, signedBy, completedAt: new Date().toISOString() };
setStates((m) => ({ ...m, [delivery.id]: finalState }));
setStatuses((m) => ({ ...m, [delivery.id]: "done" }));
setSigning(false);
setClaimOpen(false);
onComplete?.(delivery, discrepancies(delivery, finalState), signature);
if (claim.lines.length) onClaim?.(claim);
say(`${delivery.supplier} received and signed${claim.total > 0 ? `. Claim of ${money(claim.total)} sent` : ""}.`, true);
};
const openDelivery = (id: string) => {
setActiveId(id);
setUnknown(null);
setScan("");
setMobileView("receive");
};
const nextOpen = deliveries.find((d) => statuses[d.id] !== "done" && d.id !== activeId);
/* ------------------------------- shortcuts ------------------------------ */
React.useEffect(() => {
const onKey = (e: KeyboardEvent) => {
const root = rootRef.current;
const t = e.target as HTMLElement | null;
if (!root || (t && t !== document.body && !root.contains(t))) return;
if (dialogOpen) return;
const typing = t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.isContentEditable);
if (e.key === "/" && !typing && showReceive && status !== "done") {
e.preventDefault();
scanRef.current?.focus();
return;
}
if (e.key === "Escape") {
if (unknown) setUnknown(null);
else if (!desktop && mobileView === "receive" && !typing) setMobileView("list");
return;
}
if (typing || e.metaKey || e.ctrlKey || e.altKey || !showReceive || status === "done" || !lineId) return;
if (e.key === "+" || e.key === "=") {
e.preventDefault();
adjust(lineId, 1);
} else if (e.key === "-") {
e.preventDefault();
adjust(lineId, -1);
} else if (e.key.toLowerCase() === "d") {
e.preventDefault();
setIssueFor(lineId);
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
});
/* --------------------------------- views -------------------------------- */
const scanBar = delivery && (
<ScanBar
inputRef={scanRef}
value={scan}
onChange={setScan}
onScan={doScan}
onCommand={(k) => {
if (!lineId) return;
if (k === "+") adjust(lineId, 1);
else if (k === "-") adjust(lineId, -1);
else if (k === "d") setIssueFor(lineId);
else moveSelection(k === "up" ? -1 : 1);
}}
onSimulate={simulate}
simulateDisabled={b.open === 0}
unknown={unknown}
onAddExtra={addExtra}
onDismissUnknown={() => {
setUnknown(null);
focusScan();
}}
compact={!desktop}
/>
);
const deliveryHeader = delivery && (
<div className="flex flex-wrap items-center gap-x-4 gap-y-1">
<div className="min-w-0">
<div className="flex items-center gap-2">
<h2 className="truncate text-[16px] font-semibold tracking-tight">{delivery.supplier}</h2>
<StatusBadge status={status} />
</div>
<p className="mt-0.5 flex flex-wrap items-center gap-x-2 text-[12px] text-muted-foreground">
<span className="font-mono">{delivery.po}</span>
<span aria-hidden>·</span>
<span>ETA {time(delivery.eta)}</span>
{delivery.driver && (
<>
<span aria-hidden>·</span>
<span className="inline-flex items-center gap-1">
<User className="size-3" aria-hidden />
{delivery.driver}
</span>
</>
)}
{delivery.vehicle && (
<>
<span aria-hidden className="hidden sm:inline">
·
</span>
<span className="hidden items-center gap-1 sm:inline-flex">
<Truck className="size-3" aria-hidden />
{delivery.vehicle}
</span>
</>
)}
</p>
</div>
<div className="ml-auto hidden text-right sm:block">
<p className="text-[11px] uppercase tracking-wider text-muted-foreground">Order value</p>
<p className="text-[14px] font-semibold tabular-nums">{money(orderValue(delivery))}</p>
</div>
</div>
);
const lines = delivery && (
<ReceiveLines
infos={b.infos}
extras={state.extras}
selectedId={lineId}
flash={flash}
desktop={desktop}
onSelect={(id) => {
setSelectedLine((m) => ({ ...m, [delivery.id]: id }));
focusScan();
}}
onAdjust={(id, d) => {
adjust(id, d);
focusScan();
}}
onIssue={(id) => {
setSelectedLine((m) => ({ ...m, [delivery.id]: id }));
setIssueFor(id);
}}
onTemp={(id, v) => patch((s) => ({ ...s, temps: { ...s.temps, [id]: v } }))}
onRemoveExtra={(ean) => patch((s) => ({ ...s, extras: s.extras.filter((e) => e.ean !== ean) }))}
/>
);
const summary = delivery && claim && (
<SummaryCard
delivery={delivery}
state={state}
claim={claim}
money={money}
time={time}
onNext={nextOpen ? () => openDelivery(nextOpen.id) : !desktop ? () => setMobileView("list") : undefined}
nextLabel={nextOpen ? `Next: ${nextOpen.supplier}` : "Back to deliveries"}
/>
);
const claimPanel = claim && (
<ClaimPanel
claim={claim}
money={money}
open={b.open}
temps={b.temps}
canFinish={canFinish}
onRemove={removeReport}
onFinish={() => {
setClaimOpen(false);
setSigning(true);
}}
/>
);
const done = status === "done";
const issueInfo = issueFor ? (b.infos.find((i) => i.line.id === issueFor) ?? null) : null;
return (
<MotionConfig reducedMotion="user">
<div ref={rootRef} className={cn("relative isolate flex h-[760px] w-full flex-col overflow-hidden bg-background text-foreground antialiased", ROOT_VARS, className)}>
<header className="flex h-14 shrink-0 items-center gap-2.5 border-b px-3 sm:px-4">
{!desktop && mobileView === "receive" ? (
<button type="button" onClick={() => setMobileView("list")} aria-label="Back to deliveries" className={cn("-ml-1 grid size-10 place-items-center rounded-xl hover:bg-muted", focusRing)}>
<ArrowLeft className="size-5" aria-hidden />
</button>
) : (
<DockMark />
)}
<div className="min-w-0 leading-tight">
<h1 className="truncate text-sm font-semibold tracking-tight">{!desktop && mobileView === "receive" && delivery ? delivery.supplier : "Goods in"}</h1>
<p className="flex items-center gap-1 truncate text-[11px] text-muted-foreground">
{!desktop && mobileView === "receive" && delivery ? (
<span className="font-mono">{delivery.po}</span>
) : (
<>
{storeName} <CalendarDays className="size-3" aria-hidden /> {dayLabel}
</>
)}
</p>
</div>
<div className="ml-auto hidden items-center gap-1.5 text-[11.5px] text-muted-foreground lg:flex">
<Kbd>/</Kbd> scan <Kbd>↑↓</Kbd> line <Kbd>+</Kbd>
<Kbd>−</Kbd> cases <Kbd>D</Kbd> damage <Kbd>Esc</Kbd> close
</div>
{!desktop && mobileView === "receive" && delivery && <StatusBadge status={status} />}
</header>
<div className={cn("min-h-0 flex-1", desktop && (done ? "grid grid-cols-[280px_minmax(0,1fr)]" : "grid grid-cols-[280px_minmax(0,1fr)_320px]"))}>
{(desktop || mobileView === "list") && (
<aside aria-label="Deliveries" className={cn("h-full min-h-0 bg-muted/20", desktop && "border-r")}>
<DeliveryList deliveries={deliveries} states={states} statuses={statuses} selectedId={desktop ? activeId : null} time={time} onOpen={openDelivery} />
</aside>
)}
{showReceive && delivery && (
<main className="relative h-full min-h-0" style={{ perspective: 1400 }}>
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={`${delivery.id}-${done ? "done" : "open"}`}
initial={reduced ? { opacity: 0 } : done ? { rotateY: -90, opacity: 0.4 } : { opacity: 0, y: 8 }}
animate={{ rotateY: 0, opacity: 1, y: 0 }}
exit={reduced ? { opacity: 0 } : done ? { opacity: 0 } : { rotateY: 90, opacity: 0.4, transition: { duration: 0.28, ease: "easeIn" } }}
transition={{ duration: 0.35, ease: "easeOut" }}
className="flex h-full min-h-0 flex-col [backface-visibility:hidden]"
>
{done ? (
<div className="h-full overflow-y-auto overscroll-contain p-4 sm:p-6">{summary}</div>
) : (
<>
<div className="shrink-0 space-y-3 border-b p-3 sm:p-4">
{desktop ? (
deliveryHeader
) : (
<p className="flex flex-wrap items-center gap-x-2 text-[12px] text-muted-foreground">
<span>ETA {time(delivery.eta)}</span>
{delivery.driver && <span>· {delivery.driver}</span>}
<span className="ml-auto font-medium text-foreground tabular-nums">
{delivery.lines.length - b.open}/{delivery.lines.length} lines
</span>
</p>
)}
{desktop && scanBar}
</div>
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain p-3 sm:p-4">
{lines}
{!desktop && <div className="h-40" aria-hidden />}
</div>
{!desktop && (
<div className="absolute inset-x-0 bottom-0 z-20 space-y-2 border-t bg-background/92 p-3 backdrop-blur-md">
{scanBar}
<div className="flex gap-2">
<button type="button" onClick={() => setClaimOpen(true)} className={cn("flex h-12 min-w-0 flex-1 items-center gap-2 rounded-2xl border bg-card px-3 text-left", focusRing)}>
<ClipboardList className="size-4 shrink-0 text-muted-foreground" aria-hidden />
<span className="min-w-0 flex-1 truncate text-[12.5px]">
<span className="font-semibold">{claim?.lines.length ?? 0}</span> issues
{b.open > 0 ? ` · ${b.open} open` : ""}
</span>
<span className="text-[13px] font-semibold tabular-nums">{claim && <CountUp value={claim.total} format={money} />}</span>
</button>
<button
type="button"
disabled={!canFinish}
onClick={() => setSigning(true)}
className={cn("inline-flex h-12 shrink-0 items-center gap-1.5 rounded-2xl bg-[var(--gi-ok)] px-4 text-[13.5px] font-semibold text-white disabled:bg-muted disabled:text-muted-foreground", focusRing)}
>
<Check className="size-4" aria-hidden /> Finish
</button>
</div>
</div>
)}
</>
)}
</motion.div>
</AnimatePresence>
</main>
)}
{desktop && delivery && !done && (
<aside aria-label="Discrepancies and claim" className="h-full min-h-0 border-l bg-muted/10">
{claimPanel}
</aside>
)}
</div>
{!desktop && (
<Sheet open={claimOpen} onClose={() => setClaimOpen(false)} title="Claim" description={delivery ? `${delivery.supplier} · ${delivery.po}` : undefined}>
<div className="-mx-4 -mb-5 h-[440px]">{claimPanel}</div>
</Sheet>
)}
<IssueDialog info={issueInfo} open={!!issueFor} onClose={() => { setIssueFor(null); focusScan(); }} onSubmit={recordIssue} />
<SignDialog open={signing} driver={delivery?.driver} onClose={() => setSigning(false)} onDone={complete} />
<AnimatePresence>
{toast && (
<motion.div
key={toast.id}
initial={{ opacity: 0, y: 16, scale: 0.96 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 10 }}
className="pointer-events-none absolute left-1/2 top-16 z-[60] lg:bottom-6 lg:top-auto flex max-w-[92%] -translate-x-1/2 items-center gap-2 rounded-full bg-foreground px-4 py-2 text-[12.5px] font-medium text-background shadow-xl"
>
<Check className="size-4 shrink-0" aria-hidden /> <span className="truncate">{toast.text}</span>
</motion.div>
)}
</AnimatePresence>
<p className="sr-only" aria-live="polite">
{live}
</p>
</div>
</MotionConfig>
);
}