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(),
|
forceLowerSavings: z.boolean().optional(),
|
||||||
|
confirmOver80: z.boolean().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
async function assertPercentTotal(
|
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}).` });
|
.send({ ok: false, code: "SUM_MISMATCH", message: `Targets must sum to available (${availableCents}).` });
|
||||||
}
|
}
|
||||||
const maxAllowed = Math.floor(availableCents * 0.8);
|
const maxAllowed = Math.floor(availableCents * 0.8);
|
||||||
if (availableCents > 0 && targets.some((t) => t.target > maxAllowed)) {
|
const over80 = 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." });
|
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 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);
|
const totalSavingsAfter = targets.filter((t) => t.isSavings).reduce((s, t) => s + t.target, 0);
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export type RebalanceInfo = {
|
|||||||
export type ManualRebalanceBody = {
|
export type ManualRebalanceBody = {
|
||||||
targets: Array<{ id: string; targetCents: number }>;
|
targets: Array<{ id: string; targetCents: number }>;
|
||||||
forceLowerSavings?: boolean;
|
forceLowerSavings?: boolean;
|
||||||
|
confirmOver80?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const rebalanceApi = {
|
export const rebalanceApi = {
|
||||||
|
|||||||
@@ -8,6 +8,38 @@ function sum(values: number[]) {
|
|||||||
return values.reduce((s, v) => s + (Number.isFinite(v) ? v : 0), 0);
|
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() {
|
export default function RebalancePage() {
|
||||||
const { push } = useToast();
|
const { push } = useToast();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
@@ -17,9 +49,9 @@ export default function RebalancePage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const [rows, setRows] = useState<Array<RebalanceCategory & { targetCents: number }>>([]);
|
const [rows, setRows] = useState<Array<RebalanceCategory & { targetCents: number }>>([]);
|
||||||
const [forceSavings, setForceSavings] = useState(false);
|
|
||||||
const [adjustId, setAdjustId] = useState<string>("");
|
const [adjustId, setAdjustId] = useState<string>("");
|
||||||
const [adjustValue, setAdjustValue] = useState<string>("");
|
const [adjustValue, setAdjustValue] = useState<string>("");
|
||||||
|
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (data?.categories) {
|
if (data?.categories) {
|
||||||
@@ -42,16 +74,19 @@ export default function RebalancePage() {
|
|||||||
const savingsFloor = Math.floor(available * 0.2);
|
const savingsFloor = Math.floor(available * 0.2);
|
||||||
const maxSingle = Math.floor(available * 0.8);
|
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 errors: string[] = [];
|
||||||
const warnings: string[] = [];
|
const warnings: string[] = [];
|
||||||
if (rows.some((r) => r.targetCents < 0)) errors.push("No category can be negative.");
|
if (hasNegative) errors.push("No category can be negative.");
|
||||||
if (available > 0 && rows.some((r) => r.targetCents > maxSingle))
|
if (over80Crossed) warnings.push("A category exceeds 80% of available.");
|
||||||
warnings.push("A category exceeds 80% of available.");
|
if (sumMismatch) warnings.push(`Totals differ from available ($${(available / 100).toFixed(2)}).`);
|
||||||
if (total !== available) warnings.push(`Totals differ from available ($${(available / 100).toFixed(2)}).`);
|
if (savingsCrossed) warnings.push("Savings decreased and/or fell below the 20% floor.");
|
||||||
if ((savingsTotal < savingsBefore || savingsTotal < savingsFloor) && !forceSavings)
|
|
||||||
errors.push("Confirm lowering savings / below 20% floor.");
|
|
||||||
|
|
||||||
const canSubmit = errors.length === 0;
|
const canSubmit = !hasNegative;
|
||||||
|
|
||||||
const applyAdjustOne = () => {
|
const applyAdjustOne = () => {
|
||||||
if (!adjustId) return;
|
if (!adjustId) return;
|
||||||
@@ -101,22 +136,62 @@ export default function RebalancePage() {
|
|||||||
setRows(next);
|
setRows(next);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const submitRebalance = async (
|
||||||
|
sourceRows: Array<RebalanceCategory & { targetCents: number }>,
|
||||||
|
opts?: { forceLowerSavings?: boolean; confirmOver80?: boolean }
|
||||||
|
) => {
|
||||||
|
const payload = {
|
||||||
|
targets: sourceRows.map((r) => ({ id: r.id, targetCents: r.targetCents })),
|
||||||
|
forceLowerSavings: opts?.forceLowerSavings,
|
||||||
|
confirmOver80: opts?.confirmOver80,
|
||||||
|
};
|
||||||
|
await rebalanceApi.submit(payload);
|
||||||
|
push("ok", "Rebalance applied");
|
||||||
|
setAdjustValue("");
|
||||||
|
await qc.invalidateQueries({ queryKey: ["rebalance", "info"] });
|
||||||
|
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 () => {
|
const submit = async () => {
|
||||||
if (!canSubmit) {
|
if (hasNegative) {
|
||||||
push("err", errors[0] ?? "Fix validation errors first");
|
push("err", "No category can be negative.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const payload = {
|
|
||||||
targets: rows.map((r) => ({ id: r.id, targetCents: r.targetCents })),
|
if (over80Crossed || savingsCrossed || sumMismatch) {
|
||||||
forceLowerSavings: forceSavings,
|
setConfirmOpen(true);
|
||||||
};
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await rebalanceApi.submit(payload);
|
await submitRebalance(rows);
|
||||||
push("ok", "Rebalance applied");
|
} catch (err: any) {
|
||||||
setForceSavings(false);
|
const msg = err?.body?.message || err?.message || "Rebalance failed";
|
||||||
setAdjustValue("");
|
push("err", msg);
|
||||||
await qc.invalidateQueries({ queryKey: ["rebalance", "info"] });
|
}
|
||||||
await refetch();
|
};
|
||||||
|
|
||||||
|
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) {
|
} catch (err: any) {
|
||||||
const msg = err?.body?.message || err?.message || "Rebalance failed";
|
const msg = err?.body?.message || err?.message || "Rebalance failed";
|
||||||
push("err", msg);
|
push("err", msg);
|
||||||
@@ -134,7 +209,7 @@ export default function RebalancePage() {
|
|||||||
</p>
|
</p>
|
||||||
</header>
|
</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="space-y-1">
|
||||||
<div className="text-xs uppercase tracking-wide muted">Available</div>
|
<div className="text-xs uppercase tracking-wide muted">Available</div>
|
||||||
<div className="text-2xl font-mono font-semibold">${(available / 100).toFixed(2)}</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-xs uppercase tracking-wide muted">Savings total</div>
|
||||||
<div className="text-sm font-mono">${(savingsTotal / 100).toFixed(2)}</div>
|
<div className="text-sm font-mono">${(savingsTotal / 100).toFixed(2)}</div>
|
||||||
</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>
|
||||||
|
|
||||||
<div className="card p-5 space-y-4">
|
<div className="card p-5 space-y-4">
|
||||||
@@ -253,7 +320,7 @@ export default function RebalancePage() {
|
|||||||
className="btn ghost"
|
className="btn ghost"
|
||||||
type="button"
|
type="button"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
setForceSavings(false);
|
setConfirmOpen(false);
|
||||||
setAdjustValue("");
|
setAdjustValue("");
|
||||||
const refreshed = await refetch();
|
const refreshed = await refetch();
|
||||||
if (refreshed.data?.categories) {
|
if (refreshed.data?.categories) {
|
||||||
@@ -265,6 +332,32 @@ export default function RebalancePage() {
|
|||||||
Reset
|
Reset
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user