more ui fix for rebalance, and safegaurd as well.
This commit is contained in:
@@ -3355,6 +3355,7 @@ const ManualRebalanceBody = z.object({
|
||||
})
|
||||
),
|
||||
forceLowerSavings: z.boolean().optional(),
|
||||
confirmOver80: z.boolean().optional(),
|
||||
});
|
||||
|
||||
async function assertPercentTotal(
|
||||
@@ -3606,8 +3607,13 @@ app.post("/variable-categories/manual-rebalance", async (req, reply) => {
|
||||
.send({ ok: false, code: "SUM_MISMATCH", message: `Targets must sum to available (${availableCents}).` });
|
||||
}
|
||||
const maxAllowed = Math.floor(availableCents * 0.8);
|
||||
if (availableCents > 0 && targets.some((t) => t.target > maxAllowed)) {
|
||||
return reply.code(400).send({ ok: false, code: "OVER_80_PERCENT", message: "No category can exceed 80% of available." });
|
||||
const over80 = availableCents > 0 && targets.some((t) => t.target > maxAllowed);
|
||||
if (over80 && !parsed.data.confirmOver80) {
|
||||
return reply.code(400).send({
|
||||
ok: false,
|
||||
code: "OVER_80_CONFIRM_REQUIRED",
|
||||
message: "A category exceeds 80% of available. Confirm to proceed.",
|
||||
});
|
||||
}
|
||||
const totalSavingsBefore = targets.filter((t) => t.isSavings).reduce((s, t) => s + t.currentBalance, 0);
|
||||
const totalSavingsAfter = targets.filter((t) => t.isSavings).reduce((s, t) => s + t.target, 0);
|
||||
|
||||
@@ -17,6 +17,7 @@ export type RebalanceInfo = {
|
||||
export type ManualRebalanceBody = {
|
||||
targets: Array<{ id: string; targetCents: number }>;
|
||||
forceLowerSavings?: boolean;
|
||||
confirmOver80?: boolean;
|
||||
};
|
||||
|
||||
export const rebalanceApi = {
|
||||
|
||||
@@ -8,6 +8,38 @@ function sum(values: number[]) {
|
||||
return values.reduce((s, v) => s + (Number.isFinite(v) ? v : 0), 0);
|
||||
}
|
||||
|
||||
function normalizeTargetsToAvailable(
|
||||
rows: Array<RebalanceCategory & { targetCents: number }>,
|
||||
available: number
|
||||
) {
|
||||
const next = rows.map((r) => ({ ...r, targetCents: Math.max(0, Math.floor(r.targetCents || 0)) }));
|
||||
const total = sum(next.map((r) => r.targetCents));
|
||||
const diff = available - total;
|
||||
if (diff === 0) return next;
|
||||
|
||||
if (diff > 0) {
|
||||
const idx = next
|
||||
.map((r, i) => ({ i, p: r.percent, t: r.targetCents }))
|
||||
.sort((a, b) => b.p - a.p || b.t - a.t)[0]?.i;
|
||||
if (idx === undefined) return null;
|
||||
next[idx].targetCents += diff;
|
||||
return next;
|
||||
}
|
||||
|
||||
let needed = -diff;
|
||||
const order = next
|
||||
.map((r, i) => ({ i, t: r.targetCents }))
|
||||
.sort((a, b) => b.t - a.t);
|
||||
for (const o of order) {
|
||||
if (needed <= 0) break;
|
||||
const take = Math.min(next[o.i].targetCents, needed);
|
||||
next[o.i].targetCents -= take;
|
||||
needed -= take;
|
||||
}
|
||||
if (needed > 0) return null;
|
||||
return next;
|
||||
}
|
||||
|
||||
export default function RebalancePage() {
|
||||
const { push } = useToast();
|
||||
const qc = useQueryClient();
|
||||
@@ -17,9 +49,9 @@ export default function RebalancePage() {
|
||||
});
|
||||
|
||||
const [rows, setRows] = useState<Array<RebalanceCategory & { targetCents: number }>>([]);
|
||||
const [forceSavings, setForceSavings] = useState(false);
|
||||
const [adjustId, setAdjustId] = useState<string>("");
|
||||
const [adjustValue, setAdjustValue] = useState<string>("");
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.categories) {
|
||||
@@ -42,16 +74,19 @@ export default function RebalancePage() {
|
||||
const savingsFloor = Math.floor(available * 0.2);
|
||||
const maxSingle = Math.floor(available * 0.8);
|
||||
|
||||
const hasNegative = rows.some((r) => r.targetCents < 0);
|
||||
const over80Crossed = available > 0 && rows.some((r) => r.targetCents > maxSingle);
|
||||
const sumMismatch = total !== available;
|
||||
const savingsCrossed = savingsTotal < savingsBefore || savingsTotal < savingsFloor;
|
||||
|
||||
const errors: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
if (rows.some((r) => r.targetCents < 0)) errors.push("No category can be negative.");
|
||||
if (available > 0 && rows.some((r) => r.targetCents > maxSingle))
|
||||
warnings.push("A category exceeds 80% of available.");
|
||||
if (total !== available) warnings.push(`Totals differ from available ($${(available / 100).toFixed(2)}).`);
|
||||
if ((savingsTotal < savingsBefore || savingsTotal < savingsFloor) && !forceSavings)
|
||||
errors.push("Confirm lowering savings / below 20% floor.");
|
||||
if (hasNegative) errors.push("No category can be negative.");
|
||||
if (over80Crossed) warnings.push("A category exceeds 80% of available.");
|
||||
if (sumMismatch) warnings.push(`Totals differ from available ($${(available / 100).toFixed(2)}).`);
|
||||
if (savingsCrossed) warnings.push("Savings decreased and/or fell below the 20% floor.");
|
||||
|
||||
const canSubmit = errors.length === 0;
|
||||
const canSubmit = !hasNegative;
|
||||
|
||||
const applyAdjustOne = () => {
|
||||
if (!adjustId) return;
|
||||
@@ -101,22 +136,62 @@ export default function RebalancePage() {
|
||||
setRows(next);
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
if (!canSubmit) {
|
||||
push("err", errors[0] ?? "Fix validation errors first");
|
||||
return;
|
||||
}
|
||||
const submitRebalance = async (
|
||||
sourceRows: Array<RebalanceCategory & { targetCents: number }>,
|
||||
opts?: { forceLowerSavings?: boolean; confirmOver80?: boolean }
|
||||
) => {
|
||||
const payload = {
|
||||
targets: rows.map((r) => ({ id: r.id, targetCents: r.targetCents })),
|
||||
forceLowerSavings: forceSavings,
|
||||
targets: sourceRows.map((r) => ({ id: r.id, targetCents: r.targetCents })),
|
||||
forceLowerSavings: opts?.forceLowerSavings,
|
||||
confirmOver80: opts?.confirmOver80,
|
||||
};
|
||||
try {
|
||||
await rebalanceApi.submit(payload);
|
||||
push("ok", "Rebalance applied");
|
||||
setForceSavings(false);
|
||||
setAdjustValue("");
|
||||
await qc.invalidateQueries({ queryKey: ["rebalance", "info"] });
|
||||
await refetch();
|
||||
const refreshed = await refetch();
|
||||
if (refreshed.data?.categories) {
|
||||
setRows(refreshed.data.categories.map((c) => ({ ...c, targetCents: c.balanceCents })));
|
||||
setAdjustId(refreshed.data.categories[0]?.id ?? "");
|
||||
}
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
if (hasNegative) {
|
||||
push("err", "No category can be negative.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (over80Crossed || savingsCrossed || sumMismatch) {
|
||||
setConfirmOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await submitRebalance(rows);
|
||||
} catch (err: any) {
|
||||
const msg = err?.body?.message || err?.message || "Rebalance failed";
|
||||
push("err", msg);
|
||||
}
|
||||
};
|
||||
|
||||
const confirmAndApply = async () => {
|
||||
try {
|
||||
let targetRows = rows;
|
||||
if (sumMismatch) {
|
||||
const normalized = normalizeTargetsToAvailable(rows, available);
|
||||
if (!normalized) {
|
||||
push("err", "Could not auto-balance totals to available.");
|
||||
return;
|
||||
}
|
||||
targetRows = normalized;
|
||||
setRows(normalized);
|
||||
}
|
||||
await submitRebalance(targetRows, {
|
||||
forceLowerSavings: savingsCrossed,
|
||||
confirmOver80: over80Crossed,
|
||||
});
|
||||
setConfirmOpen(false);
|
||||
} catch (err: any) {
|
||||
const msg = err?.body?.message || err?.message || "Rebalance failed";
|
||||
push("err", msg);
|
||||
@@ -134,7 +209,7 @@ export default function RebalancePage() {
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="card glass p-5 flex flex-wrap gap-6 items-center justify-between">
|
||||
<div className="card glass p-5 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 items-center">
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs uppercase tracking-wide muted">Available</div>
|
||||
<div className="text-2xl font-mono font-semibold">${(available / 100).toFixed(2)}</div>
|
||||
@@ -149,14 +224,6 @@ export default function RebalancePage() {
|
||||
<div className="text-xs uppercase tracking-wide muted">Savings total</div>
|
||||
<div className="text-sm 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-5 space-y-4">
|
||||
@@ -253,7 +320,7 @@ export default function RebalancePage() {
|
||||
className="btn ghost"
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
setForceSavings(false);
|
||||
setConfirmOpen(false);
|
||||
setAdjustValue("");
|
||||
const refreshed = await refetch();
|
||||
if (refreshed.data?.categories) {
|
||||
@@ -265,6 +332,32 @@ export default function RebalancePage() {
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{confirmOpen && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="card p-6 max-w-lg w-[92vw] space-y-4">
|
||||
<h3 className="text-lg font-semibold">Confirm safeguard overrides</h3>
|
||||
<p className="text-sm muted">
|
||||
You crossed one or more safeguards. Confirm to apply these changes.
|
||||
</p>
|
||||
<div className="text-sm space-y-1">
|
||||
{over80Crossed && <div>- A category exceeds 80% of available.</div>}
|
||||
{savingsCrossed && <div>- Savings decreased and/or is below 20% floor.</div>}
|
||||
{sumMismatch && (
|
||||
<div>- Totals do not match available; remaining difference will be auto-balanced.</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-3 justify-end">
|
||||
<button className="btn" onClick={() => setConfirmOpen(false)}>
|
||||
Cancel
|
||||
</button>
|
||||
<button className="btn primary" onClick={confirmAndApply}>
|
||||
Confirm and apply
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user