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:
megaproxy 2026-04-21 11:56:10 +00:00
commit 61a7884ee5
127 changed files with 13323 additions and 0 deletions

View file

@ -0,0 +1,448 @@
import { useState, useRef } from "react";
import { useParams, Link } from "react-router-dom";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { getAccounts, previewImport, importCsvToAccount, type CsvMapping } from "@/api/accounts";
import { getTransactions } from "@/api/transactions";
import { formatCurrency } from "@/utils/currency";
import { cn } from "@/utils/cn";
import { format } from "date-fns";
import {
ArrowLeft, Upload, FileText, XCircle, Loader2, CheckCircle,
ArrowUpCircle, ArrowDownCircle, ArrowLeftRight, ChevronLeft, ChevronRight,
} from "lucide-react";
export default function AccountDetail() {
const { accountId } = useParams<{ accountId: string }>();
const qc = useQueryClient();
const [showImport, setShowImport] = useState(false);
const [page, setPage] = useState(1);
const { data: accounts = [] } = useQuery({ queryKey: ["accounts"], queryFn: getAccounts });
const account = accounts.find(a => a.id === accountId);
const { data: txnData, isLoading: txnLoading } = useQuery({
queryKey: ["transactions", { account_id: accountId, page, page_size: 25 }],
queryFn: () => getTransactions({ account_id: accountId, page, page_size: 25 }),
enabled: !!accountId,
});
if (!account) {
return (
<div className="flex items-center justify-center h-64 text-muted-foreground">
<p>Account not found</p>
</div>
);
}
const isLiability = ["credit_card", "loan", "mortgage"].includes(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 className="space-y-6">
{/* Back + header */}
<div className="flex items-center gap-3">
<Link to="/accounts" className="p-2 rounded-lg hover:bg-secondary transition-colors text-muted-foreground">
<ArrowLeft className="w-5 h-5" />
</Link>
<div className="flex-1">
<h1 className="text-2xl font-bold">{account.name}</h1>
<p className="text-sm text-muted-foreground">
{account.institution && `${account.institution} · `}
{account.type.replace(/_/g, " ")} · {account.currency}
</p>
</div>
<button
onClick={() => setShowImport(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"
>
<Upload className="w-4 h-4" />
Import CSV
</button>
</div>
{/* Account stats */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
<div className="bg-card border border-border rounded-xl p-4">
<p className="text-xs text-muted-foreground mb-1">Current Balance</p>
<p className={cn("text-xl font-bold tabular-nums", isLiability ? "text-destructive" : "text-foreground")}>
{formatCurrency(account.current_balance, account.currency)}
</p>
</div>
{account.credit_limit != null && (
<div className="bg-card border border-border rounded-xl p-4">
<p className="text-xs text-muted-foreground mb-1">Credit Limit</p>
<p className="text-xl font-bold tabular-nums">{formatCurrency(account.credit_limit, account.currency)}</p>
</div>
)}
{account.interest_rate != null && (
<div className="bg-card border border-border rounded-xl p-4">
<p className="text-xs text-muted-foreground mb-1">Interest Rate</p>
<p className="text-xl font-bold">{Number(account.interest_rate).toFixed(2)}% p.a.</p>
</div>
)}
{txnData && (
<div className="bg-card border border-border rounded-xl p-4">
<p className="text-xs text-muted-foreground mb-1">Total Transactions</p>
<p className="text-xl font-bold tabular-nums">{txnData.total}</p>
</div>
)}
</div>
{/* Credit utilisation */}
{utilPct !== null && (
<div className="bg-card border border-border rounded-xl p-4">
<div className="flex justify-between text-sm mb-2">
<span className="font-medium">Credit Utilisation</span>
<span className={cn("font-semibold", utilPct > 80 ? "text-destructive" : utilPct > 50 ? "text-yellow-500" : "text-success")}>
{utilPct.toFixed(0)}%
</span>
</div>
<div className="h-2 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>
<p className="text-xs text-muted-foreground mt-1">
{formatCurrency(Math.abs(account.current_balance), account.currency)} used of {formatCurrency(account.credit_limit!, account.currency)}
</p>
</div>
)}
{account.notes && (
<div className="bg-card border border-border rounded-xl p-4">
<p className="text-xs text-muted-foreground mb-1">Notes</p>
<p className="text-sm">{account.notes}</p>
</div>
)}
{/* Transactions */}
<div className="bg-card border border-border rounded-xl overflow-hidden">
<div className="flex items-center justify-between px-5 py-4 border-b border-border">
<p className="font-semibold">Transactions</p>
{txnData && txnData.pages > 1 && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span>Page {page} of {txnData.pages}</span>
<button onClick={() => setPage(p => Math.max(1, p - 1))} disabled={page === 1} className="p-1 rounded hover:bg-secondary disabled:opacity-40">
<ChevronLeft className="w-4 h-4" />
</button>
<button onClick={() => setPage(p => Math.min(txnData.pages, p + 1))} disabled={page === txnData.pages} className="p-1 rounded hover:bg-secondary disabled:opacity-40">
<ChevronRight className="w-4 h-4" />
</button>
</div>
)}
</div>
{txnLoading ? (
<div className="space-y-px">
{[1, 2, 3, 4, 5].map(i => <div key={i} className="h-14 bg-secondary/20 animate-pulse" />)}
</div>
) : !txnData?.items.length ? (
<div className="py-16 text-center text-muted-foreground text-sm">
No transactions yet.{" "}
<button onClick={() => setShowImport(true)} className="text-primary hover:underline">Import from CSV</button>
</div>
) : (
<div>
{txnData.items.map(txn => (
<div key={txn.id} className="flex items-center gap-3 px-5 py-3 border-b border-border/50 hover:bg-secondary/20 transition-colors">
<div className={cn("p-1.5 rounded-lg shrink-0",
txn.type === "income" ? "bg-success/10" :
txn.type === "transfer" ? "bg-primary/10" : "bg-destructive/10"
)}>
{txn.type === "income"
? <ArrowUpCircle className="w-3.5 h-3.5 text-success" />
: txn.type === "transfer"
? <ArrowLeftRight className="w-3.5 h-3.5 text-primary" />
: <ArrowDownCircle className="w-3.5 h-3.5 text-destructive" />}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{txn.description}</p>
<p className="text-xs text-muted-foreground">{format(new Date(txn.date), "dd MMM yyyy")}</p>
</div>
<p className={cn("text-sm font-semibold tabular-nums shrink-0",
txn.type === "income" ? "text-success" :
txn.type === "expense" ? "text-destructive" : "text-muted-foreground"
)}>
{Number(txn.amount) > 0 ? "+" : ""}{formatCurrency(txn.amount, txn.currency)}
</p>
</div>
))}
</div>
)}
</div>
{showImport && account && (
<ImportModal
accountId={account.id}
accountName={account.name}
onClose={() => setShowImport(false)}
onSuccess={() => {
qc.invalidateQueries({ queryKey: ["transactions"] });
qc.invalidateQueries({ queryKey: ["accounts"] });
qc.invalidateQueries({ queryKey: ["net-worth"] });
}}
/>
)}
</div>
);
}
// ─── Import Modal ─────────────────────────────────────────────────────────────
type ImportStep = "upload" | "preview" | "done";
function ImportModal({
accountId, accountName, onClose, onSuccess,
}: {
accountId: string;
accountName: string;
onClose: () => void;
onSuccess: () => void;
}) {
const fileRef = useRef<HTMLInputElement>(null);
const [step, setStep] = useState<ImportStep>("upload");
const [file, setFile] = useState<File | null>(null);
const [preview, setPreview] = useState<Awaited<ReturnType<typeof previewImport>> | null>(null);
const [mapping, setMapping] = useState<CsvMapping | null>(null);
const [result, setResult] = useState<{ imported: number; skipped: number } | null>(null);
const [detecting, setDetecting] = useState(false);
const [detectError, setDetectError] = useState<string | null>(null);
const importMutation = useMutation({
mutationFn: () => importCsvToAccount(accountId, file!, mapping!),
onSuccess: (data) => {
setResult(data);
setStep("done");
onSuccess();
},
});
async function handleFileSelect(f: File) {
setFile(f);
setDetecting(true);
setDetectError(null);
try {
const p = await previewImport(accountId, f);
setPreview(p);
setMapping(p.mapping);
setStep("preview");
} catch (e: any) {
setDetectError(e?.response?.data?.detail ?? "Failed to read file");
} finally {
setDetecting(false);
}
}
function onDrop(e: React.DragEvent) {
e.preventDefault();
const f = e.dataTransfer.files[0];
if (f?.name.toLowerCase().endsWith(".csv")) handleFileSelect(f);
}
const isSplit = mapping ? (!!mapping.debit && !!mapping.credit) : false;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm">
<div className="bg-card border border-border rounded-2xl w-full max-w-2xl max-h-[90vh] overflow-y-auto shadow-xl">
{/* Header */}
<div className="flex items-center justify-between p-5 border-b border-border">
<div>
<h2 className="font-semibold text-lg">Import CSV</h2>
<p className="text-xs text-muted-foreground mt-0.5">into {accountName}</p>
</div>
<button onClick={onClose} className="p-1.5 rounded-lg hover:bg-secondary text-muted-foreground">
<XCircle className="w-5 h-5" />
</button>
</div>
<div className="p-5 space-y-5">
{/* Step: upload */}
{step === "upload" && (
<>
<div
onDrop={onDrop}
onDragOver={e => e.preventDefault()}
onClick={() => fileRef.current?.click()}
className="border-2 border-dashed border-border rounded-xl p-10 text-center cursor-pointer hover:border-primary/50 transition-colors"
>
{detecting ? (
<div className="flex flex-col items-center gap-2 text-muted-foreground">
<Loader2 className="w-8 h-8 animate-spin text-primary" />
<p className="text-sm">Detecting format</p>
</div>
) : (
<div className="text-muted-foreground">
<Upload className="w-8 h-8 mx-auto mb-2 opacity-50" />
<p className="text-sm font-medium">Drop your bank CSV here</p>
<p className="text-xs mt-1 opacity-60">Supports Monzo, Starling, Revolut, Barclays, Lloyds, NatWest, HSBC, Santander, Nationwide</p>
</div>
)}
<input
ref={fileRef}
type="file"
accept=".csv"
className="hidden"
onChange={e => { const f = e.target.files?.[0]; if (f) handleFileSelect(f); }}
/>
</div>
{detectError && <p className="text-destructive text-sm text-center">{detectError}</p>}
</>
)}
{/* Step: preview + mapping */}
{step === "preview" && preview && mapping && (
<>
{/* Detected format badge */}
<div className={cn(
"flex items-center gap-2 px-3 py-2 rounded-lg text-sm",
preview.detected_format ? "bg-success/10 text-success" : "bg-yellow-500/10 text-yellow-600"
)}>
{preview.detected_format ? (
<><CheckCircle className="w-4 h-4 shrink-0" /> Detected: <strong>{preview.detected_format}</strong></>
) : (
<><FileText className="w-4 h-4 shrink-0" /> Unknown format please verify column mapping below</>
)}
<span className="ml-auto text-xs opacity-70">{preview.total_rows} rows</span>
</div>
{/* Column mapping */}
<div>
<p className="text-sm font-semibold mb-3">Column Mapping</p>
<div className="grid grid-cols-2 gap-3">
<ColSelect label="Date column *" value={mapping.date} headers={preview.headers}
onChange={v => setMapping(m => m ? { ...m, date: v } : m)} />
<ColSelect label="Description column *" value={mapping.description} headers={preview.headers}
onChange={v => setMapping(m => m ? { ...m, description: v } : m)} />
{isSplit ? (
<>
<ColSelect label="Debit column (money out)" value={mapping.debit ?? ""} headers={["", ...preview.headers]}
onChange={v => setMapping(m => m ? { ...m, debit: v || null } : m)} />
<ColSelect label="Credit column (money in)" value={mapping.credit ?? ""} headers={["", ...preview.headers]}
onChange={v => setMapping(m => m ? { ...m, credit: v || null } : m)} />
</>
) : (
<ColSelect label="Amount column *" value={mapping.amount ?? ""} headers={preview.headers}
onChange={v => setMapping(m => m ? { ...m, amount: v || null } : m)} />
)}
<div className="flex items-end gap-2">
<label className="flex items-center gap-2 text-sm cursor-pointer select-none">
<input
type="checkbox"
checked={isSplit}
onChange={e => {
if (e.target.checked) {
setMapping(m => m ? { ...m, amount: null, debit: preview.headers[0] ?? "", credit: preview.headers[1] ?? "" } : m);
} else {
setMapping(m => m ? { ...m, debit: null, credit: null, amount: preview.headers[0] ?? "" } : m);
}
}}
className="rounded"
/>
Separate debit/credit columns
</label>
</div>
</div>
</div>
{/* Preview table */}
<div>
<p className="text-sm font-semibold mb-2">Preview (first {preview.preview.length} rows)</p>
<div className="overflow-x-auto rounded-lg border border-border">
<table className="w-full text-xs">
<thead className="bg-secondary/40">
<tr>
<th className="text-left px-3 py-2 text-muted-foreground font-medium">Date</th>
<th className="text-left px-3 py-2 text-muted-foreground font-medium">Description</th>
<th className="text-right px-3 py-2 text-muted-foreground font-medium">Amount</th>
</tr>
</thead>
<tbody>
{preview.preview.map((row, i) => (
<tr key={i} className="border-t border-border/50">
<td className="px-3 py-2 text-muted-foreground">{row.date_raw}</td>
<td className="px-3 py-2 truncate max-w-xs">{row.description_raw}</td>
<td className={cn("px-3 py-2 text-right tabular-nums font-medium",
row.amount_raw == null ? "text-muted-foreground" :
row.amount_raw >= 0 ? "text-success" : "text-destructive"
)}>
{row.amount_raw != null ? formatCurrency(row.amount_raw, "GBP") : "—"}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{importMutation.isError && (
<p className="text-destructive text-sm bg-destructive/10 rounded-md px-3 py-2">
{(importMutation.error as any)?.response?.data?.detail ?? "Import failed"}
</p>
)}
<div className="flex gap-3">
<button onClick={() => { setStep("upload"); setFile(null); setPreview(null); }}
className="flex-1 border border-border rounded-lg py-2.5 text-sm hover:bg-secondary transition-colors">
Back
</button>
<button
onClick={() => importMutation.mutate()}
disabled={importMutation.isPending || !mapping.date || !mapping.description || (!isSplit && !mapping.amount) || (isSplit && (!mapping.debit || !mapping.credit))}
className="flex-1 flex items-center justify-center gap-2 bg-primary text-primary-foreground rounded-lg py-2.5 text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-colors"
>
{importMutation.isPending ? <><Loader2 className="w-4 h-4 animate-spin" /> Importing</> : `Import ${preview.total_rows} rows`}
</button>
</div>
</>
)}
{/* Step: done */}
{step === "done" && result && (
<div className="text-center py-8 space-y-4">
<CheckCircle className="w-14 h-14 text-success mx-auto" />
<div>
<p className="text-xl font-bold">{result.imported} transaction{result.imported !== 1 ? "s" : ""} imported</p>
{result.skipped > 0 && (
<p className="text-sm text-muted-foreground mt-1">{result.skipped} duplicate{result.skipped !== 1 ? "s" : ""} skipped</p>
)}
</div>
<div className="flex gap-3 justify-center">
<button onClick={() => { setStep("upload"); setFile(null); setPreview(null); setResult(null); }}
className="border border-border rounded-lg px-5 py-2 text-sm hover:bg-secondary transition-colors">
Import another
</button>
<button onClick={onClose}
className="bg-primary text-primary-foreground rounded-lg px-5 py-2 text-sm font-medium hover:bg-primary/90 transition-colors">
Done
</button>
</div>
</div>
)}
</div>
</div>
</div>
);
}
function ColSelect({ label, value, headers, onChange }: {
label: string; value: string; headers: string[]; onChange: (v: string) => void;
}) {
return (
<div>
<label className="text-xs text-muted-foreground block mb-1">{label}</label>
<select
value={value}
onChange={e => onChange(e.target.value)}
className="w-full rounded-md border border-input bg-background px-2 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
>
{headers.map(h => <option key={h} value={h}>{h || "— none —"}</option>)}
</select>
</div>
);
}

View file

@ -0,0 +1,218 @@
import { useState } from "react";
import { X, Loader2 } from "lucide-react";
import { type Account, type AccountCreate } from "@/api/accounts";
const ACCOUNT_TYPES = [
{ value: "checking", label: "Checking / Current" },
{ value: "savings", label: "Savings" },
{ value: "cash_isa", label: "Cash ISA" },
{ value: "stocks_shares_isa", label: "Stocks & Shares ISA" },
{ value: "credit_card", label: "Credit Card" },
{ value: "investment", label: "Investment" },
{ value: "cash", label: "Cash" },
{ value: "crypto_wallet", label: "Crypto Wallet" },
{ value: "loan", label: "Loan" },
{ value: "mortgage", label: "Mortgage" },
{ value: "pension", label: "Pension" },
{ value: "other", label: "Other" },
];
const COLORS = ["#6366f1", "#22c55e", "#0ea5e9", "#f59e0b", "#ec4899", "#ef4444", "#a855f7", "#10b981", "#64748b"];
interface Props {
account?: Account;
onClose: () => void;
onSubmit: (data: AccountCreate) => void;
isLoading: boolean;
}
export default function AccountFormModal({ account, onClose, onSubmit, isLoading }: Props) {
const isEdit = !!account;
const [form, setForm] = useState({
name: account?.name ?? "",
institution: account?.institution ?? "",
type: account?.type ?? "checking",
currency: account?.currency ?? "GBP",
opening_balance: account ? String(account.current_balance) : "0",
credit_limit: account?.credit_limit != null ? String(account.credit_limit) : "",
interest_rate: account?.interest_rate != null ? String(account.interest_rate) : "",
include_in_net_worth: account?.include_in_net_worth ?? true,
color: account?.color ?? "#6366f1",
notes: account?.notes ?? "",
});
const [error, setError] = useState<string | null>(null);
const showCreditFields = form.type === "credit_card";
const showInterestFields = ["loan", "mortgage", "credit_card", "savings", "cash_isa", "pension"].includes(form.type);
function set(key: string, value: string | boolean) {
setForm(f => ({ ...f, [key]: value }));
}
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!form.name.trim()) { setError("Account name is required"); return; }
setError(null);
onSubmit({
name: form.name.trim(),
institution: form.institution || undefined,
type: form.type,
currency: form.currency || "GBP",
opening_balance: parseFloat(form.opening_balance) || 0,
credit_limit: form.credit_limit ? parseFloat(form.credit_limit) : undefined,
interest_rate: form.interest_rate ? parseFloat(form.interest_rate) : undefined,
include_in_net_worth: form.include_in_net_worth,
color: form.color,
notes: form.notes || undefined,
});
}
const inputCls = "w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring";
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60">
<div className="bg-card border border-border rounded-xl w-full max-w-md max-h-[90vh] overflow-y-auto">
<div className="flex items-center justify-between p-6 border-b border-border">
<h2 className="text-lg font-semibold">{isEdit ? "Edit Account" : "Add Account"}</h2>
<button onClick={onClose} className="text-muted-foreground hover:text-foreground">
<X className="w-5 h-5" />
</button>
</div>
<form onSubmit={handleSubmit} noValidate className="p-6 space-y-4">
{/* Name */}
<div>
<label className="text-sm font-medium block mb-1.5">Account Name *</label>
<input
value={form.name}
onChange={e => set("name", e.target.value)}
className={inputCls}
placeholder="e.g. Barclays Current"
/>
</div>
{/* Type (only for create) */}
{!isEdit && (
<div>
<label className="text-sm font-medium block mb-1.5">Account Type *</label>
<select value={form.type} onChange={e => set("type", e.target.value)} className={inputCls}>
{ACCOUNT_TYPES.map(t => <option key={t.value} value={t.value}>{t.label}</option>)}
</select>
</div>
)}
{isEdit && (
<div className="text-sm text-muted-foreground">
Type: <span className="text-foreground font-medium">{ACCOUNT_TYPES.find(t => t.value === form.type)?.label ?? form.type}</span>
</div>
)}
{/* Institution + Currency */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-sm font-medium block mb-1.5">Institution</label>
<input value={form.institution} onChange={e => set("institution", e.target.value)} className={inputCls} placeholder="e.g. Barclays" />
</div>
<div>
<label className="text-sm font-medium block mb-1.5">Currency</label>
<input value={form.currency} onChange={e => set("currency", e.target.value)} className={inputCls} placeholder="GBP" />
</div>
</div>
{/* Balance (label changes based on edit vs create) */}
<div>
<label className="text-sm font-medium block mb-1.5">{isEdit ? "Current Balance" : "Opening Balance"}</label>
<input
type="number" step="0.01"
value={form.opening_balance}
onChange={e => set("opening_balance", e.target.value)}
className={inputCls}
placeholder="0.00"
/>
</div>
{/* Credit limit */}
{showCreditFields && (
<div>
<label className="text-sm font-medium block mb-1.5">Credit Limit</label>
<input
type="number" step="0.01"
value={form.credit_limit}
onChange={e => set("credit_limit", e.target.value)}
className={inputCls}
placeholder="e.g. 5000"
/>
</div>
)}
{/* Interest rate */}
{showInterestFields && (
<div>
<label className="text-sm font-medium block mb-1.5">Interest Rate (% p.a.)</label>
<input
type="number" step="0.01"
value={form.interest_rate}
onChange={e => set("interest_rate", e.target.value)}
className={inputCls}
placeholder="e.g. 3.99"
/>
</div>
)}
{/* Color picker */}
<div>
<label className="text-sm font-medium block mb-1.5">Colour</label>
<div className="flex gap-2 flex-wrap">
{COLORS.map(c => (
<button
key={c}
type="button"
onClick={() => set("color", c)}
className="w-7 h-7 rounded-full border-2 transition-all"
style={{ backgroundColor: c, borderColor: form.color === c ? "white" : "transparent" }}
/>
))}
</div>
</div>
{/* Include in net worth */}
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={form.include_in_net_worth}
onChange={e => set("include_in_net_worth", e.target.checked)}
className="rounded"
/>
<span className="text-sm">Include in net worth</span>
</label>
{/* Notes */}
<div>
<label className="text-sm font-medium block mb-1.5">Notes</label>
<textarea
value={form.notes}
onChange={e => set("notes", e.target.value)}
rows={2}
className={`${inputCls} resize-none`}
placeholder="Optional notes about this account"
/>
</div>
{error && <p className="text-destructive text-sm bg-destructive/10 rounded-md px-3 py-2">{error}</p>}
<div className="flex gap-3 pt-2">
<button type="button" onClick={onClose} className="flex-1 border border-border rounded-lg py-2 text-sm hover:bg-secondary transition-colors">
Cancel
</button>
<button type="submit" disabled={isLoading} className="flex-1 flex items-center justify-center gap-2 bg-primary text-primary-foreground rounded-lg py-2 text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-colors">
{isLoading && <Loader2 className="w-4 h-4 animate-spin" />}
{isEdit ? "Save Changes" : "Create Account"}
</button>
</div>
</form>
</div>
</div>
);
}

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