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>
448 lines
20 KiB
TypeScript
448 lines
20 KiB
TypeScript
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>
|
|
);
|
|
}
|
|
|