Complete Phase 3, Phase 5 polish and hardening

Phase 3 — Investments:
- Multi-currency support: holdings track purchase currency, FX rates convert to base for totals
- Capital gains report using UK Section 104 pool method, grouped by tax year
- Capital Gains tab added to Reports page

Phase 5 — Polish & Hardening:
- Mobile-responsive layout: bottom nav, sidebar hidden on mobile, logo in TopBar, compact header buttons, hover-only actions now always visible on touch
- Backup system: encrypted GPG backups via backup.sh, nightly scheduler job, admin API (list/trigger/download/restore), Settings UI with drag-to-restore confirmation
- Docker entrypoint with gosu privilege drop to fix bind-mount ownership on fresh deployments
- OWASP fixes: refresh token now bound to its session (new refresh_token_hash column + migration), CSRF secure flag tied to environment, IP-level rate limiting on login, TOTPEnableRequest Pydantic schema replaces raw dict
- AES-256-GCM key rotation script (rotate_keys.py) with dry-run mode and atomic DB transaction
- CLAUDE.md added for AI-assisted development context
- README updated: correct reverse proxy port, accurate backup/restore commands, key rotation instructions

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
megaproxy 2026-04-22 14:59:11 +00:00
parent 74e57a35c0
commit fe4e69b9ad
40 changed files with 2079 additions and 127 deletions

32
frontend/src/api/admin.ts Normal file
View file

@ -0,0 +1,32 @@
import { api } from "./client";
export interface BackupFile {
filename: string;
size_bytes: number;
created_at: string;
}
export async function listBackups(): Promise<BackupFile[]> {
const r = await api.get("/admin/backups");
return r.data;
}
export async function triggerBackup(): Promise<{ ok: boolean; message: string }> {
const r = await api.post("/admin/backup");
return r.data;
}
export async function downloadBackup(filename: string): Promise<void> {
const r = await api.get(`/admin/backups/${filename}`, { responseType: "blob" });
const url = URL.createObjectURL(r.data);
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
export async function restoreBackup(filename: string): Promise<{ ok: boolean; message: string }> {
const r = await api.post(`/admin/restore/${filename}`);
return r.data;
}

View file

@ -61,11 +61,23 @@ export interface PricePoint {
volume: number | null;
}
export interface PerformanceMetrics {
twrr: number | null;
total_return: number;
total_return_pct: number;
currency: string;
}
export async function getPortfolio(): Promise<PortfolioSummary> {
const r = await api.get("/investments/portfolio");
return r.data;
}
export async function getPerformance(): Promise<PerformanceMetrics> {
const r = await api.get("/investments/performance");
return r.data;
}
export async function searchAssets(q: string): Promise<AssetSearchResult[]> {
const r = await api.get("/assets/search", { params: { q } });
return r.data;
@ -87,6 +99,11 @@ export async function createHolding(data: {
return r.data;
}
export async function updateHolding(id: string, data: { quantity: number; avg_cost_basis: number }): Promise<HoldingResponse> {
const r = await api.patch(`/investments/holdings/${id}`, data);
return r.data;
}
export async function deleteHolding(id: string): Promise<void> {
await api.delete(`/investments/holdings/${id}`);
}
@ -109,3 +126,33 @@ export async function getHoldingTransactions(holdingId: string): Promise<Investm
const r = await api.get(`/investments/holdings/${holdingId}/transactions`);
return r.data;
}
export interface CapitalGainsDisposal {
date: string;
symbol: string;
asset_name: string;
quantity: number;
proceeds: number;
cost: number;
gain: number;
currency: string;
}
export interface TaxYearSummary {
tax_year: string;
disposals: CapitalGainsDisposal[];
total_proceeds: number;
total_cost: number;
total_gain: number;
currency: string;
}
export interface CapitalGainsReport {
tax_years: TaxYearSummary[];
currency: string;
}
export async function getCapitalGains(): Promise<CapitalGainsReport> {
const r = await api.get("/investments/capital-gains");
return r.data;
}

View file

@ -122,6 +122,25 @@ export async function getSpendingTrends(months = 6): Promise<SpendingTrendsRepor
return r.data;
}
export interface SavingsRatePoint {
month: string;
income: number;
expenses: number;
savings: number;
savings_rate: number;
}
export interface SavingsRateReport {
points: SavingsRatePoint[];
avg_savings_rate: number;
currency: string;
}
export async function getSavingsRate(months = 12): Promise<SavingsRateReport> {
const r = await api.get("/reports/savings-rate", { params: { months } });
return r.data;
}
export interface BalanceSheetAccount {
id: string;
name: string;

View file

@ -61,6 +61,7 @@ export interface TransactionFilters {
date_from?: string;
date_to?: string;
search?: string;
is_recurring?: boolean;
page?: number;
page_size?: number;
}

View file

@ -1,7 +1,7 @@
import { useNavigate } from "react-router-dom";
import { useAuthStore } from "@/store/authStore";
import { logout } from "@/api/auth";
import { LogOut, User } from "lucide-react";
import { LogOut, User, Coins } from "lucide-react";
import ThemePicker from "./ThemePicker";
export default function TopBar() {
@ -18,10 +18,16 @@ export default function TopBar() {
}
return (
<header className="h-16 border-b border-border bg-card flex items-center justify-end px-4 md:px-6 gap-3 shrink-0">
<header className="h-16 border-b border-border bg-card flex items-center px-4 md:px-6 gap-3 shrink-0">
{/* Logo visible on mobile only (sidebar is hidden) */}
<div className="flex items-center gap-2 lg:hidden mr-auto">
<Coins className="w-6 h-6 text-primary shrink-0" />
<span className="font-semibold text-base">MyMidas</span>
</div>
<div className="ml-auto flex items-center gap-3">
<ThemePicker />
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-secondary text-sm border border-border">
<div className="hidden sm:flex items-center gap-2 px-3 py-1.5 rounded-lg bg-secondary text-sm border border-border">
<User className="w-4 h-4 text-muted-foreground" />
<span className="text-foreground font-medium">{displayName ?? "User"}</span>
</div>
@ -33,6 +39,7 @@ export default function TopBar() {
>
<LogOut className="w-4 h-4" />
</button>
</div>
</header>
);
}

View file

@ -99,7 +99,7 @@ export default function AccountList() {
</div>
{nw && (
<div className="grid grid-cols-3 gap-4">
<div className="grid grid-cols-1 sm: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 },
@ -253,7 +253,7 @@ function AccountGroup({
)}
</div>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
<div className="flex items-center gap-1 opacity-100 sm:opacity-0 sm: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"

View file

@ -1,8 +1,11 @@
import { useState, useEffect } from "react";
import { X, Search, Loader2 } from "lucide-react";
import { searchAssets, createHolding, addInvestmentTransaction, AssetSearchResult } from "@/api/investments";
import { useUiStore } from "@/store/uiStore";
import { format } from "date-fns";
const COMMON_CURRENCIES = ["GBP", "USD", "EUR", "JPY", "CAD", "AUD", "CHF"];
interface Account { id: string; name: string; type: string; }
interface Props {
@ -12,6 +15,7 @@ interface Props {
}
export default function AddHoldingModal({ accounts, onClose, onSuccess }: Props) {
const baseCurrency = useUiStore(s => s.currency);
const [query, setQuery] = useState("");
const [results, setResults] = useState<AssetSearchResult[]>([]);
const [searching, setSearching] = useState(false);
@ -27,6 +31,7 @@ export default function AddHoldingModal({ accounts, onClose, onSuccess }: Props)
account_id: investAccounts[0]?.id ?? "",
quantity: "",
price: "",
currency: baseCurrency,
fees: "0",
date: format(new Date(), "yyyy-MM-dd"),
});
@ -58,7 +63,7 @@ export default function AddHoldingModal({ accounts, onClose, onSuccess }: Props)
asset_id: selected.id,
quantity: qty,
avg_cost_basis: price,
currency: selected.currency,
currency: form.currency,
});
await addInvestmentTransaction({
holding_id: holding.id,
@ -66,7 +71,7 @@ export default function AddHoldingModal({ accounts, onClose, onSuccess }: Props)
quantity: qty,
price: price,
fees: parseFloat(form.fees) || 0,
currency: selected.currency,
currency: form.currency,
date: form.date,
});
onSuccess();
@ -156,13 +161,22 @@ export default function AddHoldingModal({ accounts, onClose, onSuccess }: Props)
</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 className="flex gap-1">
<select
value={form.currency}
onChange={(e) => setForm(f => ({ ...f, currency: e.target.value }))}
className="rounded-md border border-input bg-background px-1.5 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-ring"
>
{COMMON_CURRENCIES.map(c => <option key={c} value={c}>{c}</option>)}
</select>
<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>
<div>
<label className="text-xs font-medium block mb-1">Fees</label>
@ -176,6 +190,12 @@ export default function AddHoldingModal({ accounts, onClose, onSuccess }: Props)
</div>
</div>
{selected && form.currency !== selected.currency && (
<p className="text-xs text-muted-foreground bg-secondary/50 rounded-md px-3 py-2">
Price in <strong>{form.currency}</strong> asset trades in <strong>{selected.currency}</strong>. Current values will be converted using live exchange rates.
</p>
)}
<div>
<label className="text-sm font-medium block mb-1.5">Purchase date *</label>
<input

View file

@ -2,12 +2,14 @@ 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 { useUiStore } from "@/store/uiStore";
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 baseCurrency = useUiStore(s => s.currency);
const { data: portfolio } = useQuery({ queryKey: ["portfolio"], queryFn: getPortfolio });
const holding = portfolio?.holdings.find(h => h.asset_id === assetId);
@ -46,10 +48,10 @@ export default function AssetDetail() {
{/* 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>
<p className="text-4xl font-bold tabular-nums">{formatCurrency(latestPrice, holding?.currency ?? baseCurrency)}</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)}%)
{isUp ? "+" : ""}{formatCurrency(change, holding?.currency ?? baseCurrency)} ({changePct.toFixed(2)}%)
</div>
</div>
)}

View file

@ -1,10 +1,11 @@
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { getPortfolio, deleteHolding } from "@/api/investments";
import { getPortfolio, deleteHolding, updateHolding } from "@/api/investments";
import type { HoldingResponse } 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 { Plus, Trash2, TrendingUp, TrendingDown, ChevronRight, Pencil, AlertTriangle, X, Loader2 } from "lucide-react";
import AddHoldingModal from "./AddHoldingModal";
import { Link } from "react-router-dom";
@ -13,9 +14,127 @@ const COLORS = [
"#f59e0b","#8b5cf6","#06b6d4","#84cc16","#ef4444",
];
function ConfirmDeleteDialog({ holding, onConfirm, onCancel, isPending }: {
holding: HoldingResponse;
onConfirm: () => void;
onCancel: () => void;
isPending: boolean;
}) {
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-sm shadow-xl p-6">
<div className="flex items-start gap-4 mb-5">
<div className="w-10 h-10 rounded-full bg-destructive/15 flex items-center justify-center shrink-0">
<AlertTriangle className="w-5 h-5 text-destructive" />
</div>
<div>
<h3 className="font-semibold text-base">Delete holding?</h3>
<p className="text-sm text-muted-foreground mt-1">
This will permanently remove <span className="font-medium text-foreground">{holding.symbol}</span> ({holding.asset_name}) and all its transaction history. This cannot be undone.
</p>
</div>
</div>
<div className="flex gap-3">
<button
onClick={onCancel}
disabled={isPending}
className="flex-1 py-2.5 rounded-lg border border-border text-sm hover:bg-secondary transition-colors disabled:opacity-50"
>
Cancel
</button>
<button
onClick={onConfirm}
disabled={isPending}
className="flex-1 flex items-center justify-center gap-2 py-2.5 rounded-lg bg-destructive text-destructive-foreground text-sm font-medium hover:bg-destructive/90 transition-colors disabled:opacity-50"
>
{isPending && <Loader2 className="w-4 h-4 animate-spin" />}
{isPending ? "Deleting…" : "Delete holding"}
</button>
</div>
</div>
</div>
);
}
function EditHoldingModal({ holding, onClose, onSuccess }: {
holding: HoldingResponse;
onClose: () => void;
onSuccess: () => void;
}) {
const [quantity, setQuantity] = useState(String(holding.quantity));
const [avgCost, setAvgCost] = useState(String(holding.avg_cost_basis));
const [error, setError] = useState<string | null>(null);
const qc = useQueryClient();
const mutation = useMutation({
mutationFn: () => updateHolding(holding.id, {
quantity: parseFloat(quantity),
avg_cost_basis: parseFloat(avgCost),
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["portfolio"] });
onSuccess();
},
onError: (e: any) => setError(e?.response?.data?.detail ?? "Failed to update holding"),
});
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-sm shadow-xl">
<div className="flex items-center justify-between p-5 border-b border-border">
<div>
<h2 className="font-semibold text-lg">Edit {holding.symbol}</h2>
<p className="text-xs text-muted-foreground">{holding.asset_name}</p>
</div>
<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">
<div>
<label className="text-sm font-medium block mb-1.5">Quantity</label>
<input
type="number" min="0" step="any"
value={quantity}
onChange={(e) => setQuantity(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="text-sm font-medium block mb-1.5">Avg cost basis ({holding.currency})</label>
<input
type="number" min="0" step="any"
value={avgCost}
onChange={(e) => setAvgCost(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 onClick={onClose} className="flex-1 py-2.5 rounded-lg border border-border text-sm hover:bg-secondary transition-colors">
Cancel
</button>
<button
onClick={() => mutation.mutate()}
disabled={mutation.isPending || !quantity || !avgCost}
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"
>
{mutation.isPending && <Loader2 className="w-4 h-4 animate-spin" />}
{mutation.isPending ? "Saving…" : "Save changes"}
</button>
</div>
</div>
</div>
</div>
);
}
export default function PortfolioPage() {
const qc = useQueryClient();
const [showAdd, setShowAdd] = useState(false);
const [editHolding, setEditHolding] = useState<HoldingResponse | null>(null);
const [deleteTarget, setDeleteTarget] = useState<HoldingResponse | null>(null);
const [deleteError, setDeleteError] = useState<string | null>(null);
const { data: portfolio, isLoading } = useQuery({
queryKey: ["portfolio"],
@ -27,7 +146,14 @@ export default function PortfolioPage() {
const deleteMutation = useMutation({
mutationFn: deleteHolding,
onSuccess: () => qc.invalidateQueries({ queryKey: ["portfolio"] }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["portfolio"] });
setDeleteTarget(null);
setDeleteError(null);
},
onError: (e: any) => {
setDeleteError(e?.response?.data?.detail ?? "Failed to delete holding");
},
});
const treemapData = portfolio?.holdings
@ -75,7 +201,7 @@ export default function PortfolioPage() {
</div>
)}
{/* Treemap */}
{/* Allocation bar */}
{treemapData.length > 1 && (
<div className="bg-card border border-border rounded-xl p-4">
<p className="text-sm font-medium mb-3">Allocation</p>
@ -127,7 +253,7 @@ export default function PortfolioPage() {
<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>
<th className="w-24"></th>
</tr>
</thead>
<tbody>
@ -173,16 +299,25 @@ export default function PortfolioPage() {
) : "—"}
</td>
<td className="px-4 py-3">
<div className="flex items-center justify-end gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<div className="flex items-center justify-end gap-1 opacity-100 sm:opacity-0 sm: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"
title="View chart"
>
<ChevronRight className="w-4 h-4" />
</Link>
<button
onClick={() => deleteMutation.mutate(h.id)}
onClick={() => setEditHolding(h)}
className="p-1.5 rounded text-muted-foreground hover:text-foreground hover:bg-secondary"
title="Edit"
>
<Pencil className="w-4 h-4" />
</button>
<button
onClick={() => { setDeleteTarget(h); setDeleteError(null); }}
className="p-1.5 rounded text-muted-foreground hover:text-destructive hover:bg-destructive/10"
title="Delete"
>
<Trash2 className="w-4 h-4" />
</button>
@ -196,6 +331,13 @@ export default function PortfolioPage() {
</div>
)}
{deleteError && (
<div className="bg-destructive/10 border border-destructive/30 rounded-lg px-4 py-3 text-sm text-destructive flex items-center gap-2">
<AlertTriangle className="w-4 h-4 shrink-0" />
{deleteError}
</div>
)}
{showAdd && (
<AddHoldingModal
accounts={accounts}
@ -203,6 +345,23 @@ export default function PortfolioPage() {
onSuccess={() => { qc.invalidateQueries({ queryKey: ["portfolio"] }); setShowAdd(false); }}
/>
)}
{editHolding && (
<EditHoldingModal
holding={editHolding}
onClose={() => setEditHolding(null)}
onSuccess={() => setEditHolding(null)}
/>
)}
{deleteTarget && (
<ConfirmDeleteDialog
holding={deleteTarget}
onConfirm={() => deleteMutation.mutate(deleteTarget.id)}
onCancel={() => { setDeleteTarget(null); setDeleteError(null); }}
isPending={deleteMutation.isPending}
/>
)}
</div>
);
}

View file

@ -3,22 +3,30 @@ import { useQuery } from "@tanstack/react-query";
import {
getNetWorthReport,
getIncomeExpenseReport,
getCashFlowReport,
getCategoryBreakdown,
getBudgetVsActual,
getSpendingTrends,
getSavingsRate,
getBalanceSheet,
} from "@/api/reports";
import { getPortfolio, getPerformance, getCapitalGains } from "@/api/investments";
import type { TaxYearSummary } from "@/api/investments";
import type { BalanceSheetGroup } from "@/api/reports";
import { formatCurrency } from "@/utils/currency";
import { cn } from "@/utils/cn";
import {
AreaChart, Area, BarChart, Bar,
AreaChart, Area, BarChart, Bar, ComposedChart, Line,
PieChart, Pie, Cell, XAxis, YAxis, CartesianGrid,
Tooltip, ResponsiveContainer, Legend
} from "recharts";
import { TrendingUp, TrendingDown, Minus, Landmark, CreditCard } from "lucide-react";
import { TrendingUp, TrendingDown, Minus, Landmark, CreditCard, PiggyBank } from "lucide-react";
const TABS = ["Balance Sheet", "Net Worth", "Income vs Expense", "Categories", "Budget vs Actual", "Spending Trends"] as const;
const TABS = [
"Balance Sheet", "Net Worth", "Income vs Expense",
"Cash Flow", "Savings Rate", "Categories",
"Budget vs Actual", "Spending Trends", "Investments", "Capital Gains",
] as const;
type Tab = typeof TABS[number];
const COLORS = [
@ -70,7 +78,6 @@ function BalanceSheetTab() {
const noAccounts = data.asset_groups.length === 0 && data.liability_groups.length === 0;
if (noAccounts) return <EmptyChart message="No accounts found" />;
// Build stacked bar data: one bar for assets, one for liabilities
const assetBarData = data.asset_groups.map((g, i) => ({
name: g.label,
value: Number(g.subtotal),
@ -81,13 +88,10 @@ function BalanceSheetTab() {
value: Number(g.subtotal),
color: LIABILITY_COLORS[i % LIABILITY_COLORS.length],
}));
// Single stacked bar chart showing asset composition vs liability composition
const maxVal = Math.max(Number(data.total_assets), Number(data.total_liabilities), 1);
return (
<div className="space-y-6">
{/* Summary KPIs */}
<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">Total Assets</p>
@ -105,11 +109,8 @@ function BalanceSheetTab() {
</div>
</div>
{/* Visual proportion bars */}
<div className="bg-card border border-border rounded-xl p-5 space-y-4">
<p className="text-sm font-medium">Asset & Liability Composition</p>
{/* Assets bar */}
{assetBarData.length > 0 && (
<div className="space-y-1.5">
<div className="flex justify-between text-xs text-muted-foreground">
@ -120,16 +121,9 @@ function BalanceSheetTab() {
</div>
<div className="flex h-7 rounded-lg overflow-hidden gap-px bg-secondary">
{assetBarData.map((seg) => (
<div
key={seg.name}
className="transition-all duration-500"
style={{
width: `${(seg.value / maxVal) * 100}%`,
background: seg.color,
minWidth: seg.value > 0 ? "2px" : "0",
}}
title={`${seg.name}: ${formatCurrency(seg.value, data.currency)}`}
/>
<div key={seg.name} className="transition-all duration-500"
style={{ width: `${(seg.value / maxVal) * 100}%`, background: seg.color, minWidth: seg.value > 0 ? "2px" : "0" }}
title={`${seg.name}: ${formatCurrency(seg.value, data.currency)}`} />
))}
</div>
<div className="flex flex-wrap gap-x-4 gap-y-1">
@ -142,8 +136,6 @@ function BalanceSheetTab() {
</div>
</div>
)}
{/* Liabilities bar */}
{liabilityBarData.length > 0 && (
<div className="space-y-1.5">
<div className="flex justify-between text-xs text-muted-foreground">
@ -154,16 +146,9 @@ function BalanceSheetTab() {
</div>
<div className="flex h-7 rounded-lg overflow-hidden gap-px bg-secondary">
{liabilityBarData.map((seg) => (
<div
key={seg.name}
className="transition-all duration-500"
style={{
width: `${(seg.value / maxVal) * 100}%`,
background: seg.color,
minWidth: seg.value > 0 ? "2px" : "0",
}}
title={`${seg.name}: ${formatCurrency(seg.value, data.currency)}`}
/>
<div key={seg.name} className="transition-all duration-500"
style={{ width: `${(seg.value / maxVal) * 100}%`, background: seg.color, minWidth: seg.value > 0 ? "2px" : "0" }}
title={`${seg.name}: ${formatCurrency(seg.value, data.currency)}`} />
))}
</div>
<div className="flex flex-wrap gap-x-4 gap-y-1">
@ -178,9 +163,7 @@ function BalanceSheetTab() {
)}
</div>
{/* Side-by-side account breakdown */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* Assets */}
<div className="bg-card border border-border rounded-xl p-5">
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-semibold flex items-center gap-2">
@ -205,7 +188,6 @@ function BalanceSheetTab() {
</div>
</div>
{/* Liabilities */}
<div className="bg-card border border-border rounded-xl p-5">
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-semibold flex items-center gap-2">
@ -320,40 +302,259 @@ function IncomeExpenseTab() {
);
}
function CashFlowTab() {
const { data, isLoading } = useQuery({
queryKey: ["report-cash-flow"],
queryFn: () => getCashFlowReport(),
});
if (isLoading) return <ChartSkeleton />;
if (!data) return null;
const chartData = data.points.map(p => ({
date: p.date,
inflow: Number(p.inflow),
outflow: Number(p.outflow),
balance: Number(p.running_balance),
}));
return (
<div className="space-y-4">
<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">Total Inflow</p>
<p className="text-xl font-bold tabular-nums text-success">{formatCurrency(Number(data.total_inflow), data.currency)}</p>
</div>
<div className="bg-card border border-border rounded-xl p-4">
<p className="text-xs text-muted-foreground mb-1">Total Outflow</p>
<p className="text-xl font-bold tabular-nums text-destructive">{formatCurrency(Number(data.total_outflow), data.currency)}</p>
</div>
<div className="bg-card border border-border rounded-xl p-4">
<p className="text-xs text-muted-foreground mb-1">Net</p>
<p className={cn("text-xl font-bold tabular-nums", Number(data.total_inflow) - Number(data.total_outflow) >= 0 ? "text-success" : "text-destructive")}>
{formatCurrency(Number(data.total_inflow) - Number(data.total_outflow), data.currency)}
</p>
</div>
</div>
{chartData.length === 0 ? <EmptyChart /> : (
<div className="bg-card border border-border rounded-xl p-4">
<p className="text-sm font-medium mb-4">Daily Cash Flow Last 30 Days</p>
<ResponsiveContainer width="100%" height={320}>
<ComposedChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
<XAxis dataKey="date" tick={{ fontSize: 10 }} stroke="var(--muted-foreground)" />
<YAxis yAxisId="bars" tick={{ fontSize: 11 }} stroke="var(--muted-foreground)" tickFormatter={(v) => `£${(v/1000).toFixed(1)}k`} />
<YAxis yAxisId="line" orientation="right" tick={{ fontSize: 11 }} stroke="var(--muted-foreground)" tickFormatter={(v) => `£${(v/1000).toFixed(1)}k`} />
<Tooltip formatter={(v: number) => formatCurrency(v, data.currency)} />
<Legend />
<Bar yAxisId="bars" dataKey="inflow" fill="#22c55e" name="Inflow" radius={[2, 2, 0, 0]} />
<Bar yAxisId="bars" dataKey="outflow" fill="#ef4444" name="Outflow" radius={[2, 2, 0, 0]} />
<Line yAxisId="line" type="monotone" dataKey="balance" stroke="#6366f1" strokeWidth={2} dot={false} name="Running Balance" />
</ComposedChart>
</ResponsiveContainer>
</div>
)}
</div>
);
}
function SavingsRateTab() {
const { data, isLoading } = useQuery({ queryKey: ["report-savings-rate"], queryFn: () => getSavingsRate(12) });
if (isLoading) return <ChartSkeleton />;
if (!data || data.points.length === 0) return <EmptyChart message="No income/expense data yet" />;
const chartData = data.points.map(p => ({
month: p.month,
income: Number(p.income),
expenses: Number(p.expenses),
savings: Number(p.savings),
rate: Number(p.savings_rate),
}));
const avg = Number(data.avg_savings_rate);
return (
<div className="space-y-4">
<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">Avg Savings Rate</p>
<p className={cn("text-xl font-bold tabular-nums", avg >= 20 ? "text-success" : avg >= 0 ? "text-warning" : "text-destructive")}>
{avg >= 0 ? "" : ""}{avg.toFixed(1)}%
</p>
<p className="text-xs text-muted-foreground mt-1">over {data.points.length} months</p>
</div>
<div className="bg-card border border-border rounded-xl p-4 flex items-center gap-3">
<PiggyBank className="w-8 h-8 text-primary opacity-60" />
<div>
<p className="text-xs text-muted-foreground">Best Month</p>
<p className="text-sm font-semibold">
{chartData.reduce((best, p) => p.rate > best.rate ? p : best, chartData[0])?.month ?? "—"}
</p>
</div>
</div>
<div className="bg-card border border-border rounded-xl p-4 flex items-center gap-3">
<div>
<p className="text-xs text-muted-foreground">Latest Month Savings</p>
<p className="text-sm font-semibold tabular-nums">
{formatCurrency(chartData[chartData.length - 1]?.savings ?? 0, data.currency)}
</p>
</div>
</div>
</div>
<div className="bg-card border border-border rounded-xl p-4">
<p className="text-sm font-medium mb-4">Savings Rate by Month</p>
<ResponsiveContainer width="100%" height={320}>
<ComposedChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
<XAxis dataKey="month" tick={{ fontSize: 11 }} stroke="var(--muted-foreground)" />
<YAxis yAxisId="bars" tick={{ fontSize: 11 }} stroke="var(--muted-foreground)" tickFormatter={(v) => `£${(v/1000).toFixed(0)}k`} />
<YAxis yAxisId="rate" orientation="right" tick={{ fontSize: 11 }} stroke="var(--muted-foreground)" tickFormatter={(v) => `${v}%`} />
<Tooltip formatter={(v: number, name: string) => name === "Savings Rate %" ? `${v.toFixed(1)}%` : formatCurrency(v, data.currency)} />
<Legend />
<Bar yAxisId="bars" dataKey="income" fill="#22c55e" name="Income" radius={[2, 2, 0, 0]} />
<Bar yAxisId="bars" dataKey="expenses" fill="#ef4444" name="Expenses" radius={[2, 2, 0, 0]} />
<Line yAxisId="rate" type="monotone" dataKey="rate" stroke="#6366f1" strokeWidth={2.5} dot={{ r: 3 }} name="Savings Rate %" />
</ComposedChart>
</ResponsiveContainer>
</div>
<div className="bg-card border border-border rounded-xl overflow-hidden">
<table className="w-full text-sm">
<thead className="border-b border-border bg-secondary/30">
<tr className="text-muted-foreground text-xs uppercase tracking-wider">
<th className="text-left px-4 py-3">Month</th>
<th className="text-right px-4 py-3">Income</th>
<th className="text-right px-4 py-3">Expenses</th>
<th className="text-right px-4 py-3">Saved</th>
<th className="text-right px-4 py-3">Rate</th>
</tr>
</thead>
<tbody>
{[...chartData].reverse().map((row) => (
<tr key={row.month} className="border-b border-border/50 hover:bg-secondary/20">
<td className="px-4 py-2.5 font-medium">{row.month}</td>
<td className="px-4 py-2.5 text-right tabular-nums text-success">{formatCurrency(row.income, data.currency)}</td>
<td className="px-4 py-2.5 text-right tabular-nums text-destructive">{formatCurrency(row.expenses, data.currency)}</td>
<td className={cn("px-4 py-2.5 text-right tabular-nums", row.savings >= 0 ? "text-success" : "text-destructive")}>
{formatCurrency(row.savings, data.currency)}
</td>
<td className={cn("px-4 py-2.5 text-right tabular-nums font-semibold", row.rate >= 20 ? "text-success" : row.rate >= 0 ? "text-foreground" : "text-destructive")}>
{row.rate.toFixed(1)}%
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
function CategoriesTab() {
const [drillCategory, setDrillCategory] = useState<string | null>(null);
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) }));
const pieData = data.items.slice(0, 10).map(i => ({ name: i.category_name, value: Number(i.amount), category_id: i.category_id }));
const activeIndex = drillCategory ? pieData.findIndex(p => p.name === drillCategory) : -1;
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>
<p className="text-xs text-muted-foreground mb-4">Total: {formatCurrency(Number(data.total), data.currency)}
{drillCategory && <span> · <button onClick={() => setDrillCategory(null)} className="text-primary underline">clear filter</button></span>}
</p>
{pieData.length === 0 ? <EmptyChart /> : (
<div className="flex gap-6 items-start">
<div className="flex gap-6 items-start flex-wrap">
<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
data={pieData}
cx="50%" cy="50%"
innerRadius={60} outerRadius={90}
dataKey="value"
paddingAngle={2}
onClick={(entry) => setDrillCategory(drillCategory === entry.name ? null : entry.name)}
style={{ cursor: "pointer" }}
>
{pieData.map((_, i) => (
<Cell
key={i}
fill={COLORS[i % COLORS.length]}
opacity={activeIndex === -1 || activeIndex === i ? 1 : 0.35}
stroke={activeIndex === i ? "var(--foreground)" : "none"}
strokeWidth={activeIndex === i ? 2 : 0}
/>
))}
</Pie>
<Tooltip formatter={(v: number) => formatCurrency(v, data.currency)} />
</PieChart>
</ResponsiveContainer>
<div className="flex-1 space-y-2">
<div className="flex-1 space-y-2 min-w-48">
{data.items.slice(0, 10).map((item, i) => (
<div key={i} className="flex items-center gap-2 text-sm">
<button
key={i}
onClick={() => setDrillCategory(drillCategory === item.category_name ? null : item.category_name)}
className={cn(
"w-full flex items-center gap-2 text-sm rounded-lg px-2 py-1 transition-colors text-left",
drillCategory === item.category_name ? "bg-secondary" : "hover:bg-secondary/50"
)}
>
<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>
</button>
))}
</div>
</div>
)}
</div>
{drillCategory && (
<div className="bg-card border border-border rounded-xl p-4">
<p className="text-sm font-medium mb-3">Transactions in <span className="text-primary">{drillCategory}</span> this month</p>
<DrillDownTransactions categoryId={data.items.find(i => i.category_name === drillCategory)?.category_id ?? null} currency={data.currency} dateFrom={data.date_from} dateTo={data.date_to} />
</div>
)}
</div>
);
}
function DrillDownTransactions({ categoryId, currency, dateFrom, dateTo }: {
categoryId: string | null; currency: string; dateFrom: string; dateTo: string;
}) {
const { data, isLoading } = useQuery({
queryKey: ["drilldown-txns", categoryId, dateFrom, dateTo],
queryFn: async () => {
const { getTransactions } = await import("@/api/transactions");
return getTransactions({
category_id: categoryId ?? undefined,
date_from: dateFrom,
date_to: dateTo,
page_size: 50,
});
},
enabled: !!categoryId,
});
if (!categoryId) return <p className="text-sm text-muted-foreground">Uncategorised transactions cannot be filtered here.</p>;
if (isLoading) return <div className="h-20 animate-pulse bg-secondary/30 rounded-lg" />;
if (!data?.items.length) return <p className="text-sm text-muted-foreground py-4 text-center">No transactions found.</p>;
return (
<div className="space-y-1">
{data.items.map((txn) => (
<div key={txn.id} className="flex items-center justify-between py-1.5 border-b border-border/40 last:border-0 text-sm">
<div className="flex-1 min-w-0">
<p className="truncate font-medium">{txn.description}</p>
<p className="text-xs text-muted-foreground">{txn.date}</p>
</div>
<span className="tabular-nums font-semibold text-destructive ml-4 shrink-0">
{formatCurrency(Math.abs(Number(txn.amount)), currency)}
</span>
</div>
))}
</div>
);
}
@ -427,6 +628,207 @@ function SpendingTrendsTab() {
);
}
function InvestmentsTab() {
const { data: portfolio, isLoading: loadingPortfolio } = useQuery({ queryKey: ["portfolio"], queryFn: getPortfolio });
const { data: perf, isLoading: loadingPerf } = useQuery({ queryKey: ["investment-performance"], queryFn: getPerformance });
if (loadingPortfolio || loadingPerf) return <ChartSkeleton />;
if (!portfolio || !perf) return null;
const noHoldings = portfolio.holdings.length === 0;
if (noHoldings) return <EmptyChart message="No investment holdings yet" />;
const holdingsData = portfolio.holdings.map(h => ({
name: h.symbol,
value: Number(h.current_value ?? 0),
gain: Number(h.unrealised_gain ?? 0),
gainPct: Number(h.unrealised_gain_pct ?? 0),
}));
return (
<div className="space-y-4">
<div className="grid grid-cols-2 lg: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">Portfolio Value</p>
<p className="text-xl font-bold tabular-nums">{formatCurrency(Number(portfolio.total_value), perf.currency)}</p>
</div>
<div className="bg-card border border-border rounded-xl p-4">
<p className="text-xs text-muted-foreground mb-1">Total Cost</p>
<p className="text-xl font-bold tabular-nums">{formatCurrency(Number(portfolio.total_cost), perf.currency)}</p>
</div>
<div className="bg-card border border-border rounded-xl p-4">
<p className="text-xs text-muted-foreground mb-1">Total Return</p>
<p className={cn("text-xl font-bold tabular-nums", Number(perf.total_return) >= 0 ? "text-success" : "text-destructive")}>
{Number(perf.total_return) >= 0 ? "+" : ""}{formatCurrency(Number(perf.total_return), perf.currency)}
</p>
</div>
<div className="bg-card border border-border rounded-xl p-4">
<p className="text-xs text-muted-foreground mb-1">Return %</p>
<p className={cn("text-xl font-bold tabular-nums", Number(perf.total_return_pct) >= 0 ? "text-success" : "text-destructive")}>
{Number(perf.total_return_pct) >= 0 ? "+" : ""}{Number(perf.total_return_pct).toFixed(2)}%
</p>
</div>
</div>
<div className="bg-card border border-border rounded-xl p-4">
<p className="text-sm font-medium mb-4">Holdings Value</p>
<ResponsiveContainer width="100%" height={220}>
<BarChart data={holdingsData} layout="vertical">
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
<XAxis type="number" tick={{ fontSize: 11 }} stroke="var(--muted-foreground)" tickFormatter={(v) => `£${(v/1000).toFixed(0)}k`} />
<YAxis type="category" dataKey="name" tick={{ fontSize: 11 }} stroke="var(--muted-foreground)" width={60} />
<Tooltip formatter={(v: number) => formatCurrency(v, perf.currency)} />
<Bar dataKey="value" name="Current Value" radius={[0, 3, 3, 0]}>
{holdingsData.map((entry, i) => (
<Cell key={i} fill={entry.gain >= 0 ? "#22c55e" : "#ef4444"} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
<div className="bg-card border border-border rounded-xl overflow-hidden">
<table className="w-full text-sm">
<thead className="border-b border-border bg-secondary/30">
<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">Qty</th>
<th className="text-right px-4 py-3">Avg Cost</th>
<th className="text-right px-4 py-3">Current Price</th>
<th className="text-right px-4 py-3">Value</th>
<th className="text-right px-4 py-3">Gain / Loss</th>
</tr>
</thead>
<tbody>
{portfolio.holdings.map((h) => (
<tr key={h.id} className="border-b border-border/50 hover:bg-secondary/20">
<td className="px-4 py-3">
<p className="font-medium">{h.symbol}</p>
<p className="text-xs text-muted-foreground truncate max-w-32">{h.asset_name}</p>
</td>
<td className="px-4 py-3 text-right tabular-nums">{Number(h.quantity).toFixed(4)}</td>
<td className="px-4 py-3 text-right tabular-nums">{formatCurrency(Number(h.avg_cost_basis), h.currency)}</td>
<td className="px-4 py-3 text-right tabular-nums">{h.current_price != null ? formatCurrency(Number(h.current_price), h.currency) : "—"}</td>
<td className="px-4 py-3 text-right tabular-nums font-semibold">{h.current_value != null ? formatCurrency(Number(h.current_value), h.currency) : "—"}</td>
<td className={cn("px-4 py-3 text-right tabular-nums font-semibold", h.unrealised_gain != null && Number(h.unrealised_gain) >= 0 ? "text-success" : "text-destructive")}>
{h.unrealised_gain != null ? (
<>
{Number(h.unrealised_gain) >= 0 ? "+" : ""}{formatCurrency(Number(h.unrealised_gain), h.currency)}
<span className="text-xs font-normal ml-1">({Number(h.unrealised_gain_pct ?? 0).toFixed(1)}%)</span>
</>
) : "—"}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
function CapitalGainsTab() {
const { data, isLoading } = useQuery({ queryKey: ["capital-gains"], queryFn: getCapitalGains });
const [selectedYear, setSelectedYear] = useState<string | null>(null);
if (isLoading) return <ChartSkeleton />;
if (!data || data.tax_years.length === 0) {
return <EmptyChart message="No disposals recorded yet" />;
}
const activeYear: TaxYearSummary = data.tax_years.find(y => y.tax_year === selectedYear) ?? data.tax_years[0];
return (
<div className="space-y-4">
<div className="flex items-center gap-3 flex-wrap">
<p className="text-sm text-muted-foreground">UK tax year:</p>
<div className="flex gap-1.5 flex-wrap">
{data.tax_years.map(y => (
<button
key={y.tax_year}
onClick={() => setSelectedYear(y.tax_year)}
className={cn(
"px-3 py-1 rounded-full text-xs font-medium border transition-colors",
(selectedYear ?? data.tax_years[0].tax_year) === y.tax_year
? "bg-primary text-primary-foreground border-primary"
: "border-border text-muted-foreground hover:text-foreground"
)}
>
{y.tax_year}
</button>
))}
</div>
</div>
<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">Total Proceeds</p>
<p className="text-xl font-bold tabular-nums">{formatCurrency(Number(activeYear.total_proceeds), activeYear.currency)}</p>
</div>
<div className="bg-card border border-border rounded-xl p-4">
<p className="text-xs text-muted-foreground mb-1">Total Cost</p>
<p className="text-xl font-bold tabular-nums">{formatCurrency(Number(activeYear.total_cost), activeYear.currency)}</p>
</div>
<div className="bg-card border border-border rounded-xl p-4">
<p className="text-xs text-muted-foreground mb-1">Net Gain / Loss</p>
<p className={cn("text-xl font-bold tabular-nums", Number(activeYear.total_gain) >= 0 ? "text-success" : "text-destructive")}>
{Number(activeYear.total_gain) >= 0 ? "+" : ""}{formatCurrency(Number(activeYear.total_gain), activeYear.currency)}
</p>
</div>
</div>
<div className="bg-card border border-border rounded-xl overflow-hidden">
<table className="w-full text-sm">
<thead className="border-b border-border bg-secondary/30">
<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">Asset</th>
<th className="text-right px-4 py-3">Qty</th>
<th className="text-right px-4 py-3">Proceeds</th>
<th className="text-right px-4 py-3">Cost</th>
<th className="text-right px-4 py-3">Gain / Loss</th>
</tr>
</thead>
<tbody>
{activeYear.disposals.map((d, i) => {
const gain = Number(d.gain);
return (
<tr key={i} className="border-b border-border/50 hover:bg-secondary/20">
<td className="px-4 py-3 tabular-nums text-muted-foreground">{d.date}</td>
<td className="px-4 py-3">
<p className="font-medium">{d.symbol}</p>
<p className="text-xs text-muted-foreground truncate max-w-40">{d.asset_name}</p>
</td>
<td className="px-4 py-3 text-right tabular-nums">{Number(d.quantity).toFixed(4)}</td>
<td className="px-4 py-3 text-right tabular-nums">{formatCurrency(Number(d.proceeds), d.currency)}</td>
<td className="px-4 py-3 text-right tabular-nums">{formatCurrency(Number(d.cost), d.currency)}</td>
<td className={cn("px-4 py-3 text-right tabular-nums font-semibold", gain >= 0 ? "text-success" : "text-destructive")}>
{gain >= 0 ? "+" : ""}{formatCurrency(gain, d.currency)}
</td>
</tr>
);
})}
</tbody>
<tfoot className="border-t border-border bg-secondary/20">
<tr className="text-sm font-semibold">
<td colSpan={3} className="px-4 py-3 text-muted-foreground">Total ({activeYear.disposals.length} disposal{activeYear.disposals.length !== 1 ? "s" : ""})</td>
<td className="px-4 py-3 text-right tabular-nums">{formatCurrency(Number(activeYear.total_proceeds), activeYear.currency)}</td>
<td className="px-4 py-3 text-right tabular-nums">{formatCurrency(Number(activeYear.total_cost), activeYear.currency)}</td>
<td className={cn("px-4 py-3 text-right tabular-nums", Number(activeYear.total_gain) >= 0 ? "text-success" : "text-destructive")}>
{Number(activeYear.total_gain) >= 0 ? "+" : ""}{formatCurrency(Number(activeYear.total_gain), activeYear.currency)}
</td>
</tr>
</tfoot>
</table>
</div>
<p className="text-xs text-muted-foreground">
Calculated using the UK Section 104 pool method. Currency conversion uses current exchange rates as an approximation for historical trades. Not financial advice verify with a qualified accountant before filing.
</p>
</div>
);
}
function ChartSkeleton() {
return (
<div className="space-y-4">
@ -478,9 +880,13 @@ export default function ReportsPage() {
{activeTab === "Balance Sheet" && <BalanceSheetTab />}
{activeTab === "Net Worth" && <NetWorthTab />}
{activeTab === "Income vs Expense" && <IncomeExpenseTab />}
{activeTab === "Cash Flow" && <CashFlowTab />}
{activeTab === "Savings Rate" && <SavingsRateTab />}
{activeTab === "Categories" && <CategoriesTab />}
{activeTab === "Budget vs Actual" && <BudgetVsActualTab />}
{activeTab === "Spending Trends" && <SpendingTrendsTab />}
{activeTab === "Investments" && <InvestmentsTab />}
{activeTab === "Capital Gains" && <CapitalGainsTab />}
</div>
</div>
);

View file

@ -7,12 +7,14 @@ import {
getTotpSetup, enableTotp, disableTotp,
changePassword, updateProfile, exportData, getMe,
} from "@/api/auth";
import { listBackups, triggerBackup, downloadBackup, restoreBackup } from "@/api/admin";
import type { BackupFile } from "@/api/admin";
import { cn } from "@/utils/cn";
import { format } from "date-fns";
import {
User, Shield, MonitorSmartphone, Download,
User, Shield, MonitorSmartphone, Download, HardDrive,
Loader2, CheckCircle, Eye, EyeOff, Trash2,
LogOut, QrCode, KeyRound, AlertTriangle,
LogOut, QrCode, KeyRound, AlertTriangle, RefreshCw, RotateCcw,
} from "lucide-react";
const SECTIONS = [
@ -20,6 +22,7 @@ const SECTIONS = [
{ id: "security", label: "Security", icon: Shield },
{ id: "sessions", label: "Sessions", icon: MonitorSmartphone },
{ id: "data", label: "Data", icon: Download },
{ id: "backups", label: "Backups", icon: HardDrive },
] as const;
type Section = (typeof SECTIONS)[number]["id"];
@ -60,6 +63,7 @@ export default function SettingsPage() {
{section === "security" && <SecuritySection />}
{section === "sessions" && <SessionsSection />}
{section === "data" && <DataSection />}
{section === "backups" && <BackupsSection />}
</div>
</div>
</div>
@ -483,6 +487,167 @@ function SessionsSection() {
);
}
// ─── Backups ──────────────────────────────────────────────────────────────────
function BackupsSection() {
const qc = useQueryClient();
const [restoreTarget, setRestoreTarget] = useState<string | null>(null);
const [restoreSuccess, setRestoreSuccess] = useState("");
const [restoreError, setRestoreError] = useState("");
const { data: backups = [], isLoading } = useQuery({
queryKey: ["backups"],
queryFn: listBackups,
});
const triggerMutation = useMutation({
mutationFn: triggerBackup,
onSuccess: () => qc.invalidateQueries({ queryKey: ["backups"] }),
});
const restoreMutation = useMutation({
mutationFn: (filename: string) => restoreBackup(filename),
onSuccess: (_, filename) => {
setRestoreTarget(null);
setRestoreSuccess(`Restored from ${filename}. Reload the page to see updated data.`);
setRestoreError("");
setTimeout(() => setRestoreSuccess(""), 8000);
},
onError: (e: any) => {
setRestoreError(e?.response?.data?.detail ?? "Restore failed");
setRestoreTarget(null);
},
});
function formatSize(bytes: number) {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
return (
<div className="space-y-4">
{/* Trigger */}
<div className={cardCls}>
<div className="flex items-center gap-2">
<HardDrive className="w-4 h-4 text-muted-foreground" />
<SectionTitle>Database Backups</SectionTitle>
</div>
<p className="text-sm text-muted-foreground">
Backups run automatically at 3am daily. Each is GPG-encrypted with your backup passphrase and stored at <code className="text-xs bg-secondary px-1 py-0.5 rounded">/app/backups</code>.
</p>
{triggerMutation.isSuccess && <SuccessBanner message="Backup created successfully" />}
{triggerMutation.isError && <ErrorBanner message={(triggerMutation.error as any)?.response?.data?.detail ?? "Backup failed"} />}
<button
onClick={() => triggerMutation.mutate()}
disabled={triggerMutation.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"
>
{triggerMutation.isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <RefreshCw className="w-4 h-4" />}
{triggerMutation.isPending ? "Creating backup…" : "Backup now"}
</button>
</div>
{/* Backup list */}
<div className={cardCls}>
<div className="flex items-center justify-between">
<SectionTitle>Stored Backups</SectionTitle>
<button
onClick={() => qc.invalidateQueries({ queryKey: ["backups"] })}
className="p-1.5 rounded text-muted-foreground hover:text-foreground hover:bg-secondary transition-colors"
title="Refresh list"
>
<RefreshCw className="w-3.5 h-3.5" />
</button>
</div>
{restoreSuccess && <SuccessBanner message={restoreSuccess} />}
{restoreError && <ErrorBanner message={restoreError} />}
{isLoading ? (
<div className="space-y-2">
{[1,2,3].map(i => <div key={i} className="h-12 bg-secondary/30 rounded-lg animate-pulse" />)}
</div>
) : backups.length === 0 ? (
<p className="text-sm text-muted-foreground py-4 text-center">No backups yet click "Backup now" to create one.</p>
) : (
<div className="space-y-2">
{backups.map((b: BackupFile) => (
<div key={b.filename} className="flex items-center gap-3 p-3 rounded-lg border border-border bg-secondary/20">
<HardDrive className="w-4 h-4 text-muted-foreground shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium font-mono truncate">{b.filename}</p>
<p className="text-xs text-muted-foreground">
{formatSize(b.size_bytes)} · {format(new Date(b.created_at), "dd MMM yyyy HH:mm")}
</p>
</div>
<div className="flex items-center gap-1.5 shrink-0">
<button
onClick={() => downloadBackup(b.filename)}
className="flex items-center gap-1 text-xs px-2.5 py-1.5 rounded border border-border hover:bg-secondary transition-colors text-muted-foreground hover:text-foreground"
title="Download"
>
<Download className="w-3 h-3" />
Download
</button>
<button
onClick={() => setRestoreTarget(b.filename)}
className="flex items-center gap-1 text-xs px-2.5 py-1.5 rounded border border-destructive/40 text-destructive hover:bg-destructive/10 transition-colors"
title="Restore"
>
<RotateCcw className="w-3 h-3" />
Restore
</button>
</div>
</div>
))}
</div>
)}
</div>
{/* Restore confirmation dialog */}
{restoreTarget && (
<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-sm shadow-xl p-6">
<div className="flex items-start gap-4 mb-5">
<div className="w-10 h-10 rounded-full bg-destructive/15 flex items-center justify-center shrink-0">
<AlertTriangle className="w-5 h-5 text-destructive" />
</div>
<div>
<h3 className="font-semibold text-base">Restore this backup?</h3>
<p className="text-sm text-muted-foreground mt-1">
This will <strong>overwrite all current data</strong> with the contents of:
</p>
<p className="text-xs font-mono bg-secondary px-2 py-1 rounded mt-2 break-all">{restoreTarget}</p>
<p className="text-sm text-muted-foreground mt-2">This cannot be undone. Consider downloading your current backup first.</p>
</div>
</div>
<div className="flex gap-3">
<button
onClick={() => setRestoreTarget(null)}
disabled={restoreMutation.isPending}
className="flex-1 py-2.5 rounded-lg border border-border text-sm hover:bg-secondary transition-colors disabled:opacity-50"
>
Cancel
</button>
<button
onClick={() => restoreMutation.mutate(restoreTarget)}
disabled={restoreMutation.isPending}
className="flex-1 flex items-center justify-center gap-2 py-2.5 rounded-lg bg-destructive text-destructive-foreground text-sm font-medium hover:bg-destructive/90 disabled:opacity-50 transition-colors"
>
{restoreMutation.isPending && <Loader2 className="w-4 h-4 animate-spin" />}
{restoreMutation.isPending ? "Restoring…" : "Yes, restore"}
</button>
</div>
</div>
</div>
)}
</div>
);
}
// ─── Data ─────────────────────────────────────────────────────────────────────
function DataSection() {

View file

@ -196,7 +196,7 @@ export default function TransactionDetailDrawer({ transaction, accountName, cate
<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"
className="shrink-0 text-muted-foreground hover:text-destructive opacity-100 sm:opacity-0 sm:group-hover:opacity-100 transition-all p-1 rounded"
>
{deleteMutation.isPending ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />

View file

@ -5,10 +5,10 @@ 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 { format, startOfMonth, subMonths, startOfYear } from "date-fns";
import {
Plus, Trash2, Search, ChevronLeft, ChevronRight, Upload,
ArrowUpCircle, ArrowDownCircle, ArrowLeftRight, TrendingUp, Paperclip
ArrowUpCircle, ArrowDownCircle, ArrowLeftRight, TrendingUp, Paperclip, RefreshCw
} from "lucide-react";
import TransactionFormModal from "./TransactionFormModal";
import TransactionDetailDrawer from "./TransactionDetailDrawer";
@ -28,6 +28,23 @@ const TYPE_ICONS = {
investment: TrendingUp,
};
type DatePreset = "this_month" | "last_3_months" | "this_year" | "all";
const DATE_PRESETS: { label: string; value: DatePreset }[] = [
{ label: "This month", value: "this_month" },
{ label: "Last 3 months", value: "last_3_months" },
{ label: "This year", value: "this_year" },
{ label: "All time", value: "all" },
];
function presetToDates(preset: DatePreset): { date_from?: string; date_to?: string } {
const today = new Date();
if (preset === "this_month") return { date_from: format(startOfMonth(today), "yyyy-MM-dd") };
if (preset === "last_3_months") return { date_from: format(startOfMonth(subMonths(today, 2)), "yyyy-MM-dd") };
if (preset === "this_year") return { date_from: format(startOfYear(today), "yyyy-MM-dd") };
return {};
}
export default function TransactionList() {
const qc = useQueryClient();
const [showForm, setShowForm] = useState(false);
@ -35,15 +52,21 @@ export default function TransactionList() {
const [search, setSearch] = useState("");
const [filterType, setFilterType] = useState("");
const [filterAccount, setFilterAccount] = useState("");
const [datePreset, setDatePreset] = useState<DatePreset>("this_month");
const [recurringOnly, setRecurringOnly] = useState(false);
const [page, setPage] = useState(1);
const dates = presetToDates(datePreset);
const { data, isLoading } = useQuery({
queryKey: ["transactions", { search, filterType, filterAccount, page }],
queryKey: ["transactions", { search, filterType, filterAccount, datePreset, recurringOnly, page }],
queryFn: () =>
getTransactions({
search: search || undefined,
type: filterType || undefined,
account_id: filterAccount || undefined,
is_recurring: recurringOnly ? true : undefined,
...dates,
page,
page_size: 50,
}),
@ -82,24 +105,54 @@ export default function TransactionList() {
{data ? `${data.total} transactions` : ""}
</p>
</div>
<div className="flex gap-2">
<div className="flex gap-2 shrink-0">
<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
<span className="hidden sm:inline">Import CSV</span>
</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
<span className="hidden sm:inline">Add</span>
</button>
</div>
</div>
{/* Date presets */}
<div className="flex gap-1 flex-wrap">
{DATE_PRESETS.map((p) => (
<button
key={p.value}
onClick={() => { setDatePreset(p.value); setPage(1); }}
className={cn(
"px-3 py-1.5 rounded-lg text-xs font-medium border transition-colors",
datePreset === p.value
? "bg-primary text-primary-foreground border-primary"
: "border-border text-muted-foreground hover:text-foreground hover:bg-secondary"
)}
>
{p.label}
</button>
))}
<button
onClick={() => { setRecurringOnly(!recurringOnly); setPage(1); }}
className={cn(
"px-3 py-1.5 rounded-lg text-xs font-medium border transition-colors flex items-center gap-1.5 ml-auto",
recurringOnly
? "bg-primary text-primary-foreground border-primary"
: "border-border text-muted-foreground hover:text-foreground hover:bg-secondary"
)}
>
<RefreshCw className="w-3 h-3" />
Recurring only
</button>
</div>
{/* Filters */}
<div className="flex gap-2 flex-wrap">
<div className="relative flex-1 min-w-48">
@ -172,7 +225,8 @@ export default function TransactionList() {
onClick={() => setSelectedTxn(txn)}
>
<td className="px-4 py-3 text-muted-foreground whitespace-nowrap">
{format(new Date(txn.date), "dd MMM yyyy")}
<span className="hidden sm:inline">{format(new Date(txn.date), "dd MMM yyyy")}</span>
<span className="sm:hidden">{format(new Date(txn.date), "dd MMM")}</span>
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
@ -205,7 +259,7 @@ export default function TransactionList() {
<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"
className="p-1 rounded text-muted-foreground hover:text-destructive opacity-100 sm:opacity-0 sm:group-hover:opacity-100 transition-all"
>
<Trash2 className="w-3.5 h-3.5" />
</button>