HextaUI

Orders Table

The merchant’s orders index with saved views, filters and bulk actions. Handles partial payment and fulfillment, live orders and partial bulk failures.

Open in new tab

Orders Table is the orders index of a store admin. Each order shows its number, a relative date with the exact time on hover, the customer, channel, total, payment and fulfillment status, item count, delivery method and tags. Payment and fulfillment are separate pills, and each has a progress glyph (an empty, half or full circle) so Partially paid or Partially fulfilled reads without relying on color.

Saved views sit in tabs: All, Unfulfilled, Unpaid, Open, Archived and your own. A view is a set of filters, a sort and a search. Change any of them and the tab is marked Modified, with Reset, Update view and Save as new view. Filter chips cover status, payment, fulfillment, date, channel, tag and risk. ordersQueryToSearchParams and ordersQueryFromSearchParams keep the whole query in the URL, so a link opens the same list and Back works.

Selection works like a desktop list. Shift-click selects a range, the header checkbox selects the page, then Select all 48 orders selects every match. The bulk bar takes the header’s place, so nothing shifts. Mark as fulfilled, Capture payment (with a confirmation showing the amount), Add tags, Archive and Print packing slips are async. Partial failures name the orders that failed with a View link, and Archive can be undone. J and K move a focus ring, X selects, Enter opens, / searches and Esc clears.

New orders that arrive while you browse slide in at the top with a soft highlight. While a row is focused or anything is selected, rows never move: a “1 new order · Show” pill waits instead. The table tells an empty store (No orders yet, Create order) from a filtered empty list (No orders match these filters, Clear filters), shows skeleton rows while loading and an error with Retry. On phones the rows become cards and the bulk bar floats at the bottom.

  1. HextaUI Pro'yu alın

    Tek bir ödeme, gelecekte eklenecekler dahil tüm blokların kilidini açar. Fiyatları görün.

  2. Pro registry'yi components.json dosyasına ekleyin

    components.json
    {
      "registries": {
        "@hextaui-pro": {
          "url": "https://hextaui.com/r/pro/{name}.json",
          "headers": {
            "Authorization": "Bearer ${HEXTAUI_PRO_TOKEN}"
          }
        }
      }
    }
  3. Token'ınızı ekleyin

    Hesap sayfanızda bir token oluşturun ve .env.local dosyasına HEXTAUI_PRO_TOKEN olarak ekleyin.

  4. Bloğu ekleyin

    pnpm dlx shadcn@latest add @hextaui-pro/orders-table

From the Shopify Admin API

toShopifySearch turns the view, filters and search into an orders(query:) string, and toShopifyVariables adds the sort key and page size. Archived maps to status:closed, and partial states map to Shopify’s financial and fulfillment filters.

import { dateRanges, type OrdersQuery } from "@/components/blocks/orders-table/orders"

const paymentFilter: Record<string, string> = {
  pending: "pending",
  authorized: "authorized",
  partially_paid: "partially_paid",
  paid: "paid",
  partially_refunded: "partially_refunded",
  refunded: "refunded",
  voided: "voided",
  failed: "expired",
}

const fulfillmentFilter: Record<string, string> = {
  unfulfilled: "unshipped",
  partially_fulfilled: "partial",
  fulfilled: "shipped",
  on_hold: "on_hold",
  scheduled: "scheduled",
}

const rangeDays = { today: 0, "7d": 7, "30d": 30, "90d": 90 } satisfies Record<
  keyof typeof dateRanges,
  number
>

function anyOf(field: string, values: string[] | undefined, map?: Record<string, string>) {
  if (!values || values.length === 0) return null
  const terms = values.map((value) => `${field}:${JSON.stringify(map?.[value] ?? value)}`)
  return terms.length === 1 ? terms[0] : `(${terms.join(" OR ")})`
}

export function toShopifySearch(query: OrdersQuery, now = new Date()) {
  const { filters } = query
  const parts = [
    anyOf("status", filters.status?.map((status) => (status === "archived" ? "closed" : status))),
    anyOf("financial_status", filters.payment, paymentFilter),
    anyOf("fulfillment_status", filters.fulfillment, fulfillmentFilter),
    anyOf("source_name", filters.channel),
    anyOf("tag", filters.tag),
    anyOf("risk_level", filters.risk),
    filters.date
      ? `created_at:>=${new Date(now.getTime() - rangeDays[filters.date] * 86_400_000)
          .toISOString()
          .slice(0, 10)}`
      : null,
    query.search.trim() || null,
  ]
  return parts.filter(Boolean).join(" ")
}

export const ORDERS_QUERY = `#graphql
  query Orders($first: Int, $last: Int, $after: String, $before: String, $query: String, $sortKey: OrderSortKeys, $reverse: Boolean) {
    orders(first: $first, last: $last, after: $after, before: $before, query: $query, sortKey: $sortKey, reverse: $reverse) {
      nodes {
        id
        name
        createdAt
        closedAt
        cancelledAt
        displayFinancialStatus
        displayFulfillmentStatus
        currentTotalPriceSet { shopMoney { amount currencyCode } }
        customer { id displayName email }
        sourceName
        tags
        risk { assessments { riskLevel } }
        shippingLine { title }
        lineItems(first: 50) { nodes { id title sku quantity } }
      }
      pageInfo { hasNextPage hasPreviousPage startCursor endCursor }
    }
    ordersCount(query: $query) { count }
  }
`

export const sortKeys = {
  date: "CREATED_AT",
  order: "ORDER_NUMBER",
  customer: "CUSTOMER_NAME",
  total: "TOTAL_PRICE",
  items: "CREATED_AT",
} as const

export function toShopifyVariables(query: OrdersQuery, pageSize = 20) {
  return {
    first: pageSize,
    query: toShopifySearch(query),
    sortKey: sortKeys[query.sort.key],
    reverse: query.sort.direction === "desc",
  }
}

Server-side pagination with cursors

Keep the query in the URL with the search param helpers, pass serverSide and totalCount, and remember each page’s endCursor so Next and Previous reuse them. Loading, errors and Retry stay inside the table.

"use client"

import * as React from "react"
import { usePathname, useRouter, useSearchParams } from "next/navigation"

import {
  defaultViews,
  ordersQueryFromSearchParams,
  ordersQueryToSearchParams,
  type OrdersQuery,
  type OrdersTableOrder,
} from "@/components/blocks/orders-table/orders"
import { OrdersTable } from "@/components/blocks/orders-table/orders-table"

type OrdersPage = {
  orders: OrdersTableOrder[]
  totalCount: number
  viewCounts: Record<string, number>
  endCursor: string | null
}

async function fetchOrders(query: OrdersQuery, after: string | null) {
  const params = ordersQueryToSearchParams(query)
  if (after) params.set("after", after)
  const response = await fetch(`/api/orders?${params}`)
  if (!response.ok) throw new Error(`Orders didn’t load (${response.status}).`)
  return (await response.json()) as OrdersPage
}

export function ServerOrders() {
  const router = useRouter()
  const pathname = usePathname()
  const searchParams = useSearchParams()
  const query = ordersQueryFromSearchParams(new URLSearchParams(searchParams), defaultViews)
  const cursors = React.useRef(new Map<string, (string | null)[]>())
  const [data, setData] = React.useState<OrdersPage | null>(null)
  const [error, setError] = React.useState<string | null>(null)
  const [attempt, setAttempt] = React.useState(0)

  const key = JSON.stringify({ ...query, page: 0 })
  const page = query.page

  React.useEffect(() => {
    let cancelled = false
    const list = cursors.current.get(key) ?? [null]
    cursors.current.set(key, list)
    const after = list[page - 1] ?? null
    setError(null)
    fetchOrders(JSON.parse(key) as OrdersQuery, after).then(
      (next) => {
        if (cancelled) return
        list[page] = next.endCursor
        setData(next)
      },
      (reason: Error) => !cancelled && setError(reason.message)
    )
    return () => {
      cancelled = true
    }
  }, [key, page, attempt])

  return (
    <OrdersTable
      serverSide
      orders={data?.orders ?? []}
      totalCount={data?.totalCount}
      viewCounts={data?.viewCounts}
      loading={!data && !error}
      error={error ?? undefined}
      onRetry={() => setAttempt((count) => count + 1)}
      query={query}
      onQueryChange={(next) =>
        router.replace(`${pathname}?${ordersQueryToSearchParams(next)}`, { scroll: false })
      }
      onOpenOrder={(order) => router.push(`/orders/${order.id}`)}
    />
  )
}

Bulk actions with partial failures

onBulkAction gets the action and the selection, either explicit ids or allMatching with the query. Return the ids that failed with a short reason and the table reports “Fulfilled 5 orders. 1 couldn’t be fulfilled: #1031 is on hold” with a View link.

"use client"

import { useRouter } from "next/navigation"

import type { OrdersBulkAction } from "@/components/blocks/orders-table/bulk-actions"
import type { OrdersTableOrder } from "@/components/blocks/orders-table/orders"
import { OrdersTable, type OrdersBulkResult, type OrdersSelection } from "@/components/blocks/orders-table/orders-table"

type JobResult = {
  succeeded: string[]
  failed: { id: string; code: "ON_HOLD" | "CANCELLED" | "NOT_CAPTURABLE" | "UNKNOWN" }[]
}

const messages: Record<JobResult["failed"][number]["code"], string> = {
  ON_HOLD: "is on hold",
  CANCELLED: "was cancelled",
  NOT_CAPTURABLE: "has no payment to capture",
  UNKNOWN: "hit an unexpected error",
}

async function runBulkAction(
  action: OrdersBulkAction,
  selection: OrdersSelection
): Promise<OrdersBulkResult> {
  const response = await fetch("/api/orders/bulk", {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify({
      action,
      ids: selection.allMatching ? undefined : selection.ids,
      query: selection.allMatching ? selection.query : undefined,
    }),
  })
  if (!response.ok) throw new Error("The bulk job couldn’t start. Try again in a minute.")
  const result = (await response.json()) as JobResult
  return {
    succeeded: result.succeeded,
    failed: result.failed.map((item) => ({ id: item.id, message: messages[item.code] })),
  }
}

export function OrdersWithBulkActions({ orders }: { orders: OrdersTableOrder[] }) {
  const router = useRouter()
  return (
    <OrdersTable
      orders={orders}
      onOpenOrder={(order) => router.push(`/orders/${order.id}`)}
      onBulkAction={async (action, selection) => {
        const result = await runBulkAction(action, selection)
        router.refresh()
        return result
      }}
    />
  )
}

Yapı

Dıştan içe doğru birleştirdiğiniz parçalar.

ParçaAçıklama
OrdersTableViews, toolbar, the table or phone cards, the bulk bar and pagination.
PaymentStatusPill, FulfillmentStatusPillOutline pills with a progress glyph and a hidden “Payment:” or “Fulfillment:” prefix.
applyOrdersQuery, filterOrders, sortOrdersFilter, search, sort and paginate orders on the client or the server.
ordersQueryToSearchParams, ordersQueryFromSearchParamsRead and write the view, filters, sort, search and page as URL params.
defaultViews, queryForView, isViewModifiedThe built-in views and helpers to apply one or tell whether the query has drifted from it.

OrdersTable

Also takes div props.

PropTürVarsayılan
ordersEvery order in client mode, or the current page with serverSide. OrdersTableOrder is Order plus optional closedAt (archived) and test.
OrdersTableOrder[]–
queryThe view, filters, sort, search and page, when controlled.
OrdersQuery–
defaultQueryThe starting query when uncontrolled.
Partial<OrdersQuery>–
onQueryChangeCalled on every change. Write it to the URL with ordersQueryToSearchParams.
(query: OrdersQuery) => void–
viewsSaved views in tab order. Pair with onViewsChange to persist custom views.
OrdersView[]defaultViews
onViewsChangeCalled when a view is saved, updated or deleted.
(views: OrdersView[]) => void–
visibleColumnsShown columns, controlled. defaultVisibleColumns and onVisibleColumnsChange work the same way.
OrdersColumn[]–
serverSideTreat orders as an already filtered page. Pass totalCount and, optionally, viewCounts.
booleanfalse
totalCountOrders that match the query, for “1–20 of 48” and Select all.
number–
pageSizeRows per page.
number20
loadingShow skeleton rows.
boolean–
errorShow the error state with this message.
ReactNode–
onRetryShows Retry in the error state. A promise keeps the button busy.
() => unknown–
onOpenOrderCalled on row click and Enter.
(order) => void–
onCreateOrderShows Create order in the empty store state.
() => void–
onBulkActionRun a bulk action. Return { failed: [{ id, message }] } for partial failures, or throw to show the whole action failed.
(action, selection) => OrdersBulkResult | Promise<OrdersBulkResult>–
nowThe time relative dates and date filters use. Pass it for stable server rendering.
Date | string | number–
timeZoneThe shop time zone for Today and Yesterday.
string–
localeLocale for dates and money.
Intl.LocalesArgument"en-US"
labelsEvery visible string, including result messages.
Partial<OrdersTableLabels>–
PropTürVarsayılan
viewThe selected view id.
string–
filtersLists match any value. date is today, 7d, 30d or 90d.
{ status?, payment?, fulfillment?, date?, channel?, tag?, risk? }–
sortThe sort.
{ key: "date" | "order" | "customer" | "total" | "items"; direction }–
searchMatches order number, customer, email, SKU, product and tag.
string–
page1-based page.
number–
PropTürVarsayılan
idsSelected order ids. With allMatching in client mode, every matching id.
string[]–
allMatchingEvery order that matches query is selected, including ones not loaded.
boolean–
queryThe query the selection was made under.
OrdersQuery–
countHow many orders the action covers.
number–
TuşAction
JArrowDownMoves focus to the next order.
KArrowUpMoves focus to the previous order.
XSpaceSelects or clears the focused order.
ShiftJExtends the selection to the next order. Shift+K goes up.
EnterOpens the focused order.
/Focuses search.
EscapeClears the selection.
?Shows the shortcuts.
  • Rows use a roving tab stop, so the list is one Tab stop and J, K or the arrows move inside it. The focused row gets a background and an edge bar, and forced colors mode gets an outline.
  • Status is never color alone: each pill has an empty, half or full glyph and its words, with a hidden Payment or Fulfillment prefix. High risk orders have a warning icon with a text label.
  • A polite live region announces selection counts, new orders and bulk results, such as “Fulfilled 5 orders. 1 couldn’t be fulfilled: #1031 is on hold.” Errors use role alert.
  • The bulk bar is a labelled toolbar. Disabled actions stay focusable and explain why in a tooltip. When an action finishes, focus returns to the select-all checkbox instead of being lost.

Şunlarla geliştirildi

Orders Table bloğunun yapıldığı ücretsiz HextaUI bileşenleri. Her biri tek başına yüklenir.

Kod

8 dosya, components/blocks/orders-table konumuna eklendi.