For the complete documentation index, see llms.txt. Markdown variants are available by appending .md to documentation URLs. Homepage markdown is available at /llms.md/content.md. An agent skill is available at /.well-known/agent-skills/site-skill.md.

Finance Dashboard 01

A personal finance body with KPI cards, a cash flow diagram, spending charts and a transactions table.

Installation

Set up the @cobalt registry first, as described in Installation, then add the block:

$ pnpm dlx shadcn@latest add @cobalt/finance-dashboard-01

The block is the page body only, with no sidebar or heading, so it drops into an app shell. Transactions live in local component state. Pass addOpen and onAddOpenChange to open the add dialog from your own button, and popupClassName to scope the theme of dialogs, menus and tooltips rendered outside the block.

Source

"use client";

import { cn } from "cn";
import * as React from "react";

import { FinanceDashboardCashFlowCard } from "@/components/blocks/finance-dashboard-01/components/finance-dashboard-cash-flow-card";
import {
  dollars,
  financeStats,
  flowNodes,
  flowRanges,
  portfolioRanges,
  spendingHeatmapRanges,
  spendingRanges,
} from "@/components/blocks/finance-dashboard-01/components/finance-dashboard-data";
import { FinanceDashboardTransactionsTable } from "@/components/blocks/finance-dashboard-01/components/finance-dashboard-transactions-table";
import { HeatmapChartCard } from "@/components/blocks/heatmap-chart-card-01/components/heatmap-chart-card";
import { RadialChartCard } from "@/components/blocks/radial-chart-card-01/components/radial-chart-card";
import { ScatterChartCard } from "@/components/blocks/scatter-chart-card-01/components/scatter-chart-card";
import { StatCards } from "@/components/blocks/stat-cards-01/components/stat-cards";
import { Reveal } from "@/components/ui/reveal";

// 500ms, 12px rise with a 3px blur clearing, each row 40ms after the last. The
// cards inside a row do not animate again.
function Row({
  step,
  className,
  children,
}: {
  step: number;
  className?: string;
  children: React.ReactNode;
}) {
  return (
    <Reveal index={step} blur className="min-w-0">
      <div className={className}>{children}</div>
    </Reveal>
  );
}

/**
 * The body of a personal finance dashboard: four KPI cards, a cash flow
 * diagram, spending by category, a portfolio chart, a daily spending heatmap
 * and a transactions table. It carries no navigation or heading, so it drops
 * into an app shell or any page.
 *
 * Adding a transaction is controlled from outside: pass `addOpen` and
 * `onAddOpenChange` to open the dialog from your own button. Without them the
 * block keeps that state itself. Pass `popupClassName` (a theme scope) so
 * menus, dialogs and tooltips rendered outside the block pick up its tokens.
 */
export function FinanceDashboard({
  addOpen,
  onAddOpenChange,
  className,
  popupClassName,
}: {
  addOpen?: boolean;
  onAddOpenChange?: (open: boolean) => void;
  className?: string;
  popupClassName?: string;
}) {
  const [innerOpen, setInnerOpen] = React.useState(false);
  return (
    <div className={cn("@container flex min-w-0 flex-col gap-4", className)}>
      <Row step={0}>
        <StatCards stats={financeStats} popupClassName={popupClassName} />
      </Row>
      <Row step={1}>
        <FinanceDashboardCashFlowCard
          title="Cash flow"
          nodes={flowNodes}
          ranges={flowRanges}
          delta="+6.1%"
          format={dollars}
          axisLabels={["Income", "Spending"]}
          popupClassName={popupClassName}
        />
      </Row>
      <Row step={2} className="grid min-w-0 gap-4 @4xl:grid-cols-3">
        <RadialChartCard
          title="Spending by category"
          data={spendingRanges[0].data}
          ranges={spendingRanges}
          format={dollars}
          className="max-w-none"
          popupClassName={popupClassName}
        />
        <ScatterChartCard
          title="Portfolio"
          series={portfolioRanges[0].series}
          ranges={portfolioRanges}
          bubble
          tiles={false}
          change="+6.2%"
          format={(n) => `${Math.round(n)}%`}
          formatX={(n) => String(Math.round(n))}
          axisLabels={["Risk score", "Return"]}
          className="max-w-none"
          popupClassName={popupClassName}
        />
        <HeatmapChartCard
          title="Daily spending"
          rows={spendingHeatmapRanges[0].rows}
          columns={spendingHeatmapRanges[0].columns ?? []}
          ranges={spendingHeatmapRanges}
          format={dollars}
          className="max-w-none"
          popupClassName={popupClassName}
        />
      </Row>
      <Row step={3}>
        <FinanceDashboardTransactionsTable
          addOpen={addOpen ?? innerOpen}
          onAddOpenChange={onAddOpenChange ?? setInnerOpen}
          popupClassName={popupClassName}
        />
      </Row>
    </div>
  );
}
"use client";

import {
  RiArrowLeftLine,
  RiArrowRightLine,
  RiDeleteBinLine,
  RiMore2Line,
  RiPencilLine,
  RiSearchLine,
} from "@remixicon/react";
import {
  getCoreRowModel,
  getFilteredRowModel,
  getPaginationRowModel,
  getSortedRowModel,
  useReactTable,
} from "@tanstack/react-table";
import type { ColumnDef, SortingState } from "@tanstack/react-table";
import * as React from "react";

import {
  accountDots,
  accounts,
  categories,
  categoryIcons,
  categoryTones,
  dollars,
  initialTransactions,
} from "@/components/blocks/finance-dashboard-01/components/finance-dashboard-data";
import type {
  Account,
  Transaction,
} from "@/components/blocks/finance-dashboard-01/components/finance-dashboard-data";
import { FinanceDashboardTransactionDialog } from "@/components/blocks/finance-dashboard-01/components/finance-dashboard-transaction-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { DataTable } from "@/components/ui/data-table";
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuGroup,
  DropdownMenuItem,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
  InputGroup,
  InputGroupAddon,
  InputGroupInput,
} from "@/components/ui/input-group";
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select";

const ALL = "all";
const amountRanges = [
  { value: "income", label: "Income", match: (n: number) => n > 0 },
  {
    value: "small",
    label: "Under $100",
    match: (n: number) => n < 0 && n > -100,
  },
  { value: "large", label: "$100 and over", match: (n: number) => n <= -100 },
];
const dateFormat = new Intl.DateTimeFormat("en-US", {
  day: "2-digit",
  month: "short",
  timeZone: "UTC",
  year: "numeric",
});

// A window of page buttons with an ellipsis before the last page.
function pageWindow(index: number, count: number): (number | "gap")[] {
  if (count <= 7) {
    return Array.from({ length: count }, (_, i) => i);
  }
  const start = Math.min(Math.max(index - 2, 0), count - 6);
  const pages: (number | "gap")[] = Array.from(
    { length: 5 },
    (_, i) => start + i
  );
  return [...pages, "gap", count - 1];
}

function FilterSelect({
  label,
  value,
  onChange,
  options,
  popupClassName,
}: {
  label: string;
  value: string;
  onChange: (value: string) => void;
  options: { value: string; label: string }[];
  popupClassName?: string;
}) {
  const items = [{ label, value: ALL }, ...options];
  return (
    <Select
      items={items}
      value={value}
      onValueChange={(next) => onChange(next ?? ALL)}
    >
      <SelectTrigger aria-label={label}>
        <SelectValue />
      </SelectTrigger>
      <SelectContent className={popupClassName}>
        {items.map((item) => (
          <SelectItem key={item.value} value={item.value}>
            {item.label}
          </SelectItem>
        ))}
      </SelectContent>
    </Select>
  );
}

/**
 * The transactions table: account, category and amount filters, search, sort,
 * row selection, inline account changes, edit and delete, and pagination.
 * Adding is driven from outside through `addOpen`.
 */
export function FinanceDashboardTransactionsTable({
  addOpen,
  onAddOpenChange,
  popupClassName,
}: {
  addOpen: boolean;
  onAddOpenChange: (open: boolean) => void;
  popupClassName?: string;
}) {
  const [rows, setRows] = React.useState(initialTransactions);
  const [sorting, setSorting] = React.useState<SortingState>([]);
  const [rowSelection, setRowSelection] = React.useState<
    Record<string, boolean>
  >({
    "tx-3": true,
    "tx-5": true,
  });
  const [touched, setTouched] = React.useState(false);
  const [search, setSearch] = React.useState("");
  const [account, setAccount] = React.useState(ALL);
  const [category, setCategory] = React.useState(ALL);
  const [amount, setAmount] = React.useState(ALL);
  const [editing, setEditing] = React.useState<Transaction | undefined>();
  const counter = React.useRef(initialTransactions.length);
  const nextTransactionId = () => {
    counter.current += 1;
    return `tx-${counter.current}`;
  };

  const filtered = React.useMemo(() => {
    const range = amountRanges.find((item) => item.value === amount);
    return rows.filter(
      (row) =>
        (account === ALL || row.account === account) &&
        (category === ALL || row.category === category) &&
        (!range || range.match(row.amount))
    );
  }, [rows, account, category, amount]);

  const remove = React.useCallback((id: string) => {
    setRows((current) => current.filter((item) => item.id !== id));
    setRowSelection((current) => {
      const { [id]: _removed, ...rest } = current as Record<string, boolean>;
      return rest;
    });
  }, []);

  const columns = React.useMemo<ColumnDef<Transaction>[]>(
    () => [
      {
        id: "select",
        enableSorting: false,
        meta: { className: "w-[28px]" },
        header: ({ table }) => (
          <Checkbox
            aria-label="Select all transactions on this page"
            checked={table.getIsAllPageRowsSelected()}
            indeterminate={
              table.getIsSomePageRowsSelected() &&
              !table.getIsAllPageRowsSelected()
            }
            onCheckedChange={(checked) =>
              table.toggleAllPageRowsSelected(checked)
            }
          />
        ),
        cell: ({ row }) => (
          <Checkbox
            aria-label={`Select ${row.original.merchant}`}
            checked={row.getIsSelected()}
            onCheckedChange={(checked) => row.toggleSelected(checked)}
          />
        ),
      },
      {
        accessorKey: "merchant",
        meta: { className: "w-[19%] pl-2" },
        header: "Transaction",
        cell: ({ row }) => {
          const Icon = categoryIcons[row.original.category];
          return (
            <span className="flex items-center gap-2 font-medium">
              <span className="flex size-7 shrink-0 items-center justify-center rounded-[8px] border border-border bg-card text-muted-foreground">
                <Icon className="size-4" aria-hidden />
              </span>
              {row.original.merchant}
            </span>
          );
        },
      },
      {
        accessorKey: "account",
        meta: { className: "w-[16.6%]" },
        enableSorting: false,
        header: "Account",
        cell: ({ row }) => (
          <Select
            items={accounts.map((item) => ({ label: item, value: item }))}
            value={row.original.account}
            onValueChange={(next) => {
              if (next) {
                setRows((current) =>
                  current.map((item) =>
                    item.id === row.original.id
                      ? { ...item, account: next as Account }
                      : item
                  )
                );
              }
            }}
          >
            <SelectTrigger
              aria-label={`Account for ${row.original.merchant}`}
              className="w-[140px]"
            >
              <span
                aria-hidden
                className={`ml-0.5 size-2 shrink-0 rounded-full ring-4 ${accountDots[row.original.account]}`}
              />
              <span className="flex-1 text-start">
                <SelectValue />
              </span>
            </SelectTrigger>
            <SelectContent className={popupClassName}>
              {accounts.map((item) => (
                <SelectItem key={item} value={item}>
                  {item}
                </SelectItem>
              ))}
            </SelectContent>
          </Select>
        ),
      },
      {
        accessorKey: "category",
        meta: { className: "w-[16.6%]" },
        enableSorting: false,
        header: "Category",
        cell: ({ row }) => (
          <Badge variant="label" tone={categoryTones[row.original.category]}>
            {row.original.category}
          </Badge>
        ),
      },
      {
        accessorKey: "date",
        meta: { className: "w-[16.6%]" },
        header: "Date",
        cell: ({ row }) =>
          dateFormat.format(new Date(`${row.original.date}T00:00:00Z`)),
      },
      {
        accessorKey: "amount",
        meta: { className: "w-[16.6%]" },
        header: "Amount",
        cell: ({ row }) =>
          row.original.amount > 0 ? (
            <Badge variant="label" tone="lime">
              +{dollars(row.original.amount)}
            </Badge>
          ) : (
            <Badge variant="label" tone="gray" tabular>
              -{dollars(Math.abs(row.original.amount))}
            </Badge>
          ),
      },
      {
        enableSorting: false,
        header: "Actions",
        id: "actions",
        meta: { className: "w-[140px]" },
        cell: ({ row }) => (
          <div className="flex items-center gap-2.5">
            <Button
              aria-label={`Delete ${row.original.merchant}`}
              tooltip={{ children: "Delete", side: "top" }}
              size="icon-sm"
              variant="outline"
              onClick={() => remove(row.original.id)}
            >
              <RiDeleteBinLine />
            </Button>
            <Button
              aria-label={`Edit ${row.original.merchant}`}
              tooltip={{ children: "Edit", side: "top" }}
              size="icon-sm"
              variant="outline"
              onClick={() => setEditing(row.original)}
            >
              <RiPencilLine />
            </Button>
            <DropdownMenu>
              <DropdownMenuTrigger
                render={
                  <Button
                    aria-label={`More actions for ${row.original.merchant}`}
                    tooltip={{ children: "More actions", side: "top" }}
                    size="icon-sm"
                    variant="outline"
                  />
                }
              >
                <RiMore2Line />
              </DropdownMenuTrigger>
              <DropdownMenuContent align="end" className={popupClassName}>
                <DropdownMenuGroup>
                  <DropdownMenuItem
                    onClick={() =>
                      setRows((current) => [
                        {
                          ...row.original,
                          id: nextTransactionId(),
                        },
                        ...current,
                      ])
                    }
                  >
                    Duplicate
                  </DropdownMenuItem>
                  <DropdownMenuItem onClick={() => setEditing(row.original)}>
                    Edit details
                  </DropdownMenuItem>
                </DropdownMenuGroup>
              </DropdownMenuContent>
            </DropdownMenu>
          </div>
        ),
      },
    ],
    [popupClassName, remove]
  );

  const table = useReactTable({
    columns,
    data: filtered,
    getCoreRowModel: getCoreRowModel(),
    getFilteredRowModel: getFilteredRowModel(),
    getPaginationRowModel: getPaginationRowModel(),
    getRowId: (row) => row.id,
    getSortedRowModel: getSortedRowModel(),
    initialState: { pagination: { pageIndex: 0, pageSize: 10 } },
    onGlobalFilterChange: setSearch,
    onRowSelectionChange: (updater) => {
      setTouched(true);
      setRowSelection(updater);
    },
    onSortingChange: setSorting,
    state: { globalFilter: search, rowSelection, sorting },
  });
  const { pageIndex } = table.getState().pagination;
  const total = table.getFilteredRowModel().rows.length;
  // Two rows start checked for show; the selection bar waits for a real click.
  const selected = touched ? Object.keys(rowSelection).length : 0;
  const pageCount = Math.max(table.getPageCount(), 1);

  return (
    <>
      <Card radius={16} size="flush" variant="outline" className="w-full">
        <div className="flex flex-wrap items-center justify-between gap-3 p-3">
          <div className="text-sm font-medium">
            <div className="text-muted-foreground">
              {selected > 0 ? `${selected} selected` : "Total Results"}
            </div>
            <div>{total} transactions</div>
          </div>
          <div className="flex flex-wrap items-center gap-2.5">
            {selected > 0 && (
              <Button
                size="sm"
                variant="outline"
                onClick={() => {
                  const ids = new Set(Object.keys(rowSelection));
                  setRows((current) =>
                    current.filter((item) => !ids.has(item.id))
                  );
                  setRowSelection({});
                }}
              >
                <RiDeleteBinLine data-icon="inline-start" />
                Delete selected
              </Button>
            )}
            <FilterSelect
              label="All accounts"
              value={account}
              onChange={setAccount}
              options={accounts.map((item) => ({ label: item, value: item }))}
              popupClassName={popupClassName}
            />
            <FilterSelect
              label="All categories"
              value={category}
              onChange={setCategory}
              options={categories.map((item) => ({ label: item, value: item }))}
              popupClassName={popupClassName}
            />
            <FilterSelect
              label="All amounts"
              value={amount}
              onChange={setAmount}
              options={amountRanges.map((item) => ({
                label: item.label,
                value: item.value,
              }))}
              popupClassName={popupClassName}
            />
            <InputGroup variant="muted" className="w-[153px]">
              <InputGroupAddon>
                <RiSearchLine />
              </InputGroupAddon>
              <InputGroupInput
                aria-label="Search transactions"
                placeholder="Search"
                value={search}
                onChange={(event) => setSearch(event.target.value)}
              />
            </InputGroup>
          </div>
        </div>
        <DataTable
          aria-label="Transactions"
          emptyMessage="No transactions match your filters."
          variant="ghost"
          className="min-w-[960px] table-fixed"
          table={table}
        />
        <div className="flex items-center justify-between gap-2 border-t border-border p-3">
          <Button
            disabled={!table.getCanPreviousPage()}
            size="sm"
            variant="outline"
            onClick={() => table.previousPage()}
          >
            <RiArrowLeftLine />
            Previous
          </Button>
          <div className="hidden items-center gap-0.5 @3xl:flex">
            {pageWindow(pageIndex, pageCount).map((page, slot) =>
              page === "gap" ? (
                <span
                  aria-hidden
                  className="grid size-8 place-items-center text-sm text-muted-foreground"
                  key={`gap-${slot}`}
                >
                  …
                </span>
              ) : (
                <Button
                  aria-current={page === pageIndex ? "page" : undefined}
                  key={page}
                  size="icon-sm"
                  variant={page === pageIndex ? "outline" : "ghost"}
                  onClick={() => table.setPageIndex(page)}
                >
                  {page + 1}
                </Button>
              )
            )}
          </div>
          <span className="text-sm text-muted-foreground @3xl:hidden">
            Page {pageIndex + 1} of {pageCount}
          </span>
          <Button
            disabled={!table.getCanNextPage()}
            size="sm"
            variant="outline"
            onClick={() => table.nextPage()}
          >
            Next
            <RiArrowRightLine />
          </Button>
        </div>
      </Card>
      <FinanceDashboardTransactionDialog
        open={addOpen}
        onOpenChange={onAddOpenChange}
        popupClassName={popupClassName}
        onSubmit={(values) =>
          setRows((current) => [
            { ...values, id: nextTransactionId() },
            ...current,
          ])
        }
      />
      <FinanceDashboardTransactionDialog
        open={editing !== undefined}
        onOpenChange={(open) => {
          if (!open) {
            setEditing(undefined);
          }
        }}
        transaction={editing}
        popupClassName={popupClassName}
        onSubmit={(values) =>
          setRows((current) =>
            current.map((item) =>
              item.id === editing?.id ? { ...item, ...values } : item
            )
          )
        }
      />
    </>
  );
}