Blocks

Billing History

A four-state invoice list — per-currency money through Intl, a six-state lifecycle with partial-refund arithmetic, real filters, pagination, and PDF links that never render as dead buttons.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { AlertCircle, ChevronDown, ChevronLeft, ChevronRight, Download, ReceiptText, Search, SearchX } from "lucide-react"
import { cn } from "@/lib/utils"
import { INVOICE_STATUS_ORDER } from "./billing-history.contract"
import type { BillingHistoryData, BillingHistoryItem, InvoiceStatus } from "./billing-history.contract"

/* ------------------------------------------------------------------ constants */

/** "All" sentinel for the status `<select>`: an empty string is indistinguishable from "nothing selected". */
const ALL = "__all__"

const STATUS_LABEL: Record<InvoiceStatus, string> = {

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/billing-history.json

Prompt

Build a React + TypeScript + Tailwind "BillingHistory" block (lucide-react
icons) with zod.

Contract
- A zod schema is the single source of truth:
  { status: "loading" | "empty" | "error" | "ready";
    items: { id, number, description?, issuedAt, dueAt?, currency,
             total, refundedAmount?, status, pdf? }[] }.
- Money is an INTEGER in the currency's minor unit (9900 = $99.00,
  12800 = JPY 12,800 — the yen has no minor unit). Never floats: a billing
  row one cent off is a support ticket. Each row carries its OWN ISO-4217
  currency, because an account that changed region has a genuinely mixed
  list.
- issuedAt / dueAt are absolute instants (ISO 8601 with Z or a numeric
  offset), never a bare calendar date — which day an instant falls on is a
  time-zone decision.
- refundedAmount is a POSITIVE magnitude, and the schema refines it:
  refundedAmount <= total, and refundedAmount > 0 <=> status "refunded".
  Partial vs full is derived (refundedAmount < total), never stored twice.
- pdf is a discriminated union — { state: "ready", url } |
  { state: "pending", reason } — with the url refined to reject "" and "#".
  Omit pdf entirely when no document exists. Three-way, so "no document"
  can never be confused with "document not ready".
- Props = z.infer of the schema plus locale ("en-US"), timeZone ("UTC"),
  pageSize (10), heading (ReactNode | null), onRetry?, className, and the
  rest spread onto the root section; forwardRef to that section.

Behavior
- Four first-class branches: loading (skeleton rows mirroring the five real
  columns), empty, error (message + "Try again" only when onRetry exists),
  ready. status "ready" with zero items degrades to empty instead of
  rendering a table with a "0 of 0" paginator.
- Money: one Intl.NumberFormat per currency, cached. Take the minor-unit
  exponent from resolvedOptions().maximumFractionDigits — never hard-code a
  /100 divisor, or a zero-decimal currency renders 100x wrong. Never build
  a symbol + toFixed(2) string: in de-DE the symbol trails the number, the
  separators swap, and where the minus sign of a refund goes is a locale
  decision. Normalise -0 so a zero prints "$0.00", never "-$0.00". A
  malformed locale or currency code makes Intl throw — fall back instead of
  taking the tree down.
- Multi-currency: subtotal per currency (invoiced / refunded / net) and
  never add two currencies together; say so in the footer. Converting would
  need a rate and the date it was taken, neither of which is in the
  contract. Draft and void invoices are excluded from those subtotals —
  neither was ever owed — and the footer states that too.
- Lifecycle: draft to open to paid to refunded; open to past_due to paid;
  draft/open to void. Six badges whose treatments differ without hue (solid,
  tint, destructive tint, neutral fill, dashed outline, struck through); the
  wording is the primary channel. The status cell also carries the evidence:
  "due <date>" while open, "was due <date>" once past due, and
  "-<refund> · net <kept>" once refunded — which keeps the money column
  clean while still answering "how much came back?".
- Dates render as a time element with dateTime={iso}, formatted by
  Intl.DateTimeFormat in the explicit timeZone prop. Never getDate() on the
  visitor's clock: 2026-01-01T00:30+09:00 is Dec 31 in UTC and Jan 1 in
  Tokyo, and a support agent and a customer must not read different days off
  one invoice.
- Filters: a search box (number + description) and a status select. Both
  really narrow the result set, both reset to page 1, and the option counts
  are computed from the data. All six statuses are always offered, including
  the ones with zero rows — otherwise a refresh into data lacking that
  status leaves a controlled select with no matching option and an invisible
  active filter. Filtering to zero shows its OWN panel ("no invoices match
  these filters" plus a Clear filters button), never the same panel as an
  account that has no invoices.
- Pagination: pageSize is clamped to at least 1 with no upper cap; the
  current page is clamped during render, so filtering while on page 6 lands
  on a page that exists in the same commit. Prev/Next use aria-disabled plus
  a guard in the handler, NOT the disabled attribute — the browser blurs a
  natively disabled element the instant it becomes disabled, so paging to
  the last page with the keyboard would drop focus to the body.
- PDF column: a real anchor when the document exists; the pending reason as
  plain text while it is being generated; an em dash when there is none.
  Nothing that cannot act is drawn as a control.

Rendering & styling
- A real table with th scope="col" headers and the invoice number as
  th scope="row", named by aria-labelledby pointing at the visible card
  heading (no caption element, so the copy stays clean).
- Every cell must serialise to ONE line of the clipboard's text/plain
  flavour: a display:block child or a br inside a cell makes Chromium emit
  that fragment as its own line, which turns one invoice into six lines when
  pasted into a spreadsheet and shifts every column after it. Keep secondary
  text inline. Put NOTHING sr-only inside the table either — hidden helper
  text is copied too, glued to the visible value ("Paidstatus: paid").
  Accessible names for the icon links go on aria-label, which is not copied.
- Amounts are right-aligned, tabular-nums and whitespace-nowrap; a column of
  money is read by comparing digit positions.
- The invoice column uses wrap-anywhere (not break-words — only `anywhere`
  lowers the column's min-content width) with a min-w floor, so a long
  reverse-charge number wraps instead of widening the table while the column
  still cannot collapse to one glyph per line. The table sits in an
  overflow-x-auto wrapper: five columns of money and dates have a real
  minimum width, so on a phone it scrolls sideways instead of being clipped.
- Semantic tokens only: bg-card, border, text-muted-foreground, primary for
  the paid/open treatments, destructive for past due. Skeletons carry
  motion-reduce:animate-none.

Customization levers
- Columns: drop `description` for a denser table, or split the refund detail
  into its own column — keep every cell single-line if the copy-to-
  spreadsheet property matters to you.
- Sorting: rows render in the order supplied, so the host owns ordering; add
  a controlled sort prop if you need column sorting (or reach for
  display/data-table instead).
- Density: pageSize is a prop, and py-2.5 on the cells is the row rhythm.
- Money policy: change the excluded-status rule (draft/void) if your billing
  system means something else by them, or drop the subtotal block entirely
  for a pure list.
- Localisation: locale and timeZone are props — pass the account's own zone
  instead of UTC when you store one, and switch dateStyle to "long" for a
  wider date column.
- Lifecycle: the six-status label map and its badge treatments are two
  Records at the top of the file; add or rename states there.

Concepts

  • Minor-unit integers — every amount is a whole number of the currency's smallest unit, and how many digits that is comes from Intl, not from a hard-coded /100. Divide by 100 unconditionally and a 12,800-yen invoice renders as 128 — a 100× error in the one place a product cannot afford one.
  • Per-currency subtotals — a mixed list is subtotalled once per currency and never added across them, because converting needs a rate and the date it was taken. The footer says so, so nobody reads the missing grand total as an oversight.
  • Partial-refund arithmetic — the badge says "Partially refunded" only when refundedAmount < total, and the row shows both what came back and what stayed; the two always add up to the figure in the money column.
  • Absolute instant + explicit zone — the contract takes an instant, the component renders it in a zone you name. 2026-01-01T00:30+09:00 is December 31 in UTC and January 1 in Tokyo; deriving the day from the visitor's clock makes a support agent and a customer read different dates off one invoice.
  • Clipboard-clean cells — every cell renders on a single line and nothing inside the table is sr-only, so selecting the table and pasting into a spreadsheet gives clean tab-separated rows instead of one invoice smeared over six lines with hidden helper text glued to the values.
  • No control that cannot act — an invoice whose PDF is still rendering prints the reason; one with no document shows an em dash. A disabled-looking download button that silently does nothing is the defect the three-way pdf shape exists to prevent.
  • Filtered-empty is not empty — "you have no invoices" and "your filters match none of your invoices" are different facts and get different panels; only the second one owes the reader a way back.

On This Page