added api logic, vitest, minimal testing ui
This commit is contained in:
148
web/src/pages/DashboardPage.tsx
Normal file
148
web/src/pages/DashboardPage.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { apiGet } from "../api/http";
|
||||
import { fmtMoney } from "../utils/money";
|
||||
|
||||
type VariableCategory = {
|
||||
id: string; name: string; percent: number; priority: number;
|
||||
isSavings: boolean; balanceCents?: number;
|
||||
};
|
||||
|
||||
type FixedPlan = {
|
||||
id: string; name: string; priority: number;
|
||||
totalCents?: number; fundedCents?: number; dueOn: string;
|
||||
};
|
||||
|
||||
type Tx = { id: string; kind: "variable_spend"|"fixed_payment"; amountCents: number; occurredAt: string };
|
||||
|
||||
type DashboardResponse = {
|
||||
totals: {
|
||||
incomeCents: number;
|
||||
variableBalanceCents: number;
|
||||
fixedRemainingCents: number;
|
||||
};
|
||||
percentTotal: number;
|
||||
variableCategories: VariableCategory[];
|
||||
fixedPlans: FixedPlan[];
|
||||
recentTransactions: Tx[];
|
||||
};
|
||||
|
||||
export default function DashboardPage() {
|
||||
const [data, setData] = useState<DashboardResponse | null>(null);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
setErr(null);
|
||||
try {
|
||||
const d = await apiGet<DashboardResponse>("/dashboard");
|
||||
setData(d);
|
||||
} catch (e: any) {
|
||||
setErr(e.message || "Failed to load");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
if (loading) return <div className="p-6">Loading dashboard…</div>;
|
||||
if (err) return (
|
||||
<div className="p-6 text-red-600">
|
||||
{err} <button className="ml-2 underline" onClick={load}>retry</button>
|
||||
</div>
|
||||
);
|
||||
if (!data) return null;
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-8">
|
||||
<h1 className="text-2xl font-bold">Dashboard</h1>
|
||||
|
||||
<section className="grid gap-4 md:grid-cols-3">
|
||||
<Card label="Total Income" value={fmtMoney(data.totals.incomeCents)} />
|
||||
<Card label="Variable Balance" value={fmtMoney(data.totals.variableBalanceCents)} />
|
||||
<Card label="Fixed Remaining" value={fmtMoney(data.totals.fixedRemainingCents)} />
|
||||
</section>
|
||||
|
||||
<section className="grid gap-6 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<h2 className="font-semibold">Variable Categories (sum {data.percentTotal}%)</h2>
|
||||
<div className="border rounded-md divide-y">
|
||||
{data.variableCategories.map(c => (
|
||||
<div key={c.id} className="p-3 flex items-center justify-between">
|
||||
<div>
|
||||
<div className="font-medium">{c.name}</div>
|
||||
<div className="text-sm opacity-70">
|
||||
{c.isSavings ? "Savings • " : ""}Priority {c.priority}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="font-mono">{fmtMoney(c.balanceCents ?? 0)}</div>
|
||||
<div className="text-sm opacity-70">{c.percent}%</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h2 className="font-semibold">Fixed Plans</h2>
|
||||
<div className="border rounded-md divide-y">
|
||||
{data.fixedPlans.map(p => {
|
||||
const total = p.totalCents ?? 0;
|
||||
const funded = p.fundedCents ?? 0;
|
||||
const remaining = Math.max(total - funded, 0);
|
||||
return (
|
||||
<div key={p.id} className="p-3 flex items-center justify-between">
|
||||
<div>
|
||||
<div className="font-medium">{p.name}</div>
|
||||
<div className="text-sm opacity-70">
|
||||
Due {new Date(p.dueOn).toLocaleDateString()} • Priority {p.priority}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="font-mono">{fmtMoney(remaining)}</div>
|
||||
<div className="text-sm opacity-70">remaining</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-2">
|
||||
<h2 className="font-semibold">Recent Transactions</h2>
|
||||
<div className="border rounded-md overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="text-left p-2">Date</th>
|
||||
<th className="text-left p-2">Kind</th>
|
||||
<th className="text-right p-2">Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.recentTransactions.map(tx => (
|
||||
<tr key={tx.id} className="border-t">
|
||||
<td className="p-2">{new Date(tx.occurredAt).toLocaleString()}</td>
|
||||
<td className="p-2">{tx.kind.replace("_", " ")}</td>
|
||||
<td className="p-2 text-right font-mono">{fmtMoney(tx.amountCents)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Card({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="rounded-xl border p-4 shadow-sm">
|
||||
<div className="text-sm opacity-70">{label}</div>
|
||||
<div className="text-xl font-bold mt-1">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user