From 60c275cd052f8d0dc5e71bf94f98bff4bd15d100 Mon Sep 17 00:00:00 2001 From: Viktor Barzin Date: Sat, 9 May 2026 22:11:54 +0000 Subject: [PATCH] frontend: scenario create + delete (CRUD loop closes) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /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 --- frontend/src/App.tsx | 2 + frontend/src/api/client.ts | 18 ++ frontend/src/pages/ScenarioDetail.tsx | 50 +++++- frontend/src/pages/ScenarioNew.tsx | 235 ++++++++++++++++++++++++++ frontend/src/pages/Scenarios.tsx | 10 +- 5 files changed, 305 insertions(+), 10 deletions(-) create mode 100644 frontend/src/pages/ScenarioNew.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 2b6e31b..341ef33 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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() { } /> } /> + } /> } /> } /> diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index d5b47e5..a680062 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -70,11 +70,29 @@ export const api = { request(`/scenarios${kind ? `?kind=${kind}` : ''}`), get: (id: number) => request(`/scenarios/${id}`), projection: (id: number) => request(`/scenarios/${id}/projection`), + create: (body: ScenarioCreateBody) => + request('/scenarios', { method: 'POST', body: JSON.stringify(body) }), + delete: (id: number) => request(`/scenarios/${id}`, { method: 'DELETE' }), }, simulate: (req: SimulateRequest) => request('/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; +} + export interface Scenario { id: number; external_id: string; diff --git a/frontend/src/pages/ScenarioDetail.tsx b/frontend/src/pages/ScenarioDetail.tsx index b8fd8cf..d5882de 100644 --- a/frontend/src/pages/ScenarioDetail.tsx +++ b/frontend/src/pages/ScenarioDetail.tsx @@ -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

Invalid scenario id.

; } @@ -57,14 +72,31 @@ export function ScenarioDetail() { -
-

{s.name ?? s.external_id}

-

- {s.kind} · {s.jurisdiction} · {s.strategy} · leave UK y{s.leave_uk_year} ·{' '} - {s.glide_path} glide · {s.horizon_years}y horizon -

- {s.description &&

{s.description}

} +
+
+

{s.name ?? s.external_id}

+

+ {s.kind} · {s.jurisdiction} · {s.strategy} · leave UK y{s.leave_uk_year} ·{' '} + {s.glide_path} glide · {s.horizon_years}y horizon +

+ {s.description &&

{s.description}

} +
+ {s.kind === 'user' && ( + + )}
+ {del.isError && ( +
+ {String((del.error as Error)?.message ?? del.error)} +
+ )}
diff --git a/frontend/src/pages/ScenarioNew.tsx b/frontend/src/pages/ScenarioNew.tsx new file mode 100644 index 0000000..3333895 --- /dev/null +++ b/frontend/src/pages/ScenarioNew.tsx @@ -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(DEFAULTS); + const [nameError, setNameError] = useState(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 = (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 ( +
+
+ + ← Scenarios + +
+ +
+

New scenario

+

+ User scenarios survive recomputes. Cartesian ones come from /recompute. +

+
+ +
+ + 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" + /> + + + 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" + /> + + + + update('strategy', v)} options={STRATEGIES} /> + + + 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) => ( + + ))} + + ); +} + +function NumberInput({ + value, + onChange, + min, + max, + step = 1, +}: { + value: number; + onChange: (v: number) => void; + min?: number; + max?: number; + step?: number; +}) { + return ( + { + 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" + /> + ); +} diff --git a/frontend/src/pages/Scenarios.tsx b/frontend/src/pages/Scenarios.tsx index dbbd3c7..d634393 100644 --- a/frontend/src/pages/Scenarios.tsx +++ b/frontend/src/pages/Scenarios.tsx @@ -30,7 +30,15 @@ export function Scenarios() { Persisted plans. Cartesian rebuilt from /recompute; user-defined survive.

- +
+ + + New + +
{scenarios.isLoading ? (