Fazekit

Code

"use client";
import * as React from "react";
import { AnimatePresence, motion, MotionConfig, useReducedMotion } from "motion/react";
import { ArrowRightLeft, Boxes, ClipboardList, LayoutGrid, RotateCcw, Search, Sparkles } from "lucide-react";
import { cn } from "@/lib/utils";
import { CURRENT_WEEK, SEED_LOCATIONS, SEED_SKUS, SEED_STOCK } from "./data";
import { buildCells, buildPurchaseLines, cellKey, isExcess, isShort, shortageStats, STATUS_LABEL, STATUS_ORDER, suggestTransfers, TRANSFER_HANDLING } from "./forecast";
import { ReorderList } from "./reorder-list";
import { SkuDetail } from "./sku-detail";
import { LocationIcon, rowIssues, StockList, StockMatrix, type MatrixRow } from "./stock-matrix";
import { TransferPlanner } from "./transfer-planner";
import type { CellView, CoverDays, Location, PoLine, PurchaseOrder, Sku, StockCell, Transfer } from "./types";
import { Button, CountUp, makeFmt, ROOT_VARS, Segmented, Skeleton, STATUS_CELL, StatusGlyph, Toasts, useToasts } from "./ui";

export type { CoverDays, Location, PurchaseOrder, Sku, StockCell, Transfer } from "./types";

export type StockBalancerAppProps = {
  /** Locations (columns). Defaults to a warehouse, four stores and a web shop. */
  locations?: Location[];
  /** Products (rows). Defaults to 30 seeded SKUs in five categories. */
  skus?: Sku[];
  /** One cell per SKU × location with on-hand, in-transit and 8 weeks of sales. */
  stock?: StockCell[];
  /** Days-of-cover target the matrix is coloured against. */
  targetCoverDays?: CoverDays;
  /** Units a donor location always keeps back when giving stock away. */
  minKeepUnits?: number;
  currency?: string;
  locale?: string;
  /** Planning week shown in the header; history is the 8 weeks before it. */
  currentWeek?: number;
  /** Show skeletons while your data loads. */
  loading?: boolean;
  onAcceptTransfers?: (transfers: Transfer[]) => void;
  onSendPurchaseOrder?: (po: PurchaseOrder[]) => void;
  className?: string;
};

type Tab = "plan" | "reorder";
type MobileTab = "stock" | Tab;
const TARGETS = [7, 14, 21] as const;

export function StockBalancerApp({
  locations = SEED_LOCATIONS,
  skus = SEED_SKUS,
  stock = SEED_STOCK,
  targetCoverDays = 14,
  minKeepUnits = 4,
  currency = "PLN",
  locale = "pl-PL",
  currentWeek = CURRENT_WEEK,
  loading = false,
  onAcceptTransfers,
  onSendPurchaseOrder,
  className,
}: StockBalancerAppProps) {
  const reduced = useReducedMotion() ?? false;
  const fmt = React.useMemo(() => makeFmt(currency, locale), [currency, locale]);
  const skuMap = React.useMemo(() => new Map(skus.map((s) => [s.id, s])), [skus]);
  const [target, setTarget] = React.useState<CoverDays>(targetCoverDays);
  const [query, setQuery] = React.useState("");
  const [visibleLocs, setVisibleLocs] = React.useState<string[]>(() => locations.map((l) => l.id));
  const [groupBy, setGroupBy] = React.useState<"Category" | "Risk">("Category");
  const [transfers, setTransfers] = React.useState<Transfer[]>([]);
  const [suggested, setSuggested] = React.useState(false);
  const [phase, setPhase] = React.useState<"idle" | "animating" | "done">("idle");
  const [animKey, setAnimKey] = React.useState(0);
  const [landed, setLanded] = React.useState(0);
  const [selected, setSelected] = React.useState<string | null>(null);
  const [tab, setTab] = React.useState<Tab>("plan");
  const [mobileTab, setMobileTab] = React.useState<MobileTab>("stock");
  const [mobileLoc, setMobileLoc] = React.useState(locations[1]?.id ?? locations[0]?.id ?? "");
  const [poQty, setPoQty] = React.useState<Record<string, number>>({});
  const [poRemoved, setPoRemoved] = React.useState<string[]>([]);
  const [sent, setSent] = React.useState<Record<string, { po: string; lines: PoLine[] }>>({});
  const [announce, setAnnounce] = React.useState("");
  const poSeq = React.useRef(3901);
  const lastFocus = React.useRef<HTMLElement | null>(null);
  const rootRef = React.useRef<HTMLDivElement>(null);
  const { toasts, push, dismiss } = useToasts();

  const locs = locations.filter((l) => visibleLocs.includes(l.id));
  const live = React.useMemo(() => transfers.filter((t) => t.status !== "rejected"), [transfers]);
  const before = React.useMemo(() => buildCells(stock, [], target), [stock, target]);
  const after = React.useMemo(() => buildCells(stock, transfers, target), [stock, transfers, target]);
  const display = React.useMemo(() => (phase === "animating" ? buildCells(stock, live.slice(0, landed), target) : after), [phase, stock, live, landed, target, after]);
  const stats = React.useMemo(() => shortageStats(skus, locations, before, after), [skus, locations, before, after]);
  const savings = live.reduce((a, t) => a + t.qty * (skuMap.get(t.skuId)?.unitCost ?? 0), 0) - live.length * TRANSFER_HANDLING;

  /* ------------------------------ arc timing ------------------------------ */
  React.useEffect(() => {
    if (phase !== "animating") return;
    const timers: number[] = [];
    live.forEach((_, i) => timers.push(window.setTimeout(() => setLanded(i + 1), (i * 0.12 + 1) * 1000)));
    timers.push(window.setTimeout(() => setPhase("done"), (live.length * 0.12 + 1.3) * 1000));
    return () => timers.forEach((t) => window.clearTimeout(t));
    // Only re-run when a new suggestion starts.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [animKey, phase === "animating"]);

  const suggest = () => {
    const next = suggestTransfers(skus, locations, stock, target, minKeepUnits);
    setTransfers(next);
    setSuggested(true);
    setAnimKey((k) => k + 1);
    setLanded(0);
    setPhase(reduced || !next.length ? "done" : "animating");
    setTab("plan");
    setPoQty({});
    setPoRemoved([]);
    const after2 = buildCells(stock, next, target);
    const s = shortageStats(skus, locations, before, after2);
    setAnnounce(`${next.length} transfers suggested, covering ${s.covered} of ${s.shortages} shortages.`);
  };

  const reset = () => {
    setTransfers([]);
    setSuggested(false);
    setPhase("idle");
    setLanded(0);
    setPoQty({});
    setPoRemoved([]);
  };

  const changeTarget = (t: CoverDays) => {
    if (t === target) return;
    setTarget(t);
    if (suggested) {
      reset();
      push(`Target set to ${t} days — run Suggest transfers again`);
    }
    setAnnounce(`Matrix recoloured for a ${t}-day target`);
  };

  const setStatus = (id: string, status: Transfer["status"]) => {
    const t = transfers.find((x) => x.id === id);
    if (!t) return;
    setTransfers((ts) => ts.map((x) => (x.id === id ? { ...x, status } : x)));
    if (status === "accepted") onAcceptTransfers?.([{ ...t, status }]);
    setAnnounce(`Transfer ${status === "suggested" ? "restored" : status}`);
  };

  const acceptAll = () => {
    const accepted = transfers.filter((t) => t.status === "suggested").map((t) => ({ ...t, status: "accepted" as const }));
    setTransfers((ts) => ts.map((t) => (t.status === "suggested" ? { ...t, status: "accepted" } : t)));
    onAcceptTransfers?.(accepted);
    push(`Accepted ${accepted.length} transfers`);
  };

  /* ------------------------------ purchase orders ------------------------------ */
  const poGroups = React.useMemo(() => {
    const fresh = buildPurchaseLines(skus, locations, after, target).map((g) => ({ ...g, lines: g.lines.filter((l) => !poRemoved.includes(l.skuId)) }));
    const merged = fresh.map((g) => (sent[g.supplier] ? { supplier: g.supplier, lines: sent[g.supplier].lines } : g));
    for (const s of Object.keys(sent)) if (!merged.some((g) => g.supplier === s)) merged.push({ supplier: s, lines: sent[s].lines });
    return merged.filter((g) => g.lines.length);
  }, [skus, locations, after, target, poRemoved, sent]);
  const openPoLines = poGroups.filter((g) => !sent[g.supplier]).reduce((a, g) => a + g.lines.length, 0);

  const sendPo = (suppliers: string[]) => {
    const orders: PurchaseOrder[] = [];
    const next = { ...sent };
    for (const s of suppliers) {
      const g = poGroups.find((x) => x.supplier === s);
      if (!g || sent[s]) continue;
      const lines = g.lines.map((l) => ({ ...l, qty: poQty[l.skuId] ?? l.qty }));
      orders.push({ supplier: s, lines: lines.map((l) => ({ skuId: l.skuId, qty: l.qty })) });
      next[s] = { po: `PO-${poSeq.current++}`, lines };
    }
    if (!orders.length) return;
    setSent(next);
    onSendPurchaseOrder?.(orders);
    push(orders.length === 1 ? `Purchase order sent to ${orders[0].supplier}` : `${orders.length} purchase orders sent`);
  };

  /* ------------------------------ rows ------------------------------ */
  const rows = React.useMemo<MatrixRow[]>(() => {
    const q = query.trim().toLowerCase();
    const list = skus.filter((s) => !q || s.name.toLowerCase().includes(q) || s.category.toLowerCase().includes(q) || s.supplier.toLowerCase().includes(q));
    if (groupBy === "Risk") {
      const score = (s: Sku) => {
        const cs = locs.map((l) => display.get(cellKey(s.id, l.id))).filter((c): c is CellView => !!c);
        const minR = Math.min(...cs.map((c) => c.cover / target));
        const maxR = Math.max(...cs.map((c) => c.cover / target));
        return cs.some((c) => isShort(c.status)) ? minR : cs.some((c) => isExcess(c.status)) ? 2 + 1 / maxR : 5;
      };
      return [...list].sort((a, b) => score(a) - score(b) || a.name.localeCompare(b.name)).map((sku) => ({ kind: "sku" as const, sku }));
    }
    const cats = [...new Set(list.map((s) => s.category))];
    return cats.flatMap((cat) => {
      const items = list.filter((s) => s.category === cat);
      return [{ kind: "group" as const, name: cat, count: items.length, issues: items.reduce((a, s) => a + rowIssues(s, locs, display), 0) }, ...items.map((sku) => ({ kind: "sku" as const, sku }))];
    });
  }, [skus, query, groupBy, locs, display, target]);

  /* ------------------------------ detail ------------------------------ */
  const open = (id: string) => {
    lastFocus.current = document.activeElement as HTMLElement | null;
    setSelected(id);
    requestAnimationFrame(() => {
      const els = rootRef.current?.querySelectorAll<HTMLElement>("[data-detail] [data-autofocus]");
      els?.forEach((el) => {
        if (el.offsetParent !== null) el.focus();
      });
    });
  };
  const close = React.useCallback(() => {
    setSelected(null);
    const el = lastFocus.current;
    requestAnimationFrame(() => el?.focus?.());
  }, []);

  React.useEffect(() => {
    if (!selected) return;
    const onKey = (e: KeyboardEvent) => {
      if (e.key === "Escape") {
        e.preventDefault();
        close();
      }
    };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, [selected, close]);

  const selectedSku = selected ? skuMap.get(selected) : undefined;
  const mobileLocation = locations.find((l) => l.id === mobileLoc) ?? locations[0];

  const detail = (headingId: string) =>
    selectedSku ? (
      <SkuDetail sku={selectedSku} locations={locations} stock={stock} cells={after} transfers={transfers} target={target} currentWeek={currentWeek} fmt={fmt} onClose={close} headingId={headingId} />
    ) : null;

  const planner = (
    <TransferPlanner
      transfers={transfers}
      skus={skuMap}
      locations={locations}
      stock={stock}
      target={target}
      minKeep={minKeepUnits}
      fmt={fmt}
      animKey={animKey}
      suggested={suggested}
      stats={stats}
      onSuggest={suggest}
      onStatus={setStatus}
      onQty={(id, qty) => setTransfers((ts) => ts.map((t) => (t.id === id ? { ...t, qty } : t)))}
      onAcceptAll={acceptAll}
      onOpenSku={open}
      onGoReorder={() => {
        setTab("reorder");
        setMobileTab("reorder");
      }}
    />
  );
  const reorder = (
    <ReorderList
      groups={poGroups}
      skus={skuMap}
      locations={locations}
      qty={poQty}
      sent={Object.fromEntries(Object.entries(sent).map(([k, v]) => [k, v.po]))}
      fmt={fmt}
      onQty={(id, q) => setPoQty((p) => ({ ...p, [id]: q }))}
      onRemove={(id) => setPoRemoved((r) => [...r, id])}
      onSend={sendPo}
      onOpenSku={open}
    />
  );

  const banner = suggested && live.length > 0 && (
    <motion.div
      key={`banner-${animKey}`}
      initial={reduced ? false : { opacity: 0, y: -8, height: 0 }}
      animate={{ opacity: 1, y: 0, height: "auto" }}
      transition={{ delay: reduced ? 0 : 0.3 }}
      className="shrink-0 overflow-hidden"
    >
      <div className="relative m-3 mb-0 flex flex-wrap items-center gap-x-3 gap-y-2 overflow-hidden rounded-xl border bg-card px-3 py-2.5 lg:mx-4">
        <div aria-hidden className="pointer-events-none absolute inset-y-0 left-0 w-1/2 bg-gradient-to-r from-[color-mix(in_oklab,var(--sb-cool)_14%,transparent)] to-transparent" />
        <div aria-hidden className="pointer-events-none absolute inset-y-0 right-0 w-1/2 bg-gradient-to-l from-emerald-500/10 to-transparent" />
        <span className="relative grid size-8 shrink-0 place-items-center rounded-lg bg-emerald-500/15 text-emerald-700 dark:text-emerald-300">
          <ArrowRightLeft className="size-4" aria-hidden />
        </span>
        <p className="relative min-w-0 flex-1 text-[13px] leading-snug">
          Covers{" "}
          <span className="font-semibold">
            {stats.covered} of {stats.shortages} shortages
          </span>{" "}
          without a purchase order, saves <CountUp key={animKey} value={Math.max(0, savings)} format={fmt.money} className="font-semibold text-emerald-700 dark:text-emerald-300" />
        </p>
        <div className="relative flex items-center gap-1">
          <Button size="sm" variant="ghost" onClick={reset}>
            <RotateCcw className="size-3" aria-hidden /> Reset
          </Button>
          <Button
            size="sm"
            className="lg:hidden"
            onClick={() => setMobileTab("plan")}
          >
            Review
          </Button>
        </div>
      </div>
    </motion.div>
  );

  const legend = (
    <ul className="flex items-center gap-2.5 text-[11px] text-muted-foreground" aria-label="Legend: days of cover versus target">
      {STATUS_ORDER.map((s) => (
        <li key={s} className="flex items-center gap-1">
          <span className={cn("grid h-4 w-5 place-items-center rounded", STATUS_CELL[s], s === "ok" && "ring-1 ring-inset ring-border")}>
            <StatusGlyph status={s} className="size-2.5" />
          </span>
          {STATUS_LABEL[s]}
        </li>
      ))}
    </ul>
  );

  const searchBox = (cls: string) => (
    <div className={cn("relative", cls)}>
      <Search className="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" aria-hidden />
      <input
        type="search"
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder="Search products…"
        aria-label="Search products"
        className="h-8 w-full rounded-lg border bg-background pl-8 pr-2 text-[13px] outline-none placeholder:text-muted-foreground/70 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/20 dark:bg-input/20"
      />
    </div>
  );

  const skeleton = (
    <div className="space-y-2 p-4" aria-busy="true" aria-label="Loading stock">
      {Array.from({ length: 12 }, (_, i) => (
        <div key={i} className="flex gap-2">
          <Skeleton className="h-9 w-52" />
          {Array.from({ length: 6 }, (_, j) => (
            <Skeleton key={j} className="h-9 flex-1" />
          ))}
        </div>
      ))}
    </div>
  );

  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 */}
        <header className="flex h-14 shrink-0 items-center gap-3 border-b px-4">
          <span className="grid size-8 shrink-0 place-items-center rounded-lg bg-gradient-to-br from-[var(--sb-cool)] to-[var(--sb-warm)] text-white shadow-sm">
            <Boxes className="size-4" aria-hidden />
          </span>
          <div className="min-w-0 flex-1 lg:flex-none">
            <h1 className="truncate text-sm font-semibold">Stock Balancer</h1>
            <p className="truncate text-[11px] text-muted-foreground">
              Week {currentWeek} · {locations.length} locations · {skus.length} products
            </p>
          </div>
          <div className="hidden flex-1 lg:block" />
          {searchBox("hidden w-56 lg:block")}
          <div className="hidden items-center gap-2 lg:flex">
            <span id="sb-target-label" className="text-xs text-muted-foreground">
              Days of cover
            </span>
            <Segmented id="target" label="Days of cover target" value={target} options={TARGETS} onChange={changeTarget} />
          </div>
          <Button variant="primary" className="hidden lg:inline-flex" onClick={suggest} disabled={loading}>
            <Sparkles className="size-3.5" aria-hidden /> {suggested ? "Re-run suggestions" : "Suggest transfers"}
          </Button>
          <span className="rounded-full bg-muted px-2 py-1 text-[11px] font-medium tabular-nums text-muted-foreground lg:hidden">W{currentWeek}</span>
        </header>

        {/* Desktop */}
        <div className="hidden min-h-0 flex-1 lg:flex">
          <main className="flex min-w-0 flex-1 flex-col">
            <div className="flex shrink-0 items-center gap-3 border-b px-4 py-2">
              <div role="group" aria-label="Show locations" className="flex min-w-0 items-center gap-1.5 overflow-x-auto [scrollbar-width:none]">
                {locations.map((l) => {
                  const on = visibleLocs.includes(l.id);
                  return (
                    <button
                      key={l.id}
                      type="button"
                      aria-pressed={on}
                      onClick={() => setVisibleLocs((v) => (on ? (v.length > 1 ? v.filter((x) => x !== l.id) : v) : locations.filter((x) => v.includes(x.id) || x.id === l.id).map((x) => x.id)))}
                      className={cn(
                        "inline-flex h-7 items-center gap-1.5 whitespace-nowrap rounded-full border px-2.5 text-xs font-medium outline-none transition focus-visible:ring-2 focus-visible:ring-ring",
                        on ? "border-foreground/15 bg-foreground/[0.06] text-foreground dark:bg-foreground/10" : "border-dashed text-muted-foreground hover:text-foreground",
                      )}
                    >
                      <LocationIcon kind={l.kind} className={on ? "" : "opacity-60"} />
                      {l.short ?? l.name}
                    </button>
                  );
                })}
              </div>
              <div className="ml-auto flex shrink-0 items-center gap-3">
                <Segmented id="group" label="Order rows by" value={groupBy} options={["Category", "Risk"] as const} onChange={setGroupBy} />
              </div>
            </div>
            <AnimatePresence initial={false}>{banner}</AnimatePresence>
            <div className="min-h-0 flex-1 pt-1">
              {loading ? (
                skeleton
              ) : (
                <StockMatrix
                  rows={rows}
                  locations={locs}
                  cells={display}
                  target={target}
                  arcs={live.filter((t) => visibleLocs.includes(t.from) && visibleLocs.includes(t.to))}
                  landed={landed}
                  animKey={animKey}
                  phase={phase}
                  selectedSkuId={selected}
                  onOpen={open}
                  fmt={fmt}
                  skuName={(id) => skuMap.get(id)?.name ?? id}
                />
              )}
            </div>
            <div className="flex shrink-0 items-center justify-between gap-3 border-t px-4 py-1.5">
              {legend}
              <span className="text-[11px] text-muted-foreground">Days of cover vs. {target}-day target · arrow keys move through cells</span>
            </div>
          </main>

          <aside aria-label={selectedSku ? `Product detail: ${selectedSku.name}` : "Transfers and reorder"} className="relative flex w-[360px] shrink-0 flex-col border-l bg-muted/20" data-detail>
            <AnimatePresence initial={false} mode="popLayout">
              {selectedSku ? (
                <motion.div key={`d-${selectedSku.id}`} className="absolute inset-0 z-10 bg-background" initial={{ x: 40, opacity: 0 }} animate={{ x: 0, opacity: 1 }} exit={{ x: 40, opacity: 0 }} transition={{ type: "spring", stiffness: 420, damping: 38 }}>
                  {detail("sb-detail-desk")}
                </motion.div>
              ) : null}
            </AnimatePresence>
            <div role="tablist" aria-label="Actions" className="flex h-11 shrink-0 items-center gap-1 border-b px-2">
              {(
                [
                  { id: "plan", label: "Transfers", n: live.length, icon: ArrowRightLeft },
                  { id: "reorder", label: "Reorder", n: openPoLines, icon: ClipboardList },
                ] as const
              ).map((t) => (
                <button
                  key={t.id}
                  role="tab"
                  type="button"
                  aria-selected={tab === t.id}
                  aria-controls={`sb-panel-${t.id}`}
                  id={`sb-tab-${t.id}`}
                  onClick={() => setTab(t.id)}
                  className={cn("relative flex h-8 items-center gap-1.5 rounded-lg px-3 text-[13px] font-medium outline-none transition focus-visible:ring-2 focus-visible:ring-ring", tab === t.id ? "text-foreground" : "text-muted-foreground hover:text-foreground")}
                >
                  {tab === t.id && <motion.span layoutId="sb-tab-pill" className="absolute inset-0 rounded-lg bg-background shadow-xs ring-1 ring-border dark:bg-accent" transition={{ type: "spring", stiffness: 500, damping: 38 }} />}
                  <t.icon className="relative size-3.5" aria-hidden />
                  <span className="relative">{t.label}</span>
                  {t.n > 0 && <span className="relative rounded-full bg-foreground/10 px-1.5 text-[10px] tabular-nums">{t.n}</span>}
                </button>
              ))}
            </div>
            <div id={`sb-panel-${tab}`} role="tabpanel" aria-labelledby={`sb-tab-${tab}`} className="min-h-0 flex-1">
              {tab === "plan" ? planner : reorder}
            </div>
          </aside>
        </div>

        {/* Mobile / tablet */}
        <div className="flex min-h-0 flex-1 flex-col lg:hidden">
          <div className="min-h-0 flex-1 overflow-y-auto overscroll-contain">
            {mobileTab === "stock" && (
              <>
                <AnimatePresence initial={false}>{banner}</AnimatePresence>
                <div className="space-y-2.5 px-4 pb-2 pt-3">
                  <div role="radiogroup" aria-label="Location" className="-mx-4 flex gap-1.5 overflow-x-auto px-4 pb-1 [scrollbar-width:none]">
                    {locations.map((l) => {
                      const on = l.id === mobileLoc;
                      const issues = skus.reduce((a, s) => {
                        const c = display.get(cellKey(s.id, l.id));
                        return a + (c && isShort(c.status) ? 1 : 0);
                      }, 0);
                      return (
                        <button
                          key={l.id}
                          type="button"
                          role="radio"
                          aria-checked={on}
                          onClick={() => setMobileLoc(l.id)}
                          className={cn(
                            "inline-flex h-8 shrink-0 items-center gap-1.5 rounded-full border px-3 text-xs font-medium outline-none transition focus-visible:ring-2 focus-visible:ring-ring",
                            on ? "border-foreground bg-foreground text-background" : "bg-card text-muted-foreground",
                          )}
                        >
                          <LocationIcon kind={l.kind} />
                          {l.short ?? l.name}
                          {issues > 0 && <span className={cn("rounded-full px-1.5 text-[10px] font-bold tabular-nums", on ? "bg-background/20" : "bg-[var(--sb-warm)] text-white")}>{issues}</span>}
                        </button>
                      );
                    })}
                  </div>
                  <div className="flex items-center gap-2">
                    {searchBox("min-w-0 flex-1")}
                    <Segmented id="group-m" label="Order rows by" value={groupBy} options={["Category", "Risk"] as const} onChange={setGroupBy} />
                  </div>
                </div>
                {loading ? skeleton : mobileLocation && <StockList rows={rows} location={mobileLocation} cells={display} target={target} onOpen={open} fmt={fmt} />}
              </>
            )}
            {mobileTab === "plan" && <div className="h-full">{planner}</div>}
            {mobileTab === "reorder" && <div className="h-full">{reorder}</div>}
          </div>

          <div className="shrink-0 border-t bg-background/95 backdrop-blur">
            {mobileTab !== "reorder" && (
              <div className="flex items-center gap-2 px-3 pt-2">
                <Segmented id="target-m" label="Days of cover target" value={target} options={TARGETS} onChange={changeTarget} render={(v) => `${v}d`} />
                <Button variant="primary" className="h-9 flex-1" onClick={suggest} disabled={loading}>
                  <Sparkles className="size-3.5" aria-hidden /> {suggested ? "Re-run" : "Suggest transfers"}
                </Button>
              </div>
            )}
            <nav aria-label="Sections" className="grid grid-cols-3 px-2 pb-1.5 pt-1">
              {(
                [
                  { id: "stock", label: "Stock", icon: LayoutGrid, n: 0 },
                  { id: "plan", label: "Transfers", icon: ArrowRightLeft, n: live.length },
                  { id: "reorder", label: "Reorder", icon: ClipboardList, n: openPoLines },
                ] as const
              ).map((t) => (
                <button
                  key={t.id}
                  type="button"
                  aria-current={mobileTab === t.id ? "page" : undefined}
                  onClick={() => setMobileTab(t.id)}
                  className={cn("relative flex h-11 flex-col items-center justify-center gap-0.5 rounded-lg text-[11px] font-medium outline-none focus-visible:ring-2 focus-visible:ring-ring", mobileTab === t.id ? "text-foreground" : "text-muted-foreground")}
                >
                  <span className="relative">
                    <t.icon className="size-4" aria-hidden />
                    {t.n > 0 && <span className="absolute -right-2.5 -top-1.5 min-w-4 rounded-full bg-primary px-1 text-center text-[9px] font-bold leading-4 text-primary-foreground">{t.n}</span>}
                  </span>
                  {t.label}
                  {mobileTab === t.id && <motion.span layoutId="sb-mtab" className="absolute inset-x-6 top-0 h-0.5 rounded-full bg-foreground" />}
                </button>
              ))}
            </nav>
          </div>
        </div>

        {/* Mobile detail sheet */}
        <AnimatePresence>
          {selectedSku && (
            <motion.div
              key="m-detail"
              role="dialog"
              aria-modal="true"
              aria-labelledby="sb-detail-mob"
              data-detail
              initial={{ y: "100%" }}
              animate={{ y: 0 }}
              exit={{ y: "100%" }}
              transition={{ type: "spring", stiffness: 380, damping: 40 }}
              className="absolute inset-0 z-50 flex flex-col bg-background lg:hidden"
            >
              {detail("sb-detail-mob")}
            </motion.div>
          )}
        </AnimatePresence>

        <div aria-live="polite" className="sr-only">
          {announce}
        </div>
        <Toasts toasts={toasts} dismiss={dismiss} />
      </div>
    </MotionConfig>
  );
}

export default StockBalancerApp;

More in Business

View all →