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

View File

@@ -0,0 +1,18 @@
import { useQuery } from "@tanstack/react-query";
import { api } from "../api/client";
export default function HealthPage() {
const app = useQuery({ queryKey: ["health"], queryFn: () => api.get<{ok:true}>("/health") });
const db = useQuery({ queryKey: ["health","db"], queryFn: () => api.get<{ok:true; nowISO:string; latencyMs:number}>("/health/db") });
return (
<div className="card max-w-lg">
<h2 className="section-title">Health</h2>
<ul className="stack">
<li>API: {app.isLoading ? "…" : app.data?.ok ? "OK" : "Down"}</li>
<li>DB: {db.isLoading ? "…" : db.data?.ok ? `OK (${db.data.latencyMs} ms)` : "Down"}</li>
<li>Server Time: {db.data?.nowISO ? new Date(db.data.nowISO).toLocaleString() : "…"}</li>
</ul>
</div>
);
}

View File

@@ -0,0 +1,223 @@
import { useMemo, useState, type FormEvent } from "react";
import { useCreateIncome } from "../hooks/useIncome";
import { useDashboard } from "../hooks/useDashboard";
import { Money, Field, Button } from "../components/ui";
import CurrencyInput from "../components/CurrencyInput";
import { previewAllocation } from "../utils/allocatorPreview";
import PercentGuard from "../components/PercentGuard";
import { useToast } from "../components/Toast";
import { useIncomePreview } from "../hooks/useIncomePreview";
function dollarsToCents(input: string): number {
const n = Number.parseFloat(input || "0");
if (!Number.isFinite(n)) return 0;
return Math.round(n * 100);
}
type Alloc = { id: number | string; amountCents: number; name: string };
export default function IncomePage() {
const [amountStr, setAmountStr] = useState("");
const { push } = useToast();
const m = useCreateIncome();
const dash = useDashboard();
const cents = dollarsToCents(amountStr);
const canSubmit = (dash.data?.percentTotal ?? 0) === 100;
// Server preview (preferred) with client fallback
const srvPreview = useIncomePreview(cents);
const preview = useMemo(() => {
if (!dash.data || cents <= 0) return null;
if (srvPreview.data) return srvPreview.data;
// fallback: local simulation
return previewAllocation(cents, dash.data.fixedPlans, dash.data.variableCategories);
}, [cents, dash.data, srvPreview.data]);
const submit = (e: FormEvent) => {
e.preventDefault();
if (cents <= 0 || !canSubmit) return;
m.mutate(
{ amountCents: cents },
{
onSuccess: (res) => {
const fixed = (res.fixedAllocations ?? []).reduce(
(s: number, a: any) => s + (a.amountCents ?? 0),
0
);
const variable = (res.variableAllocations ?? []).reduce(
(s: number, a: any) => s + (a.amountCents ?? 0),
0
);
const unalloc = res.remainingUnallocatedCents ?? 0;
push(
"ok",
`Allocated: Fixed ${(fixed / 100).toFixed(2)} + Variable ${(variable / 100).toFixed(
2
)}. Unallocated ${(unalloc / 100).toFixed(2)}.`
);
setAmountStr("");
},
onError: (err: any) => push("err", err?.message ?? "Income failed"),
}
);
};
const variableAllocations: Alloc[] = useMemo(() => {
if (!m.data) return [];
const nameById = new Map<string | number, string>(
(dash.data?.variableCategories ?? []).map((c) => [c.id as string | number, c.name] as const)
);
const grouped = new Map<string | number, number>();
for (const a of m.data.variableAllocations ?? []) {
const id = (a as any).variableCategoryId ?? (a as any).id ?? -1;
grouped.set(id, (grouped.get(id) ?? 0) + (a as any).amountCents);
}
return [...grouped.entries()]
.map(([id, amountCents]) => ({ id, amountCents, name: nameById.get(id) ?? `Category #${id}` }))
.sort((a, b) => b.amountCents - a.amountCents);
}, [m.data, dash.data]);
const fixedAllocations: Alloc[] = useMemo(() => {
if (!m.data) return [];
const nameById = new Map<string | number, string>(
(dash.data?.fixedPlans ?? []).map((p) => [p.id as string | number, p.name] as const)
);
const grouped = new Map<string | number, number>();
for (const a of m.data.fixedAllocations ?? []) {
const id = (a as any).fixedPlanId ?? (a as any).id ?? -1;
grouped.set(id, (grouped.get(id) ?? 0) + (a as any).amountCents);
}
return [...grouped.entries()]
.map(([id, amountCents]) => ({ id, amountCents, name: nameById.get(id) ?? `Plan #${id}` }))
.sort((a, b) => b.amountCents - a.amountCents);
}, [m.data, dash.data]);
const hasResult = !!m.data;
return (
<div className="stack max-w-lg">
<PercentGuard />
<form onSubmit={submit} className="card">
<h2 className="section-title">Record Income</h2>
<Field label="Amount (USD)">
<CurrencyInput value={amountStr} onValue={setAmountStr} />
</Field>
<Button disabled={m.isPending || cents <= 0 || !canSubmit}>
{m.isPending ? "Allocating…" : canSubmit ? "Submit" : "Fix percents to 100%"}
</Button>
{/* Live Preview */}
{!hasResult && preview && (
<div className="mt-4 stack">
<div className="row">
<h3 className="text-sm muted">Preview (not yet applied)</h3>
<span className="ml-auto text-sm">
Unallocated: <Money cents={preview.unallocatedCents} />
</span>
</div>
<section className="border border-[--color-ink] rounded-[--radius-xl] p-3 bg-[--color-ink]">
<div className="row mb-2">
<h4 className="text-sm muted">Fixed Plans</h4>
<span className="ml-auto font-semibold">
<Money cents={preview.fixed.reduce((s, x) => s + x.amountCents, 0)} />
</span>
</div>
{preview.fixed.length === 0 ? (
<div className="muted text-sm">No fixed allocations.</div>
) : (
<ul className="list-disc pl-5 space-y-1">
{preview.fixed.map((a) => (
<li key={a.id} className="row">
<span>{a.name}</span>
<span className="ml-auto">
<Money cents={a.amountCents} />
</span>
</li>
))}
</ul>
)}
</section>
<section className="border border-[--color-ink] rounded-[--radius-xl] p-3 bg-[--color-ink]">
<div className="row mb-2">
<h4 className="text-sm muted">Variable Categories</h4>
<span className="ml-auto font-semibold">
<Money cents={preview.variable.reduce((s, x) => s + x.amountCents, 0)} />
</span>
</div>
{preview.variable.length === 0 ? (
<div className="muted text-sm">No variable allocations.</div>
) : (
<ul className="list-disc pl-5 space-y-1">
{preview.variable.map((a) => (
<li key={a.id} className="row">
<span>{a.name}</span>
<span className="ml-auto">
<Money cents={a.amountCents} />
</span>
</li>
))}
</ul>
)}
</section>
</div>
)}
{/* Actual Result */}
{m.error && <div className="toast-err mt-3"> {(m.error as any).message}</div>}
{hasResult && (
<div className="mt-4 stack">
<div className="row">
<span className="muted text-sm">Unallocated</span>
<span className="ml-auto">
<Money cents={m.data?.remainingUnallocatedCents ?? 0} />
</span>
</div>
<section className="border border-[--color-ink] rounded-[--radius-xl] p-3 bg-[--color-ink]">
<div className="row mb-2">
<h3 className="text-sm muted">Fixed Plans (Applied)</h3>
<span className="ml-auto font-semibold">
<Money cents={fixedAllocations.reduce((s, x) => s + x.amountCents, 0)} />
</span>
</div>
<ul className="list-disc pl-5 space-y-1">
{fixedAllocations.map((a) => (
<li key={a.id} className="row">
<span>{a.name}</span>
<span className="ml-auto">
<Money cents={a.amountCents} />
</span>
</li>
))}
</ul>
</section>
<section className="border border-[--color-ink] rounded-[--radius-xl] p-3 bg-[--color-ink]">
<div className="row mb-2">
<h3 className="text-sm muted">Variable Categories (Applied)</h3>
<span className="ml-auto font-semibold">
<Money cents={variableAllocations.reduce((s, x) => s + x.amountCents, 0)} />
</span>
</div>
<ul className="list-disc pl-5 space-y-1">
{variableAllocations.map((a) => (
<li key={a.id} className="row">
<span>{a.name}</span>
<span className="ml-auto">
<Money cents={a.amountCents} />
</span>
</li>
))}
</ul>
</section>
</div>
)}
</form>
</div>
);
}

168
web/src/pages/SpendPage.tsx Normal file
View File

@@ -0,0 +1,168 @@
import { useMemo, useState, type FormEvent, type ChangeEvent } from "react";
import { useDashboard } from "../hooks/useDashboard";
import { useCreateTransaction } from "../hooks/useTransactions";
import { Money, Field, Button } from "../components/ui";
import CurrencyInput from "../components/CurrencyInput";
import { useToast } from "../components/Toast";
import { nowLocalISOStringMinute } from "../utils/format";
type Kind = "variable_spend" | "fixed_payment";
function dollarsToCents(input: string): number {
const n = Number.parseFloat(input || "0");
if (!Number.isFinite(n)) return 0;
return Math.round(n * 100);
}
export default function SpendPage() {
const dash = useDashboard();
const m = useCreateTransaction();
const { push } = useToast();
const [kind, setKind] = useState<Kind>("variable_spend");
const [amountStr, setAmountStr] = useState("");
const [occurredAt, setOccurredAt] = useState(nowLocalISOStringMinute());
const [variableCategoryId, setVariableCategoryId] = useState<string>("");
const [fixedPlanId, setFixedPlanId] = useState<string>("");
const amountCents = dollarsToCents(amountStr);
// Optional UX lock: block variable spend if chosen category has 0 balance.
const selectedCategory = useMemo(() => {
if (!dash.data) return null;
return dash.data.variableCategories.find(c => String(c.id) === variableCategoryId) ?? null;
}, [dash.data, variableCategoryId]);
const disableIfZeroBalance = true; // flip to false to allow negatives
const categoryBlocked =
kind === "variable_spend" &&
!!selectedCategory &&
disableIfZeroBalance &&
(selectedCategory.balanceCents ?? 0) <= 0;
const canSubmit = useMemo(() => {
if (!dash.data) return false;
if (amountCents <= 0) return false;
if (kind === "variable_spend") return !!variableCategoryId && !categoryBlocked;
return !!fixedPlanId; // fixed_payment
}, [dash.data, amountCents, kind, variableCategoryId, fixedPlanId, categoryBlocked]);
const onSubmit = (e: FormEvent) => {
e.preventDefault();
if (!canSubmit) return;
const payload = {
kind,
amountCents,
occurredAtISO: new Date(occurredAt).toISOString(),
variableCategoryId: kind === "variable_spend" ? Number(variableCategoryId) : undefined,
fixedPlanId: kind === "fixed_payment" ? Number(fixedPlanId) : undefined,
} as any;
m.mutate(payload, {
onSuccess: () => {
push("ok", kind === "variable_spend" ? "Recorded spend." : "Recorded payment.");
setAmountStr("");
// Keep date defaulting to “now” for quick entry
setOccurredAt(nowLocalISOStringMinute());
},
onError: (err: any) => push("err", err?.message ?? "Failed to record"),
});
};
const cats = dash.data?.variableCategories ?? [];
const plans = dash.data?.fixedPlans ?? [];
return (
<div className="grid gap-4 max-w-xl">
<form onSubmit={onSubmit} className="card stack">
<h2 className="section-title">Spend / Pay</h2>
{/* Kind toggle */}
<div className="row gap-2">
<label className="row">
<input
type="radio"
checked={kind === "variable_spend"}
onChange={() => setKind("variable_spend")}
/>
<span className="text-sm">Variable Spend</span>
</label>
<label className="row">
<input
type="radio"
checked={kind === "fixed_payment"}
onChange={() => setKind("fixed_payment")}
/>
<span className="text-sm">Fixed Payment</span>
</label>
</div>
{/* Pick target */}
{kind === "variable_spend" ? (
<Field label="Category">
<select
className="input"
value={variableCategoryId}
onChange={(e: ChangeEvent<HTMLSelectElement>) => setVariableCategoryId(e.target.value)}
>
<option value="">Select a category</option>
{cats
.slice()
.sort((a, b) => (a.priority - b.priority) || a.name.localeCompare(b.name))
.map(c => (
<option key={c.id} value={String(c.id)}>
{c.name} <Money cents={c.balanceCents} />
</option>
))}
</select>
</Field>
) : (
<Field label="Fixed Plan">
<select
className="input"
value={fixedPlanId}
onChange={(e: ChangeEvent<HTMLSelectElement>) => setFixedPlanId(e.target.value)}
>
<option value="">Select a plan</option>
{plans
.slice()
.sort((a, b) => (a.priority - b.priority) || (new Date(a.dueOn).getTime() - new Date(b.dueOn).getTime()))
.map(p => (
<option key={p.id} value={String(p.id)}>
{p.name} Funded <Money cents={p.fundedCents} /> / <Money cents={p.totalCents} />
</option>
))}
</select>
</Field>
)}
{/* Amount + Date */}
<Field label="Amount (USD)">
<CurrencyInput value={amountStr} onValue={setAmountStr} />
</Field>
<Field label="When">
<input
className="input"
type="datetime-local"
value={occurredAt}
onChange={(e) => setOccurredAt(e.target.value)}
/>
</Field>
{/* Guard + submit */}
{categoryBlocked && (
<div className="toast-err">
Selected category has no available balance. Add income or pick another category.
</div>
)}
<Button disabled={m.isPending || !canSubmit}>
{m.isPending ? "Saving…" : "Submit"}
</Button>
{m.error && <div className="toast-err"> {(m.error as any).message}</div>}
</form>
</div>
);
}

View File

@@ -0,0 +1,183 @@
import { useEffect, useMemo, useState, type ChangeEvent } from "react";
import { useSearchParams } from "react-router-dom";
import { useTransactionsQuery } from "../hooks/useTransactionsQuery";
import { Money } from "../components/ui";
import Pagination from "../components/Pagination";
type Kind = "all" | "variable_spend" | "fixed_payment";
function isoDateOnly(d: Date) {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const da = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${da}`;
}
export default function TransactionsPage() {
const today = isoDateOnly(new Date());
const [sp, setSp] = useSearchParams();
// init from URL
const initKind = (sp.get("kind") as Kind) || "all";
const initQ = sp.get("q") || "";
const initFrom = sp.get("from") || "";
const initTo = sp.get("to") || today;
const initPage = Math.max(1, Number(sp.get("page") || 1));
const [kind, setKind] = useState<Kind>(initKind);
const [qRaw, setQRaw] = useState(initQ);
const [q, setQ] = useState(initQ.trim());
const [from, setFrom] = useState(initFrom);
const [to, setTo] = useState(initTo);
const [page, setPage] = useState(initPage);
const limit = 20;
// debounce search
useEffect(() => {
const id = setTimeout(() => {
setPage(1);
setQ(qRaw.trim());
}, 250);
return () => clearTimeout(id);
}, [qRaw]);
// write to URL on change
useEffect(() => {
const next = new URLSearchParams();
if (kind !== "all") next.set("kind", kind);
if (q) next.set("q", q);
if (from) next.set("from", from);
if (to) next.set("to", to);
if (page !== 1) next.set("page", String(page));
setSp(next, { replace: true });
}, [kind, q, from, to, page, setSp]);
const params = useMemo(
() => ({
page,
limit,
q: q || undefined,
from: from || undefined,
to: to || undefined,
kind: kind === "all" ? undefined : kind,
}),
[page, limit, q, from, to, kind]
);
const { data, isLoading, error, refetch, isFetching } = useTransactionsQuery(params);
const clear = () => {
setKind("all");
setQRaw("");
setFrom("");
setTo(today);
setPage(1);
};
const rows = data?.items ?? [];
const totalAmount = rows.reduce((s, r) => s + (r.amountCents ?? 0), 0);
return (
<div className="grid gap-4">
<section className="card">
<h2 className="section-title">Transactions</h2>
{/* Filters */}
<div className="row gap-2 flex-wrap mb-3">
<select
className="input w-44"
value={kind}
onChange={(e: ChangeEvent<HTMLSelectElement>) => {
setKind(e.target.value as Kind);
setPage(1);
}}
>
<option value="all">All Types</option>
<option value="variable_spend">Variable Spend</option>
<option value="fixed_payment">Fixed Payment</option>
</select>
<input
className="input w-56"
placeholder="Search…"
value={qRaw}
onChange={(e) => setQRaw(e.target.value)}
/>
<input
className="input w-40"
type="date"
value={from}
onChange={(e) => {
setFrom(e.target.value);
setPage(1);
}}
/>
<input
className="input w-40"
type="date"
value={to}
onChange={(e) => {
setTo(e.target.value);
setPage(1);
}}
/>
<button type="button" className="btn" onClick={clear}>
Clear
</button>
<div className="badge ml-auto">
{isFetching ? "Refreshing…" : `Showing ${rows.length}`}
</div>
</div>
{/* States */}
{isLoading && <div className="muted text-sm">Loading</div>}
{error && !isLoading && (
<div className="toast-err mb-3">
Couldnt load transactions.{" "}
<button className="btn ml-2" onClick={() => refetch()} disabled={isFetching}>
{isFetching ? "Retrying…" : "Retry"}
</button>
</div>
)}
{/* Table */}
{!isLoading && rows.length === 0 ? (
<div className="muted text-sm">No transactions match your filters.</div>
) : (
<>
<table className="table">
<thead>
<tr>
<th>Type</th>
<th>Amount</th>
<th>Date</th>
</tr>
</thead>
<tbody>
{rows.map((t) => (
<tr key={t.id}>
<td className="rounded-l-[--radius-xl] px-3 py-2">{t.kind}</td>
<td className="px-3 py-2">
<Money cents={t.amountCents} />
</td>
<td className="rounded-r-[--radius-xl] px-3 py-2">
{new Date(t.occurredAt).toLocaleString()}
</td>
</tr>
))}
</tbody>
</table>
<div className="row mt-2">
<div className="muted text-sm">Page total</div>
<div className="ml-auto font-semibold">
<Money cents={totalAmount} />
</div>
</div>
{data && (
<Pagination page={data.page} limit={data.limit} total={data.total} onPage={setPage} />
)}
</>
)}
</section>
</div>
);
}

View File

@@ -0,0 +1,179 @@
import { useMemo, useState, type ChangeEvent, type FormEvent } from "react";
import { useDashboard } from "../../hooks/useDashboard";
import { Money } from "../../components/ui";
import SettingsNav from "./_SettingsNav";
import { useCategories, useCreateCategory, useUpdateCategory, useDeleteCategory } from "../../hooks/useCategories";
import { useToast } from "../../components/Toast";
type Row = { id: number; name: string; percent: number; priority: number; isSavings: boolean; balanceCents: number };
function SumBadge({ total }: { total: number }) {
const ok = total === 100;
return (
<div className={`badge ${ok ? "" : ""}`}>
Total: {total}%
</div>
);
}
export default function SettingsCategoriesPage() {
const { data, isLoading, error, refetch, isFetching } = useDashboard();
const cats: Row[] = useCategories();
const createM = useCreateCategory();
const updateM = useUpdateCategory();
const deleteM = useDeleteCategory();
const { push } = useToast();
const total = useMemo(() => cats.reduce((s, c) => s + c.percent, 0), [cats]);
// Add form state
const [name, setName] = useState("");
const [percent, setPercent] = useState("");
const [priority, setPriority] = useState("");
const [isSavings, setIsSavings] = useState(false);
const addDisabled = !name || !percent || !priority || Number(percent) < 0 || Number(percent) > 100;
const onAdd = (e: FormEvent) => {
e.preventDefault();
const body = {
name: name.trim(),
percent: Math.max(0, Math.min(100, Math.floor(Number(percent) || 0))),
priority: Math.max(0, Math.floor(Number(priority) || 0)),
isSavings
};
createM.mutate(body, {
onSuccess: () => {
push("ok", "Category created");
setName(""); setPercent(""); setPriority(""); setIsSavings(false);
},
onError: (err: any) => push("err", err?.message ?? "Create failed")
});
};
const onEdit = (id: number, patch: Partial<Row>) => {
updateM.mutate({ id, body: patch }, {
onError: (err: any) => push("err", err?.message ?? "Update failed")
});
};
const onDelete = (id: number) => {
deleteM.mutate(id, {
onSuccess: () => push("ok", "Category deleted"),
onError: (err: any) => push("err", err?.message ?? "Delete failed")
});
};
if (isLoading) return <div className="card max-w-2xl"><SettingsNav/><div className="muted">Loading</div></div>;
if (error || !data) {
return (
<div className="card max-w-2xl">
<SettingsNav/>
<p className="mb-3">Couldnt load categories.</p>
<button className="btn" onClick={() => refetch()} disabled={isFetching}>{isFetching ? "Retrying…" : "Retry"}</button>
</div>
);
}
return (
<div className="grid gap-4 max-w-5xl">
<section className="card">
<SettingsNav/>
{/* Add form */}
<form onSubmit={onAdd} className="row gap-2 mb-4 flex-wrap">
<input className="input w-44" placeholder="Name" value={name} onChange={(e) => setName(e.target.value)} />
<input className="input w-28" placeholder="%"
type="number" min={0} max={100} value={percent} onChange={(e) => setPercent(e.target.value)} />
<input className="input w-28" placeholder="Priority"
type="number" min={0} value={priority} onChange={(e) => setPriority(e.target.value)} />
<label className="row">
<input type="checkbox" checked={isSavings} onChange={(e) => setIsSavings(e.target.checked)} />
<span className="muted text-sm">Savings</span>
</label>
<button className="btn" disabled={addDisabled || createM.isPending}>Add</button>
<div className="ml-auto"><SumBadge total={total} /></div>
</form>
{cats.length === 0 ? (
<div className="muted text-sm">No categories yet.</div>
) : (
<table className="table">
<thead><tr><th>Name</th><th>%</th><th>Priority</th><th>Savings</th><th>Balance</th><th></th></tr></thead>
<tbody>
{cats
.slice()
.sort((a, b) => (a.priority - b.priority) || a.name.localeCompare(b.name))
.map(c => (
<tr key={c.id}>
<td className="rounded-l-[--radius-xl] px-3 py-2">
<InlineEditText value={c.name} onChange={(v) => onEdit(c.id, { name: v })} />
</td>
<td className="px-3 py-2">
<InlineEditNumber value={c.percent} min={0} max={100} onChange={(v) => onEdit(c.id, { percent: v })} />
</td>
<td className="px-3 py-2">
<InlineEditNumber value={c.priority} min={0} onChange={(v) => onEdit(c.id, { priority: v })} />
</td>
<td className="px-3 py-2">
<InlineEditCheckbox checked={c.isSavings} onChange={(v) => onEdit(c.id, { isSavings: v })} />
</td>
<td className="px-3 py-2"><Money cents={c.balanceCents} /></td>
<td className="rounded-r-[--radius-xl] px-3 py-2">
<button className="btn" type="button" onClick={() => onDelete(c.id)} disabled={deleteM.isPending}>Delete</button>
</td>
</tr>
))}
</tbody>
</table>
)}
{/* Guard if total != 100 */}
{total !== 100 && (
<div className="toast-err mt-3">
Percents must sum to <strong>100%</strong> for allocations. Current total: {total}%.
</div>
)}
</section>
</div>
);
}
/* --- tiny inline editors --- */
function InlineEditText({ value, onChange }: { value: string; onChange: (v: string) => void }) {
const [v, setV] = useState(value);
const [editing, setEditing] = useState(false);
const commit = () => { if (v !== value) onChange(v.trim()); setEditing(false); };
return editing ? (
<input className="input" value={v} onChange={(e) => setV(e.target.value)}
onBlur={commit} onKeyDown={(e) => e.key === "Enter" && commit()} autoFocus />
) : (
<button type="button" className="link" onClick={() => setEditing(true)}>{value}</button>
);
}
function InlineEditNumber({ value, onChange, min=0, max=Number.MAX_SAFE_INTEGER }:
{ value: number; onChange: (v: number) => void; min?: number; max?: number; }) {
const [editing, setEditing] = useState(false);
const [v, setV] = useState(String(value));
const commit = () => {
const n = Math.max(min, Math.min(max, Math.floor(Number(v) || 0)));
if (n !== value) onChange(n);
setEditing(false);
};
return editing ? (
<input className="input w-24" value={v} onChange={(e) => setV(e.target.value)}
onBlur={commit} onKeyDown={(e) => e.key === "Enter" && commit()} autoFocus />
) : (
<button type="button" className="link" onClick={() => setEditing(true)}>{value}</button>
);
}
function InlineEditCheckbox({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
return (
<label className="row">
<input type="checkbox" checked={checked} onChange={(e) => onChange(e.target.checked)} />
<span className="muted text-sm">{checked ? "Yes" : "No"}</span>
</label>
);
}

View File

@@ -0,0 +1,218 @@
// web/src/pages/settings/PlansPage.tsx
import { useMemo, useState, type ChangeEvent, type FormEvent } from "react";
import { useDashboard } from "../../hooks/useDashboard";
import SettingsNav from "./_SettingsNav";
import { useCreatePlan, useUpdatePlan, useDeletePlan } from "../../hooks/useFixedPlans";
import { Money } from "../../components/ui";
import { useToast } from "../../components/Toast";
function isoDateLocal(d: Date = new Date()) {
const z = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()));
return z.toISOString().slice(0, 10);
}
function daysUntil(iso: string) {
const today = new Date(isoDateLocal());
const due = new Date(isoDateLocal(new Date(iso)));
const diffMs = due.getTime() - today.getTime();
return Math.round(diffMs / (24 * 60 * 60 * 1000));
}
function DueBadge({ dueISO }: { dueISO: string }) {
const d = daysUntil(dueISO);
if (d < 0) return <span className="badge" style={{ borderColor: "#7f1d1d" }}>Overdue</span>;
if (d <= 7) return <span className="badge">Due in {d}d</span>;
return <span className="badge" aria-hidden="true">On track</span>;
}
export default function SettingsPlansPage() {
const { data, isLoading, error, refetch, isFetching } = useDashboard();
const createM = useCreatePlan();
const updateM = useUpdatePlan();
const deleteM = useDeletePlan();
const { push } = useToast();
// Add form state
const [name, setName] = useState("");
const [total, setTotal] = useState("");
const [funded, setFunded] = useState("");
const [priority, setPriority] = useState("");
const [due, setDue] = useState(isoDateLocal());
const totals = useMemo(() => {
if (!data) return { funded: 0, total: 0, remaining: 0 };
const funded = data.fixedPlans.reduce((s, p) => s + p.fundedCents, 0);
const total = data.fixedPlans.reduce((s, p) => s + p.totalCents, 0);
return { funded, total, remaining: Math.max(0, total - funded) };
}, [data]);
if (isLoading) return <div className="card max-w-3xl"><SettingsNav/><div className="muted">Loading</div></div>;
if (error || !data) {
return (
<div className="card max-w-3xl">
<SettingsNav/>
<p className="mb-3">Couldnt load fixed plans.</p>
<button className="btn" onClick={() => refetch()} disabled={isFetching}>{isFetching ? "Retrying…" : "Retry"}</button>
</div>
);
}
const onAdd = (e: FormEvent) => {
e.preventDefault();
const totalCents = Math.max(0, Math.round((parseFloat(total || "0")) * 100));
const fundedCents = Math.max(0, Math.round((parseFloat(funded || "0")) * 100));
const body = {
name: name.trim(),
totalCents,
fundedCents: Math.min(fundedCents, totalCents),
priority: Math.max(0, Math.floor(Number(priority) || 0)),
dueOn: new Date(due).toISOString(),
};
if (!body.name || totalCents <= 0) return;
createM.mutate(body, {
onSuccess: () => {
push("ok", "Plan created");
setName(""); setTotal(""); setFunded(""); setPriority(""); setDue(isoDateLocal());
},
onError: (err: any) => push("err", err?.message ?? "Create failed"),
});
};
const onEdit = (id: number, patch: Partial<{ name: string; totalCents: number; fundedCents: number; priority: number; dueOn: string }>) => {
if ("totalCents" in patch && "fundedCents" in patch && (patch.totalCents ?? 0) < (patch.fundedCents ?? 0)) {
patch.fundedCents = patch.totalCents;
}
updateM.mutate({ id, body: patch }, {
onSuccess: () => push("ok", "Plan updated"),
onError: (err: any) => push("err", err?.message ?? "Update failed"),
});
};
const onDelete = (id: number) => {
deleteM.mutate(id, {
onSuccess: () => push("ok", "Plan deleted"),
onError: (err: any) => push("err", err?.message ?? "Delete failed"),
});
};
return (
<div className="grid gap-4 max-w-5xl">
<section className="card">
<SettingsNav/>
{/* KPI strip */}
<div className="grid gap-2 sm:grid-cols-3 mb-4">
<div className="card kpi"><h3>Funded</h3><div className="val"><Money cents={totals.funded} /></div></div>
<div className="card kpi"><h3>Total</h3><div className="val"><Money cents={totals.total} /></div></div>
<div className="card kpi"><h3>Remaining</h3><div className="val"><Money cents={totals.remaining} /></div></div>
</div>
{/* Add form */}
<form onSubmit={onAdd} className="row gap-2 mb-4 flex-wrap">
<input className="input w-48" placeholder="Name" value={name} onChange={(e) => setName(e.target.value)} />
<input className="input w-28" placeholder="Total $" type="number" min={0} step="0.01" value={total} onChange={(e) => setTotal(e.target.value)} />
<input className="input w-28" placeholder="Funded $" type="number" min={0} step="0.01" value={funded} onChange={(e) => setFunded(e.target.value)} />
<input className="input w-24" placeholder="Priority" type="number" min={0} value={priority} onChange={(e) => setPriority(e.target.value)} />
<input className="input w-40" type="date" value={due} onChange={(e) => setDue(e.target.value)} />
<button className="btn" disabled={!name || !total || createM.isPending}>Add</button>
</form>
{/* Table */}
{data.fixedPlans.length === 0 ? (
<div className="muted text-sm">No fixed plans yet.</div>
) : (
<table className="table">
<thead>
<tr>
<th>Name</th><th>Due</th><th>Priority</th>
<th>Funded</th><th>Total</th><th>Remaining</th><th>Status</th><th></th>
</tr>
</thead>
<tbody>
{data.fixedPlans
.slice()
.sort((a, b) => (a.priority - b.priority) || (new Date(a.dueOn).getTime() - new Date(b.dueOn).getTime()))
.map(p => {
const remaining = Math.max(0, p.totalCents - p.fundedCents);
return (
<tr key={p.id}>
<td className="rounded-l-[--radius-xl] px-3 py-2">
<InlineEditText value={p.name} onChange={(v) => onEdit(p.id, { name: v })} />
</td>
<td className="px-3 py-2">
<InlineEditDate value={p.dueOn} onChange={(iso) => onEdit(p.id, { dueOn: iso })} />
</td>
<td className="px-3 py-2">
<InlineEditNumber value={p.priority} min={0} onChange={(n) => onEdit(p.id, { priority: n })} />
</td>
<td className="px-3 py-2">
<InlineEditMoney
valueCents={p.fundedCents}
onChange={(cents) => onEdit(p.id, { fundedCents: Math.max(0, Math.min(cents, p.totalCents)) })}
/>
</td>
<td className="px-3 py-2">
<InlineEditMoney
valueCents={p.totalCents}
onChange={(cents) => onEdit(p.id, { totalCents: Math.max(cents, 0), fundedCents: Math.min(p.fundedCents, cents) })}
/>
</td>
<td className="px-3 py-2"><Money cents={remaining} /></td>
<td className="px-3 py-2"><DueBadge dueISO={p.dueOn} /></td>
<td className="rounded-r-[--radius-xl] px-3 py-2">
<button className="btn" type="button" onClick={() => onDelete(p.id)} disabled={deleteM.isPending}>Delete</button>
</td>
</tr>
);
})}
</tbody>
</table>
)}
</section>
</div>
);
}
/* --- Inline editors (minimal) --- */
function InlineEditText({ value, onChange }: { value: string; onChange: (v: string) => void }) {
const [editing, setEditing] = useState(false);
const [v, setV] = useState(value);
const commit = () => { const t = v.trim(); if (t && t !== value) onChange(t); setEditing(false); };
return editing ? (
<input className="input" value={v} onChange={(e) => setV(e.target.value)} onBlur={commit} onKeyDown={(e) => e.key === "Enter" && commit()} autoFocus />
) : <button type="button" className="link" onClick={() => setEditing(true)}>{value}</button>;
}
function InlineEditNumber({ value, onChange, min=0, max=Number.MAX_SAFE_INTEGER }:
{ value: number; onChange: (v: number) => void; min?: number; max?: number }) {
const [editing, setEditing] = useState(false);
const [v, setV] = useState(String(value));
const commit = () => { const n = Math.max(min, Math.min(max, Math.floor(Number(v) || 0))); if (n !== value) onChange(n); setEditing(false); };
return editing ? (
<input className="input w-24" value={v} onChange={(e) => setV(e.target.value)} onBlur={commit} onKeyDown={(e) => e.key === "Enter" && commit()} autoFocus />
) : <button type="button" className="link" onClick={() => setEditing(true)}>{value}</button>;
}
function InlineEditMoney({ valueCents, onChange }: { valueCents: number; onChange: (cents: number) => void }) {
const [editing, setEditing] = useState(false);
const [v, setV] = useState((valueCents / 100).toFixed(2));
const commit = () => {
const cents = Math.max(0, Math.round((parseFloat(v || "0")) * 100));
if (cents !== valueCents) onChange(cents);
setEditing(false);
};
return editing ? (
<input className="input w-28" type="number" step="0.01" min={0} value={v} onChange={(e) => setV(e.target.value)}
onBlur={commit} onKeyDown={(e) => e.key === "Enter" && commit()} autoFocus />
) : <button type="button" className="link" onClick={() => setEditing(true)}>{(valueCents/100).toFixed(2)}</button>;
}
function InlineEditDate({ value, onChange }: { value: string; onChange: (iso: string) => void }) {
const [editing, setEditing] = useState(false);
const local = new Date(value);
const [v, setV] = useState(local.toISOString().slice(0, 10));
const commit = () => { const iso = new Date(v + "T00:00:00Z").toISOString(); if (iso !== value) onChange(iso); setEditing(false); };
return editing ? (
<input className="input w-40" type="date" value={v} onChange={(e) => setV(e.target.value)} onBlur={commit} onKeyDown={(e) => e.key === "Enter" && commit()} autoFocus />
) : <button type="button" className="link" onClick={() => setEditing(true)}>{new Date(value).toLocaleDateString()}</button>;
}

View File

@@ -0,0 +1,14 @@
import { NavLink } from "react-router-dom";
export default function SettingsNav() {
const link = (to: string, label: string) =>
<NavLink to={to} className={({isActive}) => `link ${isActive ? "link-active" : ""}`}>{label}</NavLink>;
return (
<div className="row mb-3">
<h2 className="section-title m-0">Settings</h2>
<div className="ml-auto flex gap-1">
{link("/settings/categories", "Categories")}
{link("/settings/plans", "Fixed Plans")}
</div>
</div>
);
}