Dashboard — Inventory
Stock by variant with on-hand, committed and available shown separately, plus audited adjustments.
npx shadcn@latest add @hextor/store-dash-inventorySource
"use client";
import { useMemo, useState } from "react";
import type { ReactNode } from "react";
import Image from "next/image";
import { } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
DashboardShell,
DEFAULT_DASHBOARD_NAV,
type DashboardBrand,
type DashboardNavItem,
} from "@/registry/hextor/components/store/dashboard/dashboard-shell";
import { PageHeader } from "@/registry/hextor/components/store/dashboard/page-header";
import { FilterBar } from "@/registry/hextor/components/store/dashboard/filter-bar";
import {
DataTable,
type DataTableColumn,
type DataTableSort,
type TableDensity,
} from "@/registry/hextor/components/store/dashboard/data-table";
import {
ColumnVisibilityMenu,
DataGridCard,
DensityToggle,
SavedViews,
type SavedView,
} from "@/registry/hextor/components/store/dashboard/data-grid";
import { EmptyState } from "@/registry/hextor/components/store/dashboard/empty-state";
import {
StockLevelCell,
StockStatusBadge,
availableQuantity,
stockStatus,
type StockStatus,
} from "@/registry/hextor/components/store/dashboard/stock-level-cell";
import {
InventoryAdjustDialog,
type InventoryAdjustTarget,
type StockAdjustment,
} from "@/registry/hextor/components/store/dashboard/inventory-adjust-dialog";
import { resolveLabels, type InventoryLabelsOverrides } from "@/registry/hextor/components/store/dashboard/inventory-labels";
import type { Order, Product, ProductImage } from "@/registry/hextor/lib/store";
/**
* A stock view across every *variant*, not per product — the point of the
* whole page. `store.ts` puts `inventoryQuantity` on `ProductVariant`, never
* on `Product`, because stock lives on the variant: a product-level number
* is a lie the moment one size sells out while the others don't.
*/
export interface InventoryRow {
variantId: string;
productId: string;
productTitle: string;
variantTitle: string;
sku: string;
image?: ProductImage;
onHand: number;
/** Quantity already promised to orders that haven't shipped in full. */
committed: number;
continueSellingWhenOutOfStock: boolean;
}
/**
* Flattens the catalogue into one row per variant and computes `committed`
* from every order that hasn't fully shipped — `fulfilled`, `cancelled` and
* `returned` orders have nothing outstanding, so they don't hold stock
* hostage. Exported so a `routes/dash-inventory.tsx`-style entry point can
* build rows from the same `products`/`orders` fixtures the rest of the
* dashboard uses, without this view needing to know where the data came
* from.
*/
export function deriveInventoryRows(products: Product[], orders: Order[]): InventoryRow[] {
const committedByVariant = new Map<string, number>();
for (const order of orders) {
if (
order.fulfillmentStatus === "fulfilled" ||
order.fulfillmentStatus === "cancelled" ||
order.fulfillmentStatus === "returned"
) {
continue;
}
for (const line of order.lines) {
const outstanding = line.quantity - line.fulfilledQuantity;
if (outstanding <= 0) continue;
committedByVariant.set(line.variantId, (committedByVariant.get(line.variantId) ?? 0) + outstanding);
}
}
const rows: InventoryRow[] = [];
for (const product of products) {
for (const variant of product.variants) {
rows.push({
variantId: variant.id,
productId: product.id,
productTitle: product.title,
variantTitle: variant.title,
sku: variant.sku,
image: variant.image ?? product.images[0],
onHand: variant.inventoryQuantity,
committed: committedByVariant.get(variant.id) ?? 0,
continueSellingWhenOutOfStock: variant.continueSellingWhenOutOfStock ?? false,
});
}
}
return rows;
}
type InventoryViewId = "all" | "low_stock" | "out_of_stock";
type SortColumn = "variant" | "sku" | "onHand" | "committed" | "available" | "status";
/** One row plus the status derived from it — computed once per render of the row list, reused by filtering, sorting and every column's cell. */
interface InventoryStatusRow {
row: InventoryRow;
available: number;
status: StockStatus;
}
function matchesView(status: StockStatus, view: InventoryViewId): boolean {
switch (view) {
case "all":
return true;
case "low_stock":
return status === "low_stock";
case "out_of_stock":
return status === "backorder" || status === "unavailable";
}
}
/** Defined outside the component (like `compareOrders` in `orders-view.tsx`) since it closes over nothing from render scope — keeping it here means the `useMemo` below has a stable dependency instead of a function re-created every render. */
function compareRows(a: InventoryStatusRow, b: InventoryStatusRow, columnId: string): number {
switch (columnId as SortColumn) {
case "variant":
return a.row.productTitle.localeCompare(b.row.productTitle) || a.row.variantTitle.localeCompare(b.row.variantTitle);
case "sku":
return a.row.sku.localeCompare(b.row.sku);
case "onHand":
return a.row.onHand - b.row.onHand;
case "committed":
return a.row.committed - b.row.committed;
case "available":
return a.available - b.available;
case "status":
return a.status.localeCompare(b.status);
default:
return 0;
}
}
const REQUIRED_COLUMN_IDS = ["variant", "available"];
/**
* The inventory list: one row per variant, a low-stock and an out-of-stock
* saved view, sortable on-hand/committed/available columns, and an inline
* or bulk quantity adjustment that always asks for a reason. `reorderPoint`
* is a single threshold applied to every row — a per-SKU reorder point
* would need a field `store.ts` doesn't have, so this view takes the
* simplest version of "low" that's still useful across a whole catalogue.
*/
export default function InventoryView({
rows,
reorderPoint = 10,
onAdjust,
nav = DEFAULT_DASHBOARD_NAV,
brand,
activeHref = "/dashboard/inventory",
actions,
labels,
}: {
rows: InventoryRow[];
/** At or below this available quantity (but still > 0) a variant reads as "low stock". */
reorderPoint?: number;
/** Fired with the adjusted variant ids and the adjustment; applying it to `rows` is the caller's job. */
onAdjust?: (variantIds: string[], adjustment: StockAdjustment) => void;
nav?: DashboardNavItem[];
brand?: DashboardBrand;
activeHref?: string;
actions?: ReactNode;
labels?: InventoryLabelsOverrides;
}) {
const copy = resolveLabels(labels);
const [search, setSearch] = useState("");
const [view, setView] = useState<InventoryViewId>("all");
const [sort, setSort] = useState<DataTableSort>({ columnId: "available", direction: "asc" });
const [selectedKeys, setSelectedKeys] = useState<Set<string>>(new Set());
const [density, setDensity] = useState<TableDensity>("comfortable");
const [hiddenColumnIds, setHiddenColumnIds] = useState<Set<string>>(new Set());
const [adjustTargets, setAdjustTargets] = useState<InventoryAdjustTarget[] | null>(null);
const withStatus = useMemo(
() =>
rows.map((row) => {
const available = availableQuantity(row.onHand, row.committed);
return {
row,
available,
status: stockStatus(available, reorderPoint, row.continueSellingWhenOutOfStock),
};
}),
[rows, reorderPoint],
);
const preViewFiltered = useMemo(() => {
const query = search.trim().toLowerCase();
return withStatus.filter(
({ row }) =>
query.length === 0 ||
row.sku.toLowerCase().includes(query) ||
row.productTitle.toLowerCase().includes(query) ||
row.variantTitle.toLowerCase().includes(query),
);
}, [withStatus, search]);
const views: SavedView[] = (["all", "low_stock", "out_of_stock"] as InventoryViewId[]).map((id) => ({
id,
label: id === "all" ? copy.views.all : id === "low_stock" ? copy.views.lowStock : copy.views.outOfStock,
count: preViewFiltered.filter((r) => matchesView(r.status, id)).length,
}));
const filtered = useMemo(() => {
const rowsInView = preViewFiltered.filter((r) => matchesView(r.status, view));
const sorted = [...rowsInView].sort((a, b) => compareRows(a, b, sort.columnId));
return sort.direction === "asc" ? sorted : sorted.reverse();
}, [preViewFiltered, view, sort]);
function handleSortChange(columnId: string) {
setSort((prev) =>
prev.columnId === columnId
? { columnId, direction: prev.direction === "asc" ? "desc" : "asc" }
: { columnId, direction: "asc" },
);
}
const allColumns: DataTableColumn<InventoryStatusRow>[] = [
{
id: "variant",
header: copy.columns.variant,
sortable: true,
cell: ({ row }) => (
<div className="flex items-center gap-2.5">
<div className="relative size-9 shrink-0 overflow-hidden rounded-md bg-muted ring-1 ring-foreground/10">
{row.image ? (
<Image src={row.image.url} alt={row.image.alt} fill sizes="36px" className="object-cover" />
) : null}
</div>
<div className="min-w-0">
<p className="truncate text-sm font-medium">{row.productTitle}</p>
<p className="truncate text-xs text-muted-foreground">{row.variantTitle}</p>
</div>
</div>
),
},
{
id: "sku",
header: copy.columns.sku,
sortable: true,
className: "tnum text-muted-foreground",
cell: ({ row }) => row.sku,
},
{
id: "onHand",
header: copy.columns.onHand,
sortable: true,
align: "right",
className: "tnum",
cell: ({ row }) => row.onHand,
},
{
id: "committed",
header: copy.columns.committed,
sortable: true,
align: "right",
className: "tnum text-muted-foreground",
cell: ({ row }) => row.committed,
},
{
id: "available",
header: copy.columns.available,
sortable: true,
align: "right",
className: "tnum font-medium",
cell: ({ available }) => <span className={available <= 0 ? "text-destructive" : undefined}>{available}</span>,
},
{
id: "status",
header: copy.columns.status,
sortable: true,
cell: ({ status }) => <StockStatusBadge status={status} labels={copy.status} />,
},
{
id: "actions",
header: "",
align: "right",
cell: ({ row }) => (
<Button
variant="outline"
size="sm"
onClick={() =>
setAdjustTargets([{ variantId: row.variantId, label: `${row.productTitle} — ${row.variantTitle}`, sku: row.sku, onHand: row.onHand }])
}
>
{copy.adjust.action}
</Button>
),
},
];
const columns = allColumns.filter((col) => col.id === "actions" || !hiddenColumnIds.has(col.id));
function toggleColumn(id: string, visible: boolean) {
setHiddenColumnIds((prev) => {
const next = new Set(prev);
if (visible) next.delete(id);
else next.add(id);
return next;
});
}
const selectionCount = selectedKeys.size;
function openBulkAdjust() {
const targets = filtered
.filter((r) => selectedKeys.has(r.row.variantId))
.map((r) => ({
variantId: r.row.variantId,
label: `${r.row.productTitle} — ${r.row.variantTitle}`,
sku: r.row.sku,
onHand: r.row.onHand,
}));
if (targets.length > 0) setAdjustTargets(targets);
}
return (
<DashboardShell brand={brand} nav={nav} activeHref={activeHref} actions={actions}>
<PageHeader title={copy.title} description={copy.description} />
<div className="p-4 md:p-6">
<DataGridCard
views={<SavedViews views={views} value={view} onChange={(id) => setView(id as InventoryViewId)} />}
toolbar={
<FilterBar
searchValue={search}
onSearchChange={setSearch}
searchPlaceholder={copy.search}
actions={
<>
<ColumnVisibilityMenu
columns={allColumns.filter((c) => c.id !== "actions")}
hiddenIds={hiddenColumnIds}
onVisibilityChange={toggleColumn}
requiredIds={REQUIRED_COLUMN_IDS}
label={copy.common.columns}
/>
<DensityToggle
value={density}
onChange={setDensity}
labels={{ comfortable: copy.common.densityComfortable, compact: copy.common.densityCompact }}
/>
</>
}
/>
}
footer={
<>
<span className="tnum">{copy.common.rowsShown(filtered.length, rows.length)}</span>
{selectionCount > 0 ? (
<div className="flex items-center gap-3">
<button
type="button"
onClick={() => setSelectedKeys(new Set())}
className="text-muted-foreground underline-offset-2 hover:text-foreground hover:underline"
>
{copy.common.clearSelection}
</button>
<Button
variant="outline"
size="sm"
className="border-brand/40 text-brand hover:bg-brand-subtle"
onClick={openBulkAdjust}
>
{copy.adjust.bulkAction(selectionCount)}
</Button>
</div>
) : null}
</>
}
>
<DataTable
columns={columns}
rows={filtered}
rowKey={(r) => r.row.variantId}
selectable
selectedKeys={selectedKeys}
onSelectedKeysChange={setSelectedKeys}
sort={sort}
onSortChange={handleSortChange}
density={density}
stickyHeader
renderRowDetail={({ row, available }) => (
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<StockLevelCell
onHand={row.onHand}
committed={row.committed}
available={available}
labels={{ onHand: copy.columns.onHand, committed: copy.columns.committed, available: copy.columns.available }}
/>
<p className="text-xs text-muted-foreground">
{row.continueSellingWhenOutOfStock ? copy.detail.continueSellingOn : copy.detail.continueSellingOff}
</p>
</div>
)}
emptyState={<EmptyState title={copy.emptyState.title} description={copy.emptyState.description} className="border-none" />}
/>
</DataGridCard>
</div>
{adjustTargets ? (
<InventoryAdjustDialog
open={adjustTargets !== null}
onOpenChange={(open) => {
if (!open) setAdjustTargets(null);
}}
targets={adjustTargets}
reasonLabels={copy.adjust.reasons}
labels={copy.adjust}
onSubmit={(variantIds, adjustment) => {
onAdjust?.(variantIds, adjustment);
setSelectedKeys(new Set());
}}
/>
) : null}
</DashboardShell>
);
}
Docs
Stock lives on variants, not products. A product-level number is a lie the moment one size sells out, so every row here is a variant.
On-hand, committed and available get their own columns rather than one ambiguous figure. Available is on-hand minus what unfulfilled orders have already claimed, and confusing the two is how a store oversells.
Zero available splits into two states, never one: a variant with continueSellingWhenOutOfStock is on backorder and still sellable, one without is unavailable. Adjustments require a reason (received, damaged, correction, stocktake) because an unexplained stock change is unauditable.
Ships with sample data from @hextor/store-fixtures so it renders on install. The route file is the only place that import appears — swap it for your own query and the view is unchanged.