frontend: life events + retirement goals sections on scenario detail
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Two new nested CRUD sections on /scenarios/:id, each list + add form
in one card:
- Life events: name, kind (free-text with datalist suggestions —
retirement, kid_born, mortgage_payoff, sabbatical, inheritance...),
year_start, optional year_end (one-time vs ranged), £/year delta.
One-line summary per row; Delete button per item.
- Goals: name, kind (target_nw, never_run_out, inheritance,
spending_floor), comparator (>= < etc), target amount, target year,
success threshold (probability bar). Same list+add+delete layout.
Both wire through the existing FastAPI endpoints (POST/GET on
nested paths, DELETE on flat /life-events/{id} and /goals/{id})
already shipped in Phase 0c. Mutations invalidate per-scenario
queries so the list refreshes immediately.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
b2af5c5893
commit
18981459b3
4 changed files with 501 additions and 0 deletions
|
|
@ -93,6 +93,82 @@ export interface ScenarioCreateBody {
|
|||
config_json?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// ── life events ──────────────────────────────────────────────────────
|
||||
|
||||
export interface LifeEvent {
|
||||
id: number;
|
||||
scenario_id: number;
|
||||
kind: string;
|
||||
name: string;
|
||||
year_start: number;
|
||||
year_end: number | null;
|
||||
delta_gbp_per_year: string;
|
||||
one_time_amount_gbp: string | null;
|
||||
enabled: boolean;
|
||||
payload: Record<string, unknown> | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface LifeEventCreateBody {
|
||||
kind: string;
|
||||
name: string;
|
||||
year_start: number;
|
||||
year_end?: number | null;
|
||||
delta_gbp_per_year?: string;
|
||||
one_time_amount_gbp?: string | null;
|
||||
enabled?: boolean;
|
||||
payload?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export const lifeEventsApi = {
|
||||
list: (scenarioId: number) =>
|
||||
request<LifeEvent[]>(`/scenarios/${scenarioId}/life-events`),
|
||||
create: (scenarioId: number, body: LifeEventCreateBody) =>
|
||||
request<LifeEvent>(`/scenarios/${scenarioId}/life-events`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
delete: (eventId: number) =>
|
||||
request<void>(`/life-events/${eventId}`, { method: 'DELETE' }),
|
||||
};
|
||||
|
||||
// ── goals ────────────────────────────────────────────────────────────
|
||||
|
||||
export interface Goal {
|
||||
id: number;
|
||||
scenario_id: number;
|
||||
kind: string;
|
||||
name: string;
|
||||
target_amount_gbp: string | null;
|
||||
target_year: number | null;
|
||||
comparator: string;
|
||||
success_threshold: string;
|
||||
enabled: boolean;
|
||||
payload: Record<string, unknown> | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface GoalCreateBody {
|
||||
kind: string;
|
||||
name: string;
|
||||
target_amount_gbp?: string | null;
|
||||
target_year?: number | null;
|
||||
comparator?: string;
|
||||
success_threshold?: string;
|
||||
enabled?: boolean;
|
||||
payload?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export const goalsApi = {
|
||||
list: (scenarioId: number) => request<Goal[]>(`/scenarios/${scenarioId}/goals`),
|
||||
create: (scenarioId: number, body: GoalCreateBody) =>
|
||||
request<Goal>(`/scenarios/${scenarioId}/goals`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
delete: (goalId: number) => request<void>(`/goals/${goalId}`, { method: 'DELETE' }),
|
||||
};
|
||||
|
||||
export interface Scenario {
|
||||
id: number;
|
||||
external_id: string;
|
||||
|
|
|
|||
208
frontend/src/components/GoalsSection.tsx
Normal file
208
frontend/src/components/GoalsSection.tsx
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
/**
|
||||
* Retirement goals nested under a scenario. Inline list + add form.
|
||||
*/
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { goalsApi, type Goal, type GoalCreateBody } from '@/api/client';
|
||||
import { gbp, pct } from '@/lib/format';
|
||||
|
||||
const KIND_SUGGESTIONS = ['target_nw', 'never_run_out', 'inheritance', 'spending_floor'];
|
||||
const COMPARATORS = ['>=', '>', '<=', '<', '='];
|
||||
|
||||
const EMPTY_FORM: GoalCreateBody = {
|
||||
kind: 'target_nw',
|
||||
name: '',
|
||||
target_amount_gbp: '2000000',
|
||||
target_year: 15,
|
||||
comparator: '>=',
|
||||
success_threshold: '0.95',
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
export function GoalsSection({ scenarioId }: { scenarioId: number }) {
|
||||
const goals = useQuery({
|
||||
queryKey: ['scenarios', scenarioId, 'goals'],
|
||||
queryFn: () => goalsApi.list(scenarioId),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-slate-200 bg-white p-5">
|
||||
<h2 className="text-lg font-semibold mb-4">Retirement goals</h2>
|
||||
{goals.isLoading ? (
|
||||
<p className="text-sm text-slate-500">Loading…</p>
|
||||
) : goals.isError ? (
|
||||
<p className="text-sm text-red-700">Failed to load goals.</p>
|
||||
) : (
|
||||
<GoalsList goals={goals.data ?? []} />
|
||||
)}
|
||||
<AddGoalForm scenarioId={scenarioId} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GoalsList({ goals }: { goals: Goal[] }) {
|
||||
const qc = useQueryClient();
|
||||
const del = useMutation({
|
||||
mutationFn: (id: number) => goalsApi.delete(id),
|
||||
onSettled: () =>
|
||||
qc.invalidateQueries({ predicate: (q) => q.queryKey.includes('goals') }),
|
||||
});
|
||||
|
||||
if (goals.length === 0) {
|
||||
return <p className="text-sm text-slate-500 mb-4">No goals yet.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="space-y-2 mb-5">
|
||||
{goals.map((g) => (
|
||||
<li
|
||||
key={g.id}
|
||||
className="flex items-center justify-between rounded-md border border-slate-200 bg-slate-50 px-4 py-2 text-sm"
|
||||
>
|
||||
<div>
|
||||
<div className="font-medium">{g.name}</div>
|
||||
<div className="text-xs text-slate-500">
|
||||
{g.kind}
|
||||
{g.target_amount_gbp ? ` · ${g.comparator} ${gbp(g.target_amount_gbp)}` : ''}
|
||||
{g.target_year !== null ? ` · year ${g.target_year}` : ''}
|
||||
{' · threshold '}
|
||||
{pct(g.success_threshold)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => del.mutate(g.id)}
|
||||
disabled={del.isPending}
|
||||
aria-label={`Delete ${g.name}`}
|
||||
className="text-xs text-slate-500 hover:text-red-700 px-2 py-1 disabled:opacity-50"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
function AddGoalForm({ scenarioId }: { scenarioId: number }) {
|
||||
const [form, setForm] = useState<GoalCreateBody>(EMPTY_FORM);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const qc = useQueryClient();
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: (body: GoalCreateBody) => goalsApi.create(scenarioId, body),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['scenarios', scenarioId, 'goals'] });
|
||||
setForm(EMPTY_FORM);
|
||||
setErr(null);
|
||||
},
|
||||
onError: (e) => setErr(String((e as Error)?.message ?? e)),
|
||||
});
|
||||
|
||||
const update = <K extends keyof GoalCreateBody>(k: K, v: GoalCreateBody[K]) =>
|
||||
setForm((f) => ({ ...f, [k]: v }));
|
||||
|
||||
const onSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!form.name.trim()) {
|
||||
setErr('Name required');
|
||||
return;
|
||||
}
|
||||
setErr(null);
|
||||
create.mutate(form);
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={onSubmit}
|
||||
className="grid grid-cols-1 md:grid-cols-6 gap-3 items-end pt-3 border-t border-slate-100"
|
||||
>
|
||||
<label className="md:col-span-2 text-xs">
|
||||
<span className="uppercase tracking-wide text-slate-500">Name</span>
|
||||
<input
|
||||
type="text"
|
||||
value={form.name}
|
||||
onChange={(e) => update('name', e.target.value)}
|
||||
placeholder="≥ £2M at 50"
|
||||
className="mt-1 w-full rounded-md border border-slate-300 px-2 py-1.5 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<label className="text-xs">
|
||||
<span className="uppercase tracking-wide text-slate-500">Kind</span>
|
||||
<input
|
||||
type="text"
|
||||
value={form.kind}
|
||||
onChange={(e) => update('kind', e.target.value)}
|
||||
list="goal-kind-suggestions"
|
||||
className="mt-1 w-full rounded-md border border-slate-300 px-2 py-1.5 text-sm"
|
||||
/>
|
||||
<datalist id="goal-kind-suggestions">
|
||||
{KIND_SUGGESTIONS.map((k) => (
|
||||
<option key={k} value={k} />
|
||||
))}
|
||||
</datalist>
|
||||
</label>
|
||||
<label className="text-xs">
|
||||
<span className="uppercase tracking-wide text-slate-500">Cmp</span>
|
||||
<select
|
||||
value={form.comparator ?? '>='}
|
||||
onChange={(e) => update('comparator', e.target.value)}
|
||||
className="mt-1 w-full rounded-md border border-slate-300 px-2 py-1.5 text-sm"
|
||||
>
|
||||
{COMPARATORS.map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{c}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="text-xs">
|
||||
<span className="uppercase tracking-wide text-slate-500">Amount £</span>
|
||||
<input
|
||||
type="number"
|
||||
value={Number(form.target_amount_gbp ?? 0)}
|
||||
step={10000}
|
||||
onChange={(e) => update('target_amount_gbp', String(e.target.value))}
|
||||
className="mt-1 w-full rounded-md border border-slate-300 px-2 py-1.5 text-sm tabular-nums"
|
||||
/>
|
||||
</label>
|
||||
<label className="text-xs">
|
||||
<span className="uppercase tracking-wide text-slate-500">Year</span>
|
||||
<input
|
||||
type="number"
|
||||
value={form.target_year ?? ''}
|
||||
min={0}
|
||||
max={100}
|
||||
placeholder="—"
|
||||
onChange={(e) =>
|
||||
update('target_year', e.target.value === '' ? null : Number(e.target.value))
|
||||
}
|
||||
className="mt-1 w-full rounded-md border border-slate-300 px-2 py-1.5 text-sm tabular-nums"
|
||||
/>
|
||||
</label>
|
||||
<label className="text-xs">
|
||||
<span className="uppercase tracking-wide text-slate-500">Threshold</span>
|
||||
<input
|
||||
type="number"
|
||||
value={Number(form.success_threshold ?? 0.95)}
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
onChange={(e) => update('success_threshold', String(e.target.value))}
|
||||
className="mt-1 w-full rounded-md border border-slate-300 px-2 py-1.5 text-sm tabular-nums"
|
||||
/>
|
||||
</label>
|
||||
<div className="md:col-span-6 flex items-center gap-3">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={create.isPending}
|
||||
className="rounded-md bg-slate-900 text-white text-sm font-medium px-3 py-1.5 hover:bg-slate-800 disabled:opacity-60"
|
||||
>
|
||||
{create.isPending ? 'Adding…' : 'Add goal'}
|
||||
</button>
|
||||
{err && <span className="text-xs text-red-700">{err}</span>}
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
212
frontend/src/components/LifeEventsSection.tsx
Normal file
212
frontend/src/components/LifeEventsSection.tsx
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
/**
|
||||
* Life events nested under a scenario. Inline list + add form.
|
||||
*
|
||||
* Event kinds are free-text on the backend; we suggest common ones via
|
||||
* a datalist but let users type anything. Year range optional — leave
|
||||
* year_end blank for one-time events.
|
||||
*/
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useState } from 'react';
|
||||
|
||||
import {
|
||||
lifeEventsApi,
|
||||
type LifeEvent,
|
||||
type LifeEventCreateBody,
|
||||
} from '@/api/client';
|
||||
import { gbp } from '@/lib/format';
|
||||
|
||||
const KIND_SUGGESTIONS = [
|
||||
'retirement',
|
||||
'partner_retirement',
|
||||
'kid_born',
|
||||
'kids_leave_home',
|
||||
'mortgage_payoff',
|
||||
'home_purchase',
|
||||
'sabbatical',
|
||||
'inheritance',
|
||||
'expense_range',
|
||||
'one_time_income',
|
||||
];
|
||||
|
||||
const EMPTY_FORM: LifeEventCreateBody = {
|
||||
kind: 'retirement',
|
||||
name: '',
|
||||
year_start: 0,
|
||||
year_end: null,
|
||||
delta_gbp_per_year: '0',
|
||||
one_time_amount_gbp: null,
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
export function LifeEventsSection({ scenarioId }: { scenarioId: number }) {
|
||||
const events = useQuery({
|
||||
queryKey: ['scenarios', scenarioId, 'life-events'],
|
||||
queryFn: () => lifeEventsApi.list(scenarioId),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-slate-200 bg-white p-5">
|
||||
<h2 className="text-lg font-semibold mb-4">Life events</h2>
|
||||
{events.isLoading ? (
|
||||
<p className="text-sm text-slate-500">Loading…</p>
|
||||
) : events.isError ? (
|
||||
<p className="text-sm text-red-700">Failed to load events.</p>
|
||||
) : (
|
||||
<EventsList events={events.data ?? []} />
|
||||
)}
|
||||
<AddEventForm scenarioId={scenarioId} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EventsList({ events }: { events: LifeEvent[] }) {
|
||||
const qc = useQueryClient();
|
||||
const del = useMutation({
|
||||
mutationFn: (id: number) => lifeEventsApi.delete(id),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['life-events'] }),
|
||||
// also invalidate the scenario-scoped list:
|
||||
onSettled: () => qc.invalidateQueries({ predicate: (q) => q.queryKey.includes('life-events') }),
|
||||
});
|
||||
|
||||
if (events.length === 0) {
|
||||
return <p className="text-sm text-slate-500 mb-4">No events yet.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="space-y-2 mb-5">
|
||||
{events.map((ev) => (
|
||||
<li
|
||||
key={ev.id}
|
||||
className="flex items-center justify-between rounded-md border border-slate-200 bg-slate-50 px-4 py-2 text-sm"
|
||||
>
|
||||
<div>
|
||||
<div className="font-medium">{ev.name}</div>
|
||||
<div className="text-xs text-slate-500">
|
||||
{ev.kind} · year {ev.year_start}
|
||||
{ev.year_end !== null && ev.year_end !== ev.year_start ? `–${ev.year_end}` : ''}
|
||||
{Number(ev.delta_gbp_per_year) !== 0 ? ` · ${gbp(ev.delta_gbp_per_year)}/y` : ''}
|
||||
{ev.one_time_amount_gbp ? ` · one-time ${gbp(ev.one_time_amount_gbp)}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => del.mutate(ev.id)}
|
||||
disabled={del.isPending}
|
||||
aria-label={`Delete ${ev.name}`}
|
||||
className="text-xs text-slate-500 hover:text-red-700 px-2 py-1 disabled:opacity-50"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
function AddEventForm({ scenarioId }: { scenarioId: number }) {
|
||||
const [form, setForm] = useState<LifeEventCreateBody>(EMPTY_FORM);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const qc = useQueryClient();
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: (body: LifeEventCreateBody) => lifeEventsApi.create(scenarioId, body),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['scenarios', scenarioId, 'life-events'] });
|
||||
setForm(EMPTY_FORM);
|
||||
setErr(null);
|
||||
},
|
||||
onError: (e) => setErr(String((e as Error)?.message ?? e)),
|
||||
});
|
||||
|
||||
const update = <K extends keyof LifeEventCreateBody>(k: K, v: LifeEventCreateBody[K]) =>
|
||||
setForm((f) => ({ ...f, [k]: v }));
|
||||
|
||||
const onSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!form.name.trim()) {
|
||||
setErr('Name required');
|
||||
return;
|
||||
}
|
||||
if (form.year_end !== null && form.year_end !== undefined && form.year_end < form.year_start) {
|
||||
setErr('year_end must be ≥ year_start');
|
||||
return;
|
||||
}
|
||||
setErr(null);
|
||||
create.mutate(form);
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={onSubmit} className="grid grid-cols-1 md:grid-cols-6 gap-3 items-end pt-3 border-t border-slate-100">
|
||||
<label className="md:col-span-2 text-xs">
|
||||
<span className="uppercase tracking-wide text-slate-500">Name</span>
|
||||
<input
|
||||
type="text"
|
||||
value={form.name}
|
||||
onChange={(e) => update('name', e.target.value)}
|
||||
placeholder="Retire at 50"
|
||||
className="mt-1 w-full rounded-md border border-slate-300 px-2 py-1.5 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<label className="text-xs">
|
||||
<span className="uppercase tracking-wide text-slate-500">Kind</span>
|
||||
<input
|
||||
type="text"
|
||||
value={form.kind}
|
||||
onChange={(e) => update('kind', e.target.value)}
|
||||
list="event-kind-suggestions"
|
||||
className="mt-1 w-full rounded-md border border-slate-300 px-2 py-1.5 text-sm"
|
||||
/>
|
||||
<datalist id="event-kind-suggestions">
|
||||
{KIND_SUGGESTIONS.map((k) => (
|
||||
<option key={k} value={k} />
|
||||
))}
|
||||
</datalist>
|
||||
</label>
|
||||
<label className="text-xs">
|
||||
<span className="uppercase tracking-wide text-slate-500">Year start</span>
|
||||
<input
|
||||
type="number"
|
||||
value={form.year_start}
|
||||
min={0}
|
||||
max={100}
|
||||
onChange={(e) => update('year_start', Number(e.target.value))}
|
||||
className="mt-1 w-full rounded-md border border-slate-300 px-2 py-1.5 text-sm tabular-nums"
|
||||
/>
|
||||
</label>
|
||||
<label className="text-xs">
|
||||
<span className="uppercase tracking-wide text-slate-500">Year end</span>
|
||||
<input
|
||||
type="number"
|
||||
value={form.year_end ?? ''}
|
||||
min={0}
|
||||
max={100}
|
||||
placeholder="—"
|
||||
onChange={(e) =>
|
||||
update('year_end', e.target.value === '' ? null : Number(e.target.value))
|
||||
}
|
||||
className="mt-1 w-full rounded-md border border-slate-300 px-2 py-1.5 text-sm tabular-nums"
|
||||
/>
|
||||
</label>
|
||||
<label className="text-xs">
|
||||
<span className="uppercase tracking-wide text-slate-500">£ / year</span>
|
||||
<input
|
||||
type="number"
|
||||
value={Number(form.delta_gbp_per_year ?? 0)}
|
||||
step={1000}
|
||||
onChange={(e) => update('delta_gbp_per_year', String(e.target.value))}
|
||||
className="mt-1 w-full rounded-md border border-slate-300 px-2 py-1.5 text-sm tabular-nums"
|
||||
/>
|
||||
</label>
|
||||
<div className="md:col-span-6 flex items-center gap-3">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={create.isPending}
|
||||
className="rounded-md bg-slate-900 text-white text-sm font-medium px-3 py-1.5 hover:bg-slate-800 disabled:opacity-60"
|
||||
>
|
||||
{create.isPending ? 'Adding…' : 'Add event'}
|
||||
</button>
|
||||
{err && <span className="text-xs text-red-700">{err}</span>}
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
|
@ -10,6 +10,8 @@ import { Link, useNavigate, useParams } from 'react-router-dom';
|
|||
import { api } from '@/api/client';
|
||||
import { ApiError } from '@/api/client';
|
||||
import { FanChart } from '@/components/FanChart';
|
||||
import { GoalsSection } from '@/components/GoalsSection';
|
||||
import { LifeEventsSection } from '@/components/LifeEventsSection';
|
||||
import { gbp, pct } from '@/lib/format';
|
||||
|
||||
export function ScenarioDetail() {
|
||||
|
|
@ -141,6 +143,9 @@ export function ScenarioDetail() {
|
|||
{String((proj.error as Error)?.message ?? proj.error)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<LifeEventsSection scenarioId={id} />
|
||||
<GoalsSection scenarioId={id} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue