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>
|
||||
);
|
||||
}
|
||||
18
web/src/pages/HealthPage.tsx
Normal file
18
web/src/pages/HealthPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
223
web/src/pages/IncomePage.tsx
Normal file
223
web/src/pages/IncomePage.tsx
Normal 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
168
web/src/pages/SpendPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
183
web/src/pages/TransactionsPage.tsx
Normal file
183
web/src/pages/TransactionsPage.tsx
Normal 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">
|
||||
Couldn’t 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>
|
||||
);
|
||||
}
|
||||
179
web/src/pages/settings/CategoriesPage.tsx
Normal file
179
web/src/pages/settings/CategoriesPage.tsx
Normal 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">Couldn’t 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>
|
||||
);
|
||||
}
|
||||
218
web/src/pages/settings/PlansPage.tsx
Normal file
218
web/src/pages/settings/PlansPage.tsx
Normal 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">Couldn’t 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>;
|
||||
}
|
||||
14
web/src/pages/settings/_SettingsNav.tsx
Normal file
14
web/src/pages/settings/_SettingsNav.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user