Skip to contentVibraUI

Receivables by age

The whole receivable ledger by age — not yet due, one to thirty, thirty-one to sixty and over sixty days late — each with its sum and how many invoices; reads agingBuckets().

Preview

Install

npx shadcn@latest add @vibra/widget-finance-invoices-aging-buckets

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

Source

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

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 Billing ledger page