added api logic, vitest, minimal testing ui

This commit is contained in:
2025-11-15 23:26:57 -06:00
parent f4160b91db
commit 4eae966f96
95 changed files with 14155 additions and 469 deletions

View 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>
);
}