This is the initial push of the outline of the life dashboard
This commit is contained in:
278
frontend/src/pages/Finances.tsx
Normal file
278
frontend/src/pages/Finances.tsx
Normal file
@@ -0,0 +1,278 @@
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user