Compare commits

..

4 Commits

Author SHA1 Message Date
admGitAICDS 42d5585969 Assistent als Herzstueck: Tests und Spezifikation 0.36
Deploy App / deploy (push) Successful in 2m2s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 22:33:46 +02:00
admGitAICDS c440063a93 Matrix-Steuerung eingebettet, Tour als Demo-Popup
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 22:28:56 +02:00
admGitAICDS 79fb2b9ca6 Szenario-Screen mit vier Kacheln, FPT-Assistent, Onboarding auf einen Knopf
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 22:23:13 +02:00
admGitAICDS 2f762175d2 Fundament: Element-Stammdaten, Fixpunkte, Horizont in Jahren, PK-Bezugsalter
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 22:06:04 +02:00
32 changed files with 2983 additions and 2381 deletions
+166 -104
View File
@@ -4,10 +4,10 @@
| | |
|---|---|
| **Dokument** | Funktionale und Technische Spezifikation FPT |
| **Version** | 0.35 |
| **Version** | 0.36 |
| **Datum** | 2026-07-25 |
| **Status** | Lebendes Dokument |
| **Codestand** | Arbeitsstand nach `1865db5` inkl. Pensionierung als Eigenschaft der Person (Branch `main`) |
| **Codestand** | Arbeitsstand nach `c440063` inkl. Assistent als Herzstück (Branch `main`) |
| **Ersetzt** | `FDD_TDD_FPT.docx` (v1v5) im Ordner `Info Dateien` diese sind ab Version 0.1 dieses Dokuments obsolet |
| **Geltungsbereich** | Gesamter Code im Verzeichnis `FPT` |
@@ -17,6 +17,7 @@
| Version | Datum | Autor | Änderung |
|---|---|---|---|
| 0.36 | 2026-07-26 | Claude (Opus 5) | **Der Assistent wird das Herzstück** (neues Kapitel 3.14). Ein Umbau von Onboarding, Bildschirmaufbau und Führung. (1) **Ein Weg hinein.** Die Übersicht zeigt im leeren Zustand nur noch «Meinen ersten Finanzplan anlegen»; der geführte Start und der Beispielplan entfallen. Drei Knöpfe waren eine Wahl, die niemand treffen kann, der das Tool noch nicht kennt. Der Plan-Dialog fragt nur noch sechs Dinge: Name, Haushaltsform, Personennamen, Startjahr, Alter, Inflation. **Das Pensionsalter wird nicht mehr abgefragt** es ist kein Stammdatum, sondern der erste Entscheid der Pensionsplanung, und es erzeugt eine Phasengrenze. Bis dahin gilt das Referenzalter. (2) **Element-Stammdaten** (`FinancialElement.baseData`): Bestand bei Planbeginn und Ausgangs-Annahmen hängen neu am ELEMENT statt in Phase 1. Zwei Gründe ein Startwert ist nicht «phase-1-spezifisch», sondern schlicht der Stand am Anfang; und Elemente lassen sich damit erfassen, **bevor es Lebensphasen gibt**. Genau das braucht die Bestandsaufnahme als erster Schritt. Zugleich sind die Stammdaten die **Wurzel der Feld-Vererbung**: Phase 1 hatte bisher nichts, von dem sie hätte erben können, und fiel auf 0. (3) **Aus einem Fixpunkt werden bis zu vier je Person.** `phaseplan.ts` kannte nur das Erwerbsende. Da AHV, Pensionskasse und jedes 3a-Konto eigene Bezugsalter haben (`pkWithdrawalAge` neu), erzwingt jeder Bezugsbeginn eine Phasengrenze sonst fiele er mitten in eine Phase und rutschte auf die nächste Grenze, unter Umständen Jahre später. Die Phasendauer-Kappung zählt sie mit; Ereignisse im selben Jahr teilen sich eine Grenze. (4) **Planungshorizont in JAHREN** am Szenario (`planningHorizonYears`) statt als Endalter je Person: eine Zahl statt zweier, die bei einem Paar auseinanderlaufen könnten; die Endalter sind die Ableitung. Ersetzt `Person.planningHorizonAge` aus 0.35. (5) **Neuer Szenario-Bildschirm.** Zwei farblich getrennte Hälften: oben die Steuerung in vier Kacheln (Grundeinstellungen mit «Pensionsplanung» je Person · Kennzahlen inkl. neuem **«Vermögen heute»** · Schnellaktionen · Assistent) plus die Zeitachse über die volle Breite; unten die Matrix. Die Aktionsleiste über der Matrix ist verschwunden: **«+ Element», «+ Phase», der Nominal/Real-Umschalter und «Alle auf-/zuklappen» sitzen jetzt in der Ecke oben links der Matrix** sie steuern die Matrix und lagen vorher lose darüber wie Aktionen der ganzen Seite. (6) **Der FPT-Assistent** ersetzt die Karte «Nächste Schritte». Sieben Schritte von der Bestandsaufnahme bis zum Feinschliff. Jeder öffnet ein Popup, das **zuerst erklärt** (welche Fragen der Schritt beantwortet, was man wissen sollte) und **danach das Werkzeug** anbietet; «Selbst erledigen» überspringt beides. Der Haken ist **manuell** das Tool masst sich nicht an zu wissen, wann jemand fertig ist , aber daneben steht der **abgeleitete Stand** («0 Lebensphasen»), damit ein abgehakter Schritt ohne Substanz auffällt. Erledigte rutschen nach unten und werden blass; die Kachel ist gelb, bis alle sieben stehen, dann grün. (7) **Neue Tour**: ein grosses Popup mit einem **nachgebauten** Bildschirm und erfundenen Zahlen, schrittweise erklärt. Das frühere Spotlight lag über der echten Ansicht und hatte auf einem frisch angelegten, leeren Plan nichts hervorzuheben, also gerade dann nicht, wenn es am nötigsten war. Der Preis ist, dass die Attrappe bei UI-Änderungen nachzuführen ist. (8) Entfallen: `PlanWizard` (der Assistent führt jetzt IM Plan statt davor) und `demoplan.ts`. Neue Module `assistant.ts`, neue Komponenten `Assistant`, `AssistantStepDialog`, `AssistantSteps`; neue Endpunkte `PUT /api/elements/<id>/base` und `POST /api/scenarios/<id>/assistant`. **Keine Datenmigration** (Pläne wurden vorgängig gelöscht). 12 Tests ergänzt (322 → 334). |
| 0.35 | 2026-07-26 | Claude (Opus 5) | **Die Pensionierung ist eine Eigenschaft der PERSON, nicht der Zeitachse** (neues Kapitel 3.13). Der grösste Eingriff seit V7. Bisher hing jeder Bezugs-Entscheid an `transitionValues[phaseId]` am Schlüssel Element × Phasen-ID. Daraus folgte fast alles, was an der Pensionsplanung störte: Entscheide, die inhaltlich **eine** Frage sind, lagen in drei weit auseinander liegenden Matrix-Zellen; das Alter zu ändern war ein struktureller Eingriff, bei dem Entscheide über `mergeTransition` verlustbehaftet von Grenze zu Grenze gerettet werden mussten; ein Szenario nur für ein anderes Pensionsalter hiess, alles neu zu entscheiden; und der Ziel-Solver (Roadmap Nr. 21) hätte nichts zum Anfassen gehabt. **Neu liegt der Entscheid am ELEMENT** (`FinancialElement.retirementDecision`, ohne Phasenbezug) und überlebt damit jede Verschiebung der Zeitachse. (1) **Neuer Pensionierungs-Bildschirm** gleichrangig neben der Matrix, mit der **Rentenlücke** als Leitzahl keine neue Rechnung, sondern die Verzehrquote im ersten voll pensionierten Jahr; sie fehlte bisher nur als Begriff. Gerechnet im Rechenkern (`PlanComputed.retirement`), damit Bildschirm und PDF-Bericht nicht auseinanderlaufen. Die Matrix-Zellen am Pensions-Übergang bleiben bedienbar und nutzen **dieselbe Komponente** (`RetirementFields`) zwei Ansichten auf ein Objekt, kein Duplikat. (2) **AHV-Vorbezug und -Aufschub** werden gerechnet (Kap. 4.4.7 neu geschrieben): Kürzung 6,8 %/Jahr, Zuschlag +5,2/10,8/17,1/24,0/31,5 % nach 15 Jahren, Teilbezug 2080 %. Bis 0.34 startete die Rente **immer** mit 65 wer mit 62 aufhörte, bekam die ungekürzte Rente drei Jahre später, wer bis 68 arbeitete, verschenkte den Zuschlag. Dabei wurde eine fachliche Trennung eingeführt, die es vorher gar nicht gab: **Rentenbeginn und Beitragspflicht sind zwei verschiedene Alter.** Wer mit 62 aufhört und ab 63 vorbezieht, bezieht ab 63 **und** zahlt bis 65 weiter als Nichterwerbstätige(r). (3) **Pensionskasse: ein Regler statt eines Modus.** `payoutMode` (`PENSION`/`CAPITAL`/`COMBI`) und der absolute `capitalAmount` entfallen zugunsten von `capitalSharePct` (0100 %). Als Quote, weil sich das Guthaben mit dem Pensionsalter ändert ein fixer Betrag bedeutete beim Verschieben still ein anderes Verhältnis. (4) **Säule 3a: wählbares Bezugsalter** (6070) statt starr am Pensions-Übergang. Ein Konto lässt sich nur ganz auflösen, und alle Bezüge desselben Jahres werden steuerlich zusammengezählt gestaffelt wird deshalb über Konten und Jahre. Gezogen wird an der ersten Phasengrenze bei oder nach dem Wunschalter. (5) **Planungshorizont** (`Person.planningHorizonAge`): Bisher ergab sich das Planende stillschweigend aus der Summe der Phasendauern zwei Szenarien konnten unbemerkt verschieden weit rechnen und waren nicht vergleichbar. Neue Funktion `planHorizonChange`, neuer Endpunkt `POST /api/scenarios/<id>/horizon`. (6) **Ampel mit drei Zuständen** (Kap. 3.5.3 neu): `unbeantwortet` · `auf Vorgabe` · `bestätigt`. Mit durchgängigen Vorgaben bewusst, damit niemand am Anfang Fragen beantworten muss, die er erst am Ende beantworten kann entstand ein Zustand, den das Modell nicht kannte: Das System **hat** eine Antwort, nur nicht die des Benutzers. Eine Vorgabe wie «volle Rente statt Kapitalbezug» als beantwortet zu zählen hiesse, sie unbemerkt durchgehen zu lassen. Sie zählt deshalb mit, aber getrennt benannt: «2 offene Entscheide · 3 Vorgaben ungeprüft», bestätigt wird je Säule. (7) **Drei neue Treiber** in Tornado und Live-Simulation: PK-Kapitalanteil, AHV-Vorbezug/Aufschub (in Monaten, neue Einheit `delta_months`) und das bestehende Pensionsalter wird endlich **korrekt**, weil der AHV-Beginn jetzt mitzieht. (8) Nebenbei zwei Vereinfachungen: Die Beitragskarriere vor Planbeginn lag an **zwei** Orten (Übergangszelle bzw. Phasenzelle für bereits Pensionierte) mit zwei Codepfaden jetzt an einem. Und Szenario-Kopie, Versionierung und Diff tragen den Entscheid mit; ohne das wäre die Kopie genau für den Zweck unbrauchbar, für den man sie am häufigsten anlegt. **Keine Datenmigration** (Testdaten wurden vorgängig gelöscht); alte Werte in `transitionValues` werden ignoriert, betroffene Elemente erscheinen als «Vorgabe ungeprüft». (9) **Assistent:** neuer Überblicksschritt «Deine Pensionierung» (Kap. 3.2.8) er ZEIGT Rentenlücke und Reichweite, statt Fragen zu stellen, die zu diesem Zeitpunkt niemand beantworten kann. Die Vorschau wird gerechnet, bevor der Plan existiert; Vorschau und Anlage speisen sich aus EINER Element-Liste, damit sie nicht auseinanderlaufen. Neues Modul `retirement-decision.ts`, neue Komponenten `RetirementPanel` und `RetirementFields`. 23 Tests ergänzt (292 → 315). |
| 0.34 | 2026-07-25 | Claude (Opus 5) | **Modul-Review 4, Nachbesserungen: die Übergangs-Entscheide bis ans Ende durchgezogen.** (1) **Zuordnung überall dort, wo Elemente über ihren Namen angeboten werden.** Zwei Personen nennen ihre Guthaben typischerweise gleich («Säule 3a», «ETF»); ohne die Person wählt man im Dropdown blind. Betroffen waren das **Ziel der Anlage-Quote** beim Kapitalbezug (dort mit hoher Folgewirkung: Ein Fehlgriff leitet das Alterskapital in das Depot der falschen Person) und die Zeilen im Dialog **«Kapital verteilen»**. Die Klartext-Zuordnung liegt neu als `ownerLabel` in `src/lib/elements.ts` und wird von allen drei Stellen genutzt. (2) **Herkunft des umgeleiteten Alterskapitals wird ausgewiesen.** Fliessen PK **und** 3a in dasselbe Vermögens-Element, stand dort bisher nur eine Summe ob wirklich beide angekommen sind, liess sich nicht prüfen. `Carry` und `ElementPhaseComputed` führen neu `capitalInSources` bzw. `capitalFromTransferSources` mit: Betrag **je Quelle**, benannt mit Element **und** Person. Sichtbar am Ziel-Element und im Dialog «Kapital verteilen». Das Feld heisst neu **«Zusatzinvestition aus Kapitalbezug»** (vorher «Davon aus Kapitalbezug (PK/3a)» irreführend, weil es kein Anteil an der manuell erfassten Zusatzinvestition ist, sondern ein zweiter, davon unabhängiger Betrag). (3) **Der Dialog «Kapital verteilen» zeigt das bereits Zugeteilte.** Vorher stand dort eine **0**, obwohl die Quote geflossen war das Feld führt nur den manuell erfassten Teil. Neu erscheint darüber eine read-only Zeile mit dem aus dem Bezugs-Entscheid stammenden Betrag samt Aufschlüsselung, darunter das editierbare Feld und die Summe beider. (4) **Bezogene Vorsorge-Guthaben werden in beiden Verteil-Dialogen nicht mehr angeboten.** Nach der Pensionierung ignoriert die Rechnung Beiträge und Zusatzeinlagen in PK und Säule 3a die Dialoge boten sie trotzdem an, inklusive eines aus der Vorphase geerbten 3a-Beitrags, der dort als aktive Rate erschien. Der Filter prüfte nur den `status` (`ACTIVE`), und der bleibt nach dem Bezug bestehen. Neu setzt die Rechnung selbst das Kennzeichen `acceptsCapital: false`; die Dialoge lesen es, statt die Regel ein zweites Mal nachzubauen. 4 Tests ergänzt (288 → 292). |
| 0.33 | 2026-07-25 | Claude (Opus 5) | **Modul-Review 4 (Matrix: Phasen und Elemente).** (1) **Kapitalverwendung neu am Vorsorge-Element** (Punkt C aus Roadmap Nr. 44): Die Prozent-Aufteilung des bezogenen Alterskapitals hing am **Cash-Übergang** dem falschen Ort, denn mit zwei Guthaben (PK und 3a) liess sie sich dort gar nicht getrennt beantworten. Sie steht jetzt beim **Bezugs-Entscheid** der Pensionskasse (nur bei Kapitalbezug) bzw. der **Säule 3a**. Beide Dialoge führen neu **brutto → Steuersatz → netto** und darunter die Verteilung. Der zugeteilte Betrag fliesst über den regulären Weg (`Carry.capitalIn` → Zusatzeinlage der Folgephase) und ist damit **überall sichtbar**: am Ziel-Element, in der Cash-Brücke als Investition und im «Kapital verteilen»-Dialog. Vorher erhöhte er still den Bestand, weshalb Element und Dialog eine **0** zeigten. Die **Säule 3a** ist am Pensions-Übergang neu ein **offener Entscheid** (Steuersatz und Verwendung); vorher galt sie als automatisch beantwortet. (2) **Phasendauer: die Folgephase gleicht aus** (Kap. 3.3.2). Bis 0.32 prüfte die Kappung nur die **bearbeitete** Phase wurde Phase 1 von 10 auf 12 Jahre verlängert, überspannte danach Phase 2 die Pensionierung, und die tragende Invariante aus Roadmap Nr. 44 kippte. Neu trägt die Folgephase die Differenz (Gesamtdauer bleibt gleich, wie beim Verschieben des Pensionsalters); passt sie nicht, wird blockiert; vorher erscheint eine Rückfrage. Neue reine Funktion `planDurationChange`. (3) **Element und Phase direkt bedienbar:** In der Matrix tragen Element-Zeile und Phasenkopf neu **Stift** (umbenennen, beim Element inkl. **Zuordnung**) und **Papierkorb**; das Expand-Symbol ist **immer** sichtbar statt nur bei Mouseover. `PATCH /api/elements/<id>` nimmt dafür neu auch `ownerRole` (bleibt für AHV/PK/3a personengebunden). (4) **Hilfetexte** werden über ein **Portal** gezeichnet in scrollenden Dialogen schnitt der Container sie vorher ab; sie klappen nach oben, wenn unten kein Platz ist. (5) **Verteil-Dialoge:** Zeilen zeigen die **Zuordnung** (Person A/B/Gemeinsam) und sind nach **«vom Cash»/«ins Cash»** gruppiert; die Vorbelegung nutzt neu den **effektiven** Wert inklusive Vererbung aus der Vorphase ein geerbter 3a-Beitrag erschien vorher als 0. (6) **Matrix:** alle Phasenspalten **gleich breit**, bei vielen Phasen wird horizontal gescrollt; **«Alle auf-/zuklappen»**; eine zugeklappte Kategorie zeigt je Phase die **Summe** ihrer Elemente. (7) **Phasen-Detailansicht** nutzt die neue Aufteilungs-Grafik (Fläche + Ring) statt der alten Balken. (8) **Übersicht:** «Leer starten» steht neu auch im leeren Zustand zur Wahl. (9) Nebenbei: dritte vom Umlaut-Sweep verstümmelte Hex-Farbe (`#7c3äd`) repariert, das Phasen-Panel nutzt den eigenen Bestätigungs-Dialog statt `window.confirm`. 10 Tests ergänzt (278 → 288). |
@@ -302,15 +303,13 @@ die ersten 72 Byte), keine ARIA-Labels auf der Login-Maske und der Befehls-Palet
### 3.2.1 Plan erstellen
Der «+»-Knopf öffnet eine Auswahl mit drei Wegen:
Es gibt genau **einen** Weg: den Knopf «Meinen ersten Finanzplan anlegen» in der Übersicht
bzw. das «+» in der Seitenleiste. Beide öffnen denselben Dialog. Zur Begründung, warum die
frühere Auswahl aus drei Wegen entfallen ist, siehe
[3.2.8](#328-der-einstieg-ein-weg-eine-tour-ein-assistent).
| Weg | Für wen | Was passiert |
|---|---|---|
| **Geführt erstellen** (empfohlen) | neue Nutzer | der Assistent aus [3.2.8](#328-geführter-assistent-und-beispielplan) |
| **Leer starten** | geübte Nutzer | der bisherige Dialog (unten) nur Grundprofil, keine Phasen/Elemente |
| **Beispielplan ansehen** | Erkunden | legt einen fiktiven, voll ausgefüllten Plan an ([3.2.8](#328-geführter-assistent-und-beispielplan)) |
Der Dialog «Leer starten» fragt Name plus das vollständige Grundprofil:
Der Dialog fragt Name plus Grundprofil -- **ohne Pensionsalter**, das gehört in die
Pensionsplanung:
| Feld | Typ | Default | Wertebereich |
|---|---|---|---|
@@ -428,105 +427,39 @@ und Grafiken beschriften damit Jahre statt nur Alter. Die Berechnung rechnet unv
Beim Anlegen wird das laufende Jahr vorbelegt; bestehende Pläne wurden per Migration darauf
gesetzt. Kalenderjahr eines Planjahrs: `startYear + (Jahr 1)`.
### 3.2.8 Geführter Assistent und Beispielplan
### 3.2.8 Der Einstieg: ein Weg, eine Tour, ein Assistent
**Der Plan-Assistent** (Roadmap Nr. 10: «Schritt für Schritt statt leerer Matrix») fragt in
**sieben** Schritten in Alltagssprache: (1) Grundprofil, (2) Lebensphasen, (3) Einkommen und
Ausgaben plus Kontostand, (4) Vorsorge und Vermögen (**nur Bestandswerte**), (5) Sparen und
Verteilen, (6) **Deine Pensionierung**, (7) Zusammenfassung. Im **Einzelmodus** durchgehend in
**Du-Form** («Was verdienst du?»); im Paarmodus je Person bzw. «ihr».
Bis 0.35 standen im leeren Zustand **drei** Knöpfe: geführt starten, Beispielplan ansehen, leer
starten. Das ist eine Wahl, die niemand treffen kann, der das Tool noch nicht kennt -- und sie
kostete den ersten Eindruck.
**Schritt 6 ist ein Überblick, kein Erfassungsschritt.** An dieser Stelle weiss niemand, wie
hoch seine PK-Rente sein wird danach zu fragen hiesse, eine unbeantwortbare Frage zu stellen.
Die Antwort zu *zeigen* ist dagegen der stärkste Moment im ganzen Onboarding: Der Schritt weist
**Rentenlücke** und **Kapitalreichweite** aus und darunter die Vorgaben zu AHV, Pensionskasse
und Säule 3a ([3.13](#313-pensionierung)) über exakt dieselben Bausteine wie der
Pensionierungs-Bildschirm. Ändern kann man sie hier, muss aber nicht.
Seit 0.36 gibt es genau einen Weg:
Gerechnet wird die Vorschau, **bevor der Plan existiert**: `computePlan` ist rein und läuft im
Browser in Bruchteilen einer Millisekunde. Damit Vorschau und erzeugter Plan nicht auseinander-
laufen können, speist **eine einzige Element-Liste** (`elementSpecs`) beides die Vorschau und
die Anlage über die API. Zwei Aufbauten desselben Plans wären garantiert irgendwann verschieden,
und die Vorschau zeigte dann Zahlen, die der erzeugte Plan nie hat. Die im Schritt getroffenen
Entscheide werden **nach** dem Anlegen geschrieben; vorher gibt es keine Element-Ids, an denen
sie hängen könnten.
```
"Meinen ersten Finanzplan anlegen"
|
v
Plan-Dialog (sechs Felder)
|
v
Basisszenario, leer -> Tour (Demo-Popup) -> FPT-Assistent
```
**Schritt 2 ist an den fixen Pensionierungszeitpunkten ausgerichtet.** Das Pensionsalter jeder
Person ist ein Fixpunkt auf der Lebenslinie; dazwischen entstehen Abschnitte mit konstantem
Erwerbsstatus (reines Modul `phaseplan.ts`, `planSegments`):
**Der Plan-Dialog** fragt nur noch: Name des Plans, Haushaltsform, Namen der Personen
(freiwillig), Startjahr, Alter, Inflation. Dasselbe Fenster öffnet auch das Plus in der
Seitenleiste.
| Abschnitt | Bedeutung | Länge |
|---|---|---|
| **Erwerb** | alle arbeiten | fest (bis zur ersten Pensionierung) |
| **Misch** | eine Person pensioniert, eine arbeitet | fest (zwischen den Pensionierungen) |
| **Pension** | alle pensioniert | **offen** (Lebensdauer frei) |
**Das Pensionsalter wird bewusst NICHT gefragt.** Es ist kein Stammdatum, sondern der erste
Entscheid der Pensionsplanung -- und es erzeugt eine Phasengrenze
([3.14.4](#3144-fixpunkte-jeder-bezugsbeginn-erzwingt-eine-phasengrenze)). Bis Schritt 2 des
Assistenten gilt das Referenzalter.
Die **Anzahl** Abschnitte wird abgeleitet, nicht vorgegeben: Einzelplan → 2 (Erwerb, Pension);
Paar mit gleichem Pensionsalter → 2 (keine Mischzeit); Paar mit unterschiedlichem Pensionsalter
→ 3. Ist eine Person bei Planbeginn bereits pensioniert, beginnt die Linie mit einem Misch- oder
Pensions-Abschnitt.
**Der frühere Plan-Assistent (`PlanWizard`) und der Beispielplan sind entfallen.** Der Wizard
führte VOR dem Plan durch ein Formular und liess einen danach mit der Matrix allein; der
Assistent führt jetzt IM Plan und bleibt dort, solange man ihn braucht
([3.14](#314-der-fpt-assistent)). Was der Beispielplan leistete -- einmal sehen, wie ein
gefüllter Plan aussieht --, übernimmt die Tour.
In jedem **fest begrenzten** Abschnitt verteilt der Nutzer beliebig viele Phasen (mit
+/Papierkorb und eigenem Namen je Phase); eine **Live-Summe** erzwingt, dass die Phasendauern
exakt der festen Länge entsprechen «Weiter» bleibt gesperrt, bis es aufgeht. Das ist zwingend:
Die Berechnung leitet den Phasentyp am Phasenbeginn ab und **kappt jede Phase am nächsten
Pensionsereignis** ([2.3](#23-phasentyp--abgeleitet-nicht-gespeichert)) eine Phase, die eine
Pensionierung überspannt, gäbe es nicht. Der frühere Assistent liess die Erwerbsphase beliebig
über das Pensionsalter hinaus setzen; das ist damit behoben. Der **offene** Pensions-Abschnitt
nimmt beliebige Dauern (Summe = geplante Restlebensdauer).
Eine **Zeitachse** zeigt den proportionalen Verlauf mit den Pensionierungs-Fixpunkten als
Flaggen; die Phasen sind nummeriert und **unter** dem Balken beschriftet, damit auch kurze
Phasen lesbar bleiben.
**Schritt 4 (Vorsorge & Vermögen)** ist bei Paaren in **Gemeinsam / Person A / Person B**
aufgeteilt. Pensionskasse und Säule 3a sind immer persönlich (personengebundene Kategorien);
Wertschriften, Wohneigentum und Schulden lassen sich gemeinsam **oder** je Person erfassen. Hier
werden nur die **heutigen Bestandswerte** erfasst (Guthaben, Kaufpreis, Hypothek, Restschuld)
die laufenden Jahresbeträge folgen in Schritt 5. Zusätzlich fragt das Wohneigentum die
**Wertsteigerung** und den **Zins-in-Ausgaben-Schalter** ab (damit Hypothekarzinsen nicht
doppelt zählen). Der Schalter **«Selbstständig ohne PK (grosse Säule 3a)»** der Säule 3a steht
in **Schritt 5** (direkt bei der 3a-Einzahlung, denn er betrifft deren Obergrenze) und hebt die
Beitrags-Obergrenze an (siehe 4.11 / Feld `selfEmployed3a`).
**Schritt 5 (Sparen & Verteilen)** bringt das Kernmodell des Tools zum Anfassen: Aus
`Nettoeinkommen Ausgaben` entsteht die **Sparquote**; der Nutzer verteilt sie auf Säule 3a,
Wertschriften, Amortisation und Schuldtilgung, und der **noch nicht verteilte Rest** steht
**prominent zwischen PK-Block und Verteilung** und bleibt auf dem Cash-Konto (live gerechnet,
negativer Rest wird gewarnt). Die Verteilung ist nach **Gemeinsam / Person A / Person B**
gruppiert. Hypothekarzinsen, die im vorigen Schritt als «noch nicht in den Ausgaben» markiert
sind, rechnet die Sparquote-Vorschau zu den Ausgaben dazu so wie der Rechenkern bei
`interestHandling: ADD`.
**Vor Schritt 1** steht ein **Willkommens-Screen** mit dem Gesamtbild der fünf Schritte (Icon,
Titel, ein Satz je Schritt); danach begleitet eine **persistente Schritt-Leiste** den ganzen
Ablauf (links im breiten Modal, auf schmalen Screens als Fortschrittsbalken): aktueller Schritt
hervorgehoben mit Kurzbeschreibung, erledigte mit Haken, kommende gedämpft. Der Nutzer weiss so
jederzeit, wo er steht und was noch folgt. Die **Zusammenfassung** ist der Abschluss und trägt
keine eigene Schritt-Nummer. Die **PK-Einzahlung** steht bewusst
in einem **eigenen** Block mit dem Hinweis, dass sie vom **Bruttolohn** bezahlt wird also
**vor** dem Nettoeinkommen und die Sparquote deshalb **nicht** schmälert. Das deckt sich exakt
mit dem Rechenkern, wo der PK-Beitrag nicht zur Quote zählt ([4.6.3](#463-pension_fund)). Der
Schritt entfällt fachlich nie, aber wenn keine Spar-/Vorsorgeposten angehakt sind, weist er nur
darauf hin, dass der ganze Betrag auf dem Cash-Konto wächst.
Zwei bewusste Entscheide bleiben:
- **Einkommen wird pro Person erfasst**, nie als «Gemeinsam» in Paar-Plänen zählt
Haushalts-Einkommen nicht für die AHV ([9.9](#99-gemeinsames-einkommen-zählt-bei-paaren-nicht-für-die-ahv));
der Assistent räumt diese Falle von Anfang an aus.
- Technisch ist der Assistent **reine Orchestrierung bestehender Endpunkte** (Plan → Phase 1 →
Elemente samt Werten → Folgephasen; die Reihenfolge stellt sicher, dass die Phasen-Route die
Folgephasen korrekt vorbelegt). Kein neuer Endpunkt, keine Berechnungsänderung. Grenze: 9.23.
**Der Beispielplan** («Beispiel: Alex Muster», `src/lib/demoplan.ts`) ist ein fiktiver, voll
ausgefüllter Plan per Ein-Klick ebenfalls reine Orchestrierung. Die Übergangs-Entscheide
bleiben **absichtlich offen**: Der neue Nutzer sieht die Ampel («N offen») in Aktion und lernt
das wichtigste Konzept am Beispiel statt aus einer Erklärung.
Nach dem ersten Öffnen eines Plans mit Phasen startet einmalig die **Tour**
([3.7.8](#378-tour-und-nächste-schritte)).
## 3.3 Lebensphasen
### 3.3.1 Phase anlegen
@@ -2172,6 +2105,135 @@ genug reichen muss.
Referenz: `src/lib/retirement-decision.ts`, `src/components/RetirementPanel.tsx`,
`src/components/RetirementFields.tsx`, `src/lib/retirement.ts` (`planHorizonChange`).
## 3.14 Der FPT-Assistent
### 3.14.1 Warum er die «Nächsten Schritte» ersetzt
Die frühere Karte leitete AB, was zu tun wäre -- und liess einen damit allein. Sie konnte
sagen «4 Übergangs-Entscheide offen», aber nicht, was ein Übergangs-Entscheid überhaupt ist
oder in welcher Reihenfolge man vorgeht.
Der Assistent führt stattdessen. Sieben Schritte, jeder mit einem eigenen Werkzeug und einer
Seite davor, die erklärt, worum es geht:
| # | Schritt | Was dabei entsteht |
|---|---|---|
| 1 | **Bestandsaufnahme** | alle Elemente mit ihrem heutigen Stand |
| 2 | **Eckdaten und Pensionsplanung** | Planungshorizont; je Person Erwerbsende und Bezugszeitpunkte |
| 3 | **Lebensphasen** | die Zeitachse, an den Fixpunkten aus Schritt 2 ausgerichtet |
| 4 | **Erwerbsjahre planen** | Sparquoten und Übergänge bis zur Pensionierung |
| 5 | **Pensionierung planen** | Rente oder Kapital, Verwendung, die Übergänge dorthin |
| 6 | **Ruhestand planen** | Bezüge aus dem Vermögen, die restlichen Übergänge |
| 7 | **Feinschliff** | reine Information: was jetzt noch möglich ist |
Die Reihenfolge ist nicht beliebig. Sie beginnt mit dem, was **feststeht** (was habe ich?),
geht dann zu dem, was man **entscheidet** (wann höre ich auf?), und erst danach zu dem, was
sich daraus **ergibt** (wie teile ich ein?). Genau deshalb steht die Bestandsaufnahme vor der
Zeitachse -- und genau deshalb brauchte es die Element-Stammdaten (3.14.3).
### 3.14.2 Der Haken ist manuell -- der Stand daneben nicht
Zwei Gestaltungsentscheide, die zusammengehören:
1. **Abgehakt wird von Hand.** Wann jemand mit einem Schritt fertig ist, ist eine Einschätzung
und keine Messgrösse. «Genug geplant» kann das Tool nicht wissen.
2. **Daneben steht der abgeleitete Stand** (`stepStatus`): «0 Lebensphasen», «2 Elemente»,
«Planungshorizont fehlt». Ein abgehakter Schritt ohne Substanz fällt damit auf, ohne dass
das Tool den Haken verweigert.
Gesperrt wird nur das **Werkzeug**, nie die Selbstauskunft (`stepBlockedReason`): Die
Phasenplanung ohne Planungshorizont wäre gegenstandslos, die Übergangs-Schritte ohne Phasen
ebenso. Der Haken bleibt trotzdem jederzeit setzbar.
Erledigte Schritte rutschen nach unten und werden blass -- oben steht immer das Nächste. Die
Kachel ist **gelb**, solange etwas offen ist, und **grün**, wenn alle sieben stehen.
### 3.14.3 Element-Stammdaten: Bestand vor Zeitachse
Bis 0.35 lagen alle Elementwerte unter `phaseValues[phaseId]`. Ohne Phase gab es keinen
Schlüssel -- eine Bestandsaufnahme als erster Schritt war damit unmöglich.
Seit 0.36 trennt `FinancialElement.baseData` zwei Dinge, die nie dasselbe waren:
| gehört zum **Element** (`baseData`) | gehört zur **Phase** (`phaseValues`) |
|---|---|
| Bestand bei Planbeginn, Kaufpreis, Anfangshypothek, Restschuld | Sparraten, Amortisation, Bezugsraten |
| Ausgangs-Annahmen: Rendite, Wertsteigerung, Zinssatz | abweichende Werte einzelner Phasen |
Das ist nicht nur ein Kunstgriff für Schritt 1. Ein Startwert war **nie** «phase-1-spezifisch»
-- er ist der Stand am Anfang der Planung, und dass er in Phase 1 stand, war eine Eigenheit
der Speicherung. Nebenbei löst der Umbau eine alte Unstimmigkeit: Die Feld-Vererbung
([3.12.4](#3124-punkt-a-aus-vorphase-übernehmen)) hatte in Phase 1 nichts, von dem sie hätte
erben können, und fiel auf 0. Die Stammdaten sind jetzt die **Wurzel** dieser Kette:
```
eigener Phasenwert -> aus der Vorphase geerbt -> Stammdaten -> 0
```
In der Matrix erscheinen Elemente ohne Lebensphasen mit ihren Stammdaten; Endwerte gibt es
erst, wenn eine Phase eine Dauer vorgibt.
### 3.14.4 Fixpunkte: jeder Bezugsbeginn erzwingt eine Phasengrenze
`phaseplan.ts` kannte bis 0.35 genau einen Fixpunkt je Person -- das Erwerbsende. Seit AHV,
Pensionskasse und jedes 3a-Konto ein eigenes Bezugsalter haben, sind es bis zu vier:
| Fixpunkt | Quelle |
|---|---|
| Erwerbsende | `Person.retirementAge` |
| AHV-Rentenbeginn | `ahvStartAge(retirementDecision)` |
| PK-Bezug | `pkWithdrawalAge` (neu in 0.36) |
| je 3a-Konto | `withdrawalAge` |
Der Grund ist derselbe wie beim Erwerbsende: Die Rechnung leitet Erwerbsstatus und Bezüge am
**Phasenbeginn** ab. Fiele ein Bezug mitten in eine Phase, rutschte er auf die nächste Grenze
-- unter Umständen Jahre später, und die Zahlen wären still falsch. `maxPhaseDuration` zählt
die Fixpunkte deshalb mit. Ereignisse im selben Jahr teilen sich **eine** Grenze und werden
dort mehrfach beschriftet.
Die Folge ist ehrlich, aber spürbar: Ein Paar mit gestaffelten Bezügen kommt schnell auf acht
bis zehn Pflichtphasen.
### 3.14.5 Der Bildschirm
Zwei farblich getrennte Hälften, damit sichtbar ist: **oben stellst du ein, unten siehst du
das Ergebnis.**
Oben vier gleichrangige Kacheln plus die Zeitachse über die volle Breite:
| Kachel | Inhalt |
|---|---|
| **Grundeinstellungen** | plan-weit (Personen, Startjahr, Inflation) und szenario-eigen (Horizont, Endjahr, Endalter, Pensionsalter). Stift zum Bearbeiten; je Person ein Knopf **«Pensionsplanung»** |
| **Kennzahlen** | **Vermögen heute** (Summe der Stammdaten -- die einzige Zahl, die schon vor jeder Zeitplanung etwas aussagt), Endvermögen nominal und real, Reichweite |
| **Schnellaktionen** | Neues Szenario · Tour · Änderungshistorie · Rechenwege · CSV-Export |
| **Assistent** | siehe 3.14.1 |
Das **Pensionsalter lässt sich in den Grundeinstellungen nicht ändern** -- es erzeugt eine
Phasengrenze und gehört deshalb in die Pensionsplanung.
Unten die Matrix. Die frühere Aktionsleiste darüber ist verschwunden: **«+ Element»,
«+ Phase», der Nominal/Real-Umschalter, die Plan/Ist-Umschaltung und «Alle auf-/zuklappen»
sitzen in der Ecke oben links der Matrix.** Sie steuern die Matrix und lagen vorher lose
darüber wie Aktionen der ganzen Seite.
### 3.14.6 Die Tour
Ein grosses Popup mit einem **nachgebauten** Bildschirm und erfundenen Zahlen, in neun
Schritten erklärt. Der letzte führt zum Assistenten.
Das frühere Spotlight legte sich über die echte Ansicht. Zwei Nachteile liessen sich nicht
beheben: Auf einem frisch angelegten, leeren Plan gab es kaum etwas hervorzuheben -- also
gerade dann nicht, wenn die Tour am nötigsten ist. Und das Ausschneiden kämpfte dauernd mit
Scroll-Containern und Z-Ebenen ([9.24](#924-tour-spotlight-ohne-engine)).
Der Preis ist bekannt und bewusst in Kauf genommen: **Die Attrappe muss bei UI-Änderungen
nachgeführt werden.** Dafür funktioniert die Tour ab der ersten Sekunde und unabhängig davon,
was im Plan schon steht.
Referenz: `src/lib/assistant.ts`, `src/components/Assistant.tsx`,
`src/components/AssistantStepDialog.tsx`, `src/components/AssistantSteps.tsx`,
`src/components/Tour.tsx`.
---
# 4. Berechnungsmodell
@@ -3923,7 +3985,7 @@ wird der Plan neu geladen; die Berechnung kommt immer vom Server.
| `SpecView` | 65 | Rendert `SPEZIFIKATION.md` (via `/api/spec`) als lesbares Dokument, inkl. Sprungmarken aus den Rechenwegen |
| `InfoBubble` | 28 | Hilfe-Tooltip |
| `ui` | ~370 | UI-Primitiven: Button, Modal, InspectorShell, Confirm, Toast, Skeleton, EmptyState ([3.7.6](#376-sprache-und-ui-primitiven)/[3.7.7](#377-inspector-panel-statt-modals)) |
| `PlanWizard` | ~560 | Geführter Plan-Assistent in fünf Schritten, abschnittsbasierte Phasenplanung ([3.2.8](#328-geführter-assistent-und-beispielplan)) |
| `Assistant` · `AssistantStepDialog` · `AssistantSteps` | ~1200 | Der FPT-Assistent: Fortschrittskachel, Erklärseiten und die Werkzeuge der sieben Schritte ([3.14](#314-der-fpt-assistent)) |
| `Tour` | ~140 | Interaktive Kurz-Tour über die Planansicht ([3.7.8](#378-tour-und-nächste-schritte)) |
| `CommandPalette` | ~130 | Befehls-Palette Ctrl/Cmd+K ([3.7.9](#379-befehls-palette-und-sparklines)) |
| `DistributionDialogs` | ~460 | Verteil-Werkzeuge für Kapital und Spar-/Verzehrquote ([3.6.10](#3610-verteil-werkzeuge)) |
@@ -4244,7 +4306,7 @@ Include `src/**/*.test.ts`). Es gibt **keine** Komponenten-, API- oder E2E-Tests
| `migrations.test.ts` | 3 | spielt alle Migrationen gegen echtes PostgreSQL (PGlite) ein; prüft zusätzlich die V7-**Datenübernahme** (Basisszenario gewinnt, Pensionsalter bleiben szenario-eigen) |
| `rate-limit.test.ts` | 6 | Fixed-Window: erlaubt bis Limit, blockt danach, startet nach Fensterablauf neu, trennt je Schlüssel; Client-IP aus X-Forwarded-For / X-Real-IP |
| `csv.test.ts` | 7 | BOM, alle vier Blöcke, jedes Element als Zeile, Beginn-/Ende-/Übergangsspalten, Entscheid im Klartext, ein Eintrag je Planjahr, Maskierung von `;` und `"` |
| **Total** | **315** | |
| **Total** | **334** | |
## 8.2 Testfälle
@@ -0,0 +1,19 @@
-- Umbau 0.36: Bestandsaufnahme vor der Zeitachse, Assistent als Herzstueck.
--
-- (1) `FinancialElement.baseData` haelt den Bestand bei PLANBEGINN und die Ausgangs-Annahmen.
-- Bisher lagen diese Werte in `ElementPhaseValue` der ersten Phase. Das war schon immer
-- schief -- ein Startwert ist nicht "phase-1-spezifisch", sondern der Stand am Anfang --
-- und es machte den ersten Schritt des Assistenten unmoeglich: Eine Bestandsaufnahme
-- braucht noch keine Lebensphasen. Zugleich ist es die Wurzel der Feld-Vererbung.
--
-- (2) `Scenario.planningHorizonYears` fuehrt den Horizont in JAHREN statt als Endalter je
-- Person. Eine Zahl statt zweier, die bei einem Paar auseinanderlaufen koennten; die
-- Endalter werden abgeleitet. Ersetzt `Person.planningHorizonAge` aus 0.35.
--
-- (3) `Scenario.assistantProgress` haelt die sieben Haken des FPT-Assistenten.
--
-- Bewusst OHNE Datenmigration (Plaene wurden vorgaengig geloescht).
ALTER TABLE "FinancialElement" ADD COLUMN "baseData" JSONB;
ALTER TABLE "Scenario" ADD COLUMN "planningHorizonYears" INTEGER;
ALTER TABLE "Scenario" ADD COLUMN "assistantProgress" JSONB;
ALTER TABLE "Person" DROP COLUMN IF EXISTS "planningHorizonAge";
+18 -4
View File
@@ -76,11 +76,9 @@ model Person {
scenarioId String
scenario Scenario @relation(fields: [scenarioId], references: [id], onDelete: Cascade)
role PersonRole
// Alter, in dem die Person die Erwerbstaetigkeit aufgibt. Wird bei der Plan-Anlage nicht
// mehr abgefragt (Default 65) -- die Pensionsplanung im Assistenten legt es fest.
retirementAge Int
// Bis zu welchem Alter gerechnet wird. Bisher ergab sich das Planende stillschweigend aus
// der Summe der Phasendauern -- zwei Szenarien konnten dadurch unbemerkt verschieden weit
// rechnen und waren nicht vergleichbar.
planningHorizonAge Int?
@@unique([scenarioId, role])
}
@@ -224,6 +222,14 @@ model Scenario {
inflationRateDefault Float
initialCash Float @default(0)
// Wie viele Jahre die Planung umfasst. Bewusst in JAHREN und nicht als Endalter je Person:
// eine Zahl statt zweier, die bei einem Paar auseinanderlaufen koennten. Die Endalter
// werden daraus abgeleitet. NULL = wie bisher aus der Summe der Phasendauern.
planningHorizonYears Int?
// Fortschritt des FPT-Assistenten (sieben Schritte, vom Benutzer abgehakt).
assistantProgress Json?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -309,6 +315,14 @@ model FinancialElement {
// er jede Verschiebung der Zeitachse. Siehe src/lib/retirement-decision.ts.
retirementDecision Json?
// Stammdaten des Elements: der Bestand bei PLANBEGINN und die Ausgangs-Annahmen (Rendite,
// Wertsteigerung, Zins). Zwei Gruende dafuer, dass das nicht in Phase 1 liegt:
// 1. Ein Startwert ist nicht "phase-1-spezifisch", sondern schlicht der Stand am Anfang.
// 2. Elemente lassen sich damit erfassen, BEVOR es Lebensphasen gibt -- der erste Schritt
// des Assistenten ist eine Bestandsaufnahme und braucht noch keine Zeitachse.
// Zugleich die Wurzel der Feld-Vererbung: Phase 1 erbt von hier.
baseData Json?
phaseValues ElementPhaseValue[]
transitionValues ElementTransitionValue[]
}
@@ -0,0 +1,35 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { getOwnedElement } from "@/lib/queries";
import { getCurrentUserId } from "@/lib/session";
import { touchScenario } from "@/lib/versioning-db";
import { phaseDataSchema } from "@/lib/elements";
// Stammdaten eines Elements: der Bestand bei PLANBEGINN und die Ausgangs-Annahmen.
//
// Bewusst ohne Phasenbezug. Zwei Gründe: Ein Startwert ist nicht «phase-1-spezifisch»,
// sondern schlicht der Stand am Anfang -- und Elemente lassen sich damit erfassen, BEVOR es
// Lebensphasen gibt. Genau das braucht der erste Schritt des Assistenten: eine
// Bestandsaufnahme, die noch keine Zeitachse voraussetzt.
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ elementId: string }> }
) {
const userId = await getCurrentUserId();
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
const { elementId } = await params;
const element = await getOwnedElement(elementId, userId);
if (!element) return NextResponse.json({ error: "Element nicht gefunden." }, { status: 404 });
const parsed = phaseDataSchema.safeParse(await request.json());
if (!parsed.success) return NextResponse.json({ error: "Ungültige Eingabe." }, { status: 400 });
await prisma.financialElement.update({
where: { id: elementId },
data: { baseData: parsed.data },
});
await touchScenario(element.scenarioId, userId);
return NextResponse.json({ ok: true });
}
@@ -0,0 +1,34 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { prisma } from "@/lib/db";
import { getOwnedScenario } from "@/lib/queries";
import { getCurrentUserId } from "@/lib/session";
import { ASSISTANT_STEP_COUNT, normalizeProgress } from "@/lib/assistant";
// Fortschritt des FPT-Assistenten setzen (ein Haken je Schritt).
//
// Bewusst OHNE `touchScenario`: Das Abhaken ist eine Notiz des Benutzers über sich selbst,
// keine Änderung am Plan. Eine Version dafür anzulegen würde die Historie mit Einträgen
// fluten, die inhaltlich nichts unterscheiden.
const bodySchema = z.object({
step: z.number().int().min(0).max(ASSISTANT_STEP_COUNT - 1),
done: z.boolean(),
});
export async function POST(request: NextRequest, { params }: { params: Promise<{ scenarioId: string }> }) {
const userId = await getCurrentUserId();
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
const { scenarioId } = await params;
const scenario = await getOwnedScenario(scenarioId, userId);
if (!scenario) return NextResponse.json({ error: "Szenario nicht gefunden." }, { status: 404 });
const parsed = bodySchema.safeParse(await request.json().catch(() => ({})));
if (!parsed.success) return NextResponse.json({ error: "Ungültige Eingabe." }, { status: 400 });
const progress = normalizeProgress(scenario.assistantProgress);
progress[parsed.data.step] = parsed.data.done;
await prisma.scenario.update({ where: { id: scenarioId }, data: { assistantProgress: progress } });
return NextResponse.json({ ok: true, progress });
}
@@ -33,12 +33,10 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
// kopiert. Szenario-eigen sind nur Inflation, Cash-Anfangswert und Pensionsalter.
inflationRateDefault: source.inflationRateDefault,
initialCash: source.initialCash,
planningHorizonYears: source.planningHorizonYears,
assistantProgress: source.assistantProgress ?? undefined,
persons: {
create: source.persons.map((p) => ({
role: p.role,
retirementAge: p.retirementAge,
planningHorizonAge: p.planningHorizonAge,
})),
create: source.persons.map((p) => ({ role: p.role, retirementAge: p.retirementAge })),
},
},
});
@@ -71,6 +69,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
// Ohne das waere die Kopie eines Szenarios genau fuer den Zweck unbrauchbar, fuer
// den man sie am haeufigsten anlegt: ein anderes Pensionierungs-Szenario.
retirementDecision: el.retirementDecision ?? undefined,
baseData: el.baseData ?? undefined,
sourceElementId: el.id,
},
});
@@ -5,18 +5,17 @@ import { getOwnedScenario, toPlanInput } from "@/lib/queries";
import { getCurrentUserId } from "@/lib/session";
import { touchScenario } from "@/lib/versioning-db";
import { planHorizonChange } from "@/lib/retirement";
import { MAX_PLANNING_HORIZON_AGE, MIN_PLANNING_HORIZON_AGE } from "@/lib/constants";
import { MAX_PLANNING_HORIZON_YEARS, MIN_PLANNING_HORIZON_YEARS } from "@/lib/constants";
// Planungshorizont setzen: bis zu welchem Alter gerechnet wird.
// Planungshorizont setzen: wie viele JAHRE die Planung umfasst.
//
// Wie beim Pensionsalter gilt: Zahl stellen, Struktur folgt. Die LETZTE Lebensphase wird so
// verlängert oder gekürzt, dass der Plan genau bis zum Horizont läuft. Vorher ergab sich das
// Planende stillschweigend aus der Summe der Phasendauern -- zwei Szenarien konnten dadurch
// unbemerkt verschieden weit rechnen und waren nicht vergleichbar.
// verlängert oder gekürzt, dass der Plan genau bis zum Horizont läuft. Gibt es noch keine
// Phasen, wird nur die Zahl gespeichert -- sie ist dann die Grundlage, auf der der Assistent
// die Zeitachse aufspannt.
const bodySchema = z.object({
role: z.enum(["PERSON_A", "PERSON_B"]),
horizonAge: z.number().int().min(MIN_PLANNING_HORIZON_AGE).max(MAX_PLANNING_HORIZON_AGE),
horizonYears: z.number().int().min(MIN_PLANNING_HORIZON_YEARS).max(MAX_PLANNING_HORIZON_YEARS),
});
export async function POST(request: NextRequest, { params }: { params: Promise<{ scenarioId: string }> }) {
@@ -29,27 +28,19 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
const parsed = bodySchema.safeParse(await request.json().catch(() => ({})));
if (!parsed.success) return NextResponse.json({ error: "Ungültige Eingabe." }, { status: 400 });
const { role, horizonAge } = parsed.data;
const { horizonYears } = parsed.data;
const planInput = toPlanInput(scenario);
const person = planInput.persons.find((p) => p.role === role);
if (!person) return NextResponse.json({ error: "Diese Person gibt es in diesem Szenario nicht." }, { status: 400 });
if (horizonAge <= person.retirementAge) {
return NextResponse.json(
{ error: "Der Planungshorizont muss nach der Pensionierung liegen." },
{ status: 400 }
);
}
const change = planHorizonChange(planInput, horizonAge, role);
if (!change) return NextResponse.json({ error: "Es gibt keine Lebensphase, die sich anpassen liesse." }, { status: 400 });
if (change.blocked) return NextResponse.json({ error: change.blocked }, { status: 400 });
const change = planInput.phases.length > 0 ? planHorizonChange(planInput, horizonYears) : null;
if (change?.blocked) return NextResponse.json({ error: change.blocked }, { status: 400 });
await prisma.$transaction([
prisma.person.update({ where: { id: person.id }, data: { planningHorizonAge: horizonAge } }),
prisma.phase.update({ where: { id: change.lastPhaseId }, data: { durationYears: change.newDuration } }),
prisma.scenario.update({ where: { id: scenarioId }, data: { planningHorizonYears: horizonYears } }),
...(change
? [prisma.phase.update({ where: { id: change.lastPhaseId }, data: { durationYears: change.newDuration } })]
: []),
]);
await touchScenario(scenario.id, userId);
return NextResponse.json({ ok: true, lastPhaseDuration: change.newDuration });
return NextResponse.json({ ok: true, lastPhaseDuration: change?.newDuration ?? null });
}
+39 -202
View File
@@ -3,7 +3,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
BarChart3,
BookOpen,
ChevronDown,
ChevronRight,
Copy,
@@ -12,9 +11,6 @@ import {
FolderKanban,
CalendarClock,
GitBranch,
HelpCircle,
History,
Download,
Layers,
LayoutDashboard,
Menu,
@@ -43,7 +39,6 @@ import { SystemParametersView } from "@/components/SystemParametersView";
import { PlanTraceDialog } from "@/components/DetailView";
import { computePlan } from "@/lib/calculations";
import { ProfileMenu } from "@/components/ProfileMenu";
import { PlanWizard } from "@/components/PlanWizard";
import { Tour, TOUR_DONE_KEY } from "@/components/Tour";
import { CommandPalette, type PaletteAction } from "@/components/CommandPalette";
import { PlanProfileFields, emptyProfileDraft, type ProfileDraft } from "@/components/PlanProfileFields";
@@ -58,7 +53,6 @@ import {
useToast,
} from "@/components/ui";
import { api } from "@/lib/api-client";
import { createDemoPlan } from "@/lib/demoplan";
import { computeScenarioDiff } from "@/lib/diff";
import type { PlanInput, PlanListItem, ScenarioMeta } from "@/lib/types";
import type { PlanComputed } from "@/lib/calculations";
@@ -95,10 +89,7 @@ function AppShellInner({ username }: { username: string }) {
const [detail, setDetail] = useState<ScenarioDetail | null>(null);
const [loading, setLoading] = useState(true);
const [sidebarOpen, setSidebarOpen] = useState(false);
const [showNewPlanChooser, setShowNewPlanChooser] = useState(false);
const [showWizard, setShowWizard] = useState(false);
const [showEmptyPlanDialog, setShowEmptyPlanDialog] = useState(false);
const [creatingDemo, setCreatingDemo] = useState(false);
const [copyFrom, setCopyFrom] = useState<ScenarioMeta | null>(null);
const [showSpec, setShowSpec] = useState(false);
const [showCharts, setShowCharts] = useState(false);
@@ -284,22 +275,6 @@ function AppShellInner({ username }: { username: string }) {
if (selectedScenarioId === s.id) setSelectedScenarioId(null);
}
async function handleCreateDemo() {
setCreatingDemo(true);
try {
const sid = await createDemoPlan();
await loadPlans();
setShowNewPlanChooser(false);
pendingTourRef.current = true; // Tour bei jeder Plan-Erstellung (F14)
openScenario(sid);
toast("success", "Beispielplan angelegt die Tour zeigt dir gleich die wichtigsten Stellen.");
} catch (e) {
toast("error", e instanceof Error ? e.message : "Beispielplan konnte nicht angelegt werden.");
} finally {
setCreatingDemo(false);
}
}
const activePlan =
plans.find((p) => p.scenarios.some((s) => s.id === selectedScenarioId)) ??
(planNav ? plans.find((p) => p.id === planNav.planId) ?? null : null);
@@ -315,7 +290,7 @@ function AppShellInner({ username }: { username: string }) {
// Aktionen der Befehls-Palette -- kontextabhängig (Analysen nur bei offenem Szenario).
const paletteActions = useMemo<PaletteAction[]>(() => {
const base: PaletteAction[] = [
{ id: "a-new", label: "Neuen Plan erstellen", hint: "Aktion", run: () => setShowNewPlanChooser(true) },
{ id: "a-new", label: "Neuen Plan erstellen", hint: "Aktion", run: () => setShowEmptyPlanDialog(true) },
{ id: "a-home", label: "Übersicht öffnen", hint: "Aktion", run: () => { setSelectedScenarioId(null); setPlanNav(null); setShowSpec(false); setShowSystemParams(false); } },
{ id: "a-spec", label: "So rechnet FPT (Spezifikation)", hint: "Aktion", run: () => { setShowSpec(true); setSpecAnchor(null); setShowSystemParams(false); setSelectedScenarioId(null); setPlanNav(null); } },
{ id: "a-params", label: "Systemparameter", hint: "Aktion", run: () => { setShowSystemParams(true); setShowSpec(false); setSelectedScenarioId(null); setPlanNav(null); } },
@@ -372,7 +347,7 @@ function AppShellInner({ username }: { username: string }) {
<span className="text-[11px] font-semibold uppercase tracking-wide text-faint">Meine Pläne</span>
<button
type="button"
onClick={() => setShowNewPlanChooser(true)}
onClick={() => setShowEmptyPlanDialog(true)}
aria-label="Neuen Plan erstellen"
className="rounded-md p-1 text-accent transition-colors hover:bg-accent-soft"
>
@@ -631,73 +606,36 @@ function AppShellInner({ username }: { username: string }) {
<DashboardHome
username={username}
plans={plans}
creatingDemo={creatingDemo}
onOpenPlan={(id) => openPlanTab(id, "dashboard")}
onCreateGuided={() => setShowWizard(true)}
onCreateChooser={() => setShowNewPlanChooser(true)}
onCreateEmpty={() => setShowEmptyPlanDialog(true)}
onCreateDemo={handleCreateDemo}
onCreatePlan={() => setShowEmptyPlanDialog(true)}
onDelete={handleDeletePlan}
/>
)}
{!showSpec && !showSystemParams && !loading && detail && selectedScenarioId && (
<div className="flex flex-col gap-6">
{/* Funktions-Buttons der Szenario-Ansicht. Grafiken/Effektive Werte/Live-Sim/
Monte-Carlo/Einflussfaktoren sind hier bewusst NICHT mehr -- sie laufen über
die eigenen Menüpunkte (Analysen / Effektive Werte). Hier bleiben die reinen
Szenario-Aktionen plus Tour und CSV-Export. */}
<div className="flex flex-wrap items-center gap-2" data-tour="toolbar">
{detail.plan.phases.length > 0 && (
<Button variant="secondary" onClick={() => setShowHistory(true)}>
<History className="h-4 w-4" />
Änderungshistorie
</Button>
)}
{detail.plan.phases.length > 0 && (
<Button variant="secondary" onClick={() => setShowTour(true)} title="Kurze Tour durch die Ansicht">
<HelpCircle className="h-4 w-4" />
Tour
</Button>
)}
<Button variant="secondary" onClick={() => setCopyFrom(detail.meta)}>
<Copy className="h-4 w-4" />
Neues Szenario aus diesem
</Button>
{detail.plan.phases.length > 0 && (
<Button
variant="secondary"
onClick={() => setShowPlanTraces(true)}
title="Wie wird gerechnet? Plan-weite Grössen wie Deflatoren, AHV-Karriere und Ruinalter"
>
<BookOpen className="h-4 w-4" />
Rechenwege
</Button>
)}
{detail.plan.phases.length > 0 && (
<a
href={`/api/scenarios/${detail.meta.id}/export`}
className="inline-flex items-center justify-center gap-1.5 rounded-lg border border-border bg-surface px-4 py-2 text-sm font-medium text-muted transition-all duration-150 hover:bg-surface-2 hover:text-fg"
>
<Download className="h-4 w-4" />
CSV-Export
</a>
)}
{!detail.meta.isBase && (
<Button variant="danger" onClick={() => handleDeleteScenario(detail.meta)}>
<Trash2 className="h-4 w-4" />
Szenario löschen
</Button>
)}
{diff && detail.base && (
<span className="ml-auto flex items-center gap-1.5 rounded-lg border border-diff bg-diff-soft px-3 py-1.5 text-xs font-medium text-diff">
<GitBranch className="h-3.5 w-3.5" />
{diff.total === 0
? "Unverändert gegenüber der Vorlage"
: `${diff.total} Abweichung${diff.total === 1 ? "" : "en"} gegenüber der Vorlage`}
</span>
)}
</div>
{/* Nur noch, was NICHT zum Szenario-Inhalt gehört: das Löschen des Szenarios
und der Hinweis auf Abweichungen zur Vorlage. Die inhaltlichen Aktionen
(Historie, Tour, Kopie, Rechenwege, Export) sitzen seit 0.36 als Kachel im
Szenario-Screen -- dort, wo man sie braucht. */}
{(!detail.meta.isBase || (diff && detail.base)) && (
<div className="flex flex-wrap items-center gap-2">
{!detail.meta.isBase && (
<Button variant="danger" onClick={() => handleDeleteScenario(detail.meta)}>
<Trash2 className="h-4 w-4" />
Szenario löschen
</Button>
)}
{diff && detail.base && (
<span className="ml-auto flex items-center gap-1.5 rounded-lg border border-diff bg-diff-soft px-3 py-1.5 text-xs font-medium text-diff">
<GitBranch className="h-3.5 w-3.5" />
{diff.total === 0
? "Unverändert gegenüber der Vorlage"
: `${diff.total} Abweichung${diff.total === 1 ? "" : "en"} gegenüber der Vorlage`}
</span>
)}
</div>
)}
<PlanView
plan={detail.plan}
@@ -708,40 +646,18 @@ function AppShellInner({ username }: { username: string }) {
onChanged={refreshCurrent}
onOpenSpec={openSpecAt}
onOpenSensitivity={() => setShowSensitivity(true)}
onOpenHistory={() => setShowHistory(true)}
onStartTour={() => setShowTour(true)}
onCopyScenario={() => setCopyFrom(detail.meta)}
onOpenTraces={() => setShowPlanTraces(true)}
exportHref={`/api/scenarios/${detail.meta.id}/export`}
/>
</div>
)}
</main>
</div>
{showNewPlanChooser && (
<NewPlanChooser
creatingDemo={creatingDemo}
onGuided={() => {
setShowNewPlanChooser(false);
setShowWizard(true);
}}
onEmpty={() => {
setShowNewPlanChooser(false);
setShowEmptyPlanDialog(true);
}}
onDemo={handleCreateDemo}
onClose={() => setShowNewPlanChooser(false)}
/>
)}
{showWizard && (
<PlanWizard
onCreated={async (sid) => {
setShowWizard(false);
await loadPlans();
pendingTourRef.current = true; // Tour bei jeder Plan-Erstellung (F14)
openScenario(sid);
toast("success", "Plan erstellt die Tour zeigt dir gleich die wichtigsten Stellen.");
}}
onClose={() => setShowWizard(false)}
/>
)}
{showEmptyPlanDialog && (
<PlanDialog
@@ -870,73 +786,6 @@ function AppShellInner({ username }: { username: string }) {
);
}
// Auswahl beim Anlegen eines Plans: geführt (empfohlen), leer oder Beispielplan.
function NewPlanChooser({
creatingDemo,
onGuided,
onEmpty,
onDemo,
onClose,
}: {
creatingDemo: boolean;
onGuided: () => void;
onEmpty: () => void;
onDemo: () => void;
onClose: () => void;
}) {
const options = [
{
icon: <Sparkles className="h-5 w-5" />,
title: "Geführt erstellen",
badge: "Empfohlen",
text: "Der Assistent fragt Schritt für Schritt in fünf Minuten steht dein Grundgerüst.",
run: onGuided,
},
{
icon: <Plus className="h-5 w-5" />,
title: "Leer starten",
badge: null,
text: "Nur das Grundprofil erfassen, alles Weitere selbst aufbauen für geübte Nutzer.",
run: onEmpty,
},
{
icon: <PiggyBank className="h-5 w-5" />,
title: creatingDemo ? "Beispielplan wird angelegt…" : "Beispielplan ansehen",
badge: null,
text: "Ein fertig ausgefüllter, fiktiver Plan zum Erkunden so siehst du das Tool zuerst in Aktion.",
run: onDemo,
},
];
return (
<Modal title="Neuer Plan" onClose={onClose}>
<div className="flex flex-col gap-2">
{options.map((o) => (
<button
key={o.title}
type="button"
disabled={creatingDemo}
onClick={o.run}
className="flex items-start gap-3 rounded-xl border border-border p-3.5 text-left transition-colors hover:border-accent hover:bg-accent-soft/30 disabled:opacity-60"
>
<span className="mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-accent-soft text-accent-soft-fg">
{o.icon}
</span>
<span className="min-w-0">
<span className="flex items-center gap-2 text-sm font-semibold text-fg">
{o.title}
{o.badge && (
<span className="rounded bg-accent px-1.5 py-0.5 text-[9px] font-semibold uppercase text-accent-fg">{o.badge}</span>
)}
</span>
<span className="mt-0.5 block text-xs text-muted">{o.text}</span>
</span>
</button>
))}
</div>
</Modal>
);
}
// Breiter Dialog für den Analyse-Bereich (Grafiken).
function ChartsDialog({
title,
@@ -1056,22 +905,14 @@ function ScenarioTree({
function DashboardHome({
username,
plans,
creatingDemo,
onOpenPlan,
onCreateGuided,
onCreateChooser,
onCreateDemo,
onCreateEmpty,
onCreatePlan,
onDelete,
}: {
username: string;
plans: PlanListItem[];
creatingDemo: boolean;
onOpenPlan: (planId: string) => void;
onCreateGuided: () => void;
onCreateChooser: () => void;
onCreateDemo: () => void;
onCreateEmpty: () => void;
onCreatePlan: () => void;
onDelete: (id: string) => void;
}) {
return (
@@ -1085,20 +926,16 @@ function DashboardHome({
</div>
{plans.length === 0 ? (
/* Genau EIN Weg hinein. Bis 0.35 standen hier drei Knöpfe (geführt / Beispielplan /
leer) -- eine Wahl, die niemand treffen kann, der das Tool noch nicht kennt. Der
Assistent führt jetzt IM Plan statt davor; die Tour zeigt vorab den Aufbau. */
<EmptyState
icon={<Sparkles className="h-6 w-6" />}
title="Deine erste Finanzplanung"
text="Am schnellsten geht es geführt: Der Assistent fragt dich Schritt für Schritt und baut dein Grundgerüst in rund fünf Minuten. Oder schau dir zuerst den Beispielplan an."
text="Ein paar Angaben zu dir danach zeigt dir eine kurze Tour, wie FPT aufgebaut ist, und der Assistent führt dich Schritt für Schritt durch die Planung."
>
<Button onClick={onCreateGuided}>
<Sparkles className="h-4 w-4" /> Geführt starten
</Button>
<Button variant="secondary" onClick={onCreateDemo} disabled={creatingDemo}>
<PiggyBank className="h-4 w-4" /> {creatingDemo ? "Wird angelegt…" : "Beispielplan ansehen"}
</Button>
{/* Dritter Weg -- sonst gaebe es "Leer starten" nur ueber das Plus in der Seitenleiste. */}
<Button variant="ghost" onClick={onCreateEmpty}>
<Plus className="h-4 w-4" /> Leer starten
<Button onClick={onCreatePlan}>
<Plus className="h-4 w-4" /> Meinen ersten Finanzplan anlegen
</Button>
</EmptyState>
) : (
@@ -1151,7 +988,7 @@ function DashboardHome({
<button
type="button"
onClick={onCreateChooser}
onClick={onCreatePlan}
className="flex min-h-20 items-center justify-center gap-2 rounded-xl border border-dashed border-accent bg-accent-soft text-sm font-medium text-accent-soft-fg transition-colors hover:bg-accent-soft"
>
<Plus className="h-4 w-4" />
+131
View File
@@ -0,0 +1,131 @@
"use client";
// Der FPT-Assistent -- das Herzstück der Anwendung.
//
// Er ersetzt die frühere Karte «Nächste Schritte». Der Unterschied ist nicht kosmetisch: Die
// alte Karte leitete AB, was zu tun wäre, und liess einen damit allein. Hier führt jeder
// Schritt sein eigenes Werkzeug mit sich -- und davor eine Seite, die erklärt, worum es
// überhaupt geht.
//
// Zwei Gestaltungsentscheide, die zusammengehören:
//
// 1. Der Haken ist MANUELL. Das Tool masst sich nicht an zu wissen, wann jemand mit einem
// Schritt fertig ist -- «genug geplant» ist eine Einschätzung, keine Messgrösse.
// 2. Daneben steht der ABGELEITETE Stand («0 Lebensphasen»). Ein abgehakter Schritt ohne
// Substanz fällt so auf, ohne dass das Tool den Haken verweigert.
//
// Erledigte Schritte rutschen nach unten und werden blass -- oben steht immer das, was als
// Nächstes ansteht.
import { useState } from "react";
import { Check, ChevronRight, Lock, Sparkles } from "lucide-react";
import { api } from "@/lib/api-client";
import { ASSISTANT_STEPS, stepBlockedReason, stepStatus, type AssistantProgress } from "@/lib/assistant";
import type { PlanInput } from "@/lib/types";
export function Assistant({
plan,
progress,
onOpenStep,
onChanged,
}: {
plan: PlanInput;
progress: AssistantProgress;
onOpenStep: (index: number) => void;
onChanged: () => void;
}) {
const [busy, setBusy] = useState<number | null>(null);
const done = progress.filter(Boolean).length;
const allDone = done === ASSISTANT_STEPS.length;
async function toggle(index: number, next: boolean) {
setBusy(index);
try {
await api.post(`/api/scenarios/${plan.id}/assistant`, { step: index, done: next });
onChanged();
} finally {
setBusy(null);
}
}
// Offene zuerst, erledigte darunter -- die Reihenfolge innerhalb der Gruppen bleibt.
const ordered = [...ASSISTANT_STEPS].sort((a, b) => {
const da = progress[a.index] ? 1 : 0;
const db = progress[b.index] ? 1 : 0;
return da - db || a.index - b.index;
});
return (
<div
data-tour="assistant"
className={`flex h-full flex-col rounded-xl border px-3 py-2.5 shadow-sm transition-colors ${
allDone ? "border-success bg-success-soft" : "border-attention bg-attention-soft"
}`}
>
<div className="mb-2 flex items-center gap-2">
<Sparkles className={`h-4 w-4 ${allDone ? "text-success" : "text-attention-fg"}`} />
<span className="text-xs font-semibold uppercase tracking-wide text-fg">FPT-Assistent</span>
<span className={`ml-auto text-[11px] font-semibold ${allDone ? "text-success" : "text-muted"}`}>
{done} / {ASSISTANT_STEPS.length}
</span>
</div>
<div className="flex min-h-0 flex-1 flex-col gap-1 overflow-auto">
{ordered.map((step) => {
const isDone = progress[step.index] === true;
const blocked = stepBlockedReason(plan, step.index);
const status = stepStatus(plan, step.index);
return (
<div
key={step.index}
className={`flex items-start gap-2 rounded-lg px-1.5 py-1 transition-colors ${
isDone ? "opacity-50" : "hover:bg-surface/60"
}`}
>
<button
type="button"
aria-label={isDone ? "Als offen markieren" : "Als erledigt markieren"}
disabled={busy === step.index}
onClick={() => toggle(step.index, !isDone)}
className={`mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded border transition-colors ${
isDone ? "border-success bg-success text-white" : "border-border bg-surface hover:border-accent"
}`}
>
{isDone && <Check className="h-3 w-3" />}
</button>
<button
type="button"
onClick={() => onOpenStep(step.index)}
className="min-w-0 flex-1 text-left"
>
<span
className={`flex items-center gap-1 text-xs font-semibold ${
isDone ? "text-muted line-through" : "text-fg"
}`}
>
<span className="truncate">
{step.index + 1}. {step.title}
</span>
{blocked && !isDone && <Lock className="h-3 w-3 shrink-0 text-faint" />}
<ChevronRight className="h-3 w-3 shrink-0 text-faint" />
</span>
{!isDone && (
<span className="mt-0.5 block truncate text-[11px] text-muted">
{status || step.short}
</span>
)}
</button>
</div>
);
})}
</div>
{allDone && (
<p className="mt-2 border-t border-success/30 pt-2 text-[11px] text-success">
Alle Schritte erledigt. Dein Plan steht verfeinere ihn jederzeit über die Matrix.
</p>
)}
</div>
);
}
+297
View File
@@ -0,0 +1,297 @@
"use client";
// Das Popup eines Assistenten-Schritts.
//
// Immer gleich aufgebaut: SEITE 1 erklärt, worum es geht -- welche Fragen man sich stellt,
// welche Möglichkeiten das Tool bietet, worauf es ankommt. Erst danach folgt das Werkzeug.
//
// Warum die Erklärseite nicht übersprungen wird: Finanzplanung scheitert selten an der
// Bedienung und fast immer daran, dass unklar ist, was der Schritt eigentlich bezweckt. Wer
// es schon weiss, klickt unten links auf «Selbst erledigen» und ist in zwei Sekunden draussen.
import { useState } from "react";
import { ArrowLeft, ArrowRight, Check, Lock } from "lucide-react";
import { Button, Modal } from "@/components/ui";
import { ASSISTANT_STEPS, stepBlockedReason } from "@/lib/assistant";
import type { PlanInput } from "@/lib/types";
export interface StepExplainer {
// Kurze Einordnung ganz oben.
lead: string;
// Die Fragen, die dieser Schritt beantwortet.
questions: string[];
// Was man wissen sollte, bevor man loslegt.
notes: { title: string; text: string }[];
}
export const STEP_EXPLAINERS: StepExplainer[] = [
{
lead:
"Bevor du planst, hältst du fest, was heute da ist. Alles Weitere baut darauf auf ohne Bestandsaufnahme rechnet das Tool ins Leere.",
questions: [
"Was verdienst du, was gibst du aus?",
"Welche Guthaben hast du Pensionskasse, Säule 3a, Wertschriften, Konto?",
"Besitzt du Wohneigentum? Wie hoch ist die Hypothek?",
"Hast du Schulden ausserhalb der Hypothek?",
],
notes: [
{
title: "Nur Bestände, noch keine Raten",
text: "Wie viel du jährlich sparst oder amortisierst, kommt später das hängt von deinen Lebensphasen ab. Hier zählt nur der Stand von heute.",
},
{
title: "Lieber grob als gar nicht",
text: "Ein geschätzter Wert ist besser als ein leeres Feld. Du kannst jede Zahl später überall im Tool korrigieren.",
},
{
title: "Getrennt nach Person",
text: "Pensionskasse und Säule 3a gehören immer einer Person. Wertschriften, Immobilien und Schulden können gemeinsam sein.",
},
],
},
{
lead:
"Jetzt legst du die Zeitachse fest: wie weit du planst und wann welche Rente oder welches Kapital zu fliessen beginnt. Das bestimmt, wo deine Lebensphasen zwingend beginnen müssen.",
questions: [
"Über wie viele Jahre soll die Planung laufen?",
"Wann hörst du auf zu arbeiten?",
"Wann beziehst du die AHV früher, mit 65, oder später?",
"Wann beziehst du Pensionskasse und Säule 3a?",
],
notes: [
{
title: "Noch keine Beträge",
text: "Hier geht es nur um Zeitpunkte. Ob Rente oder Kapital, und wohin das Geld fliesst, entscheidest du in Schritt 5.",
},
{
title: "Jeder Bezug erzeugt eine Phasengrenze",
text: "Beziehst du die AHV mit 63 und die Säule 3a mit 65, sind das zwei Fixpunkte. FPT rechnet phasenweise deshalb muss dort jeweils eine neue Lebensphase beginnen.",
},
{
title: "Früher kostet, später bringt",
text: "Ein AHV-Vorbezug kürzt die Rente lebenslang um 6,8 % pro Jahr, ein Aufschub erhöht sie um bis zu 31,5 %. Das Tool rechnet beides für dich aus.",
},
],
},
{
lead:
"Deine Planung zerfällt in Abschnitte mit gleichbleibenden Verhältnissen. Die Fixpunkte aus Schritt 2 stehen fest dazwischen teilst du frei ein.",
questions: [
"Gibt es Jahre, die sich deutlich voneinander unterscheiden?",
"Planst du einen Hauskauf, eine Weiterbildung, ein Sabbatical?",
"Wie fein willst du planen wenige grosse Phasen oder viele kleine?",
],
notes: [
{
title: "Eine Phase = gleichbleibende Verhältnisse",
text: "Innerhalb einer Lebensphase laufen Einkommen, Ausgaben und Sparraten gleichmässig weiter. Ändert sich etwas grundlegend, beginnt eine neue Phase.",
},
{
title: "Weniger ist oft mehr",
text: "Drei bis sechs Phasen reichen für die meisten Planungen. Jede zusätzliche Phase bedeutet mehr Entscheide, die du treffen musst.",
},
],
},
{
lead:
"Für die Jahre, in denen du noch arbeitest: Wohin fliesst das Geld, das übrig bleibt und was passiert an den Übergängen dazwischen?",
questions: [
"Wie viel sparst du pro Jahr, und wohin?",
"Zahlst du in die Säule 3a ein? Amortisierst du die Hypothek?",
"Verkaufst du unterwegs etwas, oder kommt Geld herein?",
],
notes: [
{
title: "Der Rest bleibt auf dem Konto",
text: "Was du nicht zuteilst, sammelt sich als Cash an. Das ist kein Fehler aber meist nicht die Absicht.",
},
{
title: "Übergänge sind Entscheidungspunkte",
text: "Zwischen zwei Lebensphasen entscheidest du, was mit Immobilien, Schulden und einmaligen Ereignissen geschieht.",
},
],
},
{
lead:
"Der wichtigste Schritt: Was passiert mit deinem Altersguthaben? Hier entscheidest du über Rente oder Kapital und darüber, wovon du in der Pension lebst.",
questions: [
"Rente oder Kapital oder eine Mischung?",
"Wenn Kapital: wohin damit? Hypothek amortisieren, anlegen, liegen lassen?",
"Reicht das Renteneinkommen für deine Ausgaben?",
],
notes: [
{
title: "Rente ist sicher, Kapital ist flexibel",
text: "Eine Rente fliesst lebenslang, unabhängig von den Märkten. Kapital bleibt verfügbar und vererbbar, trägt aber das Anlagerisiko und muss reichen.",
},
{
title: "Die Rentenlücke ist normal",
text: "Fast niemand deckt seine Ausgaben allein aus AHV und Pensionskasse. Entscheidend ist nicht, ob eine Lücke besteht, sondern wie lange dein Vermögen sie trägt.",
},
{
title: "Steuern beim Kapitalbezug",
text: "Ein Kapitalbezug wird einmalig und getrennt vom Einkommen besteuert. Alle Bezüge desselben Jahres werden zusammengezählt gestaffelt über mehrere Jahre zahlst du weniger.",
},
],
},
{
lead:
"Die Jahre nach der Pensionierung: Kein Lohn mehr, dafür Renten und das Vermögen, das du aufgebaut hast. Jetzt legst du fest, wovon du lebst.",
questions: [
"Wie viel entnimmst du jährlich aus deinem Vermögen?",
"Ändern sich deine Ausgaben im Alter Reisen zuerst, Pflege später?",
"Verkaufst du irgendwann die Immobilie?",
],
notes: [
{
title: "Verzehr statt Sparen",
text: "In diesen Phasen ist die Quote meist negativ: Du gibst mehr aus, als hereinkommt. Die Differenz kommt aus dem Vermögen das ist der Zweck des Sparens.",
},
{
title: "Achte auf das Ruinalter",
text: "Reicht das Vermögen nicht bis zum Planungshorizont, meldet FPT das. Dann helfen: später aufhören, weniger ausgeben, oder anders anlegen.",
},
],
},
{
lead:
"Dein Plan steht. Was jetzt noch möglich ist und wo du es findest.",
questions: [],
notes: [
{
title: "Werte je Lebensphase anpassen",
text: "Willst du für eine einzelne Phase eine andere Rendite oder Sparrate? Klicke die Zelle in der Matrix an. Leere Felder erben automatisch von der Vorphase so gilt eine Änderung ab dort weiter.",
},
{
title: "Pensionsplanung zentral ändern",
text: "Alle Entscheide zu AHV, Pensionskasse und Säule 3a stehen gebündelt unter «Pensionsplanung» in den Grundeinstellungen für jede Person einzeln.",
},
{
title: "Varianten vergleichen",
text: "Lege ein zweites Szenario an (Schnellaktionen), ändere dort etwa das Pensionsalter, und vergleiche beide. Deine Entscheide werden mitkopiert.",
},
{
title: "Unsicherheiten prüfen",
text: "Unter «Analysen» findest du die Einflussfaktoren (welcher Hebel wirkt am stärksten), die Live-Simulation (an Reglern drehen) und die Monte-Carlo-Simulation (wie robust ist der Plan).",
},
{
title: "Nachvollziehen, was gerechnet wird",
text: "«Rechenwege» in den Schnellaktionen zeigt zu jeder Zahl den vollständigen Weg inklusive der Vereinfachungen, die FPT bewusst macht.",
},
],
},
];
export function AssistantStepDialog({
plan,
stepIndex,
isDone,
onToggleDone,
onClose,
children,
}: {
plan: PlanInput;
stepIndex: number;
isDone: boolean;
onToggleDone: (done: boolean) => void;
onClose: () => void;
// Das Werkzeug des Schritts. Fehlt es, ist der Schritt reine Information.
children?: React.ReactNode;
}) {
const step = ASSISTANT_STEPS[stepIndex];
const explainer = STEP_EXPLAINERS[stepIndex];
const blocked = stepBlockedReason(plan, stepIndex);
const [showTool, setShowTool] = useState(false);
const canGuide = step.guided && !blocked && !!children;
return (
<Modal
title={`Schritt ${stepIndex + 1}: ${step.title}`}
subtitle={showTool ? undefined : step.lead}
onClose={onClose}
xwide
>
{showTool ? (
<div className="flex flex-col gap-4">
{children}
<div className="flex items-center justify-between border-t border-border pt-3">
<Button variant="ghost" onClick={() => setShowTool(false)}>
<ArrowLeft className="h-4 w-4" /> Zurück zur Erklärung
</Button>
<Button
onClick={() => {
onToggleDone(true);
onClose();
}}
>
<Check className="h-4 w-4" /> Schritt abschliessen
</Button>
</div>
</div>
) : (
<div className="flex flex-col gap-4">
<p className="text-sm text-fg">{explainer.lead}</p>
{explainer.questions.length > 0 && (
<div className="rounded-xl border border-border bg-surface-2 p-4">
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-faint">
Diese Fragen beantwortest du hier
</p>
<ul className="flex flex-col gap-1.5">
{explainer.questions.map((q) => (
<li key={q} className="flex items-start gap-2 text-sm text-fg">
<ArrowRight className="mt-0.5 h-3.5 w-3.5 shrink-0 text-accent" />
{q}
</li>
))}
</ul>
</div>
)}
<div className="grid gap-3 sm:grid-cols-2">
{explainer.notes.map((n) => (
<div key={n.title} className="rounded-xl border border-border p-3">
<p className="text-sm font-semibold text-fg">{n.title}</p>
<p className="mt-1 text-xs leading-relaxed text-muted">{n.text}</p>
</div>
))}
</div>
{blocked && (
<p className="flex items-start gap-2 rounded-lg border border-attention bg-attention-soft px-3 py-2 text-xs text-attention-soft-fg">
<Lock className="mt-0.5 h-3.5 w-3.5 shrink-0" />
{blocked}
</p>
)}
<div className="flex items-center justify-between border-t border-border pt-3">
<Button
variant="ghost"
onClick={() => {
onToggleDone(!isDone);
onClose();
}}
>
{isDone ? "Wieder als offen markieren" : "Selbst erledigen"}
</Button>
{canGuide ? (
<Button onClick={() => setShowTool(true)}>
Schritt für Schritt <ArrowRight className="h-4 w-4" />
</Button>
) : (
<Button
onClick={() => {
onToggleDone(true);
onClose();
}}
>
<Check className="h-4 w-4" /> Verstanden
</Button>
)}
</div>
</div>
)}
</Modal>
);
}
+917
View File
@@ -0,0 +1,917 @@
"use client";
// Die Werkzeuge der Assistenten-Schritte.
//
// Alle arbeiten ausschliesslich über die bestehenden Endpunkte -- der Assistent ist
// Orchestrierung, kein zweiter Datenpfad. Was er schreibt, hätte man auch von Hand über die
// Matrix schreiben können; er nimmt einem nur das Suchen ab.
import { useState } from "react";
import { AlertTriangle, ArrowLeft, ArrowRight, Check, Plus, Trash2 } from "lucide-react";
import { api } from "@/lib/api-client";
import { Button } from "@/components/ui";
import { MoneyField, NumberField, SelectField, TextField } from "@/components/FormField";
import { RetirementFields } from "@/components/RetirementFields";
import { CapitalDistributionDialog, RateDistributionDialog } from "@/components/DistributionDialogs";
import { formatChf } from "@/lib/format";
import { CATEGORY_LABELS, ownerLabel, num, type PhaseData } from "@/lib/elements";
import {
DEFAULT_PLANNING_HORIZON_YEARS,
MAX_PLANNING_HORIZON_YEARS,
MIN_PLANNING_HORIZON_YEARS,
PILLAR_3A_MAX_ANNUAL,
} from "@/lib/constants";
import { planSegments, defaultOpenDuration, type PlanSegment } from "@/lib/phaseplan";
import { fixpointPersonsOf, type PlanComputed } from "@/lib/calculations";
import type { PlanInput, PersonRole } from "@/lib/types";
// ============================================================================================
// Schritt 1: Bestandsaufnahme
// ============================================================================================
//
// Legt Elemente an und schreibt ihre STAMMDATEN -- ohne dass Lebensphasen existieren müssen.
// Das ist der Grund, warum es `baseData` gibt: Eine Bestandsaufnahme ist keine Aussage über
// eine Phase, sondern über den Stand von heute.
type Scope = "HOUSEHOLD" | "PERSON_A" | "PERSON_B";
interface Kind {
key: string;
category: string;
label: string;
hint: string;
personOnly?: boolean;
fields: (v: PhaseData, set: (p: Partial<PhaseData>) => void, plan: PlanInput) => React.ReactNode;
}
const KINDS: Kind[] = [
{
key: "income",
category: "INCOME",
label: "Erwerbseinkommen",
hint: "Dein Nettolohn also das, was auf dem Konto ankommt.",
personOnly: true,
fields: (v, set) => (
<>
<MoneyField
label="Nettolohn pro Jahr"
help="Was tatsächlich aufs Konto kommt, nach allen Abzügen. FPT rechnet für die AHV intern auf den Bruttolohn hoch."
value={num(v.amount)}
onChange={(x) => set({ amount: x })}
/>
<NumberField
label="Jährliche Lohnerhöhung (%)"
help="Nominal, also inklusive Teuerung. 1 % ist ein vorsichtiger Ausgangswert."
step={0.5}
value={num(v.teuerungsausgleich, 1)}
onChange={(x) => set({ teuerungsausgleich: x })}
/>
</>
),
},
{
key: "expense",
category: "EXPENSE",
label: "Lebenshaltung",
hint: "Alles, was du im Jahr ausgibst Wohnen, Essen, Versicherungen, Freizeit.",
fields: (v, set) => (
<MoneyField
label="Ausgaben pro Jahr"
help="In heutiger Kaufkraft. Die Teuerung rechnet FPT selbst dazu."
value={num(v.amount)}
onChange={(x) => set({ amount: x })}
/>
),
},
{
key: "pk",
category: "PENSION_FUND",
label: "Pensionskasse",
hint: "Dein Altersguthaben laut PK-Ausweis.",
personOnly: true,
fields: (v, set) => (
<>
<MoneyField
label="Altersguthaben heute"
help="Steht auf deinem Vorsorgeausweis."
value={num(v.currentValue)}
onChange={(x) => set({ currentValue: x })}
/>
<NumberField
label="Verzinsung (%/Jahr)"
help="Der BVG-Mindestzins liegt aktuell tief; viele Kassen verzinsen etwas darüber."
step={0.25}
value={num(v.expectedReturn, 1.25)}
onChange={(x) => set({ expectedReturn: x })}
/>
</>
),
},
{
key: "p3a",
category: "PILLAR_3A",
label: "Säule 3a",
hint: "Ein Konto oder Depot der gebundenen Vorsorge. Mehrere Konten legst du einzeln an.",
personOnly: true,
fields: (v, set) => (
<>
<MoneyField
label="Guthaben heute"
value={num(v.currentValue)}
onChange={(x) => set({ currentValue: x })}
/>
<NumberField
label="Erwartete Rendite (%/Jahr)"
help="Auf einem Konto praktisch 0, in einer Wertschriftenlösung deutlich mehr."
step={0.5}
value={num(v.expectedReturn, 2)}
onChange={(x) => set({ expectedReturn: x })}
/>
</>
),
},
{
key: "etf",
category: "OTHER_ASSET",
label: "Wertschriften und Sparguthaben",
hint: "Depot, ETF, Sparkonto alles freie Vermögen ausser dem Cash-Konto.",
fields: (v, set) => (
<>
<MoneyField label="Wert heute" value={num(v.startValue)} onChange={(x) => set({ startValue: x })} />
<NumberField
label="Erwartete Rendite (%/Jahr)"
help="Langfristiger Durchschnitt. Für ein gemischtes Portfolio sind 3 bis 5 % ein üblicher Ansatz."
step={0.5}
value={num(v.expectedReturn, 4)}
onChange={(x) => set({ expectedReturn: x })}
/>
</>
),
},
{
key: "re",
category: "REAL_ESTATE",
label: "Wohneigentum",
hint: "Haus oder Wohnung samt Hypothek.",
fields: (v, set) => (
<>
<MoneyField
label="Kaufpreis"
help="Der ursprüngliche Kaufpreis Grundlage der Grundstückgewinnsteuer bei einem späteren Verkauf."
value={num(v.purchasePrice)}
onChange={(x) => set({ purchasePrice: x })}
/>
<MoneyField
label="Hypothek heute"
value={num(v.mortgage)}
onChange={(x) => set({ mortgage: x })}
/>
<NumberField
label="Hypothekarzins (%)"
step={0.25}
value={num(v.interestRate, 2)}
onChange={(x) => set({ interestRate: x })}
/>
<NumberField
label="Wertsteigerung (%/Jahr)"
help="Auf den Wert der Liegenschaft, nicht auf dein Eigenkapital dort wirkt sie gehebelt."
step={0.25}
value={num(v.valueGrowth, 1)}
onChange={(x) => set({ valueGrowth: x })}
/>
<div className="sm:col-span-2">
<SelectField
label="Sind die Hypothekarzinsen in deinen Ausgaben schon enthalten?"
help="Wenn ja, rechnet FPT sie nicht noch einmal dazu sonst zählten sie doppelt."
value={v.interestHandling ?? "INCLUDED"}
onChange={(x: "INCLUDED" | "ADD") => set({ interestHandling: x })}
options={[
{ value: "INCLUDED", label: "Ja, sind in der Lebenshaltung enthalten" },
{ value: "ADD", label: "Nein, bitte zusätzlich rechnen" },
]}
/>
</div>
</>
),
},
{
key: "debt",
category: "OTHER_DEBT",
label: "Schulden",
hint: "Kredite ausserhalb der Hypothek.",
fields: (v, set) => (
<MoneyField label="Restschuld heute" value={num(v.startValue)} onChange={(x) => set({ startValue: x })} />
),
},
];
export function Step1Elements({ plan, onChanged }: { plan: PlanInput; onChanged: () => void }) {
const scopes: Scope[] =
plan.householdType === "COUPLE" ? ["HOUSEHOLD", "PERSON_A", "PERSON_B"] : ["PERSON_A"];
const [page, setPage] = useState(0);
const scope = scopes[page];
return (
<div className="flex flex-col gap-4">
{scopes.length > 1 && (
<div className="flex gap-1 rounded-lg border border-border bg-surface-2 p-1 text-xs">
{scopes.map((sc, i) => (
<button
key={sc}
type="button"
onClick={() => setPage(i)}
className={`flex-1 rounded-md px-2 py-1 font-medium ${
page === i ? "bg-accent text-accent-fg" : "text-muted hover:bg-surface"
}`}
>
{sc === "HOUSEHOLD" ? "Gemeinsam" : ownerLabel(plan.persons, sc)}
</button>
))}
</div>
)}
<ScopeElements plan={plan} scope={scope} onChanged={onChanged} />
{scopes.length > 1 && (
<div className="flex justify-between">
<Button variant="ghost" disabled={page === 0} onClick={() => setPage((p) => p - 1)}>
<ArrowLeft className="h-4 w-4" /> Zurück
</Button>
<Button
variant="secondary"
disabled={page === scopes.length - 1}
onClick={() => setPage((p) => p + 1)}
>
Weiter <ArrowRight className="h-4 w-4" />
</Button>
</div>
)}
</div>
);
}
function ScopeElements({
plan,
scope,
onChanged,
}: {
plan: PlanInput;
scope: Scope;
onChanged: () => void;
}) {
const [busy, setBusy] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const kinds = KINDS.filter((k) => (scope === "HOUSEHOLD" ? !k.personOnly : true));
const mine = plan.elements.filter((e) => (e.ownerRole ?? "HOUSEHOLD") === scope);
async function add(kind: Kind) {
setBusy(kind.key);
setError(null);
try {
const label = plan.householdType === "COUPLE" && scope !== "HOUSEHOLD" ? ` ${ownerLabel(plan.persons, scope)}` : "";
await api.post(`/api/scenarios/${plan.id}/elements`, {
category: kind.category,
name: `${kind.label}${label}`,
ownerRole: scope,
});
onChanged();
} catch (e) {
setError(e instanceof Error ? e.message : "Anlegen fehlgeschlagen.");
} finally {
setBusy(null);
}
}
return (
<div className="flex flex-col gap-3">
<div className="flex flex-wrap gap-2">
{kinds.map((k) => (
<button
key={k.key}
type="button"
disabled={busy === k.key}
onClick={() => add(k)}
className="flex items-center gap-1.5 rounded-lg border border-dashed border-border px-2.5 py-1.5 text-xs font-medium text-muted transition-colors hover:border-accent hover:text-accent-soft-fg"
>
<Plus className="h-3.5 w-3.5" /> {k.label}
</button>
))}
</div>
{mine.length === 0 ? (
<p className="rounded-lg border border-dashed border-border bg-surface-2 p-3 text-sm text-muted">
Noch nichts erfasst. Wähle oben, was zutrifft du kannst jederzeit weitere Positionen ergänzen.
</p>
) : (
<div className="flex flex-col gap-2">
{mine.map((el) => (
<BaseDataCard key={el.id} plan={plan} elementId={el.id} onChanged={onChanged} />
))}
</div>
)}
{error && <p className="text-sm text-danger">{error}</p>}
</div>
);
}
function BaseDataCard({
plan,
elementId,
onChanged,
}: {
plan: PlanInput;
elementId: string;
onChanged: () => void;
}) {
const el = plan.elements.find((x) => x.id === elementId)!;
const kind = KINDS.find((k) => k.category === el.category);
const [draft, setDraft] = useState<PhaseData>(el.baseData ?? {});
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
const [name, setName] = useState(el.name);
async function save() {
setSaving(true);
try {
if (name.trim() && name.trim() !== el.name) {
await api.patch(`/api/elements/${el.id}`, { name: name.trim() });
}
await api.put(`/api/elements/${el.id}/base`, draft);
setSaved(true);
onChanged();
} finally {
setSaving(false);
}
}
async function remove() {
await api.delete(`/api/elements/${el.id}`);
onChanged();
}
const set = (p: Partial<PhaseData>) => {
setDraft((d) => ({ ...d, ...p }));
setSaved(false);
};
return (
<div className="rounded-xl border border-border p-3">
<div className="mb-2 flex items-center gap-2">
<span className="text-xs font-semibold uppercase tracking-wide text-faint">
{CATEGORY_LABELS[el.category]}
</span>
<button
type="button"
onClick={remove}
className="ml-auto rounded p-1 text-faint hover:bg-danger-soft hover:text-danger"
aria-label="Entfernen"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div className="sm:col-span-2">
<TextField label="Bezeichnung" value={name} onChange={setName} />
</div>
{kind?.fields(draft, set, plan)}
{el.category === "PILLAR_3A" && (
<p className="sm:col-span-2 text-xs text-faint">
Hinweis: Der jährliche Höchstbetrag liegt bei {formatChf(PILLAR_3A_MAX_ANNUAL)}. Wie viel du einzahlst,
legst du in Schritt 4 fest.
</p>
)}
</div>
<div className="mt-3 flex items-center gap-2">
<Button size="sm" disabled={saving} onClick={save}>
{saving ? "…" : saved ? "Gespeichert" : "Speichern"}
</Button>
{saved && <Check className="h-4 w-4 text-success" />}
</div>
</div>
);
}
// ============================================================================================
// Schritt 2: Eckdaten und Pensionsplanung
// ============================================================================================
export function Step2Timeline({
plan,
computed,
onChanged,
}: {
plan: PlanInput;
computed: PlanComputed;
onChanged: () => void;
}) {
const [page, setPage] = useState(0);
const pages = ["HORIZON", ...plan.persons.map((p) => p.role)];
return (
<div className="flex flex-col gap-4">
<div className="flex gap-1 rounded-lg border border-border bg-surface-2 p-1 text-xs">
{pages.map((pg, i) => (
<button
key={pg}
type="button"
onClick={() => setPage(i)}
className={`flex-1 rounded-md px-2 py-1 font-medium ${
page === i ? "bg-accent text-accent-fg" : "text-muted hover:bg-surface"
}`}
>
{pg === "HORIZON" ? "Planungshorizont" : `Pensionierung ${ownerLabel(plan.persons, pg)}`}
</button>
))}
</div>
{page === 0 ? (
<HorizonStep plan={plan} onChanged={onChanged} />
) : (
<PersonTimingStep
plan={plan}
computed={computed}
role={pages[page] as PersonRole}
onChanged={onChanged}
/>
)}
<div className="flex justify-between">
<Button variant="ghost" disabled={page === 0} onClick={() => setPage((p) => p - 1)}>
<ArrowLeft className="h-4 w-4" /> Zurück
</Button>
<Button variant="secondary" disabled={page === pages.length - 1} onClick={() => setPage((p) => p + 1)}>
Weiter <ArrowRight className="h-4 w-4" />
</Button>
</div>
</div>
);
}
function HorizonStep({ plan, onChanged }: { plan: PlanInput; onChanged: () => void }) {
const [years, setYears] = useState(plan.planningHorizonYears ?? DEFAULT_PLANNING_HORIZON_YEARS);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
async function save() {
setBusy(true);
setError(null);
try {
await api.post(`/api/scenarios/${plan.id}/horizon`, { horizonYears: Math.round(years) });
onChanged();
} catch (e) {
setError(e instanceof Error ? e.message : "Nicht möglich.");
} finally {
setBusy(false);
}
}
return (
<div className="flex flex-col gap-3">
<p className="text-sm text-muted">
Über wie viele Jahre soll gerechnet werden? Der Horizont sollte deutlich über der Lebenserwartung liegen
eine zu kurze Planung sieht tragfähig aus, obwohl das Geld nur nicht lange genug reichen muss.
</p>
<div className="flex items-end gap-3">
<div className="w-40">
<NumberField
label="Planungshorizont (Jahre)"
value={years}
min={MIN_PLANNING_HORIZON_YEARS}
max={MAX_PLANNING_HORIZON_YEARS}
step={1}
onChange={setYears}
/>
</div>
<Button disabled={busy} onClick={save}>
{busy ? "…" : "Übernehmen"}
</Button>
</div>
<div className="rounded-xl border border-border bg-surface-2 p-3">
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-faint">Daraus ergibt sich</p>
<div className="flex flex-col gap-1 text-sm">
{plan.persons.map((p) => (
<div key={p.role} className="flex justify-between gap-3">
<span className="text-muted">{ownerLabel(plan.persons, p.role)} am Planende</span>
<span className="tabular-nums text-fg">{p.age + Math.round(years)} Jahre</span>
</div>
))}
{plan.startYear && (
<div className="flex justify-between gap-3 border-t border-border pt-1">
<span className="text-muted">Letztes Planjahr</span>
<span className="tabular-nums text-fg">{plan.startYear + Math.round(years) - 1}</span>
</div>
)}
</div>
</div>
{error && <p className="text-sm text-danger">{error}</p>}
</div>
);
}
function PersonTimingStep({
plan,
computed,
role,
onChanged,
}: {
plan: PlanInput;
computed: PlanComputed;
role: PersonRole;
onChanged: () => void;
}) {
const person = plan.persons.find((p) => p.role === role)!;
const own = plan.elements.filter(
(e) => e.ownerRole === role && ["AHV", "PENSION_FUND", "PILLAR_3A"].includes(e.category)
);
const summary = computed.retirement.perPerson.find((x) => x.role === role);
const [age, setAge] = useState(person.retirementAge);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [draft, setDraft] = useState<Record<string, Record<string, unknown>>>({});
async function saveAge() {
setBusy(true);
setError(null);
try {
await api.patch(`/api/scenarios/${plan.id}`, {
persons: plan.persons.map((p) => ({
role: p.role,
retirementAge: p.role === role ? Math.round(age) : p.retirementAge,
})),
});
onChanged();
} catch (e) {
setError(e instanceof Error ? e.message : "Nicht möglich.");
} finally {
setBusy(false);
}
}
async function saveDecisions() {
setBusy(true);
try {
for (const [id, patch] of Object.entries(draft)) {
const el = plan.elements.find((x) => x.id === id);
await api.put(`/api/elements/${id}/retirement`, { ...(el?.retirementDecision ?? {}), ...patch });
}
setDraft({});
onChanged();
} finally {
setBusy(false);
}
}
return (
<div className="flex flex-col gap-4">
<div className="rounded-xl border border-border p-3">
<div className="flex items-end gap-3">
<div className="w-44">
<NumberField
label="Erwerbstätigkeit endet mit"
help="Ab diesem Alter fliesst kein Lohn mehr. Das ist nicht zwingend dasselbe Alter, in dem deine Renten beginnen."
value={age}
min={40}
max={75}
step={1}
onChange={setAge}
/>
</div>
<Button disabled={busy || Math.round(age) === person.retirementAge} onClick={saveAge}>
Übernehmen
</Button>
</div>
{plan.startYear && (
<p className="mt-1 text-xs text-faint">
Das ist das Jahr {plan.startYear + (Math.round(age) - person.age)}.
</p>
)}
</div>
{own.length === 0 ? (
<p className="rounded-lg border border-dashed border-border bg-surface-2 p-3 text-sm text-muted">
Für {ownerLabel(plan.persons, role)} sind noch keine Vorsorge-Elemente erfasst. Gehe zurück zu Schritt 1
und lege AHV, Pensionskasse und Säule 3a an dann kannst du hier die Bezugszeitpunkte festlegen.
</p>
) : (
<>
<p className="text-sm text-muted">
Wann soll welches Guthaben zu fliessen beginnen? Jeder dieser Zeitpunkte erzeugt in Schritt 3 eine
Phasengrenze FPT rechnet phasenweise, deshalb muss ein Bezug immer am Anfang einer Lebensphase stehen.
</p>
<div className="flex flex-col gap-2">
{own.map((el) => (
<RetirementFields
key={el.id}
plan={plan}
el={el}
defaultOpen
patch={(id, patch) =>
setDraft((d) => ({ ...d, [id]: { ...(d[id] ?? {}), ...patch } }))
}
summary={summary}
/>
))}
</div>
{Object.keys(draft).length > 0 && (
<Button disabled={busy} onClick={saveDecisions}>
{busy ? "…" : "Bezugszeitpunkte speichern"}
</Button>
)}
</>
)}
{error && <p className="text-sm text-danger">{error}</p>}
</div>
);
}
// ============================================================================================
// Schritt 3: Lebensphasen
// ============================================================================================
export function Step3Phases({ plan, onChanged }: { plan: PlanInput; onChanged: () => void }) {
const segments = planSegments(fixpointPersonsOf(plan));
const horizon = plan.planningHorizonYears ?? DEFAULT_PLANNING_HORIZON_YEARS;
const [draft, setDraft] = useState<{ name: string; years: number }[][]>(() =>
segments.map((seg, i) => [
{
name: defaultName(seg, i),
years: seg.fixedYears ?? defaultOpenDuration(plan.persons, seg.fromYear, horizon),
},
])
);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const sums = draft.map((phs) => phs.reduce((s, p) => s + Math.max(0, p.years), 0));
const valid = segments.every((seg, i) => seg.fixedYears === null || sums[i] === seg.fixedYears);
async function create() {
setBusy(true);
setError(null);
try {
for (const phs of draft) {
for (const ph of phs) {
await api.post(`/api/scenarios/${plan.id}/phases`, {
name: ph.name.trim() || "Lebensphase",
durationYears: Math.max(1, Math.round(ph.years)),
});
}
}
onChanged();
} catch (e) {
setError(e instanceof Error ? e.message : "Anlegen fehlgeschlagen.");
} finally {
setBusy(false);
}
}
if (plan.phases.length > 0) {
return (
<p className="rounded-lg border border-dashed border-border bg-surface-2 p-3 text-sm text-muted">
Dieses Szenario hat bereits {plan.phases.length} Lebensphasen. Weitere legst du direkt in der Matrix an
(«+ Lebensphase» in der Ecke oben links) dort siehst du auch gleich, was sich dadurch ändert.
</p>
);
}
return (
<div className="flex flex-col gap-4">
<p className="text-sm text-muted">
Die dick umrandeten Abschnitte sind gesetzt sie ergeben sich aus deinen Bezugszeitpunkten. Innerhalb
eines Abschnitts kannst du beliebig fein unterteilen, die Summe muss aber genau aufgehen.
</p>
{segments.map((seg, i) => (
<div key={i} className="rounded-xl border border-border p-3">
<div className="mb-2 flex flex-wrap items-baseline gap-2">
<span className="text-sm font-semibold text-fg">
{seg.type === "ERWERB" ? "Erwerbsphase" : seg.type === "MIXED" ? "Übergangszeit" : "Ruhestand"}
</span>
<span className="text-xs text-muted">
ab Jahr {seg.fromYear + 1}
{seg.fixedYears !== null ? ` · ${seg.fixedYears} Jahre fest` : " · Dauer frei"}
</span>
{seg.endedBy.length > 0 && (
<span className="text-xs text-faint">endet mit: {seg.endedBy.map((f) => f.label).join(", ")}</span>
)}
{seg.fixedYears !== null && (
<span
className={`ml-auto text-xs font-semibold ${
sums[i] === seg.fixedYears ? "text-success" : "text-danger"
}`}
>
{sums[i]} / {seg.fixedYears} Jahre
</span>
)}
</div>
<div className="flex flex-col gap-2">
{draft[i].map((ph, j) => (
<div key={j} className="flex items-end gap-2">
<div className="flex-1">
<TextField
label="Bezeichnung"
value={ph.name}
onChange={(v) =>
setDraft((d) => d.map((x, xi) => (xi === i ? x.map((y, yj) => (yj === j ? { ...y, name: v } : y)) : x)))
}
/>
</div>
<div className="w-28">
<NumberField
label="Jahre"
value={ph.years}
min={1}
step={1}
onChange={(v) =>
setDraft((d) => d.map((x, xi) => (xi === i ? x.map((y, yj) => (yj === j ? { ...y, years: Math.round(v) } : y)) : x)))
}
/>
</div>
{draft[i].length > 1 && (
<button
type="button"
onClick={() => setDraft((d) => d.map((x, xi) => (xi === i ? x.filter((_, yj) => yj !== j) : x)))}
className="mb-1 rounded p-1.5 text-faint hover:bg-danger-soft hover:text-danger"
aria-label="Phase entfernen"
>
<Trash2 className="h-4 w-4" />
</button>
)}
</div>
))}
<button
type="button"
onClick={() =>
setDraft((d) =>
d.map((x, xi) => (xi === i ? [...x, { name: `Abschnitt ${x.length + 1}`, years: 1 }] : x))
)
}
className="flex w-fit items-center gap-1 rounded-lg border border-dashed border-border px-2 py-1 text-xs text-muted hover:border-accent"
>
<Plus className="h-3.5 w-3.5" /> Weiter unterteilen
</button>
</div>
</div>
))}
{!valid && (
<p className="flex items-start gap-2 text-xs text-danger">
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
In mindestens einem festen Abschnitt geht die Summe der Jahre nicht auf.
</p>
)}
{error && <p className="text-sm text-danger">{error}</p>}
<Button disabled={busy || !valid} onClick={create}>
{busy ? "…" : "Lebensphasen anlegen"}
</Button>
</div>
);
}
function defaultName(seg: PlanSegment, i: number): string {
if (seg.type === "ERWERB") return i === 0 ? "Erwerbsjahre" : "Erwerbsjahre II";
if (seg.type === "MIXED") return "Übergangszeit";
return "Ruhestand";
}
// ============================================================================================
// Schritte 4 bis 6: durch die Phasen und Übergänge führen
// ============================================================================================
//
// Alle drei arbeiten gleich -- sie unterscheiden sich nur im Ausschnitt der Zeitachse. Statt
// drei fast gleicher Komponenten gibt es eine mit einem Filter.
export type WalkScope = "WORK" | "RETIRE" | "PENSION";
export function StepWalkthrough({
plan,
computed,
scope,
onChanged,
}: {
plan: PlanInput;
computed: PlanComputed;
scope: WalkScope;
onChanged: () => void;
}) {
const [pos, setPos] = useState(0);
const [open, setOpen] = useState<"capital" | "rates" | null>(null);
// Die Stationen: Phasen und die Übergänge dazwischen, gefiltert auf den Ausschnitt.
const stations = computed.phases.flatMap((ph, i) => {
const next = computed.phases[i + 1];
const kind = phaseKind(ph.type, next?.type);
const out: { phaseId: string; label: string; type: "phase" | "transition"; toName?: string }[] = [];
if (inScope(scope, ph.type === "ERWERB" ? "WORK" : ph.type === "MIXED" ? "RETIRE" : "PENSION"))
out.push({ phaseId: ph.id, label: ph.name, type: "phase" });
if (next && inScope(scope, kind))
out.push({ phaseId: ph.id, label: `Übergang nach ${next.name}`, type: "transition", toName: next.name });
return out;
});
if (stations.length === 0) {
return (
<p className="rounded-lg border border-dashed border-border bg-surface-2 p-3 text-sm text-muted">
In diesem Abschnitt deiner Planung gibt es nichts zu entscheiden.
</p>
);
}
const st = stations[Math.min(pos, stations.length - 1)];
const phase = computed.phases.find((p) => p.id === st.phaseId)!;
return (
<div className="flex flex-col gap-4">
<div className="flex items-center gap-2">
<span className="text-xs text-muted">
Station {pos + 1} von {stations.length}
</span>
<div className="flex flex-1 gap-1">
{stations.map((_, i) => (
<div key={i} className={`h-1 flex-1 rounded-full ${i <= pos ? "bg-accent" : "bg-surface-2"}`} />
))}
</div>
</div>
<div className="rounded-xl border border-border p-4">
<h4 className="text-base font-semibold text-fg">{st.label}</h4>
<p className="mt-1 text-xs text-muted">
{st.type === "phase"
? `${phase.durationYears} Jahre · ${phase.isConsumption ? "Verzehrquote" : "Sparquote"} ${formatChf(phase.quotaStart)} im ersten Jahr`
: "Was geschieht beim Wechsel in die nächste Lebensphase?"}
</p>
<div className="mt-3 flex flex-wrap gap-2">
{st.type === "phase" ? (
<>
<Button size="sm" onClick={() => setOpen("rates")}>
{phase.isConsumption ? "Bezüge verteilen" : "Sparquote verteilen"}
</Button>
<Button size="sm" variant="secondary" onClick={() => setOpen("capital")}>
Kapital verteilen
</Button>
</>
) : (
<p className="text-sm text-muted">
Die Entscheide dieses Übergangs triffst du in der Matrix klicke dort auf die Übergangsspalte
nach «{phase.name}».
</p>
)}
</div>
</div>
<div className="flex justify-between">
<Button variant="ghost" disabled={pos === 0} onClick={() => setPos((p) => p - 1)}>
<ArrowLeft className="h-4 w-4" /> Vorherige
</Button>
<Button
variant="secondary"
disabled={pos >= stations.length - 1}
onClick={() => setPos((p) => p + 1)}
>
Nächste <ArrowRight className="h-4 w-4" />
</Button>
</div>
{open === "capital" && (
<CapitalDistributionDialog
plan={plan}
computed={computed}
phaseId={st.phaseId}
onClose={() => setOpen(null)}
onSaved={() => {
setOpen(null);
onChanged();
}}
/>
)}
{open === "rates" && (
<RateDistributionDialog
plan={plan}
computed={computed}
phaseId={st.phaseId}
onClose={() => setOpen(null)}
onSaved={() => {
setOpen(null);
onChanged();
}}
/>
)}
</div>
);
}
// Zu welchem Abschnitt gehört ein Übergang? Massgeblich ist, WOHIN er führt.
function phaseKind(from: string, to?: string): WalkScope {
if (!to) return from === "ERWERB" ? "WORK" : from === "MIXED" ? "RETIRE" : "PENSION";
if (to === "ERWERB") return "WORK";
if (to === "MIXED") return "RETIRE";
return from === "PENSION" ? "PENSION" : "RETIRE";
}
function inScope(scope: WalkScope, kind: WalkScope): boolean {
return scope === kind;
}
+22 -34
View File
@@ -1,6 +1,7 @@
"use client";
import { FieldLabel, NumberField, SelectField, TextField } from "@/components/FormField";
import { NumberField, SelectField, TextField } from "@/components/FormField";
import { AHV_REFERENCE_AGE } from "@/lib/constants";
import type { HouseholdType, PersonRole } from "@/lib/types";
export interface ProfileDraft {
@@ -15,23 +16,28 @@ export function emptyProfileDraft(): ProfileDraft {
householdType: "SINGLE",
inflationRateDefault: 1.5,
startYear: new Date().getFullYear(),
persons: [{ role: "PERSON_A", name: "", age: 35, retirementAge: 65 }],
// Das Pensionsalter wird beim Anlegen nicht gefragt: Es ist der erste Entscheid der
// Pensionsplanung. Bis dahin gilt das Referenzalter.
persons: [{ role: "PERSON_A", name: "", age: 35, retirementAge: AHV_REFERENCE_AGE }],
};
}
// Gemeinsame Formularfelder für das Grundprofil eines Plans (Haushaltsform, Personen,
// Inflation). Wird beim Plan-Erstellen und in den Plan-Einstellungen verwendet.
// Startjahr, Inflation). Wird beim Plan-Erstellen und in den Grundeinstellungen verwendet.
//
// Das PENSIONSALTER fehlt hier seit 0.36 bewusst. Es ist kein Stammdatum, sondern der erste
// Entscheid der Pensionsplanung -- und es erzeugt eine Phasengrenze. Es wird deshalb dort
// gesetzt («Pensionsplanung» je Person) und hier höchstens angezeigt.
export function PlanProfileFields({
draft,
onChange,
// Sobald Lebensphasen bestehen, liegt jede Pensionierung auf einer Phasengrenze. Ein frei
// änderbares Alter würde diese Grenze zerreissen -- deshalb übernimmt dort die eigene
// Bedienung «Pensionsalter anpassen» (Roadmap Nr. 44), und das Feld ist nur noch Anzeige.
lockRetirement = false,
// Blendet den Hinweis aus, dass diese Angaben plan-weit gelten -- beim Anlegen eines Plans
// gibt es noch keine anderen Szenarien, die betroffen sein könnten.
hideScopeNote = false,
}: {
draft: ProfileDraft;
onChange: (next: ProfileDraft) => void;
lockRetirement?: boolean;
hideScopeNote?: boolean;
}) {
function setType(type: HouseholdType) {
if (type === "SINGLE") {
@@ -39,7 +45,7 @@ export function PlanProfileFields({
} else {
const persons =
draft.persons.length < 2
? [...draft.persons, { role: "PERSON_B" as PersonRole, name: "", age: 35, retirementAge: 65 }]
? [...draft.persons, { role: "PERSON_B" as PersonRole, name: "", age: 35, retirementAge: AHV_REFERENCE_AGE }]
: draft.persons;
onChange({ ...draft, householdType: type, persons });
}
@@ -57,11 +63,12 @@ export function PlanProfileFields({
{/* Seit V7 beschreiben Haushaltsform, Personen und Startjahr den PLAN und gelten für
ALLE Szenarien. Das muss sichtbar sein -- sonst ändert man beim Bearbeiten eines
Nebenszenarios unbemerkt auch alle anderen. */}
<p className="rounded-lg border border-attention bg-attention-soft px-3 py-2 text-xs text-attention-soft-fg">
Haushaltsform, Personen und Planstart gelten für <strong>alle Szenarien</strong> dieses
Plans. Unterscheiden sie sich, ist es ein anderer Plan. Szenario-eigen sind nur das{" "}
<strong>Pensionsalter</strong> und die <strong>Inflation</strong>.
</p>
{!hideScopeNote && (
<p className="rounded-lg border border-attention bg-attention-soft px-3 py-2 text-xs text-attention-soft-fg">
Haushaltsform, Personen und Planstart gelten für <strong>alle Szenarien</strong> dieses
Plans. Unterscheiden sie sich, ist es ein anderer Plan.
</p>
)}
<SelectField
label="Haushaltsform (plan-weit)"
@@ -94,26 +101,7 @@ export function PlanProfileFields({
max={120}
onChange={(v) => updatePerson(index, { age: Math.round(v) })}
/>
{lockRetirement ? (
<div>
<FieldLabel
label="Pensionierungsalter"
help="Liegt immer auf einer Phasengrenze und wird deshalb unten unter «Pensionsalter anpassen» verschoben -- dabei wandern die angrenzenden Phasendauern mit."
/>
<div className="mt-1 rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-muted">
{person.retirementAge} Jahre
</div>
</div>
) : (
<NumberField
label="Pensionierungsalter (nur dieses Szenario)"
help="Steuert die Ableitung des Phasentyps (Erwerb/Pension). Als einzige Personenangabe szenario-eigen -- das ist der Kern jedes Früh-/Spätpensionierungs-Szenarios."
value={person.retirementAge}
min={30}
max={100}
onChange={(v) => updatePerson(index, { retirementAge: Math.round(v) })}
/>
)}
{/* Das Pensionsalter steht bewusst NICHT hier -- siehe Kommentar oben. */}
</div>
))}
+375 -323
View File
@@ -3,7 +3,6 @@
import { useEffect, useMemo, useState } from "react";
import {
AlertCircle,
ArrowRight,
Building2,
CheckCircle2,
ChevronDown,
@@ -11,13 +10,15 @@ import {
CreditCard,
Home,
Landmark,
ListTodo,
Pencil,
Maximize2,
PiggyBank,
Table2,
Copy,
HelpCircle,
History as HistoryIcon,
BookOpen,
Download,
Plus,
Settings2,
ShoppingCart,
Tornado,
Trash2,
@@ -35,15 +36,22 @@ import {
type DecisionCounts,
transitionInactive as inactiveAtTransition,
TRANSITION_CATEGORIES,
retirementConfirmed,
} from "@/lib/decisions";
import { AHV_REFERENCE_AGE } from "@/lib/constants";
import {
RETIREMENT_CATEGORIES,
withRetirementDefaults,
type RetirementDecision,
} from "@/lib/retirement-decision";
import { RetirementFields } from "@/components/RetirementFields";
import { Assistant } from "@/components/Assistant";
import { AssistantStepDialog } from "@/components/AssistantStepDialog";
import {
Step1Elements,
Step2Timeline,
Step3Phases,
StepWalkthrough,
} from "@/components/AssistantSteps";
import { normalizeProgress } from "@/lib/assistant";
import { RetirementPanel } from "@/components/RetirementPanel";
import { Button, EmptyState, InspectorShell, Modal, useConfirm, useToast } from "@/components/ui";
import { ElementDetailDialog, PhaseDetailDialog } from "@/components/DetailView";
@@ -80,7 +88,7 @@ import {
} from "@/lib/elements";
import { computePlan, type PhaseComputed, type PlanComputed } from "@/lib/calculations";
import type { ScenarioDiff } from "@/lib/diff";
import type { ElementInput, PlanInput } from "@/lib/types";
import type { ElementInput, PersonRole, PlanInput } from "@/lib/types";
const CATEGORY_ICON: Record<ElementCategory, React.ReactNode> = {
INCOME: <Wallet className="h-4 w-4" />,
@@ -128,6 +136,11 @@ export function PlanView({
onChanged,
onOpenSpec,
onOpenSensitivity,
onOpenHistory,
onStartTour,
onCopyScenario,
onOpenTraces,
exportHref,
}: {
plan: PlanInput;
computed: PlanComputed;
@@ -140,6 +153,13 @@ export function PlanView({
onChanged: () => void;
onOpenSpec?: (anchor: string) => void;
onOpenSensitivity?: () => void;
// Schnellaktionen -- sie liegen beim Aufrufer, weil sie Dialoge ausserhalb dieser Ansicht
// oeffnen. Die Kachel dafuer sitzt seit 0.36 hier im Screen.
onOpenHistory?: () => void;
onStartTour?: () => void;
onCopyScenario?: () => void;
onOpenTraces?: () => void;
exportHref?: string;
}) {
// Planzahlen oder effektive Zahlen (inkl. Abweichung). Bewusst zwei Möglichkeiten statt
// dreier: Plan UND Ist als Rohwerte nebeneinander wären mit nominal/real acht Zahlen je
@@ -182,8 +202,10 @@ export function PlanView({
// weil beide mehrere Elemente auf einmal bearbeiten.
const [distribute, setDistribute] = useState<{ kind: "capital" | "rates"; phaseId: string } | null>(null);
const [valueMode, setValueMode] = useState<ValueMode>("nominal");
// Matrix oder Pensionierung. Kein URL-Zustand: Es ist eine Arbeitsansicht, kein Ort.
const [view, setView] = useState<"matrix" | "retirement">("matrix");
// Offener Assistenten-Schritt (Index) bzw. offene Pensionsplanung (Rolle).
const [assistantStep, setAssistantStep] = useState<number | null>(null);
const [retirementRole, setRetirementRole] = useState<PersonRole | null>(null);
const progress = normalizeProgress(plan.assistantProgress);
// Die Tour (Start-Knopf, Auto-Start bei Plan-Erstellung, Rendering) liegt seit dem
// Layout-Umbau in AppShell -- sie liest die data-tour-Ziele im DOM dieser Ansicht.
// Nur-Lese-Detailansicht (Roadmap Nr. 43). Der Rechenweg wird erst beim Öffnen erzeugt.
@@ -366,72 +388,40 @@ export function PlanView({
onChanged();
}
// Kurzfassung der Pensionierung fuer die Matrix-Ansicht: die eine Zahl, die zaehlt, plus
// der Hinweis auf ungepruefte Vorgaben. Ein Klick fuehrt in den Bildschirm.
const retirementSummaryText = (() => {
const r = computed.retirement;
if (r.gapAnnual === null) return null;
const unconfirmed = plan.elements.filter(
(e) => RETIREMENT_CATEGORIES.includes(e.category) && !retirementConfirmed(e)
).length;
const luecke = r.gapAnnual < 0 ? `Rentenluecke ${formatChf(r.gapAnnual)}/Jahr` : `Ueberschuss ${formatChf(r.gapAnnual)}/Jahr`;
return unconfirmed > 0 ? `${luecke} · ${unconfirmed} Vorgaben ungeprueft` : luecke;
})();
const hasPhases = computed.phases.length > 0;
const firstPhase = computed.phases[0] ?? null;
const lastPhase = computed.phases[computed.phases.length - 1] ?? null;
return (
<div className="flex flex-col gap-5">
<NextSteps
plan={plan}
computed={computed}
columns={columns}
openCountFor={transitionOpenCount}
onReview={(fromPhaseId) => setReviewFromPhaseId(fromPhaseId)}
onAddElement={(cat) => {
setAddCategory(cat);
setShowAdd(true);
}}
onAddPhase={() => setShowAddPhase(true)}
onOpenSensitivity={onOpenSensitivity}
/>
{/* Kompakter Klotz: Grundprofil (25 %) · Zeitachse (50 %) · Kennzahlen (25 %). Auf
schmalen Screens stapeln die drei. */}
<div className="grid grid-cols-1 gap-4 lg:grid-cols-4">
{/* Grundprofil -- Daten untereinander, «Bearbeiten» oben rechts. */}
<div
data-tour="profil"
className={`flex flex-col gap-2 rounded-xl border px-4 py-3 shadow-sm lg:col-span-1 ${
diff?.profileChanged ? "border-diff bg-diff-soft" : "border-border bg-surface"
}`}
>
<div className="flex items-start justify-between gap-2">
<span className="text-xs font-semibold uppercase tracking-wide text-faint">
Grundprofil{diff?.profileChanged ? " · abweichend" : ""}
</span>
<button
type="button"
onClick={() => setPanel({ kind: "profile" })}
className="flex shrink-0 items-center gap-1 rounded-lg border border-border px-2 py-0.5 text-xs font-medium text-muted hover:bg-surface-2"
>
<Settings2 className="h-3.5 w-3.5" /> Bearbeiten
</button>
</div>
<div className="flex flex-col gap-1">
{plan.persons.map((p) => (
<span key={p.role} className="text-xs text-muted">
{personLabel(p.role)}: {p.age} J., Pension {p.retirementAge}
</span>
))}
<span className="text-xs text-muted">Inflation {plan.inflationRateDefault}%</span>
</div>
{/* OBERER BEREICH -- die Steuerung. Farblich abgesetzt vom unteren, damit sichtbar
ist: hier stellst du ein, unten siehst du das Ergebnis. Vier Kacheln nebeneinander,
darunter die Zeitachse über die volle Breite. */}
<div className="rounded-2xl border border-border bg-surface-2/60 p-4 shadow-sm">
<div className="grid grid-cols-1 gap-3 lg:grid-cols-4">
<SettingsTile
plan={plan}
computed={computed}
diffChanged={!!diff?.profileChanged}
onEdit={() => setPanel({ kind: "profile" })}
onRetirement={(role) => setRetirementRole(role)}
/>
<FiguresTile plan={plan} computed={computed} />
<QuickActionsTile
onHistory={onOpenHistory}
onTour={onStartTour}
onNewScenario={onCopyScenario}
onTraces={onOpenTraces}
exportHref={exportHref}
/>
<Assistant
plan={plan}
progress={progress}
onOpenStep={(i) => setAssistantStep(i)}
onChanged={onChanged}
/>
</div>
{/* Zeitachse */}
<div data-tour="timeline" className="lg:col-span-2">
<div data-tour="timeline" className="mt-3">
<Timeline
phases={computed.phases}
persons={personAxes}
@@ -440,116 +430,8 @@ export function PlanView({
startYear={plan.startYear}
/>
</div>
{/* Kennzahlen: Endvermögen nominal + real (+ Ruin, falls vorhanden). */}
<div data-tour="endwealth" className="flex flex-col justify-center gap-2 rounded-xl border border-border bg-surface px-4 py-3 shadow-sm lg:col-span-1">
<span className="text-xs font-semibold uppercase tracking-wide text-faint">Endvermögen</span>
<div>
<div className="text-[11px] text-muted">Nominal</div>
<div className="text-lg font-bold text-fg">
{lastPhase ? formatChf(Math.round(lastPhase.endWealthNominal)) : "—"}
</div>
</div>
<div>
<div className="text-[11px] text-muted">Real (heutige Kaufkraft)</div>
<div className="text-lg font-bold text-fg">
{lastPhase ? formatChf(Math.round(lastPhase.endWealthReal)) : "—"}
</div>
</div>
{computed.ruinAge !== null && (
<div className="mt-0.5 rounded-lg bg-danger-soft px-2 py-1 text-xs font-medium text-danger">
Kapital reicht nur bis Alter {computed.ruinAge}
</div>
)}
</div>
</div>
{/* Ansicht: Matrix oder Pensionierung. Die Pensionierung steht bewusst gleichrangig
NEBEN der Matrix und nicht in ihr: Sie ist keine Zelle, sondern eine eigene Frage --
und die einzige, die man zwischen Szenarien systematisch variiert. */}
{hasPhases && (
<div className="flex flex-wrap items-center gap-2">
<div className="inline-flex rounded-lg border border-border bg-surface p-0.5 text-sm">
{(["matrix", "retirement"] as const).map((v) => (
<button
key={v}
type="button"
onClick={() => setView(v)}
className={`flex items-center gap-1.5 rounded-md px-3 py-1 font-medium ${
view === v ? "bg-accent text-accent-fg" : "text-muted hover:bg-surface-2"
}`}
>
{v === "matrix" ? <Table2 className="h-4 w-4" /> : <PiggyBank className="h-4 w-4" />}
{v === "matrix" ? "Matrix" : "Pensionierung"}
</button>
))}
</div>
{view === "matrix" && retirementSummaryText && (
<button
type="button"
onClick={() => setView("retirement")}
className="truncate text-xs text-muted underline decoration-dotted underline-offset-2 hover:text-fg"
title="Zur Pensionierung"
>
{retirementSummaryText}
</button>
)}
</div>
)}
{view === "retirement" && (
<RetirementPanel plan={plan} computed={computed} onSaved={onChanged} />
)}
{/* Anzeige-Umschalter (nominal/real, Plan/Ist) -- direkt über der Matrix, weil er nur
deren Zahlen steuert. */}
{hasPhases && view === "matrix" && (
<div className="flex items-center gap-2">
<span className="text-xs text-muted">Anzeige</span>
<div className="inline-flex rounded-lg border border-border bg-surface p-0.5 text-xs">
{(["nominal", "both", "real"] as ValueMode[]).map((m) => (
<button
key={m}
type="button"
onClick={() => changeValueMode(m)}
className={`rounded-md px-2.5 py-1 font-medium ${
valueMode === m ? "bg-accent text-accent-fg" : "text-muted hover:bg-surface-2"
}`}
>
{m === "nominal" ? "Nominal" : m === "both" ? "Beide" : "Real"}
</button>
))}
</div>
<span className="text-[11px] text-faint">real = kaufkraftbereinigt (Planbeginn)</span>
{/* Zweite Achse: Datenquelle. Erscheint nur, wenn es überhaupt Ist-Werte gibt --
sonst wäre es ein Umschalter ohne Gegenstück. */}
{hasActuals && (
<>
<span className="ml-2 text-xs text-muted">Zahlen</span>
<div className="inline-flex rounded-lg border border-border bg-surface p-0.5 text-xs">
{DATA_SOURCE_OPTIONS.map((o) => (
<button
key={o.value}
type="button"
onClick={() => setDataSource(o.value)}
title={o.label}
className={`rounded-md px-2.5 py-1 font-medium ${
dataSource === o.value ? "bg-accent text-accent-fg" : "text-muted hover:bg-surface-2"
}`}
>
{o.short}
</button>
))}
</div>
{dataSource === "ACTUAL" && (
<span className="text-[11px] text-faint">Abweichung gegenüber Plan farbig</span>
)}
</>
)}
</div>
)}
{!hasPhases && (
<EmptyState
icon={<Plus className="h-6 w-6" />}
@@ -562,41 +444,6 @@ export function PlanView({
</EmptyState>
)}
{hasPhases && (
<div className="flex flex-wrap items-center gap-2">
<Button size="sm" className="px-3 py-1.5 text-sm" onClick={() => setShowAdd(true)}>
<Plus className="h-4 w-4" /> Finanzielles Element
</Button>
<button
type="button"
onClick={() => setShowAddPhase(true)}
className="flex items-center gap-1.5 rounded-lg border border-dashed border-accent bg-accent-soft px-3 py-1.5 text-sm font-medium text-accent-soft-fg transition-colors hover:bg-accent-soft"
>
<Plus className="h-4 w-4" /> Lebensphase
</button>
{/* Alle Kategoriegruppen auf einen Schlag auf- oder zuklappen. */}
<button
type="button"
onClick={() =>
setCollapsedCats((prev) =>
prev.size === 0 ? new Set(CATEGORY_ORDER.filter((c) => (elementsByCategory.get(c)?.length ?? 0) > 0)) : new Set()
)
}
className="ml-auto flex items-center gap-1.5 rounded-lg border border-border px-2.5 py-1.5 text-xs font-medium text-muted transition-colors hover:bg-surface-2 hover:text-fg"
>
{collapsedCats.size === 0 ? (
<>
<ChevronRight className="h-3.5 w-3.5" /> Alle zuklappen
</>
) : (
<>
<ChevronDown className="h-3.5 w-3.5" /> Alle aufklappen
</>
)}
</button>
</div>
)}
{computed.ruinAge !== null && (
<div className="flex flex-wrap items-center gap-2 rounded-xl border border-danger bg-danger-soft px-4 py-2 text-sm font-medium text-danger">
<AlertCircle className="h-4 w-4 shrink-0" />
@@ -617,7 +464,7 @@ export function PlanView({
{/* Matrix: eigener Scrollbereich, damit Phasen-Köpfe (oben) UND Elementnamen (links)
beim Scrollen sichtbar bleiben. */}
{hasPhases && view === "matrix" && (
{hasPhases && (
<div data-tour="matrix" className="max-h-[75vh] overflow-auto rounded-xl border border-border bg-surface shadow-sm">
{/* Feste Spaltenbreiten: Alle Phasenspalten sind gleich breit -- bei einer einzigen
Phase bleibt die Tabelle dadurch schmal, bei vielen wird horizontal gescrollt.
@@ -625,8 +472,90 @@ export function PlanView({
<table className="border-collapse text-sm">
<thead>
<tr>
<th className="sticky left-0 top-0 z-40 w-56 min-w-56 border-b border-r border-border bg-surface-2 px-3 py-2 text-left text-xs font-semibold uppercase tracking-wide text-faint">
Finanzielle Elemente
{/* Die Ecke oben links trägt seit 0.36 alles, was die Matrix STEUERT: was man
hinzufügt und wie die Zahlen dargestellt werden. Vorher lagen diese Knöpfe
lose darüber und wirkten wie Aktionen der ganzen Seite. */}
<th className="sticky left-0 top-0 z-40 w-56 min-w-56 border-b border-r border-border bg-surface-2 px-2 py-2 text-left align-top">
<div className="flex flex-col gap-1.5">
<div className="flex items-center gap-1">
<span className="text-xs font-semibold uppercase tracking-wide text-faint">Matrix</span>
<button
type="button"
onClick={() =>
setCollapsedCats((prev) =>
prev.size === 0
? new Set(CATEGORY_ORDER.filter((c) => (elementsByCategory.get(c)?.length ?? 0) > 0))
: new Set()
)
}
className="ml-auto rounded p-0.5 text-faint hover:bg-surface hover:text-fg"
title={collapsedCats.size === 0 ? "Alle zuklappen" : "Alle aufklappen"}
>
{collapsedCats.size === 0 ? (
<ChevronRight className="h-3.5 w-3.5" />
) : (
<ChevronDown className="h-3.5 w-3.5" />
)}
</button>
</div>
<div className="flex gap-1">
<button
type="button"
onClick={() => setShowAdd(true)}
className="flex flex-1 items-center justify-center gap-1 rounded-md border border-dashed border-border px-1.5 py-1 text-[10px] font-medium normal-case text-muted hover:border-accent hover:text-accent-soft-fg"
>
<Plus className="h-3 w-3" /> Element
</button>
<button
type="button"
onClick={() => setShowAddPhase(true)}
className="flex flex-1 items-center justify-center gap-1 rounded-md border border-dashed border-border px-1.5 py-1 text-[10px] font-medium normal-case text-muted hover:border-accent hover:text-accent-soft-fg"
>
<Plus className="h-3 w-3" /> Phase
</button>
</div>
<div className="flex rounded-md border border-border bg-surface p-0.5">
{(["nominal", "both", "real"] as ValueMode[]).map((m) => (
<button
key={m}
type="button"
onClick={() => changeValueMode(m)}
title={
m === "real"
? "Kaufkraftbereinigt auf den Planbeginn"
: m === "both"
? "Nominal und real"
: "Nominale Beträge"
}
className={`flex-1 rounded px-1 py-0.5 text-[10px] font-medium normal-case ${
valueMode === m ? "bg-accent text-accent-fg" : "text-muted hover:bg-surface-2"
}`}
>
{m === "nominal" ? "Nom." : m === "both" ? "Beide" : "Real"}
</button>
))}
</div>
{hasActuals && (
<div className="flex rounded-md border border-border bg-surface p-0.5">
{DATA_SOURCE_OPTIONS.map((o) => (
<button
key={o.value}
type="button"
onClick={() => setDataSource(o.value)}
title={o.label}
className={`flex-1 rounded px-1 py-0.5 text-[10px] font-medium normal-case ${
dataSource === o.value ? "bg-accent text-accent-fg" : "text-muted hover:bg-surface-2"
}`}
>
{o.short}
</button>
))}
</div>
)}
</div>
</th>
{columns.map((col) =>
col.kind === "phase" ? (
@@ -935,6 +864,43 @@ export function PlanView({
/>
)}
{assistantStep !== null && (
<AssistantStepDialog
plan={plan}
stepIndex={assistantStep}
isDone={progress[assistantStep] === true}
onToggleDone={async (done) => {
await api.post(`/api/scenarios/${plan.id}/assistant`, { step: assistantStep, done });
onChanged();
}}
onClose={() => setAssistantStep(null)}
>
{assistantStep === 0 && <Step1Elements plan={plan} onChanged={onChanged} />}
{assistantStep === 1 && <Step2Timeline plan={plan} computed={computed} onChanged={onChanged} />}
{assistantStep === 2 && <Step3Phases plan={plan} onChanged={onChanged} />}
{assistantStep === 3 && (
<StepWalkthrough plan={plan} computed={computed} scope="WORK" onChanged={onChanged} />
)}
{assistantStep === 4 && (
<StepWalkthrough plan={plan} computed={computed} scope="RETIRE" onChanged={onChanged} />
)}
{assistantStep === 5 && (
<StepWalkthrough plan={plan} computed={computed} scope="PENSION" onChanged={onChanged} />
)}
</AssistantStepDialog>
)}
{retirementRole && (
<Modal
title={`Pensionsplanung · ${ownerLabel(plan.persons, retirementRole)}`}
subtitle="Alle Entscheide zu AHV, Pensionskasse und Säule 3a an einem Ort"
onClose={() => setRetirementRole(null)}
xwide
>
<RetirementPanel plan={plan} computed={computed} role={retirementRole} onSaved={onChanged} />
</Modal>
)}
{reviewFromPhaseId && (() => {
const fromPhase = computed.phases.find((p) => p.id === reviewFromPhaseId);
if (!fromPhase) return null;
@@ -1563,114 +1529,6 @@ function FragmentRows({ children }: { children: React.ReactNode }) {
return <>{children}</>;
}
// --- "Nächste Schritte": abgeleitete Checkliste ------------------------------------------
// Führt neue (und wiederkehrende) Nutzer zum jeweils nächsten sinnvollen Schritt, statt
// sie vor der Matrix raten zu lassen. Alles wird aus den vorhandenen Daten abgeleitet --
// kein eigener Zustand, kein API-Aufruf, verschwindet von selbst, wenn nichts offen ist.
function NextSteps({
plan,
computed,
columns,
openCountFor,
onReview,
onAddElement,
onAddPhase,
onOpenSensitivity,
}: {
plan: PlanInput;
computed: PlanComputed;
columns: Column[];
openCountFor: (fromPhase: PhaseComputed, toPhase: PhaseComputed) => DecisionCounts;
onReview: (fromPhaseId: string) => void;
onAddElement: (category: ElementCategory) => void;
onAddPhase: () => void;
onOpenSensitivity?: () => void;
}) {
if (computed.phases.length === 0) return null;
const items: { key: string; text: string; action: string; run: () => void }[] = [];
// Offene Übergangs-Entscheide (inkl. Cash) -- der wichtigste Handlungsbedarf.
let openTotal = 0;
let firstOpenPhaseId: string | null = null;
for (const col of columns) {
if (col.kind !== "transition") continue;
const n = openCountFor(col.fromPhase, col.toPhase);
openTotal += n.open;
if (n.open > 0 && firstOpenPhaseId === null) firstOpenPhaseId = col.fromPhase.id;
}
if (openTotal > 0 && firstOpenPhaseId) {
const target = firstOpenPhaseId;
items.push({
key: "transitions",
text: `${openTotal} Übergangs-Entscheid${openTotal === 1 ? "" : "e"} offen z. B. ob eine Immobilie verkauft wird.`,
action: "Jetzt durchgehen",
run: () => onReview(target),
});
}
if (!plan.elements.some((e) => e.category === "INCOME")) {
items.push({
key: "income",
text: "Noch kein Einkommen erfasst ohne Einkommen gibt es keine Sparquote und keine AHV-Rente.",
action: "Einkommen erfassen",
run: () => onAddElement("INCOME"),
});
}
if (!plan.elements.some((e) => e.category === "EXPENSE")) {
items.push({
key: "expense",
text: "Noch keine Ausgaben erfasst sie sind erfahrungsgemäss der stärkste Hebel der ganzen Planung.",
action: "Ausgaben erfassen",
run: () => onAddElement("EXPENSE"),
});
}
// Plan endet vor der Pensionierung: die spannendste Phase fehlt noch.
const totalYears = computed.phases.reduce((s, p) => s + p.durationYears, 0);
if (plan.persons.some((p) => p.age + totalYears < p.retirementAge)) {
items.push({
key: "pension-phase",
text: "Dein Plan endet vor der Pensionierung die entscheidende Phase fehlt noch.",
action: "Phase anlegen",
run: onAddPhase,
});
}
if (computed.ruinAge !== null && onOpenSensitivity) {
items.push({
key: "ruin",
text: `Das Kapital reicht nicht bis ans Planende (Ruin mit ${computed.ruinAge}). Finde heraus, welche Annahme am meisten bewegt.`,
action: "Einflussfaktoren",
run: onOpenSensitivity,
});
}
if (items.length === 0) return null;
return (
<div className="rounded-xl border border-attention-soft-fg/30 bg-attention-soft px-4 py-3">
<div className="mb-2 flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-attention-soft-fg">
<ListTodo className="h-4 w-4" /> Nächste Schritte
</div>
<ul className="flex flex-col gap-1.5">
{items.map((it) => (
<li key={it.key} className="flex flex-wrap items-center gap-2 text-sm text-fg">
<span className="min-w-0 flex-1">{it.text}</span>
<button
type="button"
onClick={it.run}
className="flex shrink-0 items-center gap-1 rounded-lg border border-attention-soft-fg/40 px-2.5 py-1 text-xs font-semibold text-attention-soft-fg transition-colors hover:bg-attention hover:text-attention-fg"
>
{it.action} <ArrowRight className="h-3 w-3" />
</button>
</li>
))}
</ul>
</div>
);
}
// --- Dialog: neues finanzielles Element mit Direkteingabe der Phase-1-Werte ---
function AddElementDialog({
plan,
@@ -1932,7 +1790,7 @@ function ProfilePanel({ plan, onClose, onSaved }: { plan: PlanInput; onClose: ()
return (
<InspectorShell title="Szenario-Profil" subtitle="Haushalt, Personen, Planstart und Inflation dieses Szenarios" onClose={onClose}>
<div className="flex flex-col gap-3">
<PlanProfileFields draft={draft} onChange={setDraft} lockRetirement={plan.phases.length > 0} />
<PlanProfileFields draft={draft} onChange={setDraft} />
{error && <p className="text-sm text-danger">{error}</p>}
<div className="flex gap-2 pt-1">
<Button disabled={saving} onClick={save}>{saving ? "…" : "Speichern"}</Button>
@@ -2432,3 +2290,197 @@ function ElementMetaPanel({
</div>
);
}
// --- Kacheln des oberen Bereichs -----------------------------------------------------------
//
// Vier gleichrangige Blöcke: was gilt (Grundeinstellungen), was herauskommt (Kennzahlen),
// was man tun kann (Schnellaktionen) und was als Nächstes ansteht (Assistent). Darunter die
// Zeitachse über die volle Breite -- sie gehört zu allen vieren.
function Tile({
title,
children,
action,
tour,
}: {
title: string;
children: React.ReactNode;
action?: React.ReactNode;
tour?: string;
}) {
return (
<div data-tour={tour} className="flex h-full flex-col rounded-xl border border-border bg-surface px-3 py-2.5 shadow-sm">
<div className="mb-2 flex items-start justify-between gap-2">
<span className="text-xs font-semibold uppercase tracking-wide text-faint">{title}</span>
{action}
</div>
<div className="flex min-h-0 flex-1 flex-col gap-1">{children}</div>
</div>
);
}
function SettingsTile({
plan,
computed,
diffChanged,
onEdit,
onRetirement,
}: {
plan: PlanInput;
computed: PlanComputed;
diffChanged: boolean;
onEdit: () => void;
onRetirement: (role: PersonRole) => void;
}) {
const horizon = plan.planningHorizonYears ?? null;
const endYear = plan.startYear && horizon ? plan.startYear + horizon - 1 : null;
return (
<Tile
tour="profil"
title={`Grundeinstellungen${diffChanged ? " · abweichend" : ""}`}
action={
<button
type="button"
onClick={onEdit}
className="flex shrink-0 items-center gap-1 rounded-lg border border-border px-1.5 py-0.5 text-[11px] font-medium text-muted hover:bg-surface-2"
title="Grundeinstellungen bearbeiten"
>
<Pencil className="h-3 w-3" />
</button>
}
>
{/* Plan-weit: gilt für alle Szenarien. */}
<div className="text-[11px] text-muted">
{plan.persons.map((p) => (
<div key={p.role} className="truncate">
{ownerLabel(plan.persons, p.role)} · {p.age} J.
</div>
))}
<div>Start {plan.startYear ?? "—"} · Inflation {plan.inflationRateDefault} %</div>
</div>
{/* Szenario-eigen: das, was man zwischen Varianten unterscheidet. */}
<div className="mt-1 border-t border-border pt-1 text-[11px] text-muted">
<div>
Horizont {horizon ? `${horizon} Jahre` : "—"}
{endYear ? ` · bis ${endYear}` : ""}
</div>
{plan.persons.map((p) => (
<div key={p.role} className="mt-0.5 flex items-center gap-1">
<span className="truncate">
Pension {p.retirementAge}
{horizon ? ` · Ende ${p.age + horizon}` : ""}
</span>
<button
type="button"
onClick={() => onRetirement(p.role)}
className="ml-auto shrink-0 rounded border border-border px-1.5 py-0.5 text-[10px] font-medium text-muted hover:bg-surface-2"
title={`Pensionsplanung ${ownerLabel(plan.persons, p.role)}`}
>
Pensionsplanung
</button>
</div>
))}
</div>
{computed.retirement.gapAnnual !== null && (
<div className="mt-1 text-[11px] text-faint">
Rentenlücke {formatChf(computed.retirement.gapAnnual)}/Jahr
</div>
)}
</Tile>
);
}
function FiguresTile({ plan, computed }: { plan: PlanInput; computed: PlanComputed }) {
const last = computed.phases[computed.phases.length - 1] ?? null;
// «Vermögen heute» ist die Summe der erfassten Bestände. Sie steht hier, weil sie die
// einzige Kennzahl ist, die schon VOR jeder Zeitplanung etwas aussagt -- und damit die
// erste Rückmeldung auf die Bestandsaufnahme.
const today = plan.elements.reduce((sum, e) => {
const b = e.baseData ?? {};
if (e.category === "OTHER_ASSET") return sum + num(b.startValue);
if (e.category === "PENSION_FUND" || e.category === "PILLAR_3A") return sum + num(b.currentValue);
if (e.category === "REAL_ESTATE") return sum + num(b.purchasePrice) - num(b.mortgage);
if (e.category === "OTHER_DEBT") return sum - num(b.startValue);
return sum;
}, plan.initialCash || 0);
return (
<Tile tour="endwealth" title="Kennzahlen">
<Figure label="Vermögen heute" value={today} />
<Figure label="Endvermögen nominal" value={last ? Math.round(last.endWealthNominal) : null} />
<Figure label="Endvermögen real" value={last ? Math.round(last.endWealthReal) : null} />
<div className="mt-auto pt-1">
{computed.ruinAge !== null ? (
<div className="rounded-lg bg-danger-soft px-2 py-1 text-[11px] font-medium text-danger">
Kapital reicht nur bis Alter {computed.ruinAge}
</div>
) : (
computed.phases.length > 0 && (
<div className="rounded-lg bg-success-soft px-2 py-1 text-[11px] font-medium text-success">
Kapital reicht über die ganze Planung
</div>
)
)}
</div>
</Tile>
);
}
function Figure({ label, value }: { label: string; value: number | null }) {
return (
<div className="flex items-baseline justify-between gap-2">
<span className="truncate text-[11px] text-muted">{label}</span>
<span className="shrink-0 text-sm font-semibold tabular-nums text-fg">
{value === null ? "—" : formatChf(value)}
</span>
</div>
);
}
function QuickActionsTile({
onHistory,
onTour,
onNewScenario,
onTraces,
exportHref,
}: {
onHistory?: () => void;
onTour?: () => void;
onNewScenario?: () => void;
onTraces?: () => void;
exportHref?: string;
}) {
const cls =
"flex w-full items-center gap-1.5 rounded-lg border border-border px-2 py-1 text-[11px] font-medium text-muted transition-colors hover:bg-surface-2 hover:text-fg";
return (
<Tile tour="toolbar" title="Schnellaktionen">
{onNewScenario && (
<button type="button" onClick={onNewScenario} className={cls}>
<Copy className="h-3.5 w-3.5" /> Neues Szenario
</button>
)}
{onTour && (
<button type="button" onClick={onTour} className={cls}>
<HelpCircle className="h-3.5 w-3.5" /> Tour
</button>
)}
{onHistory && (
<button type="button" onClick={onHistory} className={cls}>
<HistoryIcon className="h-3.5 w-3.5" /> Änderungshistorie
</button>
)}
{onTraces && (
<button type="button" onClick={onTraces} className={cls}>
<BookOpen className="h-3.5 w-3.5" /> Rechenwege
</button>
)}
{exportHref && (
<a href={exportHref} className={cls}>
<Download className="h-3.5 w-3.5" /> CSV-Export
</a>
)}
</Tile>
);
}
File diff suppressed because it is too large Load Diff
+19 -91
View File
@@ -23,17 +23,11 @@ import { AlertTriangle } from "lucide-react";
import { api } from "@/lib/api-client";
import { Button, useToast } from "@/components/ui";
import { InfoBubble } from "@/components/InfoBubble";
import { NumberField } from "@/components/FormField";
import { RetirementAdjuster } from "@/components/RetirementAdjuster";
import { RetirementFields } from "@/components/RetirementFields";
import { formatChf } from "@/lib/format";
import { ownerLabel } from "@/lib/elements";
import {
AHV_REFERENCE_AGE,
MAX_PLANNING_HORIZON_AGE,
MIN_PLANNING_HORIZON_AGE,
PK_MIN_RETIREMENT_AGE,
} from "@/lib/constants";
import { AHV_REFERENCE_AGE, PK_MIN_RETIREMENT_AGE } from "@/lib/constants";
import { withRetirementDefaults, type RetirementDecision } from "@/lib/retirement-decision";
import type { PlanComputed, RetirementPersonSummary } from "@/lib/calculations";
import type { ElementInput, PersonRole, PlanInput } from "@/lib/types";
@@ -43,10 +37,14 @@ type Draft = Record<string, RetirementDecision>;
export function RetirementPanel({
plan,
computed,
role,
onSaved,
}: {
plan: PlanInput;
computed: PlanComputed;
// Seit 0.36 immer GENAU eine Person: Der Bildschirm wird über den Knopf «Pensionsplanung»
// in den Grundeinstellungen geöffnet, und der steht je Person.
role: PersonRole;
onSaved: () => void;
}) {
const toast = useToast();
@@ -93,29 +91,20 @@ export function RetirementPanel({
return (
<div className="flex flex-col gap-5">
<div>
<h2 className="text-lg font-semibold text-fg">Pensionierung</h2>
<p className="mt-1 text-sm text-muted">
Alle Entscheide zu AHV, Pensionskasse und Säule 3a an einem Ort. Was du hier festlegst, steht auch in der
Matrix am Pensions-Übergang es ist derselbe Entscheid, nur anders angeschaut.
</p>
</div>
<p className="text-sm text-muted">
Was du hier festlegst, steht auch in der Matrix am Pensions-Übergang es ist derselbe Entscheid, nur
anders angeschaut.
</p>
{plan.persons.map((person) => {
const summary = computed.retirement.perPerson.find((s) => s.role === person.role);
return (
<PersonBlock
key={person.role}
plan={plan}
computed={computed}
role={person.role}
summary={summary}
effective={effective}
patch={patch}
onSaved={onSaved}
/>
);
})}
<PersonBlock
plan={plan}
computed={computed}
role={role}
summary={computed.retirement.perPerson.find((s) => s.role === role)}
effective={effective}
patch={patch}
onSaved={onSaved}
/>
{error && <p className="text-sm text-danger">{error}</p>}
{dirty && (
@@ -174,9 +163,8 @@ function PersonBlock({
</div>
</header>
<div className="grid gap-4 px-4 py-4 md:grid-cols-2">
<div className="px-4 py-4">
<RetirementAdjuster plan={plan} onSaved={onSaved} />
<HorizonControl plan={plan} role={role} onSaved={onSaved} />
</div>
<GapBox plan={plan} computed={computed} summary={summary} />
@@ -276,63 +264,3 @@ function Line({ label, value, note, strong }: { label: string; value: number; no
);
}
// --- Planungshorizont ----------------------------------------------------------------------
function HorizonControl({ plan, role, onSaved }: { plan: PlanInput; role: PersonRole; onSaved: () => void }) {
const person = plan.persons.find((p) => p.role === role)!;
const [value, setValue] = useState(person.planningHorizonAge ?? null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const toast = useToast();
// Ohne erfassten Horizont: das Alter, bei dem der Plan heute faktisch endet.
const implied = person.age + plan.phases.reduce((s, p) => s + p.durationYears, 0);
async function submit(next: number) {
setBusy(true);
setError(null);
try {
await api.post(`/api/scenarios/${plan.id}/horizon`, { role, horizonAge: next });
toast("success", `Planungshorizont auf Alter ${next} gesetzt.`);
onSaved();
} catch (e) {
setError(e instanceof Error ? e.message : "Nicht möglich.");
setValue(person.planningHorizonAge ?? null);
} finally {
setBusy(false);
}
}
return (
<div className="rounded-xl border border-border p-3">
<h4 className="mb-2 flex items-center text-sm font-semibold text-fg">
Planungshorizont
<InfoBubble text="Bis zu welchem Alter gerechnet wird. Die letzte Lebensphase passt sich an. Ohne diesen Wert ergibt sich das Planende stillschweigend aus der Summe der Phasendauern zwei Szenarien rechnen dann womöglich unbemerkt verschieden weit und sind nicht vergleichbar." />
</h4>
<div className="flex items-end gap-2">
<div className="flex-1">
<NumberField
label="Alter"
value={value ?? implied}
min={MIN_PLANNING_HORIZON_AGE}
max={MAX_PLANNING_HORIZON_AGE}
step={1}
onChange={setValue}
/>
</div>
<Button
disabled={busy || value === null || value === person.planningHorizonAge}
onClick={() => value !== null && submit(value)}
>
Setzen
</Button>
</div>
{person.planningHorizonAge === null && (
<p className="mt-1 text-xs text-faint">
Noch nicht gesetzt der Plan endet aktuell rechnerisch mit Alter {implied}.
</p>
)}
{error && <p className="mt-1 text-xs text-danger">{error}</p>}
</div>
);
}
+286 -156
View File
@@ -1,211 +1,341 @@
"use client";
// Interaktive Kurz-Tour über die Planansicht (Teil von Roadmap Nr. 10).
// Die Tour: eine kurze Demo statt eines Spotlights.
//
// Spotlight ohne Engine-Bibliothek: Der abgedunkelte Bereich entsteht aus VIER fixed-Flächen
// um die Bounding-Box des Ziels herum (oben/unten/links/rechts). Das lässt ein «Loch» am Ziel
// frei und funktioniert -- anders als ein box-shadow-Trick -- unabhängig von z-index (sticky
// Matrix-Köpfe) und overflow (Matrix-Scrollbereich). Ziele, die es im aktuellen Plan nicht
// gibt oder die unsichtbar sind, werden übersprungen.
// Bis 0.35 legte sich die Tour über den ECHTEN Bildschirm und hob Bereiche hervor. Das hatte
// zwei Nachteile, die sich nicht beheben liessen: Auf einem frisch angelegten, leeren Plan gab
// es kaum etwas hervorzuheben -- gerade dann, wenn die Tour am nötigsten ist. Und das
// Ausschneiden der Hervorhebung kämpfte dauernd mit Scroll-Containern und Z-Ebenen.
//
// Neu zeigt die Tour einen NACHGEBAUTEN Bildschirm mit erfundenen Zahlen. Er ist bewusst
// keine echte Ansicht, sondern eine Illustration: gerade so detailliert, dass man den Aufbau
// erkennt, und so einfach, dass sie sich in einem Popup erklären lässt. Der Preis ist, dass
// die Attrappe bei UI-Änderungen nachgeführt werden muss -- dafür funktioniert die Tour ab
// der ersten Sekunde und unabhängig davon, was im Plan schon steht.
import { useEffect, useMemo, useState } from "react";
import { Lightbulb, X } from "lucide-react";
import { useState } from "react";
import { ArrowLeft, ArrowRight, Sparkles, X } from "lucide-react";
import { Button } from "@/components/ui";
export const TOUR_DONE_KEY = "fpt-tour-done";
type Focus = "none" | "settings" | "figures" | "actions" | "assistant" | "timeline" | "matrix" | "sidebar";
interface TourStep {
target: string; // data-tour="…"
title: string;
text: string;
focus: Focus;
}
const STEPS: TourStep[] = [
{
target: "profil",
title: "Dein Grundprofil",
text: "Alter, Pensionsalter und Inflationsannahme die Basis dieses Szenarios. Über «Bearbeiten» änderst du sie jederzeit.",
title: "Willkommen bei FPT",
text: "In zwei Minuten siehst du, wie das Tool aufgebaut ist. Alles, was hier steht, sind erfundene Beispielzahlen dein eigener Plan ist noch leer.",
focus: "none",
},
{
title: "Oben stellst du ein, unten siehst du das Ergebnis",
text: "Der Bildschirm hat zwei Hälften. Oben die Steuerung: Grundeinstellungen, Kennzahlen, Schnellaktionen und der Assistent. Unten die Matrix mit allen Zahlen.",
focus: "none",
},
{
title: "Die Grundeinstellungen",
text: "Wer plant, ab wann und wie lange. Alter, Startjahr und Inflation gelten für den ganzen Plan; Planungshorizont und Pensionsalter gehören zum Szenario. Je Person führt ein Knopf in die Pensionsplanung.",
focus: "settings",
},
{
title: "Die Kennzahlen",
text: "Was unter dem Strich herauskommt: dein Vermögen heute, das Endvermögen nominal und real und ob das Kapital über die ganze Planung reicht.",
focus: "figures",
},
{
title: "Der FPT-Assistent",
text: "Dein Wegweiser. Sieben Schritte führen von der Bestandsaufnahme bis zum fertigen Plan. Jeder Schritt erklärt zuerst, worum es geht, und bietet danach das passende Werkzeug an. Wann ein Schritt erledigt ist, entscheidest du.",
focus: "assistant",
},
{
target: "timeline",
title: "Die Zeitachse",
text: "Deine Lebensphasen im Zeitverlauf: Die Breite zeigt die Dauer, die Fähnchen die Pensionierungen und falls es dazu kommt, den Punkt, an dem das Kapital ausgeht. Die ganze Planung denkt in diesen Phasen.",
text: "Deine Planung im Überblick: Lebensphasen, die Zeitpunkte der Pensionierung und, falls das Geld nicht reicht, das Jahr, in dem es knapp wird.",
focus: "timeline",
},
{
target: "endwealth",
title: "Dein Endvermögen",
text: "Das Ergebnis auf einen Blick: das Vermögen am Ende der Planung nominal und real (in heutiger Kaufkraft). Reicht das Kapital nicht bis zum Schluss, steht hier stattdessen das Alter, in dem es ausgeht.",
title: "Die Matrix",
text: "Das Herz des Tools. Jede Zeile ist ein finanzielles Element, jede Spalte eine Lebensphase. Die schmalen Spalten dazwischen sind die Übergänge dort entscheidest du, was beim Wechsel geschieht.",
focus: "matrix",
},
{
target: "matrix",
title: "Die Matrix das Herzstück",
text: "Zeilen sind deine Finanzen, Spalten deine Lebensphasen. Klick auf eine Zelle: rechts öffnet sich das Bearbeiten-Panel, die Matrix bleibt sichtbar.",
title: "Pläne und Szenarien",
text: "Links verwaltest du alles. Ein Szenario ist eine Variante deines Plans etwa mit anderem Pensionsalter. Du kannst beliebig viele anlegen und vergleichen.",
focus: "sidebar",
},
{
target: "transition",
title: "Übergänge",
text: "Zwischen zwei Phasen triffst du einmalige Entscheide verkaufen, beziehen, verrenten. Orange heisst: noch offen. Klick auf den Spaltenkopf führt dich durch alle Entscheide.",
},
{
target: "cash",
title: "Das Cash-Konto",
text: "Dein Ausgleichskonto: Was übrig bleibt, landet hier was fehlt, macht es rot. Negativ = Liquiditätslücke.",
},
{
target: "toolbar",
title: "Aktionen zum Szenario",
text: "Hier oben: die Änderungshistorie ansehen, ein neues Szenario aus diesem ableiten, die Rechenwege einsehen, die Matrix als CSV exportieren und diese Tour jederzeit neu starten.",
},
{
target: "menu",
title: "Analysen & Berichte",
text: "Links im Menü öffnest du die grossen Werkzeuge: unter «Analysen» die Grafiken, die Live-Simulation, Monte-Carlo und die Einflussfaktoren; unter «Berichte» erzeugst du ein PDF. «Effektive Werte» hält deine echten Zahlen gegen den Plan.",
title: "Jetzt bist du dran",
text: "Starte mit dem FPT-Assistenten oben rechts. Schritt 1 ist die Bestandsaufnahme: alles, was du heute besitzt. Von dort führt dich der Assistent weiter.",
focus: "assistant",
},
];
interface Box {
top: number;
left: number;
width: number;
height: number;
}
export function Tour({ onClose }: { onClose: () => void }) {
// Nur Schritte, deren Ziel im DOM existiert UND sichtbar ist (z. B. die Menü-Leiste ist auf
// schmalen Screens ausgeblendet -- getClientRects() ist dann leer).
const steps = useMemo(
() =>
STEPS.filter((s) => {
const el = document.querySelector(`[data-tour="${s.target}"]`);
return el !== null && el.getClientRects().length > 0;
}),
[]
);
const [index, setIndex] = useState(0);
const [rect, setRect] = useState<Box | null>(null);
const step = steps[index];
useEffect(() => {
if (!step) return;
const el = document.querySelector(`[data-tour="${step.target}"]`);
if (!el) return;
el.scrollIntoView({ block: "center", behavior: "smooth" });
// Die Bounding-Box jedes Frame nachführen (folgt dem sanften Scrollen und Resize), aber
// den State nur bei tatsächlicher Änderung neu setzen.
let raf = 0;
const update = () => {
const r = el.getBoundingClientRect();
setRect((prev) =>
prev && prev.top === r.top && prev.left === r.left && prev.width === r.width && prev.height === r.height
? prev
: { top: r.top, left: r.left, width: r.width, height: r.height }
);
raf = requestAnimationFrame(update);
};
update();
return () => cancelAnimationFrame(raf);
}, [step]);
if (!step || !rect) return null;
const pad = 6;
const hole: Box = {
top: Math.max(0, rect.top - pad),
left: Math.max(0, rect.left - pad),
width: rect.width + pad * 2,
height: rect.height + pad * 2,
};
const cardAtBottom = rect.top + rect.height / 2 < window.innerHeight * 0.5;
const step = STEPS[index];
const last = index === STEPS.length - 1;
function finish() {
try {
localStorage.setItem(TOUR_DONE_KEY, "1");
} catch {
/* localStorage nicht verfügbar -> Tour erscheint beim nächsten Mal erneut */
// Privater Modus o. Ä. -- die Tour lässt sich dann eben erneut öffnen.
}
onClose();
}
// Vier Abdunkel-Flächen um das Loch. pointer-events-none, damit nichts blockiert wird.
const dim = "fixed z-[45] bg-black/60 pointer-events-none";
const belowTop = hole.top + hole.height;
const rightOfLeft = hole.left + hole.width;
return (
<>
<div className={dim} style={{ top: 0, left: 0, width: "100vw", height: hole.top }} />
<div className={dim} style={{ top: belowTop, left: 0, width: "100vw", height: `calc(100vh - ${belowTop}px)` }} />
<div className={dim} style={{ top: hole.top, left: 0, width: hole.left, height: hole.height }} />
<div
className={dim}
style={{ top: hole.top, left: rightOfLeft, width: `calc(100vw - ${rightOfLeft}px)`, height: hole.height }}
/>
{/* Pulsierender Rahmen ums Ziel. */}
<div
className="tour-ring pointer-events-none fixed z-[46] rounded-xl"
style={{ top: hole.top, left: hole.left, width: hole.width, height: hole.height }}
/>
{/* Karte -- springt auf die dem Ziel gegenüberliegende Bildschirmhälfte. */}
<div
key={index}
className={`ui-pop fixed left-1/2 z-[50] w-[32rem] max-w-[calc(100vw-2rem)] -translate-x-1/2 rounded-2xl border-2 border-accent bg-surface p-5 shadow-2xl ${
cardAtBottom ? "bottom-8" : "top-8"
}`}
>
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2.5">
<span className="flex h-9 w-9 items-center justify-center rounded-xl bg-accent text-accent-fg shadow-sm">
<Lightbulb className="h-5 w-5" />
</span>
<div>
<div className="text-base font-semibold text-fg">{step.title}</div>
<div className="text-[11px] font-medium uppercase tracking-wide text-accent">
Schritt {index + 1} von {steps.length}
</div>
</div>
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/60 p-3 sm:p-6">
<div className="flex h-full max-h-[900px] w-full max-w-6xl flex-col overflow-hidden rounded-2xl border border-border bg-surface shadow-2xl">
<header className="flex items-center gap-3 border-b border-border px-4 py-3">
<Sparkles className="h-4 w-4 shrink-0 text-accent" />
<div className="min-w-0 flex-1">
<h2 className="truncate text-sm font-semibold text-fg">{step.title}</h2>
<p className="text-[11px] text-muted">
Schritt {index + 1} von {STEPS.length}
</p>
</div>
<button
type="button"
onClick={finish}
aria-label="Tour beenden"
className="rounded-md p-1 text-faint hover:bg-surface-2 hover:text-fg"
aria-label="Tour schliessen"
className="rounded-lg p-1.5 text-faint hover:bg-surface-2 hover:text-fg"
>
<X className="h-4 w-4" />
</button>
</header>
{/* Die Attrappe. Rein illustrativ -- nichts darin ist klickbar. */}
<div className="min-h-0 flex-1 overflow-auto bg-surface-2/40 p-4">
<MockScreen focus={step.focus} />
</div>
<p className="mt-3 text-sm leading-relaxed text-muted">{step.text}</p>
<div className="mt-4 flex items-center justify-between gap-2">
<button type="button" onClick={finish} className="text-xs font-medium text-faint hover:text-muted">
Überspringen
</button>
<div className="flex items-center gap-3">
<div className="flex gap-1">
{steps.map((_, i) => (
<span key={i} className={`h-1.5 w-1.5 rounded-full ${i === index ? "bg-accent" : "bg-border-strong"}`} />
<footer className="border-t border-border px-4 py-3">
<p className="mb-3 text-sm leading-relaxed text-fg">{step.text}</p>
<div className="flex items-center gap-2">
<div className="flex flex-1 gap-1">
{STEPS.map((_, i) => (
<div
key={i}
className={`h-1 flex-1 rounded-full transition-colors ${i <= index ? "bg-accent" : "bg-surface-2"}`}
/>
))}
</div>
<div className="flex gap-2">
{index > 0 && (
<Button variant="secondary" size="sm" onClick={() => setIndex(index - 1)}>
Zurück
</Button>
)}
{index < steps.length - 1 ? (
<Button size="sm" onClick={() => setIndex(index + 1)}>
Weiter
</Button>
) : (
<Button size="sm" onClick={finish}>
Fertig
</Button>
)}
<Button variant="ghost" size="sm" disabled={index === 0} onClick={() => setIndex((i) => i - 1)}>
<ArrowLeft className="h-4 w-4" /> Zurück
</Button>
{last ? (
<Button size="sm" onClick={finish}>
<Sparkles className="h-4 w-4" /> Mit dem Assistenten starten
</Button>
) : (
<Button size="sm" onClick={() => setIndex((i) => i + 1)}>
Weiter <ArrowRight className="h-4 w-4" />
</Button>
)}
</div>
</footer>
</div>
</div>
);
}
// --- Die Attrappe ---------------------------------------------------------------------------
//
// Feste, erfundene Zahlen. Sie müssen weder stimmen noch zueinander passen -- ihr einziger
// Zweck ist, den Aufbau greifbar zu machen.
function MockScreen({ focus }: { focus: Focus }) {
const dim = (area: Focus) => (focus === "none" || focus === area ? "opacity-100" : "opacity-20");
const ring = (area: Focus) => (focus === area ? "ring-2 ring-accent" : "");
return (
<div className="flex gap-3 text-[10px]">
{/* Seitenleiste */}
<div
className={`hidden w-36 shrink-0 flex-col gap-1 rounded-lg border border-border bg-surface p-2 transition-opacity sm:flex ${dim("sidebar")} ${ring("sidebar")}`}
>
<div className="mb-1 font-semibold uppercase tracking-wide text-faint">Meine Pläne</div>
<div className="rounded bg-accent-soft px-1.5 py-1 font-medium text-accent-soft-fg">Familie Muster</div>
<div className="pl-2 text-muted">Szenarien</div>
<div className="rounded bg-surface-2 px-1.5 py-0.5 pl-3 text-fg">Basis</div>
<div className="px-1.5 py-0.5 pl-3 text-muted">Pension mit 62</div>
<div className="pl-2 text-muted">Effektive Werte</div>
<div className="pl-2 text-muted">Analysen</div>
<div className="pl-2 text-muted">Berichte</div>
</div>
<div className="flex min-w-0 flex-1 flex-col gap-3">
{/* Oberer Bereich */}
<div className="rounded-lg border border-border bg-surface-2/60 p-2">
<div className="grid grid-cols-2 gap-2 lg:grid-cols-4">
<MockTile title="Grundeinstellungen" cls={`${dim("settings")} ${ring("settings")}`}>
<div>Anna · 42 J.</div>
<div>Beat · 45 J.</div>
<div className="text-faint">Start 2026 · Inflation 1,5 %</div>
<div className="mt-1 border-t border-border pt-1">Horizont 40 Jahre · bis 2065</div>
<div className="flex items-center gap-1">
Pension 65 · Ende 82
<span className="ml-auto rounded border border-border px-1 text-[9px]">Pensionsplanung</span>
</div>
</MockTile>
<MockTile title="Kennzahlen" cls={`${dim("figures")} ${ring("figures")}`}>
<MockFigure label="Vermögen heute" value="412'000" />
<MockFigure label="Endvermögen nominal" value="1'284'000" />
<MockFigure label="Endvermögen real" value="702'000" />
<div className="mt-1 rounded bg-success-soft px-1.5 py-0.5 text-success">
Kapital reicht über die ganze Planung
</div>
</MockTile>
<MockTile title="Schnellaktionen" cls={`${dim("actions")} ${ring("actions")}`}>
{["Neues Szenario", "Tour", "Änderungshistorie", "Rechenwege", "CSV-Export"].map((a) => (
<div key={a} className="rounded border border-border px-1.5 py-0.5 text-muted">
{a}
</div>
))}
</MockTile>
<div
className={`flex flex-col gap-1 rounded-lg border border-attention bg-attention-soft px-2 py-1.5 transition-opacity ${dim("assistant")} ${ring("assistant")}`}
>
<div className="flex items-center gap-1">
<Sparkles className="h-3 w-3 text-attention-fg" />
<span className="font-semibold uppercase tracking-wide text-fg">FPT-Assistent</span>
<span className="ml-auto font-semibold text-muted">2 / 7</span>
</div>
{[
["3. Lebensphasen", "4 Lebensphasen"],
["4. Erwerbsjahre planen", ""],
["5. Pensionierung planen", ""],
].map(([t, sub]) => (
<div key={t} className="flex items-start gap-1">
<span className="mt-0.5 h-2.5 w-2.5 shrink-0 rounded-sm border border-border bg-surface" />
<span className="min-w-0">
<span className="block truncate font-semibold text-fg">{t}</span>
{sub && <span className="block truncate text-muted">{sub}</span>}
</span>
</div>
))}
{["1. Bestandsaufnahme", "2. Eckdaten"].map((t) => (
<div key={t} className="flex items-start gap-1 opacity-50">
<span className="mt-0.5 flex h-2.5 w-2.5 shrink-0 items-center justify-center rounded-sm bg-success text-[7px] text-white">
</span>
<span className="truncate text-muted line-through">{t}</span>
</div>
))}
</div>
</div>
{/* Zeitachse */}
<div
className={`mt-2 rounded-lg border border-border bg-surface p-2 transition-opacity ${dim("timeline")} ${ring("timeline")}`}
>
<div className="mb-1 flex justify-between text-faint">
<span>2026</span>
<span>2065</span>
</div>
<div className="flex h-4 overflow-hidden rounded">
<div className="flex w-[45%] items-center justify-center bg-accent/70 text-[8px] text-white">
Erwerbsjahre
</div>
<div className="flex w-[15%] items-center justify-center bg-attention/70 text-[8px] text-white">
Übergangszeit
</div>
<div className="flex w-[40%] items-center justify-center bg-success/60 text-[8px] text-white">
Ruhestand
</div>
</div>
<div className="mt-1 flex justify-between text-faint">
<span>Anna 42 · Beat 45</span>
<span>Pension 65</span>
<span>Anna 82 · Beat 85</span>
</div>
</div>
</div>
{/* Matrix */}
<div
className={`overflow-hidden rounded-lg border border-border bg-surface transition-opacity ${dim("matrix")} ${ring("matrix")}`}
>
<table className="w-full border-collapse">
<thead>
<tr className="bg-surface-2">
<th className="border-b border-r border-border px-2 py-1.5 text-left align-top">
<div className="font-semibold uppercase tracking-wide text-faint">Matrix</div>
<div className="mt-1 flex gap-1">
<span className="rounded border border-dashed border-border px-1 text-[9px] font-normal text-muted">
+ Element
</span>
<span className="rounded border border-dashed border-border px-1 text-[9px] font-normal text-muted">
+ Phase
</span>
</div>
</th>
{["Erwerbsjahre", "Übergang", "Ruhestand I", "Übergang", "Ruhestand II"].map((h, i) => (
<th
key={i}
className={`border-b border-r border-border px-2 py-1.5 text-center font-medium ${
h === "Übergang" ? "bg-attention text-attention-fg" : "text-fg"
}`}
>
{h}
{h === "Übergang" && <div className="text-[8px] font-normal">2 offen</div>}
</th>
))}
</tr>
</thead>
<tbody>
{[
["Lohn Anna", "96'000", "→", "—", "→", "—"],
["Lebenshaltung", "78'000", "→", "72'000", "→", "68'000"],
["Pensionskasse Anna", "310'000", "40 % Kapital", "Rente 18'400", "→", "18'400"],
["Säule 3a Anna", "84'000", "Bezug mit 64", "—", "→", "—"],
["Wertschriften", "120'000", "→", "486'000", "→", "310'000"],
].map((row, i) => (
<tr key={i} className={i % 2 ? "bg-surface-2/40" : ""}>
<td className="border-b border-r border-border px-2 py-1 text-fg">{row[0]}</td>
{row.slice(1).map((c, j) => (
<td
key={j}
className={`border-b border-r border-border px-2 py-1 text-center tabular-nums ${
j % 2 === 1 ? "bg-attention-soft text-attention-soft-fg" : "text-muted"
}`}
>
{c}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
</>
</div>
);
}
function MockTile({ title, cls, children }: { title: string; cls: string; children: React.ReactNode }) {
return (
<div className={`flex flex-col gap-0.5 rounded-lg border border-border bg-surface px-2 py-1.5 transition-opacity ${cls}`}>
<div className="mb-0.5 font-semibold uppercase tracking-wide text-faint">{title}</div>
{children}
</div>
);
}
function MockFigure({ label, value }: { label: string; value: string }) {
return (
<div className="flex justify-between gap-1">
<span className="truncate text-muted">{label}</span>
<span className="shrink-0 font-semibold tabular-nums text-fg">{value}</span>
</div>
);
}
+145
View File
@@ -0,0 +1,145 @@
// Der FPT-Assistent und die Element-Stammdaten (0.36).
//
// Der Assistent selbst ist UI; getestet wird hier die reine Logik dahinter -- der Fortschritt,
// der abgeleitete Stand und die Voraussetzungen je Schritt. Dazu die Eigenschaft, wegen der
// `baseData` überhaupt existiert: Ein Element muss sich erfassen lassen, BEVOR es Phasen gibt.
import { describe, it, expect } from "vitest";
import {
ASSISTANT_STEP_COUNT,
ASSISTANT_STEPS,
emptyProgress,
normalizeProgress,
stepBlockedReason,
stepStatus,
} from "@/lib/assistant";
import { computePlan } from "@/lib/calculations";
import type { PhaseData } from "@/lib/elements";
import type { PlanInput } from "@/lib/types";
function plan(over: Partial<PlanInput> = {}): PlanInput {
return {
id: "plan",
name: "T",
householdType: "SINGLE",
inflationRateDefault: 0,
initialCash: 0,
startYear: 2026,
planningHorizonYears: null,
persons: [{ id: "A", role: "PERSON_A", name: null, age: 40, retirementAge: 65 }],
phases: [],
elements: [],
...over,
};
}
function el(category: string, baseData: PhaseData = {}, phaseValues: Record<string, PhaseData> = {}) {
return {
id: `e-${category}-${Math.random().toString(36).slice(2, 7)}`,
category: category as never,
name: category,
ownerRole: "PERSON_A" as never,
orderIndex: 0,
phaseValues,
transitionValues: {},
baseData,
};
}
describe("Assistenten-Fortschritt", () => {
it("startet mit sieben offenen Schritten", () => {
expect(emptyProgress()).toHaveLength(ASSISTANT_STEP_COUNT);
expect(emptyProgress().some(Boolean)).toBe(false);
expect(ASSISTANT_STEPS).toHaveLength(ASSISTANT_STEP_COUNT);
});
it("verwirft kaputte Daten, statt daran zu scheitern", () => {
// Der Fortschritt liegt als JSON in der Datenbank -- ein alter oder manipulierter Stand
// darf die Ansicht nicht zerlegen.
expect(normalizeProgress(null)).toEqual(emptyProgress());
expect(normalizeProgress([true, false])).toEqual(emptyProgress()); // falsche Länge
expect(normalizeProgress("kaputt")).toEqual(emptyProgress());
const gut = [true, false, true, false, true, false, true];
expect(normalizeProgress(gut)).toEqual(gut);
});
});
describe("Abgeleiteter Stand neben dem Haken", () => {
// Der Haken ist bewusst manuell -- aber der Assistent soll nichts Falsches behaupten.
// Deshalb steht daneben, was tatsächlich da ist.
it("zählt die erfassten Elemente", () => {
expect(stepStatus(plan(), 0)).toBe("noch nichts erfasst");
expect(stepStatus(plan({ elements: [el("INCOME")] }), 0)).toBe("1 Element");
expect(stepStatus(plan({ elements: [el("INCOME"), el("EXPENSE")] }), 0)).toBe("2 Elemente");
});
it("meldet den fehlenden Planungshorizont", () => {
expect(stepStatus(plan(), 1)).toBe("Planungshorizont fehlt");
expect(stepStatus(plan({ planningHorizonYears: 40 }), 1)).toContain("40 Jahre");
});
it("zählt die Lebensphasen -- auch wenn es keine gibt", () => {
expect(stepStatus(plan(), 2)).toBe("0 Lebensphasen");
expect(
stepStatus(
plan({ phases: [{ id: "p1", sequenceNumber: 1, name: "p1", durationYears: 5, cashTransition: {} }] }),
2
)
).toBe("1 Lebensphase");
});
});
describe("Voraussetzungen je Schritt", () => {
it("sperrt die Phasenplanung, solange der Horizont fehlt", () => {
expect(stepBlockedReason(plan(), 2)).toContain("Planungshorizont");
expect(stepBlockedReason(plan({ planningHorizonYears: 40 }), 2)).toBeNull();
});
it("sperrt die Übergangs-Schritte, solange es keine Phasen gibt", () => {
const p = plan({ planningHorizonYears: 40 });
for (const step of [3, 4, 5]) expect(stepBlockedReason(p, step)).toContain("Lebensphasen");
});
it("lässt die Bestandsaufnahme immer zu -- sie braucht keine Zeitachse", () => {
expect(stepBlockedReason(plan(), 0)).toBeNull();
expect(stepBlockedReason(plan(), 1)).toBeNull();
});
});
describe("Element-Stammdaten (baseData)", () => {
// Der Grund für den ganzen Umbau: Eine Bestandsaufnahme ist keine Aussage über eine Phase.
it("erlaubt Elemente ohne jede Lebensphase", () => {
const p = plan({ elements: [el("OTHER_ASSET", { startValue: 100000, expectedReturn: 3 })] });
const c = computePlan(p);
expect(c.phases).toHaveLength(0);
expect(c.ruinAge).toBeNull();
});
it("dient der ersten Phase als Startwert", () => {
const p = plan({
phases: [{ id: "p1", sequenceNumber: 1, name: "p1", durationYears: 10, cashTransition: {} }],
elements: [el("OTHER_ASSET", { startValue: 100000, expectedReturn: 0 }, { p1: {} })],
});
const asset = computePlan(p).phases[0].elements.find((e) => e.category === "OTHER_ASSET")!;
expect(asset.startValue).toBe(100000);
});
it("ist die Wurzel der Vererbung -- Phase 1 erbt die Rendite von dort", () => {
// Vor 0.36 hatte Phase 1 nichts, von dem sie hätte erben können, und fiel auf 0.
const p = plan({
phases: [{ id: "p1", sequenceNumber: 1, name: "p1", durationYears: 10, cashTransition: {} }],
elements: [el("OTHER_ASSET", { startValue: 100000, expectedReturn: 5 }, { p1: {} })],
});
const asset = computePlan(p).phases[0].elements.find((e) => e.category === "OTHER_ASSET")!;
expect(asset.endValue).toBe(Math.round(100000 * Math.pow(1.05, 10)));
});
it("weicht einem erfassten Phasenwert -- der gewinnt", () => {
const p = plan({
phases: [{ id: "p1", sequenceNumber: 1, name: "p1", durationYears: 10, cashTransition: {} }],
elements: [el("OTHER_ASSET", { startValue: 100000, expectedReturn: 5 }, { p1: { expectedReturn: 0 } })],
});
const asset = computePlan(p).phases[0].elements.find((e) => e.category === "OTHER_ASSET")!;
expect(asset.endValue).toBe(100000);
});
});
+132
View File
@@ -0,0 +1,132 @@
// Der FPT-Assistent: sieben Schritte von der leeren Matrix zum fertigen Plan.
//
// Er ersetzt die frueheren "Naechsten Schritte". Der Unterschied ist nicht kosmetisch: Die
// alte Karte leitete AB, was zu tun waere; hier fuehrt jeder Schritt sein eigenes Werkzeug
// mit sich. Der Nutzer haelt den Fortschritt selbst fest (Haken) -- das Tool masst sich nicht
// an zu wissen, wann jemand mit einem Schritt fertig IST.
//
// Damit der Assistent trotzdem nichts Falsches behauptet, steht neben jedem Haken der
// ABGELEITETE Stand ("0 Lebensphasen"). Ein abgehakter Schritt ohne Substanz faellt so auf,
// ohne dass das Tool den Haken verweigert.
import { z } from "zod";
import type { PlanInput } from "@/lib/types";
export const ASSISTANT_STEP_COUNT = 7;
export type AssistantProgress = boolean[];
export const assistantProgressSchema = z.array(z.boolean()).length(ASSISTANT_STEP_COUNT);
export function emptyProgress(): AssistantProgress {
return Array.from({ length: ASSISTANT_STEP_COUNT }, () => false);
}
export function normalizeProgress(raw: unknown): AssistantProgress {
const parsed = assistantProgressSchema.safeParse(raw);
return parsed.success ? parsed.data : emptyProgress();
}
export interface AssistantStep {
index: number;
title: string;
short: string;
// Ein Satz, der sagt, worum es geht -- steht in der Liste unter dem Titel.
lead: string;
// Hat dieser Schritt ein Werkzeug, oder ist er reine Information?
guided: boolean;
}
export const ASSISTANT_STEPS: AssistantStep[] = [
{
index: 0,
title: "Bestandsaufnahme",
short: "Was du hast",
lead: "Alle Konten, Guthaben, Immobilien und Schulden erfassen mit ihrem heutigen Stand.",
guided: true,
},
{
index: 1,
title: "Eckdaten und Pensionsplanung",
short: "Wann was passiert",
lead: "Wie weit du planst und wann du aufhörst zu arbeiten und deine Renten beziehst.",
guided: true,
},
{
index: 2,
title: "Lebensphasen",
short: "Die Zeitachse",
lead: "Deine Planung in Abschnitte teilen. Die Fixpunkte aus Schritt 2 sind gesetzt.",
guided: true,
},
{
index: 3,
title: "Erwerbsjahre planen",
short: "Sparen bis zur Pension",
lead: "Wohin dein Sparbetrag fliesst und was an den Übergängen dazwischen passiert.",
guided: true,
},
{
index: 4,
title: "Pensionierung planen",
short: "Der Übergang",
lead: "Rente oder Kapital, wohin das Geld fliesst und wie es in den Jahren danach weitergeht.",
guided: true,
},
{
index: 5,
title: "Ruhestand planen",
short: "Die Jahre danach",
lead: "Wovon du lebst, wenn kein Lohn mehr kommt Bezüge aus dem Vermögen und die restlichen Übergänge.",
guided: true,
},
{
index: 6,
title: "Feinschliff",
short: "Was noch geht",
lead: "Was du jetzt noch verfeinern kannst und wo du es findest.",
guided: false,
},
];
// --- Abgeleiteter Stand je Schritt -----------------------------------------------------------
//
// Bewusst NICHT zum Erzwingen des Hakens, sondern als Gegenprobe daneben. Wer "Lebensphasen"
// abhakt, ohne eine anzulegen, sieht "0 Lebensphasen" -- das reicht.
export function stepStatus(plan: PlanInput, index: number): string {
const phases = plan.phases.length;
const elements = plan.elements.length;
switch (index) {
case 0:
return elements === 0
? "noch nichts erfasst"
: `${elements} ${elements === 1 ? "Element" : "Elemente"}`;
case 1: {
const geplant = plan.elements.filter(
(e) => e.category === "AHV" && e.retirementDecision?.confirmed === true
).length;
const horizon = plan.planningHorizonYears;
if (!horizon) return "Planungshorizont fehlt";
return geplant === plan.persons.length
? `${horizon} Jahre · Pensionsplanung steht`
: `${horizon} Jahre · Pensionsplanung offen`;
}
case 2:
return phases === 0 ? "0 Lebensphasen" : `${phases} ${phases === 1 ? "Lebensphase" : "Lebensphasen"}`;
default:
return "";
}
}
// Ein Schritt lässt sich erst sinnvoll öffnen, wenn seine Voraussetzung erfüllt ist. Der Haken
// bleibt trotzdem jederzeit setzbar -- gesperrt wird nur das Werkzeug, nicht die Selbstauskunft.
export function stepBlockedReason(plan: PlanInput, index: number): string | null {
const hasHorizon = typeof plan.planningHorizonYears === "number" && plan.planningHorizonYears > 0;
if (index === 2 && !hasHorizon) {
return "Lege zuerst den Planungshorizont und die Pensionsplanung fest daraus ergeben sich die Fixpunkte der Zeitachse.";
}
if (index >= 3 && plan.phases.length === 0) {
return "Lege zuerst deine Lebensphasen an ohne sie gibt es keine Übergänge zu entscheiden.";
}
return null;
}
+98 -43
View File
@@ -12,9 +12,12 @@ import {
DEFAULT_PROPERTY_GAINS_TAX_RATE,
PILLAR_3A_MAX_WITHDRAWAL_AGE,
PILLAR_3A_MIN_WITHDRAWAL_AGE,
PK_MAX_RETIREMENT_AGE,
PK_MIN_RETIREMENT_AGE,
} from "@/lib/constants";
import { num } from "@/lib/elements";
import { actualsForYear, rebaseFlow, type ResolvedActuals } from "@/lib/actuals";
import { planFixpoints, type Fixpoint, type FixpointPerson } from "@/lib/phaseplan";
import {
ahvDrawLabel,
ahvFactor,
@@ -23,7 +26,7 @@ import {
withRetirementDefaults,
type RetirementDecision,
} from "@/lib/retirement-decision";
import type { ElementCategory } from "@/lib/elements";
import type { ElementCategory, PhaseData } from "@/lib/elements";
import type { ElementInput, PersonRole, PlanInput } from "@/lib/types";
export type PhaseType = "ERWERB" | "PENSION" | "MIXED";
@@ -317,16 +320,49 @@ export function ahvYearsBeforePlan(ageAtPlanStart: number): number {
return Math.max(0, ageAtPlanStart - AHV_CONTRIBUTION_START_AGE);
}
// Maximale Dauer einer neuen Phase bis zum nächsten Pensionsereignis (null = unbegrenzt).
// Alle Fixpunkte eines Plans als Personen-Sicht für `phaseplan`. Bündelt an EINER Stelle,
// welche Ereignisse eine Phasengrenze erzwingen -- Erwerbsende plus die drei Bezugsbeginne.
export function fixpointPersonsOf(plan: PlanInput): FixpointPerson[] {
return plan.persons.map((p) => {
const own = (cat: ElementCategory) =>
plan.elements.filter((e) => e.category === cat && e.ownerRole === p.role);
const ahvEl = own("AHV")[0];
const pkEl = own("PENSION_FUND")[0];
const rdAhv = ahvEl ? withRetirementDefaults("AHV", p.retirementAge, ahvEl.retirementDecision) : null;
const rdPk = pkEl ? withRetirementDefaults("PENSION_FUND", p.retirementAge, pkEl.retirementDecision) : null;
return {
role: p.role,
name: p.name,
age: p.age,
retirementAge: p.retirementAge,
ahvStartAge: rdAhv ? Math.round(ahvStartAge(rdAhv)) : undefined,
pkWithdrawalAge: rdPk?.pkWithdrawalAge,
pillar3aAges: own("PILLAR_3A")
.map((e) => withRetirementDefaults("PILLAR_3A", p.retirementAge, e.retirementDecision).withdrawalAge)
.filter((x): x is number => typeof x === "number"),
};
});
}
// Maximale Dauer einer neuen Phase bis zum nächsten FIXPUNKT (null = unbegrenzt).
//
// Bis 0.35 zählte nur das Erwerbsende. Seit auch die Bezugsbeginne von AHV, PK und 3a eigene
// Zeitpunkte haben, muss die Kappung sie mitzählen: Fiele ein Bezug mitten in eine Phase,
// würde er auf die nächste Grenze rutschen -- unter Umständen Jahre später.
export function maxPhaseDuration(
persons: { role: PersonRole; age: number; retirementAge: number }[],
yearsBefore: number
yearsBefore: number,
extraFixpoints: Fixpoint[] = []
): number | null {
const caps: number[] = [];
for (const p of persons) {
const startAge = p.age + yearsBefore;
if (startAge < p.retirementAge) caps.push(p.retirementAge - startAge);
}
for (const f of extraFixpoints) {
const d = f.year - yearsBefore;
if (d > 0) caps.push(d);
}
return caps.length > 0 ? Math.min(...caps) : null;
}
@@ -390,6 +426,11 @@ function sourceLabelOf(
return `${e.name} (${who})`;
}
// Alter innerhalb eines gesetzlichen Fensters halten.
function clampAge(v: number, lo: number, hi: number): number {
return Math.max(lo, Math.min(hi, Math.round(v)));
}
function personByRole<T extends { role: PersonRole }>(persons: T[], role: string): T | null {
return persons.find((p) => p.role === role) ?? null;
}
@@ -514,7 +555,9 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
const capsFromWorking = personInfos
.filter((p) => p.working)
.map((p) => retirementAge.get(p.personId)! - p.startAge)
.filter((d) => d > 0);
.filter((d) => d > 0)
// Dazu jeder noch bevorstehende Bezugsbeginn: Auch er erzwingt eine Phasengrenze.
.concat(planFixpoints(fixpointPersonsOf(plan)).map((f) => f.year - yearsBefore).filter((d) => d > 0));
const maxDurationYears = capsFromWorking.length > 0 ? Math.min(...capsFromWorking) : null;
const workingByPerson = new Map(personInfos.map((p) => [p.personId, p.working]));
@@ -618,7 +661,12 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
for (const e of orderedElements) {
const carry = carries.get(e.id)!;
const pd = e.phaseValues[phase.id] ?? {};
// Stammdaten (Bestand bei Planbeginn, Ausgangs-Annahmen) und Phasenwerte. In der ERSTEN
// Phase gilt der Phasenwert, wo einer erfasst ist, sonst die Stammdaten -- damit ist
// Phase 1 nichts Besonderes mehr, sondern erbt schlicht von der Wurzel.
const bd = e.baseData ?? {};
const raw = e.phaseValues[phase.id] ?? {};
const pd: PhaseData = isFirstPhase ? { ...bd, ...raw } : raw;
const owner = e.ownerRole && e.ownerRole !== "HOUSEHOLD" ? personByRole(persons, e.ownerRole) : null;
const ownerWorking = owner ? workingByPerson.get(owner.id) ?? false : anyWorking;
@@ -628,7 +676,14 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
// verwendete Wert wird mitgeführt, damit die Kette über mehrere Phasen trägt.
const inherited = (key: string, fallback = 0): number => {
const own = (pd as Record<string, unknown>)[key];
const v = typeof own === "number" ? own : carry.rates[key] ?? fallback;
// Reihenfolge: eigener Wert -> aus der Vorphase geerbt -> Stammdaten -> Fallback.
// Die Stammdaten sind damit die WURZEL der Kette; vor 0.36 hatte Phase 1 nichts, von
// dem sie hätte erben können, und fiel auf 0.
const root = (bd as Record<string, unknown>)[key];
const v =
typeof own === "number"
? own
: carry.rates[key] ?? (typeof root === "number" ? root : fallback);
carry.rates[key] = v;
return v;
};
@@ -1541,24 +1596,30 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
// und innerhalb einer Phase kennt das Modell kein Einzelereignis -- gezogen wird deshalb
// an der ERSTEN Grenze bei oder nach dem Wunschalter. Liegt der Wunsch hinter dem
// Planende, greift die letzte Grenze, damit das Guthaben nicht unbezogen liegen bleibt.
const drawsPillar3aHere = (
// Zieht dieses Vorsorge-Guthaben an DIESER Phasengrenze? Gilt gleichermassen für die
// Pensionskasse und die Säule 3a: Beide haben seit 0.36 ein eigenes Bezugsalter, und
// innerhalb einer Phase kennt das Modell kein Einzelereignis. Gezogen wird an der ERSTEN
// Grenze bei oder nach dem Wunschalter; liegt der Wunsch hinter dem Planende, greift die
// letzte Grenze, damit kein Guthaben unbezogen liegen bleibt.
const drawsHere = (
el: ElementInput,
ownerPerson: { id: string; age: number } | null,
ageAtBoundary: number
): boolean => {
// 3a ist personengebunden -- ohne Besitzer gibt es kein Bezugsalter und keinen Bezug.
// Beide Kategorien sind personengebunden -- ohne Besitzer gibt es kein Bezugsalter.
if (!ownerPerson) return false;
const rd = withRetirementDefaults(
"PILLAR_3A",
retirementAge.get(ownerPerson.id) ?? AHV_REFERENCE_AGE,
el.retirementDecision
);
const wish = Math.max(
PILLAR_3A_MIN_WITHDRAWAL_AGE,
Math.min(PILLAR_3A_MAX_WITHDRAWAL_AGE, Math.round(num(rd.withdrawalAge, ageAtBoundary)))
);
const ra = retirementAge.get(ownerPerson.id) ?? AHV_REFERENCE_AGE;
const rd = withRetirementDefaults(el.category, ra, el.retirementDecision);
const wish =
el.category === "PENSION_FUND"
? clampAge(num(rd.pkWithdrawalAge, ra), PK_MIN_RETIREMENT_AGE, PK_MAX_RETIREMENT_AGE)
: clampAge(
num(rd.withdrawalAge, ageAtBoundary),
PILLAR_3A_MIN_WITHDRAWAL_AGE,
PILLAR_3A_MAX_WITHDRAWAL_AGE
);
const prevBoundaryAge = ownerPerson.age + yearsBefore;
if (prevBoundaryAge >= wish) return false; // in einer früheren Phase bereits gezogen
if (prevBoundaryAge >= wish) return false; // an einer früheren Grenze bereits gezogen
return ageAtBoundary >= wish || !nextPhase;
};
// Echte Vermögensänderungen an dieser Grenze (für die Brücke der Folgephase).
@@ -1597,11 +1658,10 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
const ec = ecById.get(e.id)!;
const td = e.transitionValues[phase.id] ?? {};
const owner = e.ownerRole && e.ownerRole !== "HOUSEHOLD" ? personByRole(persons, e.ownerRole) : null;
const ownerRetiresNext =
!!owner && !!nextPhase && workingByPerson.get(owner.id) === true &&
retiresInPhase(owner.id, persons, retirementAge, yearsBefore + duration);
// Pensionsalter des Besitzers und sein Alter AN dieser Phasengrenze -- Bezugspunkt für
// die Vorgaben des Pensionierungs-Entscheids und für das 3a-Bezugsalter.
// die Vorgaben des Pensionierungs-Entscheids und für die Bezugsalter von PK und 3a.
// Ob ein Guthaben HIER gezogen wird, entscheidet seit 0.36 `drawsHere` anhand des
// jeweiligen Bezugsalters, nicht mehr das Erwerbsende.
const ownerRetirementAge = owner ? retirementAge.get(owner.id) ?? AHV_REFERENCE_AGE : AHV_REFERENCE_AGE;
const ownerAgeAtBoundary = owner ? owner.age + yearsBefore + duration : 0;
@@ -1628,7 +1688,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
switch (e.category) {
case "PENSION_FUND": {
if (ownerRetiresNext) {
if (drawsHere(e, owner, ownerAgeAtBoundary)) {
// EINE Quote statt PENSION/CAPITAL/COMBI plus Frankenbetrag: 0 % = volle Rente,
// 100 % = volles Kapital, alles dazwischen ist die Kombination. Als Quote, weil
// sich das Guthaben mit dem Pensionsalter ändert -- ein fixer Betrag würde beim
@@ -1665,7 +1725,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
// Übergang: Ein 3a-Konto lässt sich nur GANZ auflösen, und alle Bezüge desselben
// Jahres werden steuerlich zusammengezählt -- gestaffelt wird deshalb über Konten
// und Jahre. Gezogen wird an der ersten Phasengrenze bei oder nach dem Wunschalter.
if (drawsPillar3aHere(e, owner, ownerAgeAtBoundary)) {
if (drawsHere(e, owner, ownerAgeAtBoundary)) {
const rd = withRetirementDefaults("PILLAR_3A", ownerRetirementAge, e.retirementDecision);
const net = Math.round(ec.endValue * (1 - num(rd.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE) / 100));
txInflow += net;
@@ -1746,17 +1806,22 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
if (e.category === "PENSION_FUND" || e.category === "PILLAR_3A") {
anchor = e.category === "PENSION_FUND" ? "491-pension_fund" : "492-pillar_3a";
const mode = ownerRetiresNext ? td.payoutMode ?? "PENSION" : td.withdrawalMode ?? "NONE";
title = `Übergang «${ec.name}» ${ownerRetiresNext ? "Pensionierung" : "Vorbezug"}`;
if (ownerRetiresNext && e.category === "PENSION_FUND") {
steps.push(st("Gewählte Bezugsart", 0, undefined, mode === "CAPITAL" ? "Kapitalbezug" : mode === "COMBI" ? "Kombination" : "Rente", undefined, ""));
if (mode === "PENSION" || mode === "COMBI") {
steps.push(st("Umwandlungssatz", num(td.conversionRate, DEFAULT_PK_CONVERSION_RATE), undefined, undefined, undefined, "%"));
// Der Bezugs-Entscheid liegt seit 0.34 am Element; der ZEITPUNKT hat seit 0.36 ein
// eigenes Alter und muss nicht mehr mit dem Erwerbsende zusammenfallen.
const drawnHere = drawsHere(e, owner, ownerAgeAtBoundary);
const rd = withRetirementDefaults(e.category, ownerRetirementAge, e.retirementDecision);
title = `Übergang «${ec.name}» ${drawnHere ? "Bezug" : "Vorbezug"}`;
if (drawnHere && e.category === "PENSION_FUND") {
const share = Math.max(0, Math.min(100, num(rd.capitalSharePct)));
steps.push(st("Anteil Kapitalbezug", share, undefined, undefined, "0 % = volle Rente, 100 % = volles Kapital.", "%"));
if (share < 100) {
steps.push(st("Umwandlungssatz", num(rd.conversionRate, DEFAULT_PK_CONVERSION_RATE), undefined, undefined, undefined, "%"));
steps.push(st("Jährliche Rente", carry.pkPensionAnnual, "verrentetes Kapital × Umwandlungssatz", undefined, "Das verrentete Kapital verlässt die Vermögensbilanz und erscheint fortan als Renteneinkommen."));
}
}
if (taxHere > 0) {
steps.push(st("Kapitalbezugssteuer", num(td.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE), undefined, undefined, "Pauschalsatz die tatsächliche Steuer ist kantonal und progressiv.", "%"));
const satz = drawnHere ? num(rd.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE) : num(td.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE);
steps.push(st("Kapitalbezugssteuer", satz, undefined, undefined, "Pauschalsatz die tatsächliche Steuer ist kantonal und progressiv.", "%"));
steps.push(st("Steuerbetrag", -taxHere));
}
if (inflowHere !== 0) steps.push(st("Netto ins Cash", inflowHere, "Bruttobezug Kapitalbezugssteuer"));
@@ -1924,7 +1989,8 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
personId: p.id,
role: p.role,
retirementAge: retirementAge.get(p.id) ?? AHV_REFERENCE_AGE,
planningHorizonAge: p.planningHorizonAge ?? null,
// Alter am Planende -- abgeleitet aus dem Horizont, nicht erfasst.
planningHorizonAge: typeof plan.planningHorizonYears === "number" ? p.age + plan.planningHorizonYears : null,
ahvAnnual: Math.round(ahvFinalByPerson.get(p.id) ?? 0),
ahvFromAge: Math.round(ahvStartAge(rd)),
ahvDraw: ahvDrawLabel(rd),
@@ -2027,15 +2093,4 @@ function buildCareer(
};
}
function retiresInPhase(
personId: string,
persons: { id: string; role: PersonRole; age: number }[],
retirementAge: Map<string, number>,
yearsBeforeNext: number
): boolean {
const p = persons.find((x) => x.id === personId);
if (!p) return false;
return p.age + yearsBeforeNext >= retirementAge.get(personId)!;
}
// Der CSV-Export liegt seit 0.31 in lib/csv.ts (vollstaendige Matrix statt Phasen-Summary).
+5 -3
View File
@@ -72,9 +72,11 @@ export const PK_BUYIN_BLOCKING_YEARS = 3;
// Summe der Phasendauern -- zwei Szenarien konnten dadurch unbemerkt verschieden weit rechnen
// und waren nicht vergleichbar. Der Default liegt bewusst ueber der Lebenserwartung: Eine zu
// kurze Planung sieht tragfaehig aus, obwohl das Geld nur nicht lange genug reichen muss.
export const DEFAULT_PLANNING_HORIZON_AGE = 90;
export const MIN_PLANNING_HORIZON_AGE = 70;
export const MAX_PLANNING_HORIZON_AGE = 110;
// Gefuehrt wird die Laufzeit in JAHREN und nicht als Endalter je Person: eine Zahl statt
// zweier, die bei einem Paar auseinanderlaufen koennten. Die Endalter sind die Ableitung.
export const DEFAULT_PLANNING_HORIZON_YEARS = 35;
export const MIN_PLANNING_HORIZON_YEARS = 1;
export const MAX_PLANNING_HORIZON_YEARS = 70;
// Umrechnung Netto- -> Bruttolohn für die AHV. Das Tool erfasst das Einkommen NETTO (so
// denkt der Nutzer, und so stimmt der Cash-Fluss), die AHV bemisst sich aber am BRUTTOlohn.
-96
View File
@@ -1,96 +0,0 @@
// Beispielplan mit einem Klick (Teil von Roadmap Nr. 10). Erzeugt einen realistischen,
// vollständig ausgefuellten Plan über die BESTEHENDEN API-Endpunkte -- reine Orchestrierung,
// kein neuer Endpunkt, keine Änderung an der Berechnung.
//
// Didaktischer Entscheid: Die Übergangs-Entscheide bleiben absichtlich OFFEN. Der neue
// Nutzer sieht dadurch die Ampel ("N offen") in Aktion und lernt das wichtigste Konzept des
// Tools am Beispiel, statt es erklärt zu bekommen.
//
// WICHTIG zur Reihenfolge: erst Phase 1, dann die Elemente samt Phase-1-Werten, dann die
// Folgephase. Die Phasen-Route belegt beim Anlegen einer Phase die editierbaren Felder aus
// der Vorphase vor -- existieren die Elemente noch nicht, bleibt die Folgephase leer
// (Renditen/Beiträge wären dort 0).
import { api } from "@/lib/api-client";
import type { PhaseData } from "@/lib/elements";
async function addElement(
scenarioId: string,
firstPhaseId: string,
category: string,
name: string,
ownerRole: string,
values?: PhaseData
): Promise<void> {
const { element } = await api.post<{ element: { id: string } }>(
`/api/scenarios/${scenarioId}/elements`,
{ category, name, ownerRole }
);
if (values && Object.keys(values).length > 0) {
await api.put(`/api/elements/${element.id}/phase/${firstPhaseId}`, values);
}
}
// Liefert die Szenario-Id des angelegten Beispielplans.
export async function createDemoPlan(): Promise<string> {
const currentYear = new Date().getFullYear();
const { scenario } = await api.post<{ plan: { id: string }; scenario: { id: string } }>(
"/api/plans",
{
name: "Beispiel: Alex Muster",
householdType: "SINGLE",
inflationRateDefault: 1.5,
startYear: currentYear,
persons: [{ role: "PERSON_A", name: "Alex", age: 45, retirementAge: 65 }],
}
);
const sid = scenario.id;
await api.patch(`/api/scenarios/${sid}`, { initialCash: 40000 });
// Phase 1: Erwerbsphase bis zur Pension (die Route kappt die Dauer ohnehin am
// Pensionsereignis; 20 Jahre = 45 -> 65).
const p1 = await api.post<{ phase: { id: string } }>(`/api/scenarios/${sid}/phases`, {
name: "Erwerbsphase",
durationYears: 20,
});
const firstPhaseId = p1.phase.id;
await addElement(sid, firstPhaseId, "INCOME", "Lohn Alex", "PERSON_A", {
amount: 110000,
teuerungsausgleich: 1,
});
await addElement(sid, firstPhaseId, "EXPENSE", "Lebenshaltung", "HOUSEHOLD", {
amount: 78000,
teuerungsausgleich: 0,
});
await addElement(sid, firstPhaseId, "AHV", "AHV Alex", "PERSON_A", { gapYears: 0 });
await addElement(sid, firstPhaseId, "PENSION_FUND", "Pensionskasse", "PERSON_A", {
currentValue: 380000,
annualContribution: 14000,
expectedReturn: 1.5,
});
await addElement(sid, firstPhaseId, "PILLAR_3A", "Säule 3a", "PERSON_A", {
currentValue: 75000,
annualContribution: 7000,
expectedReturn: 2,
});
await addElement(sid, firstPhaseId, "OTHER_ASSET", "ETF-Depot", "HOUSEHOLD", {
startValue: 120000,
annualContribution: 12000,
expectedReturn: 5,
});
await addElement(sid, firstPhaseId, "REAL_ESTATE", "Eigenheim", "HOUSEHOLD", {
purchasePrice: 900000,
mortgage: 600000,
amortization: 12000,
interestRate: 1.5,
valueGrowth: 1,
});
// Pensionsphase 65 -> 90. Die Route belegt die editierbaren Felder aus Phase 1 vor.
await api.post(`/api/scenarios/${sid}/phases`, { name: "Pensionsphase", durationYears: 25 });
return sid;
}
+4 -2
View File
@@ -110,7 +110,8 @@ export function computeScenarioDiff(scenario: PlanInput, base: PlanInput | null)
if (
el.name !== src.name ||
el.ownerRole !== src.ownerRole ||
!sameData(el.retirementDecision ?? {}, src.retirementDecision ?? {})
!sameData(el.retirementDecision ?? {}, src.retirementDecision ?? {}) ||
!sameData(el.baseData ?? {}, src.baseData ?? {})
) {
d.elementRow.set(el.id, "changed");
}
@@ -139,10 +140,11 @@ export function computeScenarioDiff(scenario: PlanInput, base: PlanInput | null)
// --- Profil und Cash-Anfangswert ---
d.cashInitialChanged = Math.round(scenario.initialCash) !== Math.round(base.initialCash);
const personKey = (p: PlanInput["persons"][number]) =>
`${p.role}|${p.name ?? ""}|${p.age}|${p.retirementAge}|${p.planningHorizonAge ?? ""}`;
`${p.role}|${p.name ?? ""}|${p.age}|${p.retirementAge}`;
d.profileChanged =
scenario.householdType !== base.householdType ||
scenario.inflationRateDefault !== base.inflationRateDefault ||
(scenario.planningHorizonYears ?? null) !== (base.planningHorizonYears ?? null) ||
scenario.persons.map(personKey).sort().join(";") !== base.persons.map(personKey).sort().join(";");
d.total =
+10 -4
View File
@@ -68,17 +68,23 @@ describe("Datenbank-Migrationen", () => {
expect(scenCols).not.toContain("startYear");
expect(scenCols).not.toContain("userId"); // Eigentümer hängt am Plan
// Person trägt Pensionsalter und Planungshorizont -- Name und Alter beschreiben den
// Haushalt und liegen am Plan.
// Person trägt nur noch das Pensionsalter -- Name und Alter beschreiben den Haushalt und
// liegen am Plan, der Planungshorizont liegt seit 0.36 als JAHRESZAHL am Szenario.
const personCols = await cols("Person");
expect(personCols).toContain("retirementAge");
expect(personCols).toContain("planningHorizonAge");
expect(personCols).not.toContain("planningHorizonAge");
expect(personCols).not.toContain("name");
expect(personCols).not.toContain("age");
const scenCols2 = await cols("Scenario");
expect(scenCols2).toContain("planningHorizonYears");
expect(scenCols2).toContain("assistantProgress");
// Der Pensionierungs-Entscheid hängt am ELEMENT und bewusst an keiner Phase (0.34) --
// nur so überlebt er eine Verschiebung der Zeitachse.
// nur so überlebt er eine Verschiebung der Zeitachse. Die Stammdaten (0.36) ebenso: Sie
// beschreiben den Bestand bei Planbeginn und existieren, bevor es Phasen gibt.
expect(await cols("FinancialElement")).toContain("retirementDecision");
expect(await cols("FinancialElement")).toContain("baseData");
// Er muss NULL zulassen: Ohne erfassten Entscheid gelten die Vorgaben.
const rd = await db.query<{ is_nullable: string }>(
+72 -6
View File
@@ -1,10 +1,15 @@
import { describe, it, expect } from "vitest";
import { planSegments, defaultOpenDuration, planDurationChange } from "@/lib/phaseplan";
import { planFixpoints, planSegments, defaultOpenDuration, planDurationChange } from "@/lib/phaseplan";
// Seit 0.36 tragen Abschnitte zusaetzlich `endedBy` (Beschriftung). Die Struktur-Tests
// projizieren deshalb auf die drei Kernfelder.
const shape = (segs: ReturnType<typeof planSegments>) =>
segs.map((s) => ({ type: s.type, fromYear: s.fromYear, fixedYears: s.fixedYears }));
describe("planSegments", () => {
it("Einzelperson: genau zwei Teile (Erwerb fix, Pension offen)", () => {
const segs = planSegments([{ age: 45, retirementAge: 65 }]);
expect(segs).toEqual([
expect(shape(segs)).toEqual([
{ type: "ERWERB", fromYear: 0, fixedYears: 20 },
{ type: "PENSION", fromYear: 20, fixedYears: null },
]);
@@ -25,7 +30,7 @@ describe("planSegments", () => {
{ age: 45, retirementAge: 65 }, // Ret in 20 J.
{ age: 50, retirementAge: 62 }, // Ret in 12 J.
]);
expect(segs).toEqual([
expect(shape(segs)).toEqual([
{ type: "ERWERB", fromYear: 0, fixedYears: 12 },
{ type: "MIXED", fromYear: 12, fixedYears: 8 },
{ type: "PENSION", fromYear: 20, fixedYears: null },
@@ -37,7 +42,7 @@ describe("planSegments", () => {
{ age: 67, retirementAge: 65 }, // bereits pensioniert -> Ret in 0 J.
{ age: 60, retirementAge: 65 }, // Ret in 5 J.
]);
expect(segs).toEqual([
expect(shape(segs)).toEqual([
{ type: "MIXED", fromYear: 0, fixedYears: 5 },
{ type: "PENSION", fromYear: 5, fixedYears: null },
]);
@@ -48,11 +53,11 @@ describe("planSegments", () => {
{ age: 70, retirementAge: 65 },
{ age: 68, retirementAge: 64 },
]);
expect(segs).toEqual([{ type: "PENSION", fromYear: 0, fixedYears: null }]);
expect(shape(segs)).toEqual([{ type: "PENSION", fromYear: 0, fixedYears: null }]);
});
it("Einzelperson bereits pensioniert: nur Pension", () => {
expect(planSegments([{ age: 66, retirementAge: 65 }])).toEqual([
expect(shape(planSegments([{ age: 66, retirementAge: 65 }]))).toEqual([
{ type: "PENSION", fromYear: 0, fixedYears: null },
]);
});
@@ -127,3 +132,64 @@ describe("planDurationChange", () => {
expect(after).toBe(before);
});
});
describe("planFixpoints (0.36)", () => {
// Bis 0.35 gab es genau einen Fixpunkt pro Person: das Erwerbsende. Seit die Bezugsbeginne
// von AHV, PK und 3a eigene Zeitpunkte haben, erzwingt jeder davon eine Phasengrenze --
// sonst fiele ein Bezug mitten in eine Phase und rutschte auf die nächste Grenze.
const person = {
role: "PERSON_A",
name: "Anna",
age: 60,
retirementAge: 62,
ahvStartAge: 63,
pkWithdrawalAge: 62,
pillar3aAges: [64, 65],
};
it("sammelt jeden Bezugsbeginn als eigenen Zeitpunkt", () => {
const f = planFixpoints([person]);
// Erwerbsende 62 und PK 62 fallen auf dasselbe Jahr -> zwei Eintraege, EIN Jahr.
expect(f.map((x) => x.year)).toEqual([2, 2, 3, 4, 5]);
expect(new Set(f.map((x) => x.kind))).toEqual(
new Set(["RETIREMENT", "PENSION_FUND", "AHV", "PILLAR_3A"])
);
});
it("erzeugt je distinktem Jahr genau eine Abschnittsgrenze", () => {
const segs = planSegments([person]);
// Grenzen bei 2, 3, 4, 5 -> fuenf Abschnitte (der letzte offen).
expect(segs.map((x) => x.fromYear)).toEqual([0, 2, 3, 4, 5]);
expect(segs[segs.length - 1].fixedYears).toBeNull();
});
it("beschriftet jede Grenze mit den Ereignissen, die sie ausloesen", () => {
const segs = planSegments([person]);
// Der erste Abschnitt endet mit Erwerbsende UND PK-Bezug -- beide im selben Jahr.
expect(segs[0].endedBy).toHaveLength(2);
expect(segs[0].endedBy.every((f) => f.label.includes("Anna"))).toBe(true);
});
it("ignoriert Ereignisse, die bei Planbeginn schon vorbei sind", () => {
const f = planFixpoints([{ role: "PERSON_A", age: 67, retirementAge: 65, ahvStartAge: 65 }]);
expect(f).toHaveLength(0);
});
it("faellt ohne Bezugsalter auf das alte Verhalten zurueck", () => {
const segs = planSegments([{ role: "PERSON_A", age: 45, retirementAge: 65 }]);
expect(shape(segs)).toEqual([
{ type: "ERWERB", fromYear: 0, fixedYears: 20 },
{ type: "PENSION", fromYear: 20, fixedYears: null },
]);
});
});
describe("defaultOpenDuration mit Planungshorizont", () => {
it("leitet die Dauer aus dem Horizont ab, wenn er erfasst ist", () => {
expect(defaultOpenDuration([{ age: 45 }], 20, 35)).toBe(15);
});
it("faellt ohne Horizont auf die Schaetzung bis Alter 90 zurueck", () => {
expect(defaultOpenDuration([{ age: 45 }], 20)).toBe(25);
});
});
+88 -14
View File
@@ -11,40 +11,114 @@
export type SegmentType = "ERWERB" | "MIXED" | "PENSION";
// Ein Zeitpunkt, an dem ZWINGEND eine neue Lebensphase beginnen muss.
//
// Bis 0.35 gab es genau einen pro Person: das Erwerbsende. Seit 0.36 sind es bis zu vier --
// jeder Beginn eines Renten- oder Kapitalbezugs ist einer. Der Grund ist derselbe wie beim
// Erwerbsende: Die Rechnung leitet Erwerbsstatus und Bezuege am PHASENBEGINN ab. Faellt ein
// Bezug mitten in eine Phase, waere der halbe Phasenertrag falsch.
export type FixpointKind = "RETIREMENT" | "AHV" | "PENSION_FUND" | "PILLAR_3A";
export interface Fixpoint {
// Jahre ab Planbeginn (1-basiert wie die Phasengrenzen: Jahr 5 = Ende des 5. Planjahres).
year: number;
age: number;
role: string;
kind: FixpointKind;
label: string;
}
export interface PlanSegment {
type: SegmentType;
fromYear: number; // Jahre ab Planbeginn (Beginn des Abschnitts)
// Feste Länge in Jahren (durch den nächsten Pensionierungspunkt bestimmt) ODER null für den
// letzten, OFFENEN Abschnitt (Pension bis Lebensende -- Dauer frei wählbar).
// Feste Länge in Jahren (durch den nächsten Fixpunkt bestimmt) ODER null für den letzten,
// OFFENEN Abschnitt (Dauer frei wählbar bzw. durch den Planungshorizont bestimmt).
fixedYears: number | null;
// Welche Ereignisse den Abschnitt beenden -- für die Beschriftung der Zeitachse.
endedBy: Fixpoint[];
}
// Ein Person-Erwerbsstatus wechselt genau einmal: im Jahr `retirementAge - age` (auf 0 gekappt,
// falls bei Planbeginn bereits pensioniert). Die distinkten dieser Zeitpunkte sind die
// Abschnittsgrenzen; der Abschnitt nach dem letzten Zeitpunkt ist offen.
export function planSegments(persons: { age: number; retirementAge: number }[]): PlanSegment[] {
if (persons.length === 0) return [{ type: "PENSION", fromYear: 0, fixedYears: null }];
export interface FixpointPerson {
// Optional, damit Aufrufer, die nur Abschnitte brauchen (Zeitachse), nichts erfinden muessen.
role?: string;
name?: string | null;
age: number;
retirementAge: number;
// Bezugsalter aus dem Pensionierungs-Entscheid. Fehlen sie, gilt das Erwerbsende.
ahvStartAge?: number;
pkWithdrawalAge?: number;
pillar3aAges?: number[];
}
const KIND_LABEL: Record<FixpointKind, string> = {
RETIREMENT: "Erwerbsende",
AHV: "AHV-Rente",
PENSION_FUND: "Pensionskasse",
PILLAR_3A: "Säule 3a",
};
// Alle Fixpunkte, sortiert und ohne Duplikate im selben Jahr (mehrere Ereignisse im gleichen
// Jahr brauchen nur EINE Phasengrenze -- sie wird dann mehrfach beschriftet).
export function planFixpoints(persons: FixpointPerson[]): Fixpoint[] {
const out: Fixpoint[] = [];
const add = (person: FixpointPerson, age: number | undefined, kind: FixpointKind) => {
if (typeof age !== "number") return;
const year = Math.round(age - person.age);
// Ereignisse vor oder bei Planbeginn sind keine Grenze -- sie sind bereits Geschichte.
if (year <= 0) return;
out.push({
year,
age: Math.round(age),
role: person.role ?? "PERSON_A",
kind,
label: `${KIND_LABEL[kind]}${person.name ? " " + person.name : ""} (${Math.round(age)})`,
});
};
for (const p of persons) {
add(p, p.retirementAge, "RETIREMENT");
add(p, p.ahvStartAge, "AHV");
add(p, p.pkWithdrawalAge, "PENSION_FUND");
for (const a of p.pillar3aAges ?? []) add(p, a, "PILLAR_3A");
}
return out.sort((a, b) => a.year - b.year || a.kind.localeCompare(b.kind));
}
// Die distinkten Fixpunkt-Jahre sind die Abschnittsgrenzen; der Abschnitt nach dem letzten
// ist offen.
export function planSegments(persons: FixpointPerson[]): PlanSegment[] {
if (persons.length === 0) return [{ type: "PENSION", fromYear: 0, fixedYears: null, endedBy: [] }];
const fixpoints = planFixpoints(persons);
const retYears = persons.map((p) => Math.max(0, p.retirementAge - p.age));
const events = [...new Set(retYears.filter((r) => r > 0))].sort((a, b) => a - b);
const boundaries = [0, ...events];
const years = [...new Set(fixpoints.map((f) => f.year))].sort((a, b) => a - b);
const boundaries = [0, ...years];
const segments: PlanSegment[] = [];
for (let i = 0; i < boundaries.length; i++) {
const from = boundaries[i];
const next = i + 1 < boundaries.length ? boundaries[i + 1] : null;
// Eine Person arbeitet in einem Abschnitt, der bei Jahr `from` beginnt, genau dann, wenn
// ihr Pensionierungszeitpunkt echt später liegt.
// ihr Erwerbsende echt später liegt.
const working = retYears.filter((r) => r > from).length;
const type: SegmentType = working === persons.length ? "ERWERB" : working > 0 ? "MIXED" : "PENSION";
segments.push({ type, fromYear: from, fixedYears: next === null ? null : next - from });
segments.push({
type,
fromYear: from,
fixedYears: next === null ? null : next - from,
endedBy: next === null ? [] : fixpoints.filter((f) => f.year === next),
});
}
return segments;
}
// Vorgeschlagene Dauer des offenen Pensions-Abschnitts: bis das älteste Haushaltsmitglied
// ungefähr 90 ist, mindestens aber 5 Jahre.
export function defaultOpenDuration(persons: { age: number }[], fromYear: number): number {
// Vorgeschlagene Dauer des offenen Abschnitts. Mit erfasstem Planungshorizont ergibt sie sich
// daraus; ohne ihn bis etwa Alter 90, mindestens aber 5 Jahre.
export function defaultOpenDuration(
persons: { age: number }[],
fromYear: number,
horizonYears?: number | null
): number {
if (typeof horizonYears === "number" && horizonYears > fromYear) return horizonYears - fromYear;
const oldestNow = persons.length > 0 ? Math.max(...persons.map((p) => p.age)) : 65;
return Math.max(5, 90 - (oldestNow + fromYear));
}
+4 -1
View File
@@ -2,6 +2,7 @@ import { Prisma } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import { cashTransitionSchema, phaseDataSchema, transitionDataSchema } from "@/lib/elements";
import { retirementDecisionSchema } from "@/lib/retirement-decision";
import { normalizeProgress } from "@/lib/assistant";
import type { CashTransitionData, PhaseData, TransitionData } from "@/lib/elements";
import type { RetirementDecision } from "@/lib/retirement-decision";
import type { PlanInput } from "@/lib/types";
@@ -50,6 +51,8 @@ export function toPlanInput(plan: PlanWithRelations): PlanInput {
inflationRateDefault: plan.inflationRateDefault,
initialCash: plan.initialCash,
startYear: plan.plan.startYear,
planningHorizonYears: plan.planningHorizonYears,
assistantProgress: normalizeProgress(plan.assistantProgress),
// Name und Alter vom Plan, Pensionsalter vom Szenario. Fehlt zu einer Rolle die
// Plan-Person, greift ein Notbehelf -- die Berechnung darf daran nicht scheitern.
persons: plan.persons.map((p) => {
@@ -60,7 +63,6 @@ export function toPlanInput(plan: PlanWithRelations): PlanInput {
name: hh?.name ?? null,
age: hh?.age ?? 0,
retirementAge: p.retirementAge,
planningHorizonAge: p.planningHorizonAge,
};
}),
phases: plan.phases.map((phase) => ({
@@ -85,6 +87,7 @@ export function toPlanInput(plan: PlanWithRelations): PlanInput {
phaseValues,
transitionValues,
retirementDecision: parseRetirementDecision(e.retirementDecision),
baseData: parsePhaseData(e.baseData),
sourceElementId: e.sourceElementId,
};
}),
+2 -8
View File
@@ -55,14 +55,7 @@ function plan(opts: {
inflationRateDefault: 0,
initialCash: 0,
persons: [
{
id: "A",
role: "PERSON_A",
name: null,
age: opts.age,
retirementAge: opts.retirementAge,
planningHorizonAge: opts.horizon ?? null,
},
{ id: "A", role: "PERSON_A", name: null, age: opts.age, retirementAge: opts.retirementAge },
],
phases: opts.phases.map((p, i) => ({
id: p.id,
@@ -71,6 +64,7 @@ function plan(opts: {
durationYears: p.durationYears,
cashTransition: p.cashTransition ?? {},
})),
planningHorizonYears: opts.horizon ?? null,
elements: opts.elements,
};
}
+10
View File
@@ -30,6 +30,8 @@ import {
DEFAULT_PK_CONVERSION_RATE,
PILLAR_3A_MAX_WITHDRAWAL_AGE,
PILLAR_3A_MIN_WITHDRAWAL_AGE,
PK_MAX_RETIREMENT_AGE,
PK_MIN_RETIREMENT_AGE,
} from "@/lib/constants";
import type { ElementCategory } from "@/lib/elements";
@@ -58,6 +60,11 @@ export interface RetirementDecision {
// Guthaben mit dem Pensionsalter aendert -- ein fixer Betrag wuerde still falsch.
capitalSharePct?: number;
conversionRate?: number;
// Alter, in dem das PK-Guthaben bezogen bzw. verrentet wird. Bis 0.35 war das starr an das
// Erwerbsende gekoppelt -- wer mit 62 aufhoert, aber die PK erst mit 65 beziehen will,
// konnte das nicht abbilden. Reglementarisch fruehestens 58, Aufschub bis 70 nur bei
// fortgesetzter Erwerbstaetigkeit.
pkWithdrawalAge?: number;
// Reines Hinweis-Flag: Ein Kapitalbezug innerhalb von drei Jahren nach einem Einkauf laesst
// den Steuerabzug nachtraeglich entfallen (Art. 79b Abs. 3 BVG). Das Tool kennt keine
// Einkaeufe und kann das nicht selbst pruefen -- deshalb die Frage statt einer Automatik.
@@ -91,6 +98,7 @@ export const retirementDecisionSchema = z
capitalSharePct: pct.optional(),
conversionRate: z.number().min(0).max(20).optional(),
pkWithdrawalAge: z.number().int().min(PK_MIN_RETIREMENT_AGE).max(PK_MAX_RETIREMENT_AGE).optional(),
recentBuyIn: z.boolean().optional(),
withdrawalAge: z
@@ -130,6 +138,8 @@ export function withRetirementDefaults(
d.capitalSharePct ??= 0;
d.conversionRate ??= DEFAULT_PK_CONVERSION_RATE;
d.capitalTaxRate ??= DEFAULT_CAPITAL_TAX_RATE;
// Vorgabe: mit dem Erwerbsende, im gesetzlichen Fenster gehalten.
d.pkWithdrawalAge ??= clamp(retirementAge, PK_MIN_RETIREMENT_AGE, PK_MAX_RETIREMENT_AGE);
} else if (category === "PILLAR_3A") {
// Im Pensionierungsjahr, aber nie ausserhalb des gesetzlichen Fensters: Wer mit 58
// aufhoert, kann die 3a trotzdem erst mit 60 beziehen.
+21 -22
View File
@@ -49,18 +49,24 @@ export function sortedPhases(plan: PlanInput) {
// --- Planungshorizont -----------------------------------------------------------------------
//
// Bis 0.33 ergab sich das Planende stillschweigend als Summe der Phasendauern. Zwei Szenarien
// Bis 0.35 ergab sich das Planende stillschweigend als Summe der Phasendauern. Zwei Szenarien
// konnten dadurch unbemerkt verschieden weit rechnen -- und waren dann nicht vergleichbar,
// obwohl genau das ihr Zweck ist. Neu ist der Horizont eine erfasste Zahl, und die LETZTE
// Phase folgt ihr (dieselbe Richtung wie beim Pensionsalter: Zahl stellen, Struktur folgt).
// obwohl genau das ihr Zweck ist.
//
// Referenzperson ist die aelteste: Sie erreicht ihren Horizont zuerst, und der Plan muss so
// lange laufen, bis die LETZTE Person ihren erreicht hat.
// Seit 0.36 ist der Horizont eine Zahl in JAHREN am Szenario. Vorher stand er als Endalter je
// PERSON -- bei einem Paar zwei Zahlen, die auseinanderlaufen konnten und aus denen sich das
// Planende erst per Maximum ergab. Eine Laufzeit ist eine Eigenschaft der PLANUNG, nicht der
// Person; die Endalter sind die Ableitung davon, nicht umgekehrt.
export function planEndYear(plan: PlanInput): number | null {
const years = plan.persons
.filter((p) => typeof p.planningHorizonAge === "number")
.map((p) => (p.planningHorizonAge as number) - p.age);
return years.length > 0 ? Math.max(...years) : null;
const y = plan.planningHorizonYears;
return typeof y === "number" && y > 0 ? y : null;
}
// Alter, das eine Person am Ende der Planung erreicht. Read-only-Anzeige neben dem Horizont.
export function endAgeOf(plan: PlanInput, role: PersonRole): number | null {
const years = planEndYear(plan);
const person = plan.persons.find((p) => p.role === role);
return years !== null && person ? person.age + years : null;
}
export interface HorizonChange {
@@ -70,23 +76,15 @@ export interface HorizonChange {
blocked: string | null;
}
// Was muesste an der letzten Phase geschehen, damit der Plan bis zum Horizont laeuft?
export function planHorizonChange(plan: PlanInput, horizonAge: number, role: PersonRole): HorizonChange | null {
// Was muesste an der letzten Phase geschehen, damit der Plan genau bis zum Horizont laeuft?
// Dieselbe Richtung wie beim Pensionsalter: Zahl stellen, Struktur folgt.
export function planHorizonChange(plan: PlanInput, horizonYears: number): HorizonChange | null {
const phases = sortedPhases(plan);
const last = phases[phases.length - 1];
if (!last) return null;
const person = plan.persons.find((p) => p.role === role);
if (!person) return null;
// Zielgesamtdauer aus SICHT DIESER Person; die uebrigen Horizonte bleiben unberuehrt und
// koennen laenger sein -- deshalb das Maximum ueber alle.
const wish = Math.max(
horizonAge - person.age,
...plan.persons.filter((p) => p.role !== role && typeof p.planningHorizonAge === "number")
.map((p) => (p.planningHorizonAge as number) - p.age)
);
const before = phases.slice(0, -1).reduce((s, p) => s + p.durationYears, 0);
const newDuration = wish - before;
const newDuration = horizonYears - before;
const change: HorizonChange = {
lastPhaseId: last.id,
oldDuration: last.durationYears,
@@ -94,7 +92,8 @@ export function planHorizonChange(plan: PlanInput, horizonAge: number, role: Per
blocked: null,
};
if (newDuration < 1) {
change.blocked = `Der Horizont liegt vor dem Ende der zweitletzten Lebensphase. Die letzte Phase muss mindestens ein Jahr dauern kürze zuerst eine frühere Phase.`;
change.blocked =
"Der Horizont liegt vor dem Ende der zweitletzten Lebensphase. Die letzte Phase muss mindestens ein Jahr dauern kürze zuerst eine frühere Phase oder verlängere den Horizont.";
}
return change;
}
+9 -3
View File
@@ -5,6 +5,7 @@
import type { CashTransitionData, ElementCategory, OwnerRole, PhaseData, TransitionData } from "@/lib/elements";
import type { RetirementDecision } from "@/lib/retirement-decision";
import type { AssistantProgress } from "@/lib/assistant";
export type HouseholdType = "SINGLE" | "COUPLE";
export type PersonRole = "PERSON_A" | "PERSON_B";
@@ -15,9 +16,6 @@ export interface PersonInput {
name: string | null;
age: number;
retirementAge: number;
// Bis zu welchem Alter gerechnet wird. Fehlt der Wert, ergibt sich das Planende wie bisher
// aus der Summe der Phasendauern -- deshalb optional und nicht mit Default belegt.
planningHorizonAge?: number | null;
}
export interface PhaseInput {
@@ -43,6 +41,9 @@ export interface ElementInput {
// Pensionierungs-Entscheid (nur AHV, PENSION_FUND, PILLAR_3A). Bewusst OHNE Phasenbezug --
// er gilt für die Pensionierung des Besitzers, wo immer die gerade liegt.
retirementDecision?: RetirementDecision | null;
// Stammdaten: Bestand bei Planbeginn und Ausgangs-Annahmen. Existieren, BEVOR es Phasen
// gibt, und sind zugleich die Wurzel der Feld-Vererbung (Phase 1 erbt von hier).
baseData?: PhaseData | null;
// Gegenstück im Eltern-Szenario (Diff-Grundlage); null im Basisszenario.
sourceElementId?: string | null;
}
@@ -82,6 +83,11 @@ export interface PlanInput {
// Kalenderjahr des Planbeginns (Jahr 1) -- nur für die Darstellung, nicht für die
// Berechnung. Optional, damit Berechnungs-Fixtures es nicht setzen müssen.
startYear?: number | null;
// Laufzeit der Planung in JAHREN. Die Endalter je Person werden daraus abgeleitet. Fehlt
// der Wert, ergibt sich das Planende wie bisher aus der Summe der Phasendauern.
planningHorizonYears?: number | null;
// Fortschritt des FPT-Assistenten (sieben Schritte).
assistantProgress?: AssistantProgress | null;
persons: PersonInput[];
phases: PhaseInput[];
elements: ElementInput[];
+2
View File
@@ -34,6 +34,8 @@ const EXEMPT: Record<string, string> = {
"plans/[planId]/actuals/[setId]": "dito (Löschen eines Ist-Satzes)",
"plans/[planId]/analyses": "gespeicherte Analysen sind read-only Momentaufnahmen kein Szenario betroffen",
"plans/[planId]/analyses/[analysisId]": "dito (Öffnen/Löschen einer Analyse)",
"scenarios/[scenarioId]/assistant":
"das Abhaken eines Assistenten-Schritts ist eine Notiz des Benutzers über sich selbst, keine Planänderung eine Version dafür würde die Historie mit inhaltsgleichen Einträgen fluten",
"plans/[planId]/reports": "Berichte sind erzeugte Dokumente sie verändern kein Szenario",
"plans/[planId]/reports/[reportId]": "dito (Herunterladen/Löschen eines Berichts)",
};
+5 -8
View File
@@ -181,14 +181,8 @@ export async function restoreVersion(
for (const p of snap.persons) {
await tx.person.upsert({
where: { scenarioId_role: { scenarioId, role: p.role } },
create: {
id: p.id,
scenarioId,
role: p.role,
retirementAge: p.retirementAge,
planningHorizonAge: p.planningHorizonAge ?? null,
},
update: { retirementAge: p.retirementAge, planningHorizonAge: p.planningHorizonAge ?? null },
create: { id: p.id, scenarioId, role: p.role, retirementAge: p.retirementAge },
update: { retirementAge: p.retirementAge },
});
}
if (plan.deletePersonRoles.length > 0) {
@@ -236,6 +230,9 @@ export async function restoreVersion(
// Der Pensionierungs-Entscheid gehoert zum Inhalt des Szenarios -- ohne ihn wuerde
// eine Wiederherstellung die Bezugsentscheide still auf die Vorgaben zuruecksetzen.
retirementDecision: (el.retirementDecision ?? undefined) as Prisma.InputJsonValue | undefined,
// Stammdaten gehoeren zum Inhalt -- ohne sie verloere eine Wiederherstellung alle
// Bestaende bei Planbeginn.
baseData: (el.baseData ?? undefined) as Prisma.InputJsonValue | undefined,
sourceElementId: el.sourceElementId ?? null,
};
};