Skip to contentVibraUI

Profit and loss

The closed month's revenue walked down through payroll, marketing, hosting and tooling to the net, as a waterfall with CSV, PNG and print exports; reads profitAndLoss().

Preview

Install

npx shadcn@latest add @vibra/widget-finance-overview-profit-and-loss

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

Source

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

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 Finance dashboard page