Latest transactions
The last eight rows that settled or failed, newest first, each with who, how much in its own currency and which way, whether it went through, and when; reads latestTransactions().
Preview
import { cn } from "@/lib/utils"
import { formatDate } from "@/lib/format"
import { type Transaction } from "@/lib/sample-data"
import { SimpleTable } from "@/components/ui/simple-table"
import { StatusBadge } from "@/components/ui/status-badge"
import { Widget } from "@/components/ui/widget"
import { KIND_LABELS, signed, STATUS_LABELS, STATUS_MAP } from "./balances-vocabulary"
import { Counterparty } from "./counterparty"
/**
* The last eight rows that settled or failed, newest first: who, how much in
* its own currency and which way, whether it went through, and when. Money in
* takes the positive tone; money out stays in ink with a true minus; a failed
* row's amount is muted, because it never moved.
*/
export function LatestTransactions({ rows }: { rows: Transaction[] }) {
return (
<Widget
data-widget="widget-finance-balances-latest-transactions"
title="Latest transactions"
description="The last eight that settled or failed"
contentClassName="p-0"
className="h-full"
>
<SimpleTable
size="sm"
rows={rows}
rowKey="id"
className="rounded-none border-0"
columns={[
{
key: "counterparty",
header: "Counterparty",
cell: (row) => (
<Counterparty
row={row}
detail={
// On a phone the Status and Date columns step aside, so what
// they say rides under the name: the date always, and the
// status whenever the money did not simply settle — a muted
// amount alone would say "failed" in colour alone.
<span data-slot="counterparty-detail">
{KIND_LABELS[row.kind]}
{row.status === "completed" ? null : (
<span className="font-medium text-foreground sm:hidden"> · {STATUS_LABELS[row.status]}</span>
)}
<span className="sm:hidden"> · {formatDate(row.at, "medium", { timeZone: "UTC" })}</span>
</span>
}
/>
),
},
{
key: "amountCents",
header: "Amount",
align: "right",
cell: (row) => (
<span
className={cn(
"whitespace-nowrap tabular-nums",
row.status === "failed" ? "text-muted-foreground" : row.amountCents > 0 && "text-success"
)}
>
{signed(row.amountCents, row.currency)}
</span>
),
},
{
key: "status",
header: "Status",
className: "hidden sm:table-cell",
cell: (row) => <StatusBadge status={row.status} map={STATUS_MAP} label={STATUS_LABELS[row.status]} />,
},
{
key: "at",
header: "Date",
align: "right",
className: "hidden sm:table-cell",
cell: (row) => (
<time dateTime={row.at.toISOString()} className="whitespace-nowrap text-muted-foreground">
{formatDate(row.at, "medium", { timeZone: "UTC" })}
</time>
),
},
]}
/>
</Widget>
)
}Install
$
npx shadcn@latest add @vibra/widget-finance-balances-latest-transactionsNeeds the @vibra registry in your components.json — set it up once.
Source
import { cn } from "@/lib/utils"
import { formatDate } from "@/lib/format"
import { type Transaction } from "@/lib/sample-data"
import { SimpleTable } from "@/components/ui/simple-table"
import { StatusBadge } from "@/components/ui/status-badge"
import { Widget } from "@/components/ui/widget"
import { KIND_LABELS, signed, STATUS_LABELS, STATUS_MAP } from "./balances-vocabulary"
import { Counterparty } from "./counterparty"
/**
* The last eight rows that settled or failed, newest first: who, how much in
* its own currency and which way, whether it went through, and when. Money in
* takes the positive tone; money out stays in ink with a true minus; a failed
* row's amount is muted, because it never moved.
*/
export function LatestTransactions({ rows }: { rows: Transaction[] }) {
return (
<Widget
data-widget="widget-finance-balances-latest-transactions"
title="Latest transactions"
description="The last eight that settled or failed"
contentClassName="p-0"
className="h-full"
>
<SimpleTable
size="sm"
rows={rows}
rowKey="id"
className="rounded-none border-0"
columns={[
{
key: "counterparty",
header: "Counterparty",
cell: (row) => (
<Counterparty
row={row}
detail={
// On a phone the Status and Date columns step aside, so what
// they say rides under the name: the date always, and the
// status whenever the money did not simply settle — a muted
// amount alone would say "failed" in colour alone.
<span data-slot="counterparty-detail">
{KIND_LABELS[row.kind]}
{row.status === "completed" ? null : (
<span className="font-medium text-foreground sm:hidden"> · {STATUS_LABELS[row.status]}</span>
)}
<span className="sm:hidden"> · {formatDate(row.at, "medium", { timeZone: "UTC" })}</span>
</span>
}
/>
),
},
{
key: "amountCents",
header: "Amount",
align: "right",
cell: (row) => (
<span
className={cn(
"whitespace-nowrap tabular-nums",
row.status === "failed" ? "text-muted-foreground" : row.amountCents > 0 && "text-success"
)}
>
{signed(row.amountCents, row.currency)}
</span>
),
},
{
key: "status",
header: "Status",
className: "hidden sm:table-cell",
cell: (row) => <StatusBadge status={row.status} map={STATUS_MAP} label={STATUS_LABELS[row.status]} />,
},
{
key: "at",
header: "Date",
align: "right",
className: "hidden sm:table-cell",
cell: (row) => (
<time dateTime={row.at.toISOString()} className="whitespace-nowrap text-muted-foreground">
{formatDate(row.at, "medium", { timeZone: "UTC" })}
</time>
),
},
]}
/>
</Widget>
)
}import { ArrowLeftRightIcon, LandmarkIcon, ReceiptIcon, type LucideIcon } from "lucide-react"
import { formatCurrency } from "@/lib/format"
import { type StatusVariant } from "@/components/ui/status-badge"
/**
* The words, marks and money formatting this page uses. Vocabulary, not
* data: it lives beside the islands that print it, because `data.ts` reads
* `db` and must never reach the browser.
*/
export const CURRENCY_NAMES: Record<string, string> = {
USD: "US dollar",
EUR: "Euro",
GBP: "British pound",
JPY: "Japanese yen",
CHF: "Swiss franc",
}
/** Minor units in a major one: a hundred cents, or one yen — which has no minor unit. */
const MINOR: Record<string, number> = { USD: 100, EUR: 100, GBP: 100, JPY: 1, CHF: 100 }
/** An amount held in minor units, in its own currency: 1234567 USD cents → "$12,345.67"; 480000 yen → "¥480,000". */
export function money(minor: number, currency: string): string {
return formatCurrency(minor / (MINOR[currency] ?? 100), currency)
}
/** A movement with its direction: "+$1.00" in, "−$1.00" out (a true minus), "$0.00" for none. */
export function signed(minor: number, currency: string): string {
if (minor === 0) return money(0, currency)
return `${minor < 0 ? "−" : "+"}${money(Math.abs(minor), currency)}`
}
/**
* A rate as a desk quotes it: to four places, or to two when it is a price
* in yen — 144.21 yen to the dollar — and to four figures when a single yen
* is the unit, which two places would round away to nothing.
*/
export function quoteRate(rate: number, base: string, quote: string): string {
if (quote === "JPY") return rate.toFixed(2)
if (base === "JPY") return rate.toPrecision(4)
return rate.toFixed(4)
}
export const KIND_LABELS: Record<string, string> = {
charge: "Charge",
payout: "Payout",
refund: "Refund",
transfer: "Transfer",
fee: "Fee",
}
/** The glyph for a counterparty that is not a person: a bank for a payout, a receipt for a fee, arrows for a transfer. */
export const KIND_ICONS: Record<string, LucideIcon> = {
payout: LandmarkIcon,
fee: ReceiptIcon,
transfer: ArrowLeftRightIcon,
}
export const STATUS_LABELS: Record<string, string> = {
completed: "Completed",
failed: "Failed",
pending: "Pending",
scheduled: "Scheduled",
}
export const STATUS_MAP: Record<string, StatusVariant> = {
completed: "success",
failed: "danger",
pending: "warning",
scheduled: "primary",
}import type * as React from "react"
import { cn } from "@/lib/utils"
import { type Transaction } from "@/lib/sample-data"
import { UserCell } from "@/components/ui/user-cell"
import { KIND_ICONS, KIND_LABELS } from "./balances-vocabulary"
/**
* Who a row's money came from or went to. A customer is drawn with their
* face; a bank account, a fee or another balance with its kind's glyph on a
* quiet tile, because a bank has no face and inventing one would be a lie.
*/
export function Counterparty({
row,
detail,
className,
}: {
row: Transaction
/** The quieter line under the name; the row's kind when left out. */
detail?: React.ReactNode
className?: string
}) {
const description = detail ?? KIND_LABELS[row.kind]
if (row.avatarUrl) {
return <UserCell size="sm" name={row.counterparty} description={description} src={row.avatarUrl} className={className} />
}
const Icon = KIND_ICONS[row.kind] ?? KIND_ICONS.fee
return (
<div className={cn("flex min-w-0 items-center gap-2", className)}>
<span
data-slot="kind-glyph"
aria-hidden="true"
className="grid size-6 shrink-0 place-items-center rounded-full bg-secondary text-muted-foreground [&_svg]:size-3.5"
>
<Icon />
</span>
<span className="flex min-w-0 flex-col leading-tight">
<span className="truncate text-xs font-medium">{row.counterparty}</span>
<span className="truncate text-2xs text-muted-foreground">{description}</span>
</span>
</div>
)
}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 Balances page