MyMidas/frontend/src/pages/accounts/AccountList.tsx
megaproxy 0b326cbd87 fix: theme consistency — chart colours, axis readability, warning system
- 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>
2026-04-23 12:58:06 +00:00

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