Files
SkyMoney/web/src/pages/IncomePage.tsx

224 lines
8.6 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}