Fazekit

Code

"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>
  );
}

More in Business

View all →