test run for user rebalance expenses feature, added safeguard for estimate exepenses acceptance in onboarding
This commit is contained in:
@@ -432,7 +432,6 @@ export default function OnboardingPage() {
|
||||
const name = f.name.trim();
|
||||
if (!name) continue; // skip empties just in case
|
||||
try {
|
||||
const amountMode = f.amountMode ?? "fixed";
|
||||
const planAmountCents = Math.max(0, f.amountCents || 0);
|
||||
const schedule = f.autoPayEnabled
|
||||
? {
|
||||
@@ -451,8 +450,6 @@ export default function OnboardingPage() {
|
||||
name,
|
||||
totalCents: planAmountCents,
|
||||
fundedCents: 0,
|
||||
amountMode,
|
||||
estimatedCents: amountMode === "estimated" ? planAmountCents : null,
|
||||
priority: i + 1,
|
||||
dueOn: dueOnISO,
|
||||
frequency: f.frequency,
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
type FormEvent,
|
||||
} from "react";
|
||||
import type React from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { DndContext, closestCenter, type DragEndEvent } from "@dnd-kit/core";
|
||||
import {
|
||||
SortableContext,
|
||||
@@ -412,9 +413,14 @@ function CategoriesSettingsInner(
|
||||
<header className="space-y-1">
|
||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||
<h2 className="text-lg font-semibold">Expense Categories</h2>
|
||||
{hasChanges && (
|
||||
<span className="text-xs text-amber-400">Unsaved changes</span>
|
||||
)}
|
||||
<div className="flex items-center gap-3">
|
||||
<Link className="btn" to="/settings/rebalance">
|
||||
Rebalance pool
|
||||
</Link>
|
||||
{hasChanges && (
|
||||
<span className="text-xs text-amber-400">Unsaved changes</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm muted">
|
||||
Decide how every dollar is divided. Percentages must always add up to
|
||||
|
||||
@@ -449,11 +449,6 @@ const PlansSettings = forwardRef<PlansSettingsHandle, PlansSettingsProps>(
|
||||
name: plan.name,
|
||||
totalCents: plan.totalCents,
|
||||
fundedCents: plan.fundedCents ?? 0,
|
||||
amountMode: plan.amountMode ?? "fixed",
|
||||
estimatedCents:
|
||||
(plan.amountMode ?? "fixed") === "estimated"
|
||||
? (plan.estimatedCents ?? plan.totalCents)
|
||||
: null,
|
||||
priority: plan.priority,
|
||||
dueOn: plan.dueOn,
|
||||
frequency: plan.frequency,
|
||||
@@ -502,10 +497,6 @@ const PlansSettings = forwardRef<PlansSettingsHandle, PlansSettingsProps>(
|
||||
patch.frequency = local.frequency;
|
||||
if ((local.nextPaymentDate ?? null) !== (server.nextPaymentDate ?? null))
|
||||
patch.nextPaymentDate = local.nextPaymentDate ?? null;
|
||||
if ((local.amountMode ?? "fixed") !== (server.amountMode ?? "fixed"))
|
||||
patch.amountMode = local.amountMode ?? "fixed";
|
||||
if ((local.estimatedCents ?? null) !== (server.estimatedCents ?? null))
|
||||
patch.estimatedCents = local.estimatedCents ?? 0;
|
||||
|
||||
if (Object.keys(patch).length > 0) {
|
||||
await fixedPlansApi.update(local.id, patch);
|
||||
|
||||
245
web/src/pages/settings/RebalancePage.tsx
Normal file
245
web/src/pages/settings/RebalancePage.tsx
Normal file
@@ -0,0 +1,245 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { rebalanceApi, type RebalanceCategory } from "../../api/rebalance";
|
||||
import CurrencyInput from "../../components/CurrencyInput";
|
||||
import { useToast } from "../../components/Toast";
|
||||
|
||||
function sum(values: number[]) {
|
||||
return values.reduce((s, v) => s + (Number.isFinite(v) ? v : 0), 0);
|
||||
}
|
||||
|
||||
export default function RebalancePage() {
|
||||
const { push } = useToast();
|
||||
const qc = useQueryClient();
|
||||
const { data, isLoading, refetch } = useQuery({
|
||||
queryKey: ["rebalance", "info"],
|
||||
queryFn: rebalanceApi.fetchInfo,
|
||||
});
|
||||
|
||||
const [rows, setRows] = useState<Array<RebalanceCategory & { targetCents: number }>>([]);
|
||||
const [forceSavings, setForceSavings] = useState(false);
|
||||
const [adjustId, setAdjustId] = useState<string>("");
|
||||
const [adjustValue, setAdjustValue] = useState<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.categories) {
|
||||
setRows(data.categories.map((c) => ({ ...c, targetCents: c.balanceCents })));
|
||||
setAdjustId(data.categories[0]?.id ?? "");
|
||||
}
|
||||
}, [data?.categories]);
|
||||
|
||||
const available = data?.availableCents ?? 0;
|
||||
const total = useMemo(() => sum(rows.map((r) => r.targetCents)), [rows]);
|
||||
const savingsTotal = useMemo(
|
||||
() => sum(rows.filter((r) => r.isSavings).map((r) => r.targetCents)),
|
||||
[rows]
|
||||
);
|
||||
const savingsBefore = useMemo(
|
||||
() => sum(rows.filter((r) => r.isSavings).map((r) => r.balanceCents)),
|
||||
[rows]
|
||||
);
|
||||
|
||||
const savingsFloor = Math.floor(available * 0.2);
|
||||
const maxSingle = Math.floor(available * 0.8);
|
||||
|
||||
const errors: string[] = [];
|
||||
if (rows.some((r) => r.targetCents < 0)) errors.push("No category can be negative.");
|
||||
if (available > 0 && rows.some((r) => r.targetCents > maxSingle))
|
||||
errors.push("No category can exceed 80% of available.");
|
||||
if (total !== available) errors.push(`Totals must equal available ($${(available / 100).toFixed(2)}).`);
|
||||
if ((savingsTotal < savingsBefore || savingsTotal < savingsFloor) && !forceSavings)
|
||||
errors.push("Lowering savings requires confirmation.");
|
||||
|
||||
const canSubmit = errors.length === 0;
|
||||
|
||||
const applyAdjustOne = () => {
|
||||
if (!adjustId) return;
|
||||
const desired = Math.round(Number(parseFloat(adjustValue || "0") * 100));
|
||||
if (!Number.isFinite(desired) || desired < 0) {
|
||||
push("err", "Enter a valid amount");
|
||||
return;
|
||||
}
|
||||
if (desired > available) {
|
||||
push("err", "Amount exceeds available budget");
|
||||
return;
|
||||
}
|
||||
const others = rows.filter((r) => r.id !== adjustId);
|
||||
if (others.length === 0) return;
|
||||
const remaining = available - desired;
|
||||
if (remaining < 0) {
|
||||
push("err", "Amount exceeds available budget");
|
||||
return;
|
||||
}
|
||||
const baseSum = sum(others.map((o) => o.targetCents)) || others.length;
|
||||
const next = rows.map((r) => ({ ...r }));
|
||||
let distributed = 0;
|
||||
others.forEach((o) => {
|
||||
const share = Math.floor((remaining * (o.targetCents || 1)) / baseSum);
|
||||
const idx = next.findIndex((n) => n.id === o.id);
|
||||
next[idx].targetCents = share;
|
||||
distributed += share;
|
||||
});
|
||||
const leftover = remaining - distributed;
|
||||
if (leftover > 0) {
|
||||
const firstIdx = next.findIndex((n) => n.id !== adjustId);
|
||||
if (firstIdx >= 0) next[firstIdx].targetCents += leftover;
|
||||
}
|
||||
const targetIdx = next.findIndex((n) => n.id === adjustId);
|
||||
next[targetIdx].targetCents = desired;
|
||||
setRows(next);
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
if (!canSubmit) {
|
||||
push("err", errors[0] ?? "Fix validation errors first");
|
||||
return;
|
||||
}
|
||||
const payload = {
|
||||
targets: rows.map((r) => ({ id: r.id, targetCents: r.targetCents })),
|
||||
forceLowerSavings: forceSavings,
|
||||
};
|
||||
try {
|
||||
await rebalanceApi.submit(payload);
|
||||
push("ok", "Rebalance applied");
|
||||
setForceSavings(false);
|
||||
setAdjustValue("");
|
||||
await qc.invalidateQueries({ queryKey: ["rebalance", "info"] });
|
||||
await refetch();
|
||||
} catch (err: any) {
|
||||
const msg = err?.body?.message || err?.message || "Rebalance failed";
|
||||
push("err", msg);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading || !data) return <div className="muted">Loading…</div>;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<header className="space-y-1">
|
||||
<h2 className="text-lg font-semibold">Rebalance variable pool</h2>
|
||||
<p className="text-sm muted">
|
||||
Redistribute your current variable pool without changing future income percentages.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="card p-4 flex flex-wrap gap-4 items-center justify-between">
|
||||
<div className="text-sm">
|
||||
<div className="font-semibold">Available</div>
|
||||
<div className="text-xl font-mono">${(available / 100).toFixed(2)}</div>
|
||||
</div>
|
||||
<div className="text-sm">
|
||||
<div>Totals</div>
|
||||
<div className="font-mono">
|
||||
${(total / 100).toFixed(2)} / ${(available / 100).toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm">
|
||||
<div>Savings total</div>
|
||||
<div className="font-mono">${(savingsTotal / 100).toFixed(2)}</div>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={forceSavings}
|
||||
onChange={(e) => setForceSavings(e.target.checked)}
|
||||
/>
|
||||
<span>Confirm lowering savings / below 20% floor</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="card p-4 space-y-3">
|
||||
<div className="flex flex-wrap gap-2 items-end">
|
||||
<div className="flex flex-col">
|
||||
<label className="text-xs muted">Adjust one category</label>
|
||||
<select
|
||||
className="input"
|
||||
value={adjustId}
|
||||
onChange={(e) => setAdjustId(e.target.value)}
|
||||
>
|
||||
{rows.map((r) => (
|
||||
<option key={r.id} value={r.id}>
|
||||
{r.name} {r.isSavings ? "(Savings)" : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<label className="text-xs muted">Amount</label>
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
min={0}
|
||||
step="0.01"
|
||||
value={adjustValue}
|
||||
onChange={(e) => setAdjustValue(e.target.value)}
|
||||
placeholder="0.00"
|
||||
/>
|
||||
</div>
|
||||
<button className="btn" type="button" onClick={applyAdjustOne}>
|
||||
Apply helper
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="overflow-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-left">
|
||||
<th className="py-2">Category</th>
|
||||
<th className="py-2">Current</th>
|
||||
<th className="py-2">Percent</th>
|
||||
<th className="py-2">Target</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => (
|
||||
<tr key={row.id} className="border-t border-[--color-panel]">
|
||||
<td className="py-2 font-medium">
|
||||
{row.name} {row.isSavings ? <span className="text-xs text-emerald-500">Savings</span> : null}
|
||||
</td>
|
||||
<td className="py-2 font-mono">${(row.balanceCents / 100).toFixed(2)}</td>
|
||||
<td className="py-2">{row.percent}%</td>
|
||||
<td className="py-2">
|
||||
<CurrencyInput
|
||||
className="w-32"
|
||||
valueCents={row.targetCents}
|
||||
onChange={(cents) =>
|
||||
setRows((prev) =>
|
||||
prev.map((r) => (r.id === row.id ? { ...r, targetCents: cents } : r))
|
||||
)
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{errors.length > 0 && (
|
||||
<div className="toast-err space-y-1">
|
||||
{errors.map((err) => (
|
||||
<div key={err}>{err}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button className="btn primary" type="button" onClick={submit} disabled={!canSubmit}>
|
||||
Apply rebalance
|
||||
</button>
|
||||
<button
|
||||
className="btn"
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
setForceSavings(false);
|
||||
setAdjustValue("");
|
||||
await refetch();
|
||||
}}
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -10,8 +10,9 @@ import PlansSettings, { type PlansSettingsHandle } from "./PlansSettings";
|
||||
import AccountSettings from "./AccountSettings";
|
||||
import ThemeSettings from "./ThemeSettings";
|
||||
import ReconcileSettings from "./ReconcileSettings";
|
||||
import RebalancePage from "./RebalancePage";
|
||||
|
||||
type Tab = "categories" | "plans" | "account" | "theme" | "reconcile";
|
||||
type Tab = "categories" | "rebalance" | "plans" | "account" | "theme" | "reconcile";
|
||||
|
||||
export default function SettingsPage() {
|
||||
const location = useLocation();
|
||||
@@ -21,6 +22,7 @@ export default function SettingsPage() {
|
||||
if (location.pathname.includes("/settings/account")) return "account";
|
||||
if (location.pathname.includes("/settings/theme")) return "theme";
|
||||
if (location.pathname.includes("/settings/reconcile")) return "reconcile";
|
||||
if (location.pathname.includes("/settings/rebalance")) return "rebalance";
|
||||
return "categories";
|
||||
};
|
||||
|
||||
@@ -66,6 +68,7 @@ export default function SettingsPage() {
|
||||
|
||||
const tabs = [
|
||||
{ id: "categories" as const, label: "Expenses" },
|
||||
{ id: "rebalance" as const, label: "Rebalance" },
|
||||
{ id: "plans" as const, label: "Fixed Expenses" },
|
||||
{ id: "account" as const, label: "Account" },
|
||||
{ id: "theme" as const, label: "Theme" },
|
||||
@@ -135,6 +138,8 @@ export default function SettingsPage() {
|
||||
onDirtyChange={setIsDirty}
|
||||
/>
|
||||
);
|
||||
case "rebalance":
|
||||
return <RebalancePage />;
|
||||
case "plans":
|
||||
return (
|
||||
<PlansSettings ref={plansRef} onDirtyChange={setIsDirty} />
|
||||
|
||||
Reference in New Issue
Block a user