frontend: run-now + save-as-scenario + edit form (CRUD complete)
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Three small UX wins: - /scenarios/:id Run now — POSTs /simulate with the scenario's params and renders the result in a "Live preview run" card below the persisted projection. Removes the recompute-or-wait friction. - /what-if Save as scenario — appears once a simulation has run. Prompts for a name (with a sensible default), POSTs the form values to /scenarios as a user scenario, redirects to its detail page. - /scenarios/:id/edit — PATCH form for user scenarios. Pre-fills from current scenario; on save invalidates the scenarios query and navigates back to detail. Backend already rejects PATCH on cartesian; the UI also hides the Edit button for them. api.scenarios gained patch(). 7 tests pass, typecheck + build clean. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
18981459b3
commit
cb79118da7
5 changed files with 359 additions and 9 deletions
|
|
@ -5,6 +5,7 @@ import { api } from '@/api/client';
|
||||||
import { Compare } from '@/pages/Compare';
|
import { Compare } from '@/pages/Compare';
|
||||||
import { Dashboard } from '@/pages/Dashboard';
|
import { Dashboard } from '@/pages/Dashboard';
|
||||||
import { ScenarioDetail } from '@/pages/ScenarioDetail';
|
import { ScenarioDetail } from '@/pages/ScenarioDetail';
|
||||||
|
import { ScenarioEdit } from '@/pages/ScenarioEdit';
|
||||||
import { ScenarioNew } from '@/pages/ScenarioNew';
|
import { ScenarioNew } from '@/pages/ScenarioNew';
|
||||||
import { Scenarios } from '@/pages/Scenarios';
|
import { Scenarios } from '@/pages/Scenarios';
|
||||||
import { WhatIf } from '@/pages/WhatIf';
|
import { WhatIf } from '@/pages/WhatIf';
|
||||||
|
|
@ -44,6 +45,7 @@ export function App() {
|
||||||
<Route path="/scenarios" element={<Scenarios />} />
|
<Route path="/scenarios" element={<Scenarios />} />
|
||||||
<Route path="/scenarios/new" element={<ScenarioNew />} />
|
<Route path="/scenarios/new" element={<ScenarioNew />} />
|
||||||
<Route path="/scenarios/:id" element={<ScenarioDetail />} />
|
<Route path="/scenarios/:id" element={<ScenarioDetail />} />
|
||||||
|
<Route path="/scenarios/:id/edit" element={<ScenarioEdit />} />
|
||||||
<Route path="/compare" element={<Compare />} />
|
<Route path="/compare" element={<Compare />} />
|
||||||
<Route path="/what-if" element={<WhatIf />} />
|
<Route path="/what-if" element={<WhatIf />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,8 @@ export const api = {
|
||||||
projection: (id: number) => request<ScenarioProjection>(`/scenarios/${id}/projection`),
|
projection: (id: number) => request<ScenarioProjection>(`/scenarios/${id}/projection`),
|
||||||
create: (body: ScenarioCreateBody) =>
|
create: (body: ScenarioCreateBody) =>
|
||||||
request<Scenario>('/scenarios', { method: 'POST', body: JSON.stringify(body) }),
|
request<Scenario>('/scenarios', { method: 'POST', body: JSON.stringify(body) }),
|
||||||
|
patch: (id: number, body: Partial<ScenarioCreateBody>) =>
|
||||||
|
request<Scenario>(`/scenarios/${id}`, { method: 'PATCH', body: JSON.stringify(body) }),
|
||||||
delete: (id: number) => request<void>(`/scenarios/${id}`, { method: 'DELETE' }),
|
delete: (id: number) => request<void>(`/scenarios/${id}`, { method: 'DELETE' }),
|
||||||
},
|
},
|
||||||
simulate: (req: SimulateRequest) =>
|
simulate: (req: SimulateRequest) =>
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { Link, useNavigate, useParams } from 'react-router-dom';
|
import { Link, useNavigate, useParams } from 'react-router-dom';
|
||||||
|
|
||||||
import { api } from '@/api/client';
|
import { api, type Scenario, type SimulateRequest } from '@/api/client';
|
||||||
import { ApiError } from '@/api/client';
|
import { ApiError } from '@/api/client';
|
||||||
import { FanChart } from '@/components/FanChart';
|
import { FanChart } from '@/components/FanChart';
|
||||||
import { GoalsSection } from '@/components/GoalsSection';
|
import { GoalsSection } from '@/components/GoalsSection';
|
||||||
|
|
@ -44,11 +44,29 @@ export function ScenarioDetail() {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const sim = useMutation({
|
||||||
|
mutationFn: (req: SimulateRequest) => api.simulate(req),
|
||||||
|
});
|
||||||
|
|
||||||
const onDelete = () => {
|
const onDelete = () => {
|
||||||
if (!confirm(`Delete scenario "${scen.data?.name ?? id}"? This can't be undone.`)) return;
|
if (!confirm(`Delete scenario "${scen.data?.name ?? id}"? This can't be undone.`)) return;
|
||||||
del.mutate();
|
del.mutate();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onRunNow = (s: Scenario) =>
|
||||||
|
sim.mutate({
|
||||||
|
jurisdiction: s.jurisdiction,
|
||||||
|
strategy: s.strategy,
|
||||||
|
leave_uk_year: s.leave_uk_year,
|
||||||
|
glide_path: s.glide_path,
|
||||||
|
spending_gbp: s.spending_gbp,
|
||||||
|
nw_seed_gbp: s.nw_seed_gbp,
|
||||||
|
savings_per_year_gbp: s.savings_per_year_gbp,
|
||||||
|
horizon_years: s.horizon_years,
|
||||||
|
n_paths: 5000,
|
||||||
|
seed: 42,
|
||||||
|
});
|
||||||
|
|
||||||
if (!Number.isFinite(id)) {
|
if (!Number.isFinite(id)) {
|
||||||
return <p className="text-red-700">Invalid scenario id.</p>;
|
return <p className="text-red-700">Invalid scenario id.</p>;
|
||||||
}
|
}
|
||||||
|
|
@ -83,16 +101,34 @@ export function ScenarioDetail() {
|
||||||
</p>
|
</p>
|
||||||
{s.description && <p className="text-sm text-slate-600 mt-2">{s.description}</p>}
|
{s.description && <p className="text-sm text-slate-600 mt-2">{s.description}</p>}
|
||||||
</div>
|
</div>
|
||||||
{s.kind === 'user' && (
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onDelete}
|
onClick={() => onRunNow(s)}
|
||||||
disabled={del.isPending}
|
disabled={sim.isPending}
|
||||||
className="rounded-md border border-red-300 text-red-700 text-sm px-3 py-1.5 hover:bg-red-50 disabled:opacity-60"
|
className="rounded-md border border-slate-300 bg-white text-sm px-3 py-1.5 hover:bg-slate-50 disabled:opacity-60"
|
||||||
>
|
>
|
||||||
{del.isPending ? 'Deleting…' : 'Delete'}
|
{sim.isPending ? 'Running…' : 'Run now'}
|
||||||
</button>
|
</button>
|
||||||
)}
|
{s.kind === 'user' && (
|
||||||
|
<>
|
||||||
|
<Link
|
||||||
|
to={`/scenarios/${s.id}/edit`}
|
||||||
|
className="rounded-md border border-slate-300 bg-white text-sm px-3 py-1.5 hover:bg-slate-50"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onDelete}
|
||||||
|
disabled={del.isPending}
|
||||||
|
className="rounded-md border border-red-300 text-red-700 text-sm px-3 py-1.5 hover:bg-red-50 disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{del.isPending ? 'Deleting…' : 'Delete'}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
{del.isError && (
|
{del.isError && (
|
||||||
<div className="rounded-md border border-red-200 bg-red-50 p-3 text-red-800 text-sm">
|
<div className="rounded-md border border-red-200 bg-red-50 p-3 text-red-800 text-sm">
|
||||||
|
|
@ -144,6 +180,23 @@ export function ScenarioDetail() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{sim.data && (
|
||||||
|
<div className="rounded-lg border border-slate-200 bg-white p-5">
|
||||||
|
<div className="flex items-baseline justify-between mb-3">
|
||||||
|
<h2 className="text-lg font-semibold">Live preview run</h2>
|
||||||
|
<span className="text-xs text-slate-500">
|
||||||
|
{sim.data.elapsed_seconds}s · 5,000 paths · success {pct(sim.data.success_rate)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<FanChart yearly={sim.data.yearly} height={360} showWithdrawal />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{sim.isError && (
|
||||||
|
<div className="rounded-md border border-red-200 bg-red-50 p-3 text-red-800 text-sm">
|
||||||
|
{String((sim.error as Error)?.message ?? sim.error)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<LifeEventsSection scenarioId={id} />
|
<LifeEventsSection scenarioId={id} />
|
||||||
<GoalsSection scenarioId={id} />
|
<GoalsSection scenarioId={id} />
|
||||||
</section>
|
</section>
|
||||||
|
|
|
||||||
244
frontend/src/pages/ScenarioEdit.tsx
Normal file
244
frontend/src/pages/ScenarioEdit.tsx
Normal file
|
|
@ -0,0 +1,244 @@
|
||||||
|
/**
|
||||||
|
* Edit a user scenario via PATCH /scenarios/:id. Cartesian scenarios
|
||||||
|
* cannot be edited (the backend rejects PATCH for kind != 'user'); the
|
||||||
|
* UI also hides the edit affordance for them, so this page is only
|
||||||
|
* reachable for user scenarios.
|
||||||
|
*/
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Link, useNavigate, useParams } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { api, type ScenarioCreateBody } from '@/api/client';
|
||||||
|
|
||||||
|
const JURISDICTIONS = ['uk', 'cyprus', 'bulgaria', 'malaysia', 'thailand', 'uae', 'nomad'];
|
||||||
|
const STRATEGIES = ['trinity', 'guyton_klinger', 'vpw', 'vpw_floor'];
|
||||||
|
const GLIDES = ['rising', 'static_60_40'];
|
||||||
|
|
||||||
|
export function ScenarioEdit() {
|
||||||
|
const params = useParams<{ id: string }>();
|
||||||
|
const id = Number(params.id);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
|
||||||
|
const scen = useQuery({
|
||||||
|
queryKey: ['scenarios', id],
|
||||||
|
queryFn: () => api.scenarios.get(id),
|
||||||
|
enabled: Number.isFinite(id),
|
||||||
|
});
|
||||||
|
|
||||||
|
const [form, setForm] = useState<ScenarioCreateBody | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (scen.data && form === null) {
|
||||||
|
setForm({
|
||||||
|
name: scen.data.name ?? '',
|
||||||
|
description: scen.data.description ?? '',
|
||||||
|
jurisdiction: scen.data.jurisdiction,
|
||||||
|
strategy: scen.data.strategy,
|
||||||
|
leave_uk_year: scen.data.leave_uk_year,
|
||||||
|
glide_path: scen.data.glide_path,
|
||||||
|
spending_gbp: scen.data.spending_gbp,
|
||||||
|
nw_seed_gbp: scen.data.nw_seed_gbp,
|
||||||
|
savings_per_year_gbp: scen.data.savings_per_year_gbp,
|
||||||
|
horizon_years: scen.data.horizon_years,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [scen.data, form]);
|
||||||
|
|
||||||
|
const patch = useMutation({
|
||||||
|
mutationFn: (body: Partial<ScenarioCreateBody>) => api.scenarios.patch(id, body),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['scenarios'] });
|
||||||
|
navigate(`/scenarios/${id}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!Number.isFinite(id)) return <p className="text-red-700">Invalid scenario id.</p>;
|
||||||
|
if (scen.isLoading || !form) return <p className="text-slate-500">Loading…</p>;
|
||||||
|
if (scen.isError || !scen.data)
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border border-red-200 bg-red-50 p-4 text-red-800 text-sm">
|
||||||
|
Couldn't load scenario {id}.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
if (scen.data.kind !== 'user') {
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border border-amber-200 bg-amber-50 p-4 text-amber-800 text-sm">
|
||||||
|
Cartesian scenarios are auto-generated and can't be edited.{' '}
|
||||||
|
<Link to={`/scenarios/${id}`} className="underline">
|
||||||
|
Back to detail
|
||||||
|
</Link>
|
||||||
|
.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const update = <K extends keyof ScenarioCreateBody>(key: K, value: ScenarioCreateBody[K]) =>
|
||||||
|
setForm((f) => (f ? { ...f, [key]: value } : f));
|
||||||
|
|
||||||
|
const onSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!form.name.trim()) return;
|
||||||
|
patch.mutate(form);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="space-y-6">
|
||||||
|
<div className="text-sm">
|
||||||
|
<Link to={`/scenarios/${id}`} className="text-slate-500 hover:text-slate-900">
|
||||||
|
← {scen.data.name ?? scen.data.external_id}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<header>
|
||||||
|
<h1 className="text-3xl font-semibold tracking-tight">Edit scenario</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<form
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
className="rounded-lg border border-slate-200 bg-white p-6 grid grid-cols-1 md:grid-cols-2 gap-4 max-w-3xl"
|
||||||
|
>
|
||||||
|
<Field label="Name *">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.name}
|
||||||
|
onChange={(e) => update('name', e.target.value)}
|
||||||
|
className="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-slate-400"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Description">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.description ?? ''}
|
||||||
|
onChange={(e) => update('description', e.target.value)}
|
||||||
|
className="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-slate-400"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Jurisdiction">
|
||||||
|
<Select value={form.jurisdiction} onChange={(v) => update('jurisdiction', v)} options={JURISDICTIONS} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Strategy">
|
||||||
|
<Select value={form.strategy} onChange={(v) => update('strategy', v)} options={STRATEGIES} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Glide path">
|
||||||
|
<Select value={form.glide_path} onChange={(v) => update('glide_path', v)} options={GLIDES} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Years until leaving UK">
|
||||||
|
<NumberInput value={form.leave_uk_year} onChange={(v) => update('leave_uk_year', v)} min={0} max={60} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Annual spending (£)">
|
||||||
|
<NumberInput
|
||||||
|
value={Number(form.spending_gbp)}
|
||||||
|
onChange={(v) => update('spending_gbp', String(v))}
|
||||||
|
min={0}
|
||||||
|
step={1000}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="NW seed (£)">
|
||||||
|
<NumberInput
|
||||||
|
value={Number(form.nw_seed_gbp)}
|
||||||
|
onChange={(v) => update('nw_seed_gbp', String(v))}
|
||||||
|
min={0}
|
||||||
|
step={10000}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Annual savings (£)">
|
||||||
|
<NumberInput
|
||||||
|
value={Number(form.savings_per_year_gbp ?? 0)}
|
||||||
|
onChange={(v) => update('savings_per_year_gbp', String(v))}
|
||||||
|
min={0}
|
||||||
|
step={1000}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Horizon (years)">
|
||||||
|
<NumberInput
|
||||||
|
value={form.horizon_years ?? 60}
|
||||||
|
onChange={(v) => update('horizon_years', v)}
|
||||||
|
min={5}
|
||||||
|
max={100}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
{patch.isError && (
|
||||||
|
<div className="md:col-span-2 rounded-md border border-red-200 bg-red-50 p-3 text-red-800 text-sm">
|
||||||
|
{String((patch.error as Error)?.message ?? patch.error)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="md:col-span-2 flex items-center gap-3 pt-2">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={patch.isPending}
|
||||||
|
className="rounded-md bg-slate-900 text-white text-sm font-medium px-5 py-2 hover:bg-slate-800 disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{patch.isPending ? 'Saving…' : 'Save changes'}
|
||||||
|
</button>
|
||||||
|
<Link to={`/scenarios/${id}`} className="text-sm text-slate-500 hover:text-slate-900">
|
||||||
|
Cancel
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-xs uppercase tracking-wide text-slate-500">{label}</span>
|
||||||
|
<div className="mt-1">{children}</div>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Select({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
options,
|
||||||
|
}: {
|
||||||
|
value: string;
|
||||||
|
onChange: (v: string) => void;
|
||||||
|
options: string[];
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<select
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
className="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-slate-400"
|
||||||
|
>
|
||||||
|
{options.map((o) => (
|
||||||
|
<option key={o} value={o}>
|
||||||
|
{o}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NumberInput({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
min,
|
||||||
|
max,
|
||||||
|
step = 1,
|
||||||
|
}: {
|
||||||
|
value: number;
|
||||||
|
onChange: (v: number) => void;
|
||||||
|
min?: number;
|
||||||
|
max?: number;
|
||||||
|
step?: number;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={value}
|
||||||
|
min={min}
|
||||||
|
max={max}
|
||||||
|
step={step}
|
||||||
|
onChange={(e) => {
|
||||||
|
const n = Number(e.target.value);
|
||||||
|
if (Number.isFinite(n)) onChange(n);
|
||||||
|
}}
|
||||||
|
className="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm tabular-nums focus:outline-none focus:ring-2 focus:ring-slate-400"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -2,8 +2,9 @@
|
||||||
* What-If — interactive Monte Carlo. Form on the left, fan chart on the
|
* What-If — interactive Monte Carlo. Form on the left, fan chart on the
|
||||||
* right. Hits POST /simulate (no DB write); ~1-3s for 5k paths.
|
* right. Hits POST /simulate (no DB write); ~1-3s for 5k paths.
|
||||||
*/
|
*/
|
||||||
import { useMutation } from '@tanstack/react-query';
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
import { api, type SimulateRequest, type SimulateResult } from '@/api/client';
|
import { api, type SimulateRequest, type SimulateResult } from '@/api/client';
|
||||||
import { FanChart } from '@/components/FanChart';
|
import { FanChart } from '@/components/FanChart';
|
||||||
|
|
@ -29,10 +30,32 @@ const DEFAULTS: SimulateRequest = {
|
||||||
|
|
||||||
export function WhatIf() {
|
export function WhatIf() {
|
||||||
const [form, setForm] = useState<SimulateRequest>(DEFAULTS);
|
const [form, setForm] = useState<SimulateRequest>(DEFAULTS);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
|
||||||
const sim = useMutation({
|
const sim = useMutation({
|
||||||
mutationFn: (req: SimulateRequest) => api.simulate(req),
|
mutationFn: (req: SimulateRequest) => api.simulate(req),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const save = useMutation({
|
||||||
|
mutationFn: (name: string) =>
|
||||||
|
api.scenarios.create({
|
||||||
|
name,
|
||||||
|
jurisdiction: form.jurisdiction,
|
||||||
|
strategy: form.strategy,
|
||||||
|
leave_uk_year: form.leave_uk_year,
|
||||||
|
glide_path: form.glide_path,
|
||||||
|
spending_gbp: form.spending_gbp,
|
||||||
|
nw_seed_gbp: form.nw_seed_gbp,
|
||||||
|
savings_per_year_gbp: form.savings_per_year_gbp,
|
||||||
|
horizon_years: form.horizon_years,
|
||||||
|
}),
|
||||||
|
onSuccess: (s) => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['scenarios'] });
|
||||||
|
navigate(`/scenarios/${s.id}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const onSubmit = (e: React.FormEvent) => {
|
const onSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
sim.mutate({
|
sim.mutate({
|
||||||
|
|
@ -41,6 +64,13 @@ export function WhatIf() {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onSaveAs = () => {
|
||||||
|
const suggested = `${form.jurisdiction}-${form.strategy}-leave-y${form.leave_uk_year}`;
|
||||||
|
const name = prompt('Save as scenario — name:', suggested);
|
||||||
|
if (!name?.trim()) return;
|
||||||
|
save.mutate(name.trim());
|
||||||
|
};
|
||||||
|
|
||||||
const update = <K extends keyof SimulateRequest>(key: K, value: SimulateRequest[K]) =>
|
const update = <K extends keyof SimulateRequest>(key: K, value: SimulateRequest[K]) =>
|
||||||
setForm((f) => ({ ...f, [key]: value }));
|
setForm((f) => ({ ...f, [key]: value }));
|
||||||
|
|
||||||
|
|
@ -164,7 +194,26 @@ export function WhatIf() {
|
||||||
Running Monte Carlo…
|
Running Monte Carlo…
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{sim.data && <Results result={sim.data} horizon={form.horizon_years ?? 60} />}
|
{sim.data && (
|
||||||
|
<>
|
||||||
|
<Results result={sim.data} horizon={form.horizon_years ?? 60} />
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onSaveAs}
|
||||||
|
disabled={save.isPending}
|
||||||
|
className="rounded-md border border-slate-300 bg-white text-sm px-4 py-2 hover:bg-slate-50 disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{save.isPending ? 'Saving…' : 'Save as scenario'}
|
||||||
|
</button>
|
||||||
|
{save.isError && (
|
||||||
|
<span className="text-xs text-red-700">
|
||||||
|
{String((save.error as Error)?.message ?? save.error)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue