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
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>
)
}Install
$
npx shadcn@latest add @vibra/widget-finance-invoices-aging-bucketsNeeds the @vibra registry in your components.json — set it up once.
Source
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>
)
}/**
* 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 }))
}/**
* 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 constIts 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