/finance/balancesBalances
The store's money by currency: a card per balance with what is available and pending and a pill on thirty days ago, a verification callout, the latest and upcoming transactions with a face on every person, an exchange-rate chart with period chips, and a converter that works as you type.
Every balance is worked out, never typed in: the ledger's opening entry for the currency plus every completed db.transactions row in it, amounts held in the currency's minor unit — whole yen for JPY. Pending rows are said beside the balance, signed, and what is available is the balance less the money already on its way out; the pill measures the balance against where it stood thirty days before REFERENCE_DATE, and the line under the title totals the four in dollars at the latest rates. The verification callout links to /settings and its dismiss is client state only, so it returns on the next visit. The latest list is the last eight rows that settled or failed, newest first — a transfer shows as its two legs, out of one balance and into the other — and on a phone, where the Status and Date columns step aside, the date and any status but completed ride under the name; the upcoming list is every scheduled row after REFERENCE_DATE, soonest first, with how many days off it is; a customer is drawn with their avatarFor face and a bank, a fee or another balance with its kind's lucide glyph. The chart reads db.fxRates for the chosen pair over 1W, 1M, 3M or 1Y — seven to 365 daily points — as a grey line on LineChart's fitted axis (baseline="auto"), since a rate is read by how it moves and a zero floor would flatten it, with the accent kept for a rule at the latest rate; the chips are ChartTimeRange's toggles, the chosen one on the kit's selected fill. The converter crosses every pair through the dollar from the same latest rates, so it and the chart never disagree, and quotes its rate line as the chart does — two places for a price in yen, four figures for a single yen; it converts as you type, and an amount that is not a number or is below zero is marked on the field with the reason. Read-only apart from the sign-out: nothing here writes. Composes AppShell, PageHeader, Callout, StatCardGroup, StatCard, DashboardGrid, Widget, SimpleTable, StatusBadge, UserCell, ChartTimeRange, LineChart, MetricDelta, NativeSelect, Field, FieldLabel, FieldError, Input, Button, Counterparty (block-local) and CurrencyConverter (block-local).
Preview
import { REFERENCE_DATE } from "@/lib/sample-data"
import { AppShell } from "@/components/ui/app-shell"
import { DashboardGrid, DashboardGridItem } from "@/components/ui/dashboard-grid"
import { PageHeader } from "@/components/ui/page-header"
import { signOut } from "./actions"
import { BalanceCards } from "./components/balance-cards"
import { CurrencyConverter } from "./components/currency-converter"
import { ExchangeRates } from "./components/exchange-rates"
import { LatestTransactions } from "./components/latest-transactions"
import { UpcomingTransactions } from "./components/upcoming-transactions"
import { VerificationCallout } from "./components/verification-callout"
import {
balanceCards,
currentUser,
dollarRates,
lastUpdated,
latestTransactions,
rateSeries,
shellNotifications,
totalInDollars,
upcomingTransactions,
} from "./data"
import { NAV, ROUTES } from "@/lib/dashboards/finance/nav"
/**
* The store's money, by currency. A server component: it works every balance
* out of the ledger, reads the latest and upcoming rows and the rate series,
* and hands the chart and the converter — the two parts a reader drives —
* to client islands with the numbers they need and nothing more.
*/
export default async function BalancesPage() {
return (
<AppShell
nav={NAV}
activeHref={ROUTES.balances}
user={currentUser()}
notifications={shellNotifications()}
now={REFERENCE_DATE}
onSignOut={signOut}
>
<PageHeader
title="Balances"
description={`${totalInDollars()} across four currencies, at today's rates.`}
meta={lastUpdated()}
/>
<VerificationCallout business="Northwind" />
<BalanceCards cards={balanceCards()} />
<DashboardGrid>
<DashboardGridItem colSpan={{ base: 12, lg: 8 }}>
<LatestTransactions rows={latestTransactions()} />
</DashboardGridItem>
<DashboardGridItem colSpan={{ base: 12, lg: 4 }}>
<ExchangeRates series={rateSeries()} />
</DashboardGridItem>
<DashboardGridItem colSpan={{ base: 12, lg: 8 }}>
<UpcomingTransactions rows={upcomingTransactions()} />
</DashboardGridItem>
<DashboardGridItem colSpan={{ base: 12, lg: 4 }}>
<CurrencyConverter rates={dollarRates()} />
</DashboardGridItem>
</DashboardGrid>
</AppShell>
)
}Install
npx shadcn@latest add @vibra/finance-balancesNeeds the @vibra registry in your components.json — set it up once.
Source
import { REFERENCE_DATE } from "@/lib/sample-data"
import { AppShell } from "@/components/ui/app-shell"
import { DashboardGrid, DashboardGridItem } from "@/components/ui/dashboard-grid"
import { PageHeader } from "@/components/ui/page-header"
import { signOut } from "./actions"
import { BalanceCards } from "./components/balance-cards"
import { CurrencyConverter } from "./components/currency-converter"
import { ExchangeRates } from "./components/exchange-rates"
import { LatestTransactions } from "./components/latest-transactions"
import { UpcomingTransactions } from "./components/upcoming-transactions"
import { VerificationCallout } from "./components/verification-callout"
import {
balanceCards,
currentUser,
dollarRates,
lastUpdated,
latestTransactions,
rateSeries,
shellNotifications,
totalInDollars,
upcomingTransactions,
} from "./data"
import { NAV, ROUTES } from "@/lib/dashboards/finance/nav"
/**
* The store's money, by currency. A server component: it works every balance
* out of the ledger, reads the latest and upcoming rows and the rate series,
* and hands the chart and the converter — the two parts a reader drives —
* to client islands with the numbers they need and nothing more.
*/
export default async function BalancesPage() {
return (
<AppShell
nav={NAV}
activeHref={ROUTES.balances}
user={currentUser()}
notifications={shellNotifications()}
now={REFERENCE_DATE}
onSignOut={signOut}
>
<PageHeader
title="Balances"
description={`${totalInDollars()} across four currencies, at today's rates.`}
meta={lastUpdated()}
/>
<VerificationCallout business="Northwind" />
<BalanceCards cards={balanceCards()} />
<DashboardGrid>
<DashboardGridItem colSpan={{ base: 12, lg: 8 }}>
<LatestTransactions rows={latestTransactions()} />
</DashboardGridItem>
<DashboardGridItem colSpan={{ base: 12, lg: 4 }}>
<ExchangeRates series={rateSeries()} />
</DashboardGridItem>
<DashboardGridItem colSpan={{ base: 12, lg: 8 }}>
<UpcomingTransactions rows={upcomingTransactions()} />
</DashboardGridItem>
<DashboardGridItem colSpan={{ base: 12, lg: 4 }}>
<CurrencyConverter rates={dollarRates()} />
</DashboardGridItem>
</DashboardGrid>
</AppShell>
)
}/**
* What the balances page reads. A balance is never typed in: it is the
* ledger's opening entry for the currency plus every completed row of
* `db.transactions` in it. Pending rows have not settled, so they are not in
* the balance — they are said beside it — and what is available is the
* balance less the money already on its way out. Rates are `db.fxRates`; a
* conversion between two currencies goes through the dollar, so the page's
* figures and its converter can never disagree. "Now" is `REFERENCE_DATE`.
*/
import { formatCurrency, formatDate, getInitials } from "@/lib/format"
import {
db,
REFERENCE_DATE,
type Currency,
type FxPair,
type FxPoint,
type Member,
type Transaction,
} from "@/lib/sample-data"
const DAY_MS = 86_400_000
/**
* The ledger's opening entry per currency, in minor units — cents, pence and
* whole yen — as it stood before the first row `db.transactions` holds. The
* one figure on the page that is not a row: every balance is this plus what
* has completed since.
*/
export const OPENING_BALANCES: Record<Currency, number> = {
USD: 14_260_000,
EUR: 5_840_000,
GBP: 3_675_000,
JPY: 6_400_000,
}
/** The currencies in the order the cards stand. */
const CURRENCIES: Currency[] = ["USD", "EUR", "GBP", "JPY"]
/** Minor units in a major one: a hundred cents, or one yen. */
export const MINOR: Record<Currency, number> = { USD: 100, EUR: 100, GBP: 100, JPY: 1 }
export type BalanceCard = {
currency: Currency
/** Opening plus every completed row, in minor units. */
balance: number
/** The balance less the payouts, refunds and fees already pending — what can still be spent. */
available: number
/** Every pending row, signed: money that has not landed yet, or has not left yet. */
pending: number
/** The change on thirty days ago, as a ratio; absent when there was nothing then to measure against. */
delta?: number
}
const completed = (row: Transaction) => row.status === "completed"
const sum = (rows: Transaction[]) => rows.reduce((total, row) => total + row.amountCents, 0)
/** One card per currency, each computed from the ledger. */
export function balanceCards(): BalanceCard[] {
const ledger = db.transactions.all()
const monthAgo = REFERENCE_DATE.getTime() - 30 * DAY_MS
return CURRENCIES.map((currency) => {
const rows = ledger.filter((row) => row.currency === currency)
const balance = OPENING_BALANCES[currency] + sum(rows.filter(completed))
const then = OPENING_BALANCES[currency] + sum(rows.filter((row) => completed(row) && row.at.getTime() <= monthAgo))
const pending = rows.filter((row) => row.status === "pending")
return {
currency,
balance,
available: balance + sum(pending.filter((row) => row.amountCents < 0)),
pending: sum(pending),
...(then === 0 ? {} : { delta: balance / then - 1 }),
}
})
}
/** The last eight rows that settled or failed, newest first. */
export function latestTransactions(): Transaction[] {
return db.transactions
.all()
.filter((row) => row.status === "completed" || row.status === "failed")
.sort((a, b) => b.at.getTime() - a.at.getTime())
.slice(0, 8)
}
/** A scheduled row still ahead, and how many days off it is. */
export type UpcomingRow = Transaction & { daysAway: number }
/** Every scheduled row after "now", soonest first. */
export function upcomingTransactions(): UpcomingRow[] {
const now = REFERENCE_DATE.getTime()
return db.transactions
.all()
.filter((row) => row.status === "scheduled" && row.at.getTime() > now)
.sort((a, b) => a.at.getTime() - b.at.getTime())
.map((row) => ({ ...row, daysAway: Math.ceil((row.at.getTime() - now) / DAY_MS) }))
}
/** Every pair's year of daily rates, for the chart. */
export function rateSeries(): Record<FxPair, FxPoint[]> {
return db.fxRates
}
/** Dollars per unit of each currency the converter offers, from the latest rates. */
export function dollarRates(): Record<string, number> {
const latest = (pair: FxPair) => db.fxRates[pair][db.fxRates[pair].length - 1].rate
return {
USD: 1,
EUR: latest("EUR/USD"),
GBP: latest("GBP/USD"),
JPY: 1 / latest("USD/JPY"),
CHF: 1 / latest("USD/CHF"),
}
}
/** Every balance in dollars at the latest rates, for the line under the title. */
export function totalInDollars(): string {
const rates = dollarRates()
const total = balanceCards().reduce(
(sum, card) => sum + (card.balance / MINOR[card.currency]) * rates[card.currency],
0
)
return formatCurrency(total, "USD", { maximumFractionDigits: 0 })
}
/** The freshness line under the title, measured against REFERENCE_DATE. */
export function lastUpdated(): string {
return `Synced ${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 }))
}"use server"
import { mockAuthAdapter } from "@/lib/auth-adapter"
import { type Result } from "@/lib/sample-data"
/**
* The one thing this page changes. The balances are read-only — every figure
* is worked out from the ledger — and the converter is arithmetic on the
* rates the page already holds, so the sign-out is all that reaches the
* server.
*/
export async function signOut(): Promise<Result<{ signedOut: true }>> {
await mockAuthAdapter.signOut()
return { ok: true, data: { signedOut: true } }
}import { StatCard } from "@/components/ui/stat-card"
import { StatCardGroup } from "@/components/ui/stat-card-group"
import { type BalanceCard } from "../data"
import { CURRENCY_NAMES, money, signed } from "./balances-vocabulary"
/**
* One card per currency the store holds: the balance, what of it is available
* and what is still pending under it, and a pill against where it stood
* thirty days ago. The currency's code is set in the mono face, as every code.
*/
export function BalanceCards({ cards }: { cards: BalanceCard[] }) {
return (
<StatCardGroup data-widget="widget-finance-balances-balance-cards" columns={4}>
{cards.map((card) => (
<StatCard
key={card.currency}
data-currency={card.currency}
label={
<span className="flex items-center gap-2">
<span className="font-mono text-xs text-muted-foreground">{card.currency}</span>
{CURRENCY_NAMES[card.currency]}
</span>
}
value={money(card.balance, card.currency)}
delta={card.delta}
description={`${money(card.available, card.currency)} available · ${signed(card.pending, card.currency)} pending`}
/>
))}
</StatCardGroup>
)
}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>
)
}"use client"
import * as React from "react"
import { ArrowLeftRightIcon } from "lucide-react"
import { formatCurrency } from "@/lib/format"
import { Button } from "@/components/ui/button"
import { Field, FieldError, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select"
import { Widget } from "@/components/ui/widget"
import { CURRENCY_NAMES, quoteRate } from "./balances-vocabulary"
const CURRENCIES = ["USD", "EUR", "GBP", "JPY", "CHF"]
/** What the amount box holds, read: a number, or why it is not one. */
function read(text: string): { value: number } | { error: string } {
const cleaned = text.trim().replace(/,/g, "")
if (cleaned === "") return { error: "Enter an amount." }
if (!/^-?(\d+\.?\d*|\.\d+)$/.test(cleaned)) return { error: "Enter a number." }
const value = Number(cleaned)
if (value < 0) return { error: "Enter an amount of zero or more." }
return { value }
}
/**
* An amount in one currency, in another, at the latest rates. Every pair is
* crossed through the dollar — dollars per unit of each currency, from the
* store's own rate series — so this and the chart beside it can never
* disagree. It converts as you type; an amount that is not a number, or is
* below zero, is marked on the field itself and no result is shown.
*/
export function CurrencyConverter({ rates }: { rates: Record<string, number> }) {
const [amount, setAmount] = React.useState("1000")
const [from, setFrom] = React.useState("USD")
const [to, setTo] = React.useState("EUR")
const id = React.useId()
const parsed = read(amount)
const rate = rates[from] / rates[to]
return (
<Widget
data-widget="widget-finance-balances-currency-converter"
title="Convert"
description="At the latest rates, through the dollar"
className="h-full"
>
<div className="flex flex-col gap-4">
<Field data-invalid={"error" in parsed || undefined}>
<FieldLabel htmlFor={`${id}-amount`}>Amount</FieldLabel>
<Input
id={`${id}-amount`}
inputMode="decimal"
autoComplete="off"
value={amount}
onChange={(event) => setAmount(event.target.value)}
aria-invalid={"error" in parsed || undefined}
aria-describedby={"error" in parsed ? `${id}-error` : undefined}
className="tabular-nums"
/>
{"error" in parsed ? <FieldError id={`${id}-error`}>{parsed.error}</FieldError> : null}
</Field>
<div className="grid grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-end gap-2">
<Field>
<FieldLabel htmlFor={`${id}-from`}>From</FieldLabel>
<NativeSelect id={`${id}-from`} value={from} onChange={(event) => setFrom(event.target.value)} className="w-full">
{CURRENCIES.map((code) => (
<NativeSelectOption key={code} value={code}>
{code} · {CURRENCY_NAMES[code]}
</NativeSelectOption>
))}
</NativeSelect>
</Field>
<Button
type="button"
variant="outline"
size="icon"
aria-label="Swap currencies"
onClick={() => {
setFrom(to)
setTo(from)
}}
>
<ArrowLeftRightIcon aria-hidden="true" />
</Button>
<Field>
<FieldLabel htmlFor={`${id}-to`}>To</FieldLabel>
<NativeSelect id={`${id}-to`} value={to} onChange={(event) => setTo(event.target.value)} className="w-full">
{CURRENCIES.map((code) => (
<NativeSelectOption key={code} value={code}>
{code} · {CURRENCY_NAMES[code]}
</NativeSelectOption>
))}
</NativeSelect>
</Field>
</div>
<div className="flex flex-col gap-1 border-t border-rule pt-4" aria-live="polite">
{"value" in parsed ? (
<span data-slot="conversion-result" className="type-numeral text-2xl">
{formatCurrency(parsed.value * rate, to)}
</span>
) : (
<span className="type-numeral text-2xl text-muted-foreground">—</span>
)}
<span data-slot="conversion-rate" className="text-xs text-muted-foreground tabular-nums">
1 {from} = {quoteRate(rate, from, to)} {to}
</span>
</div>
</div>
</Widget>
)
}"use client"
import * as React from "react"
import { formatDate } from "@/lib/format"
import { type FxPair, type FxPoint } from "@/lib/sample-data"
import { ChartTimeRange } from "@/components/ui/chart-time-range"
import type { ChartSeries } from "@/components/ui/chart-core"
import { LineChart } from "@/components/ui/line-chart"
import { MetricDelta } from "@/components/ui/metric-delta"
import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select"
import { Widget } from "@/components/ui/widget"
import { quoteRate } from "./balances-vocabulary"
const PAIRS: FxPair[] = ["EUR/USD", "GBP/USD", "USD/JPY", "USD/CHF", "EUR/GBP"]
/** The periods the chips offer, each the number of daily points it draws. */
const PERIODS = [
{ value: "1w", label: "1W", days: 7, span: "the last week" },
{ value: "1m", label: "1M", days: 30, span: "the last month" },
{ value: "3m", label: "3M", days: 90, span: "the last three months" },
{ value: "1y", label: "1Y", days: 365, span: "the last year" },
] as const
const SERIES: ChartSeries[] = [{ key: "rate", label: "Rate", color: "chart-1" }]
// Fixed to UTC so the axis reads the same wherever the page is rendered.
const DAY = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", timeZone: "UTC" })
/** A pair's rate as the desk quotes it — the same rule the converter's rate line follows. */
const quote = (pair: FxPair, rate: number) => {
const [base, counter] = pair.split("/")
return quoteRate(rate, base, counter)
}
/**
* One pair's rate over a chosen period. The line is drawn in the palette's
* first grey — one series, so no hue — and the accent marks only the latest
* rate, as a rule across the plot at the level the pair closed today. The
* chips are the kit's time-range toggles, the chosen one on the kit's
* selected fill; the pill is the move across the period on show.
*
* A rate is a level, not a quantity, so the chart's axis is fitted to the
* period's own range (`baseline="auto"`) rather than counted up from zero —
* from zero, a year of EUR/USD is a flat line along the top of the plot.
*/
export function ExchangeRates({ series }: { series: Record<FxPair, FxPoint[]> }) {
const [pair, setPair] = React.useState<FxPair>("EUR/USD")
const [period, setPeriod] = React.useState<(typeof PERIODS)[number]["value"]>("1m")
const id = React.useId()
const chosen = PERIODS.find((entry) => entry.value === period) ?? PERIODS[1]
const points = series[pair].slice(-chosen.days)
const first = points[0]
const last = points[points.length - 1]
return (
<Widget
data-widget="widget-finance-balances-exchange-rates"
title="Exchange rates"
description={`Closing rate on ${formatDate(`${last.date}T00:00:00Z`, "medium", { timeZone: "UTC" })}`}
className="h-full"
>
<div className="flex flex-col gap-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<label htmlFor={`${id}-pair`} className="sr-only">
Currency pair
</label>
<NativeSelect id={`${id}-pair`} size="sm" value={pair} onChange={(event) => setPair(event.target.value as FxPair)}>
{PAIRS.map((entry) => (
<NativeSelectOption key={entry} value={entry}>
{entry}
</NativeSelectOption>
))}
</NativeSelect>
<ChartTimeRange
size="sm"
value={period}
onValueChange={(next) => setPeriod(next as typeof period)}
options={PERIODS.map(({ value, label }) => ({ value, label }))}
/>
</div>
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
<span className="type-numeral text-2xl">{quote(pair, last.rate)}</span>
<MetricDelta value={last.rate / first.rate - 1} variant="pill" size="sm" />
<span className="text-xs text-muted-foreground">over {chosen.span}</span>
</div>
<LineChart
data={points}
index="date"
series={SERIES}
height={200}
curve="linear"
baseline="auto"
showLegend={false}
annotations={[{ kind: "line", axis: "y", value: last.rate, label: quote(pair, last.rate), tone: "brand" }]}
valueFormatter={(value) => quote(pair, value)}
indexFormatter={(value) => DAY.format(new Date(`${value}T00:00:00Z`))}
/>
</div>
</Widget>
)
}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 { cn } from "@/lib/utils"
import { formatDate } from "@/lib/format"
import { Widget } from "@/components/ui/widget"
import { type UpcomingRow } from "../data"
import { KIND_LABELS, signed } from "./balances-vocabulary"
import { Counterparty } from "./counterparty"
/**
* What is booked ahead, soonest first, each with how far off it is — the
* payouts and transfers the balances above are about to feel.
*/
export function UpcomingTransactions({ rows }: { rows: UpcomingRow[] }) {
return (
<Widget
data-widget="widget-finance-balances-upcoming-transactions"
title="Upcoming"
description="Booked ahead, soonest first"
className="h-full"
>
{rows.length === 0 ? (
<p className="text-sm text-muted-foreground">Nothing is booked ahead.</p>
) : (
<ul aria-label="Upcoming transactions" className="flex flex-col">
{rows.map((row) => (
<li key={row.id} className="flex items-center justify-between gap-3 border-b py-2.5 first:pt-0 last:border-b-0 last:pb-0">
<Counterparty
row={row}
detail={`${KIND_LABELS[row.kind]} · ${formatDate(row.at, "medium", { timeZone: "UTC" })}`}
className="min-w-0"
/>
<div className="flex shrink-0 flex-col items-end">
<span className={cn("text-sm tabular-nums", row.amountCents > 0 && "text-success")}>
{signed(row.amountCents, row.currency)}
</span>
<span className="text-xs text-muted-foreground tabular-nums">
in {row.daysAway} day{row.daysAway === 1 ? "" : "s"}
</span>
</div>
</li>
))}
</ul>
)}
</Widget>
)
}"use client"
import * as React from "react"
import Link from "next/link"
import { XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { Button, buttonVariants } from "@/components/ui/button"
import { Callout } from "@/components/ui/callout"
/**
* The one thing the balances ask of the reader: verify the business so its
* payouts can grow. The action goes to settings, where verification lives;
* dismissing it hides it for as long as the page is open — held in state and
* nowhere else, so it comes back on the next visit until the thing is done.
*/
export function VerificationCallout({ business }: { business: string }) {
const [open, setOpen] = React.useState(true)
if (!open) return null
return (
<Callout variant="warning" title={`Verify ${business}`} className="items-center">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<p>Payouts stay at the starter limit until the business is verified. Verify it to raise its payout limits.</p>
<div className="flex shrink-0 items-center gap-1">
<Link href="/settings" className={cn(buttonVariants({ size: "sm" }))}>
Start verification
</Link>
<Button type="button" variant="ghost" size="icon-sm" aria-label="Dismiss" onClick={() => setOpen(false)}>
<XIcon aria-hidden="true" />
</Button>
</div>
</div>
</Callout>
)
}