Initial commit: MyMidas personal finance tracker
Full-stack self-hosted finance app with FastAPI backend and React frontend. Features: - Accounts, transactions, budgets, investments with GBP base currency - CSV import with auto-detection for 10 UK bank formats - ML predictions: spending forecast, net worth projection, Monte Carlo - 7 selectable themes (Obsidian, Arctic, Midnight, Vault, Terminal, Synthwave, Ledger) - Receipt/document attachments on transactions (JPEG, PNG, WebP, PDF) - AES-256-GCM field encryption, RS256 JWT, TOTP 2FA, RLS, audit log - Encrypted nightly backups + key rotation script - Mobile-responsive layout with bottom nav Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
commit
61a7884ee5
127 changed files with 13323 additions and 0 deletions
295
frontend/src/pages/accounts/AccountList.tsx
Normal file
295
frontend/src/pages/accounts/AccountList.tsx
Normal file
|
|
@ -0,0 +1,295 @@
|
|||
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 { 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 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-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)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{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)}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
function AccountGroup({
|
||||
title,
|
||||
accounts,
|
||||
onEdit,
|
||||
onDelete,
|
||||
muted = false,
|
||||
}: {
|
||||
title: string;
|
||||
accounts: Account[];
|
||||
onEdit: (a: Account) => void;
|
||||
onDelete: (id: string) => void;
|
||||
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">
|
||||
<p className={cn("font-semibold tabular-nums", isLiability ? "text-destructive" : "text-foreground")}>
|
||||
{formatCurrency(account.current_balance, account.currency)}
|
||||
</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-0 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-yellow-500" : "bg-success")}
|
||||
style={{ width: `${utilPct}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue