A double-entry ledger with separate debit and credit columns, a computed running-balance column, hairline dividers, tabular figures, and a sticky header.
npx shadcn@latest add @paragon/ledger-table"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
export interface LedgerEntry {
/** Unique id for the row key. */
id: string;
/** Posting date, pre-formatted, e.g. "Jul 03". */
date: string;
/** Description / memo line. */
description: string;
/** Optional reference (invoice #, check #). */
reference?: string;
/** Debit amount in major units, or null when this line is a credit. */
debit?: number | null;
/** Credit amount in major units, or null when this line is a debit. */
credit?: number | null;
}
export interface LedgerTableProps extends React.ComponentProps<"div"> {
entries: LedgerEntry[];
/** Opening balance the running column starts from. */
openingBalance?: number;
/** ISO currency code for formatting. */
currency?: string;
/** Intl locale. */
locale?: string;
/** Constrains height to activate the sticky header. */
maxHeight?: number | string;
}
/**
* A double-entry ledger with separate debit and credit columns and a computed
* running-balance column. The header sticks to the top of the scroll area so
* column meaning survives long registers; rows separate with hairline dividers
* and every figure is tabular so the columns align to the decimal. The running
* balance is derived from the opening balance and each line's net — no motion,
* because a ledger is read hundreds of times a day.
*/
export function LedgerTable({
entries,
openingBalance = 0,
currency = "USD",
locale = "en-US",
maxHeight = 360,
className,
...props
}: LedgerTableProps) {
const fmt = React.useMemo(
() =>
new Intl.NumberFormat(locale, {
style: "currency",
currency,
}),
[locale, currency],
);
const rows = React.useMemo(() => {
let balance = openingBalance;
return entries.map((e) => {
const debit = e.debit ?? 0;
const credit = e.credit ?? 0;
balance += credit - debit;
return { ...e, balance };
});
}, [entries, openingBalance]);
const closing = rows.length ? rows[rows.length - 1].balance : openingBalance;
return (
<div
className={cn(
"w-full overflow-auto rounded-xl bg-card shadow-border",
className,
)}
style={{ maxHeight }}
{...props}
>
<table className="w-full border-separate border-spacing-0 text-sm">
<thead>
<tr>
<th className="sticky top-0 z-10 border-b bg-card px-4 py-2.5 text-left text-xs font-medium whitespace-nowrap text-muted-foreground">
Date
</th>
<th className="sticky top-0 z-10 border-b bg-card px-4 py-2.5 text-left text-xs font-medium whitespace-nowrap text-muted-foreground">
Description
</th>
<th className="sticky top-0 z-10 border-b bg-card px-4 py-2.5 text-right text-xs font-medium whitespace-nowrap text-muted-foreground">
Debit
</th>
<th className="sticky top-0 z-10 border-b bg-card px-4 py-2.5 text-right text-xs font-medium whitespace-nowrap text-muted-foreground">
Credit
</th>
<th className="sticky top-0 z-10 border-b bg-card px-4 py-2.5 text-right text-xs font-medium whitespace-nowrap text-muted-foreground">
Balance
</th>
</tr>
</thead>
<tbody className="[&>tr:last-child>td]:border-b-0">
{rows.map((row) => (
<tr
key={row.id}
className="transition-colors duration-(--duration-fast) hover:bg-muted/40"
>
<td className="border-b px-4 py-2.5 align-top whitespace-nowrap tabular-nums text-muted-foreground">
{row.date}
</td>
<td className="border-b px-4 py-2.5 align-top">
<span className="block">{row.description}</span>
{row.reference && (
<span className="block text-[12px] tabular-nums text-muted-foreground">
{row.reference}
</span>
)}
</td>
<td className="border-b px-4 py-2.5 text-right align-top tabular-nums">
{row.debit ? (
<span className="text-foreground">{fmt.format(row.debit)}</span>
) : (
<span className="text-muted-foreground/40">—</span>
)}
</td>
<td className="border-b px-4 py-2.5 text-right align-top tabular-nums">
{row.credit ? (
<span className="text-success">{fmt.format(row.credit)}</span>
) : (
<span className="text-muted-foreground/40">—</span>
)}
</td>
<td className="border-b px-4 py-2.5 text-right align-top font-medium tabular-nums">
{fmt.format(row.balance)}
</td>
</tr>
))}
</tbody>
<tfoot>
<tr>
<td
colSpan={4}
className="border-t px-4 py-2.5 text-right text-xs font-medium text-muted-foreground"
>
Closing balance
</td>
<td className="border-t px-4 py-2.5 text-right text-sm font-semibold tabular-nums">
{fmt.format(closing)}
</td>
</tr>
</tfoot>
</table>
</div>
);
}