224 lines
8.6 KiB
TypeScript
224 lines
8.6 KiB
TypeScript
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>
|
||
);
|
||
}
|