HextorUI
block

Dashboard — Inventory

Stock by variant with on-hand, committed and available shown separately, plus audited adjustments.

npx shadcn@latest add @hextor/store-dash-inventory

Source

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

Dependencies

@hextor/store-dash-ui@hextor/store-fixtures