Fazekit

Code

"use client";
import * as React from "react";
import { AnimatePresence, LayoutGroup, motion, useReducedMotion } from "motion/react";
import { ArrowLeftRight, BarChart3, Bell, LayoutDashboard, Receipt, Target, X, type LucideIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import { CATEGORY, defaultFinanceData, makeFormatters, monthKey, monthlyCashFlow, spendByCategory } from "./data";
import { TransactionsView } from "./transactions";
import { TransferDialog, type Payee } from "./transfer-dialog";
import type { Budgets, CategoryId, FinanceData, Split, Transaction, TransferRequest, ViewKey } from "./types";
import { focusRing, IconButton } from "./ui";
import { BudgetsView, CashFlowView, OverviewView } from "./views";

export type { Account, Budgets, CategoryId, FinanceData, NetWorthPoint, Split, Transaction, TransferRequest, ViewKey } from "./types";
export { generateFinanceData, CATEGORIES } from "./data";

const NAV: { key: ViewKey; label: string; icon: LucideIcon }[] = [
  { key: "overview", label: "Home", icon: LayoutDashboard },
  { key: "transactions", label: "Transactions", icon: Receipt },
  { key: "budgets", label: "Budgets", icon: Target },
  { key: "cashflow", label: "Cash flow", icon: BarChart3 },
];

const TITLES: Record<ViewKey, string> = { overview: "Good morning, Alex", transactions: "Transactions", budgets: "Budgets", cashflow: "Cash flow" };

export interface FinanceAppProps {
  /** Accounts, transactions, budgets and net-worth history. Defaults to a seeded, deterministic demo. */
  data?: FinanceData;
  /** ISO 4217 code used by Intl.NumberFormat. */
  currency?: string;
  locale?: string;
  brand?: string;
  user?: { name: string; plan?: string };
  payees?: Payee[];
  defaultView?: ViewKey;
  onViewChange?: (view: ViewKey) => void;
  /** Fired after a transaction is recategorized, split or annotated. */
  onTransactionChange?: (tx: Transaction) => void;
  onBudgetsChange?: (budgets: Budgets) => void;
  /** Fired when a transfer is confirmed (balances update locally either way). */
  onTransfer?: (req: TransferRequest) => void;
  className?: string;
}

type Toast = { id: number; text: string; undo?: () => void };
let seq = 0;

function BrandMark() {
  return (
    <svg viewBox="0 0 32 32" className="size-8 shrink-0" aria-hidden>
      <rect width="32" height="32" rx="10" className="fill-foreground" />
      <circle cx="13" cy="16" r="6" fill="none" strokeWidth="2.6" className="stroke-background" />
      <circle cx="19" cy="16" r="6" fill="none" strokeWidth="2.6" className="stroke-primary" />
    </svg>
  );
}

export function FinanceApp({
  data = defaultFinanceData,
  currency = "USD",
  locale = "en-US",
  brand = "Lumen",
  user = { name: "Alex Rivera", plan: "Plus plan" },
  payees,
  defaultView = "overview",
  onViewChange,
  onTransactionChange,
  onBudgetsChange,
  onTransfer,
  className,
}: FinanceAppProps) {
  const [view, setView] = React.useState<ViewKey>(defaultView);
  const [accounts, setAccounts] = React.useState(data.accounts);
  const [txs, setTxs] = React.useState(data.transactions);
  const [budgets, setBudgets] = React.useState<Budgets>(data.budgets);
  const [transfer, setTransfer] = React.useState<{ from?: string } | null>(null);
  const [catFilter, setCatFilter] = React.useState<CategoryId | "all">("all");
  const [toasts, setToasts] = React.useState<Toast[]>([]);
  const reduce = useReducedMotion();
  const uid = React.useId();
  const fmt = React.useMemo(() => makeFormatters(currency, locale), [currency, locale]);
  const today = data.today;

  const total = accounts.reduce((s, a) => s + a.balance, 0);
  const netWorth = React.useMemo(() => {
    const nw = data.netWorth.slice();
    if (nw.length) nw[nw.length - 1] = { ...nw[nw.length - 1], value: total };
    return nw;
  }, [data.netWorth, total]);
  const monthTx = React.useMemo(() => txs.filter((t) => monthKey(t.date) === monthKey(today)), [txs, today]);
  const spent = React.useMemo(() => spendByCategory(monthTx), [monthTx]);
  const flow = React.useMemo(() => monthlyCashFlow(txs, 6, today), [txs, today]);
  const overCount = Object.entries(budgets).filter(([k, b]) => b && (spent[k as CategoryId] ?? 0) > b).length;

  const toast = React.useCallback((text: string, undo?: () => void) => {
    const id = ++seq;
    setToasts((t) => [...t.slice(-2), { id, text, undo }]);
    window.setTimeout(() => setToasts((t) => t.filter((x) => x.id !== id)), 4500);
  }, []);

  const go = (v: ViewKey, cat: CategoryId | "all" = "all") => {
    setCatFilter(cat);
    setView(v);
    onViewChange?.(v);
  };

  const patchTx = (id: string, fn: (t: Transaction) => Transaction) => {
    let next: Transaction | undefined;
    setTxs((ts) =>
      ts.map((t) => {
        if (t.id !== id) return t;
        next = fn(t);
        return next;
      }),
    );
    queueMicrotask(() => next && onTransactionChange?.(next));
  };

  const recategorize = (id: string, c: CategoryId) => {
    const before = txs.find((t) => t.id === id);
    if (!before) return;
    patchTx(id, (t) => ({ ...t, category: c }));
    toast(`${before.merchant} moved to ${CATEGORY[c].label}`, () => patchTx(id, (t) => ({ ...t, category: before.category })));
  };
  const split = (id: string, s: Split[] | undefined) => {
    patchTx(id, (t) => ({ ...t, splits: s }));
    toast(s ? `Split into ${s.length} categories` : "Split removed");
  };
  const annotate = (id: string, note: string) => patchTx(id, (t) => ({ ...t, note: note || undefined }));
  const setBudget = (id: CategoryId, amount: number) => {
    const next = { ...budgets, [id]: amount };
    setBudgets(next);
    onBudgetsChange?.(next);
    toast(`${CATEGORY[id].label} budget set to ${fmt.money(amount, { cents: false })}`);
  };

  const doTransfer = (req: TransferRequest) => {
    const txid = `tx-new-${++seq}`;
    setAccounts((as) =>
      as.map((a) => {
        if (a.id === req.fromId) return { ...a, balance: Math.round((a.balance - req.amount) * 100) / 100 };
        if (req.kind === "internal" && a.id === req.toId) return { ...a, balance: Math.round((a.balance + req.amount) * 100) / 100 };
        return a;
      }),
    );
    const toName = req.kind === "internal" ? accounts.find((a) => a.id === req.toId)?.name ?? "account" : req.recipient;
    const fromName = accounts.find((a) => a.id === req.fromId)?.name ?? "account";
    const added: Transaction[] = [{ id: `${txid}-out`, date: today, merchant: `To ${toName}`, amount: -req.amount, category: "transfer", accountId: req.fromId, note: req.note || undefined }];
    if (req.kind === "internal") added.unshift({ id: `${txid}-in`, date: today, merchant: `From ${fromName}`, amount: req.amount, category: "transfer", accountId: req.toId, note: req.note || undefined });
    setTxs((ts) => [...added, ...ts]);
    onTransfer?.(req);
  };

  const navButtons = (mobile: boolean) => (
    <LayoutGroup id={`${uid}-${mobile ? "m" : "d"}`}>
      {NAV.map((n) => {
        const on = n.key === view;
        return (
          <button
            key={n.key}
            type="button"
            onClick={() => go(n.key)}
            aria-current={on ? "page" : undefined}
            className={cn(
              "relative outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring",
              mobile ? "flex flex-1 flex-col items-center gap-0.5 rounded-xl py-1.5 text-[10.5px] font-medium" : "flex h-10 items-center gap-3 rounded-xl px-3 text-sm font-medium",
              on ? "text-foreground" : "text-muted-foreground hover:text-foreground",
              !mobile && !on && "hover:bg-accent/60",
            )}
          >
            {on && <motion.span layoutId="fin-nav" className={cn("absolute rounded-xl bg-accent", mobile ? "inset-x-3 inset-y-0.5" : "inset-0 shadow-xs ring-1 ring-border")} transition={{ type: "spring", stiffness: 450, damping: 36 }} />}
            <n.icon className={cn("relative size-[18px]", on && "text-primary")} aria-hidden />
            <span className="relative">{n.label}</span>
            {n.key === "budgets" && overCount > 0 && (
              <span className={cn("relative grid place-items-center rounded-full bg-rose-500 text-[10px] font-bold leading-none text-white tabular-nums", mobile ? "absolute right-[calc(50%-18px)] top-0.5 size-4" : "ml-auto size-5")} aria-label={`${overCount} over budget`}>
                {overCount}
              </span>
            )}
          </button>
        );
      })}
    </LayoutGroup>
  );

  return (
    <div data-fin-root className={cn("relative flex h-[760px] w-full overflow-hidden bg-background text-foreground", className)}>
      <aside className="hidden w-60 shrink-0 flex-col gap-5 border-r bg-muted/30 p-4 md:flex">
        <div className="flex h-9 items-center gap-2.5 px-1">
          <BrandMark />
          <span className="text-base font-semibold tracking-tight">{brand}</span>
        </div>
        <button type="button" onClick={() => setTransfer({})} className={cn("inline-flex h-10 items-center justify-center gap-2 rounded-xl bg-primary text-sm font-semibold text-primary-foreground shadow-sm transition hover:opacity-90", focusRing)}>
          <ArrowLeftRight className="size-4" aria-hidden /> Move money
        </button>
        <nav aria-label="Main" className="flex flex-col gap-0.5">
          {navButtons(false)}
        </nav>
        <div className="mt-auto rounded-2xl border bg-background/70 p-3">
          <div className="flex items-center gap-2.5">
            <span className="grid size-9 place-items-center rounded-full bg-gradient-to-br from-primary to-fuchsia-500 text-xs font-semibold text-white" aria-hidden>
              {user.name
                .split(" ")
                .map((w) => w[0])
                .join("")
                .slice(0, 2)}
            </span>
            <div className="min-w-0">
              <div className="truncate text-sm font-medium">{user.name}</div>
              {user.plan && <div className="truncate text-xs text-muted-foreground">{user.plan}</div>}
            </div>
          </div>
        </div>
      </aside>

      <div className="flex min-w-0 flex-1 flex-col">
        <header className="flex h-14 shrink-0 items-center gap-3 border-b bg-background/80 px-4 backdrop-blur sm:px-6">
          <span className="md:hidden">
            <BrandMark />
          </span>
          <div className="min-w-0 flex-1">
            <h1 className="truncate text-base font-semibold tracking-tight">{TITLES[view]}</h1>
            <p className="hidden text-xs text-muted-foreground sm:block">{fmt.dayLong(today)}</p>
          </div>
          <IconButton label="Notifications" className="relative">
            <Bell className="size-4" />
            {overCount > 0 && <span className="absolute right-2 top-2 size-2 rounded-full bg-rose-500 ring-2 ring-background" />}
          </IconButton>
          <button type="button" onClick={() => setTransfer({})} className={cn("inline-flex h-9 items-center gap-2 rounded-xl bg-primary px-3 text-sm font-semibold text-primary-foreground shadow-sm md:hidden", focusRing)} aria-label="Move money">
            <ArrowLeftRight className="size-4" aria-hidden />
            <span className="hidden min-[400px]:inline">Move</span>
          </button>
        </header>

        <main className={cn("relative min-h-0 flex-1", view === "transactions" ? "overflow-hidden" : "overflow-y-auto overflow-x-hidden")}>
          <AnimatePresence mode="wait" initial={false}>
            <motion.div key={view} className={cn(view === "transactions" && "h-full")} initial={reduce ? false : { opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} exit={reduce ? undefined : { opacity: 0, y: -4 }} transition={{ duration: 0.18 }}>
              {view === "overview" && (
                <OverviewView accounts={accounts} netWorth={netWorth} transactions={txs} budgets={budgets} spent={spent} fmt={fmt} onSeeAll={() => go("transactions")} onOpenBudgets={() => go("budgets")} onTransfer={(from) => setTransfer({ from })} />
              )}
              {view === "transactions" && <TransactionsView transactions={txs} accounts={accounts} fmt={fmt} today={today} onRecategorize={recategorize} onSplit={split} onNote={annotate} initialCategory={catFilter} />}
              {view === "budgets" && <BudgetsView budgets={budgets} spent={spent} fmt={fmt} today={today} onChange={setBudget} onOpenCategory={(c) => go("transactions", c)} />}
              {view === "cashflow" && <CashFlowView flow={flow} transactions={txs} fmt={fmt} />}
            </motion.div>
          </AnimatePresence>
        </main>

        <nav aria-label="Main" className="flex shrink-0 gap-1 border-t bg-background/95 px-2 pb-2 pt-1.5 backdrop-blur md:hidden">
          {navButtons(true)}
        </nav>
      </div>

      <TransferDialog open={!!transfer} onClose={() => setTransfer(null)} accounts={accounts} fmt={fmt} defaultFrom={transfer?.from} payees={payees} onSubmit={doTransfer} />

      <div className="pointer-events-none absolute inset-x-0 bottom-20 z-[60] flex flex-col items-center gap-2 px-4 md:bottom-5" aria-live="polite">
        <AnimatePresence>
          {toasts.map((t) => (
            <motion.div
              key={t.id}
              layout
              initial={{ opacity: 0, y: 16, scale: 0.96 }}
              animate={{ opacity: 1, y: 0, scale: 1 }}
              exit={{ opacity: 0, y: 8, scale: 0.96 }}
              className="pointer-events-auto flex max-w-full items-center gap-3 rounded-2xl bg-foreground py-2 pl-4 pr-2 text-sm text-background shadow-xl"
            >
              <span className="truncate">{t.text}</span>
              {t.undo && (
                <button
                  type="button"
                  onClick={() => {
                    t.undo?.();
                    setToasts((ts) => ts.filter((x) => x.id !== t.id));
                  }}
                  className="rounded-lg bg-background/15 px-2.5 py-1 text-xs font-semibold hover:bg-background/25 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
                >
                  Undo
                </button>
              )}
              <button type="button" aria-label="Dismiss" onClick={() => setToasts((ts) => ts.filter((x) => x.id !== t.id))} className="grid size-7 place-items-center rounded-lg opacity-70 hover:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
                <X className="size-3.5" />
              </button>
            </motion.div>
          ))}
        </AnimatePresence>
      </div>
    </div>
  );
}

export default FinanceApp;

More in Dashboards

View all →