Files
life/frontend/src/pages/Finances.tsx

279 lines
9.7 KiB
TypeScript

import { useEffect, useState } from "react";
import {
PieChart,
Pie,
Cell,
Tooltip,
ResponsiveContainer,
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
} from "recharts";
import { Trash2 } from "lucide-react";
import { api } from "@/api/client";
import type { Transaction, Snapshot, CategorySummary } from "@/api/types";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
const COLORS = [
"hsl(220 90% 56%)",
"hsl(150 60% 50%)",
"hsl(40 90% 56%)",
"hsl(0 84% 60%)",
"hsl(270 60% 56%)",
"hsl(180 60% 45%)",
"hsl(30 80% 55%)",
"hsl(320 60% 50%)",
];
export default function Finances() {
const [transactions, setTransactions] = useState<Transaction[]>([]);
const [snapshots, setSnapshots] = useState<Snapshot[]>([]);
const [summary, setSummary] = useState<CategorySummary[]>([]);
const [tab, setTab] = useState<"transactions" | "networth">("transactions");
// Transaction form
const [tDate, setTDate] = useState(today());
const [tAmount, setTAmount] = useState("");
const [tCategory, setTCategory] = useState("");
const [tDesc, setTDesc] = useState("");
// Snapshot form
const [sDate, setSDate] = useState(today());
const [sNetWorth, setSNetWorth] = useState("");
const [sNotes, setSNotes] = useState("");
const load = () => {
api.get<Transaction[]>("/finances/transactions").then(setTransactions);
api.get<Snapshot[]>("/finances/snapshots").then(setSnapshots);
api.get<CategorySummary[]>("/finances/summary?period=month").then(setSummary);
};
useEffect(() => {
load();
}, []);
const handleAddTransaction = async (e: React.FormEvent) => {
e.preventDefault();
await api.post("/finances/transactions", {
date: tDate,
amount: parseFloat(tAmount),
category: tCategory,
description: tDesc || null,
});
setTAmount("");
setTCategory("");
setTDesc("");
load();
};
const handleDeleteTransaction = async (id: number) => {
await api.delete(`/finances/transactions/${id}`);
load();
};
const handleAddSnapshot = async (e: React.FormEvent) => {
e.preventDefault();
await api.post("/finances/snapshots", {
date: sDate,
net_worth: parseFloat(sNetWorth),
notes: sNotes || null,
});
setSNetWorth("");
setSNotes("");
load();
};
const pieData = summary.map((s) => ({
name: s.category,
value: Math.abs(s.total),
}));
const nwData = [...snapshots].reverse();
return (
<div className="space-y-6">
<h2 className="text-2xl font-bold">Finances</h2>
{/* Tab switcher */}
<div className="flex gap-2">
<Button
variant={tab === "transactions" ? "default" : "outline"}
size="sm"
onClick={() => setTab("transactions")}
>
Transactions
</Button>
<Button
variant={tab === "networth" ? "default" : "outline"}
size="sm"
onClick={() => setTab("networth")}
>
Net Worth
</Button>
</div>
{tab === "transactions" && (
<>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Transaction form */}
<Card>
<CardHeader>
<CardTitle>Add Transaction</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleAddTransaction} className="space-y-4">
<div className="space-y-2">
<Label>Date</Label>
<Input type="date" value={tDate} onChange={(e) => setTDate(e.target.value)} required />
</div>
<div className="space-y-2">
<Label>Amount (negative = expense)</Label>
<Input type="number" step="0.01" value={tAmount} onChange={(e) => setTAmount(e.target.value)} required />
</div>
<div className="space-y-2">
<Label>Category</Label>
<Input value={tCategory} onChange={(e) => setTCategory(e.target.value)} placeholder="e.g. food, rent" required />
</div>
<div className="space-y-2">
<Label>Description</Label>
<Input value={tDesc} onChange={(e) => setTDesc(e.target.value)} />
</div>
<Button type="submit" className="w-full">Save</Button>
</form>
</CardContent>
</Card>
{/* Category pie chart */}
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle>Monthly Spending by Category</CardTitle>
</CardHeader>
<CardContent>
{pieData.length > 0 ? (
<ResponsiveContainer width="100%" height={300}>
<PieChart>
<Pie
data={pieData}
cx="50%"
cy="50%"
outerRadius={100}
dataKey="value"
label={({ name, value }) => `${name}: $${value.toFixed(0)}`}
>
{pieData.map((_, i) => (
<Cell key={i} fill={COLORS[i % COLORS.length]} />
))}
</Pie>
<Tooltip formatter={(v: number) => `$${v.toFixed(2)}`} />
</PieChart>
</ResponsiveContainer>
) : (
<p className="text-muted-foreground text-sm">No spending data this month.</p>
)}
</CardContent>
</Card>
</div>
{/* Transactions table */}
<Card>
<CardHeader>
<CardTitle>Recent Transactions</CardTitle>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b">
<th className="text-left py-2 px-2">Date</th>
<th className="text-left py-2 px-2">Amount</th>
<th className="text-left py-2 px-2">Category</th>
<th className="text-left py-2 px-2">Description</th>
<th className="py-2 px-2"></th>
</tr>
</thead>
<tbody>
{transactions.map((t) => (
<tr key={t.id} className="border-b">
<td className="py-2 px-2">{t.date}</td>
<td className={`py-2 px-2 font-medium ${t.amount >= 0 ? "text-green-600" : "text-red-500"}`}>
{t.amount >= 0 ? "+" : ""}${Math.abs(t.amount).toFixed(2)}
</td>
<td className="py-2 px-2">{t.category}</td>
<td className="py-2 px-2 text-muted-foreground">{t.description || "—"}</td>
<td className="py-2 px-2">
<Button variant="ghost" size="icon" onClick={() => handleDeleteTransaction(t.id)}>
<Trash2 className="h-4 w-4" />
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
</>
)}
{tab === "networth" && (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Snapshot form */}
<Card>
<CardHeader>
<CardTitle>Log Net Worth</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleAddSnapshot} className="space-y-4">
<div className="space-y-2">
<Label>Date</Label>
<Input type="date" value={sDate} onChange={(e) => setSDate(e.target.value)} required />
</div>
<div className="space-y-2">
<Label>Net Worth</Label>
<Input type="number" step="0.01" value={sNetWorth} onChange={(e) => setSNetWorth(e.target.value)} required />
</div>
<div className="space-y-2">
<Label>Notes</Label>
<Input value={sNotes} onChange={(e) => setSNotes(e.target.value)} />
</div>
<Button type="submit" className="w-full">Save</Button>
</form>
</CardContent>
</Card>
{/* Net worth chart */}
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle>Net Worth Over Time</CardTitle>
</CardHeader>
<CardContent>
{nwData.length > 0 ? (
<ResponsiveContainer width="100%" height={300}>
<LineChart data={nwData}>
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
<XAxis dataKey="date" className="text-xs" />
<YAxis className="text-xs" />
<Tooltip formatter={(v: number) => `$${v.toLocaleString()}`} />
<Line type="monotone" dataKey="net_worth" stroke="hsl(150 60% 50%)" strokeWidth={2} name="Net Worth" />
</LineChart>
</ResponsiveContainer>
) : (
<p className="text-muted-foreground text-sm">No snapshots yet.</p>
)}
</CardContent>
</Card>
</div>
)}
</div>
);
}
function today() {
return new Date().toISOString().slice(0, 10);
}