block
Dashboard — Orders
Sortable, filterable order table with payment and fulfilment as separate columns and bulk selection.
npx shadcn@latest add @hextor/store-dash-ordersSource
"use client";
import { useMemo, useState } from "react";
import type { ReactNode } from "react";
import Image from "next/image";
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, 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 {
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;
}
}
/**
* The five destinations a saved view can put an operator in, on top of
* whatever they searched or filtered for — "which slice do I usually check"
* is a different question than "which slice do I need right now" (that one
* is still the payment/fulfilment selects below the tabs).
*/
type OrderView = "all" | "unfulfilled" | "needs_attention" | "fulfilled";
function matchesView(order: Order, view: OrderView): boolean {
switch (view) {
case "all":
return true;
case "unfulfilled":
return order.fulfillmentStatus === "unfulfilled" || order.fulfillmentStatus === "partially_fulfilled";
case "needs_attention":
return (
(order.paymentStatus === "pending" || order.paymentStatus === "authorized") &&
order.fulfillmentStatus !== "cancelled"
);
case "fulfilled":
return order.fulfillmentStatus === "fulfilled";
}
}
const REQUIRED_COLUMN_IDS = ["number", "total"];
export type OrdersBulkAction = "mark_fulfilled" | "capture_payment" | "export";
/**
* The order list: saved views for the segments an operator checks every
* day, search plus two independent status filters underneath for anything
* more specific, sortable columns, row selection for a bulk action, and a
* disclosure per row that previews its line items inline — the drill-down
* that doesn't cost the list its scroll position or its filters. All
* filter/sort/selection/view 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 [view, setView] = useState<OrderView>("all");
const [sort, setSort] = useState<DataTableSort>({ columnId: "date", direction: "desc" });
const [selectedKeys, setSelectedKeys] = useState<Set<string>>(new Set());
const [density, setDensity] = useState<TableDensity>("comfortable");
const [hiddenColumnIds, setHiddenColumnIds] = useState<Set<string>>(new Set());
// Search + the two status selects apply before the saved view splits the
// result into segments — a saved view's count reflects "how many would
// show up here given what's already typed in," not the full table.
const preViewFiltered = useMemo(() => {
const query = search.trim().toLowerCase();
return 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;
});
}, [orders, search, paymentFilter, fulfillmentFilter]);
const views: SavedView[] = (["all", "unfulfilled", "needs_attention", "fulfilled"] as OrderView[]).map((id) => ({
id,
label:
id === "all"
? copy.common.allViews
: id === "unfulfilled"
? copy.statuses.fulfillment.unfulfilled
: id === "fulfilled"
? copy.statuses.fulfillment.fulfilled
: copy.orders.views.needsAttention,
count: preViewFiltered.filter((order) => matchesView(order, id)).length,
}));
const filtered = useMemo(() => {
const rows = preViewFiltered.filter((order) => matchesView(order, view));
const sorted = [...rows].sort((a, b) => compareOrders(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<Order>[] = [
{
id: "number",
header: copy.orders.columns.order,
sortable: true,
cell: (order) =>
orderHref ? (
<Link href={orderHref(order)} className="font-medium text-brand 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 columns = allColumns.filter((col) => !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 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="p-4 md:p-6">
<DataGridCard
views={<SavedViews views={views} value={view} onChange={(id) => setView(id as OrderView)} />}
toolbar={
<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={
<>
<ColumnVisibilityMenu
columns={allColumns}
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, orders.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>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button variant="outline" size="sm" className="border-brand/40 text-brand hover:bg-brand-subtle">
{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>
) : null}
</>
}
>
<DataTable
columns={columns}
rows={filtered}
rowKey={(order) => order.id}
selectable
selectedKeys={selectedKeys}
onSelectedKeysChange={setSelectedKeys}
sort={sort}
onSortChange={handleSortChange}
density={density}
stickyHeader
detailToggleLabels={{ show: copy.common.viewDetails, hide: copy.common.hideDetails }}
renderRowDetail={(order) => (
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-wrap items-center gap-3">
{order.lines.slice(0, 4).map((line) => (
<div key={line.id} className="flex items-center gap-2">
<div className="relative size-8 shrink-0 overflow-hidden rounded-md bg-muted ring-1 ring-foreground/10">
{line.image ? (
<Image src={line.image.url} alt={line.image.alt} fill sizes="32px" className="object-cover" />
) : null}
</div>
<div className="text-xs">
<p className="max-w-40 truncate font-medium">{line.title}</p>
<p className="tnum text-muted-foreground">
{line.quantity} × {formatMoney(line.unitPrice, locale)}
</p>
</div>
</div>
))}
{order.lines.length > 4 ? (
<span className="text-xs text-muted-foreground">+{order.lines.length - 4} more</span>
) : null}
</div>
{orderHref ? (
<Link href={orderHref(order)} className="shrink-0 text-sm font-medium text-brand hover:underline">
{copy.common.viewDetails} →
</Link>
) : null}
</div>
)}
emptyState={
<EmptyState
title={copy.emptyStates.ordersTitle}
description={copy.emptyStates.ordersDescription}
className="border-none"
/>
}
/>
</DataGridCard>
</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