);
}
@@ -368,15 +384,17 @@ function FloatingStats(props: {
function Badge({
label,
value,
+ sub,
accent,
signed,
}: {
label: string;
value: string;
+ sub?: string;
accent?: boolean;
signed?: string;
}) {
- let cls = 'text-slate-700';
+ let cls = 'text-slate-800';
if (accent) cls = 'text-emerald-700';
if (signed != null) {
const n = Number(signed);
@@ -384,13 +402,21 @@ function Badge({
else if (n < 0) cls = 'text-red-600';
}
return (
-
-
{label}
-
{value}
+
+
{label}
+
+ {value}
+ {sub && {sub}}
+
);
}
+function isRuined(networth?: string): boolean {
+ if (!networth) return false;
+ return Number(networth) <= 0;
+}
+
function Legend() {
return (
diff --git a/frontend/src/pages/ScenarioShell.tsx b/frontend/src/pages/ScenarioShell.tsx
index acbdaaa..d29e491 100644
--- a/frontend/src/pages/ScenarioShell.tsx
+++ b/frontend/src/pages/ScenarioShell.tsx
@@ -15,10 +15,10 @@ export function ScenarioShell() {
const tabs: TabSpec[] = [
{ to: `/scenarios/${id}`, label: 'Plan', end: true },
{ to: `/scenarios/${id}/cash-flow`, label: 'Cash Flow' },
- { to: `/scenarios/${id}/tax-analytics`, label: 'Tax Analytics' },
- { to: `/scenarios/${id}/compare`, label: 'Compare' },
- { to: `/scenarios/${id}/reports`, label: 'Reports' },
- { to: `/scenarios/${id}/estate`, label: 'Estate' },
+ { to: `/scenarios/${id}/tax-analytics`, label: 'Tax Analytics', stub: true },
+ { to: `/scenarios/${id}/compare`, label: 'Compare', stub: true },
+ { to: `/scenarios/${id}/reports`, label: 'Reports', stub: true },
+ { to: `/scenarios/${id}/estate`, label: 'Estate', stub: true },
{ to: `/scenarios/${id}/settings`, label: 'Settings' },
];
diff --git a/frontend/src/pages/SettingsTab.tsx b/frontend/src/pages/SettingsTab.tsx
index da2643e..d47db96 100644
--- a/frontend/src/pages/SettingsTab.tsx
+++ b/frontend/src/pages/SettingsTab.tsx
@@ -11,12 +11,12 @@ export function SettingsTab() {
const items = [
{ to: `/scenarios/${id}/settings`, label: 'Milestones', end: true },
{ to: `/scenarios/${id}/settings/rates`, label: 'Rates' },
- { to: `/scenarios/${id}/settings/dividends`, label: 'Dividends' },
- { to: `/scenarios/${id}/settings/bonds`, label: 'Bonds' },
- { to: `/scenarios/${id}/settings/tax`, label: 'Tax' },
- { to: `/scenarios/${id}/settings/metrics`, label: 'Metrics' },
- { to: `/scenarios/${id}/settings/other`, label: 'Other Settings' },
{ to: `/scenarios/${id}/settings/notes`, label: 'Notes' },
+ { to: `/scenarios/${id}/settings/dividends`, label: 'Dividends', stub: true },
+ { to: `/scenarios/${id}/settings/bonds`, label: 'Bonds', stub: true },
+ { to: `/scenarios/${id}/settings/tax`, label: 'Tax', stub: true },
+ { to: `/scenarios/${id}/settings/metrics`, label: 'Metrics', stub: true },
+ { to: `/scenarios/${id}/settings/other`, label: 'Other', stub: true },
];
return (
diff --git a/tests/test_api_scenarios.py b/tests/test_api_scenarios.py
index 65d72a1..3c9e29f 100644
--- a/tests/test_api_scenarios.py
+++ b/tests/test_api_scenarios.py
@@ -153,11 +153,30 @@ async def test_patch_user_scenario(client: AsyncClient) -> None:
assert body["leave_uk_year"] == 2
-async def test_patch_cartesian_blocked(client: AsyncClient, session: AsyncSession) -> None:
+async def test_patch_cartesian_core_fields_blocked(
+ client: AsyncClient, session: AsyncSession,
+) -> None:
+ """Cartesian scenarios reject edits to fields that get rebuilt by
+ recompute (jurisdiction/strategy/etc), but allow free-form metadata
+ (config_json/name/description) so users can pin notes + flex_rules."""
cart = await _seed(session)
- resp = await client.patch(f"/scenarios/{cart.id}", json={"name": "Renamed"})
- assert resp.status_code == 400
- assert "cartesian" in resp.json()["detail"]
+
+ # Core field — still blocked.
+ bad = await client.patch(f"/scenarios/{cart.id}",
+ json={"jurisdiction": "uae"})
+ assert bad.status_code == 400
+ assert "cartesian" in bad.json()["detail"]
+
+ # config_json and name — allowed (preserves user edits).
+ ok = await client.patch(
+ f"/scenarios/{cart.id}",
+ json={"config_json": {"flex_rules": [{"from_ath_pct": 0.2,
+ "cut_discretionary_pct": 0.5}]},
+ "name": "Renamed"},
+ )
+ assert ok.status_code == 200, ok.text
+ assert ok.json()["name"] == "Renamed"
+ assert ok.json()["config_json"]["flex_rules"][0]["from_ath_pct"] == 0.2
async def test_delete_user_scenario(client: AsyncClient) -> None: