Skip to contentVibraUI
Part of the E-commerce dashboardinstalls at /ecommerce/orders

Order book

A server-paged orders table with status tabs, a payment filter and search, and an inspector for the order you click: its lines, its payment, its timeline, and a refund that can be refused.

Open the live page

The page awaits db.orders.list for its first page; the status tabs, the search box, the payment filter, the column sort and the pager all rewrite one query the repository answers, so the browser never holds more than the page it is showing. Each row carries its lines already resolved against db.products and the account that placed it, so opening the inspector costs no second read. The refund is a server action returning Result: an order that is pending, cancelled or already refunded is refused with its reason, and the refusal is printed in the panel rather than swallowed — the refund button on such an order is aria-disabled with the reason beside it and still clickable, so pressing it hears the rule out loud. The panel's Open order page link goes to the order's own record at /ecommerce/orders/[id], where its parcel and a fulfilment live. Composes AppShell, PageHeader, QuickFilters, FilterBar, FilterMenu, SearchInput, DataTableColumnHeader, DataTableViewOptions, DataTablePagination, DataTableRows, InspectorPanel, DescriptionList, SimpleTable, Timeline, StatusBadge, UserCell, AsyncButton and Callout.

Preview

Install

npx shadcn@latest add @vibra/ecommerce-orders

Needs the @vibra registry in your components.json — set it up once.

Source

app/ecommerce/orders/page.tsx
import { REFERENCE_DATE } from "@/lib/sample-data"
import { AppShell } from "@/components/ui/app-shell"
import { PageHeader } from "@/components/ui/page-header"

import { signOut } from "./actions"
import { OrdersTable } from "./components/orders-table"
import { currentUser, lastUpdated, listOrders, paymentOptions, shellNotifications, statusFilters } from "./data"
import { DEFAULT_QUERY } from "./vocabulary"
import { NAV, ROUTES } from "@/lib/dashboards/ecommerce/nav"

/**
 * The order book. The page is an async server component: it reads the first
 * page of orders through `db.orders.list` and hands it to the table, which asks
 * the server for every page after that — with the counts beside each filter,
 * which are read here too, so the table never imports the module that reads
 * the store.
 */
export default async function OrdersPage() {
  const initial = await listOrders(DEFAULT_QUERY)

  return (
    <AppShell
      nav={NAV}
      activeHref={ROUTES.orders}
      user={currentUser()}
      notifications={shellNotifications()}
      now={REFERENCE_DATE}
      onSignOut={signOut}
    >
      <PageHeader
        titleId="ecommerce-orders-title"
        title="Order book"
        description="What has been bought, who bought it, and where it got to."
        meta={lastUpdated()}
      />

      <OrdersTable
        titleId="ecommerce-orders-title"
        initial={initial}
        statusFilters={statusFilters()}
        paymentOptions={paymentOptions()}
      />
    </AppShell>
  )
}
app/ecommerce/orders/data.ts
/**
 * What this page reads. Every row is a `db.orders` record, paged by the
 * repository rather than by the browser: `listOrders` hands `db.orders.list`
 * the page, the sort, the search string, the status tab and the payment
 * filter. Each row carries its own lines resolved against `db.products` and
 * the account that placed it, so opening the inspector costs no second read.
 * "Now" is `REFERENCE_DATE`.
 */
import { formatDate, getInitials } from "@/lib/format"
import {
  db,
  ownKey,
  REFERENCE_DATE,
  type Customer,
  type Member,
  type Order,
  type Page,
  type Product,
} from "@/lib/sample-data"

import { PAYMENT_LABEL, type OrderFilterOption, type OrdersQuery } from "./vocabulary"

export type OrderLine = { name: string; sku: string; qty: number; unit: number; total: number }

/** One row of the table, and everything the inspector shows about it. */
export type OrderRow = {
  id: string
  number: string
  customerId: string
  company: string
  contact: string
  status: Order["status"]
  paymentMethod: Order["paymentMethod"]
  country: string
  /** Order total in whole dollars. */
  total: number
  units: number
  lines: OrderLine[]
  placedAt: Date
  fulfilledAt?: Date
}

// The table's column ids on the left, the repository's fields on the right.
const SORT_FIELDS: Record<string, keyof Order> = {
  number: "number",
  status: "status",
  paymentMethod: "paymentMethod",
  country: "country",
  total: "totalCents",
  placedAt: "placedAt",
}

type Lookups = { customers: Map<string, Customer>; products: Map<string, Product> }

// Read per page asked for, never held at module scope: an account or a product
// written since the server started is what the next page shows.
const lookups = (): Lookups => ({
  customers: new Map(db.customers.all().map((row) => [row.id, row])),
  products: new Map(db.products.all().map((row) => [row.id, row])),
})

function toRow(order: Order, { customers, products }: Lookups): OrderRow {
  const customer = customers.get(order.customerId)
  const lines = order.items.map((item) => {
    const product = products.get(item.productId)
    return {
      name: product?.name ?? item.productId,
      sku: product?.sku ?? item.productId,
      qty: item.qty,
      unit: item.unitCents / 100,
      total: (item.qty * item.unitCents) / 100,
    }
  })

  return {
    id: order.id,
    number: order.number,
    customerId: order.customerId,
    // A sale the till rang up for nobody in particular has no account at all.
    company: customer?.company ?? (order.customerId ? "Unknown account" : "Walk-in"),
    contact: customer?.name ?? "—",
    status: order.status,
    paymentMethod: order.paymentMethod,
    country: order.country,
    total: order.totalCents / 100,
    units: order.items.reduce((sum, item) => sum + item.qty, 0),
    lines,
    placedAt: order.placedAt,
    fulfilledAt: order.fulfilledAt,
  }
}

/** One page of orders, filtered, searched and sorted by the repository. */
export async function listOrders(query: OrdersQuery): Promise<Page<OrderRow>> {
  const field = ownKey(SORT_FIELDS, query.sort.id) ? SORT_FIELDS[query.sort.id] : undefined
  const page = await db.orders.list({
    page: query.page,
    pageSize: query.pageSize,
    sort: field ? { id: field, desc: query.sort.desc } : undefined,
    search: query.search,
    filters: {
      status: query.status === "all" ? [] : [query.status],
      paymentMethod: query.payments,
    },
  })
  const read = lookups()
  return { ...page, rows: page.rows.map((order) => toRow(order, read)) }
}

function countBy(pick: (order: Order) => string): Map<string, number> {
  const counts = new Map<string, number>()
  for (const row of db.orders.all()) counts.set(pick(row), (counts.get(pick(row)) ?? 0) + 1)
  return counts
}

const STATUS_ORDER: Order["status"][] = ["pending", "paid", "fulfilled", "refunded", "cancelled"]
const PAYMENT_ORDER: Order["paymentMethod"][] = ["card", "wallet", "bank", "cash", "voucher"]

const titleCase = (value: string) => value.charAt(0).toUpperCase() + value.slice(1)

/** The tabs above the table: every order, then one per status, counted as the book stands. */
export function statusFilters(): OrderFilterOption[] {
  const byStatus = countBy((order) => order.status)
  return [
    { value: "all", label: "All", count: db.orders.all().length },
    ...STATUS_ORDER.map((status) => ({
      value: status,
      label: titleCase(status),
      count: byStatus.get(status) ?? 0,
    })),
  ]
}

/** The payment filter's options, each with how many orders were paid that way. */
export function paymentOptions(): OrderFilterOption[] {
  const byPayment = countBy((order) => order.paymentMethod)
  return PAYMENT_ORDER.map((method) => ({
    value: method,
    label: PAYMENT_LABEL[method],
    count: byPayment.get(method) ?? 0,
  }))
}

/** The freshness line under the title, measured against REFERENCE_DATE. */
export function lastUpdated(): string {
  return `Synced ${formatDate(REFERENCE_DATE, "medium", { timeZone: "UTC" })}`
}

function ownerRow(): Member {
  return db.members.all().find((member) => member.role === "owner") ?? db.members.all()[0]
}

/** The person looking at the page: whoever owns this workspace. */
export function currentUser() {
  const owner = ownerRow()
  return { name: owner.name, email: owner.email, initials: getInitials(owner.name), avatarUrl: owner.avatarUrl }
}

/** The bell's contents: the newest notifications, unread first in the panel. */
export function shellNotifications() {
  return db.notifications
    .all()
    .sort((a, b) => b.at.getTime() - a.at.getTime())
    .slice(0, 6)
    .map(({ id, title, description, at, read, href }) => ({ id, title, description, at, read, href }))
}
app/ecommerce/orders/actions.ts
"use server"

import { mockAuthAdapter } from "@/lib/auth-adapter"
import { db, tableQuery, type Page, type Result } from "@/lib/sample-data"

import { listOrders, type OrderRow } from "./data"
import { DEFAULT_QUERY, REFUNDABLE, type OrdersQuery } from "./vocabulary"

/**
 * Everything the table asks the server for. The page renders its first page on
 * the server; every page, sort, search and filter after that comes back through
 * `fetchOrders`. The refund is the page's one mutation, and it returns `Result`
 * so a refusal reaches the reader with its reason attached.
 */

export async function signOut(): Promise<Result<{ signedOut: true }>> {
  await mockAuthAdapter.signOut()
  return { ok: true, data: { signedOut: true } }
}

/** One page of orders for the query the reader has built up. */
export async function fetchOrders(query: OrdersQuery): Promise<Page<OrderRow>> {
  return listOrders(tableQuery(query, DEFAULT_QUERY))
}

/**
 * Sends the money back. Only an order that has actually been paid for can be
 * refunded — a pending or cancelled one never took payment, and a refunded one
 * has already had it returned.
 */
export async function refundOrder(id: string): Promise<Result<{ id: string; status: string }>> {
  const order = await db.orders.get(id)
  if (!order) {
    return { ok: false, error: { code: "not_found", message: `No order with id ${id}.` } }
  }

  if (order.status === "refunded") {
    return {
      ok: false,
      error: { code: "already_refunded", message: `${order.number} has already been refunded.` },
    }
  }
  if (!REFUNDABLE.includes(order.status)) {
    return {
      ok: false,
      error: {
        code: "not_refundable",
        message: `${order.number} is ${order.status}, so there is no payment to send back.`,
      },
    }
  }

  const updated = await db.orders.update(id, { status: "refunded" })
  if (!updated.ok) return updated
  return { ok: true, data: { id, status: updated.data.status } }
}
app/ecommerce/orders/vocabulary.ts
/**
 * The order book's words and defaults: the query the table opens on, the
 * status tones, what each way to pay is called, which orders can be refunded,
 * and how an order's history is told. Vocabulary, not data — none of it reads `db` — so the islands import
 * it from here rather than from `data.ts`, which reads the store at module
 * scope and must never reach the browser. The counts beside each filter are
 * data, and come to the table as props.
 */
import type { Order, SortSpec } from "@/lib/sample-data"

import type { OrderRow } from "./data"

export type OrdersQuery = {
  page: number
  pageSize: number
  sort: SortSpec
  search: string
  status: string
  payments: string[]
}

export const PAGE_SIZE = 10

export const DEFAULT_QUERY: OrdersQuery = {
  page: 1,
  pageSize: PAGE_SIZE,
  sort: { id: "placedAt", desc: true },
  search: "",
  status: "all",
  payments: [],
}

/** A status tab or a payment option, with how many orders it holds. */
export type OrderFilterOption = { value: string; label: string; count: number }

/** Statuses `StatusBadge` has no default for. */
export const STATUS_MAP = { fulfilled: "success", refunded: "warning", paid: "info" } as const

/** Each way an order is paid, as the table, the payment filter and the inspector name it. */
export const PAYMENT_LABEL: Record<Order["paymentMethod"], string> = {
  card: "Card",
  wallet: "Wallet",
  bank: "Bank",
  cash: "Cash",
  voucher: "Voucher",
}

/** How the timeline says the money came in. */
const PAID_WITH: Record<Order["paymentMethod"], string> = {
  card: "Paid by card",
  wallet: "Paid with a wallet",
  bank: "Paid by bank transfer",
  cash: "Paid in cash",
  voucher: "Paid with a voucher",
}

/** The statuses money can be sent back from. */
export const REFUNDABLE: Order["status"][] = ["paid", "fulfilled"]

export type OrderEvent = {
  title: string
  description?: string
  time: Date
  tone: "default" | "success" | "warning" | "danger" | "info"
}

/** The order's own history, oldest first, built from the dates the row carries. */
export function orderTimeline(row: OrderRow): OrderEvent[] {
  const events: OrderEvent[] = [
    {
      title: "Order placed",
      description: `${row.contact} · ${row.company}`,
      time: row.placedAt,
      tone: "info",
    },
  ]

  if (row.status !== "pending" && row.status !== "cancelled") {
    events.push({
      title: PAID_WITH[row.paymentMethod],
      time: row.placedAt,
      tone: "success",
    })
  }
  if (row.fulfilledAt) {
    events.push({ title: "Fulfilled", description: `Shipped to ${row.country}`, time: row.fulfilledAt, tone: "success" })
  }
  if (row.status === "refunded") {
    events.push({ title: "Refunded", time: row.fulfilledAt ?? row.placedAt, tone: "warning" })
  }
  if (row.status === "cancelled") {
    events.push({ title: "Cancelled", time: row.placedAt, tone: "danger" })
  }

  return events
}
app/ecommerce/orders/components/order-columns.tsx
"use client"

import { DataTableColumnHeader, type DataTableColumnDef } from "@/components/ui/data-table"
import { StatusBadge } from "@/components/ui/status-badge"
import { CurrencyCell, DateCell, NumberCell } from "@/components/ui/table-cells"
import { UserCell } from "@/components/ui/user-cell"

import { type OrderRow } from "../data"
import { PAYMENT_LABEL, STATUS_MAP } from "../vocabulary"

/**
 * Every sortable column is sorted by the repository, not in the browser. There
 * is no selection column: an order is opened one at a time in the inspector,
 * and nothing here acts on several at once.
 */
export const ORDER_COLUMNS: DataTableColumnDef<OrderRow>[] = [
  {
    accessorKey: "number",
    header: ({ column }) => <DataTableColumnHeader column={column} title="Order" />,
    cell: ({ row }) => <span className="font-mono text-xs">{row.original.number}</span>,
    meta: { label: "Order" },
  },
  {
    accessorKey: "company",
    header: ({ column }) => <DataTableColumnHeader column={column} title="Account" />,
    enableSorting: false,
    cell: ({ row }) => (
      <UserCell size="sm" name={row.original.company} description={row.original.contact} />
    ),
    meta: { label: "Account" },
  },
  {
    accessorKey: "status",
    header: ({ column }) => <DataTableColumnHeader column={column} title="Status" />,
    cell: ({ row }) => <StatusBadge status={row.original.status} map={STATUS_MAP} />,
    meta: { label: "Status" },
  },
  {
    accessorKey: "paymentMethod",
    header: ({ column }) => <DataTableColumnHeader column={column} title="Payment" />,
    cell: ({ row }) => (
      <span className="text-muted-foreground">{PAYMENT_LABEL[row.original.paymentMethod]}</span>
    ),
    meta: { label: "Payment" },
  },
  {
    accessorKey: "country",
    header: ({ column }) => <DataTableColumnHeader column={column} title="Ships to" />,
    cell: ({ row }) => <span className="whitespace-nowrap">{row.original.country}</span>,
    meta: { label: "Ships to" },
  },
  {
    accessorKey: "units",
    header: ({ column }) => <DataTableColumnHeader column={column} title="Units" />,
    enableSorting: false,
    cell: ({ row }) => <NumberCell value={row.original.units} />,
    meta: { align: "right", label: "Units" },
  },
  {
    accessorKey: "total",
    header: ({ column }) => <DataTableColumnHeader column={column} title="Total" />,
    cell: ({ row }) => <CurrencyCell value={row.original.total} />,
    meta: { align: "right", label: "Total" },
  },
  {
    accessorKey: "placedAt",
    header: ({ column }) => <DataTableColumnHeader column={column} title="Placed" />,
    cell: ({ row }) => <DateCell date={row.original.placedAt} className="text-muted-foreground" />,
    meta: { align: "right", label: "Placed" },
  },
]
app/ecommerce/orders/components/order-inspector.tsx
"use client"

import * as React from "react"
import Link from "next/link"
import { ArrowUpRightIcon, UndoIcon } from "lucide-react"

import { formatCurrency, formatDate } from "@/lib/format"
import { ROUTES } from "@/lib/dashboards/ecommerce/nav"
import { AsyncButton } from "@/components/ui/async-button"
import { buttonVariants } from "@/components/ui/button"
import { Callout } from "@/components/ui/callout"
import { DescriptionList } from "@/components/ui/description-list"
import { InspectorPanel } from "@/components/ui/inspector-panel"
import { SectionHeader } from "@/components/ui/section-header"
import { SimpleTable } from "@/components/ui/simple-table"
import { StatusBadge } from "@/components/ui/status-badge"
import { Timeline, TimelineItem } from "@/components/ui/timeline"

import { type OrderRow } from "../data"
import { orderTimeline, PAYMENT_LABEL, REFUNDABLE, STATUS_MAP } from "../vocabulary"

export type OrderInspectorProps = {
  order: OrderRow | null
  onClose: () => void
  /** Runs the refund and reports what came back; the panel prints either way. */
  onRefund: (id: string) => Promise<{ ok: boolean; message: string }>
}

/**
 * One order, opened over the table: what was bought, how it was paid for, and
 * what has happened to it. The refund is the only thing that changes anything,
 * and whatever the server says about it is printed here — a refusal in an
 * alert, a success in a status line — rather than closing the panel and
 * leaving the reader to guess.
 *
 * The refund button on an order that cannot be refunded is `aria-disabled`
 * with the reason beside it, and still clickable: the server owns that rule,
 * and pressing it hears the rule out loud rather than nothing at all.
 */
export function OrderInspector({ order, onClose, onRefund }: OrderInspectorProps) {
  const [notice, setNotice] = React.useState<{ ok: boolean; message: string } | null>(null)

  // The panel outlives the selection by one animation, so the last order stays
  // rendered while the sheet slides out instead of blanking mid-flight.
  const [shown, setShown] = React.useState(order)
  if (order && order.id !== shown?.id) {
    setShown(order)
    // A new order in the panel is a new conversation; the last one's outcome
    // does not belong to it.
    setNotice(null)
  }

  if (!shown) return null
  const refundable = REFUNDABLE.includes(shown.status)

  return (
    <InspectorPanel
      open={order !== null}
      mode="overlay"
      width="lg"
      onOpenChange={(next) => {
        if (!next) onClose()
      }}
      title={<span className="font-mono">{shown.number}</span>}
      description={`${shown.company} · ${formatDate(shown.placedAt, "medium", { timeZone: "UTC" })}`}
      footer={
        <div className="flex flex-col gap-2">
          {notice ? (
            <Callout
              role={notice.ok ? "status" : "alert"}
              variant={notice.ok ? "success" : "danger"}
            >
              {notice.message}
            </Callout>
          ) : null}
          {/* The whole record — its parcel, its customer, a fulfilment — is on the order's own page. */}
          <Link href={ROUTES.order.replace("[id]", shown.id)} className={buttonVariants({ variant: "outline" })}>
            <ArrowUpRightIcon data-icon="inline-start" aria-hidden="true" />
            Open order page
          </Link>
          <AsyncButton
            variant="outline"
            aria-disabled={refundable ? undefined : true}
            aria-describedby={refundable ? undefined : "refund-reason"}
            onClick={async () => setNotice(await onRefund(shown.id))}
          >
            <UndoIcon data-icon="inline-start" />
            Refund order
          </AsyncButton>
          {refundable ? null : (
            <p id="refund-reason" className="text-xs text-muted-foreground">
              {shown.status === "refunded"
                ? "This order has already been refunded."
                : `An order that is ${shown.status} never took a payment to send back.`}
            </p>
          )}
        </div>
      }
    >
      <div className="flex flex-col gap-5">
        <div className="flex flex-col gap-3">
          <SectionHeader as="h2" size="sm" title="Payment" />
          <DescriptionList
            items={[
              {
                term: "Status",
                description: <StatusBadge status={shown.status} map={STATUS_MAP} />,
              },
              {
                term: "Method",
                description: PAYMENT_LABEL[shown.paymentMethod],
              },
              { term: "Ships to", description: shown.country },
              {
                term: "Total",
                description: <span className="tabular-nums">{formatCurrency(shown.total)}</span>,
              },
            ]}
          />
        </div>

        <div className="flex flex-col gap-3">
          <SectionHeader as="h2" size="sm" title="Items" />
          <SimpleTable
            size="sm"
            rows={shown.lines}
            rowKey="sku"
            columns={[
              { key: "name", header: "Product" },
              {
                key: "sku",
                header: "SKU",
                cell: (line) => <span className="font-mono text-xs">{line.sku}</span>,
              },
              {
                key: "qty",
                header: "Qty",
                align: "right",
                cell: (line) => <span className="tabular-nums">{line.qty}</span>,
              },
              {
                key: "total",
                header: "Amount",
                align: "right",
                cell: (line) => <span className="tabular-nums">{formatCurrency(line.total)}</span>,
              },
            ]}
          />
        </div>

        <div className="flex flex-col gap-3">
          <SectionHeader as="h2" size="sm" title="Timeline" />
          <Timeline>
            {orderTimeline(shown).map((event) => (
              <TimelineItem
                key={event.title}
                tone={event.tone}
                title={event.title}
                description={event.description}
                time={formatDate(event.time, "medium", { timeZone: "UTC" })}
              />
            ))}
          </Timeline>
        </div>
      </div>
    </InspectorPanel>
  )
}
app/ecommerce/orders/components/orders-table.tsx
"use client"

import * as React from "react"

import { useServerTable } from "@/hooks/use-server-table"
import { type Page } from "@/lib/sample-data"
import { DataTablePagination, DataTableRows } from "@/components/ui/data-table"
import { QuickFilters } from "@/components/ui/quick-filters"

import { fetchOrders, refundOrder } from "../actions"
import { type OrderRow } from "../data"
import { DEFAULT_QUERY, type OrderFilterOption } from "../vocabulary"
import { ORDER_COLUMNS } from "./order-columns"
import { OrderInspector } from "./order-inspector"
import { OrdersToolbar } from "./orders-toolbar"

/**
 * The orders table. The status tabs, the search box, the payment filter, the
 * column sort and the pager all rewrite one query object and ask the server for
 * the page it describes; nothing is filtered or sorted in the browser. Clicking
 * a row opens it in the inspector, which already has the row in hand — the page
 * carries each order's lines with it, so there is no second round trip.
 * useServerTable holds the query and asks for each page, the newest question's
 * answer winning — the re-read after a refund included.
 */
export type OrdersTableProps = {
  /** The page's h1 id, which names the table. */
  titleId: string
  /** The first page, read on the server. */
  initial: Page<OrderRow>
  /** The status tabs, each with how many orders it holds — counted on the server. */
  statusFilters: OrderFilterOption[]
  /** The payment filter's options, likewise counted. */
  paymentOptions: OrderFilterOption[]
}

export function OrdersTable({ titleId, initial, statusFilters, paymentOptions }: OrdersTableProps) {
  const [selected, setSelected] = React.useState<OrderRow | null>(null)
  const { table, query, pending, apply, refresh, summary } = useServerTable({
    initialQuery: DEFAULT_QUERY,
    initialPage: initial,
    fetchPage: fetchOrders,
    columns: ORDER_COLUMNS,
    getRowId: (row) => row.id,
    defaultSort: DEFAULT_QUERY.sort,
    // The page has always counted "orders", one or many.
    noun: ["orders", "orders"],
  })

  /**
   * Refunds, then re-reads the page so the row shows its new status. The
   * re-read is a question of its own: when the reader has asked a newer one in
   * the meantime, that one's answer is the one on show. And it only refreshes
   * the order it refunded while that order is still the one open: a reader who
   * closed it, or opened another, while the re-read was out keeps what they did.
   */
  async function runRefund(id: string) {
    const result = await refundOrder(id)
    if (!result.ok) return { ok: false, message: result.error.message }

    const fresh = await refresh()
    if (fresh) {
      const row = fresh.rows.find((candidate) => candidate.id === id) ?? null
      setSelected((open) => (open?.id === id ? row : open))
    }
    return { ok: true, message: "Refunded. The payment is on its way back." }
  }

  return (
    <div className="flex flex-col gap-4">
      <QuickFilters
        aria-label="Filter orders by status"
        options={statusFilters}
        value={query.status}
        onValueChange={(status) => apply({ status })}
      />

      <OrdersToolbar
        table={table}
        search={query.search}
        onSearchChange={(search) => apply({ search })}
        payments={query.payments}
        paymentOptions={paymentOptions}
        onPaymentsChange={(payments) => apply({ payments })}
        onClearFilters={() => apply({ search: "", payments: [], status: "all" })}
        activeCount={query.payments.length + (query.status === "all" ? 0 : 1) + (query.search ? 1 : 0)}
        summary={summary}
      />

      <DataTableRows
        table={table}
        aria-labelledby={titleId}
        busy={pending}
        onRowClick={setSelected}
        emptyMessage="No orders match these filters."
        footer={
          <DataTablePagination table={table} showSelectedCount={false} pageSizeOptions={[10, 25, 50]} />
        }
      />

      <OrderInspector order={selected} onClose={() => setSelected(null)} onRefund={runRefund} />
    </div>
  )
}
app/ecommerce/orders/components/orders-toolbar.tsx
"use client"

import type { RowData } from "@tanstack/react-table"

import { DataTableViewOptions, type DataTableInstance } from "@/components/ui/data-table"
import { FilterBar, FilterMenu } from "@/components/ui/filter-bar"
import { SearchInput } from "@/components/ui/search-input"

import { type OrderFilterOption } from "../vocabulary"

export type OrdersToolbarProps<TData extends RowData> = {
  table: DataTableInstance<TData>
  search: string
  onSearchChange: (value: string) => void
  payments: string[]
  /** Every payment method, with how many orders used it. */
  paymentOptions: OrderFilterOption[]
  onPaymentsChange: (value: string[]) => void
  onClearFilters: () => void
  activeCount: number
  /** "1–10 of 400 orders", built from the page the server sent back. */
  summary: string
}

/**
 * Everything above the rows. The search box matches an order number or an
 * account id, because those are the two things `db.orders` searches on.
 */
export function OrdersToolbar<TData extends RowData>({
  table,
  search,
  onSearchChange,
  payments,
  paymentOptions,
  onPaymentsChange,
  onClearFilters,
  activeCount,
  summary,
}: OrdersToolbarProps<TData>) {
  return (
    <FilterBar activeCount={activeCount} onClearAll={activeCount > 0 ? onClearFilters : undefined}>
      <SearchInput
        size="sm"
        // Controlled, so "Clear all" empties the box as well as the query —
        // with a debounce the field still keeps its own draft while typing.
        value={search}
        onValueChange={onSearchChange}
        debounce={200}
        placeholder="Search order or account id"
        aria-label="Search orders"
        className="w-full max-w-64"
      />

      <FilterMenu
        label="Payment"
        size="sm"
        options={paymentOptions}
        value={payments}
        onValueChange={onPaymentsChange}
      />

      <p
        data-slot="orders-summary"
        aria-live="polite"
        className="text-sm tabular-nums text-muted-foreground"
      >
        {summary}
      </p>

      <DataTableViewOptions table={table} />
    </FilterBar>
  )
}