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

View file

@ -0,0 +1,188 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { login, loginTotp, getMe } from "@/api/auth";
import { useAuthStore } from "@/store/authStore";
import { DollarSign, Eye, EyeOff, Loader2, ShieldCheck } from "lucide-react";
export default function LoginPage() {
const navigate = useNavigate();
const { setToken, setTotpEnabled } = useAuthStore();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [showPassword, setShowPassword] = useState(false);
const [totpCode, setTotpCode] = useState("");
const [challengeToken, setChallengeToken] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
async function handleLogin(e: React.FormEvent) {
e.preventDefault();
if (!email || !password) {
setError("Please enter your email and password.");
return;
}
setError(null);
setLoading(true);
try {
const res = await login(email, password);
if (res.totp_required && res.challenge_token) {
setChallengeToken(res.challenge_token);
return;
}
if (res.access_token) {
// Set token first so getMe() has the Authorization header
setToken(res.access_token, "", "");
const me = await getMe();
setToken(res.access_token, me.id, me.display_name ?? me.email);
setTotpEnabled(me.totp_enabled);
navigate("/");
}
} catch (e: unknown) {
const detail = (e as { response?: { data?: { detail?: unknown } } }).response?.data?.detail;
if (typeof detail === "string") {
setError(detail);
} else if (Array.isArray(detail)) {
setError((detail[0] as { msg?: string })?.msg ?? "Login failed");
} else {
setError("Login failed. Check your credentials and try again.");
}
} finally {
setLoading(false);
}
}
async function handleTotp(e: React.FormEvent) {
e.preventDefault();
if (!challengeToken) return;
setError(null);
setLoading(true);
try {
const res = await loginTotp(challengeToken, totpCode);
if (res.access_token) {
setToken(res.access_token, "", "");
const me = await getMe();
setToken(res.access_token, me.id, me.display_name ?? me.email);
setTotpEnabled(me.totp_enabled);
navigate("/");
}
} catch {
setError("Invalid TOTP code. Try again.");
} finally {
setLoading(false);
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-background p-4">
<div className="w-full max-w-md">
<div className="flex items-center justify-center gap-2 mb-8">
<div className="p-2 rounded-xl bg-primary/20">
<DollarSign className="w-8 h-8 text-primary" />
</div>
<span className="text-2xl font-bold">Finance Tracker</span>
</div>
<div className="bg-card border border-border rounded-xl p-8 shadow-xl">
{!challengeToken ? (
<>
<h1 className="text-xl font-semibold mb-6">Sign in</h1>
<form onSubmit={handleLogin} className="space-y-4" noValidate>
<div>
<label className="text-sm font-medium text-foreground block mb-1.5">Email</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
autoComplete="email"
autoFocus
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
placeholder="you@example.com"
/>
</div>
<div>
<label className="text-sm font-medium text-foreground block mb-1.5">Password</label>
<div className="relative">
<input
type={showPassword ? "text" : "password"}
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
className="w-full rounded-md border border-input bg-background px-3 py-2 pr-10 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
</div>
{error && (
<p className="text-destructive text-sm bg-destructive/10 rounded-md px-3 py-2 font-medium">
{error}
</p>
)}
<button
type="submit"
disabled={loading}
className="w-full flex items-center justify-center gap-2 bg-primary text-primary-foreground rounded-md py-2.5 text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-colors"
>
{loading && <Loader2 className="w-4 h-4 animate-spin" />}
{loading ? "Signing in…" : "Sign in"}
</button>
</form>
</>
) : (
<>
<div className="flex items-center gap-2 mb-4">
<ShieldCheck className="w-5 h-5 text-primary" />
<h1 className="text-xl font-semibold">Two-factor authentication</h1>
</div>
<p className="text-sm text-muted-foreground mb-6">
Enter the 6-digit code from your authenticator app.
</p>
<form onSubmit={handleTotp} className="space-y-4" noValidate>
<input
type="text"
inputMode="numeric"
maxLength={6}
value={totpCode}
onChange={(e) => setTotpCode(e.target.value)}
autoComplete="one-time-code"
autoFocus
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-center tracking-widest text-lg font-mono focus:outline-none focus:ring-2 focus:ring-ring"
placeholder="000000"
/>
{error && (
<p className="text-destructive text-sm bg-destructive/10 rounded-md px-3 py-2 font-medium">
{error}
</p>
)}
<button
type="submit"
disabled={loading}
className="w-full flex items-center justify-center gap-2 bg-primary text-primary-foreground rounded-md py-2.5 text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-colors"
>
{loading && <Loader2 className="w-4 h-4 animate-spin" />}
{loading ? "Verifying…" : "Verify"}
</button>
<button
type="button"
onClick={() => setChallengeToken(null)}
className="w-full text-sm text-muted-foreground hover:text-foreground transition-colors"
>
Back to login
</button>
</form>
</>
)}
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,159 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { getTotpSetup, enableTotp } from "@/api/auth";
import { useAuthStore } from "@/store/authStore";
import { useQuery, useMutation } from "@tanstack/react-query";
import { ShieldCheck, Copy, CheckCircle, Loader2 } from "lucide-react";
const schema = z.object({ code: z.string().length(6, "6-digit code required") });
type Form = z.infer<typeof schema>;
export default function TwoFactorSetupPage() {
const navigate = useNavigate();
const { setTotpEnabled } = useAuthStore();
const [copied, setCopied] = useState(false);
const [secret, setSecret] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const { data, isLoading } = useQuery({
queryKey: ["totp-setup"],
queryFn: async () => {
const res = await getTotpSetup();
setSecret(res.secret);
return res;
},
});
const { register, handleSubmit, formState } = useForm<Form>({
resolver: zodResolver(schema),
});
const enableMutation = useMutation({
mutationFn: ({ code }: { code: string }) => enableTotp(secret!, code),
onSuccess: () => {
setTotpEnabled(true);
navigate("/settings");
},
onError: () => setError("Invalid code — try again"),
});
function copySecret() {
if (data?.secret) {
navigator.clipboard.writeText(data.secret);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
}
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
);
}
return (
<div className="max-w-md mx-auto mt-8">
<div className="bg-card border border-border rounded-xl p-8">
<div className="flex items-center gap-2 mb-6">
<ShieldCheck className="w-6 h-6 text-primary" />
<h1 className="text-xl font-semibold">Set up two-factor authentication</h1>
</div>
{/* QR code */}
<div className="flex justify-center mb-6">
<div className="p-3 bg-white rounded-lg">
{data?.qr_code_png_b64 && (
<img
src={`data:image/png;base64,${data.qr_code_png_b64}`}
alt="TOTP QR code"
className="w-48 h-48"
/>
)}
</div>
</div>
<p className="text-sm text-muted-foreground mb-2 text-center">
Scan with your authenticator app (Authy, Google Authenticator, etc.)
</p>
{/* Manual secret */}
<div className="mb-6">
<p className="text-xs text-muted-foreground mb-1">Or enter the secret manually:</p>
<div className="flex items-center gap-2">
<code className="flex-1 text-xs bg-secondary px-3 py-2 rounded font-mono break-all">
{data?.secret}
</code>
<button onClick={copySecret} className="text-muted-foreground hover:text-foreground">
{copied ? <CheckCircle className="w-4 h-4 text-success" /> : <Copy className="w-4 h-4" />}
</button>
</div>
</div>
{/* Backup codes */}
{data?.backup_codes && (
<div className="mb-6">
<p className="text-xs font-medium text-warning mb-2">
Save these backup codes you can only see them once:
</p>
<div className="grid grid-cols-2 gap-1">
{data.backup_codes.map((code) => (
<code key={code} className="text-xs bg-secondary px-2 py-1 rounded font-mono text-center">
{code}
</code>
))}
</div>
</div>
)}
{/* Verify */}
<form
onSubmit={handleSubmit(({ code }) => enableMutation.mutate({ code }))}
className="space-y-3"
>
<div>
<label className="text-sm font-medium block mb-1.5">
Enter code to confirm setup
</label>
<input
{...register("code")}
type="text"
inputMode="numeric"
maxLength={6}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-center tracking-widest font-mono focus:outline-none focus:ring-2 focus:ring-ring"
placeholder="000000"
/>
{formState.errors.code && (
<p className="text-destructive text-xs mt-1">{formState.errors.code.message}</p>
)}
</div>
{error && (
<p className="text-destructive text-sm bg-destructive/10 rounded px-3 py-2">{error}</p>
)}
<button
type="submit"
disabled={enableMutation.isPending}
className="w-full flex items-center justify-center gap-2 bg-primary text-primary-foreground rounded-md py-2.5 text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-colors"
>
{enableMutation.isPending && <Loader2 className="w-4 h-4 animate-spin" />}
Enable 2FA
</button>
<button
type="button"
onClick={() => navigate("/")}
className="w-full text-sm text-muted-foreground hover:text-foreground transition-colors"
>
Skip for now
</button>
</form>
</div>
</div>
);
}

View file

@ -0,0 +1,164 @@
import { useState } from "react";
import { X } from "lucide-react";
import { BudgetCreate } from "@/api/budgets";
import { format } from "date-fns";
interface Category {
id: string;
name: string;
type: string;
}
interface Props {
categories: Category[];
onClose: () => void;
onSubmit: (data: BudgetCreate) => void;
isLoading: boolean;
}
export default function BudgetFormModal({ categories, onClose, onSubmit, isLoading }: Props) {
const today = format(new Date(), "yyyy-MM-dd");
const [form, setForm] = useState<BudgetCreate>({
category_id: "",
name: "",
amount: 0,
currency: "GBP",
period: "monthly",
start_date: today,
end_date: null,
rollover: false,
alert_threshold: 80,
});
const expenseCategories = categories.filter((c) => c.type === "expense" || c.type === "system");
function set<K extends keyof BudgetCreate>(key: K, value: BudgetCreate[K]) {
setForm((f) => ({ ...f, [key]: value }));
}
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!form.category_id || !form.name || !form.amount) return;
onSubmit(form);
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4">
<div className="bg-card border border-border rounded-2xl w-full max-w-md shadow-xl">
<div className="flex items-center justify-between p-5 border-b border-border">
<h2 className="font-semibold text-lg">New Budget</h2>
<button onClick={onClose} className="p-1.5 rounded-lg hover:bg-secondary text-muted-foreground">
<X className="w-4 h-4" />
</button>
</div>
<form onSubmit={handleSubmit} className="p-5 space-y-4">
<div>
<label className="text-sm font-medium block mb-1.5">Budget name *</label>
<input
value={form.name}
onChange={(e) => set("name", e.target.value)}
placeholder="e.g. Monthly Groceries"
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
required
/>
</div>
<div>
<label className="text-sm font-medium block mb-1.5">Category *</label>
<select
value={form.category_id}
onChange={(e) => set("category_id", e.target.value)}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
required
>
<option value="">Select category...</option>
{expenseCategories.map((c) => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
</select>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-sm font-medium block mb-1.5">Amount *</label>
<input
type="number"
min="0.01"
step="0.01"
value={form.amount || ""}
onChange={(e) => set("amount", parseFloat(e.target.value) || 0)}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
required
/>
</div>
<div>
<label className="text-sm font-medium block mb-1.5">Period</label>
<select
value={form.period}
onChange={(e) => set("period", e.target.value as BudgetCreate["period"])}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
>
<option value="weekly">Weekly</option>
<option value="monthly">Monthly</option>
<option value="quarterly">Quarterly</option>
<option value="yearly">Yearly</option>
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-sm font-medium block mb-1.5">Start date *</label>
<input
type="date"
value={form.start_date}
onChange={(e) => set("start_date", e.target.value)}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
required
/>
</div>
<div>
<label className="text-sm font-medium block mb-1.5">Alert at (%)</label>
<input
type="number"
min="0"
max="100"
value={form.alert_threshold}
onChange={(e) => set("alert_threshold", parseFloat(e.target.value))}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
</div>
<label className="flex items-center gap-2 cursor-pointer text-sm">
<input
type="checkbox"
checked={form.rollover}
onChange={(e) => set("rollover", e.target.checked)}
className="rounded border-input"
/>
Roll over unused budget to next period
</label>
<div className="flex gap-3 pt-2">
<button
type="button"
onClick={onClose}
className="flex-1 py-2.5 rounded-lg border border-border text-sm hover:bg-secondary transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={isLoading}
className="flex-1 py-2.5 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-colors"
>
{isLoading ? "Creating..." : "Create Budget"}
</button>
</div>
</form>
</div>
</div>
);
}

View file

@ -0,0 +1,197 @@
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { getBudgetSummary, createBudget, deleteBudget } from "@/api/budgets";
import { getCategories } from "@/api/transactions";
import { formatCurrency } from "@/utils/currency";
import { cn } from "@/utils/cn";
import { Plus, Trash2, AlertTriangle, CheckCircle } from "lucide-react";
import BudgetFormModal from "./BudgetFormModal";
function RadialGauge({ percent, size = 80 }: { percent: number; size?: number }) {
const r = size / 2 - 8;
const circumference = 2 * Math.PI * r;
const clamped = Math.min(percent, 100);
const offset = circumference - (clamped / 100) * circumference;
const color = percent >= 100 ? "#ef4444" : percent >= 80 ? "#f97316" : "#22c55e";
return (
<svg width={size} height={size} className="shrink-0">
<circle
cx={size / 2}
cy={size / 2}
r={r}
fill="none"
stroke="currentColor"
strokeWidth={6}
className="text-secondary"
/>
<circle
cx={size / 2}
cy={size / 2}
r={r}
fill="none"
stroke={color}
strokeWidth={6}
strokeDasharray={circumference}
strokeDashoffset={offset}
strokeLinecap="round"
transform={`rotate(-90 ${size / 2} ${size / 2})`}
style={{ transition: "stroke-dashoffset 0.4s ease" }}
/>
<text
x={size / 2}
y={size / 2 + 4}
textAnchor="middle"
fontSize={12}
fontWeight="600"
fill={color}
>
{Math.round(percent)}%
</text>
</svg>
);
}
export default function BudgetPage() {
const qc = useQueryClient();
const [showForm, setShowForm] = useState(false);
const { data: summary = [], isLoading } = useQuery({
queryKey: ["budget-summary"],
queryFn: getBudgetSummary,
refetchInterval: 60_000,
});
const { data: categories = [] } = useQuery({ queryKey: ["categories"], queryFn: getCategories });
const createMutation = useMutation({
mutationFn: createBudget,
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["budget-summary"] });
qc.invalidateQueries({ queryKey: ["budgets"] });
setShowForm(false);
},
});
const deleteMutation = useMutation({
mutationFn: deleteBudget,
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["budget-summary"] });
qc.invalidateQueries({ queryKey: ["budgets"] });
},
});
const overBudget = summary.filter((s) => s.is_over_budget).length;
const alerted = summary.filter((s) => s.alert_triggered && !s.is_over_budget).length;
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Budgets</h1>
<p className="text-sm text-muted-foreground mt-1">
{summary.length} active budget{summary.length !== 1 ? "s" : ""}
{overBudget > 0 && (
<span className="ml-2 text-destructive font-medium">· {overBudget} over budget</span>
)}
{alerted > 0 && (
<span className="ml-2 text-orange-500 font-medium">· {alerted} near limit</span>
)}
</p>
</div>
<button
onClick={() => setShowForm(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 Budget
</button>
</div>
{isLoading ? (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="h-32 bg-card border border-border rounded-xl animate-pulse" />
))}
</div>
) : summary.length === 0 ? (
<div className="text-center py-16 text-muted-foreground">
<p className="font-medium">No budgets yet</p>
<p className="text-sm mt-1">Create a budget to start tracking your spending</p>
</div>
) : (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{summary.map((item) => (
<div
key={item.budget_id}
className={cn(
"bg-card border rounded-xl p-5 relative group",
item.is_over_budget
? "border-destructive/50"
: item.alert_triggered
? "border-orange-500/50"
: "border-border"
)}
>
<button
onClick={() => deleteMutation.mutate(item.budget_id)}
className="absolute top-3 right-3 p-1 rounded text-muted-foreground hover:text-destructive opacity-0 group-hover:opacity-100 transition-all"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
<div className="flex items-start gap-4">
<RadialGauge percent={Number(item.percent_used)} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 mb-1">
{item.is_over_budget ? (
<AlertTriangle className="w-3.5 h-3.5 text-destructive shrink-0" />
) : item.alert_triggered ? (
<AlertTriangle className="w-3.5 h-3.5 text-orange-500 shrink-0" />
) : (
<CheckCircle className="w-3.5 h-3.5 text-success shrink-0" />
)}
<p className="font-semibold text-sm truncate">{item.budget_name}</p>
</div>
<p className="text-xs text-muted-foreground mb-2">{item.category_name} · {item.period}</p>
<div className="space-y-0.5">
<div className="flex justify-between text-xs">
<span className="text-muted-foreground">Spent</span>
<span className={cn("font-medium", item.is_over_budget ? "text-destructive" : "")}>
{formatCurrency(item.spent_amount, item.currency)}
</span>
</div>
<div className="flex justify-between text-xs">
<span className="text-muted-foreground">Budget</span>
<span className="font-medium">{formatCurrency(item.budget_amount, item.currency)}</span>
</div>
<div className="flex justify-between text-xs">
<span className="text-muted-foreground">Remaining</span>
<span className={cn("font-medium", item.remaining_amount < 0 ? "text-destructive" : "text-success")}>
{formatCurrency(Math.abs(item.remaining_amount), item.currency)}
{item.remaining_amount < 0 ? " over" : ""}
</span>
</div>
</div>
</div>
</div>
<div className="mt-3 text-xs text-muted-foreground">
{item.period_start} {item.period_end}
</div>
</div>
))}
</div>
)}
{showForm && (
<BudgetFormModal
categories={categories}
onClose={() => setShowForm(false)}
onSubmit={(data) => createMutation.mutate(data)}
isLoading={createMutation.isPending}
/>
)}
</div>
);
}

View file

@ -0,0 +1,244 @@
import { useQuery } from "@tanstack/react-query";
import { useAuthStore } from "@/store/authStore";
import { getNetWorth, getAccounts } from "@/api/accounts";
import { getTransactions } from "@/api/transactions";
import { getNetWorthReport, getIncomeExpenseReport, getCategoryBreakdown } from "@/api/reports";
import { formatCurrency } from "@/utils/currency";
import { cn } from "@/utils/cn";
import { format } from "date-fns";
import {
TrendingUp, CreditCard, PiggyBank, ArrowLeftRight, ShieldAlert,
ArrowUpCircle, ArrowDownCircle,
} from "lucide-react";
import {
AreaChart, Area, BarChart, Bar, PieChart, Pie, Cell,
XAxis, YAxis, Tooltip, ResponsiveContainer,
} from "recharts";
import { Link } from "react-router-dom";
const COLORS = ["#6366f1","#22c55e","#f97316","#ec4899","#14b8a6","#f59e0b","#8b5cf6","#ef4444"];
const TYPE_COLORS: Record<string, string> = {
income: "text-success",
expense: "text-destructive",
transfer: "text-muted-foreground",
investment: "text-primary",
};
export default function Dashboard() {
const displayName = useAuthStore((s) => s.displayName);
const totpEnabled = useAuthStore((s) => s.totpEnabled);
const { data: nw } = useQuery({ queryKey: ["net-worth"], queryFn: getNetWorth });
const { data: accounts = [] } = useQuery({ queryKey: ["accounts"], queryFn: getAccounts });
const { data: nwReport } = useQuery({ queryKey: ["report-net-worth"], queryFn: () => getNetWorthReport(6) });
const { data: ieReport } = useQuery({ queryKey: ["report-income-expense"], queryFn: () => getIncomeExpenseReport(6) });
const { data: catReport } = useQuery({ queryKey: ["report-categories"], queryFn: () => getCategoryBreakdown() });
const { data: txnData } = useQuery({
queryKey: ["transactions", { page: 1, page_size: 5 }],
queryFn: () => getTransactions({ page: 1, page_size: 5 }),
});
const currentMonth = ieReport?.points[ieReport.points.length - 1];
return (
<div className="space-y-6">
{/* Header */}
<div>
<h1 className="text-2xl font-bold">
Welcome back{displayName ? `, ${displayName}` : ""}
</h1>
<p className="text-muted-foreground text-sm mt-1">Here's your financial overview</p>
</div>
{/* 2FA nudge */}
{!totpEnabled && (
<div className="flex items-center gap-3 bg-yellow-500/10 border border-yellow-500/30 rounded-xl px-4 py-3">
<ShieldAlert className="w-5 h-5 text-yellow-500 shrink-0" />
<p className="flex-1 text-sm">
<span className="font-medium text-yellow-500">Enable two-factor authentication</span>
<span className="text-muted-foreground ml-1">to secure your account.</span>
</p>
<Link to="/security/totp" className="text-xs text-yellow-500 underline underline-offset-2 shrink-0">
Set up 2FA
</Link>
</div>
)}
{/* KPI cards */}
<div className="grid grid-cols-2 xl:grid-cols-4 gap-4">
<KpiCard
title="Net Worth"
value={nw ? formatCurrency(nw.net_worth, nw.base_currency) : "—"}
subtitle={`${accounts.filter(a => a.is_active).length} active accounts`}
icon={TrendingUp}
positive={nw ? nw.net_worth >= 0 : undefined}
/>
<KpiCard
title="Total Assets"
value={nw ? formatCurrency(nw.total_assets, nw.base_currency) : "—"}
subtitle="Cash + investments"
icon={PiggyBank}
positive
/>
<KpiCard
title="Total Liabilities"
value={nw ? formatCurrency(nw.total_liabilities, nw.base_currency) : "—"}
subtitle="Loans, mortgages, credit"
icon={CreditCard}
positive={nw ? nw.total_liabilities === 0 : undefined}
/>
<KpiCard
title="This Month"
value={currentMonth
? formatCurrency(Number(currentMonth.net), "GBP")
: "—"}
subtitle={currentMonth
? `${formatCurrency(Number(currentMonth.income), "GBP")}${formatCurrency(Number(currentMonth.expenses), "GBP")}`
: "No transactions yet"}
icon={ArrowLeftRight}
positive={currentMonth ? Number(currentMonth.net) >= 0 : undefined}
/>
</div>
{/* Charts row */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* Net worth trend */}
<div className="bg-card border border-border rounded-xl p-5">
<p className="text-sm font-semibold mb-4">Net Worth Trend</p>
{nwReport && nwReport.points.length > 0 ? (
<ResponsiveContainer width="100%" height={180}>
<AreaChart data={nwReport.points.map(p => ({ date: p.date, value: Number(p.net_worth) }))}>
<defs>
<linearGradient id="nwGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#6366f1" stopOpacity={0.3} />
<stop offset="95%" stopColor="#6366f1" stopOpacity={0} />
</linearGradient>
</defs>
<XAxis dataKey="date" tick={{ fontSize: 10 }} stroke="var(--muted-foreground)" />
<YAxis tick={{ fontSize: 10 }} stroke="var(--muted-foreground)" tickFormatter={v => `£${(v/1000).toFixed(0)}k`} width={45} />
<Tooltip formatter={(v: number) => formatCurrency(v, nwReport.base_currency)} />
<Area type="monotone" dataKey="value" stroke="#6366f1" fill="url(#nwGrad)" strokeWidth={2} />
</AreaChart>
</ResponsiveContainer>
) : (
<div className="h-44 flex flex-col items-center justify-center text-muted-foreground text-sm gap-1">
<TrendingUp className="w-8 h-8 opacity-20 mb-1" />
<p>Snapshots taken nightly</p>
<p className="text-xs">Check back tomorrow for your trend</p>
</div>
)}
</div>
{/* Monthly income vs expenses */}
<div className="bg-card border border-border rounded-xl p-5">
<p className="text-sm font-semibold mb-4">Income vs Expenses</p>
{ieReport && ieReport.points.length > 0 ? (
<ResponsiveContainer width="100%" height={180}>
<BarChart data={ieReport.points.map(p => ({ month: p.month, income: Number(p.income), expenses: Number(p.expenses) }))}>
<XAxis dataKey="month" tick={{ fontSize: 10 }} stroke="var(--muted-foreground)" />
<YAxis tick={{ fontSize: 10 }} stroke="var(--muted-foreground)" tickFormatter={v => `£${(v/1000).toFixed(0)}k`} width={45} />
<Tooltip formatter={(v: number) => formatCurrency(v, "GBP")} />
<Bar dataKey="income" fill="#22c55e" radius={[2,2,0,0]} name="Income" />
<Bar dataKey="expenses" fill="#ef4444" radius={[2,2,0,0]} name="Expenses" />
</BarChart>
</ResponsiveContainer>
) : (
<div className="h-44 flex flex-col items-center justify-center text-muted-foreground text-sm gap-1">
<ArrowLeftRight className="w-8 h-8 opacity-20 mb-1" />
<p>No transactions yet</p>
<Link to="/transactions" className="text-xs text-primary hover:underline">Add your first transaction</Link>
</div>
)}
</div>
</div>
{/* Bottom row */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* Spending by category */}
<div className="bg-card border border-border rounded-xl p-5">
<p className="text-sm font-semibold mb-4">Spending This Month</p>
{catReport && catReport.items.length > 0 ? (
<div className="flex gap-4 items-center">
<ResponsiveContainer width={140} height={140}>
<PieChart>
<Pie data={catReport.items.slice(0,8).map(i => ({ name: i.category_name, value: Number(i.amount) }))}
cx="50%" cy="50%" innerRadius={42} outerRadius={65} dataKey="value" paddingAngle={2}>
{catReport.items.slice(0,8).map((_, i) => <Cell key={i} fill={COLORS[i % COLORS.length]} />)}
</Pie>
<Tooltip formatter={(v: number) => formatCurrency(v, "GBP")} />
</PieChart>
</ResponsiveContainer>
<div className="flex-1 space-y-1.5 min-w-0">
{catReport.items.slice(0,6).map((item, i) => (
<div key={i} className="flex items-center gap-2 text-xs">
<div className="w-2 h-2 rounded-full shrink-0" style={{ background: COLORS[i % COLORS.length] }} />
<span className="flex-1 truncate text-muted-foreground">{item.category_name}</span>
<span className="font-medium tabular-nums">{formatCurrency(Number(item.amount), "GBP")}</span>
</div>
))}
</div>
</div>
) : (
<div className="h-36 flex items-center justify-center text-muted-foreground text-sm">
No expenses this month
</div>
)}
</div>
{/* Recent transactions */}
<div className="bg-card border border-border rounded-xl p-5">
<div className="flex items-center justify-between mb-4">
<p className="text-sm font-semibold">Recent Transactions</p>
<Link to="/transactions" className="text-xs text-primary hover:underline">View all</Link>
</div>
{txnData && txnData.items.length > 0 ? (
<div className="space-y-2">
{txnData.items.map((txn) => (
<div key={txn.id} className="flex items-center gap-3">
<div className={cn("p-1.5 rounded-lg shrink-0", txn.type === "income" ? "bg-success/10" : "bg-destructive/10")}>
{txn.type === "income"
? <ArrowUpCircle className="w-3.5 h-3.5 text-success" />
: <ArrowDownCircle className="w-3.5 h-3.5 text-destructive" />}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm truncate font-medium">{txn.description}</p>
<p className="text-xs text-muted-foreground">{format(new Date(txn.date), "dd MMM")}</p>
</div>
<p className={cn("text-sm font-semibold tabular-nums shrink-0", TYPE_COLORS[txn.type])}>
{Number(txn.amount) >= 0 ? "+" : ""}{formatCurrency(txn.amount, txn.currency)}
</p>
</div>
))}
</div>
) : (
<div className="h-36 flex items-center justify-center text-muted-foreground text-sm">
<Link to="/transactions" className="text-primary hover:underline">Add your first transaction</Link>
</div>
)}
</div>
</div>
</div>
);
}
function KpiCard({ title, value, subtitle, icon: Icon, positive }: {
title: string; value: string; subtitle?: string; icon: React.ElementType; positive?: boolean;
}) {
return (
<div className="bg-card border border-border rounded-xl p-5">
<div className="flex items-center justify-between mb-3">
<p className="text-xs text-muted-foreground font-medium uppercase tracking-wider">{title}</p>
<div className="p-1.5 bg-primary/10 rounded-lg">
<Icon className="w-4 h-4 text-primary" />
</div>
</div>
<p className={cn("text-2xl font-bold tabular-nums",
positive === true ? "text-success" : positive === false ? "text-destructive" : "text-foreground"
)}>
{value}
</p>
{subtitle && <p className="text-xs text-muted-foreground mt-1">{subtitle}</p>}
</div>
);
}

View file

@ -0,0 +1,208 @@
import { useState, useEffect } from "react";
import { X, Search, Loader2 } from "lucide-react";
import { searchAssets, createHolding, addInvestmentTransaction, AssetSearchResult } from "@/api/investments";
import { format } from "date-fns";
interface Account { id: string; name: string; type: string; }
interface Props {
accounts: Account[];
onClose: () => void;
onSuccess: () => void;
}
export default function AddHoldingModal({ accounts, onClose, onSuccess }: Props) {
const [query, setQuery] = useState("");
const [results, setResults] = useState<AssetSearchResult[]>([]);
const [searching, setSearching] = useState(false);
const [selected, setSelected] = useState<AssetSearchResult | null>(null);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const investAccounts = accounts.filter(a =>
["investment", "pension", "savings", "other"].includes(a.type)
);
const [form, setForm] = useState({
account_id: investAccounts[0]?.id ?? "",
quantity: "",
price: "",
fees: "0",
date: format(new Date(), "yyyy-MM-dd"),
});
useEffect(() => {
const t = setTimeout(async () => {
if (query.length < 1) { setResults([]); return; }
setSearching(true);
try {
const r = await searchAssets(query);
setResults(r);
} finally {
setSearching(false);
}
}, 400);
return () => clearTimeout(t);
}, [query]);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!selected || !form.account_id || !form.quantity || !form.price) return;
setSaving(true);
setError(null);
try {
const qty = parseFloat(form.quantity);
const price = parseFloat(form.price);
const holding = await createHolding({
account_id: form.account_id,
asset_id: selected.id,
quantity: qty,
avg_cost_basis: price,
currency: selected.currency,
});
await addInvestmentTransaction({
holding_id: holding.id,
type: "buy",
quantity: qty,
price: price,
fees: parseFloat(form.fees) || 0,
currency: selected.currency,
date: form.date,
});
onSuccess();
} catch (e: any) {
setError(e?.response?.data?.detail ?? "Failed to add holding");
} finally {
setSaving(false);
}
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4">
<div className="bg-card border border-border rounded-2xl w-full max-w-md shadow-xl">
<div className="flex items-center justify-between p-5 border-b border-border">
<h2 className="font-semibold text-lg">Add Holding</h2>
<button onClick={onClose} className="p-1.5 rounded-lg hover:bg-secondary text-muted-foreground">
<X className="w-4 h-4" />
</button>
</div>
<div className="p-5 space-y-4">
{/* Asset search */}
<div>
<label className="text-sm font-medium block mb-1.5">Search asset *</label>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<input
value={query}
onChange={(e) => { setQuery(e.target.value); setSelected(null); }}
placeholder="e.g. AAPL, Vanguard, BTC..."
className="w-full pl-9 pr-3 py-2 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
{searching && <Loader2 className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 animate-spin text-muted-foreground" />}
</div>
{results.length > 0 && !selected && (
<div className="mt-1 border border-border rounded-lg overflow-hidden shadow-lg bg-card">
{results.map((r) => (
<button
key={r.id}
type="button"
onClick={() => { setSelected(r); setResults([]); setQuery(`${r.symbol}${r.name}`); }}
className="w-full flex items-center justify-between px-3 py-2.5 hover:bg-secondary transition-colors text-left"
>
<div>
<span className="font-semibold text-sm">{r.symbol}</span>
<span className="text-muted-foreground text-sm ml-2">{r.name}</span>
</div>
<div className="text-right">
<span className="text-xs text-muted-foreground">{r.type} · {r.currency}</span>
{r.last_price && <p className="text-xs font-medium">{r.last_price}</p>}
</div>
</button>
))}
</div>
)}
{selected && (
<p className="text-xs text-success mt-1"> Selected: {selected.symbol} ({selected.name})</p>
)}
</div>
{/* Account */}
<div>
<label className="text-sm font-medium block mb-1.5">Account *</label>
<select
value={form.account_id}
onChange={(e) => setForm(f => ({ ...f, account_id: e.target.value }))}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
>
{investAccounts.map(a => <option key={a.id} value={a.id}>{a.name}</option>)}
{investAccounts.length === 0 && <option value="">No investment accounts add one first</option>}
</select>
</div>
{/* Quantity / Price / Fees */}
<div className="grid grid-cols-3 gap-3">
<div>
<label className="text-xs font-medium block mb-1">Quantity *</label>
<input
type="number" min="0" step="any"
value={form.quantity}
onChange={(e) => setForm(f => ({ ...f, quantity: 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"
placeholder="10"
/>
</div>
<div>
<label className="text-xs font-medium block mb-1">Price paid *</label>
<input
type="number" min="0" step="any"
value={form.price}
onChange={(e) => setForm(f => ({ ...f, price: 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"
placeholder="150.00"
/>
</div>
<div>
<label className="text-xs font-medium block mb-1">Fees</label>
<input
type="number" min="0" step="any"
value={form.fees}
onChange={(e) => setForm(f => ({ ...f, fees: 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"
placeholder="0"
/>
</div>
</div>
<div>
<label className="text-sm font-medium block mb-1.5">Purchase date *</label>
<input
type="date"
value={form.date}
onChange={(e) => setForm(f => ({ ...f, date: e.target.value }))}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
</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-1">
<button type="button" onClick={onClose} className="flex-1 py-2.5 rounded-lg border border-border text-sm hover:bg-secondary transition-colors">
Cancel
</button>
<button
onClick={handleSubmit}
disabled={saving || !selected || !form.quantity || !form.price || !form.account_id}
className="flex-1 flex items-center justify-center gap-2 py-2.5 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-colors"
>
{saving && <Loader2 className="w-4 h-4 animate-spin" />}
{saving ? "Adding…" : "Add Holding"}
</button>
</div>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,122 @@
import { useParams, Link } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { getPriceHistory, getPortfolio } from "@/api/investments";
import { formatCurrency } from "@/utils/currency";
import { cn } from "@/utils/cn";
import { ArrowLeft, TrendingUp, TrendingDown } from "lucide-react";
import Plot from "react-plotly.js";
export default function AssetDetail() {
const { assetId } = useParams<{ assetId: string }>();
const { data: portfolio } = useQuery({ queryKey: ["portfolio"], queryFn: getPortfolio });
const holding = portfolio?.holdings.find(h => h.asset_id === assetId);
const { data: prices = [], isLoading } = useQuery({
queryKey: ["prices", assetId],
queryFn: () => getPriceHistory(assetId!, 365),
enabled: !!assetId,
});
const dates = prices.map(p => p.date);
const opens = prices.map(p => p.open ?? p.close);
const highs = prices.map(p => p.high ?? p.close);
const lows = prices.map(p => p.low ?? p.close);
const closes = prices.map(p => p.close);
const volumes = prices.map(p => p.volume ?? 0);
const latestPrice = closes[closes.length - 1];
const prevPrice = closes[closes.length - 2];
const change = latestPrice && prevPrice ? latestPrice - prevPrice : 0;
const changePct = prevPrice && prevPrice !== 0 ? (change / prevPrice) * 100 : 0;
const isUp = change >= 0;
return (
<div className="space-y-6">
<div className="flex items-center gap-3">
<Link to="/investments" className="p-2 rounded-lg hover:bg-secondary transition-colors text-muted-foreground">
<ArrowLeft className="w-5 h-5" />
</Link>
<div>
<h1 className="text-2xl font-bold">{holding?.symbol ?? "Asset"}</h1>
<p className="text-sm text-muted-foreground">{holding?.asset_name}</p>
</div>
</div>
{/* Price header */}
{latestPrice != null && (
<div className="flex items-end gap-4">
<p className="text-4xl font-bold tabular-nums">{formatCurrency(latestPrice, holding?.currency ?? "GBP")}</p>
<div className={cn("flex items-center gap-1 pb-1 text-sm font-medium", isUp ? "text-success" : "text-destructive")}>
{isUp ? <TrendingUp className="w-4 h-4" /> : <TrendingDown className="w-4 h-4" />}
{isUp ? "+" : ""}{formatCurrency(change, holding?.currency ?? "GBP")} ({changePct.toFixed(2)}%)
</div>
</div>
)}
{/* Your position */}
{holding && (
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
{[
{ label: "Shares held", value: Number(holding.quantity).toLocaleString() },
{ label: "Avg cost", value: formatCurrency(holding.avg_cost_basis, holding.currency) },
{ label: "Current value", value: holding.current_value != null ? formatCurrency(holding.current_value, holding.currency) : "—" },
{ label: "Unrealised gain", value: holding.unrealised_gain != null ? formatCurrency(holding.unrealised_gain, holding.currency) : "—", color: holding.unrealised_gain != null ? (holding.unrealised_gain >= 0 ? "text-success" : "text-destructive") : "" },
].map(({ label, value, color }) => (
<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("font-semibold tabular-nums", color)}>{value}</p>
</div>
))}
</div>
)}
{/* Candlestick chart */}
<div className="bg-card border border-border rounded-xl p-4">
<p className="text-sm font-medium mb-3">Price History (1 Year)</p>
{isLoading ? (
<div className="h-80 animate-pulse bg-secondary/30 rounded-lg" />
) : prices.length === 0 ? (
<div className="h-80 flex items-center justify-center text-muted-foreground text-sm">No price data available</div>
) : (
<Plot
data={[
{
type: "candlestick",
x: dates,
open: opens as number[],
high: highs as number[],
low: lows as number[],
close: closes as number[],
increasing: { line: { color: "#22c55e" } },
decreasing: { line: { color: "#ef4444" } },
name: holding?.symbol ?? "Price",
},
{
type: "bar",
x: dates,
y: volumes as number[],
yaxis: "y2",
marker: { color: "rgba(99,102,241,0.3)" },
name: "Volume",
},
]}
layout={{
paper_bgcolor: "transparent",
plot_bgcolor: "transparent",
font: { color: "var(--muted-foreground)", size: 11 },
xaxis: { rangeslider: { visible: false }, gridcolor: "var(--border)", showgrid: true },
yaxis: { gridcolor: "var(--border)", showgrid: true, domain: [0.25, 1] },
yaxis2: { domain: [0, 0.2], showgrid: false },
margin: { t: 10, r: 10, b: 40, l: 60 },
showlegend: false,
dragmode: "pan",
}}
config={{ responsive: true, displayModeBar: false, scrollZoom: true }}
style={{ width: "100%", height: "360px" }}
/>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,208 @@
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { getPortfolio, deleteHolding } from "@/api/investments";
import { getAccounts } from "@/api/accounts";
import { formatCurrency } from "@/utils/currency";
import { cn } from "@/utils/cn";
import { Plus, Trash2, TrendingUp, TrendingDown, ChevronRight } from "lucide-react";
import AddHoldingModal from "./AddHoldingModal";
import { Link } from "react-router-dom";
const COLORS = [
"#6366f1","#22c55e","#f97316","#ec4899","#14b8a6",
"#f59e0b","#8b5cf6","#06b6d4","#84cc16","#ef4444",
];
export default function PortfolioPage() {
const qc = useQueryClient();
const [showAdd, setShowAdd] = useState(false);
const { data: portfolio, isLoading } = useQuery({
queryKey: ["portfolio"],
queryFn: getPortfolio,
refetchInterval: 60_000,
});
const { data: accounts = [] } = useQuery({ queryKey: ["accounts"], queryFn: getAccounts });
const deleteMutation = useMutation({
mutationFn: deleteHolding,
onSuccess: () => qc.invalidateQueries({ queryKey: ["portfolio"] }),
});
const treemapData = portfolio?.holdings
.filter((h) => (h.current_value ?? h.cost_basis_total) > 0)
.map((h, i) => ({
name: h.symbol,
size: Number(h.current_value ?? h.cost_basis_total),
fill: COLORS[i % COLORS.length],
})) ?? [];
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Investments</h1>
<p className="text-sm text-muted-foreground mt-1">
{portfolio ? `${portfolio.holdings.length} holding${portfolio.holdings.length !== 1 ? "s" : ""}` : ""}
</p>
</div>
<button
onClick={() => setShowAdd(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 Holding
</button>
</div>
{/* Summary cards */}
{portfolio && (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{[
{ label: "Portfolio Value", value: portfolio.total_value, positive: true },
{ label: "Total Cost", value: portfolio.total_cost, positive: true },
{ label: "Unrealised Gain", value: portfolio.total_gain, positive: portfolio.total_gain >= 0 },
{ label: "Return", value: portfolio.total_gain_pct, positive: portfolio.total_gain_pct >= 0, isPercent: true },
].map(({ label, value, positive, isPercent }) => (
<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 tabular-nums", positive ? "text-success" : "text-destructive")}>
{isPercent ? `${Number(value).toFixed(2)}%` : formatCurrency(value, portfolio.currency)}
</p>
</div>
))}
</div>
)}
{/* Treemap */}
{treemapData.length > 1 && (
<div className="bg-card border border-border rounded-xl p-4">
<p className="text-sm font-medium mb-3">Allocation</p>
<div className="flex gap-1 flex-wrap">
{(() => {
const total = treemapData.reduce((s, d) => s + d.size, 0);
return treemapData.map((d, i) => (
<div
key={d.name}
style={{ width: `${Math.max(d.size / total * 100, 4)}%`, backgroundColor: COLORS[i % COLORS.length] }}
className="h-16 rounded flex items-center justify-center text-white text-xs font-bold overflow-hidden"
title={`${d.name}: ${formatCurrency(d.size, "GBP")}`}
>
{d.size / total > 0.06 ? d.name : ""}
</div>
));
})()}
</div>
<div className="flex flex-wrap gap-3 mt-3">
{treemapData.map((d, i) => (
<div key={d.name} className="flex items-center gap-1.5 text-xs text-muted-foreground">
<div className="w-2.5 h-2.5 rounded-sm shrink-0" style={{ backgroundColor: COLORS[i % COLORS.length] }} />
{d.name} {formatCurrency(d.size, "GBP")}
</div>
))}
</div>
</div>
)}
{/* Holdings table */}
{isLoading ? (
<div className="space-y-2">
{[1,2,3].map(i => <div key={i} className="h-16 bg-card border border-border rounded-xl animate-pulse" />)}
</div>
) : !portfolio || portfolio.holdings.length === 0 ? (
<div className="bg-card border border-border rounded-xl py-16 text-center text-muted-foreground">
<TrendingUp className="w-10 h-10 mx-auto mb-3 opacity-30" />
<p className="font-medium">No holdings yet</p>
<p className="text-sm mt-1">Add your first investment holding to get started</p>
</div>
) : (
<div className="bg-card border border-border rounded-xl overflow-hidden">
<table className="w-full text-sm">
<thead className="border-b border-border">
<tr className="text-muted-foreground text-xs uppercase tracking-wider">
<th className="text-left px-4 py-3">Asset</th>
<th className="text-right px-4 py-3 hidden sm:table-cell">Quantity</th>
<th className="text-right px-4 py-3 hidden md:table-cell">Price</th>
<th className="text-right px-4 py-3">Value</th>
<th className="text-right px-4 py-3 hidden lg:table-cell">Gain / Loss</th>
<th className="text-right px-4 py-3 hidden lg:table-cell">24h</th>
<th className="w-16"></th>
</tr>
</thead>
<tbody>
{portfolio.holdings.map((h) => {
const isUp = (h.unrealised_gain ?? 0) >= 0;
const change24Up = (h.price_change_24h ?? 0) >= 0;
return (
<tr key={h.id} className="border-b border-border/50 hover:bg-secondary/20 transition-colors group">
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded-lg bg-primary/15 flex items-center justify-center shrink-0">
<span className="text-xs font-bold text-primary">{h.symbol.slice(0,3)}</span>
</div>
<div className="min-w-0">
<p className="font-semibold truncate">{h.symbol}</p>
<p className="text-xs text-muted-foreground truncate">{h.asset_name}</p>
</div>
</div>
</td>
<td className="px-4 py-3 text-right hidden sm:table-cell tabular-nums">
{Number(h.quantity).toLocaleString()}
</td>
<td className="px-4 py-3 text-right hidden md:table-cell tabular-nums">
{h.current_price != null ? formatCurrency(h.current_price, h.currency) : "—"}
</td>
<td className="px-4 py-3 text-right font-semibold tabular-nums">
{h.current_value != null ? formatCurrency(h.current_value, h.currency) : formatCurrency(h.cost_basis_total, h.currency)}
</td>
<td className={cn("px-4 py-3 text-right hidden lg:table-cell", isUp ? "text-success" : "text-destructive")}>
{h.unrealised_gain != null ? (
<div>
<p className="tabular-nums font-medium">{isUp ? "+" : ""}{formatCurrency(h.unrealised_gain, h.currency)}</p>
<p className="text-xs">{isUp ? "+" : ""}{Number(h.unrealised_gain_pct).toFixed(2)}%</p>
</div>
) : "—"}
</td>
<td className={cn("px-4 py-3 text-right hidden lg:table-cell text-xs", change24Up ? "text-success" : "text-destructive")}>
{h.price_change_24h != null ? (
<span className="flex items-center justify-end gap-0.5">
{change24Up ? <TrendingUp className="w-3 h-3" /> : <TrendingDown className="w-3 h-3" />}
{Number(h.price_change_24h).toFixed(2)}%
</span>
) : "—"}
</td>
<td className="px-4 py-3">
<div className="flex items-center justify-end gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<Link
to={`/investments/${h.asset_id}`}
className="p-1.5 rounded text-muted-foreground hover:text-foreground hover:bg-secondary"
>
<ChevronRight className="w-4 h-4" />
</Link>
<button
onClick={() => deleteMutation.mutate(h.id)}
className="p-1.5 rounded text-muted-foreground hover:text-destructive hover:bg-destructive/10"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
{showAdd && (
<AddHoldingModal
accounts={accounts}
onClose={() => setShowAdd(false)}
onSuccess={() => { qc.invalidateQueries({ queryKey: ["portfolio"] }); setShowAdd(false); }}
/>
)}
</div>
);
}

View file

@ -0,0 +1,499 @@
import { useState } from "react";
import { useQuery, useMutation } from "@tanstack/react-query";
import {
getSpendingForecast, getNetWorthProjection, postMonteCarlo,
getBudgetForecast, getCashFlowForecast,
} from "@/api/predictions";
import { formatCurrency } from "@/utils/currency";
import { cn } from "@/utils/cn";
import { Sparkles, TrendingUp, BarChart3, Wallet, RefreshCw, Loader2 } from "lucide-react";
import {
AreaChart, Area, BarChart, Bar, LineChart, Line,
XAxis, YAxis, Tooltip, ResponsiveContainer, Legend, ReferenceLine,
} from "recharts";
import Plot from "react-plotly.js";
const TABS = [
{ id: "spending", label: "Spending", icon: BarChart3 },
{ id: "networth", label: "Net Worth", icon: TrendingUp },
{ id: "montecarlo", label: "Monte Carlo", icon: Sparkles },
{ id: "cashflow", label: "Cash Flow", icon: Wallet },
] as const;
type Tab = (typeof TABS)[number]["id"];
export default function PredictionsPage() {
const [tab, setTab] = useState<Tab>("spending");
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold">Predictions</h1>
<p className="text-sm text-muted-foreground mt-1">ML-powered forecasts based on your financial history</p>
</div>
{/* Tab bar */}
<div className="flex gap-1 bg-secondary/50 p-1 rounded-xl w-fit">
{TABS.map(({ id, label, icon: Icon }) => (
<button
key={id}
onClick={() => setTab(id)}
className={cn(
"flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors",
tab === id ? "bg-card text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"
)}
>
<Icon className="w-4 h-4" />
{label}
</button>
))}
</div>
{tab === "spending" && <SpendingTab />}
{tab === "networth" && <NetWorthTab />}
{tab === "montecarlo" && <MonteCarloTab />}
{tab === "cashflow" && <CashFlowTab />}
</div>
);
}
// ─── Spending Forecast ───────────────────────────────────────────────────────
function SpendingTab() {
const { data, isLoading } = useQuery({ queryKey: ["pred-spending"], queryFn: getSpendingForecast });
const [selected, setSelected] = useState(0);
if (isLoading) return <LoadingCard />;
if (!data?.categories.length) return <EmptyCard message="Add some transactions to generate a spending forecast." />;
const cat = data.categories[selected];
const chartData = [
...cat.actuals.map(p => ({ date: p.date.slice(0, 7), actual: p.amount })),
...cat.forecast.map(p => ({
date: p.date.slice(0, 7),
forecast: p.amount,
lower: p.lower,
upper: p.upper,
})),
];
return (
<div className="space-y-4">
{/* Category selector */}
<div className="flex gap-2 flex-wrap">
{data.categories.map((c, i) => (
<button
key={c.category_id}
onClick={() => setSelected(i)}
className={cn(
"px-3 py-1.5 rounded-lg text-sm font-medium transition-colors border",
selected === i
? "bg-primary text-primary-foreground border-primary"
: "border-border text-muted-foreground hover:text-foreground hover:bg-secondary"
)}
>
{c.category_name}
<span className="ml-1.5 opacity-60 text-xs">{formatCurrency(c.monthly_avg, "GBP")}/mo</span>
</button>
))}
</div>
<div className="bg-card border border-border rounded-xl p-5">
<div className="flex items-center justify-between mb-4">
<p className="text-sm font-semibold">{cat.category_name} Spending Forecast</p>
<p className="text-xs text-muted-foreground">Shaded = 80% confidence interval</p>
</div>
<ResponsiveContainer width="100%" height={260}>
<BarChart data={chartData} margin={{ top: 5, right: 10, left: 0, bottom: 5 }}>
<XAxis dataKey="date" tick={{ fontSize: 10 }} stroke="var(--muted-foreground)" />
<YAxis tick={{ fontSize: 10 }} stroke="var(--muted-foreground)" tickFormatter={v => `£${v}`} width={55} />
<Tooltip formatter={(v: number) => formatCurrency(v, "GBP")} />
<Bar dataKey="actual" fill="#6366f1" name="Actual" radius={[2, 2, 0, 0]} />
<Bar dataKey="forecast" fill="#6366f180" name="Forecast" radius={[2, 2, 0, 0]} />
</BarChart>
</ResponsiveContainer>
{/* Confidence band as area overlay */}
{cat.forecast.length > 0 && (
<div className="mt-2 text-xs text-muted-foreground text-center">
Forecast next 3 months: {cat.forecast.map(f =>
`${f.date.slice(0, 7)}: ${formatCurrency(f.amount, "GBP")} (${formatCurrency(f.lower, "GBP")}${formatCurrency(f.upper, "GBP")})`
).join(" · ")}
</div>
)}
</div>
{/* Budget forecast alert cards */}
<BudgetAlerts />
</div>
);
}
function BudgetAlerts() {
const { data } = useQuery({ queryKey: ["pred-budget"], queryFn: getBudgetForecast });
if (!data?.forecasts.length) return null;
const atRisk = data.forecasts.filter(f => f.probability_overspend > 0.5);
if (!atRisk.length) return null;
return (
<div className="bg-card border border-border rounded-xl p-5">
<p className="text-sm font-semibold mb-3">Budget Overspend Risk</p>
<div className="space-y-3">
{atRisk.slice(0, 5).map(f => {
const forecastPct = Math.min(140, (f.forecast_month_total / f.budget_amount) * 100);
return (
<div key={f.category_id}>
<div className="flex justify-between text-sm mb-1">
<span className="font-medium">{f.category_name}</span>
<span className={cn("text-xs font-medium", f.probability_overspend > 0.75 ? "text-destructive" : "text-yellow-500")}>
{(f.probability_overspend * 100).toFixed(0)}% overspend risk
</span>
</div>
<div className="h-2 bg-secondary rounded-full overflow-hidden relative">
<div
className={cn("h-full rounded-full", f.probability_overspend > 0.75 ? "bg-destructive" : "bg-yellow-500")}
style={{ width: `${Math.min(100, forecastPct)}%` }}
/>
<div className="absolute top-0 h-full w-0.5 bg-foreground/40" style={{ left: "100%" }} />
</div>
<div className="flex justify-between text-xs text-muted-foreground mt-0.5">
<span>Spent: {formatCurrency(f.spent_so_far, "GBP")}</span>
<span>Forecast: {formatCurrency(f.forecast_month_total, "GBP")} / {formatCurrency(f.budget_amount, "GBP")}</span>
</div>
</div>
);
})}
</div>
</div>
);
}
// ─── Net Worth Projection ────────────────────────────────────────────────────
function NetWorthTab() {
const [years, setYears] = useState(5);
const { data, isLoading } = useQuery({
queryKey: ["pred-networth", years],
queryFn: () => getNetWorthProjection(years),
});
if (isLoading) return <LoadingCard />;
if (!data) return <EmptyCard message="No data available." />;
if (data.insufficient_data) {
return <EmptyCard message="Not enough net worth history yet. Snapshots are taken nightly — check back after a few days." />;
}
const historyPoints = data.history.map(p => ({ date: p.date, history: p.value }));
const projPoints = data.projections.base.map((p, i) => ({
date: p.date,
conservative: data.projections.conservative[i]?.value,
base: p.value,
optimistic: data.projections.optimistic[i]?.value,
}));
const chartData = [...historyPoints, ...projPoints];
const lastHistory = data.history[data.history.length - 1];
const lastBase = data.projections.base[data.projections.base.length - 1];
const lastOpt = data.projections.optimistic[data.projections.optimistic.length - 1];
const lastCons = data.projections.conservative[data.projections.conservative.length - 1];
return (
<div className="space-y-4">
{/* Year selector */}
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">Projection horizon:</span>
{[1, 3, 5, 10].map(y => (
<button
key={y}
onClick={() => setYears(y)}
className={cn(
"px-3 py-1 rounded-lg text-sm font-medium transition-colors border",
years === y ? "bg-primary text-primary-foreground border-primary" : "border-border text-muted-foreground hover:bg-secondary"
)}
>
{y}yr
</button>
))}
</div>
{/* Summary cards */}
<div className="grid grid-cols-3 gap-4">
{[
{ label: "Conservative", value: lastCons?.value, color: "text-destructive" },
{ label: "Base Case", value: lastBase?.value, color: "text-foreground" },
{ label: "Optimistic", value: lastOpt?.value, color: "text-success" },
].map(({ label, value, color }) => (
<div key={label} className="bg-card border border-border rounded-xl p-4">
<p className="text-xs text-muted-foreground mb-1">{label} ({years}yr)</p>
<p className={cn("text-lg font-bold tabular-nums", color)}>
{value != null ? formatCurrency(value, "GBP") : "—"}
</p>
</div>
))}
</div>
<div className="bg-card border border-border rounded-xl p-5">
<p className="text-sm font-semibold mb-4">Net Worth Projection</p>
<ResponsiveContainer width="100%" height={300}>
<LineChart data={chartData} margin={{ top: 5, right: 10, left: 0, bottom: 5 }}>
<XAxis dataKey="date" tick={{ fontSize: 10 }} stroke="var(--muted-foreground)" interval="preserveStartEnd" />
<YAxis tick={{ fontSize: 10 }} stroke="var(--muted-foreground)" tickFormatter={v => `£${(v / 1000).toFixed(0)}k`} width={55} />
<Tooltip formatter={(v: number) => formatCurrency(v, "GBP")} />
<Legend />
{lastHistory && <ReferenceLine x={lastHistory.date} stroke="var(--border)" strokeDasharray="4 2" label={{ value: "Today", fontSize: 10 }} />}
<Line type="monotone" dataKey="history" stroke="#6366f1" strokeWidth={2} dot={false} name="History" />
<Line type="monotone" dataKey="conservative" stroke="#ef4444" strokeWidth={1.5} strokeDasharray="4 2" dot={false} name="Conservative" />
<Line type="monotone" dataKey="base" stroke="#22c55e" strokeWidth={2} strokeDasharray="4 2" dot={false} name="Base" />
<Line type="monotone" dataKey="optimistic" stroke="#f59e0b" strokeWidth={1.5} strokeDasharray="4 2" dot={false} name="Optimistic" />
</LineChart>
</ResponsiveContainer>
</div>
</div>
);
}
// ─── Monte Carlo ─────────────────────────────────────────────────────────────
function MonteCarloTab() {
const [years, setYears] = useState(5);
const [contribution, setContribution] = useState(0);
const mutation = useMutation({
mutationFn: () => postMonteCarlo({ years, n_simulations: 1000, annual_contribution: contribution }),
});
const data = mutation.data;
return (
<div className="space-y-4">
{/* Controls */}
<div className="bg-card border border-border rounded-xl p-5">
<p className="text-sm font-semibold mb-4">Simulation Parameters</p>
<div className="grid grid-cols-2 gap-4 mb-4">
<div>
<label className="text-xs text-muted-foreground block mb-1.5">Projection years</label>
<div className="flex gap-2">
{[1, 3, 5, 10].map(y => (
<button
key={y}
onClick={() => setYears(y)}
className={cn(
"flex-1 py-1.5 rounded-lg text-sm font-medium transition-colors border",
years === y ? "bg-primary text-primary-foreground border-primary" : "border-border text-muted-foreground hover:bg-secondary"
)}
>
{y}yr
</button>
))}
</div>
</div>
<div>
<label className="text-xs text-muted-foreground block mb-1.5">Annual contribution (£)</label>
<input
type="number"
min="0"
step="500"
value={contribution}
onChange={e => setContribution(Number(e.target.value))}
className="w-full rounded-md border border-input bg-background px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
</div>
<button
onClick={() => mutation.mutate()}
disabled={mutation.isPending}
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 disabled:opacity-50 transition-colors"
>
{mutation.isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <RefreshCw className="w-4 h-4" />}
{mutation.isPending ? "Running 1,000 simulations…" : "Run Simulation"}
</button>
</div>
{data && !data.insufficient_data && (
<>
{/* Summary */}
<div className="grid grid-cols-3 gap-4">
<div className="bg-card border border-border rounded-xl p-4">
<p className="text-xs text-muted-foreground mb-1">Current Value</p>
<p className="text-lg font-bold">{formatCurrency(data.current_value, "GBP")}</p>
</div>
<div className="bg-card border border-border rounded-xl p-4">
<p className="text-xs text-muted-foreground mb-1">Expected Value (P50, {years}yr)</p>
<p className="text-lg font-bold text-success">{formatCurrency(data.expected_value, "GBP")}</p>
</div>
<div className="bg-card border border-border rounded-xl p-4">
<p className="text-xs text-muted-foreground mb-1">Probability of Gain</p>
<p className={cn("text-lg font-bold", data.probability_of_gain >= 0.5 ? "text-success" : "text-destructive")}>
{(data.probability_of_gain * 100).toFixed(1)}%
</p>
</div>
</div>
{/* Fan chart */}
<div className="bg-card border border-border rounded-xl p-5">
<p className="text-sm font-semibold mb-1">Portfolio Simulation Fan Chart</p>
<p className="text-xs text-muted-foreground mb-4">1,000 simulations shaded regions show P10P90 range</p>
<Plot
data={[
{
type: "scatter" as const,
x: data.percentiles.p90.map(p => p.date),
y: data.percentiles.p90.map(p => p.value),
fill: "tonexty",
fillcolor: "rgba(99,102,241,0.15)",
line: { color: "#6366f1", width: 1 },
name: "P90",
mode: "lines",
},
{
type: "scatter" as const,
x: data.percentiles.p75.map(p => p.date),
y: data.percentiles.p75.map(p => p.value),
fill: "tonexty",
fillcolor: "rgba(99,102,241,0.2)",
line: { color: "#6366f1", width: 1 },
name: "P75",
mode: "lines",
},
{
type: "scatter" as const,
x: data.percentiles.p50.map(p => p.date),
y: data.percentiles.p50.map(p => p.value),
line: { color: "#22c55e", width: 2.5 },
name: "P50 (Median)",
mode: "lines",
},
{
type: "scatter" as const,
x: data.percentiles.p25.map(p => p.date),
y: data.percentiles.p25.map(p => p.value),
fill: "tonexty",
fillcolor: "rgba(239,68,68,0.1)",
line: { color: "#ef4444", width: 1 },
name: "P25",
mode: "lines",
},
{
type: "scatter" as const,
x: data.percentiles.p10.map(p => p.date),
y: data.percentiles.p10.map(p => p.value),
fill: "tonexty",
fillcolor: "rgba(239,68,68,0.15)",
line: { color: "#ef4444", width: 1 },
name: "P10",
mode: "lines",
},
]}
layout={{
paper_bgcolor: "transparent",
plot_bgcolor: "transparent",
font: { color: "var(--muted-foreground)", size: 11 },
xaxis: { gridcolor: "var(--border)", showgrid: true },
yaxis: {
gridcolor: "var(--border)",
showgrid: true,
tickformat: "£,.0f",
},
margin: { t: 10, r: 10, b: 40, l: 80 },
showlegend: true,
legend: { orientation: "h", y: -0.2 },
}}
config={{ responsive: true, displayModeBar: false }}
style={{ width: "100%", height: "360px" }}
/>
</div>
</>
)}
{data?.insufficient_data && (
<EmptyCard message="No investment holdings found. Add holdings in the Investments section first." />
)}
</div>
);
}
// ─── Cash Flow ───────────────────────────────────────────────────────────────
function CashFlowTab() {
const { data, isLoading } = useQuery({ queryKey: ["pred-cashflow"], queryFn: getCashFlowForecast });
if (isLoading) return <LoadingCard />;
if (!data) return <EmptyCard message="No data available." />;
const hasRisk = data.negative_risk_days.length > 0;
return (
<div className="space-y-4">
{/* Summary cards */}
<div className="grid grid-cols-3 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-lg font-bold tabular-nums", data.current_balance >= 0 ? "text-foreground" : "text-destructive")}>
{formatCurrency(data.current_balance, "GBP")}
</p>
</div>
<div className="bg-card border border-border rounded-xl p-4">
<p className="text-xs text-muted-foreground mb-1">Avg Daily Inflow</p>
<p className="text-lg font-bold text-success tabular-nums">+{formatCurrency(data.avg_daily_inflow, "GBP")}</p>
</div>
<div className="bg-card border border-border rounded-xl p-4">
<p className="text-xs text-muted-foreground mb-1">Avg Daily Outflow</p>
<p className="text-lg font-bold text-destructive tabular-nums">-{formatCurrency(data.avg_daily_outflow, "GBP")}</p>
</div>
</div>
{hasRisk && (
<div className="flex items-start gap-3 bg-destructive/10 border border-destructive/30 rounded-xl px-4 py-3">
<span className="text-destructive text-sm font-medium shrink-0"> Negative balance risk</span>
<p className="text-sm text-muted-foreground">
Balance may go negative on: {data.negative_risk_days.slice(0, 5).join(", ")}
{data.negative_risk_days.length > 5 && ` +${data.negative_risk_days.length - 5} more`}
</p>
</div>
)}
<div className="bg-card border border-border rounded-xl p-5">
<p className="text-sm font-semibold mb-1">30-Day Balance Forecast</p>
<p className="text-xs text-muted-foreground mb-4">Based on {data.history_days} days of transaction history</p>
<ResponsiveContainer width="100%" height={260}>
<AreaChart data={data.forecast} margin={{ top: 5, right: 10, left: 0, bottom: 5 }}>
<defs>
<linearGradient id="balanceGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#6366f1" stopOpacity={0.3} />
<stop offset="95%" stopColor="#6366f1" stopOpacity={0} />
</linearGradient>
</defs>
<XAxis dataKey="date" tick={{ fontSize: 10 }} stroke="var(--muted-foreground)" tickFormatter={v => v.slice(5)} />
<YAxis tick={{ fontSize: 10 }} stroke="var(--muted-foreground)" tickFormatter={v => `£${(v / 1000).toFixed(1)}k`} width={55} />
<Tooltip formatter={(v: number) => formatCurrency(v, "GBP")} />
<ReferenceLine y={0} stroke="#ef4444" strokeDasharray="4 2" />
<Area type="monotone" dataKey="balance" stroke="#6366f1" fill="url(#balanceGrad)" strokeWidth={2} name="Balance" />
</AreaChart>
</ResponsiveContainer>
</div>
</div>
);
}
// ─── Shared components ────────────────────────────────────────────────────────
function LoadingCard() {
return (
<div className="space-y-3">
{[1, 2].map(i => (
<div key={i} className="h-48 bg-card border border-border rounded-xl animate-pulse" />
))}
</div>
);
}
function EmptyCard({ message }: { message: string }) {
return (
<div className="bg-card border border-border rounded-xl py-16 text-center text-muted-foreground">
<Sparkles className="w-10 h-10 mx-auto mb-3 opacity-20" />
<p className="text-sm">{message}</p>
</div>
);
}

View file

@ -0,0 +1,282 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import {
getNetWorthReport,
getIncomeExpenseReport,
getCategoryBreakdown,
getBudgetVsActual,
getSpendingTrends,
} from "@/api/reports";
import { formatCurrency } from "@/utils/currency";
import { cn } from "@/utils/cn";
import {
AreaChart, Area, BarChart, Bar,
PieChart, Pie, Cell, XAxis, YAxis, CartesianGrid,
Tooltip, ResponsiveContainer, Legend
} from "recharts";
import { TrendingUp, TrendingDown, Minus } from "lucide-react";
const TABS = ["Net Worth", "Income vs Expense", "Categories", "Budget vs Actual", "Spending Trends"] as const;
type Tab = typeof TABS[number];
const COLORS = [
"#6366f1", "#22c55e", "#f97316", "#ec4899", "#14b8a6",
"#f59e0b", "#8b5cf6", "#06b6d4", "#84cc16", "#ef4444",
];
function StatCard({ label, value, change, currency }: {
label: string; value: number; change?: number; currency: string;
}) {
const positive = change !== undefined ? change >= 0 : undefined;
return (
<div className="bg-card border border-border rounded-xl p-4">
<p className="text-xs text-muted-foreground mb-1">{label}</p>
<p className="text-xl font-bold tabular-nums">{formatCurrency(value, currency)}</p>
{change !== undefined && (
<div className={cn("flex items-center gap-1 mt-1 text-xs", positive ? "text-success" : "text-destructive")}>
{positive ? <TrendingUp className="w-3 h-3" /> : <TrendingDown className="w-3 h-3" />}
{positive ? "+" : ""}{formatCurrency(change, currency)} (30d)
</div>
)}
</div>
);
}
function NetWorthTab() {
const { data, isLoading } = useQuery({ queryKey: ["report-net-worth"], queryFn: () => getNetWorthReport(12) });
if (isLoading) return <ChartSkeleton />;
if (!data) return null;
return (
<div className="space-y-4">
<div className="grid grid-cols-3 gap-4">
<StatCard label="Net Worth" value={Number(data.current_net_worth)} change={Number(data.change_30d)} currency={data.base_currency} />
<StatCard label="30d Change %" value={Number(data.change_30d_pct)} currency="%" />
<StatCard label="Data Points" value={data.points.length} currency="" />
</div>
{data.points.length === 0 ? (
<EmptyChart message="No snapshots yet — snapshots are taken daily at 2am" />
) : (
<div className="bg-card border border-border rounded-xl p-4">
<p className="text-sm font-medium mb-4">Net Worth Over Time</p>
<ResponsiveContainer width="100%" height={300}>
<AreaChart data={data.points.map(p => ({ ...p, net_worth: Number(p.net_worth), total_assets: Number(p.total_assets), total_liabilities: Number(p.total_liabilities) }))}>
<defs>
<linearGradient id="nwGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#6366f1" stopOpacity={0.3} />
<stop offset="95%" stopColor="#6366f1" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
<XAxis dataKey="date" tick={{ fontSize: 11 }} stroke="var(--muted-foreground)" />
<YAxis tick={{ fontSize: 11 }} stroke="var(--muted-foreground)" tickFormatter={(v) => `£${(v/1000).toFixed(0)}k`} />
<Tooltip formatter={(v: number) => formatCurrency(v, data.base_currency)} />
<Area type="monotone" dataKey="net_worth" stroke="#6366f1" fill="url(#nwGrad)" strokeWidth={2} name="Net Worth" />
<Area type="monotone" dataKey="total_assets" stroke="#22c55e" fill="none" strokeWidth={1.5} strokeDasharray="4 2" name="Assets" />
<Area type="monotone" dataKey="total_liabilities" stroke="#ef4444" fill="none" strokeWidth={1.5} strokeDasharray="4 2" name="Liabilities" />
</AreaChart>
</ResponsiveContainer>
</div>
)}
</div>
);
}
function IncomeExpenseTab() {
const { data, isLoading } = useQuery({ queryKey: ["report-income-expense"], queryFn: () => getIncomeExpenseReport(12) });
if (isLoading) return <ChartSkeleton />;
if (!data) return null;
const chartData = data.points.map(p => ({ ...p, income: Number(p.income), expenses: Number(p.expenses), net: Number(p.net) }));
return (
<div className="space-y-4">
<div className="grid grid-cols-4 gap-4">
<StatCard label="Total Income" value={Number(data.total_income)} currency={data.currency} />
<StatCard label="Total Expenses" value={Number(data.total_expenses)} currency={data.currency} />
<StatCard label="Avg Monthly Income" value={Number(data.avg_monthly_income)} currency={data.currency} />
<StatCard label="Avg Monthly Expenses" value={Number(data.avg_monthly_expenses)} currency={data.currency} />
</div>
{chartData.length === 0 ? <EmptyChart /> : (
<div className="bg-card border border-border rounded-xl p-4">
<p className="text-sm font-medium mb-4">Monthly Income vs Expenses</p>
<ResponsiveContainer width="100%" height={300}>
<BarChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
<XAxis dataKey="month" tick={{ fontSize: 11 }} stroke="var(--muted-foreground)" />
<YAxis tick={{ fontSize: 11 }} stroke="var(--muted-foreground)" tickFormatter={(v) => `£${(v/1000).toFixed(0)}k`} />
<Tooltip formatter={(v: number) => formatCurrency(v, data.currency)} />
<Legend />
<Bar dataKey="income" fill="#22c55e" name="Income" radius={[2, 2, 0, 0]} />
<Bar dataKey="expenses" fill="#ef4444" name="Expenses" radius={[2, 2, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
)}
</div>
);
}
function CategoriesTab() {
const { data, isLoading } = useQuery({ queryKey: ["report-categories"], queryFn: () => getCategoryBreakdown() });
if (isLoading) return <ChartSkeleton />;
if (!data) return null;
const pieData = data.items.slice(0, 10).map(i => ({ name: i.category_name, value: Number(i.amount) }));
return (
<div className="space-y-4">
<div className="bg-card border border-border rounded-xl p-4">
<p className="text-sm font-medium mb-1">Expense Breakdown This Month</p>
<p className="text-xs text-muted-foreground mb-4">Total: {formatCurrency(Number(data.total), data.currency)}</p>
{pieData.length === 0 ? <EmptyChart /> : (
<div className="flex gap-6 items-start">
<ResponsiveContainer width={220} height={220}>
<PieChart>
<Pie data={pieData} cx="50%" cy="50%" innerRadius={60} outerRadius={90} dataKey="value" paddingAngle={2}>
{pieData.map((_, i) => <Cell key={i} fill={COLORS[i % COLORS.length]} />)}
</Pie>
<Tooltip formatter={(v: number) => formatCurrency(v, data.currency)} />
</PieChart>
</ResponsiveContainer>
<div className="flex-1 space-y-2">
{data.items.slice(0, 10).map((item, i) => (
<div key={i} className="flex items-center gap-2 text-sm">
<div className="w-2.5 h-2.5 rounded-full shrink-0" style={{ background: COLORS[i % COLORS.length] }} />
<span className="flex-1 truncate text-muted-foreground">{item.category_name}</span>
<span className="font-medium tabular-nums">{formatCurrency(Number(item.amount), data.currency)}</span>
<span className="text-xs text-muted-foreground w-10 text-right">{item.percent}%</span>
</div>
))}
</div>
</div>
)}
</div>
</div>
);
}
function BudgetVsActualTab() {
const { data, isLoading } = useQuery({ queryKey: ["report-budget-actual"], queryFn: getBudgetVsActual });
if (isLoading) return <ChartSkeleton />;
if (!data || data.items.length === 0) return <EmptyChart message="No active budgets" />;
const chartData = data.items.map(i => ({
name: i.budget_name,
budgeted: Number(i.budgeted),
actual: Number(i.actual),
}));
return (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<StatCard label="Total Budgeted" value={Number(data.total_budgeted)} currency={data.currency} />
<StatCard label="Total Actual" value={Number(data.total_actual)} currency={data.currency} />
</div>
<div className="bg-card border border-border rounded-xl p-4">
<p className="text-sm font-medium mb-4">Budget vs Actual Spending</p>
<ResponsiveContainer width="100%" height={300}>
<BarChart data={chartData} layout="vertical">
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
<XAxis type="number" tick={{ fontSize: 11 }} stroke="var(--muted-foreground)" tickFormatter={(v) => `£${v}`} />
<YAxis type="category" dataKey="name" tick={{ fontSize: 11 }} stroke="var(--muted-foreground)" width={120} />
<Tooltip formatter={(v: number) => formatCurrency(v, data.currency)} />
<Legend />
<Bar dataKey="budgeted" fill="#6366f1" name="Budgeted" radius={[0, 2, 2, 0]} />
<Bar dataKey="actual" fill="#f97316" name="Actual" radius={[0, 2, 2, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
</div>
);
}
function SpendingTrendsTab() {
const { data, isLoading } = useQuery({ queryKey: ["report-spending-trends"], queryFn: () => getSpendingTrends(6) });
if (isLoading) return <ChartSkeleton />;
if (!data || data.points.length === 0) return <EmptyChart />;
const months = [...new Set(data.points.map(p => p.month))].sort();
const chartData = months.map(month => {
const row: Record<string, string | number> = { month };
data.categories.forEach(cat => {
const pt = data.points.find(p => p.month === month && p.category_name === cat);
row[cat] = pt ? Number(pt.amount) : 0;
});
return row;
});
return (
<div className="bg-card border border-border rounded-xl p-4">
<p className="text-sm font-medium mb-4">Spending by Category (6 months)</p>
<ResponsiveContainer width="100%" height={320}>
<BarChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
<XAxis dataKey="month" tick={{ fontSize: 11 }} stroke="var(--muted-foreground)" />
<YAxis tick={{ fontSize: 11 }} stroke="var(--muted-foreground)" tickFormatter={(v) => `£${v}`} />
<Tooltip formatter={(v: number) => formatCurrency(v, data.currency)} />
<Legend />
{data.categories.slice(0, 8).map((cat, i) => (
<Bar key={cat} dataKey={cat} stackId="a" fill={COLORS[i % COLORS.length]} />
))}
</BarChart>
</ResponsiveContainer>
</div>
);
}
function ChartSkeleton() {
return (
<div className="space-y-4">
<div className="grid grid-cols-3 gap-4">
{[1, 2, 3].map(i => <div key={i} className="h-20 bg-card border border-border rounded-xl animate-pulse" />)}
</div>
<div className="h-80 bg-card border border-border rounded-xl animate-pulse" />
</div>
);
}
function EmptyChart({ message = "No data for this period" }: { message?: string }) {
return (
<div className="bg-card border border-border rounded-xl py-16 text-center text-muted-foreground">
<Minus className="w-8 h-8 mx-auto mb-2 opacity-30" />
<p>{message}</p>
</div>
);
}
export default function ReportsPage() {
const [activeTab, setActiveTab] = useState<Tab>("Net Worth");
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold">Reports</h1>
<p className="text-sm text-muted-foreground mt-1">Financial insights and analysis</p>
</div>
<div className="flex gap-1 border-b border-border">
{TABS.map((tab) => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={cn(
"px-4 py-2.5 text-sm font-medium border-b-2 transition-colors",
activeTab === tab
? "border-primary text-foreground"
: "border-transparent text-muted-foreground hover:text-foreground"
)}
>
{tab}
</button>
))}
</div>
<div>
{activeTab === "Net Worth" && <NetWorthTab />}
{activeTab === "Income vs Expense" && <IncomeExpenseTab />}
{activeTab === "Categories" && <CategoriesTab />}
{activeTab === "Budget vs Actual" && <BudgetVsActualTab />}
{activeTab === "Spending Trends" && <SpendingTrendsTab />}
</div>
</div>
);
}

View file

@ -0,0 +1,542 @@
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom";
import { useAuthStore } from "@/store/authStore";
import {
getSessions, revokeSession, revokeAllSessions,
getTotpSetup, enableTotp, disableTotp,
changePassword, updateProfile, exportData, getMe,
} from "@/api/auth";
import { cn } from "@/utils/cn";
import { format } from "date-fns";
import {
User, Shield, MonitorSmartphone, Download,
Loader2, CheckCircle, Eye, EyeOff, Trash2,
LogOut, QrCode, KeyRound, AlertTriangle,
} from "lucide-react";
const SECTIONS = [
{ id: "profile", label: "Profile", icon: User },
{ id: "security", label: "Security", icon: Shield },
{ id: "sessions", label: "Sessions", icon: MonitorSmartphone },
{ id: "data", label: "Data", icon: Download },
] as const;
type Section = (typeof SECTIONS)[number]["id"];
export default function SettingsPage() {
const [section, setSection] = useState<Section>("profile");
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold">Settings</h1>
<p className="text-sm text-muted-foreground mt-1">Manage your account and preferences</p>
</div>
<div className="flex gap-6 flex-col lg:flex-row">
{/* Side nav */}
<nav className="flex lg:flex-col gap-1 lg:w-48 shrink-0 overflow-x-auto lg:overflow-visible">
{SECTIONS.map(({ id, label, icon: Icon }) => (
<button
key={id}
onClick={() => setSection(id)}
className={cn(
"flex items-center gap-2.5 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors whitespace-nowrap",
section === id
? "bg-primary/15 text-primary"
: "text-muted-foreground hover:text-foreground hover:bg-secondary"
)}
>
<Icon className="w-4 h-4 shrink-0" />
{label}
</button>
))}
</nav>
{/* Content */}
<div className="flex-1 min-w-0 space-y-4">
{section === "profile" && <ProfileSection />}
{section === "security" && <SecuritySection />}
{section === "sessions" && <SessionsSection />}
{section === "data" && <DataSection />}
</div>
</div>
</div>
);
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
const inputCls = "w-full rounded-lg border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring";
const cardCls = "bg-card border border-border rounded-xl p-5 space-y-4";
function SectionTitle({ children }: { children: React.ReactNode }) {
return <h2 className="font-semibold text-base">{children}</h2>;
}
function SuccessBanner({ message }: { message: string }) {
return (
<div className="flex items-center gap-2 bg-success/10 border border-success/30 text-success rounded-lg px-3 py-2 text-sm">
<CheckCircle className="w-4 h-4 shrink-0" />
{message}
</div>
);
}
function ErrorBanner({ message }: { message: string }) {
return (
<div className="flex items-center gap-2 bg-destructive/10 border border-destructive/30 text-destructive rounded-lg px-3 py-2 text-sm">
<AlertTriangle className="w-4 h-4 shrink-0" />
{message}
</div>
);
}
// ─── Profile ──────────────────────────────────────────────────────────────────
function ProfileSection() {
const qc = useQueryClient();
const { displayName, setToken, token, userId } = useAuthStore();
const [name, setName] = useState(displayName ?? "");
const [currency, setCurrency] = useState("GBP");
const [success, setSuccess] = useState(false);
useQuery({ queryKey: ["me"], queryFn: getMe, onSuccess: (d: any) => {
setName(d.display_name ?? "");
setCurrency(d.base_currency ?? "GBP");
}} as any);
const mutation = useMutation({
mutationFn: () => updateProfile({ display_name: name, base_currency: currency }),
onSuccess: () => {
setSuccess(true);
setToken(token!, userId!, name);
qc.invalidateQueries({ queryKey: ["me"] });
setTimeout(() => setSuccess(false), 3000);
},
});
return (
<div className={cardCls}>
<SectionTitle>Profile</SectionTitle>
{success && <SuccessBanner message="Profile updated" />}
{mutation.isError && <ErrorBanner message={(mutation.error as any)?.response?.data?.detail ?? "Update failed"} />}
<div>
<label className="text-sm font-medium block mb-1.5">Display name</label>
<input value={name} onChange={e => setName(e.target.value)} className={inputCls} placeholder="Your name" />
</div>
<div>
<label className="text-sm font-medium block mb-1.5">Base currency</label>
<input value={currency} onChange={e => setCurrency(e.target.value.toUpperCase())} className={inputCls} placeholder="GBP" maxLength={10} />
<p className="text-xs text-muted-foreground mt-1">Used for net worth and report totals</p>
</div>
<button
onClick={() => mutation.mutate()}
disabled={mutation.isPending}
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 disabled:opacity-50 transition-colors"
>
{mutation.isPending && <Loader2 className="w-4 h-4 animate-spin" />}
Save Changes
</button>
</div>
);
}
// ─── Security ─────────────────────────────────────────────────────────────────
function SecuritySection() {
return (
<div className="space-y-4">
<PasswordCard />
<TotpCard />
</div>
);
}
function PasswordCard() {
const [current, setCurrent] = useState("");
const [next, setNext] = useState("");
const [confirm, setConfirm] = useState("");
const [showCurrent, setShowCurrent] = useState(false);
const [showNext, setShowNext] = useState(false);
const [success, setSuccess] = useState(false);
const mutation = useMutation({
mutationFn: () => changePassword(current, next),
onSuccess: () => {
setSuccess(true);
setCurrent(""); setNext(""); setConfirm("");
setTimeout(() => setSuccess(false), 4000);
},
});
const mismatch = next.length > 0 && confirm.length > 0 && next !== confirm;
const tooShort = next.length > 0 && next.length < 10;
const canSubmit = current && next && confirm && next === confirm && next.length >= 10;
return (
<div className={cardCls}>
<div className="flex items-center gap-2">
<KeyRound className="w-4 h-4 text-muted-foreground" />
<SectionTitle>Change Password</SectionTitle>
</div>
{success && <SuccessBanner message="Password changed successfully" />}
{mutation.isError && <ErrorBanner message={(mutation.error as any)?.response?.data?.detail ?? "Password change failed"} />}
<div>
<label className="text-sm font-medium block mb-1.5">Current password</label>
<div className="relative">
<input
type={showCurrent ? "text" : "password"}
value={current}
onChange={e => setCurrent(e.target.value)}
className={cn(inputCls, "pr-10")}
/>
<button type="button" onClick={() => setShowCurrent(v => !v)} className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground">
{showCurrent ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
</div>
<div>
<label className="text-sm font-medium block mb-1.5">New password</label>
<div className="relative">
<input
type={showNext ? "text" : "password"}
value={next}
onChange={e => setNext(e.target.value)}
className={cn(inputCls, "pr-10", tooShort && "border-destructive")}
/>
<button type="button" onClick={() => setShowNext(v => !v)} className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground">
{showNext ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
{tooShort && <p className="text-xs text-destructive mt-1">Minimum 10 characters</p>}
{/* Strength bar */}
{next.length > 0 && (
<div className="mt-2 flex gap-1">
{[1,2,3,4].map(i => {
const score = Math.min(4, Math.floor(next.length / 3));
return <div key={i} className={cn("h-1 flex-1 rounded-full transition-colors", i <= score ? (score <= 1 ? "bg-destructive" : score <= 2 ? "bg-yellow-500" : score <= 3 ? "bg-primary" : "bg-success") : "bg-secondary")} />;
})}
</div>
)}
</div>
<div>
<label className="text-sm font-medium block mb-1.5">Confirm new password</label>
<input
type="password"
value={confirm}
onChange={e => setConfirm(e.target.value)}
className={cn(inputCls, mismatch && "border-destructive")}
/>
{mismatch && <p className="text-xs text-destructive mt-1">Passwords don't match</p>}
</div>
<button
onClick={() => mutation.mutate()}
disabled={!canSubmit || mutation.isPending}
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 disabled:opacity-50 transition-colors"
>
{mutation.isPending && <Loader2 className="w-4 h-4 animate-spin" />}
Update Password
</button>
</div>
);
}
function TotpCard() {
const qc = useQueryClient();
const totpEnabled = useAuthStore(s => s.totpEnabled);
const { setToken, token, userId, displayName } = useAuthStore();
const [step, setStep] = useState<"idle" | "setup" | "disable">("idle");
const [setupData, setSetupData] = useState<{ secret: string; qr_code_png_b64: string; backup_codes: string[] } | null>(null);
const [code, setCode] = useState("");
const [password, setPassword] = useState("");
const [backupCodes, setBackupCodes] = useState<string[] | null>(null);
const [success, setSuccess] = useState("");
const setupMutation = useMutation({
mutationFn: getTotpSetup,
onSuccess: (data) => { setSetupData(data); setStep("setup"); },
});
const enableMutation = useMutation({
mutationFn: () => enableTotp(setupData!.secret, code),
onSuccess: () => {
setBackupCodes(setupData!.backup_codes);
setToken(token!, userId!, displayName ?? "");
useAuthStore.setState({ totpEnabled: true });
qc.invalidateQueries({ queryKey: ["me"] });
setStep("idle");
setCode("");
},
});
const disableMutation = useMutation({
mutationFn: () => disableTotp(password),
onSuccess: () => {
useAuthStore.setState({ totpEnabled: false });
qc.invalidateQueries({ queryKey: ["me"] });
setStep("idle");
setPassword("");
setSuccess("Two-factor authentication disabled");
setTimeout(() => setSuccess(""), 4000);
},
});
return (
<div className={cardCls}>
<div className="flex items-center gap-2">
<QrCode className="w-4 h-4 text-muted-foreground" />
<SectionTitle>Two-Factor Authentication</SectionTitle>
<span className={cn("ml-auto text-xs px-2 py-0.5 rounded-full font-medium", totpEnabled ? "bg-success/15 text-success" : "bg-secondary text-muted-foreground")}>
{totpEnabled ? "Enabled" : "Disabled"}
</span>
</div>
{success && <SuccessBanner message={success} />}
{(enableMutation.isError || disableMutation.isError) && (
<ErrorBanner message={(enableMutation.error as any)?.response?.data?.detail ?? (disableMutation.error as any)?.response?.data?.detail ?? "Failed"} />
)}
{/* Backup codes shown after enabling */}
{backupCodes && (
<div className="bg-success/10 border border-success/30 rounded-lg p-4 space-y-2">
<p className="text-sm font-semibold text-success">2FA enabled save your backup codes</p>
<p className="text-xs text-muted-foreground">Store these somewhere safe. Each can only be used once.</p>
<div className="grid grid-cols-2 gap-1 mt-2">
{backupCodes.map(c => (
<code key={c} className="text-xs bg-background px-2 py-1 rounded font-mono">{c}</code>
))}
</div>
<button onClick={() => setBackupCodes(null)} className="text-xs text-muted-foreground hover:text-foreground underline mt-1">
I've saved these
</button>
</div>
)}
{step === "idle" && !totpEnabled && (
<div className="space-y-3">
<p className="text-sm text-muted-foreground">Add an extra layer of security with an authenticator app.</p>
<button
onClick={() => setupMutation.mutate()}
disabled={setupMutation.isPending}
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 disabled:opacity-50 transition-colors"
>
{setupMutation.isPending && <Loader2 className="w-4 h-4 animate-spin" />}
Set up 2FA
</button>
</div>
)}
{step === "idle" && totpEnabled && (
<div className="space-y-3">
<p className="text-sm text-muted-foreground">2FA is active. Your account is protected.</p>
<button
onClick={() => setStep("disable")}
className="flex items-center gap-2 border border-destructive/40 text-destructive px-4 py-2 rounded-lg text-sm font-medium hover:bg-destructive/10 transition-colors"
>
Disable 2FA
</button>
</div>
)}
{step === "setup" && setupData && (
<div className="space-y-4">
<p className="text-sm text-muted-foreground">Scan this QR code with your authenticator app, then enter the 6-digit code to confirm.</p>
<div className="flex justify-center">
<img src={`data:image/png;base64,${setupData.qr_code_png_b64}`} alt="TOTP QR Code" className="w-40 h-40 rounded-lg" />
</div>
<div>
<label className="text-sm font-medium block mb-1.5">Verification code</label>
<input
value={code}
onChange={e => setCode(e.target.value.replace(/\D/g, "").slice(0, 6))}
className={inputCls}
placeholder="000000"
maxLength={6}
/>
</div>
<div className="flex gap-3">
<button onClick={() => setStep("idle")} className="flex-1 border border-border rounded-lg py-2 text-sm hover:bg-secondary transition-colors">
Cancel
</button>
<button
onClick={() => enableMutation.mutate()}
disabled={code.length !== 6 || enableMutation.isPending}
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"
>
{enableMutation.isPending && <Loader2 className="w-4 h-4 animate-spin" />}
Verify & Enable
</button>
</div>
</div>
)}
{step === "disable" && (
<div className="space-y-4">
<p className="text-sm text-muted-foreground">Enter your password to confirm disabling 2FA.</p>
<div>
<label className="text-sm font-medium block mb-1.5">Password</label>
<input type="password" value={password} onChange={e => setPassword(e.target.value)} className={inputCls} />
</div>
<div className="flex gap-3">
<button onClick={() => setStep("idle")} className="flex-1 border border-border rounded-lg py-2 text-sm hover:bg-secondary transition-colors">
Cancel
</button>
<button
onClick={() => disableMutation.mutate()}
disabled={!password || disableMutation.isPending}
className="flex-1 flex items-center justify-center gap-2 bg-destructive text-destructive-foreground rounded-lg py-2 text-sm font-medium hover:bg-destructive/90 disabled:opacity-50 transition-colors"
>
{disableMutation.isPending && <Loader2 className="w-4 h-4 animate-spin" />}
Disable 2FA
</button>
</div>
</div>
)}
</div>
);
}
// ─── Sessions ─────────────────────────────────────────────────────────────────
function SessionsSection() {
const qc = useQueryClient();
const navigate = useNavigate();
const { clearAuth } = useAuthStore();
const { data: sessions = [], isLoading } = useQuery({
queryKey: ["sessions"],
queryFn: getSessions,
});
const revokeMutation = useMutation({
mutationFn: revokeSession,
onSuccess: () => qc.invalidateQueries({ queryKey: ["sessions"] }),
});
const revokeAllMutation = useMutation({
mutationFn: revokeAllSessions,
onSuccess: () => { clearAuth(); navigate("/login"); },
});
return (
<div className={cardCls}>
<div className="flex items-center justify-between">
<SectionTitle>Active Sessions</SectionTitle>
<button
onClick={() => revokeAllMutation.mutate()}
disabled={revokeAllMutation.isPending}
className="flex items-center gap-1.5 text-xs text-destructive hover:text-destructive/80 border border-destructive/30 px-3 py-1.5 rounded-lg hover:bg-destructive/10 transition-colors"
>
{revokeAllMutation.isPending ? <Loader2 className="w-3 h-3 animate-spin" /> : <LogOut className="w-3 h-3" />}
Sign out all
</button>
</div>
<p className="text-sm text-muted-foreground">All devices currently signed into your account.</p>
{isLoading ? (
<div className="space-y-2">
{[1,2,3].map(i => <div key={i} className="h-14 bg-secondary/30 rounded-lg animate-pulse" />)}
</div>
) : (
<div className="space-y-2">
{(sessions as any[]).map((s: any) => (
<div key={s.id} className={cn(
"flex items-center gap-3 p-3 rounded-lg border",
s.is_current ? "border-primary/30 bg-primary/5" : "border-border bg-secondary/20"
)}>
<MonitorSmartphone className="w-4 h-4 text-muted-foreground shrink-0" />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<p className="text-sm font-medium truncate">{s.user_agent?.split(" ")[0] ?? "Unknown device"}</p>
{s.is_current && <span className="text-xs bg-primary/20 text-primary px-1.5 py-0.5 rounded font-medium shrink-0">This device</span>}
</div>
<p className="text-xs text-muted-foreground">
{s.ip_address} · {s.last_active_at ? `Active ${format(new Date(s.last_active_at), "dd MMM HH:mm")}` : `Created ${format(new Date(s.created_at), "dd MMM")}`}
</p>
</div>
{!s.is_current && (
<button
onClick={() => revokeMutation.mutate(s.id)}
disabled={revokeMutation.isPending}
className="p-1.5 rounded text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors shrink-0"
title="Revoke session"
>
<Trash2 className="w-4 h-4" />
</button>
)}
</div>
))}
</div>
)}
</div>
);
}
// ─── Data ─────────────────────────────────────────────────────────────────────
function DataSection() {
const [exporting, setExporting] = useState(false);
const [exported, setExported] = useState(false);
async function handleExport() {
setExporting(true);
try {
await exportData();
setExported(true);
setTimeout(() => setExported(false), 4000);
} finally {
setExporting(false);
}
}
return (
<div className="space-y-4">
<div className={cardCls}>
<div className="flex items-center gap-2">
<Download className="w-4 h-4 text-muted-foreground" />
<SectionTitle>Export Data</SectionTitle>
</div>
{exported && <SuccessBanner message="Download started" />}
<p className="text-sm text-muted-foreground">
Download all your transactions as a CSV file. Includes date, description, amount, category, and account for every transaction.
</p>
<button
onClick={handleExport}
disabled={exporting}
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 disabled:opacity-50 transition-colors"
>
{exporting ? <Loader2 className="w-4 h-4 animate-spin" /> : <Download className="w-4 h-4" />}
{exporting ? "Preparing export…" : "Download transactions CSV"}
</button>
</div>
<div className={cn(cardCls, "border-destructive/30")}>
<SectionTitle>Danger Zone</SectionTitle>
<p className="text-sm text-muted-foreground">
These actions are permanent. Export your data first if needed.
</p>
<div className="flex items-center gap-3 p-3 border border-destructive/20 rounded-lg bg-destructive/5">
<AlertTriangle className="w-4 h-4 text-destructive shrink-0" />
<div className="flex-1">
<p className="text-sm font-medium">Delete account</p>
<p className="text-xs text-muted-foreground">Permanently removes all your data. Cannot be undone.</p>
</div>
<button className="text-xs text-destructive border border-destructive/30 px-3 py-1.5 rounded-lg hover:bg-destructive/10 transition-colors" disabled>
Contact admin
</button>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,258 @@
import { useCallback, useRef, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { format } from "date-fns";
import {
X, Paperclip, Upload, Trash2, FileText, ImageIcon, Loader2,
ArrowUpCircle, ArrowDownCircle, ArrowLeftRight, TrendingUp,
} from "lucide-react";
import { cn } from "@/utils/cn";
import { formatCurrency } from "@/utils/currency";
import type { Transaction, AttachmentRef } from "@/api/transactions";
import { uploadAttachment, deleteAttachment, getAttachmentUrl } from "@/api/transactions";
const TYPE_COLORS = {
income: "text-success",
expense: "text-destructive",
transfer: "text-muted-foreground",
investment: "text-primary",
};
const TYPE_ICONS = {
income: ArrowUpCircle,
expense: ArrowDownCircle,
transfer: ArrowLeftRight,
investment: TrendingUp,
};
const TYPE_BG = {
income: "bg-success/10",
expense: "bg-destructive/10",
transfer: "bg-secondary",
investment: "bg-primary/10",
};
function humanFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function FileIcon({ mimeType }: { mimeType: string }) {
if (mimeType === "application/pdf") return <FileText className="w-4 h-4 shrink-0" />;
return <ImageIcon className="w-4 h-4 shrink-0" />;
}
interface Props {
transaction: Transaction;
accountName?: string;
categoryName?: string;
onClose: () => void;
}
export default function TransactionDetailDrawer({ transaction, accountName, categoryName, onClose }: Props) {
const qc = useQueryClient();
const [attachments, setAttachments] = useState<AttachmentRef[]>(transaction.attachment_refs ?? []);
const [dragging, setDragging] = useState(false);
const [uploadError, setUploadError] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const Icon = TYPE_ICONS[transaction.type] ?? ArrowDownCircle;
const uploadMutation = useMutation({
mutationFn: (file: File) => uploadAttachment(transaction.id, file),
onSuccess: (ref) => {
setAttachments((prev) => [...prev, ref]);
qc.invalidateQueries({ queryKey: ["transactions"] });
setUploadError(null);
},
onError: (err: any) => {
setUploadError(err?.response?.data?.detail ?? "Upload failed");
},
});
const deleteMutation = useMutation({
mutationFn: (attachmentId: string) => deleteAttachment(transaction.id, attachmentId),
onSuccess: (_data, attachmentId) => {
setAttachments((prev) => prev.filter((a) => a.id !== attachmentId));
qc.invalidateQueries({ queryKey: ["transactions"] });
},
});
const handleFiles = useCallback((files: FileList | null) => {
if (!files) return;
setUploadError(null);
for (const file of Array.from(files)) {
uploadMutation.mutate(file);
}
}, [uploadMutation]);
const onDragOver = (e: React.DragEvent) => { e.preventDefault(); setDragging(true); };
const onDragLeave = () => setDragging(false);
const onDrop = (e: React.DragEvent) => {
e.preventDefault();
setDragging(false);
handleFiles(e.dataTransfer.files);
};
return (
<>
{/* Backdrop */}
<div
className="fixed inset-0 z-40 bg-black/40"
onClick={onClose}
/>
{/* Drawer */}
<div className="fixed right-0 top-0 bottom-0 z-50 w-full max-w-md bg-card border-l border-border shadow-2xl flex flex-col overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-border shrink-0">
<div className="flex items-center gap-3 min-w-0">
<div className={cn("w-9 h-9 rounded-full flex items-center justify-center shrink-0", TYPE_BG[transaction.type])}>
<Icon className={cn("w-4 h-4", TYPE_COLORS[transaction.type])} />
</div>
<div className="min-w-0">
<p className="font-semibold truncate">{transaction.description}</p>
<p className="text-xs text-muted-foreground">
{format(new Date(transaction.date), "dd MMMM yyyy")}
</p>
</div>
</div>
<button onClick={onClose} className="ml-2 shrink-0 text-muted-foreground hover:text-foreground p-1 rounded transition-colors">
<X className="w-5 h-5" />
</button>
</div>
{/* Body */}
<div className="flex-1 overflow-y-auto p-5 space-y-5">
{/* Amount */}
<div className="bg-secondary/50 rounded-xl p-4 text-center">
<p className={cn("text-3xl font-bold tabular-nums", TYPE_COLORS[transaction.type])}>
{transaction.amount >= 0 ? "+" : ""}
{formatCurrency(transaction.amount, transaction.currency)}
</p>
{transaction.amount_base !== null && transaction.currency !== transaction.base_currency && (
<p className="text-xs text-muted-foreground mt-1">
{formatCurrency(transaction.amount_base, transaction.base_currency)}
</p>
)}
</div>
{/* Detail rows */}
<div className="space-y-2 text-sm">
{[
["Account", accountName ?? "—"],
["Category", categoryName ?? "Uncategorised"],
["Status", transaction.status.charAt(0).toUpperCase() + transaction.status.slice(1)],
["Type", transaction.type.charAt(0).toUpperCase() + transaction.type.slice(1)],
...(transaction.merchant ? [["Merchant", transaction.merchant]] : []),
...(transaction.notes ? [["Notes", transaction.notes]] : []),
].map(([label, value]) => (
<div key={label} className="flex justify-between gap-4 py-1.5 border-b border-border/50 last:border-0">
<span className="text-muted-foreground shrink-0">{label}</span>
<span className="text-right break-words">{value}</span>
</div>
))}
{transaction.tags.length > 0 && (
<div className="flex justify-between gap-4 py-1.5">
<span className="text-muted-foreground shrink-0">Tags</span>
<div className="flex flex-wrap gap-1 justify-end">
{transaction.tags.map((t) => (
<span key={t} className="text-xs bg-secondary px-2 py-0.5 rounded-full">{t}</span>
))}
</div>
</div>
)}
</div>
{/* Attachments */}
<div>
<div className="flex items-center gap-2 mb-3">
<Paperclip className="w-4 h-4 text-muted-foreground" />
<h3 className="text-sm font-semibold">Receipts & Attachments</h3>
<span className="text-xs text-muted-foreground ml-auto">{attachments.length}/10</span>
</div>
{/* Existing attachments */}
{attachments.length > 0 && (
<div className="space-y-2 mb-3">
{attachments.map((att) => (
<div
key={att.id}
className="flex items-center gap-3 bg-secondary/50 rounded-lg px-3 py-2 group"
>
<FileIcon mimeType={att.mime_type} />
<div className="flex-1 min-w-0">
<a
href={getAttachmentUrl(transaction.id, att.id)}
target="_blank"
rel="noopener noreferrer"
className="text-sm font-medium truncate hover:text-primary transition-colors block"
download={att.filename}
>
{att.filename}
</a>
<p className="text-xs text-muted-foreground">{humanFileSize(att.size)}</p>
</div>
<button
onClick={() => deleteMutation.mutate(att.id)}
disabled={deleteMutation.isPending}
className="shrink-0 text-muted-foreground hover:text-destructive opacity-0 group-hover:opacity-100 transition-all p-1 rounded"
>
{deleteMutation.isPending ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<Trash2 className="w-3.5 h-3.5" />
)}
</button>
</div>
))}
</div>
)}
{/* Drop zone */}
{attachments.length < 10 && (
<div
onDragOver={onDragOver}
onDragLeave={onDragLeave}
onDrop={onDrop}
onClick={() => fileInputRef.current?.click()}
className={cn(
"border-2 border-dashed rounded-xl p-5 text-center cursor-pointer transition-colors select-none",
dragging
? "border-primary bg-primary/5"
: "border-border hover:border-primary/50 hover:bg-secondary/30"
)}
>
{uploadMutation.isPending ? (
<div className="flex flex-col items-center gap-2 text-muted-foreground">
<Loader2 className="w-6 h-6 animate-spin" />
<p className="text-sm">Uploading</p>
</div>
) : (
<div className="flex flex-col items-center gap-2 text-muted-foreground">
<Upload className="w-6 h-6" />
<p className="text-sm font-medium">Drop files here or click to browse</p>
<p className="text-xs">JPEG, PNG, WebP, PDF max 10 MB</p>
</div>
)}
</div>
)}
<input
ref={fileInputRef}
type="file"
multiple
accept=".jpg,.jpeg,.png,.webp,.pdf,image/jpeg,image/png,image/webp,application/pdf"
className="sr-only"
onChange={(e) => handleFiles(e.target.files)}
/>
{uploadError && (
<p className="text-destructive text-xs mt-2">{uploadError}</p>
)}
</div>
</div>
</div>
</>
);
}

View file

@ -0,0 +1,168 @@
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { format } from "date-fns";
import { X, Loader2 } from "lucide-react";
import type { Account } from "@/api/accounts";
const schema = z.object({
account_id: z.string().uuid("Select an account"),
transfer_account_id: z.string().uuid().optional().or(z.literal("")),
category_id: z.string().uuid().optional().or(z.literal("")),
type: z.enum(["income", "expense", "transfer", "investment"]),
status: z.enum(["pending", "cleared", "reconciled", "void"]).default("cleared"),
amount: z.coerce.number().refine((v) => v !== 0, "Amount cannot be zero"),
currency: z.string().min(3).max(10).default("GBP"),
date: z.string().min(1, "Date required"),
description: z.string().min(1, "Description required"),
merchant: z.string().optional(),
notes: z.string().optional(),
});
type Form = z.infer<typeof schema>;
interface Props {
accounts: Account[];
categories: { id: string; name: string; type: string }[];
onClose: () => void;
onSubmit: (data: any) => void;
isLoading: boolean;
}
export default function TransactionFormModal({ accounts, categories, onClose, onSubmit, isLoading }: Props) {
const { register, handleSubmit, watch, formState: { errors } } = useForm<Form>({
resolver: zodResolver(schema),
defaultValues: {
type: "expense",
status: "cleared",
currency: "GBP",
date: format(new Date(), "yyyy-MM-dd"),
},
});
const txnType = watch("type");
const filteredCategories = categories.filter((c) =>
txnType === "income" ? c.type === "income" :
txnType === "expense" ? c.type === "expense" :
c.type === "transfer"
);
function handleFormSubmit(data: Form) {
const payload: any = {
...data,
category_id: data.category_id || undefined,
transfer_account_id: data.transfer_account_id || undefined,
};
// Expenses: ensure negative; Income: ensure positive
if (txnType === "expense" && payload.amount > 0) payload.amount = -payload.amount;
if (txnType === "income" && payload.amount < 0) payload.amount = -payload.amount;
onSubmit(payload);
}
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">Add Transaction</h2>
<button onClick={onClose} className="text-muted-foreground hover:text-foreground">
<X className="w-5 h-5" />
</button>
</div>
<form onSubmit={handleSubmit(handleFormSubmit)} className="p-6 space-y-4">
{/* Type */}
<div>
<label className="text-sm font-medium block mb-1.5">Type</label>
<div className="grid grid-cols-4 gap-1">
{(["expense","income","transfer","investment"] as const).map((t) => (
<label key={t} className="cursor-pointer">
<input {...register("type")} type="radio" value={t} className="sr-only" />
<span className={`block text-center py-1.5 rounded text-xs font-medium border transition-colors ${
watch("type") === t
? "bg-primary text-primary-foreground border-primary"
: "border-border hover:bg-secondary"
}`}>
{t.charAt(0).toUpperCase() + t.slice(1)}
</span>
</label>
))}
</div>
</div>
{/* Account */}
<div>
<label className="text-sm font-medium block mb-1.5">Account *</label>
<select {...register("account_id")} className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring">
<option value="">Select account...</option>
{accounts.map((a) => <option key={a.id} value={a.id}>{a.name}</option>)}
</select>
{errors.account_id && <p className="text-destructive text-xs mt-1">{errors.account_id.message}</p>}
</div>
{/* Transfer destination */}
{txnType === "transfer" && (
<div>
<label className="text-sm font-medium block mb-1.5">To Account *</label>
<select {...register("transfer_account_id")} className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring">
<option value="">Select account...</option>
{accounts.map((a) => <option key={a.id} value={a.id}>{a.name}</option>)}
</select>
</div>
)}
{/* Date + Amount */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-sm font-medium block mb-1.5">Date *</label>
<input {...register("date")} type="date" className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring" />
{errors.date && <p className="text-destructive text-xs mt-1">{errors.date.message}</p>}
</div>
<div>
<label className="text-sm font-medium block mb-1.5">Amount *</label>
<input {...register("amount")} type="number" step="0.01" className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring" placeholder="0.00" />
{errors.amount && <p className="text-destructive text-xs mt-1">{errors.amount.message}</p>}
</div>
</div>
{/* Description */}
<div>
<label className="text-sm font-medium block mb-1.5">Description *</label>
<input {...register("description")} className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring" placeholder="e.g. Tesco groceries" />
{errors.description && <p className="text-destructive text-xs mt-1">{errors.description.message}</p>}
</div>
{/* Merchant */}
<div>
<label className="text-sm font-medium block mb-1.5">Merchant</label>
<input {...register("merchant")} className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring" placeholder="e.g. Tesco" />
</div>
{/* Category */}
<div>
<label className="text-sm font-medium block mb-1.5">Category</label>
<select {...register("category_id")} className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring">
<option value="">Uncategorised</option>
{filteredCategories.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
</div>
{/* Notes */}
<div>
<label className="text-sm font-medium block mb-1.5">Notes</label>
<textarea {...register("notes")} rows={2} className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring resize-none" />
</div>
<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" />}
Add Transaction
</button>
</div>
</form>
</div>
</div>
);
}

View file

@ -0,0 +1,171 @@
import { useState, useRef } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { importCsv } from "@/api/transactions";
import { getAccounts } from "@/api/accounts";
import { Upload, FileText, CheckCircle, XCircle, Loader2, Download } from "lucide-react";
import { api } from "@/api/client";
import { cn } from "@/utils/cn";
export default function TransactionImport() {
const qc = useQueryClient();
const fileRef = useRef<HTMLInputElement>(null);
const [file, setFile] = useState<File | null>(null);
const [accountId, setAccountId] = useState("");
const [colDate, setColDate] = useState("date");
const [colDesc, setColDesc] = useState("description");
const [colAmount, setColAmount] = useState("amount");
const [result, setResult] = useState<{ imported: number; skipped: number } | null>(null);
const { data: accounts = [] } = useQuery({ queryKey: ["accounts"], queryFn: getAccounts });
const importMutation = useMutation({
mutationFn: () => importCsv(file!, accountId, { date: colDate, description: colDesc, amount: colAmount }),
onSuccess: (data) => {
setResult(data);
qc.invalidateQueries({ queryKey: ["transactions"] });
qc.invalidateQueries({ queryKey: ["accounts"] });
},
});
function onDrop(e: React.DragEvent) {
e.preventDefault();
const f = e.dataTransfer.files[0];
if (f?.name.endsWith(".csv")) setFile(f);
}
async function downloadTemplate() {
const res = await api.get("/transactions/import/template", { responseType: "blob" });
const url = URL.createObjectURL(res.data);
const a = document.createElement("a");
a.href = url;
a.download = "import_template.csv";
a.click();
}
return (
<div className="max-w-xl mx-auto space-y-6">
<div>
<h1 className="text-2xl font-bold">Import Transactions</h1>
<p className="text-sm text-muted-foreground mt-1">Import from a CSV bank export</p>
</div>
{result ? (
<div className="bg-card border border-border rounded-xl p-8 text-center space-y-4">
<CheckCircle className="w-12 h-12 text-success mx-auto" />
<div>
<p className="text-xl font-bold">{result.imported} transactions imported</p>
{result.skipped > 0 && (
<p className="text-sm text-muted-foreground mt-1">{result.skipped} duplicates skipped</p>
)}
</div>
<button
onClick={() => { setResult(null); setFile(null); }}
className="bg-primary text-primary-foreground px-6 py-2 rounded-lg text-sm font-medium hover:bg-primary/90 transition-colors"
>
Import another file
</button>
</div>
) : (
<div className="bg-card border border-border rounded-xl p-6 space-y-5">
{/* Template download */}
<button
onClick={downloadTemplate}
className="flex items-center gap-2 text-sm text-primary hover:underline"
>
<Download className="w-4 h-4" />
Download CSV template
</button>
{/* Drop zone */}
<div
onDrop={onDrop}
onDragOver={(e) => e.preventDefault()}
onClick={() => fileRef.current?.click()}
className={cn(
"border-2 border-dashed rounded-xl p-8 text-center cursor-pointer transition-colors",
file ? "border-success bg-success/5" : "border-border hover:border-primary/50"
)}
>
{file ? (
<div className="flex items-center justify-center gap-2">
<FileText className="w-5 h-5 text-success" />
<span className="font-medium text-success">{file.name}</span>
<button
type="button"
onClick={(e) => { e.stopPropagation(); setFile(null); }}
className="text-muted-foreground hover:text-destructive ml-1"
>
<XCircle className="w-4 h-4" />
</button>
</div>
) : (
<div className="text-muted-foreground">
<Upload className="w-8 h-8 mx-auto mb-2 opacity-50" />
<p className="text-sm">Drop a CSV file here or click to browse</p>
</div>
)}
<input
ref={fileRef}
type="file"
accept=".csv"
className="hidden"
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
/>
</div>
{/* Account */}
<div>
<label className="text-sm font-medium block mb-1.5">Import into account *</label>
<select
value={accountId}
onChange={(e) => setAccountId(e.target.value)}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
>
<option value="">Select account...</option>
{accounts.map((a) => <option key={a.id} value={a.id}>{a.name}</option>)}
</select>
</div>
{/* Column mapping */}
<div>
<p className="text-sm font-medium mb-2">Column names in your CSV</p>
<div className="grid grid-cols-3 gap-2">
{[
{ label: "Date column", value: colDate, onChange: setColDate },
{ label: "Description column", value: colDesc, onChange: setColDesc },
{ label: "Amount column", value: colAmount, onChange: setColAmount },
].map(({ label, value, onChange }) => (
<div key={label}>
<label className="text-xs text-muted-foreground block mb-1">{label}</label>
<input
value={value}
onChange={(e) => onChange(e.target.value)}
className="w-full rounded border border-input bg-background px-2 py-1.5 text-xs focus:outline-none focus:ring-1 focus:ring-ring"
/>
</div>
))}
</div>
</div>
<button
onClick={() => importMutation.mutate()}
disabled={!file || !accountId || importMutation.isPending}
className="w-full 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...</>
) : (
<><Upload className="w-4 h-4" /> Import</>
)}
</button>
{importMutation.isError && (
<p className="text-destructive text-sm text-center">
Import failed. Check the file format and column names.
</p>
)}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,266 @@
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { getTransactions, deleteTransaction, createTransaction, getCategories } from "@/api/transactions";
import type { Transaction } from "@/api/transactions";
import { getAccounts } from "@/api/accounts";
import { formatCurrency } from "@/utils/currency";
import { cn } from "@/utils/cn";
import { format } from "date-fns";
import {
Plus, Trash2, Search, ChevronLeft, ChevronRight, Upload,
ArrowUpCircle, ArrowDownCircle, ArrowLeftRight, TrendingUp, Paperclip
} from "lucide-react";
import TransactionFormModal from "./TransactionFormModal";
import TransactionDetailDrawer from "./TransactionDetailDrawer";
import { Link } from "react-router-dom";
const TYPE_COLORS = {
income: "text-success",
expense: "text-destructive",
transfer: "text-muted-foreground",
investment: "text-primary",
};
const TYPE_ICONS = {
income: ArrowUpCircle,
expense: ArrowDownCircle,
transfer: ArrowLeftRight,
investment: TrendingUp,
};
export default function TransactionList() {
const qc = useQueryClient();
const [showForm, setShowForm] = useState(false);
const [selectedTxn, setSelectedTxn] = useState<Transaction | null>(null);
const [search, setSearch] = useState("");
const [filterType, setFilterType] = useState("");
const [filterAccount, setFilterAccount] = useState("");
const [page, setPage] = useState(1);
const { data, isLoading } = useQuery({
queryKey: ["transactions", { search, filterType, filterAccount, page }],
queryFn: () =>
getTransactions({
search: search || undefined,
type: filterType || undefined,
account_id: filterAccount || undefined,
page,
page_size: 50,
}),
});
const { data: accounts = [] } = useQuery({ queryKey: ["accounts"], queryFn: getAccounts });
const { data: categories = [] } = useQuery({ queryKey: ["categories"], queryFn: getCategories });
const deleteMutation = useMutation({
mutationFn: deleteTransaction,
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["transactions"] });
qc.invalidateQueries({ queryKey: ["accounts"] });
},
});
const createMutation = useMutation({
mutationFn: createTransaction,
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["transactions"] });
qc.invalidateQueries({ queryKey: ["accounts"] });
setShowForm(false);
},
});
const accountMap = Object.fromEntries(accounts.map((a) => [a.id, a]));
const categoryMap = Object.fromEntries(categories.map((c) => [c.id, c]));
return (
<div className="space-y-4">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Transactions</h1>
<p className="text-sm text-muted-foreground mt-1">
{data ? `${data.total} transactions` : ""}
</p>
</div>
<div className="flex gap-2">
<Link
to="/transactions/import"
className="flex items-center gap-2 border border-border px-3 py-2 rounded-lg text-sm hover:bg-secondary transition-colors"
>
<Upload className="w-4 h-4" />
Import CSV
</Link>
<button
onClick={() => setShowForm(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
</button>
</div>
</div>
{/* Filters */}
<div className="flex gap-2 flex-wrap">
<div className="relative flex-1 min-w-48">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<input
value={search}
onChange={(e) => { setSearch(e.target.value); setPage(1); }}
placeholder="Search transactions..."
className="w-full pl-9 pr-3 py-2 rounded-lg border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
<select
value={filterType}
onChange={(e) => { setFilterType(e.target.value); setPage(1); }}
className="px-3 py-2 rounded-lg border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
>
<option value="">All types</option>
<option value="income">Income</option>
<option value="expense">Expense</option>
<option value="transfer">Transfer</option>
<option value="investment">Investment</option>
</select>
<select
value={filterAccount}
onChange={(e) => { setFilterAccount(e.target.value); setPage(1); }}
className="px-3 py-2 rounded-lg border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
>
<option value="">All accounts</option>
{accounts.map((a) => (
<option key={a.id} value={a.id}>{a.name}</option>
))}
</select>
</div>
{/* Table */}
<div className="bg-card border border-border rounded-xl overflow-hidden">
{isLoading ? (
<div className="space-y-px">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="h-14 bg-secondary/30 animate-pulse" />
))}
</div>
) : (data?.items.length ?? 0) === 0 ? (
<div className="py-16 text-center text-muted-foreground">
<p>No transactions found</p>
</div>
) : (
<table className="w-full text-sm">
<thead className="border-b border-border">
<tr className="text-muted-foreground text-xs uppercase tracking-wider">
<th className="text-left px-4 py-3">Date</th>
<th className="text-left px-4 py-3">Description</th>
<th className="text-left px-4 py-3 hidden md:table-cell">Account</th>
<th className="text-left px-4 py-3 hidden lg:table-cell">Category</th>
<th className="text-right px-4 py-3">Amount</th>
<th className="w-10"></th>
</tr>
</thead>
<tbody>
{data?.items.map((txn) => {
const Icon = TYPE_ICONS[txn.type] || ArrowDownCircle;
const account = accountMap[txn.account_id];
const category = txn.category_id ? categoryMap[txn.category_id] : null;
return (
<tr
key={txn.id}
className="border-b border-border/50 hover:bg-secondary/20 transition-colors group cursor-pointer"
onClick={() => setSelectedTxn(txn)}
>
<td className="px-4 py-3 text-muted-foreground whitespace-nowrap">
{format(new Date(txn.date), "dd MMM yyyy")}
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<Icon className={cn("w-4 h-4 shrink-0", TYPE_COLORS[txn.type])} />
<div className="min-w-0">
<div className="flex items-center gap-1.5">
<p className="truncate font-medium">{txn.description}</p>
{txn.attachment_refs?.length > 0 && (
<Paperclip className="w-3 h-3 text-muted-foreground shrink-0" />
)}
</div>
{txn.merchant && <p className="text-xs text-muted-foreground truncate">{txn.merchant}</p>}
</div>
</div>
</td>
<td className="px-4 py-3 hidden md:table-cell text-muted-foreground">
{account?.name ?? "—"}
</td>
<td className="px-4 py-3 hidden lg:table-cell">
{category ? (
<span className="text-xs bg-secondary px-2 py-0.5 rounded-full">{category.name}</span>
) : (
<span className="text-muted-foreground text-xs">Uncategorised</span>
)}
</td>
<td className={cn("px-4 py-3 text-right font-semibold tabular-nums whitespace-nowrap", TYPE_COLORS[txn.type])}>
{txn.amount >= 0 ? "+" : ""}
{formatCurrency(txn.amount, txn.currency)}
</td>
<td className="px-4 py-3" onClick={(e) => e.stopPropagation()}>
<button
onClick={() => deleteMutation.mutate(txn.id)}
className="p-1 rounded text-muted-foreground hover:text-destructive opacity-0 group-hover:opacity-100 transition-all"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</td>
</tr>
);
})}
</tbody>
</table>
)}
</div>
{/* Pagination */}
{data && data.pages > 1 && (
<div className="flex items-center justify-between text-sm">
<p className="text-muted-foreground">
Page {data.page} of {data.pages}
</p>
<div className="flex gap-2">
<button
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1}
className="p-2 rounded border border-border hover:bg-secondary disabled:opacity-40 transition-colors"
>
<ChevronLeft className="w-4 h-4" />
</button>
<button
onClick={() => setPage((p) => Math.min(data.pages, p + 1))}
disabled={page === data.pages}
className="p-2 rounded border border-border hover:bg-secondary disabled:opacity-40 transition-colors"
>
<ChevronRight className="w-4 h-4" />
</button>
</div>
</div>
)}
{showForm && (
<TransactionFormModal
accounts={accounts}
categories={categories}
onClose={() => setShowForm(false)}
onSubmit={(data) => createMutation.mutate(data)}
isLoading={createMutation.isPending}
/>
)}
{selectedTxn && (
<TransactionDetailDrawer
transaction={selectedTxn}
accountName={accountMap[selectedTxn.account_id]?.name}
categoryName={selectedTxn.category_id ? categoryMap[selectedTxn.category_id]?.name : undefined}
onClose={() => setSelectedTxn(null)}
/>
)}
</div>
);
}