Skip to contentVibraUI

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

Install

npx shadcn@latest add @vibra/widget-finance-balances-latest-transactions

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

Source

app/finance/balances/components/latest-transactions.tsx
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>
  )
}
app/finance/balances/components/balances-vocabulary.ts
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",
}
app/finance/balances/components/counterparty.tsx
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