- Recharts axes: add fill to tick objects so labels are visible on dark themes - Recharts axes: wrap stroke/gridcolor in hsl() so var() resolves to valid colour - Chart primary lines/gradients: replace hardcoded #6366f1 with hsl(var(--primary)) so charts adopt each theme's accent (gold on Vault, green on Terminal, etc.) - Plotly charts: add cssVar() helper (reads getComputedStyle) to pass actual computed colour strings instead of unresolved var() references - Budget radial gauge: use hsl(var(--destructive/success/warning)) for SVG colours - Add --warning CSS variable to all 7 themes with per-theme appropriate values; wire into Tailwind config as themed colour - Replace all text-yellow-500 / text-orange-500 / bg-yellow-500 with text-warning / bg-warning across Dashboard, Budget, Account, Predictions, Settings Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
330 lines
12 KiB
TypeScript
330 lines
12 KiB
TypeScript
import { useState } from "react";
|
|
import { Link } from "react-router-dom";
|
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
|
import { getAccounts, createAccount, updateAccount, deleteAccount, getNetWorth, type Account } from "@/api/accounts";
|
|
import { getPortfolio } from "@/api/investments";
|
|
import { formatCurrency } from "@/utils/currency";
|
|
import { cn } from "@/utils/cn";
|
|
import {
|
|
Plus, Trash2, Pencil, TrendingUp, Wallet,
|
|
CreditCard, PiggyBank, Building2, Coins, Bitcoin, Landmark, ShieldCheck, Sprout
|
|
} from "lucide-react";
|
|
import AccountFormModal from "./AccountFormModal";
|
|
|
|
const TYPE_ICONS: Record<string, React.ElementType> = {
|
|
checking: Wallet,
|
|
savings: PiggyBank,
|
|
cash_isa: Sprout,
|
|
stocks_shares_isa: TrendingUp,
|
|
credit_card: CreditCard,
|
|
investment: TrendingUp,
|
|
cash: Coins,
|
|
crypto_wallet: Bitcoin,
|
|
loan: Building2,
|
|
mortgage: Landmark,
|
|
pension: ShieldCheck,
|
|
other: Wallet,
|
|
};
|
|
|
|
const TYPE_LABELS: Record<string, string> = {
|
|
checking: "Checking",
|
|
savings: "Savings",
|
|
cash_isa: "Cash ISA",
|
|
stocks_shares_isa: "S&S ISA",
|
|
credit_card: "Credit Card",
|
|
investment: "Investment",
|
|
cash: "Cash",
|
|
crypto_wallet: "Crypto",
|
|
loan: "Loan",
|
|
mortgage: "Mortgage",
|
|
pension: "Pension",
|
|
other: "Other",
|
|
};
|
|
|
|
const LIABILITY_TYPES = new Set(["credit_card", "loan", "mortgage"]);
|
|
|
|
export default function AccountList() {
|
|
const qc = useQueryClient();
|
|
const [showCreate, setShowCreate] = useState(false);
|
|
const [editing, setEditing] = useState<Account | null>(null);
|
|
|
|
const { data: accounts = [], isLoading } = useQuery({
|
|
queryKey: ["accounts"],
|
|
queryFn: getAccounts,
|
|
});
|
|
|
|
const { data: nw } = useQuery({
|
|
queryKey: ["net-worth"],
|
|
queryFn: getNetWorth,
|
|
});
|
|
|
|
const { data: portfolio } = useQuery({
|
|
queryKey: ["portfolio"],
|
|
queryFn: getPortfolio,
|
|
});
|
|
|
|
const holdingValueByAccount = (portfolio?.holdings ?? []).reduce<Record<string, number>>((acc, h) => {
|
|
const v = Number(h.current_value || h.cost_basis_total);
|
|
acc[h.account_id] = (acc[h.account_id] ?? 0) + v;
|
|
return acc;
|
|
}, {});
|
|
|
|
const invalidate = () => {
|
|
qc.invalidateQueries({ queryKey: ["accounts"] });
|
|
qc.invalidateQueries({ queryKey: ["net-worth"] });
|
|
};
|
|
|
|
const deleteMutation = useMutation({
|
|
mutationFn: deleteAccount,
|
|
onSuccess: invalidate,
|
|
});
|
|
|
|
const createMutation = useMutation({
|
|
mutationFn: createAccount,
|
|
onSuccess: () => { invalidate(); setShowCreate(false); },
|
|
});
|
|
|
|
const updateMutation = useMutation({
|
|
mutationFn: ({ id, data }: { id: string; data: Parameters<typeof updateAccount>[1] }) =>
|
|
updateAccount(id, data),
|
|
onSuccess: () => { invalidate(); setEditing(null); },
|
|
});
|
|
|
|
const assets = accounts.filter(a => !LIABILITY_TYPES.has(a.type) && a.is_active);
|
|
const liabilities = accounts.filter(a => LIABILITY_TYPES.has(a.type) && a.is_active);
|
|
const inactive = accounts.filter(a => !a.is_active);
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h1 className="text-2xl font-bold">Accounts</h1>
|
|
<p className="text-sm text-muted-foreground mt-1">Manage your financial accounts</p>
|
|
</div>
|
|
<button
|
|
onClick={() => setShowCreate(true)}
|
|
className="flex items-center gap-2 bg-primary text-primary-foreground px-4 py-2 rounded-lg text-sm font-medium hover:bg-primary/90 transition-colors"
|
|
>
|
|
<Plus className="w-4 h-4" />
|
|
Add Account
|
|
</button>
|
|
</div>
|
|
|
|
{nw && (
|
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
|
{[
|
|
{ label: "Total Assets", value: nw.total_assets, positive: true },
|
|
{ label: "Total Liabilities", value: nw.total_liabilities, positive: nw.total_liabilities === 0 },
|
|
{ label: "Net Worth", value: nw.net_worth, positive: nw.net_worth >= 0 },
|
|
].map(({ label, value, positive }) => (
|
|
<div key={label} className="bg-card border border-border rounded-xl p-4">
|
|
<p className="text-xs text-muted-foreground mb-1">{label}</p>
|
|
<p className={cn("text-xl font-bold", positive ? "text-success" : "text-destructive")}>
|
|
{formatCurrency(value, nw.base_currency)}
|
|
</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{isLoading && (
|
|
<div className="space-y-2">
|
|
{[1, 2, 3].map(i => (
|
|
<div key={i} className="h-20 bg-card border border-border rounded-xl animate-pulse" />
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{assets.length > 0 && (
|
|
<AccountGroup
|
|
title="Assets"
|
|
accounts={assets}
|
|
onEdit={setEditing}
|
|
onDelete={id => deleteMutation.mutate(id)}
|
|
holdingValueByAccount={holdingValueByAccount}
|
|
/>
|
|
)}
|
|
|
|
{liabilities.length > 0 && (
|
|
<AccountGroup
|
|
title="Liabilities"
|
|
accounts={liabilities}
|
|
onEdit={setEditing}
|
|
onDelete={id => deleteMutation.mutate(id)}
|
|
/>
|
|
)}
|
|
|
|
{inactive.length > 0 && (
|
|
<AccountGroup
|
|
title="Inactive"
|
|
accounts={inactive}
|
|
onEdit={setEditing}
|
|
onDelete={id => deleteMutation.mutate(id)}
|
|
holdingValueByAccount={holdingValueByAccount}
|
|
muted
|
|
/>
|
|
)}
|
|
|
|
{accounts.length === 0 && !isLoading && (
|
|
<div className="text-center py-16 text-muted-foreground">
|
|
<Wallet className="w-12 h-12 mx-auto mb-3 opacity-30" />
|
|
<p className="font-medium">No accounts yet</p>
|
|
<p className="text-sm mt-1">Add your first account to get started</p>
|
|
</div>
|
|
)}
|
|
|
|
{showCreate && (
|
|
<AccountFormModal
|
|
onClose={() => setShowCreate(false)}
|
|
onSubmit={data => createMutation.mutate(data)}
|
|
isLoading={createMutation.isPending}
|
|
/>
|
|
)}
|
|
|
|
{editing && (
|
|
<AccountFormModal
|
|
account={editing}
|
|
onClose={() => setEditing(null)}
|
|
onSubmit={data => updateMutation.mutate({ id: editing.id, data })}
|
|
isLoading={updateMutation.isPending}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const INVESTMENT_TYPES = new Set(["investment", "pension", "stocks_shares_isa", "crypto_wallet"]);
|
|
|
|
function AccountGroup({
|
|
title,
|
|
accounts,
|
|
onEdit,
|
|
onDelete,
|
|
holdingValueByAccount = {},
|
|
muted = false,
|
|
}: {
|
|
title: string;
|
|
accounts: Account[];
|
|
onEdit: (a: Account) => void;
|
|
onDelete: (id: string) => void;
|
|
holdingValueByAccount?: Record<string, number>;
|
|
muted?: boolean;
|
|
}) {
|
|
return (
|
|
<div>
|
|
<h2 className={cn("text-sm font-semibold uppercase tracking-wider mb-3", muted ? "text-muted-foreground" : "text-foreground")}>
|
|
{title}
|
|
</h2>
|
|
<div className="space-y-2">
|
|
{accounts.map(account => {
|
|
const Icon = TYPE_ICONS[account.type] || Wallet;
|
|
const isLiability = LIABILITY_TYPES.has(account.type);
|
|
const utilPct = account.credit_limit && account.credit_limit > 0
|
|
? Math.min(100, (Math.abs(account.current_balance) / account.credit_limit) * 100)
|
|
: null;
|
|
|
|
return (
|
|
<div
|
|
key={account.id}
|
|
className="bg-card border border-border rounded-xl p-4 hover:border-primary/30 transition-colors group"
|
|
>
|
|
<div className="flex items-center gap-4">
|
|
<div className="p-2.5 rounded-lg shrink-0" style={{ backgroundColor: account.color + "20" }}>
|
|
<Icon className="w-5 h-5" style={{ color: account.color }} />
|
|
</div>
|
|
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
<Link to={`/accounts/${account.id}`} className="font-medium truncate hover:text-primary transition-colors">
|
|
{account.name}
|
|
</Link>
|
|
<span className="text-xs text-muted-foreground bg-secondary px-1.5 py-0.5 rounded shrink-0">
|
|
{TYPE_LABELS[account.type] || account.type}
|
|
</span>
|
|
{!account.include_in_net_worth && (
|
|
<span className="text-xs text-muted-foreground italic">excluded from net worth</span>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-3 mt-0.5 flex-wrap">
|
|
{account.institution && (
|
|
<p className="text-xs text-muted-foreground">{account.institution}</p>
|
|
)}
|
|
{account.interest_rate != null && (
|
|
<p className="text-xs text-muted-foreground">
|
|
<span className="font-medium text-foreground">{Number(account.interest_rate).toFixed(2)}%</span> p.a.
|
|
</p>
|
|
)}
|
|
{account.notes && (
|
|
<p className="text-xs text-muted-foreground truncate max-w-xs">{account.notes}</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="text-right shrink-0">
|
|
{(() => {
|
|
const holdingVal = INVESTMENT_TYPES.has(account.type) ? (holdingValueByAccount[account.id] ?? 0) : 0;
|
|
const cashBal = Number(account.current_balance);
|
|
const total = cashBal + holdingVal;
|
|
return (
|
|
<>
|
|
<p className={cn("font-semibold tabular-nums", isLiability ? "text-destructive" : "text-foreground")}>
|
|
{formatCurrency(holdingVal > 0 ? total : cashBal, account.currency)}
|
|
</p>
|
|
{holdingVal > 0 && cashBal !== 0 && (
|
|
<p className="text-xs text-muted-foreground">
|
|
{formatCurrency(cashBal, account.currency)} cash + {formatCurrency(holdingVal, account.currency)} holdings
|
|
</p>
|
|
)}
|
|
{holdingVal > 0 && cashBal === 0 && (
|
|
<p className="text-xs text-muted-foreground">holdings value</p>
|
|
)}
|
|
{account.credit_limit != null && (
|
|
<p className="text-xs text-muted-foreground">
|
|
limit {formatCurrency(account.credit_limit, account.currency)}
|
|
</p>
|
|
)}
|
|
</>
|
|
);
|
|
})()}
|
|
</div>
|
|
|
|
<div className="flex items-center gap-1 opacity-100 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity shrink-0">
|
|
<button
|
|
onClick={() => onEdit(account)}
|
|
className="p-1.5 rounded text-muted-foreground hover:text-foreground hover:bg-secondary"
|
|
title="Edit account"
|
|
>
|
|
<Pencil className="w-4 h-4" />
|
|
</button>
|
|
<button
|
|
onClick={() => onDelete(account.id)}
|
|
className="p-1.5 rounded text-muted-foreground hover:text-destructive hover:bg-destructive/10"
|
|
title="Delete account"
|
|
>
|
|
<Trash2 className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Credit utilisation bar */}
|
|
{utilPct !== null && (
|
|
<div className="mt-3">
|
|
<div className="flex justify-between text-xs text-muted-foreground mb-1">
|
|
<span>Credit used</span>
|
|
<span>{utilPct.toFixed(0)}%</span>
|
|
</div>
|
|
<div className="h-1.5 bg-secondary rounded-full overflow-hidden">
|
|
<div
|
|
className={cn("h-full rounded-full transition-all", utilPct > 80 ? "bg-destructive" : utilPct > 50 ? "bg-warning" : "bg-success")}
|
|
style={{ width: `${utilPct}%` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|