fire-planner: What-If gains the chart-first scenario editor
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful

The Plan-tab editors (interactive Gantt for life events, flex spending
rules) are now available in What-If too — with local state instead of
DB persistence so users can tweak before committing to a scenario.

Architecture refactor:

- EventGantt is now a controlled component. The `scenarioId` prop +
  internal useMutation/useQueryClient hooks went away; the component
  takes a `persister: { create, patch, delete }` prop and delegates
  every mutation through it. Plan tab wires it to lifeEventsApi +
  cache invalidation; What-If wires it to React local state with
  negative ids for new events.
- FlexRulesEditor is similarly controlled. Takes `rules + onChange`
  instead of a `scenario` object. Plan tab wraps it with the PATCH
  /scenarios/:id mutation; What-If wraps it with setFlexRules.

Backend:

- New stateless POST /scenarios/spending-profile-preview endpoint
  takes base_spending_gbp + horizon + life_events + flex_rules in the
  body and returns the same SpendingProfileResponse shape as the
  read-only /scenarios/{id}/spending-profile endpoint. Used by
  What-If to render the stacked-area chart against unsaved events.
- SpendingProfileResponse.scenario_id loosened to int | None to
  support the preview variant.

Frontend:

- What-If page gains `events` + `flexRules` local state, an
  EventGantt + FlexRulesEditor wired through them, and a Visx
  spending-profile chart fed by /spending-profile-preview.
- Sim auto-refresh: a 600ms debounced effect re-fires /simulate
  whenever the form / events / flex rules change. Manual "Run
  simulation" button stays as an immediate trigger.
- "Save as scenario" still works — preserves the scenario params but
  not yet the life events / flex rules (a Wave-3 follow-up could
  POST them after the scenario is created).

247 pytest pass; mypy + ruff + frontend typecheck/test/build all clean.
This commit is contained in:
Viktor Barzin 2026-05-12 19:35:28 +00:00
parent eb0dd3ddbf
commit 70101c836c
7 changed files with 372 additions and 118 deletions

View file

@ -427,11 +427,26 @@ class SpendingProfilePoint(BaseModel):
class SpendingProfileResponse(BaseModel):
scenario_id: int
scenario_id: int | None = None
horizon_years: int
points: list[SpendingProfilePoint]
class SpendingProfilePreviewRequest(BaseModel):
"""Stateless spending-profile preview — used by the What-If page where
the user is editing in-memory life events that aren't persisted yet.
No scenario_id needed; the caller supplies the baseline spending,
horizon, and any flex rules. Flex-cut estimation against a portfolio
fan is skipped (caller already has a /simulate response to pair
with this if they want flex visualisation).
"""
base_spending_gbp: Decimal = Field(ge=0)
horizon_years: int = Field(ge=1, le=100)
life_events: list[LifeEventInput] = Field(default_factory=list)
flex_rules: list[FlexRule] = Field(default_factory=list)
class SimulateRequest(BaseModel):
"""Sync, non-persisted simulate. Used by the React UI for what-if.

View file

@ -31,6 +31,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from fire_planner.api.dependencies import get_session
from fire_planner.api.schemas import (
SpendingProfilePoint,
SpendingProfilePreviewRequest,
SpendingProfileResponse,
)
from fire_planner.db import LifeEvent, McRun, ProjectionYearly, Scenario
@ -183,3 +184,73 @@ async def get_spending_profile(
horizon_years=horizon,
points=points,
)
@router.post("/spending-profile-preview", response_model=SpendingProfileResponse)
async def preview_spending_profile(
req: SpendingProfilePreviewRequest,
) -> SpendingProfileResponse:
"""Stateless variant — used by the What-If page where the user is
tweaking in-memory life events that aren't persisted yet."""
points: list[SpendingProfilePoint] = []
base = Decimal(str(req.base_spending_gbp))
# Flex rules are accepted for forward-compat but not applied here:
# without a portfolio path we can't decide drawdown depth. The
# paired /simulate call gives the exact cut; this endpoint just
# gives the pre-cut profile for the stacked-area chart.
_ = req.flex_rules
for year_idx in range(req.horizon_years):
essential = Decimal("0")
discretionary = Decimal("0")
not_spending = Decimal("0")
ess_inflow = Decimal("0")
disc_inflow = Decimal("0")
for ev in req.life_events:
if not ev.enabled:
continue
if year_idx < ev.year_start:
continue
end = ev.year_end if ev.year_end is not None else ev.year_start
if year_idx > end:
continue
delta = Decimal(str(ev.delta_gbp_per_year or 0))
if ev.category == "not_spending":
not_spending += abs(delta)
continue
if delta < 0:
if ev.category == "essential":
essential += -delta
elif ev.category == "discretionary":
discretionary += -delta
elif delta > 0:
if ev.category == "essential":
ess_inflow += delta
elif ev.category == "discretionary":
disc_inflow += delta
if year_idx == ev.year_start and ev.one_time_amount_gbp is not None:
ot = Decimal(str(ev.one_time_amount_gbp))
if ot < 0:
if ev.category == "essential":
essential += -ot
elif ev.category == "discretionary":
discretionary += -ot
net_base = base - ess_inflow - disc_inflow
if net_base < 0:
net_base = Decimal("0")
total = net_base + essential + discretionary
points.append(
SpendingProfilePoint(
year_idx=year_idx,
base_gbp=net_base,
essential_gbp=essential,
discretionary_gbp=discretionary,
not_spending_gbp=not_spending,
flex_cut_gbp=Decimal("0"),
total_gbp=total,
))
return SpendingProfileResponse(
scenario_id=None,
horizon_years=req.horizon_years,
points=points,
)

View file

@ -57,6 +57,11 @@ export const api = {
request<CashflowResponse>(`/scenarios/${id}/cashflow?year=${year}`),
spendingProfile: (id: number) =>
request<SpendingProfileResponse>(`/scenarios/${id}/spending-profile`),
spendingProfilePreview: (body: SpendingProfilePreviewRequest) =>
request<SpendingProfileResponse>(`/scenarios/spending-profile-preview`, {
method: 'POST',
body: JSON.stringify(body),
}),
networth: {
current: () =>
request<{
@ -210,11 +215,26 @@ export interface SpendingProfilePoint {
}
export interface SpendingProfileResponse {
scenario_id: number;
scenario_id: number | null;
horizon_years: number;
points: SpendingProfilePoint[];
}
export interface SpendingProfilePreviewRequest {
base_spending_gbp: string;
horizon_years: number;
life_events?: Array<{
kind?: string;
year_start: number;
year_end?: number | null;
delta_gbp_per_year?: string;
one_time_amount_gbp?: string | null;
category?: SpendingCategory;
enabled?: boolean;
}>;
flex_rules?: FlexRule[];
}
// ── goals ────────────────────────────────────────────────────────────
export interface Goal {

View file

@ -13,7 +13,6 @@
* fallback under the drawer remains for bulk edits and accessibility.
*/
import { useEffect, useMemo, useRef, useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { ParentSize } from '@visx/responsive';
import { scaleBand, scaleLinear } from '@visx/scale';
import { Group } from '@visx/group';
@ -21,7 +20,6 @@ import { AxisBottom } from '@visx/axis';
import { localPoint } from '@visx/event';
import {
lifeEventsApi,
type LifeEvent,
type LifeEventCreateBody,
type LifeEventPatchBody,
@ -30,10 +28,19 @@ import {
import { gbp } from '@/lib/format';
import { emojiFor } from '@/lib/milestone';
/** Persistence hooks parent decides what create/patch/delete do.
* Plan tab wires these to the lifeEventsApi; What-If wires them to
* local React state. */
export interface GanttPersister {
create: (body: LifeEventCreateBody) => void;
patch: (id: number, body: LifeEventPatchBody) => void;
delete: (id: number) => void;
}
interface Props {
scenarioId: number;
events: LifeEvent[];
horizonYears: number;
persister: GanttPersister;
height?: number;
}
@ -77,9 +84,9 @@ export function EventGantt(props: Props) {
}
function Inner({
scenarioId,
events,
horizonYears,
persister,
width,
height = 220,
}: Props & { width: number }) {
@ -112,39 +119,8 @@ function Inner({
[sortedEvents],
);
const qc = useQueryClient();
const invalidate = () =>
qc.invalidateQueries({
queryKey: ['scenarios', scenarioId, 'life-events'],
});
const invalidateProfile = () =>
qc.invalidateQueries({
queryKey: ['spending-profile', scenarioId],
});
const patchMut = useMutation({
mutationFn: ({ id, body }: { id: number; body: LifeEventPatchBody }) =>
lifeEventsApi.patch(id, body),
onSuccess: () => {
invalidate();
invalidateProfile();
},
});
const createMut = useMutation({
mutationFn: (body: LifeEventCreateBody) =>
lifeEventsApi.create(scenarioId, body),
onSuccess: () => {
invalidate();
invalidateProfile();
},
});
const deleteMut = useMutation({
mutationFn: (id: number) => lifeEventsApi.delete(id),
onSuccess: () => {
invalidate();
invalidateProfile();
},
});
// Mutations are delegated to the parent via `persister` (Plan tab
// wires to lifeEventsApi; What-If wires to local React state).
// Drag state lives in a ref so mousemove handlers don't re-render.
const drag = useRef<DragState | null>(null);
@ -192,7 +168,7 @@ function Inner({
year_end: clamp(origYearEnd + dyears, origYearStart, horizonYears - 1),
};
}
patchMut.mutate({ id: eventId, body });
persister.patch(eventId, body);
};
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
@ -200,7 +176,7 @@ function Inner({
window.removeEventListener('mousemove', onMove);
window.removeEventListener('mouseup', onUp);
};
}, [horizonYears, patchMut, xScale]);
}, [horizonYears, persister, xScale]);
const startDrag = (e: React.MouseEvent, ev: LifeEvent, mode: DragMode) => {
e.stopPropagation();
@ -401,15 +377,15 @@ function Inner({
state={popover}
onClose={() => setPopover(null)}
onCreate={(body) => {
createMut.mutate(body);
persister.create(body);
setPopover(null);
}}
onPatch={(id, body) => {
patchMut.mutate({ id, body });
persister.patch(id, body);
setPopover(null);
}}
onDelete={(id) => {
deleteMut.mutate(id);
persister.delete(id);
setPopover(null);
}}
horizonYears={horizonYears}

View file

@ -1,79 +1,35 @@
/**
* Flex-rules editor list of {from_ath_pct, cut_discretionary_pct}
* tiers stored on `scenario.config_json.flex_rules`. Saves on blur via
* the existing PATCH /scenarios/:id (config_json is a free-form blob).
* tiers. Controlled component: parent owns the rules + persistence.
*
* Plan tab wraps this with a PATCH /scenarios/:id call. What-If wraps
* it with local React state.
*/
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useEffect, useState } from 'react';
import { api, type Scenario } from '@/api/client';
interface Rule {
export interface FlexRule {
from_ath_pct: number;
cut_discretionary_pct: number;
}
interface Props {
scenario: Scenario;
rules: FlexRule[];
onChange: (next: FlexRule[]) => void;
saving?: boolean;
error?: string | null;
}
const DEFAULT_RULES: Rule[] = [
const DEFAULT_RULES: FlexRule[] = [
{ from_ath_pct: 0.10, cut_discretionary_pct: 0.20 },
{ from_ath_pct: 0.30, cut_discretionary_pct: 0.60 },
];
function readRules(scen: Scenario): Rule[] {
const blob = scen.config_json as Record<string, unknown>;
const raw = blob?.flex_rules;
if (!Array.isArray(raw)) return [];
return raw
.filter((r): r is { from_ath_pct: unknown; cut_discretionary_pct: unknown } =>
typeof r === 'object' && r !== null,
)
.map((r) => ({
from_ath_pct: Number((r as { from_ath_pct: unknown }).from_ath_pct ?? 0),
cut_discretionary_pct: Number(
(r as { cut_discretionary_pct: unknown }).cut_discretionary_pct ?? 0,
),
}));
}
export function FlexRulesEditor({ scenario }: Props) {
const qc = useQueryClient();
const [rules, setRules] = useState<Rule[]>(() => readRules(scenario));
useEffect(() => {
setRules(readRules(scenario));
}, [scenario.id, scenario.config_json]);
const save = useMutation({
mutationFn: (next: Rule[]) =>
api.scenarios.patch(scenario.id, {
config_json: {
...((scenario.config_json as Record<string, unknown>) ?? {}),
flex_rules: next,
},
} as never),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['scenarios', scenario.id] });
qc.invalidateQueries({
queryKey: ['spending-profile', scenario.id],
});
},
});
const persist = (next: Rule[]) => {
setRules(next);
save.mutate(next);
export function FlexRulesEditor({ rules, onChange, saving, error }: Props) {
const update = (idx: number, patch: Partial<FlexRule>) => {
onChange(rules.map((r, i) => (i === idx ? { ...r, ...patch } : r)));
};
const update = (idx: number, patch: Partial<Rule>) => {
persist(rules.map((r, i) => (i === idx ? { ...r, ...patch } : r)));
};
const remove = (idx: number) => persist(rules.filter((_, i) => i !== idx));
const add = () => persist([...rules, { from_ath_pct: 0.20, cut_discretionary_pct: 0.40 }]);
const seedDefaults = () => persist(DEFAULT_RULES);
const remove = (idx: number) => onChange(rules.filter((_, i) => i !== idx));
const add = () =>
onChange([...rules, { from_ath_pct: 0.20, cut_discretionary_pct: 0.40 }]);
const seedDefaults = () => onChange(DEFAULT_RULES);
return (
<div className="rounded-lg border border-slate-200 bg-white p-4">
@ -129,11 +85,8 @@ export function FlexRulesEditor({ scenario }: Props) {
>
+ Add tier
</button>
{save.isError && (
<p className="text-xs text-red-700 mt-2">
{String((save.error as Error)?.message ?? save.error)}
</p>
)}
{saving && <p className="text-xs text-slate-500 mt-2">Saving</p>}
{error && <p className="text-xs text-red-700 mt-2">{error}</p>}
</div>
);
}

View file

@ -29,7 +29,7 @@ import {
import { ApiError } from '@/api/client';
import { EventGantt } from '@/components/EventGantt';
import { FanChart } from '@/components/FanChart';
import { FlexRulesEditor } from '@/components/FlexRulesEditor';
import { FlexRulesEditor, type FlexRule } from '@/components/FlexRulesEditor';
import { GoalsSection } from '@/components/GoalsSection';
import { IncomeStreamsSection } from '@/components/IncomeStreamsSection';
import { LifeEventsSection } from '@/components/LifeEventsSection';
@ -95,6 +95,61 @@ export function ScenarioDetail() {
},
});
// API-backed persisters for the chart-first editors. Each invalidates
// the queries that drive the Plan tab so the spending profile + fan
// re-fetch after the mutation lands.
const invalidateScenarioCaches = () => {
qc.invalidateQueries({ queryKey: ['scenarios', id, 'life-events'] });
qc.invalidateQueries({ queryKey: ['spending-profile', id] });
};
const lifeEventCreate = useMutation({
mutationFn: (body: Parameters<typeof lifeEventsApi.create>[1]) =>
lifeEventsApi.create(id, body),
onSuccess: invalidateScenarioCaches,
});
const lifeEventPatch = useMutation({
mutationFn: ({ eid, body }: { eid: number; body: Parameters<typeof lifeEventsApi.patch>[1] }) =>
lifeEventsApi.patch(eid, body),
onSuccess: invalidateScenarioCaches,
});
const lifeEventDelete = useMutation({
mutationFn: (eid: number) => lifeEventsApi.delete(eid),
onSuccess: invalidateScenarioCaches,
});
const ganttPersister = useMemo(
() => ({
create: (body: Parameters<typeof lifeEventsApi.create>[1]) =>
lifeEventCreate.mutate(body),
patch: (eid: number, body: Parameters<typeof lifeEventsApi.patch>[1]) =>
lifeEventPatch.mutate({ eid, body }),
delete: (eid: number) => lifeEventDelete.mutate(eid),
}),
// mutate is stable across renders by useMutation contract
// eslint-disable-next-line react-hooks/exhaustive-deps
[],
);
const flexRulesSave = useMutation({
mutationFn: (next: FlexRule[]) =>
api.scenarios.patch(id, {
config_json: {
...((scen.data?.config_json as Record<string, unknown>) ?? {}),
flex_rules: next,
},
} as never),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['scenarios', id] });
qc.invalidateQueries({ queryKey: ['spending-profile', id] });
},
});
const persistedFlexRules: FlexRule[] = useMemo(
() => (scen.data ? readFlexRules(scen.data).map((r) => ({
from_ath_pct: Number(r.from_ath_pct),
cut_discretionary_pct: Number(r.cut_discretionary_pct),
})) : []),
[scen.data],
);
const sim = useMutation({
mutationFn: (req: SimulateRequest) => api.simulate(req),
});
@ -402,13 +457,22 @@ export function ScenarioDetail() {
</span>
</div>
<EventGantt
scenarioId={id}
events={events.data ?? []}
horizonYears={horizonYears}
persister={ganttPersister}
/>
</div>
<FlexRulesEditor scenario={s} />
<FlexRulesEditor
rules={persistedFlexRules}
onChange={(next) => flexRulesSave.mutate(next)}
saving={flexRulesSave.isPending}
error={
flexRulesSave.isError
? String((flexRulesSave.error as Error)?.message ?? flexRulesSave.error)
: null
}
/>
</>
) : projection404 ? (
<div className="rounded-lg border border-slate-200 bg-white p-8 text-center text-slate-500">

View file

@ -13,18 +13,25 @@
* `api/simulate.py::_project`.
*/
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useEffect, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
api,
type AnnualSpending,
type LifeEvent,
type LifeEventCreateBody,
type LifeEventPatchBody,
type SimulateRequest,
type SimulateResult,
type SpendingCategory,
} from '@/api/client';
import { EventGantt, type GanttPersister } from '@/components/EventGantt';
import { FanChart } from '@/components/FanChart';
import { FlexRulesEditor, type FlexRule } from '@/components/FlexRulesEditor';
import { InfoTip } from '@/components/InfoTip';
import { SegmentedControl, type SegmentedOption } from '@/components/SegmentedControl';
import { SpendingProfileChart } from '@/components/SpendingProfileChart';
import { gbp, pct } from '@/lib/format';
const JURISDICTIONS = ['uk', 'cyprus', 'bulgaria', 'malaysia', 'thailand', 'uae', 'nomad'];
@ -150,6 +157,72 @@ export function WhatIf() {
void spending.refetch();
};
// Local life-events + flex rules. Editing these never hits the DB —
// What-If is for tweaking before committing to a scenario.
const [events, setEvents] = useState<LifeEvent[]>([]);
const [flexRules, setFlexRules] = useState<FlexRule[]>([]);
const nextLocalId = useRef(-1);
const allocLocalId = () => {
const n = nextLocalId.current;
nextLocalId.current -= 1;
return n;
};
const ganttPersister: GanttPersister = useMemo(
() => ({
create: (body: LifeEventCreateBody) => {
const ev: LifeEvent = {
id: allocLocalId(),
scenario_id: 0,
kind: body.kind,
name: body.name,
year_start: body.year_start,
year_end: body.year_end ?? null,
delta_gbp_per_year: String(body.delta_gbp_per_year ?? '0'),
one_time_amount_gbp: body.one_time_amount_gbp ?? null,
category: (body.category as SpendingCategory) ?? 'essential',
enabled: body.enabled ?? true,
payload: body.payload ?? null,
created_at: new Date().toISOString(),
};
setEvents((prev) => [...prev, ev]);
},
patch: (id: number, body: LifeEventPatchBody) => {
setEvents((prev) =>
prev.map((e) =>
e.id === id
? {
...e,
...(body.name !== undefined && { name: body.name }),
...(body.kind !== undefined && { kind: body.kind }),
...(body.year_start !== undefined && {
year_start: body.year_start,
}),
...(body.year_end !== undefined && {
year_end: body.year_end,
}),
...(body.delta_gbp_per_year !== undefined && {
delta_gbp_per_year: body.delta_gbp_per_year,
}),
...(body.one_time_amount_gbp !== undefined && {
one_time_amount_gbp: body.one_time_amount_gbp,
}),
...(body.category !== undefined && {
category: body.category,
}),
...(body.enabled !== undefined && { enabled: body.enabled }),
}
: e,
),
);
},
delete: (id: number) => {
setEvents((prev) => prev.filter((e) => e.id !== id));
},
}),
[],
);
const sim = useMutation({
mutationFn: (req: SimulateRequest) => api.simulate(req),
});
@ -173,10 +246,9 @@ export function WhatIf() {
},
});
const onSubmit = (e: React.FormEvent) => {
e.preventDefault();
const buildRequest = (): SimulateRequest => {
const isCustom = form.strategy === 'custom';
sim.mutate({
return {
...form,
floor_gbp: form.strategy === 'vpw_floor' ? form.floor_gbp : null,
manual_real_return_pct:
@ -184,9 +256,60 @@ export function WhatIf() {
annual_real_adjust_pct: isCustom ? form.annual_real_adjust_pct : '0',
guardrail_threshold_pct: isCustom ? form.guardrail_threshold_pct : null,
guardrail_cut_pct: isCustom ? form.guardrail_cut_pct : '0.10',
});
life_events: events.map((e) => ({
year_start: e.year_start,
year_end: e.year_end,
delta_gbp_per_year: e.delta_gbp_per_year,
one_time_amount_gbp: e.one_time_amount_gbp,
category: e.category,
enabled: e.enabled,
})),
flex_rules: flexRules.map((r) => ({
from_ath_pct: String(r.from_ath_pct),
cut_discretionary_pct: String(r.cut_discretionary_pct),
})),
};
};
const onSubmit = (e: React.FormEvent) => {
e.preventDefault();
sim.mutate(buildRequest());
};
// Auto-refresh sim whenever the form / events / flex rules change.
// Debounced 600ms so rapid edits (drag, slider) don't fire 10x.
const signature = JSON.stringify({ form, events, flexRules });
useEffect(() => {
const t = window.setTimeout(() => {
sim.mutate(buildRequest());
}, 600);
return () => window.clearTimeout(t);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [signature]);
// Spending profile preview — recomputes on every signature change.
const profile = useQuery({
queryKey: ['spending-profile-preview', signature],
queryFn: () =>
api.spendingProfilePreview({
base_spending_gbp: form.spending_gbp,
horizon_years: form.horizon_years ?? 60,
life_events: events.map((e) => ({
year_start: e.year_start,
year_end: e.year_end,
delta_gbp_per_year: e.delta_gbp_per_year,
one_time_amount_gbp: e.one_time_amount_gbp,
category: e.category,
enabled: e.enabled,
})),
flex_rules: flexRules.map((r) => ({
from_ath_pct: String(r.from_ath_pct),
cut_discretionary_pct: String(r.cut_discretionary_pct),
})),
}),
staleTime: 0,
});
const onSaveAs = () => {
const suggested = `${form.jurisdiction}-${form.strategy}-leave-y${form.leave_uk_year}`;
const name = prompt('Save as scenario — name:', suggested);
@ -264,6 +387,38 @@ export function WhatIf() {
{sim.data && (
<>
<Results result={sim.data} horizon={form.horizon_years ?? 60} />
{/* Spending profile (stacked area) */}
{profile.data && (
<div className="rounded-lg border border-slate-200 bg-white p-5">
<div className="flex items-baseline justify-between mb-2">
<h2 className="text-lg font-semibold">Spending profile</h2>
<span className="text-xs text-slate-500">
base · essential · discretionary
</span>
</div>
<SpendingProfileChart points={profile.data.points} />
</div>
)}
{/* Interactive Gantt — chart-as-SoT for life events */}
<div className="rounded-lg border border-slate-200 bg-white p-5">
<div className="flex items-baseline justify-between mb-2">
<h2 className="text-lg font-semibold">Life events</h2>
<span className="text-xs text-slate-500">
Click empty space to add · drag bars to move · drag edges to resize
</span>
</div>
<EventGantt
events={events}
horizonYears={form.horizon_years ?? 60}
persister={ganttPersister}
/>
</div>
{/* Flex spending rules */}
<FlexRulesEditor rules={flexRules} onChange={setFlexRules} />
<div className="flex items-center gap-3">
<button
type="button"