Files
FPT/src/components/SensitivityDialog.tsx
T
admGitAICDS c2fb82b0de
Deploy App / deploy (push) Successful in 1m1s
UI-Gesamtumbau: Du-Form, Inspector-Panel, Onboarding-Assistent, Tour, Palette
Rein an der Oberflaeche -- Berechnung, Datenmodell und API-Semantik unveraendert
(103 Tests unveraendert gruen).

Paket A (Fundament):
- durchgehend Du-Form und echte Umlaute in allen sichtbaren Texten,
  inkl. API-Fehlermeldungen (vorher Mix aus Sie/Du und ae/oe/ue)
- neue UI-Primitiven (ui.tsx): Button, Modal mit ESC/Fokus-Falle/Animation,
  Bestaetigungs-Dialog statt window.confirm, Toasts statt alert,
  Skeleton-Loader, EmptyState
- eigene Attention-Farbe (Amber) fuer offene Entscheide, getrennt vom Akzent
- Micro-Interactions mit prefers-reduced-motion-Fallback

Paket B (Onboarding, Roadmap Nr. 10):
- gefuehrter Plan-Assistent in 5 Schritten; Einkommen bewusst pro Person
  (raeumt die 9.9-AHV-Falle aus); reine Orchestrierung bestehender Endpunkte
- Beispielplan mit einem Klick; Uebergaenge absichtlich offen
- interaktive Tour ueber die Planansicht (localStorage, jederzeit neu startbar)
- abgeleitete "Naechste Schritte"-Karte (offene Entscheide, fehlende Elemente,
  fehlende Pensionsphase, Ruin -> Einflussfaktoren)

Paket C (Struktur):
- Inspector-Panel rechts statt Modals fuer alle Einzel-Bearbeitungen;
  Matrix bleibt sichtbar, Zellklick wechselt den Inhalt
- Phasenkopf auf vier Kern-Infos entschlackt (Rest in der 0.11-Detailansicht)
- Matrix mit eigenem Scrollbereich, Koepfe beidachsig fixiert
- Sidebar-Gruppen "Meine Plaene" / "Wissen"; "So rechnet FPT" statt
  SPEZIFIKATION; "Szenario-Profil" statt "Plan-Einstellungen"
- Aktions-Icons ohne Hover sichtbar (Touch)

Paket D (Extras):
- Sparklines je Element-Zeile aus den 0.11-Verlaufswerten
- Befehls-Palette (Ctrl/Cmd+K)
- Ruin-Banner verlinkt auf die Einflussfaktoren

Nebenbei: der ProfileMenu-Lint-Fehler und der Selection-Rest (9.17) sind
behoben -- npm run lint laeuft erstmals fehlerfrei.

SPEZIFIKATION auf 0.13: neue Kapitel 3.2.8, 3.7.6-3.7.9, 9.23, 9.24;
3.6.3 und 3.7.1 ueberarbeitet, 9.17 bereinigt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 07:40:42 +02:00

320 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useMemo, useState } from "react";
import { Bar, BarChart, CartesianGrid, ReferenceLine, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
import { Tornado, X } from "lucide-react";
import { RequiredNumberField, SelectField } from "@/components/FormField";
import { InfoBubble } from "@/components/InfoBubble";
import { formatChf } from "@/lib/format";
import {
computeTornado,
DRIVERS,
UNIT_SUFFIX,
type DriverDef,
type DriverId,
type TornadoMetric,
type TornadoResult,
} from "@/lib/sensitivity";
import type { PlanInput } from "@/lib/types";
interface RangeDraft {
checked: boolean;
low: string;
high: string;
}
// Bewusst OHNE Defaults: Die Balkenlänge hängt direkt von der eingegebenen Bandbreite ab.
// Ein stiller Default würde nicht hinterfragt und das Ranking wäre dann eine Aussage über
// unsere Vorgabe statt über den Plan (gleiche Begründung wie bei den Monte-Carlo-Mittelwerten).
const emptyDraft: RangeDraft = { checked: false, low: "", high: "" };
function formatRange(low: number, high: number, unit: DriverDef["unit"]): string {
const suffix = UNIT_SUFFIX[unit];
const sign = (v: number) => (unit === "abs_pct" ? `${v}` : v > 0 ? `+${v}` : `${v}`);
return `${sign(low)} ${suffix}${sign(high)} ${suffix}`;
}
export function SensitivityDialog({ plan, onClose }: { plan: PlanInput; onClose: () => void }) {
const available = useMemo(() => DRIVERS.filter((d) => d.applies(plan)), [plan]);
const [metric, setMetric] = useState<TornadoMetric>("real");
const [drafts, setDrafts] = useState<Record<string, RangeDraft>>({});
const [result, setResult] = useState<TornadoResult | null>(null);
function draftFor(id: DriverId): RangeDraft {
return drafts[id] ?? emptyDraft;
}
function setDraft(id: DriverId, patch: Partial<RangeDraft>) {
setDrafts((prev) => ({ ...prev, [id]: { ...draftFor(id), ...patch } }));
setResult(null);
}
const checked = available.filter((d) => draftFor(d.id).checked);
const incomplete = checked.filter((d) => {
const dr = draftFor(d.id);
return dr.low.trim() === "" || dr.high.trim() === "";
});
const degenerate = checked.filter((d) => {
const dr = draftFor(d.id);
return dr.low.trim() !== "" && dr.high.trim() !== "" && Number(dr.low) === Number(dr.high);
});
// Mindestens zwei Treiber: Ein Tornado ist eine RANGLISTE. Mit einem einzigen Balken gibt
// es nichts zu ordnen und die Grafik würde eine Aussage suggerieren, die sie nicht hat.
const canRun =
checked.length >= 2 && incomplete.length === 0 && degenerate.length === 0 && plan.phases.length > 0;
function run() {
setResult(
computeTornado(
plan,
metric,
checked.map((d) => {
const dr = draftFor(d.id);
return { id: d.id, low: Number(dr.low), high: Number(dr.high) };
})
)
);
}
const chartData = useMemo(() => {
if (!result) return [];
// Von unten nach oben gezeichnet -> für die Trichterform (grösster Balken zuoberst)
// muss die Reihenfolge umgekehrt in die Grafik.
return [...result.bars].reverse().map((b) => ({
label: b.shortLabel,
range: [b.min, b.max] as [number, number],
swing: b.swing,
}));
}, [result]);
return (
<div className="fixed inset-0 z-40 flex items-start justify-center overflow-y-auto bg-black/40 px-4 py-8" onClick={onClose}>
<div
onClick={(e) => e.stopPropagation()}
className="flex w-full max-w-4xl flex-col gap-4 rounded-2xl border border-border bg-surface p-6 shadow-xl"
>
<div className="flex items-center justify-between">
<h2 className="flex items-center gap-2 text-base font-semibold text-fg">
<Tornado className="h-5 w-5 text-accent" /> Einflussfaktoren (Sensitivitätsanalyse)
</h2>
<button type="button" onClick={onClose} aria-label="Schliessen" className="rounded-md p-1 text-faint hover:bg-surface-2">
<X className="h-4 w-4" />
</button>
</div>
{/* Erklärung */}
<div className="rounded-xl border border-border bg-surface-2 p-4 text-xs leading-relaxed text-muted">
<p className="mb-2">
<strong className="text-fg">Was ist das?</strong> Nicht «wie viel Geld habe ich am Schluss», sondern
<strong className="text-fg"> «welche meiner Annahmen entscheidet überhaupt über das Ergebnis»</strong>. Bei
manchen Annahmen ist es egal, ob du sie exakt triffst bei anderen kippt eine kleine Abweichung den ganzen Plan.
</p>
<p className="mb-2">
<strong className="text-fg">Wie es funktioniert:</strong> Zuerst wird dein Plan wie erfasst gerechnet
(Basisfall). Dann wird <em>ein einziger</em> Parameter auf seinen tiefen und seinen hohen Wert gesetzt, alle
übrigen bleiben unverändert das ergibt zwei Ergebnisse. Das für jeden Parameter wiederholt und nach
<strong className="text-fg"> Spannweite</strong> sortiert ergibt die Trichterform: längster Balken zuoberst.
</p>
<p className="mb-2">
<strong className="text-fg">Was du davon hast:</strong> Die Reihenfolge ist die Botschaft. Bei den obersten
Balken lohnt sich Genauigkeit (dort exakte Zahlen beschaffen) und sie sind meist auch die, die du selbst
steuern kannst. Bei den untersten darfst du grob schätzen.
</p>
<p>
<strong className="text-fg">Zwei ehrliche Grenzen.</strong> Erstens hängt die Balkenlänge von den
Bandbreiten ab, die du unten eingibst wähle sie so, dass sie <em>ähnlich plausibel</em> sind, nicht ähnlich
gross. Zweitens wird immer nur ein Parameter auf einmal variiert; <em>Kombinationen</em> (schlechte Renditen
<em> und</em> hohe Ausgaben) treffen härter als die Summe der Einzelbalken dafür ist die
Monte-Carlo-Simulation zuständig.
</p>
</div>
{/* Zielgrösse */}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<SelectField
label="Zielgrösse"
help="Woran die Wirkung gemessen wird: das Endvermögen der letzten Lebensphase. «Real» ist kaufkraftbereinigt auf den Planbeginn und damit die ehrlichere Grösse."
value={metric}
onChange={(v: TornadoMetric) => {
setMetric(v);
setResult(null);
}}
options={[
{ value: "real", label: "Endvermögen real (kaufkraftbereinigt)" },
{ value: "nominal", label: "Endvermögen nominal" },
]}
/>
</div>
{/* Parameter */}
<div>
<div className="mb-2 flex items-center text-xs font-semibold uppercase tracking-wide text-faint">
Parameter und Bandbreiten
<InfoBubble text="Hake an, welche Parameter in die Analyse einfliessen sollen, und gib je Parameter eine tiefe und eine hohe Ausprägung an. Es gibt bewusst keine Vorgabewerte: Die Balkenlänge hängt direkt von diesen Bandbreiten ab, deshalb sollst du sie bewusst setzen. Die Hilfe je Parameter nennt plausible Grössenordnungen." />
</div>
<div className="flex flex-col gap-2">
{available.map((d) => {
const dr = draftFor(d.id);
const suffix = UNIT_SUFFIX[d.unit];
const unitHint =
d.unit === "abs_pct"
? "absoluter Wert in %"
: d.unit === "delta_pp"
? "Verschiebung in Prozentpunkten"
: d.unit === "rel_pct"
? "Abweichung vom Planwert in %"
: "Verschiebung in Jahren";
return (
<div
key={d.id}
className={`rounded-xl border p-3 ${dr.checked ? "border-accent bg-accent-soft/30" : "border-border bg-surface-2"}`}
>
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={dr.checked} onChange={(e) => setDraft(d.id, { checked: e.target.checked })} />
<span className="font-medium text-fg">{d.label}</span>
<InfoBubble text={d.help} />
<span className="text-[11px] text-faint">({unitHint})</span>
</label>
{dr.checked && (
<div className="mt-2 grid grid-cols-1 gap-3 sm:grid-cols-3">
<RequiredNumberField
label="tief"
value={dr.low}
suffix={suffix}
step={d.unit === "delta_years" ? 1 : 0.1}
onChange={(v) => setDraft(d.id, { low: v })}
/>
<RequiredNumberField
label="hoch"
value={dr.high}
suffix={suffix}
step={d.unit === "delta_years" ? 1 : 0.1}
onChange={(v) => setDraft(d.id, { high: v })}
/>
</div>
)}
</div>
);
})}
</div>
<p className="mt-2 text-[11px] text-faint">
Das <strong>Pensionsalter</strong> ist bewusst nicht enthalten: Es lässt sich nicht sinnvoll variieren, ohne
gleichzeitig die Phasengrenzen mitzuverschieben sonst arbeitet die Person im Modell unverändert weiter
bzw. der Pensions-Übergang entfällt ganz. Der verwandte Treiber «Lebensdauer» ist dagegen sauber abgebildet.
</p>
</div>
{checked.length < 2 && (
<p className="text-xs text-danger">Bitte mindestens zwei Parameter auswählen der Tornado ist eine Rangliste.</p>
)}
{incomplete.length > 0 && (
<p className="text-xs text-danger">Bitte für jeden ausgewählten Parameter eine tiefe und eine hohe Ausprägung angeben.</p>
)}
{degenerate.length > 0 && (
<p className="text-xs text-danger">
Tiefer und hoher Wert sind identisch bei: {degenerate.map((d) => d.label).join(", ")}. Damit gibt es keine Spannweite.
</p>
)}
<div>
<button
type="button"
disabled={!canRun}
onClick={run}
className="rounded-lg bg-accent px-4 py-2 text-sm font-medium text-accent-fg shadow-sm hover:bg-accent-hover disabled:opacity-50"
>
Einflussfaktoren berechnen
</button>
</div>
{result && <TornadoResults result={result} metric={metric} chartData={chartData} />}
</div>
</div>
);
}
function TornadoResults({
result,
metric,
chartData,
}: {
result: TornadoResult;
metric: TornadoMetric;
chartData: { label: string; range: [number, number]; swing: number }[];
}) {
const metricLabel = metric === "real" ? "Endvermögen real" : "Endvermögen nominal";
return (
<div className="flex flex-col gap-4 border-t border-border pt-4">
<div className="rounded-xl border border-border bg-surface-2 px-4 py-3">
<div className="text-xs text-muted">Basisfall · {metricLabel}</div>
<div className="mt-0.5 text-xl font-semibold text-accent">{formatChf(result.base)} CHF</div>
</div>
<div>
<div className="mb-1 text-xs font-semibold text-fg">Einflussfaktoren nach Spannweite</div>
<p className="mb-2 text-[11px] text-muted">
Jeder Balken zeigt, zwischen welchen Werten das {metricLabel} schwankt, wenn nur dieser eine Parameter
innerhalb seiner Bandbreite variiert. Die senkrechte Linie ist der Basisfall.
</p>
<div className="w-full" style={{ height: Math.max(200, chartData.length * 46 + 60) }}>
<ResponsiveContainer width="100%" height="100%">
<BarChart data={chartData} layout="vertical" margin={{ top: 8, right: 24, left: 8, bottom: 8 }}>
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
<XAxis
type="number"
tick={{ fontSize: 11 }}
tickFormatter={(v) => Intl.NumberFormat("de-CH", { notation: "compact" }).format(v)}
/>
<YAxis type="category" dataKey="label" tick={{ fontSize: 11 }} width={130} />
<Tooltip
formatter={(v) =>
Array.isArray(v)
? `${formatChf(Number(v[0]))} ${formatChf(Number(v[1]))}`
: typeof v === "number"
? formatChf(v)
: v
}
/>
<ReferenceLine x={result.base} stroke="var(--muted)" strokeDasharray="4 3" />
<Bar dataKey="range" name={metricLabel} fill="var(--accent)" radius={[3, 3, 3, 3]} isAnimationActive={false} />
</BarChart>
</ResponsiveContainer>
</div>
</div>
<div className="overflow-x-auto rounded-xl border border-border">
<table className="w-full border-collapse text-sm">
<thead>
<tr className="bg-surface-2 text-xs text-faint">
<th className="px-3 py-2 text-left font-semibold">Parameter</th>
<th className="px-3 py-2 text-left font-semibold">Bandbreite</th>
<th className="px-3 py-2 text-right font-semibold">bei «tief»</th>
<th className="px-3 py-2 text-right font-semibold">bei «hoch»</th>
<th className="px-3 py-2 text-right font-semibold">Spannweite</th>
</tr>
</thead>
<tbody>
{result.bars.map((b) => (
<tr key={b.id} className="border-t border-border">
<td className="px-3 py-2 font-medium text-fg">
{b.label}
{b.note && <div className="mt-0.5 text-[11px] font-normal text-muted">{b.note}</div>}
</td>
<td className="px-3 py-2 text-muted">{formatRange(b.low, b.high, b.unit)}</td>
<td className="px-3 py-2 text-right text-muted">{formatChf(b.lowResult)}</td>
<td className="px-3 py-2 text-right text-muted">{formatChf(b.highResult)}</td>
<td className="px-3 py-2 text-right font-semibold text-fg">{formatChf(b.swing)}</td>
</tr>
))}
</tbody>
</table>
</div>
<p className="text-[11px] text-faint">
Die Reihenfolge nicht der absolute Betrag ist die Aussage. Sie hängt von den eingegebenen Bandbreiten ab.
</p>
</div>
);
}