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
"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>
)
}Install
$
npx shadcn@latest add @vibra/widget-ecommerce-overview-orders-tableNeeds the @vibra registry in your components.json — set it up once.
Source
"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>
)
}/**
* 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`
}/**
* 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" },
]"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