Skip to contentVibraUI
Part of the Finance dashboardinstalls at /finance

Finance dashboard

A finance page: cash, net burn, runway and margin, the month's profit and loss as a waterfall, where the spend went as a treemap, everything still owed by how late it is, and the payouts leaving next.

Open the live page

A server component inside AppShell. Revenue is the book — the MRR on db.customers that still bills — and every cost is a rule over rows rather than an invented line: payroll is the active db.members priced by a written-down pay band, marketing is db.campaigns spend spread over the days each campaign ran and clipped to the month, hosting is per billed seat and tooling per desk. Receivables are db.invoices aged against REFERENCE_DATE, so "overdue" only ever describes an invoice whose due date has actually passed. The one figure no row can give is the cash balance the year opened on: it comes from seeded("finance-overview") once at module scope, and everything after it — cash, burn, runway — is arithmetic over db. Composes AppShell, PageHeader, StatCardGroup, StatCard, DashboardGrid, ChartCard, WaterfallChart, TreemapChart, StackedBarChart, Widget, DataList and DataListItem.

Preview

Install

npx shadcn@latest add @vibra/finance-overview

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

Source

app/finance/page.tsx
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 { ExpensesByCategory } from "./components/expenses-by-category"
import { FinanceStats } from "./components/finance-stats"
import { InvoiceAging } from "./components/invoice-aging"
import { ProfitAndLoss } from "./components/profit-and-loss"
import { UpcomingPayouts } from "./components/upcoming-payouts"
import { expensesByCategory, upcomingPayouts } from "./costs"
import {
  CLOSED_MONTH,
  currentUser,
  financeStats,
  invoiceAging,
  lastUpdated,
  owedTotal,
  profitAndLoss,
  shellNotifications,
} from "./data"
import { NAV, ROUTES } from "@/lib/dashboards/finance/nav"

/**
 * The finance page. A server component: it prices the team, the campaigns and
 * the book once through `db` and hands plain rows to the client islands that
 * draw them, so the data layer never has to cross into the browser bundle.
 */
export default function FinancePage() {
  return (
    <AppShell
      nav={NAV}
      activeHref={ROUTES.overview}
      user={currentUser()}
      notifications={shellNotifications()}
      now={REFERENCE_DATE}
      onSignOut={signOut}
    >
      <PageHeader
        title="Finance"
        description="What is in the bank, what is going out, and what is still owed."
        meta={lastUpdated()}
      />

      <FinanceStats stats={financeStats()} />

      <DashboardGrid>
        <DashboardGridItem colSpan={{ base: 12, lg: 7 }}>
          <ProfitAndLoss movements={profitAndLoss()} month={CLOSED_MONTH} />
        </DashboardGridItem>
        <DashboardGridItem colSpan={{ base: 12, lg: 5 }}>
          <ExpensesByCategory data={expensesByCategory()} />
        </DashboardGridItem>

        <DashboardGridItem colSpan={{ base: 12, lg: 7 }}>
          <InvoiceAging rows={invoiceAging()} owed={owedTotal()} />
        </DashboardGridItem>
        <DashboardGridItem colSpan={{ base: 12, lg: 5 }}>
          <UpcomingPayouts payouts={upcomingPayouts()} />
        </DashboardGridItem>
      </DashboardGrid>
    </AppShell>
  )
}
app/finance/data.ts
/**
 * What /finance reads. The revenue and cost model lives in `costs.ts`; this
 * file turns it into the page's figures — the P&L, the cash balance and the
 * headline stats — and adds the receivables, which are `db.invoices` aged
 * against REFERENCE_DATE. The one number no row can give is the cash balance
 * the year opened on, so it comes from `seeded("dashboard-finance")` — once,
 * at module scope.
 */
import { formatCurrency, getInitials } from "@/lib/format"
import { db, REFERENCE_DATE, seeded, type Invoice, type Member } from "@/lib/sample-data"

import { CLOSED_MONTH, DAY_MS, dollars, ledger, marketingBetween, monthStart } from "./costs"

// The page's header copy names the month the P&L closed on, which the cost
// model already worked out.
export { CLOSED_MONTH }

/** The month's profit and loss, revenue first and the net at the close. */
export function profitAndLoss(): { label: string; value: number; isTotal?: boolean }[] {
  const { revenue, costs } = ledger()
  return [
    { label: "Revenue", value: Math.round(revenue) },
    ...costs.map((cost) => ({ label: cost.label, value: -Math.round(cost.value) })),
    { label: "Net", value: 0, isTotal: true },
  ]
}

// The one figure no row can give: what the bank held twelve months ago. It is
// generated once from the block's own seed, so the balance is the same on every
// render and in every test, and every number after it is arithmetic over db.
const OPENING_CASH =
  Math.round((400_000 + seeded("dashboard-finance")() * 150_000) / 25_000) * 25_000

const MONTHS_BACK = 12

/**
 * Cash today: the opening balance plus twelve months of net. Revenue and the
 * standing costs are flat month to month — they are the book, not a history —
 * so only marketing moves, which is the one cost the rows actually date.
 */
function cashOnHand(): number {
  const { revenue, payroll, hosting, tooling } = ledger()
  return Array.from({ length: MONTHS_BACK }, (_, index) => {
    const from = monthStart(MONTHS_BACK - index)
    const to = monthStart(MONTHS_BACK - index - 1)
    return revenue - (payroll + hosting + tooling + marketingBetween(from, to))
  }).reduce((balance, net) => balance + net, OPENING_CASH)
}

export type FinanceStat = {
  key: string
  label: string
  value: number
  description: string
  format: "currency" | "months" | "percent"
}

/** Cash, what it is going out at, how long that lasts, and the margin behind it. */
export function financeStats(): FinanceStat[] {
  const { revenue, net } = ledger()
  const cash = cashOnHand()
  const burn = Math.max(0, -net)

  return [
    {
      key: "cash",
      label: "Cash on hand",
      value: Math.round(cash),
      description: `after ${MONTHS_BACK} months of trading`,
      format: "currency",
    },
    {
      key: "burn",
      label: "Net burn",
      value: Math.round(burn),
      description: `a month, at ${CLOSED_MONTH}'s run rate`,
      format: "currency",
    },
    {
      key: "runway",
      label: "Runway",
      value: burn > 0 ? cash / burn : Infinity,
      description: "at the same burn, with no new revenue",
      format: "months",
    },
    {
      key: "margin",
      label: "Net margin",
      value: net / revenue,
      description: `on ${formatCurrency(revenue, "USD", { maximumFractionDigits: 0 })} of monthly revenue`,
      format: "percent",
    },
  ]
}

/** Ageing buckets: how far past its due date an unpaid invoice is. A voided invoice is not owed. */
const BUCKETS = ["Not due", "1–30 days", "31–60 days", "61–90 days", "Over 90 days"] as const
/** The three states an invoice is still money owed in. */
type Owed = "draft" | "open" | "overdue"
const OWED = ["draft", "open", "overdue"] as const

const isOwed = (status: Invoice["status"]): status is Owed =>
  (OWED as readonly string[]).includes(status)

function bucketOf(invoice: Invoice): (typeof BUCKETS)[number] {
  const overdueDays = (REFERENCE_DATE.getTime() - invoice.dueAt.getTime()) / DAY_MS
  if (overdueDays <= 0) return BUCKETS[0]
  if (overdueDays <= 30) return BUCKETS[1]
  if (overdueDays <= 60) return BUCKETS[2]
  if (overdueDays <= 90) return BUCKETS[3]
  return BUCKETS[4]
}

export type AgingRow = { bucket: string; draft: number; open: number; overdue: number }

/** Everything still owed, by how late it is and what state the invoice is in. */
export function invoiceAging(): AgingRow[] {
  const rows = new Map<string, AgingRow>(
    BUCKETS.map((bucket) => [bucket, { bucket, draft: 0, open: 0, overdue: 0 }])
  )

  for (const invoice of db.invoices.all()) {
    const status = invoice.status
    if (!isOwed(status)) continue
    const row = rows.get(bucketOf(invoice))
    if (!row) continue
    row[status] += dollars(invoice.amountCents)
  }

  return [...rows.values()].map((row) => ({
    bucket: row.bucket,
    draft: Math.round(row.draft),
    open: Math.round(row.open),
    overdue: Math.round(row.overdue),
  }))
}

/** Everything still owed, for the copy beside the aging chart. */
export function owedTotal(): number {
  return invoiceAging().reduce((total, row) => total + row.draft + row.open + row.overdue, 0)
}

/** 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 }))
}

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 }
}

/** Which month the page closed on. */
export function lastUpdated(): string {
  return `${CLOSED_MONTH} close`
}
app/finance/costs.ts
/**
 * What the month costs, and what it earns. Revenue is the book — the MRR on
 * `db.customers` that still bills. Every cost is a rule over rows rather than
 * an invented line: payroll is the active `db.members` priced by role,
 * marketing is `db.campaigns` spend spread over the days each one ran, hosting
 * is per active seat, and tooling is per head. The accounting month is the
 * last closed one, and `data.ts` builds the page's figures on top of this.
 */
import { db, REFERENCE_DATE, type Customer, type Member } from "@/lib/sample-data"

export const DAY_MS = 86_400_000

export const dollars = (cents: number): number => cents / 100

/** Midnight UTC on the first of the month `k` months before the current one. */
export function monthStart(k: number): Date {
  return new Date(Date.UTC(REFERENCE_DATE.getUTCFullYear(), REFERENCE_DATE.getUTCMonth() - k, 1))
}

const THIS_MONTH = monthStart(0)
const LAST_MONTH = monthStart(1)

const MONTH_NAME = new Intl.DateTimeFormat("en-US", { month: "long", timeZone: "UTC" })

/** The month the P&L closed on. */
export const CLOSED_MONTH = `${MONTH_NAME.format(LAST_MONTH)} ${LAST_MONTH.getUTCFullYear()}`

// What a seat on the team costs a month, fully loaded — salary, tax and
// benefits. A pay band is a business decision, written down like the pricing
// tiers are, and payroll is that band applied to the members who are active.
const MONTHLY_COST: Record<Member["role"], number> = {
  owner: 18_000,
  admin: 15_000,
  member: 12_500,
  viewer: 7_500,
}

// The two unit costs behind the smaller lines: what a seat of product costs to
// host for a month, and what a desk costs in tools.
const HOSTING_PER_SEAT = 2.4
const TOOLING_PER_HEAD = 220

/**
 * A campaign's spend inside a window. Spend is a single figure for a run of
 * days, so it is spread evenly over them and only the overlap is counted — a
 * campaign that ran a fortnight into the month contributes a fortnight of it.
 */
export function marketingBetween(from: Date, to: Date): number {
  let total = 0
  for (const campaign of db.campaigns.all()) {
    const ran = campaign.endedAt ?? REFERENCE_DATE
    const days = Math.max(1, (ran.getTime() - campaign.startedAt.getTime()) / DAY_MS)
    const overlapFrom = Math.max(from.getTime(), campaign.startedAt.getTime())
    const overlapTo = Math.min(to.getTime(), ran.getTime())
    if (overlapTo <= overlapFrom) continue
    total += dollars(campaign.spendCents) * ((overlapTo - overlapFrom) / DAY_MS / days)
  }
  return total
}

/** The closed month's book and what it cost, as the rows stand. */
export type Ledger = {
  /** The book: what every account that still bills pays in a month. */
  revenue: number
  payroll: number
  marketing: number
  hosting: number
  tooling: number
  costs: { label: string; value: number }[]
  net: number
  /** The members on the payroll, and the accounts the book is made of. */
  team: Member[]
  billing: Customer[]
}

/**
 * The month's revenue and costs, read per call and never held at module
 * scope: an account imported or a teammate who leaves since the server
 * started moves the next render's figures.
 */
export function ledger(): Ledger {
  const team = db.members.all().filter((member) => member.status === "active")
  const billing = db.customers.all().filter((customer) => customer.mrrCents > 0)
  const revenue = dollars(billing.reduce((total, customer) => total + customer.mrrCents, 0))
  const payroll = team.reduce((total, member) => total + MONTHLY_COST[member.role], 0)
  const hosting = billing.reduce((total, customer) => total + customer.seats, 0) * HOSTING_PER_SEAT
  const tooling = team.length * TOOLING_PER_HEAD
  const marketing = marketingBetween(LAST_MONTH, THIS_MONTH)
  const costs = [
    { label: "Payroll", value: payroll },
    { label: "Marketing", value: marketing },
    { label: "Hosting", value: hosting },
    { label: "Tooling", value: tooling },
  ]
  const spend = costs.reduce((total, cost) => total + cost.value, 0)
  return { revenue, payroll, marketing, hosting, tooling, costs, net: revenue - spend, team, billing }
}

const ROLE_LABELS: Record<Member["role"], string> = {
  owner: "Founders",
  admin: "Leads",
  member: "Team",
  viewer: "Contractors",
}

const CHANNEL_LABELS: Record<string, string> = {
  search: "Search",
  social: "Social",
  email: "Email",
  referral: "Referral",
  organic: "Organic",
}

/** Where the month's money went, payroll and marketing broken out by their parts. */
export function expensesByCategory() {
  const { team, payroll, marketing, hosting, tooling } = ledger()
  const byRole = new Map<Member["role"], number>()
  for (const member of team) {
    byRole.set(member.role, (byRole.get(member.role) ?? 0) + MONTHLY_COST[member.role])
  }

  const byChannel = new Map<string, number>()
  for (const campaign of db.campaigns.all()) {
    const ran = campaign.endedAt ?? REFERENCE_DATE
    const days = Math.max(1, (ran.getTime() - campaign.startedAt.getTime()) / DAY_MS)
    const overlapFrom = Math.max(LAST_MONTH.getTime(), campaign.startedAt.getTime())
    const overlapTo = Math.min(THIS_MONTH.getTime(), ran.getTime())
    if (overlapTo <= overlapFrom) continue
    const share = dollars(campaign.spendCents) * ((overlapTo - overlapFrom) / DAY_MS / days)
    byChannel.set(campaign.channel, (byChannel.get(campaign.channel) ?? 0) + share)
  }

  return [
    {
      name: "Payroll",
      value: Math.round(payroll),
      children: [...byRole]
        .map(([role, value]) => ({ name: ROLE_LABELS[role], value: Math.round(value) }))
        .sort((a, b) => b.value - a.value),
    },
    {
      name: "Marketing",
      value: Math.round(marketing),
      children: [...byChannel]
        .map(([channel, value]) => ({
          name: CHANNEL_LABELS[channel] ?? channel,
          value: Math.round(value),
        }))
        .sort((a, b) => b.value - a.value),
    },
    { name: "Hosting", value: Math.round(hosting) },
    { name: "Tooling", value: Math.round(tooling) },
  ]
}

export type Payout = {
  id: string
  title: string
  description: string
  amount: number
  due: Date
}

// Day 0 of the next month is the last day of this one.
const MONTH_END = new Date(
  Date.UTC(REFERENCE_DATE.getUTCFullYear(), REFERENCE_DATE.getUTCMonth() + 1, 0)
)
const NEXT_MONTH = monthStart(-1)

/** What leaves the account next: the standing runs, then the live ad accounts. */
export function upcomingPayouts(): Payout[] {
  const { team, billing, payroll, hosting, tooling } = ledger()
  const live = db.campaigns
    .all()
    .filter((campaign) => campaign.status === "active")
    .map((campaign) => {
      const ran = campaign.endedAt ?? REFERENCE_DATE
      const days = Math.max(1, (ran.getTime() - campaign.startedAt.getTime()) / DAY_MS)
      return {
        id: campaign.id,
        title: campaign.name,
        description: `${CHANNEL_LABELS[campaign.channel] ?? campaign.channel} · one month of spend`,
        amount: Math.round((dollars(campaign.spendCents) / days) * 30),
        due: new Date(Date.UTC(NEXT_MONTH.getUTCFullYear(), NEXT_MONTH.getUTCMonth(), 15)),
      }
    })
    .sort((a, b) => b.amount - a.amount)
    .slice(0, 3)

  return [
    {
      id: "payroll",
      title: "Payroll run",
      description: `${team.length} people on the books`,
      amount: Math.round(payroll),
      due: MONTH_END,
    },
    {
      id: "hosting",
      title: "Cloud hosting",
      description: `${billing.reduce((seats, customer) => seats + customer.seats, 0)} billed seats`,
      amount: Math.round(hosting),
      due: NEXT_MONTH,
    },
    {
      id: "tooling",
      title: "Software and tools",
      description: `${team.length} desks`,
      amount: Math.round(tooling),
      due: NEXT_MONTH,
    },
    ...live,
  ].sort((a, b) => a.due.getTime() - b.due.getTime())
}
app/finance/actions.ts
"use server"

import { mockAuthAdapter } from "@/lib/auth-adapter"
import { type Result } from "@/lib/sample-data"

/**
 * The one thing this page changes. A server action so the page can stay a
 * server component and still hand the shell something to call, and a `Result`
 * so the caller reads the same success-or-error shape every mutation returns.
 */
export async function signOut(): Promise<Result<{ signedOut: true }>> {
  await mockAuthAdapter.signOut()
  return { ok: true, data: { signedOut: true } }
}
app/finance/components/expenses-by-category.tsx
"use client"

import { formatCurrency } from "@/lib/format"
import { ChartCard } from "@/components/ui/chart-card"
import { TreemapChart, type TreemapDatum } from "@/components/ui/treemap-chart"

const money = (value: number) => formatCurrency(value, "USD", { maximumFractionDigits: 0 })

/** Where the month's money went, payroll and marketing split into their parts. */
export function ExpensesByCategory({ data }: { data: TreemapDatum[] }) {
  return (
    <ChartCard
      data-widget="widget-finance-overview-expenses-by-category"
      title="Expenses by category"
      description="The month's spend, by what it paid for"
      height={280}
      className="h-full"
    >
      <TreemapChart data={data} height={280} valueFormatter={money} />
    </ChartCard>
  )
}
app/finance/components/finance-stats.tsx
import { CalendarClockIcon, FlameIcon, PercentIcon, WalletIcon } from "lucide-react"

import { formatCurrency, formatNumber, formatPercent } from "@/lib/format"
import { StatCard } from "@/components/ui/stat-card"
import { StatCardGroup } from "@/components/ui/stat-card-group"

import { type FinanceStat } from "../data"

// One icon per headline, keyed by the stat rather than its position, so the row
// can be reordered without the icons following the wrong numbers.
const ICONS: Record<string, React.ReactNode> = {
  cash: <WalletIcon />,
  burn: <FlameIcon />,
  runway: <CalendarClockIcon />,
  margin: <PercentIcon />,
}

function read(stat: FinanceStat): string {
  if (stat.format === "percent") return formatPercent(stat.value, { maximumFractionDigits: 1 })
  if (stat.format === "months") {
    return Number.isFinite(stat.value)
      ? `${formatNumber(stat.value, { maximumFractionDigits: 0 })} months`
      : "No burn"
  }
  return formatCurrency(stat.value, "USD", { maximumFractionDigits: 0 })
}

/** What is in the bank, what leaves it, how long that lasts, and the margin. */
export function FinanceStats({ stats }: { stats: FinanceStat[] }) {
  return (
    <StatCardGroup data-widget="widget-finance-overview-finance-stats" columns={4}>
      {stats.map((stat) => (
        <StatCard
          key={stat.key}
          label={stat.label}
          value={read(stat)}
          description={stat.description}
          icon={ICONS[stat.key]}
        />
      ))}
    </StatCardGroup>
  )
}
app/finance/components/invoice-aging.tsx
"use client"

import { formatCurrency } from "@/lib/format"
import { ChartCard } from "@/components/ui/chart-card"
import { chartTone } from "@/components/ui/chart-core"
import { StackedBarChart } from "@/components/ui/stacked-bar-chart"

import { type AgingRow } from "../data"

// Overdue is the reading the chart exists for, so it takes the danger token
// rather than a palette hue. The two that are not yet late take hues that could
// not be mistaken for it — a second red would read as a second alarm.
const SERIES = [
  { key: "open", label: "Open", color: "chart-1" as const },
  { key: "draft", label: "Draft", color: "chart-3" as const },
  { key: "overdue", label: "Overdue", color: chartTone("negative") },
]

const money = (value: number) =>
  formatCurrency(value, "USD", { compact: true, maximumFractionDigits: 1 })

export type InvoiceAgingProps = {
  rows: AgingRow[]
  owed: number
}

/** Everything still owed, by how late it is. */
export function InvoiceAging({ rows, owed }: InvoiceAgingProps) {
  return (
    <ChartCard
      data-widget="widget-finance-overview-invoice-aging"
      title="Invoices aging"
      description="What is still owed, by how far past its due date it is"
      height={300}
      className="h-full"
      footer={`${formatCurrency(owed, "USD", { maximumFractionDigits: 0 })} outstanding`}
    >
      <StackedBarChart
        data={rows}
        index="bucket"
        series={SERIES}
        height={300}
        showYAxis
        valueFormatter={money}
      />
    </ChartCard>
  )
}
app/finance/components/profit-and-loss.tsx
"use client"

import {
  chartSurface,
  downloadBlob,
  downloadText,
  exportFilename,
  printPage,
  rowsToCsv,
  svgToPng,
} from "@/lib/export"
import { formatCurrency } from "@/lib/format"
import { notify } from "@/lib/notify"
import { ChartCard } from "@/components/ui/chart-card"
import { ExportMenu, type ExportFormat } from "@/components/ui/export-menu"
import { WaterfallChart, type WaterfallMovement } from "@/components/ui/waterfall-chart"

const money = (value: number) =>
  formatCurrency(value, "USD", { compact: true, maximumFractionDigits: 1 })

const CHART_ID = "profit-and-loss"

export type ProfitAndLossProps = {
  movements: WaterfallMovement[]
  month: string
}

/** Revenue down through each cost line to the net the month closed on. */
export function ProfitAndLoss({ movements, month }: ProfitAndLossProps) {
  async function handleExport(format: ExportFormat) {
    const name = `profit-and-loss-${month}`

    if (format === "csv") {
      const rows = movements.map((movement) => ({
        line: movement.label,
        amount: movement.value,
        kind: movement.isTotal ? "total" : "movement",
      }))
      const written = downloadText(
        exportFilename(name, "csv"),
        rowsToCsv(rows, [
          { key: "line", label: "Line" },
          { key: "amount", label: "Amount (USD)" },
          { key: "kind", label: "Kind" },
        ])
      )
      notify[written ? "success" : "error"](
        written ? "Downloaded the statement as CSV." : "This browser cannot save the file."
      )
      return
    }

    if (format === "png") {
      const svg = chartSurface(document.getElementById(CHART_ID))
      if (!svg) {
        notify.error("The chart is still drawing.")
        return
      }
      // The CSV branch above already reports what happened; the picture has two
      // more ways to fail — drawing it, and saving it — and reported neither.
      try {
        const written = downloadBlob(exportFilename(name, "png"), await svgToPng(svg))
        notify[written ? "success" : "error"](
          written ? "Downloaded the chart as PNG." : "This browser cannot save the file."
        )
      } catch {
        notify.error("The chart could not be drawn as a picture.")
      }
      return
    }

    printPage({ brand: "Northwind", title: "Profit and loss", range: month })
  }

  return (
    <ChartCard
      data-widget="widget-finance-overview-profit-and-loss"
      id={CHART_ID}
      title="Profit and loss"
      description={`Revenue through to net, ${month}`}
      height={280}
      className="h-full"
      // The card the numbers are on is the card that knows how to hand them
      // over, so the menu sits in its own header rather than in the page's.
      actions={
        <ExportMenu size="sm" formats={["csv", "png", "pdf"]} onExport={handleExport} />
      }
    >
      <WaterfallChart data={movements} height={280} valueFormatter={money} />
    </ChartCard>
  )
}
app/finance/components/upcoming-payouts.tsx
"use client"

import { formatCurrency, formatDate } from "@/lib/format"
import { DataList, DataListItem } from "@/components/ui/data-list"
import { Widget } from "@/components/ui/widget"

import { type Payout } from "../costs"

const money = (value: number) => formatCurrency(value, "USD", { maximumFractionDigits: 0 })

/** How a due date reads on the list — fixed to UTC, so it is the same day for every reader. */
const dueLabel = (due: Date) => formatDate(due, "medium", { timeZone: "UTC" })

/** What leaves the account next, soonest first. */
export function UpcomingPayouts({ payouts }: { payouts: Payout[] }) {
  const total = payouts.reduce((sum, payout) => sum + payout.amount, 0)

  return (
    <Widget
      data-widget="widget-finance-overview-upcoming-payouts"
      title="Upcoming payouts"
      description="Standing runs and the live ad accounts, soonest first"
      className="h-full"
      footer={`${money(total)} leaving over the next month`}
    >
      <DataList divided>
        {payouts.map((payout) => (
          <DataListItem
            key={payout.id}
            title={payout.title}
            description={payout.description}
            meta={
              <span className="flex flex-col items-end gap-0.5">
                <span className="font-medium tabular-nums">{money(payout.amount)}</span>
                <span className="text-xs text-muted-foreground">{dueLabel(payout.due)}</span>
              </span>
            }
          />
        ))}
      </DataList>
    </Widget>
  )
}