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
448
frontend/src/pages/accounts/AccountDetail.tsx
Normal file
448
frontend/src/pages/accounts/AccountDetail.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
218
frontend/src/pages/accounts/AccountFormModal.tsx
Normal file
218
frontend/src/pages/accounts/AccountFormModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
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