279 lines
9.7 KiB
TypeScript
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);
|
|
}
|