diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index a680062..36b27a9 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -93,6 +93,82 @@ export interface ScenarioCreateBody { config_json?: Record; } +// ── 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 | 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 | null; +} + +export const lifeEventsApi = { + list: (scenarioId: number) => + request(`/scenarios/${scenarioId}/life-events`), + create: (scenarioId: number, body: LifeEventCreateBody) => + request(`/scenarios/${scenarioId}/life-events`, { + method: 'POST', + body: JSON.stringify(body), + }), + delete: (eventId: number) => + request(`/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 | 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 | null; +} + +export const goalsApi = { + list: (scenarioId: number) => request(`/scenarios/${scenarioId}/goals`), + create: (scenarioId: number, body: GoalCreateBody) => + request(`/scenarios/${scenarioId}/goals`, { + method: 'POST', + body: JSON.stringify(body), + }), + delete: (goalId: number) => request(`/goals/${goalId}`, { method: 'DELETE' }), +}; + export interface Scenario { id: number; external_id: string; diff --git a/frontend/src/components/GoalsSection.tsx b/frontend/src/components/GoalsSection.tsx new file mode 100644 index 0000000..b86095b --- /dev/null +++ b/frontend/src/components/GoalsSection.tsx @@ -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 ( +
+

Retirement goals

+ {goals.isLoading ? ( +

Loading…

+ ) : goals.isError ? ( +

Failed to load goals.

+ ) : ( + + )} + +
+ ); +} + +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

No goals yet.

; + } + + return ( +
    + {goals.map((g) => ( +
  • +
    +
    {g.name}
    +
    + {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)} +
    +
    + +
  • + ))} +
+ ); +} + +function AddGoalForm({ scenarioId }: { scenarioId: number }) { + const [form, setForm] = useState(EMPTY_FORM); + const [err, setErr] = useState(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: 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 ( +
+ + + + + + +
+ + {err && {err}} +
+
+ ); +} diff --git a/frontend/src/components/LifeEventsSection.tsx b/frontend/src/components/LifeEventsSection.tsx new file mode 100644 index 0000000..fe53438 --- /dev/null +++ b/frontend/src/components/LifeEventsSection.tsx @@ -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 ( +
+

Life events

+ {events.isLoading ? ( +

Loading…

+ ) : events.isError ? ( +

Failed to load events.

+ ) : ( + + )} + +
+ ); +} + +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

No events yet.

; + } + + return ( +
    + {events.map((ev) => ( +
  • +
    +
    {ev.name}
    +
    + {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)}` : ''} +
    +
    + +
  • + ))} +
+ ); +} + +function AddEventForm({ scenarioId }: { scenarioId: number }) { + const [form, setForm] = useState(EMPTY_FORM); + const [err, setErr] = useState(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: 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 ( +
+ + + + + +
+ + {err && {err}} +
+
+ ); +} diff --git a/frontend/src/pages/ScenarioDetail.tsx b/frontend/src/pages/ScenarioDetail.tsx index d5882de..0722cbe 100644 --- a/frontend/src/pages/ScenarioDetail.tsx +++ b/frontend/src/pages/ScenarioDetail.tsx @@ -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)} )} + + + ); }