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