Initial commit: MyMidas personal finance tracker
Full-stack self-hosted finance app with FastAPI backend and React frontend. Features: - Accounts, transactions, budgets, investments with GBP base currency - CSV import with auto-detection for 10 UK bank formats - ML predictions: spending forecast, net worth projection, Monte Carlo - 7 selectable themes (Obsidian, Arctic, Midnight, Vault, Terminal, Synthwave, Ledger) - Receipt/document attachments on transactions (JPEG, PNG, WebP, PDF) - AES-256-GCM field encryption, RS256 JWT, TOTP 2FA, RLS, audit log - Encrypted nightly backups + key rotation script - Mobile-responsive layout with bottom nav Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
commit
61a7884ee5
127 changed files with 13323 additions and 0 deletions
448
frontend/src/pages/accounts/AccountDetail.tsx
Normal file
448
frontend/src/pages/accounts/AccountDetail.tsx
Normal file
|
|
@ -0,0 +1,448 @@
|
|||
import { useState, useRef } from "react";
|
||||
import { useParams, Link } from "react-router-dom";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { getAccounts, previewImport, importCsvToAccount, type CsvMapping } from "@/api/accounts";
|
||||
import { getTransactions } from "@/api/transactions";
|
||||
import { formatCurrency } from "@/utils/currency";
|
||||
import { cn } from "@/utils/cn";
|
||||
import { format } from "date-fns";
|
||||
import {
|
||||
ArrowLeft, Upload, FileText, XCircle, Loader2, CheckCircle,
|
||||
ArrowUpCircle, ArrowDownCircle, ArrowLeftRight, ChevronLeft, ChevronRight,
|
||||
} from "lucide-react";
|
||||
|
||||
export default function AccountDetail() {
|
||||
const { accountId } = useParams<{ accountId: string }>();
|
||||
const qc = useQueryClient();
|
||||
const [showImport, setShowImport] = useState(false);
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
const { data: accounts = [] } = useQuery({ queryKey: ["accounts"], queryFn: getAccounts });
|
||||
const account = accounts.find(a => a.id === accountId);
|
||||
|
||||
const { data: txnData, isLoading: txnLoading } = useQuery({
|
||||
queryKey: ["transactions", { account_id: accountId, page, page_size: 25 }],
|
||||
queryFn: () => getTransactions({ account_id: accountId, page, page_size: 25 }),
|
||||
enabled: !!accountId,
|
||||
});
|
||||
|
||||
if (!account) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64 text-muted-foreground">
|
||||
<p>Account not found</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isLiability = ["credit_card", "loan", "mortgage"].includes(account.type);
|
||||
const utilPct = account.credit_limit && account.credit_limit > 0
|
||||
? Math.min(100, (Math.abs(account.current_balance) / account.credit_limit) * 100)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Back + header */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Link to="/accounts" className="p-2 rounded-lg hover:bg-secondary transition-colors text-muted-foreground">
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
</Link>
|
||||
<div className="flex-1">
|
||||
<h1 className="text-2xl font-bold">{account.name}</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{account.institution && `${account.institution} · `}
|
||||
{account.type.replace(/_/g, " ")} · {account.currency}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowImport(true)}
|
||||
className="flex items-center gap-2 bg-primary text-primary-foreground px-4 py-2 rounded-lg text-sm font-medium hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
<Upload className="w-4 h-4" />
|
||||
Import CSV
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Account stats */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||
<div className="bg-card border border-border rounded-xl p-4">
|
||||
<p className="text-xs text-muted-foreground mb-1">Current Balance</p>
|
||||
<p className={cn("text-xl font-bold tabular-nums", isLiability ? "text-destructive" : "text-foreground")}>
|
||||
{formatCurrency(account.current_balance, account.currency)}
|
||||
</p>
|
||||
</div>
|
||||
{account.credit_limit != null && (
|
||||
<div className="bg-card border border-border rounded-xl p-4">
|
||||
<p className="text-xs text-muted-foreground mb-1">Credit Limit</p>
|
||||
<p className="text-xl font-bold tabular-nums">{formatCurrency(account.credit_limit, account.currency)}</p>
|
||||
</div>
|
||||
)}
|
||||
{account.interest_rate != null && (
|
||||
<div className="bg-card border border-border rounded-xl p-4">
|
||||
<p className="text-xs text-muted-foreground mb-1">Interest Rate</p>
|
||||
<p className="text-xl font-bold">{Number(account.interest_rate).toFixed(2)}% p.a.</p>
|
||||
</div>
|
||||
)}
|
||||
{txnData && (
|
||||
<div className="bg-card border border-border rounded-xl p-4">
|
||||
<p className="text-xs text-muted-foreground mb-1">Total Transactions</p>
|
||||
<p className="text-xl font-bold tabular-nums">{txnData.total}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Credit utilisation */}
|
||||
{utilPct !== null && (
|
||||
<div className="bg-card border border-border rounded-xl p-4">
|
||||
<div className="flex justify-between text-sm mb-2">
|
||||
<span className="font-medium">Credit Utilisation</span>
|
||||
<span className={cn("font-semibold", utilPct > 80 ? "text-destructive" : utilPct > 50 ? "text-yellow-500" : "text-success")}>
|
||||
{utilPct.toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 bg-secondary rounded-full overflow-hidden">
|
||||
<div
|
||||
className={cn("h-full rounded-full transition-all", utilPct > 80 ? "bg-destructive" : utilPct > 50 ? "bg-yellow-500" : "bg-success")}
|
||||
style={{ width: `${utilPct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{formatCurrency(Math.abs(account.current_balance), account.currency)} used of {formatCurrency(account.credit_limit!, account.currency)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{account.notes && (
|
||||
<div className="bg-card border border-border rounded-xl p-4">
|
||||
<p className="text-xs text-muted-foreground mb-1">Notes</p>
|
||||
<p className="text-sm">{account.notes}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Transactions */}
|
||||
<div className="bg-card border border-border rounded-xl overflow-hidden">
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-border">
|
||||
<p className="font-semibold">Transactions</p>
|
||||
{txnData && txnData.pages > 1 && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<span>Page {page} of {txnData.pages}</span>
|
||||
<button onClick={() => setPage(p => Math.max(1, p - 1))} disabled={page === 1} className="p-1 rounded hover:bg-secondary disabled:opacity-40">
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</button>
|
||||
<button onClick={() => setPage(p => Math.min(txnData.pages, p + 1))} disabled={page === txnData.pages} className="p-1 rounded hover:bg-secondary disabled:opacity-40">
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{txnLoading ? (
|
||||
<div className="space-y-px">
|
||||
{[1, 2, 3, 4, 5].map(i => <div key={i} className="h-14 bg-secondary/20 animate-pulse" />)}
|
||||
</div>
|
||||
) : !txnData?.items.length ? (
|
||||
<div className="py-16 text-center text-muted-foreground text-sm">
|
||||
No transactions yet.{" "}
|
||||
<button onClick={() => setShowImport(true)} className="text-primary hover:underline">Import from CSV</button>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
{txnData.items.map(txn => (
|
||||
<div key={txn.id} className="flex items-center gap-3 px-5 py-3 border-b border-border/50 hover:bg-secondary/20 transition-colors">
|
||||
<div className={cn("p-1.5 rounded-lg shrink-0",
|
||||
txn.type === "income" ? "bg-success/10" :
|
||||
txn.type === "transfer" ? "bg-primary/10" : "bg-destructive/10"
|
||||
)}>
|
||||
{txn.type === "income"
|
||||
? <ArrowUpCircle className="w-3.5 h-3.5 text-success" />
|
||||
: txn.type === "transfer"
|
||||
? <ArrowLeftRight className="w-3.5 h-3.5 text-primary" />
|
||||
: <ArrowDownCircle className="w-3.5 h-3.5 text-destructive" />}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">{txn.description}</p>
|
||||
<p className="text-xs text-muted-foreground">{format(new Date(txn.date), "dd MMM yyyy")}</p>
|
||||
</div>
|
||||
<p className={cn("text-sm font-semibold tabular-nums shrink-0",
|
||||
txn.type === "income" ? "text-success" :
|
||||
txn.type === "expense" ? "text-destructive" : "text-muted-foreground"
|
||||
)}>
|
||||
{Number(txn.amount) > 0 ? "+" : ""}{formatCurrency(txn.amount, txn.currency)}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showImport && account && (
|
||||
<ImportModal
|
||||
accountId={account.id}
|
||||
accountName={account.name}
|
||||
onClose={() => setShowImport(false)}
|
||||
onSuccess={() => {
|
||||
qc.invalidateQueries({ queryKey: ["transactions"] });
|
||||
qc.invalidateQueries({ queryKey: ["accounts"] });
|
||||
qc.invalidateQueries({ queryKey: ["net-worth"] });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Import Modal ─────────────────────────────────────────────────────────────
|
||||
|
||||
type ImportStep = "upload" | "preview" | "done";
|
||||
|
||||
function ImportModal({
|
||||
accountId, accountName, onClose, onSuccess,
|
||||
}: {
|
||||
accountId: string;
|
||||
accountName: string;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
}) {
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const [step, setStep] = useState<ImportStep>("upload");
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [preview, setPreview] = useState<Awaited<ReturnType<typeof previewImport>> | null>(null);
|
||||
const [mapping, setMapping] = useState<CsvMapping | null>(null);
|
||||
const [result, setResult] = useState<{ imported: number; skipped: number } | null>(null);
|
||||
const [detecting, setDetecting] = useState(false);
|
||||
const [detectError, setDetectError] = useState<string | null>(null);
|
||||
|
||||
const importMutation = useMutation({
|
||||
mutationFn: () => importCsvToAccount(accountId, file!, mapping!),
|
||||
onSuccess: (data) => {
|
||||
setResult(data);
|
||||
setStep("done");
|
||||
onSuccess();
|
||||
},
|
||||
});
|
||||
|
||||
async function handleFileSelect(f: File) {
|
||||
setFile(f);
|
||||
setDetecting(true);
|
||||
setDetectError(null);
|
||||
try {
|
||||
const p = await previewImport(accountId, f);
|
||||
setPreview(p);
|
||||
setMapping(p.mapping);
|
||||
setStep("preview");
|
||||
} catch (e: any) {
|
||||
setDetectError(e?.response?.data?.detail ?? "Failed to read file");
|
||||
} finally {
|
||||
setDetecting(false);
|
||||
}
|
||||
}
|
||||
|
||||
function onDrop(e: React.DragEvent) {
|
||||
e.preventDefault();
|
||||
const f = e.dataTransfer.files[0];
|
||||
if (f?.name.toLowerCase().endsWith(".csv")) handleFileSelect(f);
|
||||
}
|
||||
|
||||
const isSplit = mapping ? (!!mapping.debit && !!mapping.credit) : false;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm">
|
||||
<div className="bg-card border border-border rounded-2xl w-full max-w-2xl max-h-[90vh] overflow-y-auto shadow-xl">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-5 border-b border-border">
|
||||
<div>
|
||||
<h2 className="font-semibold text-lg">Import CSV</h2>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">into {accountName}</p>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-1.5 rounded-lg hover:bg-secondary text-muted-foreground">
|
||||
<XCircle className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-5 space-y-5">
|
||||
{/* Step: upload */}
|
||||
{step === "upload" && (
|
||||
<>
|
||||
<div
|
||||
onDrop={onDrop}
|
||||
onDragOver={e => e.preventDefault()}
|
||||
onClick={() => fileRef.current?.click()}
|
||||
className="border-2 border-dashed border-border rounded-xl p-10 text-center cursor-pointer hover:border-primary/50 transition-colors"
|
||||
>
|
||||
{detecting ? (
|
||||
<div className="flex flex-col items-center gap-2 text-muted-foreground">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-primary" />
|
||||
<p className="text-sm">Detecting format…</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-muted-foreground">
|
||||
<Upload className="w-8 h-8 mx-auto mb-2 opacity-50" />
|
||||
<p className="text-sm font-medium">Drop your bank CSV here</p>
|
||||
<p className="text-xs mt-1 opacity-60">Supports Monzo, Starling, Revolut, Barclays, Lloyds, NatWest, HSBC, Santander, Nationwide</p>
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept=".csv"
|
||||
className="hidden"
|
||||
onChange={e => { const f = e.target.files?.[0]; if (f) handleFileSelect(f); }}
|
||||
/>
|
||||
</div>
|
||||
{detectError && <p className="text-destructive text-sm text-center">{detectError}</p>}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Step: preview + mapping */}
|
||||
{step === "preview" && preview && mapping && (
|
||||
<>
|
||||
{/* Detected format badge */}
|
||||
<div className={cn(
|
||||
"flex items-center gap-2 px-3 py-2 rounded-lg text-sm",
|
||||
preview.detected_format ? "bg-success/10 text-success" : "bg-yellow-500/10 text-yellow-600"
|
||||
)}>
|
||||
{preview.detected_format ? (
|
||||
<><CheckCircle className="w-4 h-4 shrink-0" /> Detected: <strong>{preview.detected_format}</strong></>
|
||||
) : (
|
||||
<><FileText className="w-4 h-4 shrink-0" /> Unknown format — please verify column mapping below</>
|
||||
)}
|
||||
<span className="ml-auto text-xs opacity-70">{preview.total_rows} rows</span>
|
||||
</div>
|
||||
|
||||
{/* Column mapping */}
|
||||
<div>
|
||||
<p className="text-sm font-semibold mb-3">Column Mapping</p>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<ColSelect label="Date column *" value={mapping.date} headers={preview.headers}
|
||||
onChange={v => setMapping(m => m ? { ...m, date: v } : m)} />
|
||||
<ColSelect label="Description column *" value={mapping.description} headers={preview.headers}
|
||||
onChange={v => setMapping(m => m ? { ...m, description: v } : m)} />
|
||||
|
||||
{isSplit ? (
|
||||
<>
|
||||
<ColSelect label="Debit column (money out)" value={mapping.debit ?? ""} headers={["", ...preview.headers]}
|
||||
onChange={v => setMapping(m => m ? { ...m, debit: v || null } : m)} />
|
||||
<ColSelect label="Credit column (money in)" value={mapping.credit ?? ""} headers={["", ...preview.headers]}
|
||||
onChange={v => setMapping(m => m ? { ...m, credit: v || null } : m)} />
|
||||
</>
|
||||
) : (
|
||||
<ColSelect label="Amount column *" value={mapping.amount ?? ""} headers={preview.headers}
|
||||
onChange={v => setMapping(m => m ? { ...m, amount: v || null } : m)} />
|
||||
)}
|
||||
|
||||
<div className="flex items-end gap-2">
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSplit}
|
||||
onChange={e => {
|
||||
if (e.target.checked) {
|
||||
setMapping(m => m ? { ...m, amount: null, debit: preview.headers[0] ?? "", credit: preview.headers[1] ?? "" } : m);
|
||||
} else {
|
||||
setMapping(m => m ? { ...m, debit: null, credit: null, amount: preview.headers[0] ?? "" } : m);
|
||||
}
|
||||
}}
|
||||
className="rounded"
|
||||
/>
|
||||
Separate debit/credit columns
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Preview table */}
|
||||
<div>
|
||||
<p className="text-sm font-semibold mb-2">Preview (first {preview.preview.length} rows)</p>
|
||||
<div className="overflow-x-auto rounded-lg border border-border">
|
||||
<table className="w-full text-xs">
|
||||
<thead className="bg-secondary/40">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2 text-muted-foreground font-medium">Date</th>
|
||||
<th className="text-left px-3 py-2 text-muted-foreground font-medium">Description</th>
|
||||
<th className="text-right px-3 py-2 text-muted-foreground font-medium">Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{preview.preview.map((row, i) => (
|
||||
<tr key={i} className="border-t border-border/50">
|
||||
<td className="px-3 py-2 text-muted-foreground">{row.date_raw}</td>
|
||||
<td className="px-3 py-2 truncate max-w-xs">{row.description_raw}</td>
|
||||
<td className={cn("px-3 py-2 text-right tabular-nums font-medium",
|
||||
row.amount_raw == null ? "text-muted-foreground" :
|
||||
row.amount_raw >= 0 ? "text-success" : "text-destructive"
|
||||
)}>
|
||||
{row.amount_raw != null ? formatCurrency(row.amount_raw, "GBP") : "—"}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{importMutation.isError && (
|
||||
<p className="text-destructive text-sm bg-destructive/10 rounded-md px-3 py-2">
|
||||
{(importMutation.error as any)?.response?.data?.detail ?? "Import failed"}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button onClick={() => { setStep("upload"); setFile(null); setPreview(null); }}
|
||||
className="flex-1 border border-border rounded-lg py-2.5 text-sm hover:bg-secondary transition-colors">
|
||||
Back
|
||||
</button>
|
||||
<button
|
||||
onClick={() => importMutation.mutate()}
|
||||
disabled={importMutation.isPending || !mapping.date || !mapping.description || (!isSplit && !mapping.amount) || (isSplit && (!mapping.debit || !mapping.credit))}
|
||||
className="flex-1 flex items-center justify-center gap-2 bg-primary text-primary-foreground rounded-lg py-2.5 text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{importMutation.isPending ? <><Loader2 className="w-4 h-4 animate-spin" /> Importing…</> : `Import ${preview.total_rows} rows`}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Step: done */}
|
||||
{step === "done" && result && (
|
||||
<div className="text-center py-8 space-y-4">
|
||||
<CheckCircle className="w-14 h-14 text-success mx-auto" />
|
||||
<div>
|
||||
<p className="text-xl font-bold">{result.imported} transaction{result.imported !== 1 ? "s" : ""} imported</p>
|
||||
{result.skipped > 0 && (
|
||||
<p className="text-sm text-muted-foreground mt-1">{result.skipped} duplicate{result.skipped !== 1 ? "s" : ""} skipped</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-3 justify-center">
|
||||
<button onClick={() => { setStep("upload"); setFile(null); setPreview(null); setResult(null); }}
|
||||
className="border border-border rounded-lg px-5 py-2 text-sm hover:bg-secondary transition-colors">
|
||||
Import another
|
||||
</button>
|
||||
<button onClick={onClose}
|
||||
className="bg-primary text-primary-foreground rounded-lg px-5 py-2 text-sm font-medium hover:bg-primary/90 transition-colors">
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ColSelect({ label, value, headers, onChange }: {
|
||||
label: string; value: string; headers: string[]; onChange: (v: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground block mb-1">{label}</label>
|
||||
<select
|
||||
value={value}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
className="w-full rounded-md border border-input bg-background px-2 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
>
|
||||
{headers.map(h => <option key={h} value={h}>{h || "— none —"}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
218
frontend/src/pages/accounts/AccountFormModal.tsx
Normal file
218
frontend/src/pages/accounts/AccountFormModal.tsx
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
import { useState } from "react";
|
||||
import { X, Loader2 } from "lucide-react";
|
||||
import { type Account, type AccountCreate } from "@/api/accounts";
|
||||
|
||||
const ACCOUNT_TYPES = [
|
||||
{ value: "checking", label: "Checking / Current" },
|
||||
{ value: "savings", label: "Savings" },
|
||||
{ value: "cash_isa", label: "Cash ISA" },
|
||||
{ value: "stocks_shares_isa", label: "Stocks & Shares ISA" },
|
||||
{ value: "credit_card", label: "Credit Card" },
|
||||
{ value: "investment", label: "Investment" },
|
||||
{ value: "cash", label: "Cash" },
|
||||
{ value: "crypto_wallet", label: "Crypto Wallet" },
|
||||
{ value: "loan", label: "Loan" },
|
||||
{ value: "mortgage", label: "Mortgage" },
|
||||
{ value: "pension", label: "Pension" },
|
||||
{ value: "other", label: "Other" },
|
||||
];
|
||||
|
||||
const COLORS = ["#6366f1", "#22c55e", "#0ea5e9", "#f59e0b", "#ec4899", "#ef4444", "#a855f7", "#10b981", "#64748b"];
|
||||
|
||||
interface Props {
|
||||
account?: Account;
|
||||
onClose: () => void;
|
||||
onSubmit: (data: AccountCreate) => void;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export default function AccountFormModal({ account, onClose, onSubmit, isLoading }: Props) {
|
||||
const isEdit = !!account;
|
||||
|
||||
const [form, setForm] = useState({
|
||||
name: account?.name ?? "",
|
||||
institution: account?.institution ?? "",
|
||||
type: account?.type ?? "checking",
|
||||
currency: account?.currency ?? "GBP",
|
||||
opening_balance: account ? String(account.current_balance) : "0",
|
||||
credit_limit: account?.credit_limit != null ? String(account.credit_limit) : "",
|
||||
interest_rate: account?.interest_rate != null ? String(account.interest_rate) : "",
|
||||
include_in_net_worth: account?.include_in_net_worth ?? true,
|
||||
color: account?.color ?? "#6366f1",
|
||||
notes: account?.notes ?? "",
|
||||
});
|
||||
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const showCreditFields = form.type === "credit_card";
|
||||
const showInterestFields = ["loan", "mortgage", "credit_card", "savings", "cash_isa", "pension"].includes(form.type);
|
||||
|
||||
function set(key: string, value: string | boolean) {
|
||||
setForm(f => ({ ...f, [key]: value }));
|
||||
}
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!form.name.trim()) { setError("Account name is required"); return; }
|
||||
setError(null);
|
||||
onSubmit({
|
||||
name: form.name.trim(),
|
||||
institution: form.institution || undefined,
|
||||
type: form.type,
|
||||
currency: form.currency || "GBP",
|
||||
opening_balance: parseFloat(form.opening_balance) || 0,
|
||||
credit_limit: form.credit_limit ? parseFloat(form.credit_limit) : undefined,
|
||||
interest_rate: form.interest_rate ? parseFloat(form.interest_rate) : undefined,
|
||||
include_in_net_worth: form.include_in_net_worth,
|
||||
color: form.color,
|
||||
notes: form.notes || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
const inputCls = "w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring";
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60">
|
||||
<div className="bg-card border border-border rounded-xl w-full max-w-md max-h-[90vh] overflow-y-auto">
|
||||
<div className="flex items-center justify-between p-6 border-b border-border">
|
||||
<h2 className="text-lg font-semibold">{isEdit ? "Edit Account" : "Add Account"}</h2>
|
||||
<button onClick={onClose} className="text-muted-foreground hover:text-foreground">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} noValidate className="p-6 space-y-4">
|
||||
{/* Name */}
|
||||
<div>
|
||||
<label className="text-sm font-medium block mb-1.5">Account Name *</label>
|
||||
<input
|
||||
value={form.name}
|
||||
onChange={e => set("name", e.target.value)}
|
||||
className={inputCls}
|
||||
placeholder="e.g. Barclays Current"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Type (only for create) */}
|
||||
{!isEdit && (
|
||||
<div>
|
||||
<label className="text-sm font-medium block mb-1.5">Account Type *</label>
|
||||
<select value={form.type} onChange={e => set("type", e.target.value)} className={inputCls}>
|
||||
{ACCOUNT_TYPES.map(t => <option key={t.value} value={t.value}>{t.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isEdit && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Type: <span className="text-foreground font-medium">{ACCOUNT_TYPES.find(t => t.value === form.type)?.label ?? form.type}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Institution + Currency */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-sm font-medium block mb-1.5">Institution</label>
|
||||
<input value={form.institution} onChange={e => set("institution", e.target.value)} className={inputCls} placeholder="e.g. Barclays" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium block mb-1.5">Currency</label>
|
||||
<input value={form.currency} onChange={e => set("currency", e.target.value)} className={inputCls} placeholder="GBP" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Balance (label changes based on edit vs create) */}
|
||||
<div>
|
||||
<label className="text-sm font-medium block mb-1.5">{isEdit ? "Current Balance" : "Opening Balance"}</label>
|
||||
<input
|
||||
type="number" step="0.01"
|
||||
value={form.opening_balance}
|
||||
onChange={e => set("opening_balance", e.target.value)}
|
||||
className={inputCls}
|
||||
placeholder="0.00"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Credit limit */}
|
||||
{showCreditFields && (
|
||||
<div>
|
||||
<label className="text-sm font-medium block mb-1.5">Credit Limit</label>
|
||||
<input
|
||||
type="number" step="0.01"
|
||||
value={form.credit_limit}
|
||||
onChange={e => set("credit_limit", e.target.value)}
|
||||
className={inputCls}
|
||||
placeholder="e.g. 5000"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Interest rate */}
|
||||
{showInterestFields && (
|
||||
<div>
|
||||
<label className="text-sm font-medium block mb-1.5">Interest Rate (% p.a.)</label>
|
||||
<input
|
||||
type="number" step="0.01"
|
||||
value={form.interest_rate}
|
||||
onChange={e => set("interest_rate", e.target.value)}
|
||||
className={inputCls}
|
||||
placeholder="e.g. 3.99"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Color picker */}
|
||||
<div>
|
||||
<label className="text-sm font-medium block mb-1.5">Colour</label>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{COLORS.map(c => (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
onClick={() => set("color", c)}
|
||||
className="w-7 h-7 rounded-full border-2 transition-all"
|
||||
style={{ backgroundColor: c, borderColor: form.color === c ? "white" : "transparent" }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Include in net worth */}
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.include_in_net_worth}
|
||||
onChange={e => set("include_in_net_worth", e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
<span className="text-sm">Include in net worth</span>
|
||||
</label>
|
||||
|
||||
{/* Notes */}
|
||||
<div>
|
||||
<label className="text-sm font-medium block mb-1.5">Notes</label>
|
||||
<textarea
|
||||
value={form.notes}
|
||||
onChange={e => set("notes", e.target.value)}
|
||||
rows={2}
|
||||
className={`${inputCls} resize-none`}
|
||||
placeholder="Optional notes about this account"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-destructive text-sm bg-destructive/10 rounded-md px-3 py-2">{error}</p>}
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button type="button" onClick={onClose} className="flex-1 border border-border rounded-lg py-2 text-sm hover:bg-secondary transition-colors">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" disabled={isLoading} className="flex-1 flex items-center justify-center gap-2 bg-primary text-primary-foreground rounded-lg py-2 text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-colors">
|
||||
{isLoading && <Loader2 className="w-4 h-4 animate-spin" />}
|
||||
{isEdit ? "Save Changes" : "Create Account"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
295
frontend/src/pages/accounts/AccountList.tsx
Normal file
295
frontend/src/pages/accounts/AccountList.tsx
Normal file
|
|
@ -0,0 +1,295 @@
|
|||
import { useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { getAccounts, createAccount, updateAccount, deleteAccount, getNetWorth, type Account } from "@/api/accounts";
|
||||
import { formatCurrency } from "@/utils/currency";
|
||||
import { cn } from "@/utils/cn";
|
||||
import {
|
||||
Plus, Trash2, Pencil, TrendingUp, Wallet,
|
||||
CreditCard, PiggyBank, Building2, Coins, Bitcoin, Landmark, ShieldCheck, Sprout
|
||||
} from "lucide-react";
|
||||
import AccountFormModal from "./AccountFormModal";
|
||||
|
||||
const TYPE_ICONS: Record<string, React.ElementType> = {
|
||||
checking: Wallet,
|
||||
savings: PiggyBank,
|
||||
cash_isa: Sprout,
|
||||
stocks_shares_isa: TrendingUp,
|
||||
credit_card: CreditCard,
|
||||
investment: TrendingUp,
|
||||
cash: Coins,
|
||||
crypto_wallet: Bitcoin,
|
||||
loan: Building2,
|
||||
mortgage: Landmark,
|
||||
pension: ShieldCheck,
|
||||
other: Wallet,
|
||||
};
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
checking: "Checking",
|
||||
savings: "Savings",
|
||||
cash_isa: "Cash ISA",
|
||||
stocks_shares_isa: "S&S ISA",
|
||||
credit_card: "Credit Card",
|
||||
investment: "Investment",
|
||||
cash: "Cash",
|
||||
crypto_wallet: "Crypto",
|
||||
loan: "Loan",
|
||||
mortgage: "Mortgage",
|
||||
pension: "Pension",
|
||||
other: "Other",
|
||||
};
|
||||
|
||||
const LIABILITY_TYPES = new Set(["credit_card", "loan", "mortgage"]);
|
||||
|
||||
export default function AccountList() {
|
||||
const qc = useQueryClient();
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [editing, setEditing] = useState<Account | null>(null);
|
||||
|
||||
const { data: accounts = [], isLoading } = useQuery({
|
||||
queryKey: ["accounts"],
|
||||
queryFn: getAccounts,
|
||||
});
|
||||
|
||||
const { data: nw } = useQuery({
|
||||
queryKey: ["net-worth"],
|
||||
queryFn: getNetWorth,
|
||||
});
|
||||
|
||||
const invalidate = () => {
|
||||
qc.invalidateQueries({ queryKey: ["accounts"] });
|
||||
qc.invalidateQueries({ queryKey: ["net-worth"] });
|
||||
};
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: deleteAccount,
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: createAccount,
|
||||
onSuccess: () => { invalidate(); setShowCreate(false); },
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: Parameters<typeof updateAccount>[1] }) =>
|
||||
updateAccount(id, data),
|
||||
onSuccess: () => { invalidate(); setEditing(null); },
|
||||
});
|
||||
|
||||
const assets = accounts.filter(a => !LIABILITY_TYPES.has(a.type) && a.is_active);
|
||||
const liabilities = accounts.filter(a => LIABILITY_TYPES.has(a.type) && a.is_active);
|
||||
const inactive = accounts.filter(a => !a.is_active);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Accounts</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">Manage your financial accounts</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowCreate(true)}
|
||||
className="flex items-center gap-2 bg-primary text-primary-foreground px-4 py-2 rounded-lg text-sm font-medium hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Add Account
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{nw && (
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{[
|
||||
{ label: "Total Assets", value: nw.total_assets, positive: true },
|
||||
{ label: "Total Liabilities", value: nw.total_liabilities, positive: nw.total_liabilities === 0 },
|
||||
{ label: "Net Worth", value: nw.net_worth, positive: nw.net_worth >= 0 },
|
||||
].map(({ label, value, positive }) => (
|
||||
<div key={label} className="bg-card border border-border rounded-xl p-4">
|
||||
<p className="text-xs text-muted-foreground mb-1">{label}</p>
|
||||
<p className={cn("text-xl font-bold", positive ? "text-success" : "text-destructive")}>
|
||||
{formatCurrency(value, nw.base_currency)}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading && (
|
||||
<div className="space-y-2">
|
||||
{[1, 2, 3].map(i => (
|
||||
<div key={i} className="h-20 bg-card border border-border rounded-xl animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{assets.length > 0 && (
|
||||
<AccountGroup
|
||||
title="Assets"
|
||||
accounts={assets}
|
||||
onEdit={setEditing}
|
||||
onDelete={id => deleteMutation.mutate(id)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{liabilities.length > 0 && (
|
||||
<AccountGroup
|
||||
title="Liabilities"
|
||||
accounts={liabilities}
|
||||
onEdit={setEditing}
|
||||
onDelete={id => deleteMutation.mutate(id)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{inactive.length > 0 && (
|
||||
<AccountGroup
|
||||
title="Inactive"
|
||||
accounts={inactive}
|
||||
onEdit={setEditing}
|
||||
onDelete={id => deleteMutation.mutate(id)}
|
||||
muted
|
||||
/>
|
||||
)}
|
||||
|
||||
{accounts.length === 0 && !isLoading && (
|
||||
<div className="text-center py-16 text-muted-foreground">
|
||||
<Wallet className="w-12 h-12 mx-auto mb-3 opacity-30" />
|
||||
<p className="font-medium">No accounts yet</p>
|
||||
<p className="text-sm mt-1">Add your first account to get started</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showCreate && (
|
||||
<AccountFormModal
|
||||
onClose={() => setShowCreate(false)}
|
||||
onSubmit={data => createMutation.mutate(data)}
|
||||
isLoading={createMutation.isPending}
|
||||
/>
|
||||
)}
|
||||
|
||||
{editing && (
|
||||
<AccountFormModal
|
||||
account={editing}
|
||||
onClose={() => setEditing(null)}
|
||||
onSubmit={data => updateMutation.mutate({ id: editing.id, data })}
|
||||
isLoading={updateMutation.isPending}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AccountGroup({
|
||||
title,
|
||||
accounts,
|
||||
onEdit,
|
||||
onDelete,
|
||||
muted = false,
|
||||
}: {
|
||||
title: string;
|
||||
accounts: Account[];
|
||||
onEdit: (a: Account) => void;
|
||||
onDelete: (id: string) => void;
|
||||
muted?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<h2 className={cn("text-sm font-semibold uppercase tracking-wider mb-3", muted ? "text-muted-foreground" : "text-foreground")}>
|
||||
{title}
|
||||
</h2>
|
||||
<div className="space-y-2">
|
||||
{accounts.map(account => {
|
||||
const Icon = TYPE_ICONS[account.type] || Wallet;
|
||||
const isLiability = LIABILITY_TYPES.has(account.type);
|
||||
const utilPct = account.credit_limit && account.credit_limit > 0
|
||||
? Math.min(100, (Math.abs(account.current_balance) / account.credit_limit) * 100)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={account.id}
|
||||
className="bg-card border border-border rounded-xl p-4 hover:border-primary/30 transition-colors group"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-2.5 rounded-lg shrink-0" style={{ backgroundColor: account.color + "20" }}>
|
||||
<Icon className="w-5 h-5" style={{ color: account.color }} />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Link to={`/accounts/${account.id}`} className="font-medium truncate hover:text-primary transition-colors">
|
||||
{account.name}
|
||||
</Link>
|
||||
<span className="text-xs text-muted-foreground bg-secondary px-1.5 py-0.5 rounded shrink-0">
|
||||
{TYPE_LABELS[account.type] || account.type}
|
||||
</span>
|
||||
{!account.include_in_net_worth && (
|
||||
<span className="text-xs text-muted-foreground italic">excluded from net worth</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mt-0.5 flex-wrap">
|
||||
{account.institution && (
|
||||
<p className="text-xs text-muted-foreground">{account.institution}</p>
|
||||
)}
|
||||
{account.interest_rate != null && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<span className="font-medium text-foreground">{Number(account.interest_rate).toFixed(2)}%</span> p.a.
|
||||
</p>
|
||||
)}
|
||||
{account.notes && (
|
||||
<p className="text-xs text-muted-foreground truncate max-w-xs">{account.notes}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-right shrink-0">
|
||||
<p className={cn("font-semibold tabular-nums", isLiability ? "text-destructive" : "text-foreground")}>
|
||||
{formatCurrency(account.current_balance, account.currency)}
|
||||
</p>
|
||||
{account.credit_limit != null && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
limit {formatCurrency(account.credit_limit, account.currency)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
|
||||
<button
|
||||
onClick={() => onEdit(account)}
|
||||
className="p-1.5 rounded text-muted-foreground hover:text-foreground hover:bg-secondary"
|
||||
title="Edit account"
|
||||
>
|
||||
<Pencil className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onDelete(account.id)}
|
||||
className="p-1.5 rounded text-muted-foreground hover:text-destructive hover:bg-destructive/10"
|
||||
title="Delete account"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Credit utilisation bar */}
|
||||
{utilPct !== null && (
|
||||
<div className="mt-3">
|
||||
<div className="flex justify-between text-xs text-muted-foreground mb-1">
|
||||
<span>Credit used</span>
|
||||
<span>{utilPct.toFixed(0)}%</span>
|
||||
</div>
|
||||
<div className="h-1.5 bg-secondary rounded-full overflow-hidden">
|
||||
<div
|
||||
className={cn("h-full rounded-full transition-all", utilPct > 80 ? "bg-destructive" : utilPct > 50 ? "bg-yellow-500" : "bg-success")}
|
||||
style={{ width: `${utilPct}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
188
frontend/src/pages/auth/Login.tsx
Normal file
188
frontend/src/pages/auth/Login.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
159
frontend/src/pages/auth/TwoFactorSetup.tsx
Normal file
159
frontend/src/pages/auth/TwoFactorSetup.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
164
frontend/src/pages/budgets/BudgetFormModal.tsx
Normal file
164
frontend/src/pages/budgets/BudgetFormModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
197
frontend/src/pages/budgets/BudgetPage.tsx
Normal file
197
frontend/src/pages/budgets/BudgetPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
244
frontend/src/pages/dashboard/Dashboard.tsx
Normal file
244
frontend/src/pages/dashboard/Dashboard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
208
frontend/src/pages/investments/AddHoldingModal.tsx
Normal file
208
frontend/src/pages/investments/AddHoldingModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
122
frontend/src/pages/investments/AssetDetail.tsx
Normal file
122
frontend/src/pages/investments/AssetDetail.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
208
frontend/src/pages/investments/PortfolioPage.tsx
Normal file
208
frontend/src/pages/investments/PortfolioPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
499
frontend/src/pages/predictions/PredictionsPage.tsx
Normal file
499
frontend/src/pages/predictions/PredictionsPage.tsx
Normal 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 P10–P90 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>
|
||||
);
|
||||
}
|
||||
282
frontend/src/pages/reports/ReportsPage.tsx
Normal file
282
frontend/src/pages/reports/ReportsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
542
frontend/src/pages/settings/SettingsPage.tsx
Normal file
542
frontend/src/pages/settings/SettingsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
258
frontend/src/pages/transactions/TransactionDetailDrawer.tsx
Normal file
258
frontend/src/pages/transactions/TransactionDetailDrawer.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
168
frontend/src/pages/transactions/TransactionFormModal.tsx
Normal file
168
frontend/src/pages/transactions/TransactionFormModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
171
frontend/src/pages/transactions/TransactionImport.tsx
Normal file
171
frontend/src/pages/transactions/TransactionImport.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
266
frontend/src/pages/transactions/TransactionList.tsx
Normal file
266
frontend/src/pages/transactions/TransactionList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue