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
|
|
@ -7,7 +7,7 @@
|
|||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
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 { FanChart } from '@/components/FanChart';
|
||||
import { GoalsSection } from '@/components/GoalsSection';
|
||||
|
|
@ -44,11 +44,29 @@ export function ScenarioDetail() {
|
|||
},
|
||||
});
|
||||
|
||||
const sim = useMutation({
|
||||
mutationFn: (req: SimulateRequest) => api.simulate(req),
|
||||
});
|
||||
|
||||
const onDelete = () => {
|
||||
if (!confirm(`Delete scenario "${scen.data?.name ?? id}"? This can't be undone.`)) return;
|
||||
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)) {
|
||||
return <p className="text-red-700">Invalid scenario id.</p>;
|
||||
}
|
||||
|
|
@ -83,16 +101,34 @@ export function ScenarioDetail() {
|
|||
</p>
|
||||
{s.description && <p className="text-sm text-slate-600 mt-2">{s.description}</p>}
|
||||
</div>
|
||||
{s.kind === 'user' && (
|
||||
<div className="flex items-center gap-2">
|
||||
<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"
|
||||
onClick={() => onRunNow(s)}
|
||||
disabled={sim.isPending}
|
||||
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>
|
||||
)}
|
||||
{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>
|
||||
{del.isError && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
{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} />
|
||||
<GoalsSection scenarioId={id} />
|
||||
</section>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue