Skip to contentVibraUI

Store orders

Everything placed in the window, newest first, filtered by status and searched by number or customer, the view kept in the URL; reads storeOrders().

Preview

Install

npx shadcn@latest add @vibra/widget-ecommerce-overview-orders-table

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

Source

app/ecommerce/components/orders-table.tsx
"use client"

import * as React from "react"
import { usePathname, useRouter, useSearchParams } from "next/navigation"
import type { ColumnFiltersState, Updater } from "@tanstack/react-table"

import { DataTable } from "@/components/ui/data-table"
import { Widget } from "@/components/ui/widget"

import { type StoreOrderRow } from "../data"
import { STATUS_OPTIONS } from "../vocabulary"
import { ORDER_COLUMNS } from "./order-columns"

/** The two query keys this table owns; every other key on the URL is left alone. */
const SEARCH_KEY = "q"
const STATUS_KEY = "status"

/** `?q=` and `?status=paid,refunded` as the table's own filter state. */
function fromQuery(params: URLSearchParams): ColumnFiltersState {
  const filters: ColumnFiltersState = []
  const search = params.get(SEARCH_KEY)
  if (search) filters.push({ id: "order", value: search })
  const statuses = (params.get(STATUS_KEY) ?? "").split(",").filter(Boolean)
  if (statuses.length) filters.push({ id: "status", value: statuses })
  return filters
}

/** The filter state written back onto the query the page already had. */
function toQuery(params: URLSearchParams, filters: ColumnFiltersState): string {
  // A copy of what is already there: a framed preview carries its palette and
  // its density in the same query, and dropping those would repaint the page.
  const next = new URLSearchParams(params)
  const search = filters.find((filter) => filter.id === "order")?.value
  const statuses = filters.find((filter) => filter.id === "status")?.value

  if (typeof search === "string" && search) next.set(SEARCH_KEY, search)
  else next.delete(SEARCH_KEY)

  if (Array.isArray(statuses) && statuses.length) next.set(STATUS_KEY, statuses.join(","))
  else next.delete(STATUS_KEY)

  return next.toString()
}

const resolve = <T,>(updater: Updater<T>, current: T): T =>
  typeof updater === "function" ? (updater as (old: T) => T)(current) : updater

/**
 * Every order in the window, filtered in the browser and recorded in the URL.
 *
 * The filters are seeded from the query on mount and owned here afterwards, so
 * the table answers a keystroke without a round trip — which is also what makes
 * it work inside a docs frame, where nothing re-renders the server page. Each
 * change is written back with `replace`, so `?status=refunded&q=ORD-1002` is a
 * link a reader can send, and the page they open lands on the same view. It is
 * `replace` rather than `push` because narrowing a table is not a place in the
 * history a Back button should have to walk out of, and `scroll: false` because
 * the reader is already looking at the row they filtered.
 */
export function OrdersTable({ rows }: { rows: StoreOrderRow[] }) {
  // The title names the table too, so a screen reader announces it by name.
  const titleId = React.useId()
  const router = useRouter()
  const pathname = usePathname()
  const params = useSearchParams()

  const [filters, setFilters] = React.useState<ColumnFiltersState>(() => fromQuery(params))

  function apply(updater: Updater<ColumnFiltersState>) {
    const next = resolve(updater, filters)
    setFilters(next)
    const query = toQuery(params, next)
    router.replace(query ? `${pathname}?${query}` : pathname, { scroll: false })
  }

  return (
    <Widget
      titleId={titleId}
      data-widget="widget-ecommerce-overview-orders-table"
      title="Orders"
      description="Everything placed in the window, newest first"
      contentClassName="px-0"
      footer="The status filter and the search box are both in the URL, so this view is a link."
    >
      <div className="px-4">
        <DataTable
          aria-labelledby={titleId}
          columns={ORDER_COLUMNS}
          data={rows}
          getRowId={(row) => row.id}
          searchKey="order"
          searchPlaceholder="Search orders"
          facets={[
            { columnId: "status", title: "Status", options: STATUS_OPTIONS.map(({ value, label }) => ({ value, label })) },
          ]}
          enableRowSelection={false}
          state={{ columnFilters: filters }}
          onColumnFiltersChange={apply}
          initialSorting={[{ id: "placedAt", desc: true }]}
          pageSize={10}
          emptyMessage="No orders match these filters."
        />
      </div>
    </Widget>
  )
}
app/ecommerce/data.ts
/**
 * What this page reads. Every row comes from `db`, so swapping a repository for
 * a real store is the whole migration. The one series with no entity behind it
 * — how many sessions reached a cart before they reached an order — is derived
 * from `seeded("dashboard-ecommerce")` and anchored on the orders that really
 * are in the window, so the bottom of the funnel is a fact and the steps above
 * it are a fixed, deterministic ratio rather than a number invented per render.
 */
import { getInitials } from "@/lib/format"
import {
  REFERENCE_DATE,
  daysAgo,
  db,
  seeded,
  type Member,
  type Order,
} from "@/lib/sample-data"

/** How much of the past this page is about. */
export const WINDOW_DAYS = 30

const WINDOW_START = daysAgo(WINDOW_DAYS)
const PREVIOUS_START = daysAgo(WINDOW_DAYS * 2)

/** What a period is called under a number that is compared with the one before it. */
export const COMPARE_LABEL = `vs previous ${WINDOW_DAYS} days`

/** An order counts as a sale unless it never happened. */
const SOLD: Order["status"][] = ["paid", "fulfilled", "refunded"]

// This window runs up to and including now — a sale rung up on the till is
// placed at REFERENCE_DATE, and it happened in the last 30 days — and the one
// before it up to where this one starts.
const inThisWindow = (at: Date) => at >= WINDOW_START && at <= REFERENCE_DATE
const inLastWindow = (at: Date) => at >= PREVIOUS_START && at < WINDOW_START

// Every read is per call, never held at module scope: the store is written
// while the server runs — a sale, a refund — and the page reads it as it is.
const thisWindow = (): Order[] => db.orders.all().filter((order) => inThisWindow(order.placedAt))
const lastWindow = (): Order[] => db.orders.all().filter((order) => inLastWindow(order.placedAt))
const productsById = () => new Map(db.products.all().map((product) => [product.id, product]))

const sold = (orders: Order[]) => orders.filter((order) => SOLD.includes(order.status))
const cents = (orders: Order[]) => orders.reduce((total, order) => total + order.totalCents, 0)

/** The change from `was` to `now` as a ratio; 0 when there was nothing to grow from. */
function growth(now: number, was: number): number {
  if (was === 0) return 0
  return (now - was) / was
}

export type StoreStat = {
  key: string
  label: string
  value: number
  /** "money" is in minor units; "count" is a plain number. */
  kind: "money" | "count"
  delta: number
  /** False where a rise is the bad news. */
  positiveIsGood: boolean
}

/**
 * The four headline numbers, each against the same span immediately before it.
 * Gross sales counts every order that was actually paid for, refunds included,
 * because a refund is money that arrived and then left — the refunds card is
 * what says how much of it left.
 */
export function storeStats(): StoreStat[] {
  const now = sold(thisWindow())
  const was = sold(lastWindow())
  const refundedNow = now.filter((order) => order.status === "refunded")
  const refundedWas = was.filter((order) => order.status === "refunded")

  const customers = db.customers.all()
  const newCustomers = customers.filter((customer) => inThisWindow(customer.createdAt)).length
  const newCustomersBefore = customers.filter((customer) => inLastWindow(customer.createdAt)).length

  return [
    {
      key: "gross",
      label: "Gross sales",
      value: cents(now),
      kind: "money",
      delta: growth(cents(now), cents(was)),
      positiveIsGood: true,
    },
    {
      key: "orders",
      label: "Orders",
      value: now.length,
      kind: "count",
      delta: growth(now.length, was.length),
      positiveIsGood: true,
    },
    {
      key: "customers",
      label: "New customers",
      value: newCustomers,
      kind: "count",
      delta: growth(newCustomers, newCustomersBefore),
      positiveIsGood: true,
    },
    {
      key: "refunds",
      label: "Refunds",
      value: cents(refundedNow),
      kind: "money",
      delta: growth(cents(refundedNow), cents(refundedWas)),
      positiveIsGood: false,
    },
  ]
}

export type CategorySales = {
  category: string
  orders: number
  revenueCents: number
  /** Revenue against the same span before this one, as a ratio. */
  growth: number
}

/** Revenue per line by category over a span, and how many orders touched it. */
function byCategory(orders: Order[]): Map<string, { orders: number; revenueCents: number }> {
  const totals = new Map<string, { orders: number; revenueCents: number }>()
  const products = productsById()

  for (const order of sold(orders)) {
    // An order can carry lines from several categories; each category is
    // credited once for the order and with only its own lines' money.
    const seen = new Set<string>()
    for (const item of order.items) {
      const category = products.get(item.productId)?.category
      if (!category) continue
      const entry = totals.get(category) ?? { orders: 0, revenueCents: 0 }
      entry.revenueCents += item.qty * item.unitCents
      if (!seen.has(category)) {
        entry.orders += 1
        seen.add(category)
      }
      totals.set(category, entry)
    }
  }

  return totals
}

/** What each category sold in the window, biggest first. */
export function salesByCategory(): CategorySales[] {
  const now = byCategory(thisWindow())
  const was = byCategory(lastWindow())

  return [...now.entries()]
    .map(([category, entry]) => ({
      category,
      orders: entry.orders,
      revenueCents: entry.revenueCents,
      growth: growth(entry.revenueCents, was.get(category)?.revenueCents ?? 0),
    }))
    .sort((a, b) => b.revenueCents - a.revenueCents)
}

// Each step of the funnel keeps this share of the one above it, drawn once from
// the block's own generator so the ladder is fixed rather than re-invented per
// render. Only the bottom step is measured — everything above it is scaled up
// from the orders that really are in the window.
const FUNNEL_LABELS = ["Sessions", "Carts", "Checkouts", "Orders"] as const
const FUNNEL_RATES = (() => {
  const rand = seeded("dashboard-ecommerce")
  return [0.28 + rand() * 0.06, 0.52 + rand() * 0.08, 0.61 + rand() * 0.08]
})()

/**
 * Sessions down to orders. The last step is the count of orders actually
 * placed in the window; each step above it is that number divided back up
 * through the fixed rates, so the funnel can only ever narrow.
 */
export function conversionFunnel(): { label: string; value: number }[] {
  const orders = sold(thisWindow()).length
  const values = [orders]
  for (const rate of [...FUNNEL_RATES].reverse()) {
    values.unshift(Math.round(values[0] / rate))
  }
  return FUNNEL_LABELS.map((label, index) => ({ label, value: values[index] }))
}

export type TopProduct = { id: string; name: string; category: string; units: number }

/** The eight products the window moved most of, by units. */
export function topProducts(): TopProduct[] {
  const units = new Map<string, number>()
  const products = productsById()
  for (const order of sold(thisWindow())) {
    for (const item of order.items) {
      units.set(item.productId, (units.get(item.productId) ?? 0) + item.qty)
    }
  }

  return [...units.entries()]
    .map(([id, count]) => {
      const product = products.get(id)
      return { id, name: product?.name ?? id, category: product?.category ?? "—", units: count }
    })
    .sort((a, b) => b.units - a.units)
    .slice(0, 8)
}

export type StoreOrderRow = {
  id: string
  number: string
  customer: string
  company: string
  status: Order["status"]
  units: number
  totalCents: number
  placedAt: Date
  country: string
}

/** Every order placed in the window, newest first, as plain rows for the table. */
export function storeOrders(): StoreOrderRow[] {
  const customers = new Map(db.customers.all().map((customer) => [customer.id, customer]))
  return thisWindow()
    .sort((a, b) => b.placedAt.getTime() - a.placedAt.getTime())
    .map((order) => {
      const customer = customers.get(order.customerId)
      return {
        id: order.id,
        number: order.number,
        // A sale the till rang up for nobody in particular has no customer.
        customer: customer?.name ?? (order.customerId || "Walk-in"),
        company: customer?.company ?? "—",
        status: order.status,
        units: order.items.reduce((total, item) => total + item.qty, 0),
        totalCents: order.totalCents,
        placedAt: order.placedAt,
        country: order.country,
      }
    })
}

/** 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 }))
}

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 }
}

// Fixed to UTC so the line reads the same wherever the page is rendered.
const UPDATED_AT = new Intl.DateTimeFormat("en-US", {
  dateStyle: "medium",
  timeStyle: "short",
  hourCycle: "h23",
  timeZone: "UTC",
})

/** The window these numbers cover, and when they were last collected. */
export function lastUpdated(): string {
  return `Last ${WINDOW_DAYS} days · updated ${UPDATED_AT.format(REFERENCE_DATE)} UTC`
}
app/ecommerce/vocabulary.ts
/**
 * The words the table is written in, and nothing else.
 *
 * `data.ts` reads `db` at module scope, so anything a client island imports
 * from it for a *value* drags the whole sample-data store into the browser.
 * The status map and the filter options are exactly that kind of value — plain
 * vocabulary with no rows behind it — so they live here, where the only import
 * is a type and the module is free of the store.
 */
import { type Order } from "@/lib/sample-data"

/** How each status is toned wherever it is shown. */
export const STATUS_MAP = {
  fulfilled: "success",
  paid: "info",
  pending: "warning",
  refunded: "warning",
  cancelled: "danger",
} as const

/** The statuses the table can be narrowed to, in the order a reader reads them. */
export const STATUS_OPTIONS: { value: Order["status"]; label: string }[] = [
  { value: "pending", label: "Pending" },
  { value: "paid", label: "Paid" },
  { value: "fulfilled", label: "Fulfilled" },
  { value: "refunded", label: "Refunded" },
  { value: "cancelled", label: "Cancelled" },
]
app/ecommerce/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 { type StoreOrderRow } from "../data"
import { STATUS_MAP } from "../vocabulary"

/**
 * The order column carries both the number and who placed it, in one accessor,
 * so the toolbar's single search box finds an order either way — a reader who
 * has the customer in mind should not have to know the number first.
 */
export const ORDER_COLUMNS: DataTableColumnDef<StoreOrderRow>[] = [
  {
    id: "order",
    accessorFn: (row) => `${row.number} ${row.customer} ${row.company}`,
    header: ({ column }) => <DataTableColumnHeader column={column} title="Order" />,
    cell: ({ row }) => (
      <div className="flex min-w-0 flex-col">
        <span className="font-mono text-xs">{row.original.number}</span>
        <span className="truncate text-muted-foreground">{row.original.customer}</span>
      </div>
    ),
    meta: { label: "Order" },
  },
  {
    accessorKey: "status",
    header: ({ column }) => <DataTableColumnHeader column={column} title="Status" />,
    cell: ({ row }) => <StatusBadge status={row.original.status} map={STATUS_MAP} />,
    meta: { label: "Status" },
  },
  {
    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" />,
    cell: ({ row }) => <NumberCell value={row.original.units} />,
    meta: { align: "right", label: "Units" },
  },
  {
    accessorKey: "totalCents",
    header: ({ column }) => <DataTableColumnHeader column={column} title="Total" />,
    cell: ({ row }) => <CurrencyCell value={row.original.totalCents / 100} />,
    meta: { align: "right", label: "Total" },
  },
  {
    accessorKey: "placedAt",
    header: ({ column }) => <DataTableColumnHeader column={column} title="Placed" />,
    cell: ({ row }) => <DateCell date={row.original.placedAt} />,
    meta: { align: "right", label: "Placed" },
  },
]

Its page

On its page the card sits among the rest of the dashboard and shares its range and its data with them.

From the Storefront page