frontend: scenario create + delete (CRUD loop closes)
Some checks failed
ci/woodpecker/push/woodpecker Pipeline was canceled

/scenarios/new — form posts to POST /scenarios with name, description,
jurisdiction, strategy, glide path, leave-UK year, spending, NW seed,
savings, horizon. Required-name validation; on success invalidates the
scenarios query and navigates to the new detail page.

/scenarios/:id — Delete button (user scenarios only; cartesian are
backend-protected). Browser confirm prompt + DELETE /scenarios/{id} +
invalidate + redirect to list.

api.scenarios gains create() and delete(). New ScenarioCreateBody type.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Viktor Barzin 2026-05-09 22:11:54 +00:00
parent d2fd765fe0
commit 60c275cd05
5 changed files with 305 additions and 10 deletions

View file

@ -4,6 +4,7 @@ import { NavLink, Route, Routes, Link } from 'react-router-dom';
import { api } from '@/api/client';
import { Dashboard } from '@/pages/Dashboard';
import { ScenarioDetail } from '@/pages/ScenarioDetail';
import { ScenarioNew } from '@/pages/ScenarioNew';
import { Scenarios } from '@/pages/Scenarios';
import { WhatIf } from '@/pages/WhatIf';
@ -40,6 +41,7 @@ export function App() {
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/scenarios" element={<Scenarios />} />
<Route path="/scenarios/new" element={<ScenarioNew />} />
<Route path="/scenarios/:id" element={<ScenarioDetail />} />
<Route path="/what-if" element={<WhatIf />} />
</Routes>

View file

@ -70,11 +70,29 @@ export const api = {
request<Scenario[]>(`/scenarios${kind ? `?kind=${kind}` : ''}`),
get: (id: number) => request<Scenario>(`/scenarios/${id}`),
projection: (id: number) => request<ScenarioProjection>(`/scenarios/${id}/projection`),
create: (body: ScenarioCreateBody) =>
request<Scenario>('/scenarios', { method: 'POST', body: JSON.stringify(body) }),
delete: (id: number) => request<void>(`/scenarios/${id}`, { method: 'DELETE' }),
},
simulate: (req: SimulateRequest) =>
request<SimulateResult>('/simulate', { method: 'POST', body: JSON.stringify(req) }),
};
export interface ScenarioCreateBody {
name: string;
description?: string | null;
parent_scenario_id?: number | null;
jurisdiction: string;
strategy: string;
leave_uk_year: number;
glide_path: string;
spending_gbp: string;
horizon_years?: number;
nw_seed_gbp: string;
savings_per_year_gbp?: string;
config_json?: Record<string, unknown>;
}
export interface Scenario {
id: number;
external_id: string;

View file

@ -4,8 +4,8 @@
* Reuses FanChart from the What-If page. If the scenario has no MC run
* yet, prompts the user to run /recompute.
*/
import { useQuery } from '@tanstack/react-query';
import { Link, useParams } from 'react-router-dom';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Link, useNavigate, useParams } from 'react-router-dom';
import { api } from '@/api/client';
import { ApiError } from '@/api/client';
@ -15,6 +15,8 @@ import { gbp, pct } from '@/lib/format';
export function ScenarioDetail() {
const params = useParams<{ id: string }>();
const id = Number(params.id);
const navigate = useNavigate();
const qc = useQueryClient();
const scen = useQuery({
queryKey: ['scenarios', id],
@ -32,6 +34,19 @@ export function ScenarioDetail() {
},
});
const del = useMutation({
mutationFn: () => api.scenarios.delete(id),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['scenarios'] });
navigate('/scenarios');
},
});
const onDelete = () => {
if (!confirm(`Delete scenario "${scen.data?.name ?? id}"? This can't be undone.`)) return;
del.mutate();
};
if (!Number.isFinite(id)) {
return <p className="text-red-700">Invalid scenario id.</p>;
}
@ -57,14 +72,31 @@ export function ScenarioDetail() {
</Link>
</div>
<header>
<h1 className="text-3xl font-semibold tracking-tight">{s.name ?? s.external_id}</h1>
<p className="text-sm text-slate-500 mt-1">
{s.kind} · {s.jurisdiction} · {s.strategy} · leave UK y{s.leave_uk_year} ·{' '}
{s.glide_path} glide · {s.horizon_years}y horizon
</p>
{s.description && <p className="text-sm text-slate-600 mt-2">{s.description}</p>}
<header className="flex items-start justify-between gap-4">
<div>
<h1 className="text-3xl font-semibold tracking-tight">{s.name ?? s.external_id}</h1>
<p className="text-sm text-slate-500 mt-1">
{s.kind} · {s.jurisdiction} · {s.strategy} · leave UK y{s.leave_uk_year} ·{' '}
{s.glide_path} glide · {s.horizon_years}y horizon
</p>
{s.description && <p className="text-sm text-slate-600 mt-2">{s.description}</p>}
</div>
{s.kind === 'user' && (
<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>
)}
</header>
{del.isError && (
<div className="rounded-md border border-red-200 bg-red-50 p-3 text-red-800 text-sm">
{String((del.error as Error)?.message ?? del.error)}
</div>
)}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<Stat label="Spending" value={gbp(s.spending_gbp)} />

View file

@ -0,0 +1,235 @@
/**
* Create a user scenario via POST /scenarios. Redirects to the detail
* page on success.
*/
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import { Link, useNavigate } 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'];
const DEFAULTS: ScenarioCreateBody = {
name: '',
description: '',
jurisdiction: 'cyprus',
strategy: 'guyton_klinger',
leave_uk_year: 2,
glide_path: 'rising',
spending_gbp: '60000',
nw_seed_gbp: '1500000',
savings_per_year_gbp: '0',
horizon_years: 60,
};
export function ScenarioNew() {
const [form, setForm] = useState<ScenarioCreateBody>(DEFAULTS);
const [nameError, setNameError] = useState<string | null>(null);
const navigate = useNavigate();
const qc = useQueryClient();
const create = useMutation({
mutationFn: (body: ScenarioCreateBody) => api.scenarios.create(body),
onSuccess: (s) => {
qc.invalidateQueries({ queryKey: ['scenarios'] });
navigate(`/scenarios/${s.id}`);
},
});
const update = <K extends keyof ScenarioCreateBody>(key: K, value: ScenarioCreateBody[K]) =>
setForm((f) => ({ ...f, [key]: value }));
const onSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!form.name.trim()) {
setNameError('Required');
return;
}
setNameError(null);
create.mutate(form);
};
return (
<section className="space-y-6">
<div className="text-sm">
<Link to="/scenarios" className="text-slate-500 hover:text-slate-900">
Scenarios
</Link>
</div>
<header>
<h1 className="text-3xl font-semibold tracking-tight">New scenario</h1>
<p className="text-sm text-slate-500">
User scenarios survive recomputes. Cartesian ones come from <code>/recompute</code>.
</p>
</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 *" error={nameError}>
<input
type="text"
value={form.name}
onChange={(e) => update('name', e.target.value)}
placeholder="Aggressive FIRE"
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)}
placeholder="Optional notes"
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>
{create.isError && (
<div className="md:col-span-2 rounded-md border border-red-200 bg-red-50 p-3 text-red-800 text-sm">
{String((create.error as Error)?.message ?? create.error)}
</div>
)}
<div className="md:col-span-2 flex items-center gap-3 pt-2">
<button
type="submit"
disabled={create.isPending}
className="rounded-md bg-slate-900 text-white text-sm font-medium px-5 py-2 hover:bg-slate-800 disabled:opacity-60 disabled:cursor-not-allowed"
>
{create.isPending ? 'Creating…' : 'Create scenario'}
</button>
<Link to="/scenarios" className="text-sm text-slate-500 hover:text-slate-900">
Cancel
</Link>
</div>
</form>
</section>
);
}
function Field({
label,
error,
children,
}: {
label: string;
error?: string | null;
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>
{error && <span className="text-xs text-red-600 mt-1 block">{error}</span>}
</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"
/>
);
}

View file

@ -30,7 +30,15 @@ export function Scenarios() {
Persisted plans. Cartesian rebuilt from <code>/recompute</code>; user-defined survive.
</p>
</div>
<FilterChips value={filter} onChange={setFilter} />
<div className="flex items-center gap-3">
<FilterChips value={filter} onChange={setFilter} />
<Link
to="/scenarios/new"
className="rounded-md bg-slate-900 text-white text-sm font-medium px-4 py-2 hover:bg-slate-800"
>
New
</Link>
</div>
</header>
{scenarios.isLoading ? (