/ecommerce/customersCustomer list
A server-paged accounts table: status tabs, search, a plan filter, sortable columns, a column menu, an export, and bulk actions over the rows you tick.
The page is an async server component inside AppShell: it awaits db.customers.list for the first page and hands it to the table, which asks the server for every page after that through fetchCustomers. Sorting, searching, the plan filter and the status tabs all rewrite one query object that the repository answers, so the browser only ever holds the ten rows it is showing while the counts beside them are the store's. Suspending and exporting are server actions returning Result, and a refusal — suspending an account that has already churned — is printed in a Callout rather than swallowed. Composes AppShell, PageHeader, QuickFilters, FilterBar, FilterMenu, SearchInput, ExportMenu, DataTableColumnHeader, DataTableViewOptions, DataTablePagination, DataTableRows, StatusBadge, UserCell, CurrencyCell, NumberCell, DateCell, SelectionActionBar and ConfirmDialog.
Preview
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 { CustomersTable } from "./components/customers-table"
import { currentUser, lastUpdated, listCustomers, planOptions, shellNotifications, statusFilters } from "./data"
import { DEFAULT_QUERY } from "./vocabulary"
import { NAV, ROUTES } from "@/lib/dashboards/ecommerce/nav"
/**
* The accounts list. The page is an async server component: it reads the first
* page of accounts through `db.customers.list` and hands it to the table, which
* asks the server for every page after that. Nothing but the page on show ever
* reaches the browser.
*/
export default async function CustomersPage() {
const initial = await listCustomers(DEFAULT_QUERY)
return (
<AppShell
nav={NAV}
activeHref={ROUTES.customers}
user={currentUser()}
notifications={shellNotifications()}
now={REFERENCE_DATE}
onSignOut={signOut}
>
<PageHeader
titleId="ecommerce-customers-title"
title="Customers"
description="Every account, who looks after it, and what it pays."
meta={lastUpdated()}
/>
<CustomersTable
titleId="ecommerce-customers-title"
initial={initial}
statusFilters={statusFilters()}
planOptions={planOptions()}
/>
</AppShell>
)
}Install
npx shadcn@latest add @vibra/ecommerce-customersNeeds the @vibra registry in your components.json — set it up once.
Source
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 { CustomersTable } from "./components/customers-table"
import { currentUser, lastUpdated, listCustomers, planOptions, shellNotifications, statusFilters } from "./data"
import { DEFAULT_QUERY } from "./vocabulary"
import { NAV, ROUTES } from "@/lib/dashboards/ecommerce/nav"
/**
* The accounts list. The page is an async server component: it reads the first
* page of accounts through `db.customers.list` and hands it to the table, which
* asks the server for every page after that. Nothing but the page on show ever
* reaches the browser.
*/
export default async function CustomersPage() {
const initial = await listCustomers(DEFAULT_QUERY)
return (
<AppShell
nav={NAV}
activeHref={ROUTES.customers}
user={currentUser()}
notifications={shellNotifications()}
now={REFERENCE_DATE}
onSignOut={signOut}
>
<PageHeader
titleId="ecommerce-customers-title"
title="Customers"
description="Every account, who looks after it, and what it pays."
meta={lastUpdated()}
/>
<CustomersTable
titleId="ecommerce-customers-title"
initial={initial}
statusFilters={statusFilters()}
planOptions={planOptions()}
/>
</AppShell>
)
}/**
* What this page reads. Every row is a `db.customers` record, and the table is
* paged by the repository rather than by the browser: `listCustomers` hands
* `db.customers.list` the page, the sort, the search string and the plan and
* status filters, and gets back the one page the reader is looking at plus the
* totals the pagination needs. The option counts beside each filter are taken
* from the whole store on each read, so they stay honest while the table
* shows ten rows. "Now" is `REFERENCE_DATE`.
*/
import { formatDate, getInitials } from "@/lib/format"
import { db, ownKey, REFERENCE_DATE, type Customer, type Member, type Page } from "@/lib/sample-data"
import { type CustomerFilterOption, type CustomersQuery } from "./vocabulary"
/** One row of the table: the account, flattened and priced in whole dollars. */
export type CustomerRow = {
id: string
company: string
contact: string
email: string
plan: Customer["plan"]
planLabel: string
status: Customer["status"]
owner: string
country: string
/** Monthly recurring revenue in whole dollars; zero on trials and churned accounts. */
mrr: number
seats: number
lastSeenAt: Date
createdAt: Date
}
// The table's column ids are the reader's vocabulary; these are the fields the
// repository sorts on. Anything named in neither this map nor JOINED_SORTS
// below is not a sortable column.
const SORT_FIELDS: Record<string, keyof Customer> = {
company: "company",
plan: "plan",
status: "status",
mrr: "mrrCents",
seats: "seats",
lastSeenAt: "lastSeenAt",
createdAt: "createdAt",
}
/**
* Columns the repository cannot sort, because the value it stores is not the
* value the table shows. `owner` is a `Member` id and the column reads that
* member's name, and ids do not order the way names do — a store with a real
* query language would say `ORDER BY member.name`. Sorting one of these reads
* the whole matched set here, on the server, resolves it and pages it by hand;
* the repository still does the searching and the filtering, and one page is
* still all that crosses the wire.
*/
const JOINED_SORTS: Record<string, (row: CustomerRow) => string> = {
owner: (row) => row.owner,
}
/** The repository's own paging rules, applied to rows it has already handed over. */
function pageOf(rows: CustomerRow[], page: number, pageSize: number): Page<CustomerRow> {
const total = rows.length
const size = Math.max(1, pageSize)
const pageCount = Math.ceil(total / size)
const current = Math.min(Math.max(1, page), Math.max(1, pageCount))
const start = (current - 1) * size
return { rows: rows.slice(start, start + size), total, page: current, pageSize: size, pageCount }
}
// The plans are read-only reference rows; the members are written (a rename on
// the profile page), so they are read per page asked for, never held.
const PLAN_LABELS = new Map(db.plans.all().map((plan) => [plan.name.toLowerCase(), plan.name]))
const membersById = () => new Map(db.members.all().map((member) => [member.id, member]))
function toRow(row: Customer, members: Map<string, Member>): CustomerRow {
return {
id: row.id,
company: row.company,
contact: row.name,
email: row.email,
plan: row.plan,
planLabel: PLAN_LABELS.get(row.plan) ?? row.plan,
status: row.status,
owner: members.get(row.owner)?.name ?? "Unassigned",
country: row.country,
mrr: row.mrrCents / 100,
seats: row.seats,
lastSeenAt: row.lastSeenAt,
createdAt: row.createdAt,
}
}
/**
* One page of accounts, filtered and searched by the repository and sorted by
* it too, unless the column is one the repository cannot order (see
* JOINED_SORTS). The caller never sees the rows it did not ask for, which is
* the whole point of paging on this side of the wire.
*/
export async function listCustomers(query: CustomersQuery): Promise<Page<CustomerRow>> {
const filters = { plan: query.plans, status: query.statuses }
// The maps' own keys only: a sort named "constructor" is not a column.
const joined = ownKey(JOINED_SORTS, query.sort.id) ? JOINED_SORTS[query.sort.id] : undefined
if (joined) {
const matched = await db.customers.list({
pageSize: Number.MAX_SAFE_INTEGER,
search: query.search,
filters,
})
const direction = query.sort.desc ? -1 : 1
const members = membersById()
const rows = matched.rows
.map((row) => toRow(row, members))
.sort((a, b) => direction * joined(a).localeCompare(joined(b)))
return pageOf(rows, query.page, query.pageSize)
}
const field = ownKey(SORT_FIELDS, query.sort.id) ? SORT_FIELDS[query.sort.id] : undefined
const page = await db.customers.list({
page: query.page,
pageSize: query.pageSize,
sort: field ? { id: field, desc: query.sort.desc } : undefined,
search: query.search,
filters,
})
const members = membersById()
return { ...page, rows: page.rows.map((row) => toRow(row, members)) }
}
/** Every account the current filters would leave, for an export that is not just this page. */
export async function allMatching(query: CustomersQuery): Promise<CustomerRow[]> {
const page = await listCustomers({ ...query, page: 1, pageSize: Number.MAX_SAFE_INTEGER })
return page.rows
}
function countBy(pick: (row: Customer) => string): Map<string, number> {
const counts = new Map<string, number>()
for (const row of db.customers.all()) counts.set(pick(row), (counts.get(pick(row)) ?? 0) + 1)
return counts
}
/** The plans, cheapest first — the order the pricing page lists them in. */
const PLAN_ORDER: Customer["plan"][] = ["free", "starter", "team", "enterprise"]
const STATUS_ORDER: Customer["status"][] = ["active", "trial", "suspended", "churned"]
const titleCase = (value: string) => value.charAt(0).toUpperCase() + value.slice(1)
/** The plan filter's options, cheapest first, each with how many accounts are on it now. */
export function planOptions(): CustomerFilterOption[] {
const byPlan = countBy((row) => row.plan)
return PLAN_ORDER.map((plan) => ({
value: plan,
label: PLAN_LABELS.get(plan) ?? titleCase(plan),
count: byPlan.get(plan) ?? 0,
}))
}
/** The tabs above the table: every account, then one per status, counted as the book stands. */
export function statusFilters(): CustomerFilterOption[] {
const byStatus = countBy((row) => row.status)
return [
{ value: "all", label: "All", count: db.customers.all().length },
...STATUS_ORDER.map((status) => ({
value: status,
label: titleCase(status),
count: byStatus.get(status) ?? 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 }))
}"use server"
import { mockAuthAdapter } from "@/lib/auth-adapter"
import { db, oneOf, tableQuery, type Page, type Result } from "@/lib/sample-data"
import { allMatching, listCustomers, type CustomerRow } from "./data"
import { DEFAULT_QUERY, type CustomersQuery } from "./vocabulary"
/** What an export can be written as: the two the menu offers. */
const FORMATS = ["csv", "json"] as const
/**
* 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
* `fetchCustomers`, so the browser is never handed rows it is not showing.
* Mutations return `Result`, which is what lets the page print the reason a
* bulk action was refused instead of failing quietly.
*/
export async function signOut(): Promise<Result<{ signedOut: true }>> {
await mockAuthAdapter.signOut()
return { ok: true, data: { signedOut: true } }
}
/** One page of accounts for the query the reader has built up. */
export async function fetchCustomers(query: CustomersQuery): Promise<Page<CustomerRow>> {
return listCustomers(tableQuery(query, DEFAULT_QUERY))
}
/** Statuses a suspension can move an account out of; the rest are already gone. */
const SUSPENDABLE = new Set(["active", "trial"])
/**
* Suspends the selected accounts. An account that has already churned or been
* suspended cannot be suspended again, and the whole batch is refused rather
* than half-applied, so the reader is never left guessing which rows moved.
*/
export async function suspendCustomers(ids: string[]): Promise<Result<{ count: number }>> {
if (!Array.isArray(ids) || ids.some((id) => typeof id !== "string") || ids.length > db.customers.all().length) {
return { ok: false, error: { code: "invalid_input", message: "Select accounts from the table." } }
}
if (ids.length === 0) {
return { ok: false, error: { code: "empty_selection", message: "Select an account first." } }
}
const rows = await Promise.all(ids.map((id) => db.customers.get(id)))
const missing = ids.filter((_, index) => rows[index] === undefined)
if (missing.length > 0) {
return {
ok: false,
error: { code: "not_found", message: `No account with id ${missing.join(", ")}.` },
}
}
const blocked = rows.filter((row) => row && !SUSPENDABLE.has(row.status))
if (blocked.length > 0) {
const names = blocked.map((row) => row!.company).join(", ")
return {
ok: false,
error: {
code: "not_suspendable",
message: `Only active and trial accounts can be suspended — ${names} cannot.`,
},
}
}
for (const id of ids) await db.customers.update(id, { status: "suspended" })
return { ok: true, data: { count: ids.length } }
}
/**
* Exports the accounts asked for: the ticked ones when there is a selection,
* and otherwise every account the current filters leave — not just the page on
* show. A real app streams a file back; here the action reports what it would
* have written so the page can say so out loud, and the count it reports is the
* count it exported.
*/
export async function exportCustomers(
query: CustomersQuery,
format: string,
ids?: string[]
): Promise<Result<{ format: string; rows: number }>> {
if (!oneOf(format, FORMATS)) return { ok: false, error: { code: "invalid_input", message: "Export as CSV or JSON." } }
const picked = Array.isArray(ids) ? ids.filter((id): id is string => typeof id === "string") : []
const rows = picked.length ? picked : (await allMatching(tableQuery(query, DEFAULT_QUERY))).map((row) => row.id)
if (rows.length === 0) {
return {
ok: false,
error: { code: "empty_export", message: "No accounts match these filters." },
}
}
return { ok: true, data: { format, rows: rows.length } }
}/**
* The accounts list's words and defaults: the query the table opens on and
* the status tones. 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 { SortSpec } from "@/lib/sample-data"
export type CustomersQuery = {
page: number
pageSize: number
sort: SortSpec
search: string
plans: string[]
statuses: string[]
}
export const PAGE_SIZE = 10
export const DEFAULT_QUERY: CustomersQuery = {
page: 1,
pageSize: PAGE_SIZE,
sort: { id: "mrr", desc: true },
search: "",
plans: [],
statuses: [],
}
/** A status tab or a plan option, with how many accounts it holds. */
export type CustomerFilterOption = { value: string; label: string; count: number }
/** Statuses `StatusBadge` has no default for. */
export const STATUS_MAP = { trial: "info", churned: "danger", suspended: "warning" } as const"use client"
import {
DataTableColumnHeader,
selectionColumn,
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 CustomerRow } from "../data"
import { STATUS_MAP } from "../vocabulary"
/**
* Every sortable column here is sorted by the repository, not in the browser:
* the header writes into the table's sorting state and the table asks the
* server for the page that state describes. `id` ships hidden — it is in the
* View menu for the moment someone needs to quote a record in a ticket.
*/
export const CUSTOMER_COLUMNS: DataTableColumnDef<CustomerRow>[] = [
selectionColumn<CustomerRow>({ rowLabel: (row) => row.company }),
{
accessorKey: "company",
header: ({ column }) => <DataTableColumnHeader column={column} title="Account" />,
cell: ({ row }) => (
<UserCell
size="sm"
name={row.original.company}
description={row.original.contact}
href={`/ecommerce/customers/${row.original.id}`}
/>
),
meta: { label: "Account" },
},
{
accessorKey: "id",
header: ({ column }) => <DataTableColumnHeader column={column} title="Account ID" />,
enableSorting: false,
cell: ({ row }) => <span className="font-mono text-xs">{row.original.id}</span>,
meta: { label: "Account ID" },
},
{
accessorKey: "owner",
header: ({ column }) => <DataTableColumnHeader column={column} title="Owner" />,
cell: ({ row }) => <span className="whitespace-nowrap">{row.original.owner}</span>,
meta: { label: "Owner" },
},
{
accessorKey: "plan",
header: ({ column }) => <DataTableColumnHeader column={column} title="Plan" />,
cell: ({ row }) => <span className="text-muted-foreground">{row.original.planLabel}</span>,
meta: { label: "Plan" },
},
{
accessorKey: "status",
header: ({ column }) => <DataTableColumnHeader column={column} title="Status" />,
cell: ({ row }) => <StatusBadge status={row.original.status} map={STATUS_MAP} />,
meta: { label: "Status" },
},
{
accessorKey: "seats",
header: ({ column }) => <DataTableColumnHeader column={column} title="Seats" />,
cell: ({ row }) => <NumberCell value={row.original.seats} />,
meta: { align: "right", label: "Seats" },
},
{
accessorKey: "mrr",
header: ({ column }) => <DataTableColumnHeader column={column} title="MRR" />,
cell: ({ row }) => <CurrencyCell value={row.original.mrr} />,
meta: { align: "right", label: "MRR" },
},
{
accessorKey: "lastSeenAt",
header: ({ column }) => <DataTableColumnHeader column={column} title="Last seen" />,
// Absolute, not relative: "3 days ago" is read off the clock, and this
// page's now is REFERENCE_DATE. The full date is in the cell's own title.
cell: ({ row }) => <DateCell date={row.original.lastSeenAt} className="text-muted-foreground" />,
meta: { align: "right", label: "Last seen" },
},
]"use client"
import { DownloadIcon, PauseCircleIcon } from "lucide-react"
import { SelectionActionBar } from "@/components/ui/action-bar"
import { Button } from "@/components/ui/button"
import { ConfirmDialog } from "@/components/ui/confirm-dialog"
export type CustomersBulkBarProps = {
count: number
onClear: () => void
onSuspend: () => Promise<void>
onExport: () => Promise<void>
/** True while either bulk action is in flight; both buttons wait it out. */
pending: boolean
}
/**
* What you can do to the rows you have ticked. The bar is in the page's flow
* rather than pinned over it, so it can never cover the last row of the table,
* and it disappears at zero selected.
*/
export function CustomersBulkBar({
count,
onClear,
onSuspend,
onExport,
pending,
}: CustomersBulkBarProps) {
if (count === 0) return null
return (
<div className="sticky bottom-6 z-40 flex justify-center">
<SelectionActionBar
className="static"
count={count}
itemLabel="account"
onClear={onClear}
>
<Button variant="ghost" size="sm" disabled={pending} onClick={onExport}>
<DownloadIcon data-icon="inline-start" />
Export selection
</Button>
<ConfirmDialog
variant="destructive"
title={`Suspend ${count} account${count === 1 ? "" : "s"}?`}
description="Suspended accounts keep their data and lose access until someone reactivates them."
confirmText="Suspend"
onConfirm={onSuspend}
trigger={
<Button size="sm" disabled={pending}>
<PauseCircleIcon data-icon="inline-start" />
Suspend
</Button>
}
/>
</SelectionActionBar>
</div>
)
}"use client"
import * as React from "react"
import type { RowSelectionState } from "@tanstack/react-table"
import { useServerTable } from "@/hooks/use-server-table"
import { formatNumber } from "@/lib/format"
import { type Page } from "@/lib/sample-data"
import { Callout } from "@/components/ui/callout"
import { DataTablePagination, DataTableRows } from "@/components/ui/data-table"
import type { ExportFormat } from "@/components/ui/export-menu"
import { QuickFilters } from "@/components/ui/quick-filters"
import { exportCustomers, fetchCustomers, suspendCustomers } from "../actions"
import { type CustomerRow } from "../data"
import { DEFAULT_QUERY, type CustomerFilterOption } from "../vocabulary"
import { CUSTOMER_COLUMNS } from "./customer-columns"
import { CustomersBulkBar } from "./customers-bulk-bar"
import { CustomersToolbar } from "./customers-toolbar"
type Notice = { tone: "success" | "danger"; message: string }
/**
* The accounts table. Every control on it — the status tabs, the search box,
* the plan filter, the column sort, the pager — rewrites one query object and
* asks the server for the page that query describes; nothing is filtered or
* sorted in the browser. The rows on screen are always exactly the rows the
* repository sent: useServerTable holds the query and asks for each page, the
* newest question's answer winning — the re-read after a suspension included.
*/
export type CustomersTableProps = {
/** The page's h1 id, which names the table. */
titleId: string
/** The first page, read on the server. */
initial: Page<CustomerRow>
/** The status tabs, each with how many accounts it holds — counted on the server. */
statusFilters: CustomerFilterOption[]
/** The plan filter's options, likewise counted. */
planOptions: CustomerFilterOption[]
}
export function CustomersTable({ titleId, initial, statusFilters, planOptions }: CustomersTableProps) {
const [selection, setSelection] = React.useState<RowSelectionState>({})
const [notice, setNotice] = React.useState<Notice | null>(null)
const { table, query, pending, apply, refresh, summary } = useServerTable({
initialQuery: DEFAULT_QUERY,
initialPage: initial,
fetchPage: fetchCustomers,
columns: CUSTOMER_COLUMNS,
// Selection by account id, so ticking a row and turning the page keeps it.
getRowId: (row) => row.id,
defaultSort: DEFAULT_QUERY.sort,
// The page has always counted "accounts", one or many.
noun: ["accounts", "accounts"],
// Anything but a page move goes back to page one and drops the selection,
// because the rows those ticks referred to are about to be replaced.
onApply: (patch) => {
if (patch.page === undefined) setSelection({})
setNotice(null)
},
tableOptions: {
enableRowSelection: true,
initialState: { columnVisibility: { id: false } },
state: { rowSelection: selection },
onRowSelectionChange: (updater) =>
setSelection((current) => (typeof updater === "function" ? updater(current) : updater)),
},
})
const selectedIds = Object.keys(selection).filter((id) => selection[id])
/** With ids, the ticked accounts; without, everything the filters leave. */
async function runExport(format: ExportFormat, ids?: string[]) {
const result = await exportCustomers(query, format, ids)
if (!result.ok) return setNotice({ tone: "danger", message: result.error.message })
setNotice({
tone: "success",
message: `Queued ${formatNumber(result.data.rows)} accounts as ${format.toUpperCase()}.`,
})
}
async function runSuspend() {
const result = await suspendCustomers(selectedIds)
if (!result.ok) return setNotice({ tone: "danger", message: result.error.message })
setSelection({})
setNotice({
tone: "success",
message: `Suspended ${formatNumber(result.data.count)} accounts.`,
})
void refresh()
}
return (
<div className="flex flex-col gap-4">
<QuickFilters
aria-label="Filter accounts by status"
options={statusFilters}
value={query.statuses[0] ?? "all"}
onValueChange={(value) => apply({ statuses: value === "all" ? [] : [value] })}
/>
<CustomersToolbar
table={table}
search={query.search}
onSearchChange={(search) => apply({ search })}
plans={query.plans}
planOptions={planOptions}
onPlansChange={(plans) => apply({ plans })}
onClearFilters={() => apply({ search: "", plans: [], statuses: [] })}
activeCount={query.plans.length + query.statuses.length + (query.search ? 1 : 0)}
onExport={(format) => runExport(format)}
summary={summary}
/>
{notice ? (
<Callout
role={notice.tone === "danger" ? "alert" : "status"}
variant={notice.tone}
title={notice.tone === "danger" ? "That did not run" : "Export queued"}
>
{notice.message}
</Callout>
) : null}
<DataTableRows
table={table}
aria-labelledby={titleId}
busy={pending}
emptyMessage="No accounts match these filters."
footer={
<DataTablePagination table={table} showSelectedCount={false} pageSizeOptions={[10, 25, 50]} />
}
/>
<CustomersBulkBar
count={selectedIds.length}
pending={pending}
onClear={() => setSelection({})}
onExport={() => runExport("csv", selectedIds)}
onSuspend={runSuspend}
/>
</div>
)
}"use client"
import type { RowData } from "@tanstack/react-table"
import { SlidersHorizontalIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import { DataTableViewOptions, type DataTableInstance } from "@/components/ui/data-table"
import { ExportMenu, type ExportFormat } from "@/components/ui/export-menu"
import { FilterBar, FilterMenu } from "@/components/ui/filter-bar"
import { SearchInput } from "@/components/ui/search-input"
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@/components/ui/sheet"
import { type CustomerFilterOption } from "../vocabulary"
export type CustomersToolbarProps<TData extends RowData> = {
table: DataTableInstance<TData>
search: string
onSearchChange: (value: string) => void
plans: string[]
/** Every plan, with how many accounts are on it. */
planOptions: CustomerFilterOption[]
onPlansChange: (value: string[]) => void
onClearFilters: () => void
/** How many filters are set, so the bar knows whether to offer a reset. */
activeCount: number
onExport: (format: ExportFormat) => Promise<void>
/** "1–10 of 250 accounts", built from the page the server sent back. */
summary: string
}
/**
* Everything above the rows: the search box, the plan filter, what the page is
* showing, the column menu and the export. The search and the filter report
* upwards rather than filtering anything themselves — the query they build is
* what the next request carries.
*
* Below md the plan filter, the column menu and the export move into one
* Filters sheet: five controls wrapping onto three lines is most of a phone's
* first screen, and the search box is the one of them worth keeping in view.
*/
export function CustomersToolbar<TData extends RowData>({
table,
search,
onSearchChange,
plans,
planOptions,
onPlansChange,
onClearFilters,
activeCount,
onExport,
summary,
}: CustomersToolbarProps<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 accounts"
aria-label="Search accounts"
className="w-full max-w-56"
/>
<div className="hidden items-center gap-2 md:flex">
<FilterMenu
label="Plan"
size="sm"
options={planOptions}
value={plans}
onValueChange={onPlansChange}
/>
</div>
<Sheet>
<SheetTrigger
render={
<Button
type="button"
data-slot="customers-filters-trigger"
variant="outline"
size="sm"
className="md:hidden"
>
<SlidersHorizontalIcon data-icon="inline-start" aria-hidden="true" />
Filters
{activeCount > 0 ? (
<span className="ml-1 rounded-full bg-brand-muted px-1.5 text-2xs font-medium text-brand">
{activeCount}
</span>
) : null}
</Button>
}
/>
<SheetContent side="bottom" className="max-h-[80svh] overflow-y-auto p-4">
<SheetHeader className="p-0">
<SheetTitle>Filters</SheetTitle>
<SheetDescription>Narrow the accounts, and choose what the table shows.</SheetDescription>
</SheetHeader>
<div className="flex flex-col items-stretch gap-2 [&>*]:w-full [&_[data-slot=button]]:justify-start">
<FilterMenu
label="Plan"
size="sm"
options={planOptions}
value={plans}
onValueChange={onPlansChange}
/>
<DataTableViewOptions table={table} className="ms-0" />
<ExportMenu size="sm" formats={["csv", "json"]} onExport={onExport} />
</div>
</SheetContent>
</Sheet>
<p
data-slot="customers-summary"
// The pagination below counts pages; this counts accounts, and it is
// the store's count rather than the length of the array on screen.
aria-live="polite"
className="text-sm tabular-nums text-muted-foreground"
>
{summary}
</p>
<div className="ml-auto hidden items-center gap-2 md:flex">
<DataTableViewOptions table={table} className="ms-0" />
<ExportMenu size="sm" formats={["csv", "json"]} onExport={onExport} />
</div>
</FilterBar>
)
}