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
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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue