import { BankReconnectButton } from "@/components/plaid/BankReconnectButton"; import { ConnectBankButton } from "@/components/plaid/ConnectBankButton"; import { formatMoney } from "@/lib/format"; import { useState, useMemo } from "react"; import { Layout } from "@/components/Layout"; import { Card, CardContent } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"; import { useFinanceData } from "@/context/FinanceDataContext"; import { cn } from "@/lib/utils"; import { api } from "@/lib/api"; import { Plus, Landmark, CreditCard, PiggyBank, TrendingUp, Banknote, RefreshCw, Building2, Pencil, Car, Home, Wallet, NotebookPen } from "lucide-react"; import { useNavigate, Link } from "react-router-dom"; import { toast } from "sonner"; import { formatDistanceToNow } from "date-fns"; import { useDocumentTitle } from "@/hooks/useDocumentTitle"; import type { Account } from "@/types/finance"; import { useMoney } from "@/hooks/useMoney"; import { AnimatedMoney } from "@/components/elements/AnimatedMoney"; import { AccountDialog } from "@/components/accounts/AccountDialog"; import { PageSkeleton } from "@/components/skeletons/LoadingSkeletons"; import { isDebtAccount, isBankAccount, isAssetAccount, accountDisplayName, accountSubtitle, isIlliquidAsset } from "@/lib/accounts"; import { ACCOUNT_TINTS } from "@/components/accounts/accountTints"; import { WeightBar } from "@/components/dashboard/WeightBar"; import { shareColor } from "@/components/dashboard/shareColors"; import { Progress } from "@/components/ui/progress"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog"; const typeIcons: Record = { checking: Landmark, savings: PiggyBank, credit: CreditCard, investment: TrendingUp, loan: Banknote, vehicle: Car, property: Home, cash: Wallet, }; const typeBorderColors: Record = { checking: "border-l-chart-blue", savings: "border-l-chart-green", credit: "border-l-chart-orange", investment: "border-l-chart-purple", loan: "border-l-chart-red", vehicle: "border-l-chart-yellow", property: "border-l-chart-yellow", cash: "border-l-chart-green", }; export type AccountsScope = "bank" | "assets"; const BANK_TYPES: Account["type"][] = ["checking", "savings", "credit"]; const ASSET_TYPES: Account["type"][] = ["cash", "investment", "property", "vehicle", "loan"]; /** * The account list, shared by two pages: Accounts (checking, savings, credit * cards: the everyday money) and Assets (cash on hand, investments, property, * vehicles and loans: what you own and owe beyond that). */ export const AccountsView = ({ scope }: { scope: AccountsScope }) => { const isBank = scope === "bank"; const pageTitle = isBank ? "Accounts" : "Assets"; const scopeTypes = isBank ? BANK_TYPES : ASSET_TYPES; const navigate = useNavigate(); const { accounts, summaryAccounts, hasData, isLoading, plaidItems, syncPlaid, refreshData, addAccount, updateAccount, deleteAccount, } = useFinanceData(); const { moneyAbs: fmtCurrency } = useMoney(); const [editAccount, setEditAccount] = useState(null); const [nickInput, setNickInput] = useState(""); const [savingNick, setSavingNick] = useState(false); const [editorOpen, setEditorOpen] = useState(false); const [editorAccount, setEditorAccount] = useState(null); const [confirmDelete, setConfirmDelete] = useState(null); useDocumentTitle(pageTitle); // Real sync time comes from the Plaid item (a true timestamp updated every // sync), keyed by `plaid-`. const syncedAtById = useMemo(() => { const m: Record = {}; plaidItems.forEach((item) => { if (!item?.last_synced) return; (item.accounts || []).forEach((a: { account_id: string }) => { m[`plaid-${a.account_id}`] = item.last_synced; }); }); return m; }, [plaidItems]); const scoped = useMemo( () => accounts.filter((a) => (isBank ? isBankAccount(a.type) : isAssetAccount(a.type))), [accounts, isBank] ); const ofType = (...types: Account["type"][]) => scoped.filter((a) => types.includes(a.type)); const sections = isBank ? [ { title: "Bank accounts", list: ofType("checking", "savings") }, { title: "Credit cards", list: ofType("credit") }, ] : [ { title: "Cash", list: ofType("cash") }, { title: "Investments", list: ofType("investment") }, { title: "Property & vehicles", list: ofType("property", "vehicle") }, { title: "Loans", list: ofType("loan") }, ]; const sum = (list: Account[]) => list.filter(a => summaryAccounts.some(s => s.id === a.id)).reduce((t, a) => t + a.balance, 0); const owedTotal = ofType("loan").filter(a => summaryAccounts.some(s => s.id === a.id)).reduce((t, a) => t + Math.abs(a.balance), 0); const ownedTotal = sum(ofType("cash", "investment", "property", "vehicle")); const syncLabel = (account: Account) => { const iso = syncedAtById[account.id] || account.lastSynced; const d = iso ? new Date(iso) : null; const item = plaidItems.find(item => item.accounts?.some(a => `plaid-${a.account_id}` === account.id)); const stamp = d && !isNaN(d.getTime()) ? `Synced ${formatDistanceToNow(d, { addSuffix: true })}` : "Not synced yet"; return item?.requires_reconnect ? `${stamp} · Sign-in required` : item?.sync_error ? `${stamp} · Sync needs attention` : stamp; }; /** Only Plaid-backed accounts have anything to sync. */ const isPlaid = (a: Account) => a.id.startsWith("plaid-"); const openRename = (account: Account) => { setEditAccount(account); setNickInput(account.nickname?.trim() || account.name); }; const saveNickname = async () => { if (!editAccount) return; setSavingNick(true); try { // Empty input clears the nickname (falls back to the bank-given name). const next = nickInput.trim() === editAccount.name ? "" : nickInput.trim(); await api.put("accounts", editAccount.id, { nickname: next }); refreshData(); toast.success("Account name updated"); setEditAccount(null); } catch { toast.error("Couldn't update the name"); } finally { setSavingNick(false); } }; if (isLoading) { return ( ); } if (!hasData || scoped.length === 0) { return (

{pageTitle}

{isBank ? "Manage your connected accounts" : "Cash, investments, property, vehicles and loans"}

{isBank ? "No accounts connected" : "No assets or loans yet"}

{isBank ? "Connect your bank securely to see your balances and transactions here." : "Add an investment account, a car, a home or a loan to count it toward your net worth."}

{isBank ? ( ) : ( )} {!isBank && }
{ setEditorOpen(o); if (!o) setEditorAccount(null); }} account={editorAccount} types={scopeTypes} onAdd={addAccount} onUpdate={updateAccount} /> !o && setConfirmDelete(null)}> Remove "{confirmDelete && accountDisplayName(confirmDelete)}"? The account is removed from your list. Transactions recorded against it stay in your history, so your totals do not change. Cancel { const target = confirmDelete; setConfirmDelete(null); if (!target) return; try { await deleteAccount(target.id); toast.success(`${accountDisplayName(target)} removed`); } catch { toast.error("Couldn't remove the account"); } }} > Remove
); } const renderCard = (account: Account) => { const fmtCurrency = (n: number) => formatMoney(Math.abs(n), account.currency || "USD"); const Icon = typeIcons[account.type] || Landmark; const borderColor = typeBorderColors[account.type] || "border-l-primary"; const isDebt = isDebtAccount(account.type); const owed = Math.abs(Math.min(account.balance, 0)); const availableCredit = account.available ?? null; const plaidItem = plaidItems.find(item => item.accounts?.some(a => `plaid-${a.account_id}` === account.id)); return ( navigate(`/accounts/${account.id}`)} >

{accountDisplayName(account)}

{accountSubtitle(account)}

{isDebt ? ( availableCredit !== null ? ( <>

Available credit

) : ( <>

Balance owed

) ) : ( <>

= 0 ? "text-foreground" : "text-chart-red")}> {account.balance < 0 ? "-" : ""}

{account.type}

)}
{/* Credit card secondary line: what you owe + limit, with a utilization bar */} {isDebt && availableCredit !== null && (
{fmtCurrency(owed)} owed {account.creditLimit != null ? ( Limit {fmtCurrency(account.creditLimit)} ) : account.type === "credit" ? ( Limit not set ) : null}
{account.type === "credit" && account.creditLimit != null && account.creditLimit > 0 && ( div]:bg-chart-green" : "[&>div]:bg-chart-orange")} /> )}
)}
{isPlaid(account) ? ( <> {syncLabel(account)} ) : ( Added manually )}
{/* Linked banks sync on their own, so there is no manual sync button. */} {isPlaid(account) && plaidItem?.requires_reconnect ? (
event.stopPropagation()}>
) : isPlaid(account) ? null : (
)}
); }; /** What sits under an asset's big number: progress on a loan, gain/loss on a car or home. */ const assetDetail = (account: Account) => { const fmtCurrency = (n: number) => formatMoney(Math.abs(n), account.currency || "USD"); if (account.type === "loan") { const paid = account.originalPrincipal && account.originalPrincipal > 0 ? Math.min(100, Math.max(0, ((account.originalPrincipal - Math.abs(account.balance)) / account.originalPrincipal) * 100)) : null; const terms = [ account.monthlyPayment != null ? `${fmtCurrency(account.monthlyPayment)}/mo` : null, account.interestRate != null ? `${account.interestRate}% APR` : null, ].filter(Boolean).join(" · "); if (paid == null && !terms) return null; return (
{paid != null && ( <>

{paid.toFixed(0)}% paid off{terms ? ` · ${terms}` : ""}

)} {paid == null &&

{terms}

}
); } if (isIlliquidAsset(account.type)) { if (account.purchasePrice != null) { const gain = account.balance - account.purchasePrice; return (

= 0 ? "text-chart-green font-medium" : "text-chart-red font-medium"}> {gain >= 0 ? "+" : "-"}{fmtCurrency(gain)} {" "}since purchase ({fmtCurrency(account.purchasePrice)})

); } if (account.purchaseDate) return

Purchased {account.purchaseDate}

; } return null; }; const renderAssetCard = (account: Account) => { const fmtCurrency = (n: number) => formatMoney(Math.abs(n), account.currency || "USD"); const Icon = typeIcons[account.type] || Landmark; const isDebt = isDebtAccount(account.type); const value = isDebt ? Math.abs(account.balance) : account.balance; const caption = isDebt ? "Owed" : account.type === "cash" ? "In cash" : "Current value"; const detail = assetDetail(account); const note = account.notes?.trim(); return ( navigate(`/accounts/${account.id}`)} >

{accountDisplayName(account)}

{accountSubtitle(account)}

{value < 0 ? "-" : ""}{formatMoney(Math.abs(value), account.currency || "USD")}

{caption}

{detail} {note && (

{note}

)}
{isPlaid(account) ? syncLabel(account) : "Added manually"} {!isPlaid(account) && ( )}
); }; // What you own, split for the composition bar (largest first), against what you owe. const composition = [ { key: "cash", label: "Cash", value: sum(ofType("cash")) }, { key: "investment", label: "Investments", value: sum(ofType("investment")) }, { key: "property", label: "Property & vehicles", value: sum(ofType("property", "vehicle")) }, ].filter((g) => g.value > 0).sort((a, b) => b.value - a.value); const ownedPositive = composition.reduce((t, g) => t + g.value, 0); const netAssets = ownedTotal - owedTotal; return (

{pageTitle}

{scoped.length} {isBank ? "connected account" : "asset and loan"}{scoped.length === 1 ? "" : "s"}

{isBank ? ( ) : ( )} {!isBank && }
{!isBank && (

Net assets

{netAssets < 0 ? "-" : ""}{fmtCurrency(netAssets)}

You own
{fmtCurrency(ownedTotal)}
You owe
0 && "text-chart-red")}>{fmtCurrency(owedTotal)}
{composition.length > 0 && (
`${g.label} ${((g.value / ownedPositive) * 100).toFixed(0)}%`).join(", ")}`} shares={composition.map((g) => ({ key: g.key, share: (g.value / ownedPositive) * 100 }))} className="h-2" />
    {composition.map((g, i) => (
  • {g.label} {fmtCurrency(g.value)} {((g.value / ownedPositive) * 100).toFixed(0)}%
  • ))}
)}
)} {sections.filter((sec) => sec.list.length > 0).map((sec) => (

{sec.title} · {sec.list.length}

{!isBank && ( {sec.title === "Loans" ? `${fmtCurrency(owedTotal)} owed` : fmtCurrency(sum(sec.list))} )}
{sec.list.map(isBank ? renderCard : renderAssetCard)}
))}
{/* Rename / nickname dialog */} !o && setEditAccount(null)}> Rename account
setNickInput(e.target.value)} placeholder={editAccount?.name} autoFocus onKeyDown={(e) => { if (e.key === "Enter") void saveNickname(); }} />

A nickname just for you, it won't change anything at your bank. Clear it to use the original name ({editAccount?.name}).

{ setEditorOpen(o); if (!o) setEditorAccount(null); }} account={editorAccount} types={scopeTypes} onAdd={addAccount} onUpdate={updateAccount} /> !o && setConfirmDelete(null)}> Remove "{confirmDelete && accountDisplayName(confirmDelete)}"? The account is removed from your list. Transactions recorded against it stay in your history, so your totals do not change. Cancel { const target = confirmDelete; setConfirmDelete(null); if (!target) return; try { await deleteAccount(target.id); toast.success(`${accountDisplayName(target)} removed`); } catch { toast.error("Couldn't remove the account"); } }} > Remove
); };