Skip to contentVibraUI
Part of the Finance dashboardinstalls at /finance/invoices

Billing ledger

A server-paged invoices table under four aging buckets and an overdue callout, with send and void actions that move an invoice between buckets and can be refused.

Open the live page

The page awaits db.invoices.list for its first page and the aging buckets for the whole receivable ledger; the search box, the status filter, the column sort and the pager rewrite one query the repository answers. Every age is measured against REFERENCE_DATE, so the four buckets and the table's "late" column always agree. Sending and voiding are server actions returning Result, and each returns the recomputed ledger with it, so the totals above the table are never left describing the state before the change — a paid invoice refuses to be voided, and the refusal is printed. Voiding is behind a ConfirmDialog, because it is the one action here that writes an invoice off. Composes AppShell, PageHeader, Callout, StatCardGroup, StatCard, FilterBar, FilterMenu, SearchInput, DataTableColumnHeader, DataTableViewOptions, DataTableRowActions, DataTablePagination, DataTableRows, StatusBadge, UserCell, CurrencyCell, DateCell and ConfirmDialog.

Preview

Install

npx shadcn@latest add @vibra/finance-invoices

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

Source

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

import { fetchLedger, signOut } from "./actions"
import { InvoicesView } from "./components/invoices-view"
import { currentUser, lastUpdated, listInvoices, shellNotifications, statusOptions } from "./data"
import { DEFAULT_QUERY } from "./vocabulary"
import { NAV, ROUTES } from "@/lib/dashboards/finance/nav"

/**
 * The billing ledger. The page is an async server component: it reads the
 * first page of invoices and the aging buckets through `db.invoices`, and the
 * view below asks the server for every page and every mutation after that.
 */
export default async function InvoicesPage() {
  const [initial, ledger] = await Promise.all([listInvoices(DEFAULT_QUERY), fetchLedger()])

  return (
    <AppShell
      nav={NAV}
      activeHref={ROUTES.invoices}
      user={currentUser()}
      notifications={shellNotifications()}
      now={REFERENCE_DATE}
      onSignOut={signOut}
    >
      <PageHeader
        titleId="finance-invoices-title"
        title="Invoices"
        description="What has been billed, what has been collected, and what is late."
        meta={lastUpdated()}
      />

      <InvoicesView titleId="finance-invoices-title" initial={initial} ledger={ledger} statusOptions={statusOptions()} />
    </AppShell>
  )
}
app/finance/invoices/data.ts
/**
 * What this page reads. Every row is a `db.invoices` record, paged by the
 * repository rather than by the browser: `listInvoices` hands `db.invoices.list`
 * the page, the sort, the search string and the status filter. The aging
 * buckets above the table are the whole receivable ledger, not the page on
 * show, and every age is measured against `REFERENCE_DATE` — never the clock.
 */
import { formatDate, getInitials } from "@/lib/format"
import {
  db,
  ownKey,
  REFERENCE_DATE,
  type Customer,
  type Invoice,
  type Member,
  type Page,
} from "@/lib/sample-data"

import { type InvoicesQuery, type StatusOption } from "./vocabulary"

/** One row of the table: the invoice, with the account it was sent to resolved. */
export type InvoiceRow = {
  id: string
  number: string
  customerId: string
  company: string
  contact: string
  status: Invoice["status"]
  /** Invoice total in whole dollars. */
  amount: number
  issuedAt: Date
  dueAt: Date
  paidAt?: Date
  /** Days past due at REFERENCE_DATE; zero for anything not yet due. */
  daysOverdue: number
  lines: { description: string; amount: number }[]
}

const DAY_MS = 86_400_000

// The table's column ids on the left, the repository's fields on the right.
const SORT_FIELDS: Record<string, keyof Invoice> = {
  number: "number",
  status: "status",
  amount: "amountCents",
  issuedAt: "issuedAt",
  dueAt: "dueAt",
}


/** How many days past due at REFERENCE_DATE; zero for anything still in date. */
function overdueDays(invoice: Invoice): number {
  const days = Math.floor((REFERENCE_DATE.getTime() - invoice.dueAt.getTime()) / DAY_MS)
  return days > 0 ? days : 0
}

function toRow(invoice: Invoice, customers: Map<string, Customer>): InvoiceRow {
  const customer = customers.get(invoice.customerId)
  return {
    id: invoice.id,
    number: invoice.number,
    customerId: invoice.customerId,
    company: customer?.company ?? "Unknown account",
    contact: customer?.name ?? "—",
    status: invoice.status,
    amount: invoice.amountCents / 100,
    issuedAt: invoice.issuedAt,
    dueAt: invoice.dueAt,
    paidAt: invoice.paidAt,
    daysOverdue: overdueDays(invoice),
    lines: invoice.lineItems.map((line) => ({
      description: line.description,
      amount: line.amountCents / 100,
    })),
  }
}

/** One page of invoices, filtered, searched and sorted by the repository. */
export async function listInvoices(query: InvoicesQuery): Promise<Page<InvoiceRow>> {
  const field = ownKey(SORT_FIELDS, query.sort.id) ? SORT_FIELDS[query.sort.id] : undefined
  const page = await db.invoices.list({
    page: query.page,
    pageSize: query.pageSize,
    sort: field ? { id: field, desc: query.sort.desc } : undefined,
    search: query.search,
    filters: { status: query.statuses },
  })
  // Read per page asked for, never held at module scope: an account written
  // since the server started is what the next page shows.
  const customers = new Map(db.customers.all().map((row) => [row.id, row]))
  return { ...page, rows: page.rows.map((invoice) => toRow(invoice, customers)) }
}

export type AgingBucket = {
  id: string
  label: string
  /** Amount in whole dollars. */
  amount: number
  count: number
  tone: "default" | "warning" | "danger"
}

/** Nothing here has been settled: a draft is not owed yet, and paid and void are done. */
const RECEIVABLE: Invoice["status"][] = ["open", "overdue"]

/**
 * The receivable ledger by age. Read fresh each call, because sending or
 * voiding an invoice moves it between buckets.
 */
export function agingBuckets(): AgingBucket[] {
  const buckets: AgingBucket[] = [
    { id: "current", label: "Not yet due", amount: 0, count: 0, tone: "default" },
    { id: "d1", label: "1–30 days late", amount: 0, count: 0, tone: "warning" },
    { id: "d31", label: "31–60 days late", amount: 0, count: 0, tone: "warning" },
    { id: "d61", label: "Over 60 days late", amount: 0, count: 0, tone: "danger" },
  ]

  for (const invoice of db.invoices.all()) {
    if (!RECEIVABLE.includes(invoice.status)) continue
    const days = overdueDays(invoice)
    const bucket =
      days === 0 ? buckets[0] : days <= 30 ? buckets[1] : days <= 60 ? buckets[2] : buckets[3]
    bucket.amount += invoice.amountCents / 100
    bucket.count += 1
  }

  return buckets
}

export type OverdueSummary = { count: number; amount: number; oldestDays: number }

/** What the callout at the top of the page says. */
export function overdueSummary(): OverdueSummary {
  const late = db.invoices.all().filter((invoice) => invoice.status === "overdue")
  return {
    count: late.length,
    amount: late.reduce((sum, invoice) => sum + invoice.amountCents, 0) / 100,
    oldestDays: late.reduce((worst, invoice) => Math.max(worst, overdueDays(invoice)), 0),
  }
}

const STATUS_ORDER: Invoice["status"][] = ["draft", "open", "overdue", "paid", "void"]

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

/** The status filter's options, each with how many invoices hold it now — data, so it reaches the table as a prop. */
export function statusOptions(): StatusOption[] {
  const invoices = db.invoices.all()
  return STATUS_ORDER.map((status) => ({
    value: status,
    label: titleCase(status),
    count: invoices.filter((invoice) => invoice.status === status).length,
  }))
}

/** An invoice can only be sent, or voided, while it is still owed. */
export const SENDABLE: Invoice["status"][] = ["draft", "open", "overdue"]
export const VOIDABLE: Invoice["status"][] = ["draft", "open", "overdue"]

/** The freshness line under the title, measured against REFERENCE_DATE. */
export function lastUpdated(): string {
  return `Ledger as of ${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/finance/invoices/actions.ts
"use server"

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

import {
  agingBuckets,
  listInvoices,
  overdueSummary,
  SENDABLE,
  VOIDABLE,
  type AgingBucket,
  type InvoiceRow,
  type OverdueSummary,
} from "./data"
import { DEFAULT_QUERY, type InvoicesQuery } from "./vocabulary"

/**
 * Everything the page asks the server for. Sending and voiding both move an
 * invoice between aging buckets, so each returns the recomputed ledger with its
 * `Result` — the numbers above the table are never left describing the state
 * before the change.
 */

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

/** One page of invoices for the query the reader has built up. */
export async function fetchInvoices(query: InvoicesQuery): Promise<Page<InvoiceRow>> {
  return listInvoices(tableQuery(query, DEFAULT_QUERY))
}

export type LedgerState = { buckets: AgingBucket[]; overdue: OverdueSummary }

/** The aging buckets and the overdue line, as they stand right now. */
export async function fetchLedger(): Promise<LedgerState> {
  return { buckets: agingBuckets(), overdue: overdueSummary() }
}

export type InvoiceMutation = Result<{ id: string; status: string; ledger: LedgerState }>

/**
 * Sends the invoice — issuing a draft, or chasing one already out. A paid or
 * voided invoice has nothing left to send.
 */
export async function sendInvoice(id: string): Promise<InvoiceMutation> {
  const invoice = await db.invoices.get(id)
  if (!invoice) {
    return { ok: false, error: { code: "not_found", message: `No invoice with id ${id}.` } }
  }
  if (!SENDABLE.includes(invoice.status)) {
    return {
      ok: false,
      error: {
        code: "not_sendable",
        message: `${invoice.number} is ${invoice.status}, so there is nothing left to send.`,
      },
    }
  }

  // Issuing a draft is what puts it on the ledger; a resend changes nothing.
  const updated =
    invoice.status === "draft" ? await db.invoices.update(id, { status: "open" }) : { ok: true as const, data: invoice }
  if (!updated.ok) return updated

  return { ok: true, data: { id, status: updated.data.status, ledger: await fetchLedger() } }
}

/** Writes the invoice off. Money already collected cannot be un-collected here. */
export async function voidInvoice(id: string): Promise<InvoiceMutation> {
  const invoice = await db.invoices.get(id)
  if (!invoice) {
    return { ok: false, error: { code: "not_found", message: `No invoice with id ${id}.` } }
  }
  if (!VOIDABLE.includes(invoice.status)) {
    return {
      ok: false,
      error: {
        code: "not_voidable",
        message:
          invoice.status === "paid"
            ? `${invoice.number} has been paid; refund it rather than voiding it.`
            : `${invoice.number} is already void.`,
      },
    }
  }

  const updated = await db.invoices.update(id, { status: "void" })
  if (!updated.ok) return updated
  return { ok: true, data: { id, status: updated.data.status, ledger: await fetchLedger() } }
}
app/finance/invoices/vocabulary.ts
/**
 * The ledger'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. It sits at the page root,
 * not in `components/`, because `data.ts` and the actions read the query too,
 * and a server file never imports from a block's `components/` folder. The
 * counts beside each status filter are data, and come to the table as props.
 */
import type { SortSpec } from "@/lib/sample-data"

export type InvoicesQuery = {
  page: number
  pageSize: number
  sort: SortSpec
  search: string
  statuses: string[]
}

export const PAGE_SIZE = 10

export const DEFAULT_QUERY: InvoicesQuery = {
  page: 1,
  pageSize: PAGE_SIZE,
  sort: { id: "dueAt", desc: false },
  search: "",
  statuses: [],
}

/** A status the table can be narrowed to, with how many invoices hold it. */
export type StatusOption = { value: string; label: string; count: number }

/** Statuses `StatusBadge` has no default for. */
export const STATUS_MAP = { open: "info", draft: "neutral", void: "neutral" } as const
app/finance/invoices/components/aging-buckets.tsx
import { formatCurrency, formatNumber } from "@/lib/format"
import { StatCard } from "@/components/ui/stat-card"
import { StatCardGroup } from "@/components/ui/stat-card-group"

import { type AgingBucket } from "../data"

const TONE_CLASS = {
  default: "",
  warning: "text-warning",
  danger: "text-danger",
} as const

/**
 * The receivable ledger by age — the whole book, not the page on show. Ages are
 * measured against REFERENCE_DATE, so the buckets and the "days late" column
 * below them always agree.
 */
export function AgingBuckets({ buckets }: { buckets: AgingBucket[] }) {
  return (
    <StatCardGroup
      data-widget="widget-finance-invoices-aging-buckets"
      columns={4}
      divided
      role="group"
      aria-label="Receivables by age"
    >
      {buckets.map((bucket) => (
        <StatCard
          key={bucket.id}
          label={bucket.label}
          value={<span className={TONE_CLASS[bucket.tone]}>{formatCurrency(bucket.amount)}</span>}
          description={`${formatNumber(bucket.count)} invoice${bucket.count === 1 ? "" : "s"}`}
        />
      ))}
    </StatCardGroup>
  )
}
app/finance/invoices/components/invoice-columns.tsx
"use client"

import { BanIcon, SendIcon } from "lucide-react"

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

import { type InvoiceRow } from "../data"
import { STATUS_MAP } from "../vocabulary"

export type InvoiceColumnHandlers = {
  onSend: (row: InvoiceRow) => void
  onVoid: (row: InvoiceRow) => void
}

/**
 * The row menu carries the page's two mutations. Both are offered on every
 * row — the server owns which invoices they apply to, and it says so out loud
 * when the answer is no, which is more use than a greyed-out item.
 */
export function invoiceColumns({ onSend, onVoid }: InvoiceColumnHandlers): DataTableColumnDef<InvoiceRow>[] {
  return [
    {
      accessorKey: "number",
      header: ({ column }) => <DataTableColumnHeader column={column} title="Invoice" />,
      cell: ({ row }) => <span className="font-mono text-xs">{row.original.number}</span>,
      meta: { label: "Invoice" },
    },
    {
      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: "issuedAt",
      header: ({ column }) => <DataTableColumnHeader column={column} title="Issued" />,
      cell: ({ row }) => <DateCell date={row.original.issuedAt} className="text-muted-foreground" />,
      meta: { align: "right", label: "Issued" },
    },
    {
      accessorKey: "dueAt",
      header: ({ column }) => <DataTableColumnHeader column={column} title="Due" />,
      cell: ({ row }) => <DateCell date={row.original.dueAt} />,
      meta: { align: "right", label: "Due" },
    },
    {
      accessorKey: "daysOverdue",
      header: ({ column }) => <DataTableColumnHeader column={column} title="Late" />,
      enableSorting: false,
      // Days past due at REFERENCE_DATE, and only where it is still owed —
      // a paid invoice's due date is history, not a debt.
      cell: ({ row }) => {
        const owed = row.original.status === "open" || row.original.status === "overdue"
        if (!owed || row.original.daysOverdue === 0) {
          return <span className="text-muted-foreground">—</span>
        }
        return (
          <span className="tabular-nums text-danger">{row.original.daysOverdue} days</span>
        )
      },
      meta: { align: "right", label: "Late" },
    },
    {
      accessorKey: "amount",
      header: ({ column }) => <DataTableColumnHeader column={column} title="Amount" />,
      cell: ({ row }) => <CurrencyCell value={row.original.amount} />,
      meta: { align: "right", label: "Amount" },
    },
    {
      id: "actions",
      size: 44,
      enableSorting: false,
      enableHiding: false,
      cell: ({ row }) => (
        <DataTableRowActions
          label={`Actions for ${row.original.number}`}
          actions={[
            { label: "Send invoice", icon: <SendIcon />, onSelect: () => onSend(row.original) },
            {
              label: "Void invoice",
              icon: <BanIcon />,
              destructive: true,
              separatorBefore: true,
              onSelect: () => onVoid(row.original),
            },
          ]}
        />
      ),
    },
  ]
}
app/finance/invoices/components/invoices-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 StatusOption } from "../vocabulary"

export type InvoicesToolbarProps<TData extends RowData> = {
  table: DataTableInstance<TData>
  search: string
  onSearchChange: (value: string) => void
  statuses: string[]
  /** The status filter's options, with their counts, from the server page. */
  statusOptions: StatusOption[]
  onStatusesChange: (value: string[]) => void
  onClearFilters: () => void
  activeCount: number
  /** "1–10 of 300 invoices", built from the page the server sent back. */
  summary: string
}

/**
 * Everything above the rows. The search box matches an invoice number or an
 * account id, because those are the two things `db.invoices` searches on.
 */
export function InvoicesToolbar<TData extends RowData>({
  table,
  search,
  onSearchChange,
  statuses,
  statusOptions,
  onStatusesChange,
  onClearFilters,
  activeCount,
  summary,
}: InvoicesToolbarProps<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 invoice or account id"
        aria-label="Search invoices"
        className="w-full max-w-64"
      />

      <FilterMenu
        label="Status"
        size="sm"
        options={statusOptions}
        value={statuses}
        onValueChange={onStatusesChange}
      />

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

      <DataTableViewOptions table={table} />
    </FilterBar>
  )
}
app/finance/invoices/components/invoices-view.tsx
"use client"

import * as React from "react"
import { AlertTriangleIcon } from "lucide-react"

import { useServerTable, type ServerTableControls } from "@/hooks/use-server-table"
import { formatCurrency, formatNumber } from "@/lib/format"
import { type Page } from "@/lib/sample-data"
import { Callout } from "@/components/ui/callout"
import { ConfirmDialog } from "@/components/ui/confirm-dialog"
import { DataTablePagination, DataTableRows } from "@/components/ui/data-table"

import { fetchInvoices, sendInvoice, voidInvoice, type LedgerState } from "../actions"
import { type InvoiceRow } from "../data"
import { DEFAULT_QUERY, type InvoicesQuery, type StatusOption } from "../vocabulary"
import { AgingBuckets } from "./aging-buckets"
import { invoiceColumns } from "./invoice-columns"
import { InvoicesToolbar } from "./invoices-toolbar"

type Notice = { tone: "success" | "danger"; message: string }

/**
 * The billing ledger. The aging buckets, the overdue callout and the table all
 * move together: sending or voiding an invoice returns the recomputed ledger
 * with its `Result`, so the totals above the table are never left describing
 * the state before the change. Voiding is behind a confirmation, because it is
 * the one action here that writes an invoice off. useServerTable holds the
 * query and asks for each page; both mutations re-read through its refresh(),
 * so a re-read can never land over the answer to a question asked after it.
 */
export function InvoicesView({
  titleId,
  initial,
  ledger: initialLedger,
  statusOptions,
}: {
  /** The page's h1 id, which names the table. */
  titleId: string
  initial: Page<InvoiceRow>
  ledger: LedgerState
  /** The status filter's options, each with how many invoices hold it. */
  statusOptions: StatusOption[]
}) {
  const [ledger, setLedger] = React.useState(initialLedger)
  const [notice, setNotice] = React.useState<Notice | null>(null)
  const [voiding, setVoiding] = React.useState<InvoiceRow | null>(null)
  // Send lives in the row menu, so the column defs hold it, and it re-reads
  // through the table those defs build: the defs are built from the table's
  // own refresh, once.
  const columns = React.useCallback(
    ({ refresh }: ServerTableControls<InvoicesQuery, Page<InvoiceRow>>) =>
      invoiceColumns({
        onSend: async (row) => {
          const result = await sendInvoice(row.id)
          if (!result.ok) return setNotice({ tone: "danger", message: result.error.message })
          setLedger(result.data.ledger)
          await refresh()
          setNotice({ tone: "success", message: `${row.number} is on its way to ${row.company}.` })
        },
        onVoid: setVoiding,
      }),
    []
  )

  const { table, query, pending, apply, refresh, summary } = useServerTable({
    initialQuery: DEFAULT_QUERY,
    initialPage: initial,
    fetchPage: fetchInvoices,
    columns,
    getRowId: (row) => row.id,
    defaultSort: DEFAULT_QUERY.sort,
    // The page has always counted "invoices", one or many, and its tests read it so.
    noun: ["invoices", "invoices"],
    // A new question — a filter, the sort, a page — moves past the last outcome.
    onApply: () => setNotice(null),
  })

  async function runVoid(row: InvoiceRow) {
    const result = await voidInvoice(row.id)
    setVoiding(null)
    if (!result.ok) return setNotice({ tone: "danger", message: result.error.message })
    setLedger(result.data.ledger)
    await refresh()
    setNotice({
      tone: "success",
      message: `${row.number} has been written off; ${formatCurrency(row.amount)} left the ledger.`,
    })
  }

  return (
    <div className="flex flex-col gap-4">
      {ledger.overdue.count > 0 ? (
        <Callout
          variant="danger"
          icon={<AlertTriangleIcon />}
          title={`${formatNumber(ledger.overdue.count)} invoices are overdue`}
        >
          {formatCurrency(ledger.overdue.amount)} is past its due date, the oldest by{" "}
          {formatNumber(ledger.overdue.oldestDays)} days.
        </Callout>
      ) : null}

      <AgingBuckets buckets={ledger.buckets} />

      <InvoicesToolbar
        table={table}
        search={query.search}
        onSearchChange={(search) => apply({ search })}
        statuses={query.statuses}
        statusOptions={statusOptions}
        onStatusesChange={(statuses) => apply({ statuses })}
        onClearFilters={() => apply({ search: "", statuses: [] })}
        activeCount={query.statuses.length + (query.search ? 1 : 0)}
        summary={summary}
      />

      {notice ? (
        <Callout
          role={notice.tone === "danger" ? "alert" : "status"}
          variant={notice.tone}
          title={notice.tone === "danger" ? "That did not run" : "Done"}
        >
          {notice.message}
        </Callout>
      ) : null}

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

      <ConfirmDialog
        variant="destructive"
        open={voiding !== null}
        onOpenChange={(next) => {
          if (!next) setVoiding(null)
        }}
        title={voiding ? `Void ${voiding.number}?` : "Void invoice?"}
        description="A voided invoice stays on the record but stops being owed. This cannot be undone."
        confirmText="Void invoice"
        onConfirm={() => (voiding ? runVoid(voiding) : undefined)}
      />
    </div>
  )
}