HextorUI
block

Dashboard — Orders

Sortable, filterable order table with payment and fulfilment as separate columns and bulk selection.

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

Source

"use client";

import { useMemo, useState } from "react";
import type { ReactNode } from "react";
import Link from "next/link";
import { ChevronDown } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
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 } from "@/registry/hextor/components/store/dashboard/data-table";
import {
  FulfillmentStatusBadge,
  PaymentStatusBadge,
} from "@/registry/hextor/components/store/dashboard/order-status-badges";
import { EmptyState } from "@/registry/hextor/components/store/dashboard/empty-state";
import { resolveLabels, type DashboardLabelsOverrides } from "@/registry/hextor/components/store/dashboard/labels";
import { formatMoney, type FulfillmentStatus, type Order, type PaymentStatus } from "@/registry/hextor/lib/store";

type SortColumn = "number" | "date" | "customer" | "payment" | "fulfillment" | "items" | "total";

function compareOrders(a: Order, b: Order, columnId: string): number {
  switch (columnId as SortColumn) {
    case "number":
      return a.number.localeCompare(b.number);
    case "date":
      return a.createdAt.localeCompare(b.createdAt);
    case "customer":
      return a.email.localeCompare(b.email);
    case "payment":
      return a.paymentStatus.localeCompare(b.paymentStatus);
    case "fulfillment":
      return a.fulfillmentStatus.localeCompare(b.fulfillmentStatus);
    case "items":
      return a.lines.length - b.lines.length;
    case "total":
      return a.totals.total.amount - b.totals.total.amount;
    default:
      return 0;
  }
}

export type OrdersBulkAction = "mark_fulfilled" | "capture_payment" | "export";

/**
 * The order list: search, two independent status filters (payment and
 * fulfilment stay separate columns and separate filters, matching how
 * `Order` models them), sortable columns, and row selection for a bulk
 * action menu. All filter/sort/selection state is local — the page owns
 * `orders` and re-derives the visible rows on every render rather than
 * mutating anything.
 */
export default function OrdersPage({
  orders,
  orderHref,
  onBulkAction,
  nav = DEFAULT_DASHBOARD_NAV,
  brand,
  activeHref = "/dashboard/orders",
  actions,
  locale = "en-US",
  labels,
}: {
  orders: Order[];
  orderHref?: (order: Order) => string;
  /** Fired with the action id and the selected order ids; wire up the real mutation on your end. */
  onBulkAction?: (action: OrdersBulkAction, orderIds: string[]) => void;
  nav?: DashboardNavItem[];
  brand?: DashboardBrand;
  activeHref?: string;
  actions?: ReactNode;
  locale?: string;
  labels?: DashboardLabelsOverrides;
}) {
  const copy = resolveLabels(labels);
  const [search, setSearch] = useState("");
  const [paymentFilter, setPaymentFilter] = useState<"all" | PaymentStatus>("all");
  const [fulfillmentFilter, setFulfillmentFilter] = useState<"all" | FulfillmentStatus>("all");
  const [sort, setSort] = useState<DataTableSort>({ columnId: "date", direction: "desc" });
  const [selectedKeys, setSelectedKeys] = useState<Set<string>>(new Set());

  const filtered = useMemo(() => {
    const query = search.trim().toLowerCase();
    const rows = orders.filter((order) => {
      const matchesQuery =
        query.length === 0 ||
        order.number.toLowerCase().includes(query) ||
        order.email.toLowerCase().includes(query);
      const matchesPayment = paymentFilter === "all" || order.paymentStatus === paymentFilter;
      const matchesFulfillment = fulfillmentFilter === "all" || order.fulfillmentStatus === fulfillmentFilter;
      return matchesQuery && matchesPayment && matchesFulfillment;
    });
    const sorted = [...rows].sort((a, b) => compareOrders(a, b, sort.columnId));
    return sort.direction === "asc" ? sorted : sorted.reverse();
  }, [orders, search, paymentFilter, fulfillmentFilter, sort]);

  function handleSortChange(columnId: string) {
    setSort((prev) =>
      prev.columnId === columnId ? { columnId, direction: prev.direction === "asc" ? "desc" : "asc" } : { columnId, direction: "asc" },
    );
  }

  const columns: DataTableColumn<Order>[] = [
    {
      id: "number",
      header: copy.orders.columns.order,
      sortable: true,
      cell: (order) =>
        orderHref ? (
          <Link href={orderHref(order)} className="font-medium hover:underline">
            #{order.number}
          </Link>
        ) : (
          <span className="font-medium">#{order.number}</span>
        ),
    },
    {
      id: "date",
      header: copy.orders.columns.date,
      sortable: true,
      className: "tnum text-muted-foreground",
      cell: (order) => new Date(order.createdAt).toLocaleDateString(locale),
    },
    {
      id: "customer",
      header: copy.orders.columns.customer,
      sortable: true,
      cell: (order) => <span className="text-muted-foreground">{order.email}</span>,
    },
    {
      id: "payment",
      header: copy.orders.columns.payment,
      sortable: true,
      cell: (order) => <PaymentStatusBadge status={order.paymentStatus} labels={copy.statuses.payment} />,
    },
    {
      id: "fulfillment",
      header: copy.orders.columns.fulfillment,
      sortable: true,
      cell: (order) => <FulfillmentStatusBadge status={order.fulfillmentStatus} labels={copy.statuses.fulfillment} />,
    },
    {
      id: "items",
      header: copy.orders.columns.items,
      sortable: true,
      align: "right",
      className: "tnum",
      cell: (order) => order.lines.reduce((sum, l) => sum + l.quantity, 0),
    },
    {
      id: "total",
      header: copy.orders.columns.total,
      sortable: true,
      align: "right",
      className: "tnum",
      cell: (order) => formatMoney(order.totals.total, locale),
    },
  ];

  const selectionCount = selectedKeys.size;

  function runBulkAction(action: OrdersBulkAction) {
    onBulkAction?.(action, Array.from(selectedKeys));
  }

  return (
    <DashboardShell brand={brand} nav={nav} activeHref={activeHref} actions={actions}>
      <PageHeader title={copy.orders.title} description={copy.orders.description} />

      <div className="space-y-4 p-4 md:p-6">
        <FilterBar
          searchValue={search}
          onSearchChange={setSearch}
          searchPlaceholder={copy.common.search}
          selects={[
            {
              id: "payment",
              label: copy.orders.filters.payment,
              value: paymentFilter,
              onChange: (v) => setPaymentFilter(v as "all" | PaymentStatus),
              options: [
                { value: "all", label: copy.common.allStatuses },
                ...(Object.keys(copy.statuses.payment) as PaymentStatus[]).map((status) => ({
                  value: status,
                  label: copy.statuses.payment[status],
                })),
              ],
            },
            {
              id: "fulfillment",
              label: copy.orders.filters.fulfillment,
              value: fulfillmentFilter,
              onChange: (v) => setFulfillmentFilter(v as "all" | FulfillmentStatus),
              options: [
                { value: "all", label: copy.common.allStatuses },
                ...(Object.keys(copy.statuses.fulfillment) as FulfillmentStatus[]).map((status) => ({
                  value: status,
                  label: copy.statuses.fulfillment[status],
                })),
              ],
            },
          ]}
          actions={
            <DropdownMenu>
              <DropdownMenuTrigger
                render={
                  <Button variant="outline" size="sm" disabled={selectionCount === 0}>
                    {copy.orders.bulkActions.label(selectionCount)}
                    <ChevronDown />
                  </Button>
                }
              />
              <DropdownMenuContent align="end">
                <DropdownMenuItem onClick={() => runBulkAction("mark_fulfilled")}>
                  {copy.orders.bulkActions.markFulfilled}
                </DropdownMenuItem>
                <DropdownMenuItem onClick={() => runBulkAction("capture_payment")}>
                  {copy.orders.bulkActions.capturePayment}
                </DropdownMenuItem>
                <DropdownMenuItem onClick={() => runBulkAction("export")}>
                  {copy.orders.bulkActions.export}
                </DropdownMenuItem>
              </DropdownMenuContent>
            </DropdownMenu>
          }
        />

        <div className="rounded-lg border">
          <DataTable
            columns={columns}
            rows={filtered}
            rowKey={(order) => order.id}
            selectable
            selectedKeys={selectedKeys}
            onSelectedKeysChange={setSelectedKeys}
            sort={sort}
            onSortChange={handleSortChange}
            emptyState={
              <EmptyState
                title={copy.emptyStates.ordersTitle}
                description={copy.emptyStates.ordersDescription}
                className="m-4 border-none"
              />
            }
          />
        </div>
      </div>
    </DashboardShell>
  );
}

Docs

Payment and fulfilment get a column each because they are independent axes: an order can be paid and unfulfilled, or fulfilled and refunded. Collapsing them into one status column is the modelling mistake that makes every later report wrong, and it is invisible until someone asks what "complete" meant.

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