Assistent auf zwei Schritte, Pensionsalter fix 65, Horizont abgeleitet
Deploy App / deploy (push) Successful in 1m57s
Deploy App / deploy (push) Successful in 1m57s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+56
-26
@@ -4,10 +4,10 @@
|
|||||||
| | |
|
| | |
|
||||||
|---|---|
|
|---|---|
|
||||||
| **Dokument** | Funktionale und Technische Spezifikation FPT |
|
| **Dokument** | Funktionale und Technische Spezifikation FPT |
|
||||||
| **Version** | 0.37 |
|
| **Version** | 0.38 |
|
||||||
| **Datum** | 2026-07-25 |
|
| **Datum** | 2026-07-25 |
|
||||||
| **Status** | Lebendes Dokument |
|
| **Status** | Lebendes Dokument |
|
||||||
| **Codestand** | Arbeitsstand nach `42d5585` inkl. Nachbesserungen zu Schritt 1 (Branch `main`) |
|
| **Codestand** | Arbeitsstand nach `2f6b788` inkl. Assistent auf zwei Schritte (Branch `main`) |
|
||||||
| **Ersetzt** | `FDD_TDD_FPT.docx` (v1–v5) im Ordner `Info Dateien` – diese sind ab Version 0.1 dieses Dokuments obsolet |
|
| **Ersetzt** | `FDD_TDD_FPT.docx` (v1–v5) im Ordner `Info Dateien` – diese sind ab Version 0.1 dieses Dokuments obsolet |
|
||||||
| **Geltungsbereich** | Gesamter Code im Verzeichnis `FPT` |
|
| **Geltungsbereich** | Gesamter Code im Verzeichnis `FPT` |
|
||||||
|
|
||||||
@@ -17,6 +17,7 @@
|
|||||||
|
|
||||||
| Version | Datum | Autor | Änderung |
|
| Version | Datum | Autor | Änderung |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
|
| 0.38 | 2026-08-16 | Claude (Opus 5) | **Der Assistent führt bis zur ersten Lebensphase – und nicht weiter.** Sieben Schritte waren ein Versprechen, das die letzten fünf nicht einlösten: Sie zeigten Stationen, statt zu führen. Neu sind es **zwei**, die tragen; wie es danach weitergeht, wird eigens entworfen. (1) **Trennung von Tatsache und Annahme.** Schritt 1 erfasst nur noch, was man nachschlagen kann – Kontostand, Guthaben, Kaufpreis, Restschuld. Renditen, Lohnentwicklung, Teuerung, Hypothekarzins und Wertsteigerung wandern in Schritt 2, wo sie hingehören: **Annahmen gelten immer nur für einen Zeitraum.** Bis 0.37 standen beide zusammen in der Bestandsaufnahme, wodurch eine Annahme wie eine Tatsache aussah. Neu trägt jede Kategorie `baseFields` und `phaseFields`. (2) **Neuer Schritt 2 «Erste Lebensphase»**: Name → Dauer → jährliche Annahmen je Element → **Sparquote verteilen**. Erst danach ist der Schritt fertig; endete er nach den Annahmen, bliebe der ganze Überschuss stumm auf dem Cash-Konto liegen. Die Dauer ist an der Pensionierung **gekappt** und wird im Feld begrenzt, statt hinterher als Fehler zu erscheinen. Der **PK-Beitrag** steht hier – er stammt aus dem Bruttolohn und lässt sich aus der Sparquote gar nicht verteilen, wäre also sonst durch alle Maschen gefallen. (3) **Pensionsalter im Basisszenario fest auf 65**, nicht änderbar. Vorbezug, Aufschub und gestaffelte Kapitalbezüge bleiben im Rechenkern vollständig erhalten (samt Tests), werden hier aber nicht angeboten – sie gehören zu einer eigenen Szenario-Art (Roadmap Nr. 48). Der Knopf «Pensionsplanung» und der zugehörige Bildschirm entfallen; die Grundeinstellungen nennen das Pensionsalter je Person und weisen auf die spätere Szenario-Art hin. (4) **Der Planungshorizont wird abgeleitet** – er ist die Summe der Lebensphasen. Dritter und letzter Anlauf: 0.35 führte ihn als Endalter je Person, 0.36 als Jahreszahl am Szenario, beide Male eine zweite Wahrheit über dieselbe Sache. Wer die Phasen einzeln plant, hat den Horizont bereits bestimmt. `Scenario.planningHorizonYears`, der Endpunkt `/horizon` und `planHorizonChange` entfallen. (5) Entfallen: die Schritte 3 bis 7 samt ihren Werkzeugen und `RetirementPanel`. |
|
||||||
| 0.37 | 2026-08-16 | Claude (Opus 5) | **Nachbesserungen zur Bestandsaufnahme** (aus einer Testrunde des Nutzers). (1) **Die Knöpfe tragen die Kategorienamen** (Einkommen · Ausgaben · Pensionskasse · Säule 3a · Sonstiges Vermögen · Immobilie · Sonstige Schulden), die Alltagssprache steht als Erklärzeile darunter. Vorher hatte der Assistent eigene Vokabeln erfunden – am deutlichsten «Lebenshaltung», hinter der sich ein Feld «Ausgaben pro Jahr» verbarg. Wer hier eine andere Sprache lernt als die, die Matrix, Rechenwege und Bericht sprechen, sucht sie später vergeblich. Der Elementname darf konkret bleiben: Kategorie «Ausgaben», Zeile «Lebenshaltung». (2) **Kein Speichern-Knopf je Element mehr.** Der Entwurf liegt neu auf Ebene des ganzen Schritts statt in der einzelnen Karte – nur so überlebt er den Wechsel des Reiters, denn dabei verschwinden die Karten des vorigen Bereichs samt ihrem Zustand. «Schritt abschliessen» schreibt alles in einem Zug. Weil damit alle Zahlen an einem Ort liegen, zeigt der Dialog neu die Summe **«Vermögen heute» live beim Tippen** – die Rückmeldung, die sonst mit dem Speichern-Knopf verloren gegangen wäre. Wer den Dialog mit ungespeicherten Eingaben schliesst, wird gefragt. (3) **«Zurück/Weiter» entfällt** in Schritt 1 und 2: Die Reiter oben sind der Weg durch die Bereiche, zwei Navigationen für dieselbe Bewegung sind eine zu viel. (4) Der 3a-Hinweis zum Höchstbetrag ist gestrichen – er beantwortete an dieser Stelle eine Frage, die niemand stellt. (5) **Neue Matrix-Spalte «Start»** und: **die Matrix erscheint, sobald es Elemente gibt** – Lebensphasen sind dafür nicht mehr nötig. Das war ein Fehler in 0.36: Die Stammdaten wurden eigens dafür eingeführt, dass eine Bestandsaufnahme ohne Zeitachse möglich ist, und dann rendete die Matrix nichts, weil sie ganz an den Phasen hing. Die Spezifikation behauptete das Richtige, der Code hielt es nicht. Die Spalte löst zugleich ein zweites Problem: Sie ist der Ort, an dem ein Startwert **änderbar** ist. Ohne sie hätte man ihn in Phase 1 bearbeitet und dabei still einen Phasenwert geschrieben, der die Stammdaten überdeckt – dieselbe Zahl an zwei Orten. (6) Der Text des leeren Zustands ist korrigiert: Seit dem Umbau kommen die Elemente zuerst und die Phasen danach. Neue gemeinsame Komponente `BaseFields` (Assistent und Matrix nutzen dieselben Felder), neues Panel für die Stammdaten. 2 Tests ergänzt (334 → 336). |
|
| 0.37 | 2026-08-16 | Claude (Opus 5) | **Nachbesserungen zur Bestandsaufnahme** (aus einer Testrunde des Nutzers). (1) **Die Knöpfe tragen die Kategorienamen** (Einkommen · Ausgaben · Pensionskasse · Säule 3a · Sonstiges Vermögen · Immobilie · Sonstige Schulden), die Alltagssprache steht als Erklärzeile darunter. Vorher hatte der Assistent eigene Vokabeln erfunden – am deutlichsten «Lebenshaltung», hinter der sich ein Feld «Ausgaben pro Jahr» verbarg. Wer hier eine andere Sprache lernt als die, die Matrix, Rechenwege und Bericht sprechen, sucht sie später vergeblich. Der Elementname darf konkret bleiben: Kategorie «Ausgaben», Zeile «Lebenshaltung». (2) **Kein Speichern-Knopf je Element mehr.** Der Entwurf liegt neu auf Ebene des ganzen Schritts statt in der einzelnen Karte – nur so überlebt er den Wechsel des Reiters, denn dabei verschwinden die Karten des vorigen Bereichs samt ihrem Zustand. «Schritt abschliessen» schreibt alles in einem Zug. Weil damit alle Zahlen an einem Ort liegen, zeigt der Dialog neu die Summe **«Vermögen heute» live beim Tippen** – die Rückmeldung, die sonst mit dem Speichern-Knopf verloren gegangen wäre. Wer den Dialog mit ungespeicherten Eingaben schliesst, wird gefragt. (3) **«Zurück/Weiter» entfällt** in Schritt 1 und 2: Die Reiter oben sind der Weg durch die Bereiche, zwei Navigationen für dieselbe Bewegung sind eine zu viel. (4) Der 3a-Hinweis zum Höchstbetrag ist gestrichen – er beantwortete an dieser Stelle eine Frage, die niemand stellt. (5) **Neue Matrix-Spalte «Start»** und: **die Matrix erscheint, sobald es Elemente gibt** – Lebensphasen sind dafür nicht mehr nötig. Das war ein Fehler in 0.36: Die Stammdaten wurden eigens dafür eingeführt, dass eine Bestandsaufnahme ohne Zeitachse möglich ist, und dann rendete die Matrix nichts, weil sie ganz an den Phasen hing. Die Spezifikation behauptete das Richtige, der Code hielt es nicht. Die Spalte löst zugleich ein zweites Problem: Sie ist der Ort, an dem ein Startwert **änderbar** ist. Ohne sie hätte man ihn in Phase 1 bearbeitet und dabei still einen Phasenwert geschrieben, der die Stammdaten überdeckt – dieselbe Zahl an zwei Orten. (6) Der Text des leeren Zustands ist korrigiert: Seit dem Umbau kommen die Elemente zuerst und die Phasen danach. Neue gemeinsame Komponente `BaseFields` (Assistent und Matrix nutzen dieselben Felder), neues Panel für die Stammdaten. 2 Tests ergänzt (334 → 336). |
|
||||||
| 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.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 1–5 Jahren, Teilbezug 20–80 %. 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` (0–100 %). 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** (60–70) 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.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 1–5 Jahren, Teilbezug 20–80 %. 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` (0–100 %). 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** (60–70) 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). |
|
||||||
@@ -2073,18 +2074,19 @@ Codepfaden für dieselbe Frage. Jetzt an einem.
|
|||||||
|
|
||||||
### 3.13.5 Planungshorizont
|
### 3.13.5 Planungshorizont
|
||||||
|
|
||||||
`Person.planningHorizonAge` macht das Planende **explizit**. Bisher ergab es sich stillschweigend
|
Der Planungshorizont ist die **Summe der Lebensphasen** -- eine abgeleitete Grösse, kein Feld.
|
||||||
aus der Summe der Phasendauern: Zwei Szenarien konnten dadurch unbemerkt verschieden weit
|
Er steht in den Grundeinstellungen, zusammen mit dem Endalter je Person; ohne Phasen ist er
|
||||||
rechnen und waren nicht vergleichbar – obwohl das ihr Zweck ist.
|
leer.
|
||||||
|
|
||||||
Die Mechanik ist dieselbe wie beim Pensionsalter: **Zahl stellen, Struktur folgt.** Die letzte
|
Das war der dritte Anlauf. In 0.35 stand er als **Endalter je Person**, in 0.36 als
|
||||||
Lebensphase wird so angepasst, dass der Plan bis zum Horizont läuft (`planHorizonChange`,
|
**Jahreszahl am Szenario**, der die letzte Phase folgte. Beide Male war es eine zweite
|
||||||
`POST /api/scenarios/<id>/horizon`). Fiele sie dabei unter ein Jahr, wird blockiert. Bei zwei
|
Wahrheit über dieselbe Sache: Wer seine Lebensphasen einzeln plant, hat den Horizont damit
|
||||||
Personen läuft der Plan bis zum **spätesten** Horizont.
|
bereits bestimmt. Ihn zusätzlich zu erfassen hiess nur, zwei Zahlen synchron halten zu müssen
|
||||||
|
-- und beim Verschieben einer Phase zu entscheiden, welche der beiden gewinnt.
|
||||||
|
|
||||||
Bewusst neutral formuliert («Planungshorizont», nicht «Sterbealter»). Der Default liegt über der
|
Der ursprüngliche Anlass bleibt gültig: Zwei Szenarien dürfen nicht unbemerkt verschieden weit
|
||||||
Lebenserwartung: Eine zu kurze Planung sieht tragfähig aus, obwohl das Geld nur nicht lange
|
rechnen. Sichtbar wird das jetzt über die Anzeige in den Grundeinstellungen statt über ein
|
||||||
genug reichen muss.
|
eigenes Feld.
|
||||||
|
|
||||||
### 3.13.6 Was bewusst nicht abgebildet wird
|
### 3.13.6 Was bewusst nicht abgebildet wird
|
||||||
|
|
||||||
@@ -2114,23 +2116,43 @@ Die frühere Karte leitete AB, was zu tun wäre -- und liess einen damit allein.
|
|||||||
sagen «4 Übergangs-Entscheide offen», aber nicht, was ein Übergangs-Entscheid überhaupt ist
|
sagen «4 Übergangs-Entscheide offen», aber nicht, was ein Übergangs-Entscheid überhaupt ist
|
||||||
oder in welcher Reihenfolge man vorgeht.
|
oder in welcher Reihenfolge man vorgeht.
|
||||||
|
|
||||||
Der Assistent führt stattdessen. Sieben Schritte, jeder mit einem eigenen Werkzeug und einer
|
Der Assistent führt stattdessen. Jeder Schritt hat ein eigenes Werkzeug und eine Seite davor,
|
||||||
Seite davor, die erklärt, worum es geht:
|
die erklärt, worum es geht:
|
||||||
|
|
||||||
| # | Schritt | Was dabei entsteht |
|
| # | Schritt | Was dabei entsteht |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 1 | **Bestandsaufnahme** | alle Elemente mit ihrem heutigen Stand |
|
| 1 | **Bestandsaufnahme** | alle Elemente mit ihrem heutigen Stand |
|
||||||
| 2 | **Eckdaten und Pensionsplanung** | Planungshorizont; je Person Erwerbsende und Bezugszeitpunkte |
|
| 2 | **Erste Lebensphase** | Name, Dauer, die Annahmen dieser Jahre – und die verteilte Sparquote |
|
||||||
| 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?),
|
**Warum nur zwei.** Bis 0.37 waren es sieben. Die ersten drei trugen, die letzten vier
|
||||||
geht dann zu dem, was man **entscheidet** (wann höre ich auf?), und erst danach zu dem, was
|
zeigten Stationen, statt zu führen -- sie öffneten bestehende Dialoge und überliessen den
|
||||||
sich daraus **ergibt** (wie teile ich ein?). Genau deshalb steht die Bestandsaufnahme vor der
|
Rest dem Nutzer. Ein Schritt, der nicht führt, ist schlimmer als keiner: Er behauptet
|
||||||
Zeitachse -- und genau deshalb brauchte es die Element-Stammdaten (3.14.3).
|
Anleitung und liefert Verwaltung. Wie es nach der ersten Lebensphase weitergeht, wird eigens
|
||||||
|
entworfen statt aus dem Vorhandenen zusammengesetzt.
|
||||||
|
|
||||||
|
Die Reihenfolge der beiden ist nicht beliebig: Sie beginnt mit dem, was **feststeht** (was
|
||||||
|
habe ich?), und geht dann zu dem, was man **annimmt** (womit rechne ich?). Genau deshalb
|
||||||
|
steht die Bestandsaufnahme vor der Zeitachse -- und genau deshalb brauchte es die
|
||||||
|
Element-Stammdaten (3.14.3).
|
||||||
|
|
||||||
|
### Tatsache und Annahme gehören getrennt
|
||||||
|
|
||||||
|
Der Schnitt zwischen den beiden Schritten ist kein Ablaufdetail, sondern der Kern:
|
||||||
|
|
||||||
|
| Schritt 1 -- Tatsachen (`baseData`) | Schritt 2 -- Annahmen (`phaseValues`) |
|
||||||
|
|---|---|
|
||||||
|
| Nettolohn, Ausgaben pro Jahr | Lohnerhöhung, Teuerung |
|
||||||
|
| PK-Guthaben, 3a-Guthaben, Wert der Wertschriften | Verzinsung, erwartete Renditen, **PK-Beitrag** |
|
||||||
|
| Kaufpreis, Hypothek, Restschuld | Hypothekarzins, Wertsteigerung, Zins-in-Ausgaben |
|
||||||
|
|
||||||
|
Links steht, was man nachschlagen kann. Rechts steht, was man vermutet -- und eine Vermutung
|
||||||
|
gilt immer nur für einen Zeitraum. Bis 0.37 standen beide zusammen in der Bestandsaufnahme;
|
||||||
|
dadurch sah eine Annahme aus wie eine Tatsache, und man traf sie, bevor überhaupt feststand,
|
||||||
|
für welche Jahre sie gelten sollte.
|
||||||
|
|
||||||
|
Der **PK-Beitrag** steht bewusst bei den Annahmen und nicht in der Sparquoten-Verteilung: Er
|
||||||
|
stammt aus dem Bruttolohn und belastet das Cash-Konto nicht
|
||||||
|
([4.6.3](#463-pension_fund)) -- aus der Quote liesse er sich gar nicht verteilen.
|
||||||
|
|
||||||
### 3.14.2 Der Haken ist manuell -- der Stand daneben nicht
|
### 3.14.2 Der Haken ist manuell -- der Stand daneben nicht
|
||||||
|
|
||||||
@@ -2220,8 +2242,16 @@ Oben vier gleichrangige Kacheln plus die Zeitachse über die volle Breite:
|
|||||||
| **Schnellaktionen** | Neues Szenario · Tour · Änderungshistorie · Rechenwege · CSV-Export |
|
| **Schnellaktionen** | Neues Szenario · Tour · Änderungshistorie · Rechenwege · CSV-Export |
|
||||||
| **Assistent** | siehe 3.14.1 |
|
| **Assistent** | siehe 3.14.1 |
|
||||||
|
|
||||||
Das **Pensionsalter lässt sich in den Grundeinstellungen nicht ändern** -- es erzeugt eine
|
Das **Pensionsalter ist im Basisszenario auf 65 festgelegt** und nicht änderbar. Vorbezug,
|
||||||
Phasengrenze und gehört deshalb in die Pensionsplanung.
|
Aufschub, eigene Bezugsalter für Pensionskasse und Säule 3a sowie die daraus folgenden
|
||||||
|
Fixpunkte sind im Rechenkern vollständig vorhanden und getestet -- sie werden hier bewusst
|
||||||
|
**nicht angeboten**.
|
||||||
|
|
||||||
|
Der Grund ist nicht technisch, sondern didaktisch: Wer einen Plan zum ersten Mal aufsetzt,
|
||||||
|
soll nicht zwischen fünf Pensionsmodellen wählen müssen, bevor er weiss, wie sein Plan
|
||||||
|
überhaupt aussieht. Abweichende Modelle gehören zu einer eigenen **Szenario-Art** -- man legt
|
||||||
|
ein zweites Szenario an und wählt dort «Frühpensionierung». Bis dahin weisen die
|
||||||
|
Grundeinstellungen darauf hin.
|
||||||
|
|
||||||
Unten die Matrix. Die frühere Aktionsleiste darüber ist verschwunden: **«+ Element»,
|
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»
|
«+ Phase», der Nominal/Real-Umschalter, die Plan/Ist-Umschaltung und «Alle auf-/zuklappen»
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
-- Der Planungshorizont wird ABGELEITET statt erfasst (SPEZIFIKATION 3.14.5).
|
||||||
|
--
|
||||||
|
-- Dritter und letzter Anlauf: 0.35 fuehrte ihn als Endalter je Person, 0.36 als Jahreszahl am
|
||||||
|
-- Szenario -- beide Male eine zweite Wahrheit ueber dieselbe Sache. Wer die Lebensphasen
|
||||||
|
-- einzeln plant, hat den Horizont damit bereits bestimmt; ihn zusaetzlich zu speichern hiess
|
||||||
|
-- nur, zwei Zahlen synchron halten zu muessen. Er ist jetzt schlicht die Summe der
|
||||||
|
-- Phasendauern.
|
||||||
|
ALTER TABLE "Scenario" DROP COLUMN IF EXISTS "planningHorizonYears";
|
||||||
@@ -222,10 +222,6 @@ model Scenario {
|
|||||||
inflationRateDefault Float
|
inflationRateDefault Float
|
||||||
initialCash Float @default(0)
|
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).
|
// Fortschritt des FPT-Assistenten (sieben Schritte, vom Benutzer abgehakt).
|
||||||
assistantProgress Json?
|
assistantProgress Json?
|
||||||
|
|||||||
@@ -33,7 +33,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
|||||||
// kopiert. Szenario-eigen sind nur Inflation, Cash-Anfangswert und Pensionsalter.
|
// kopiert. Szenario-eigen sind nur Inflation, Cash-Anfangswert und Pensionsalter.
|
||||||
inflationRateDefault: source.inflationRateDefault,
|
inflationRateDefault: source.inflationRateDefault,
|
||||||
initialCash: source.initialCash,
|
initialCash: source.initialCash,
|
||||||
planningHorizonYears: source.planningHorizonYears,
|
|
||||||
assistantProgress: source.assistantProgress ?? undefined,
|
assistantProgress: source.assistantProgress ?? undefined,
|
||||||
persons: {
|
persons: {
|
||||||
create: source.persons.map((p) => ({ role: p.role, retirementAge: p.retirementAge })),
|
create: source.persons.map((p) => ({ role: p.role, retirementAge: p.retirementAge })),
|
||||||
|
|||||||
@@ -1,46 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { prisma } from "@/lib/db";
|
|
||||||
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_YEARS, MIN_PLANNING_HORIZON_YEARS } from "@/lib/constants";
|
|
||||||
|
|
||||||
// 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. 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({
|
|
||||||
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 }> }) {
|
|
||||||
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 { horizonYears } = parsed.data;
|
|
||||||
|
|
||||||
const planInput = toPlanInput(scenario);
|
|
||||||
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.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 ?? null });
|
|
||||||
}
|
|
||||||
@@ -36,8 +36,8 @@ export const STEP_EXPLAINERS: StepExplainer[] = [
|
|||||||
],
|
],
|
||||||
notes: [
|
notes: [
|
||||||
{
|
{
|
||||||
title: "Nur Bestände, noch keine Raten",
|
title: "Nur Tatsachen, noch keine Annahmen",
|
||||||
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.",
|
text: "Hier stehen Zahlen, die du nachschlagen kannst: Kontostand, Guthaben, Kaufpreis, Restschuld. Renditen, Lohnentwicklung und Sparraten kommen im nächsten Schritt – sie sind Annahmen und gelten immer nur für einen Zeitraum.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Lieber grob als gar nicht",
|
title: "Lieber grob als gar nicht",
|
||||||
@@ -51,132 +51,28 @@ export const STEP_EXPLAINERS: StepExplainer[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
lead:
|
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.",
|
"Eine Lebensphase ist ein Abschnitt, in dem die Verhältnisse ungefähr gleich bleiben: ähnliches Einkommen, ähnliche Ausgaben, dieselben Annahmen. Jetzt legst du die erste an.",
|
||||||
questions: [
|
questions: [
|
||||||
"Über wie viele Jahre soll die Planung laufen?",
|
"Wie lange bleiben deine Verhältnisse ungefähr so wie heute?",
|
||||||
"Wann hörst du auf zu arbeiten?",
|
"Womit rechnest du in dieser Zeit – Renditen, Lohnentwicklung, Teuerung?",
|
||||||
"Wann beziehst du die AHV – früher, mit 65, oder später?",
|
"Wohin fliesst das Geld, das am Ende des Jahres übrig bleibt?",
|
||||||
"Wann beziehst du Pensionskasse und Säule 3a?",
|
|
||||||
],
|
],
|
||||||
notes: [
|
notes: [
|
||||||
{
|
{
|
||||||
title: "Noch keine Beträge",
|
title: "Was eine Lebensphase zusammenhält",
|
||||||
text: "Hier geht es nur um Zeitpunkte. Ob Rente oder Kapital, und wohin das Geld fliesst, entscheidest du in Schritt 5.",
|
text: "Nicht ein Kalenderabschnitt, sondern eine Konstellation: Solange du gleich viel verdienst, ähnlich viel ausgibst und dieselben Annahmen gelten, ist es eine Phase. Ändert sich etwas grundlegend – ein Hauskauf, Teilzeit, die Pensionierung – beginnt eine neue.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Jeder Bezug erzeugt eine Phasengrenze",
|
title: "Annahmen gelten pro Phase",
|
||||||
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.",
|
text: "Die Rendite, die du hier einträgst, gilt nur für diese Jahre. In einer späteren Phase kannst du eine andere setzen – etwa vorsichtiger, wenn du im Ruhestand anders anlegst.",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
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",
|
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.",
|
text: "Am Schluss verteilst du deinen Sparbetrag. Was du nicht zuteilst, sammelt sich als Cash an – rechnerisch richtig, aber meist nicht die Absicht.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Übergänge sind Entscheidungspunkte",
|
title: "Bis zur Pensionierung",
|
||||||
text: "Zwischen zwei Lebensphasen entscheidest du, was mit Immobilien, Schulden und einmaligen Ereignissen geschieht.",
|
text: "Im Basisszenario wird mit Pensionsalter 65 gerechnet. Eine Lebensphase darf diesen Zeitpunkt nicht überspannen – FPT begrenzt die Dauer deshalb automatisch.",
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
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.",
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
+242
-551
@@ -7,22 +7,15 @@
|
|||||||
// Matrix schreiben können; er nimmt einem nur das Suchen ab.
|
// Matrix schreiben können; er nimmt einem nur das Suchen ab.
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { AlertTriangle, ArrowLeft, ArrowRight, Plus, Trash2 } from "lucide-react";
|
import { Plus, Trash2 } from "lucide-react";
|
||||||
import { api } from "@/lib/api-client";
|
import { api } from "@/lib/api-client";
|
||||||
import { Button } from "@/components/ui";
|
import { Button } from "@/components/ui";
|
||||||
import { MoneyField, NumberField, SelectField, TextField } from "@/components/FormField";
|
import { MoneyField, NumberField, SelectField, TextField } from "@/components/FormField";
|
||||||
import { RetirementFields } from "@/components/RetirementFields";
|
import { RateDistributionDialog } from "@/components/DistributionDialogs";
|
||||||
import { CapitalDistributionDialog, RateDistributionDialog } from "@/components/DistributionDialogs";
|
|
||||||
import { formatChf } from "@/lib/format";
|
import { formatChf } from "@/lib/format";
|
||||||
import { CATEGORY_LABELS, ownerLabel, num, type PhaseData } from "@/lib/elements";
|
import { CATEGORY_LABELS, ownerLabel, num, type PhaseData } from "@/lib/elements";
|
||||||
import {
|
import { maxPhaseDuration, type PlanComputed } from "@/lib/calculations";
|
||||||
DEFAULT_PLANNING_HORIZON_YEARS,
|
import type { PlanInput } from "@/lib/types";
|
||||||
MAX_PLANNING_HORIZON_YEARS,
|
|
||||||
MIN_PLANNING_HORIZON_YEARS,
|
|
||||||
} 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
|
// Schritt 1: Bestandsaufnahme
|
||||||
@@ -46,7 +39,16 @@ interface Kind {
|
|||||||
defaultName: string;
|
defaultName: string;
|
||||||
hint: string;
|
hint: string;
|
||||||
personOnly?: boolean;
|
personOnly?: boolean;
|
||||||
fields: (v: PhaseData, set: (p: Partial<PhaseData>) => void, plan: PlanInput) => React.ReactNode;
|
// SCHRITT 1 -- der Bestand von heute. Nur Zahlen, die man nachschlagen kann: Kontostand,
|
||||||
|
// Guthaben, Kaufpreis, Restschuld.
|
||||||
|
baseFields: (v: PhaseData, set: (p: Partial<PhaseData>) => void) => React.ReactNode;
|
||||||
|
// SCHRITT 2 -- die Annahmen fuer EINE Lebensphase. Alles, was pro Jahr geschieht und sich
|
||||||
|
// von Phase zu Phase aendern darf: Renditen, Lohnentwicklung, Wertsteigerung, Zins.
|
||||||
|
//
|
||||||
|
// Die Trennung ist der Kern: Ein Guthaben ist eine Tatsache, eine Rendite eine Annahme --
|
||||||
|
// und Annahmen gelten immer nur fuer einen Zeitraum. Bis 0.37 standen beide zusammen in der
|
||||||
|
// Bestandsaufnahme, wodurch eine Annahme wie eine Tatsache aussah.
|
||||||
|
phaseFields?: (v: PhaseData, set: (p: Partial<PhaseData>) => void) => React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
const KINDS: Kind[] = [
|
const KINDS: Kind[] = [
|
||||||
@@ -57,22 +59,22 @@ const KINDS: Kind[] = [
|
|||||||
defaultName: "Lohn",
|
defaultName: "Lohn",
|
||||||
hint: "Dein Nettolohn – das, was auf dem Konto ankommt.",
|
hint: "Dein Nettolohn – das, was auf dem Konto ankommt.",
|
||||||
personOnly: true,
|
personOnly: true,
|
||||||
fields: (v, set) => (
|
baseFields: (v, set) => (
|
||||||
<>
|
<MoneyField
|
||||||
<MoneyField
|
label="Nettolohn pro Jahr"
|
||||||
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."
|
||||||
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)}
|
||||||
value={num(v.amount)}
|
onChange={(x) => set({ amount: x })}
|
||||||
onChange={(x) => set({ amount: x })}
|
/>
|
||||||
/>
|
),
|
||||||
<NumberField
|
phaseFields: (v, set) => (
|
||||||
label="Jährliche Lohnerhöhung (%)"
|
<NumberField
|
||||||
help="Nominal, also inklusive Teuerung. 1 % ist ein vorsichtiger Ausgangswert."
|
label="Jährliche Lohnerhöhung (%)"
|
||||||
step={0.5}
|
help="Nominal, also inklusive Teuerung. 1 % ist ein vorsichtiger Ausgangswert."
|
||||||
value={num(v.teuerungsausgleich, 1)}
|
step={0.5}
|
||||||
onChange={(x) => set({ teuerungsausgleich: x })}
|
value={num(v.teuerungsausgleich, 1)}
|
||||||
/>
|
onChange={(x) => set({ teuerungsausgleich: x })}
|
||||||
</>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -81,7 +83,7 @@ const KINDS: Kind[] = [
|
|||||||
label: "Ausgaben",
|
label: "Ausgaben",
|
||||||
defaultName: "Lebenshaltung",
|
defaultName: "Lebenshaltung",
|
||||||
hint: "Alles, was du im Jahr ausgibst – Wohnen, Essen, Versicherungen, Freizeit.",
|
hint: "Alles, was du im Jahr ausgibst – Wohnen, Essen, Versicherungen, Freizeit.",
|
||||||
fields: (v, set) => (
|
baseFields: (v, set) => (
|
||||||
<MoneyField
|
<MoneyField
|
||||||
label="Ausgaben pro Jahr"
|
label="Ausgaben pro Jahr"
|
||||||
help="In heutiger Kaufkraft. Die Teuerung rechnet FPT selbst dazu."
|
help="In heutiger Kaufkraft. Die Teuerung rechnet FPT selbst dazu."
|
||||||
@@ -89,6 +91,15 @@ const KINDS: Kind[] = [
|
|||||||
onChange={(x) => set({ amount: x })}
|
onChange={(x) => set({ amount: x })}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
|
phaseFields: (v, set) => (
|
||||||
|
<NumberField
|
||||||
|
label="Jährliche Teuerung (%)"
|
||||||
|
help="Leer lassen heisst: die Inflationsannahme des Szenarios. Eine eigene Zahl nur, wenn du für diese Ausgaben etwas anderes erwartest."
|
||||||
|
step={0.25}
|
||||||
|
value={num(v.teuerungsausgleich)}
|
||||||
|
onChange={(x) => set({ teuerungsausgleich: x })}
|
||||||
|
/>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "pk",
|
key: "pk",
|
||||||
@@ -97,14 +108,16 @@ const KINDS: Kind[] = [
|
|||||||
defaultName: "Pensionskasse",
|
defaultName: "Pensionskasse",
|
||||||
hint: "Dein Altersguthaben laut PK-Ausweis.",
|
hint: "Dein Altersguthaben laut PK-Ausweis.",
|
||||||
personOnly: true,
|
personOnly: true,
|
||||||
fields: (v, set) => (
|
baseFields: (v, set) => (
|
||||||
|
<MoneyField
|
||||||
|
label="Altersguthaben heute"
|
||||||
|
help="Steht auf deinem Vorsorgeausweis."
|
||||||
|
value={num(v.currentValue)}
|
||||||
|
onChange={(x) => set({ currentValue: x })}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
phaseFields: (v, set) => (
|
||||||
<>
|
<>
|
||||||
<MoneyField
|
|
||||||
label="Altersguthaben heute"
|
|
||||||
help="Steht auf deinem Vorsorgeausweis."
|
|
||||||
value={num(v.currentValue)}
|
|
||||||
onChange={(x) => set({ currentValue: x })}
|
|
||||||
/>
|
|
||||||
<NumberField
|
<NumberField
|
||||||
label="Verzinsung (%/Jahr)"
|
label="Verzinsung (%/Jahr)"
|
||||||
help="Der BVG-Mindestzins liegt aktuell tief; viele Kassen verzinsen etwas darüber."
|
help="Der BVG-Mindestzins liegt aktuell tief; viele Kassen verzinsen etwas darüber."
|
||||||
@@ -112,6 +125,15 @@ const KINDS: Kind[] = [
|
|||||||
value={num(v.expectedReturn, 1.25)}
|
value={num(v.expectedReturn, 1.25)}
|
||||||
onChange={(x) => set({ expectedReturn: x })}
|
onChange={(x) => set({ expectedReturn: x })}
|
||||||
/>
|
/>
|
||||||
|
{/* Der PK-Beitrag steht hier und nicht bei der Sparquote: Er stammt aus dem
|
||||||
|
Bruttolohn und belastet das Cash-Konto nicht -- aus der Quote liesse er sich
|
||||||
|
gar nicht verteilen (SPEZIFIKATION 4.6.3). */}
|
||||||
|
<MoneyField
|
||||||
|
label="Jährlicher Beitrag (Arbeitnehmer + Arbeitgeber)"
|
||||||
|
help="Die Altersgutschrift laut Vorsorgeausweis. Sie kommt aus dem Bruttolohn und schmälert deine Sparquote deshalb nicht."
|
||||||
|
value={num(v.annualContribution)}
|
||||||
|
onChange={(x) => set({ annualContribution: x })}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -122,21 +144,17 @@ const KINDS: Kind[] = [
|
|||||||
defaultName: "Säule 3a",
|
defaultName: "Säule 3a",
|
||||||
hint: "Ein Konto oder Depot der gebundenen Vorsorge. Mehrere Konten legst du einzeln an.",
|
hint: "Ein Konto oder Depot der gebundenen Vorsorge. Mehrere Konten legst du einzeln an.",
|
||||||
personOnly: true,
|
personOnly: true,
|
||||||
fields: (v, set) => (
|
baseFields: (v, set) => (
|
||||||
<>
|
<MoneyField label="Guthaben heute" value={num(v.currentValue)} onChange={(x) => set({ currentValue: x })} />
|
||||||
<MoneyField
|
),
|
||||||
label="Guthaben heute"
|
phaseFields: (v, set) => (
|
||||||
value={num(v.currentValue)}
|
<NumberField
|
||||||
onChange={(x) => set({ currentValue: x })}
|
label="Erwartete Rendite (%/Jahr)"
|
||||||
/>
|
help="Auf einem Konto praktisch 0, in einer Wertschriftenlösung deutlich mehr."
|
||||||
<NumberField
|
step={0.5}
|
||||||
label="Erwartete Rendite (%/Jahr)"
|
value={num(v.expectedReturn, 2)}
|
||||||
help="Auf einem Konto praktisch 0, in einer Wertschriftenlösung deutlich mehr."
|
onChange={(x) => set({ expectedReturn: x })}
|
||||||
step={0.5}
|
/>
|
||||||
value={num(v.expectedReturn, 2)}
|
|
||||||
onChange={(x) => set({ expectedReturn: x })}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -145,17 +163,17 @@ const KINDS: Kind[] = [
|
|||||||
label: "Sonstiges Vermögen",
|
label: "Sonstiges Vermögen",
|
||||||
defaultName: "Wertschriften",
|
defaultName: "Wertschriften",
|
||||||
hint: "Depot, ETF, Sparkonto – alles freie Vermögen ausser dem Cash-Konto.",
|
hint: "Depot, ETF, Sparkonto – alles freie Vermögen ausser dem Cash-Konto.",
|
||||||
fields: (v, set) => (
|
baseFields: (v, set) => (
|
||||||
<>
|
<MoneyField label="Wert heute" value={num(v.startValue)} onChange={(x) => set({ startValue: x })} />
|
||||||
<MoneyField label="Wert heute" value={num(v.startValue)} onChange={(x) => set({ startValue: x })} />
|
),
|
||||||
<NumberField
|
phaseFields: (v, set) => (
|
||||||
label="Erwartete Rendite (%/Jahr)"
|
<NumberField
|
||||||
help="Langfristiger Durchschnitt. Für ein gemischtes Portfolio sind 3 bis 5 % ein üblicher Ansatz."
|
label="Erwartete Rendite (%/Jahr)"
|
||||||
step={0.5}
|
help="Langfristiger Durchschnitt. Für ein gemischtes Portfolio sind 3 bis 5 % ein üblicher Ansatz."
|
||||||
value={num(v.expectedReturn, 4)}
|
step={0.5}
|
||||||
onChange={(x) => set({ expectedReturn: x })}
|
value={num(v.expectedReturn, 4)}
|
||||||
/>
|
onChange={(x) => set({ expectedReturn: x })}
|
||||||
</>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -164,7 +182,7 @@ const KINDS: Kind[] = [
|
|||||||
label: "Immobilie",
|
label: "Immobilie",
|
||||||
defaultName: "Wohneigentum",
|
defaultName: "Wohneigentum",
|
||||||
hint: "Haus oder Wohnung samt Hypothek.",
|
hint: "Haus oder Wohnung samt Hypothek.",
|
||||||
fields: (v, set) => (
|
baseFields: (v, set) => (
|
||||||
<>
|
<>
|
||||||
<MoneyField
|
<MoneyField
|
||||||
label="Kaufpreis"
|
label="Kaufpreis"
|
||||||
@@ -172,11 +190,11 @@ const KINDS: Kind[] = [
|
|||||||
value={num(v.purchasePrice)}
|
value={num(v.purchasePrice)}
|
||||||
onChange={(x) => set({ purchasePrice: x })}
|
onChange={(x) => set({ purchasePrice: x })}
|
||||||
/>
|
/>
|
||||||
<MoneyField
|
<MoneyField label="Hypothek heute" value={num(v.mortgage)} onChange={(x) => set({ mortgage: x })} />
|
||||||
label="Hypothek heute"
|
</>
|
||||||
value={num(v.mortgage)}
|
),
|
||||||
onChange={(x) => set({ mortgage: x })}
|
phaseFields: (v, set) => (
|
||||||
/>
|
<>
|
||||||
<NumberField
|
<NumberField
|
||||||
label="Hypothekarzins (%)"
|
label="Hypothekarzins (%)"
|
||||||
step={0.25}
|
step={0.25}
|
||||||
@@ -211,7 +229,7 @@ const KINDS: Kind[] = [
|
|||||||
label: "Sonstige Schulden",
|
label: "Sonstige Schulden",
|
||||||
defaultName: "Schulden",
|
defaultName: "Schulden",
|
||||||
hint: "Kredite ausserhalb der Hypothek.",
|
hint: "Kredite ausserhalb der Hypothek.",
|
||||||
fields: (v, set) => (
|
baseFields: (v, set) => (
|
||||||
<MoneyField label="Restschuld heute" value={num(v.startValue)} onChange={(x) => set({ startValue: x })} />
|
<MoneyField label="Restschuld heute" value={num(v.startValue)} onChange={(x) => set({ startValue: x })} />
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -249,15 +267,13 @@ export function BaseFields({
|
|||||||
category,
|
category,
|
||||||
values,
|
values,
|
||||||
set,
|
set,
|
||||||
plan,
|
|
||||||
}: {
|
}: {
|
||||||
category: string;
|
category: string;
|
||||||
values: PhaseData;
|
values: PhaseData;
|
||||||
set: (patch: Partial<PhaseData>) => void;
|
set: (patch: Partial<PhaseData>) => void;
|
||||||
plan: PlanInput;
|
|
||||||
}) {
|
}) {
|
||||||
const kind = KINDS.find((k) => k.category === category);
|
const kind = KINDS.find((k) => k.category === category);
|
||||||
return <>{kind?.fields(values, set, plan)}</>;
|
return <>{kind?.baseFields(values, set)}</>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Step1Elements({
|
export function Step1Elements({
|
||||||
@@ -453,7 +469,7 @@ function BaseDataCard({
|
|||||||
onChange={(v) => setDraft((d) => ({ ...d, names: { ...d.names, [el.id]: v } }))}
|
onChange={(v) => setDraft((d) => ({ ...d, names: { ...d.names, [el.id]: v } }))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{kind?.fields(values, set, plan)}
|
{kind?.baseFields(values, set)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -472,268 +488,81 @@ export async function saveStep1Draft(plan: PlanInput, draft: Step1Draft): Promis
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================================
|
|
||||||
// Schritt 2: Eckdaten und Pensionsplanung
|
|
||||||
// ============================================================================================
|
|
||||||
|
|
||||||
export function Step2Timeline({
|
// ============================================================================================
|
||||||
|
// Schritt 2: die erste Lebensphase
|
||||||
|
// ============================================================================================
|
||||||
|
//
|
||||||
|
// Drei Seiten, die zusammen EINE Frage beantworten: "Wie sehen die naechsten Jahre aus?"
|
||||||
|
//
|
||||||
|
// 1. Name und Dauer -> die Phase entsteht
|
||||||
|
// 2. Jaehrliche Annahmen je Element -> was in dieser Zeit gilt
|
||||||
|
// 3. Sparquote verteilen -> wohin das Geld fliesst, das uebrig bleibt
|
||||||
|
//
|
||||||
|
// Erst nach Seite 3 ist der Schritt fertig. Waere er nach Seite 2 zu Ende, staende eine Phase
|
||||||
|
// da, in der der ganze Ueberschuss stumm auf dem Cash-Konto liegen bliebe -- rechnerisch
|
||||||
|
// richtig, aber nie die Absicht.
|
||||||
|
|
||||||
|
export interface Step2Draft {
|
||||||
|
name: string;
|
||||||
|
years: number;
|
||||||
|
// Jaehrliche Annahmen je Element, gesammelt bis zum Anlegen der Phase.
|
||||||
|
phase: Record<string, PhaseData>;
|
||||||
|
// Ist die Phase bereits angelegt? Danach wird nur noch die Sparquote verteilt.
|
||||||
|
createdPhaseId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function emptyStep2Draft(): Step2Draft {
|
||||||
|
return { name: "Erwerbsjahre", years: 10, phase: {}, createdPhaseId: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Step2FirstPhase({
|
||||||
plan,
|
plan,
|
||||||
computed,
|
computed,
|
||||||
|
draft,
|
||||||
|
setDraft,
|
||||||
onChanged,
|
onChanged,
|
||||||
}: {
|
}: {
|
||||||
plan: PlanInput;
|
plan: PlanInput;
|
||||||
computed: PlanComputed;
|
computed: PlanComputed;
|
||||||
|
draft: Step2Draft;
|
||||||
|
setDraft: (fn: (d: Step2Draft) => Step2Draft) => void;
|
||||||
onChanged: () => void;
|
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}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Kein Zurueck/Weiter: Die Reiter oben sind der Weg durch die Bereiche. Zwei
|
|
||||||
Navigationen fuer dieselbe Bewegung sind eine zu viel. */}
|
|
||||||
</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 [busy, setBusy] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [showRates, setShowRates] = useState(false);
|
||||||
|
|
||||||
async function save() {
|
// Die Phase darf nicht ueber eine Pensionierung hinausreichen -- die Rechnung leitet den
|
||||||
|
// Erwerbsstatus am PHASENBEGINN ab. Statt das hinterher als Fehler zu melden, steht die
|
||||||
|
// Grenze hier und begrenzt das Feld.
|
||||||
|
const cap = maxPhaseDuration(
|
||||||
|
plan.persons.map((x) => ({ role: x.role, age: x.age, retirementAge: x.retirementAge })),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
const maxYears = cap ?? 60;
|
||||||
|
const years = Math.min(Math.max(1, Math.round(draft.years)), maxYears);
|
||||||
|
|
||||||
|
const existing = plan.phases.length > 0 ? plan.phases[0] : null;
|
||||||
|
const phaseId = draft.createdPhaseId ?? existing?.id ?? null;
|
||||||
|
|
||||||
|
async function createPhase() {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
await api.post(`/api/scenarios/${plan.id}/horizon`, { horizonYears: Math.round(years) });
|
const { phase } = await api.post<{ phase: { id: string } }>(`/api/scenarios/${plan.id}/phases`, {
|
||||||
onChanged();
|
name: draft.name.trim() || "Erste Lebensphase",
|
||||||
} catch (e) {
|
durationYears: years,
|
||||||
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();
|
// Die jaehrlichen Annahmen gehoeren zu DIESER Phase -- deshalb erst jetzt schreibbar.
|
||||||
} catch (e) {
|
for (const [elementId, values] of Object.entries(draft.phase)) {
|
||||||
setError(e instanceof Error ? e.message : "Nicht möglich.");
|
if (Object.keys(values).length > 0) {
|
||||||
} finally {
|
await api.put(`/api/elements/${elementId}/phase/${phase.id}`, values);
|
||||||
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)),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
setDraft((d) => ({ ...d, createdPhaseId: phase.id }));
|
||||||
onChanged();
|
onChanged();
|
||||||
|
setShowRates(true);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e instanceof Error ? e.message : "Anlegen fehlgeschlagen.");
|
setError(e instanceof Error ? e.message : "Anlegen fehlgeschlagen.");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -741,252 +570,114 @@ export function Step3Phases({ plan, onChanged }: { plan: PlanInput; onChanged: (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (plan.phases.length > 0) {
|
// Seite 3: die Sparquote verteilen. Erst moeglich, wenn die Phase steht.
|
||||||
|
if (showRates && phaseId) {
|
||||||
return (
|
return (
|
||||||
<p className="rounded-lg border border-dashed border-border bg-surface-2 p-3 text-sm text-muted">
|
<RateDistributionDialog
|
||||||
Dieses Szenario hat bereits {plan.phases.length} Lebensphasen. Weitere legst du direkt in der Matrix an
|
plan={plan}
|
||||||
(«+ Lebensphase» in der Ecke oben links) – dort siehst du auch gleich, was sich dadurch ändert.
|
computed={computed}
|
||||||
</p>
|
phaseId={phaseId}
|
||||||
|
onClose={() => setShowRates(false)}
|
||||||
|
onSaved={() => {
|
||||||
|
setShowRates(false);
|
||||||
|
onChanged();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing && !draft.createdPhaseId) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<p className="rounded-lg border border-dashed border-border bg-surface-2 p-3 text-sm text-muted">
|
||||||
|
Die erste Lebensphase «{existing.name}» steht bereits ({existing.durationYears} Jahre). Die jährlichen
|
||||||
|
Annahmen und die Sparquote änderst du direkt in der Matrix – oder hier über die Verteilung.
|
||||||
|
</p>
|
||||||
|
<Button onClick={() => setShowRates(true)}>Sparquote verteilen</Button>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<p className="text-sm text-muted">
|
{/* Seite 1: Name und Dauer. */}
|
||||||
Die dick umrandeten Abschnitte sind gesetzt – sie ergeben sich aus deinen Bezugszeitpunkten. Innerhalb
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
eines Abschnitts kannst du beliebig fein unterteilen, die Summe muss aber genau aufgehen.
|
<TextField
|
||||||
</p>
|
label="Wie nennst du diese Phase?"
|
||||||
|
value={draft.name}
|
||||||
|
onChange={(v) => setDraft((d) => ({ ...d, name: v }))}
|
||||||
|
/>
|
||||||
|
<NumberField
|
||||||
|
label="Wie viele Jahre dauert sie?"
|
||||||
|
help={
|
||||||
|
cap !== null
|
||||||
|
? `Höchstens ${cap} Jahre – danach beginnt die Pensionierung, und dort muss eine neue Lebensphase starten.`
|
||||||
|
: "Frei wählbar."
|
||||||
|
}
|
||||||
|
value={years}
|
||||||
|
min={1}
|
||||||
|
max={maxYears}
|
||||||
|
step={1}
|
||||||
|
onChange={(v) => setDraft((d) => ({ ...d, years: Math.round(v) }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{segments.map((seg, i) => (
|
{plan.startYear && (
|
||||||
<div key={i} className="rounded-xl border border-border p-3">
|
<p className="text-xs text-faint">
|
||||||
<div className="mb-2 flex flex-wrap items-baseline gap-2">
|
Das sind die Jahre {plan.startYear} bis {plan.startYear + years - 1}
|
||||||
<span className="text-sm font-semibold text-fg">
|
{plan.persons.length > 0 && (
|
||||||
{seg.type === "ERWERB" ? "Erwerbsphase" : seg.type === "MIXED" ? "Übergangszeit" : "Ruhestand"}
|
<>
|
||||||
</span>
|
{" · "}
|
||||||
<span className="text-xs text-muted">
|
{plan.persons.map((x) => `${ownerLabel(plan.persons, x.role)} ${x.age} → ${x.age + years}`).join(" · ")}
|
||||||
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>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Seite 2: die jaehrlichen Annahmen. */}
|
||||||
|
<div className="border-t border-border pt-3">
|
||||||
|
<p className="mb-1 text-sm font-semibold text-fg">Womit rechnest du in dieser Zeit?</p>
|
||||||
|
<p className="mb-3 text-xs text-muted">
|
||||||
|
Diese Annahmen gelten nur für diese Lebensphase. In einer späteren Phase kannst du andere setzen – etwa
|
||||||
|
eine vorsichtigere Rendite, wenn du im Ruhestand anders anlegst.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{plan.elements.length === 0 ? (
|
||||||
|
<p className="rounded-lg border border-dashed border-border bg-surface-2 p-3 text-sm text-muted">
|
||||||
|
Es sind noch keine finanziellen Elemente erfasst. Geh zurück zu Schritt 1.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{plan.elements.map((el) => {
|
||||||
|
const kind = KINDS.find((k) => k.category === el.category);
|
||||||
|
if (!kind?.phaseFields) return null;
|
||||||
|
const values = draft.phase[el.id] ?? {};
|
||||||
|
const set = (patch: Partial<PhaseData>) =>
|
||||||
|
setDraft((d) => ({ ...d, phase: { ...d.phase, [el.id]: { ...(d.phase[el.id] ?? {}), ...patch } } }));
|
||||||
|
return (
|
||||||
|
<div key={el.id} className="rounded-xl border border-border p-3">
|
||||||
|
<div className="mb-2 flex items-baseline gap-2">
|
||||||
|
<span className="text-sm font-semibold text-fg">{el.name}</span>
|
||||||
|
<span className="text-xs text-faint">{CATEGORY_LABELS[el.category]}</span>
|
||||||
|
{el.ownerRole && el.ownerRole !== "HOUSEHOLD" && (
|
||||||
|
<span className="rounded bg-surface-2 px-1.5 py-0.5 text-[10px] text-muted">
|
||||||
|
{ownerLabel(plan.persons, el.ownerRole)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">{kind.phaseFields(values, set)}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{error && <p className="text-sm text-danger">{error}</p>}
|
{error && <p className="text-sm text-danger">{error}</p>}
|
||||||
<Button disabled={busy || !valid} onClick={create}>
|
|
||||||
{busy ? "…" : "Lebensphasen anlegen"}
|
<Button disabled={busy || plan.elements.length === 0} onClick={createPhase}>
|
||||||
|
{busy ? "…" : "Lebensphase anlegen und Sparquote verteilen"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|||||||
+34
-48
@@ -48,16 +48,15 @@ import { Assistant } from "@/components/Assistant";
|
|||||||
import { AssistantStepDialog } from "@/components/AssistantStepDialog";
|
import { AssistantStepDialog } from "@/components/AssistantStepDialog";
|
||||||
import {
|
import {
|
||||||
Step1Elements,
|
Step1Elements,
|
||||||
Step2Timeline,
|
Step2FirstPhase,
|
||||||
Step3Phases,
|
|
||||||
StepWalkthrough,
|
|
||||||
BaseFields,
|
BaseFields,
|
||||||
emptyStep1Draft,
|
emptyStep1Draft,
|
||||||
|
emptyStep2Draft,
|
||||||
saveStep1Draft,
|
saveStep1Draft,
|
||||||
type Step1Draft,
|
type Step1Draft,
|
||||||
|
type Step2Draft,
|
||||||
} from "@/components/AssistantSteps";
|
} from "@/components/AssistantSteps";
|
||||||
import { normalizeProgress } from "@/lib/assistant";
|
import { normalizeProgress } from "@/lib/assistant";
|
||||||
import { RetirementPanel } from "@/components/RetirementPanel";
|
|
||||||
import { Button, EmptyState, InspectorShell, Modal, useConfirm, useToast } from "@/components/ui";
|
import { Button, EmptyState, InspectorShell, Modal, useConfirm, useToast } from "@/components/ui";
|
||||||
import { ElementDetailDialog, PhaseDetailDialog } from "@/components/DetailView";
|
import { ElementDetailDialog, PhaseDetailDialog } from "@/components/DetailView";
|
||||||
import {
|
import {
|
||||||
@@ -93,7 +92,7 @@ import {
|
|||||||
} from "@/lib/elements";
|
} from "@/lib/elements";
|
||||||
import { computePlan, type PhaseComputed, type PlanComputed } from "@/lib/calculations";
|
import { computePlan, type PhaseComputed, type PlanComputed } from "@/lib/calculations";
|
||||||
import type { ScenarioDiff } from "@/lib/diff";
|
import type { ScenarioDiff } from "@/lib/diff";
|
||||||
import type { ElementInput, PersonRole, PlanInput } from "@/lib/types";
|
import type { ElementInput, PlanInput } from "@/lib/types";
|
||||||
|
|
||||||
const CATEGORY_ICON: Record<ElementCategory, React.ReactNode> = {
|
const CATEGORY_ICON: Record<ElementCategory, React.ReactNode> = {
|
||||||
INCOME: <Wallet className="h-4 w-4" />,
|
INCOME: <Wallet className="h-4 w-4" />,
|
||||||
@@ -215,7 +214,7 @@ export function PlanView({
|
|||||||
// Karten: Beim Wechsel des Reiters (Gemeinsam -> Person A) verschwinden die Karten des
|
// Karten: Beim Wechsel des Reiters (Gemeinsam -> Person A) verschwinden die Karten des
|
||||||
// vorigen Bereichs, und mit ihrem eigenen Zustand waeren alle getippten Zahlen still weg.
|
// vorigen Bereichs, und mit ihrem eigenen Zustand waeren alle getippten Zahlen still weg.
|
||||||
const [step1Draft, setStep1Draft] = useState<Step1Draft>(emptyStep1Draft);
|
const [step1Draft, setStep1Draft] = useState<Step1Draft>(emptyStep1Draft);
|
||||||
const [retirementRole, setRetirementRole] = useState<PersonRole | null>(null);
|
const [step2Draft, setStep2Draft] = useState<Step2Draft>(emptyStep2Draft);
|
||||||
const progress = normalizeProgress(plan.assistantProgress);
|
const progress = normalizeProgress(plan.assistantProgress);
|
||||||
// Die Tour (Start-Knopf, Auto-Start bei Plan-Erstellung, Rendering) liegt seit dem
|
// 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.
|
// Layout-Umbau in AppShell -- sie liest die data-tour-Ziele im DOM dieser Ansicht.
|
||||||
@@ -414,7 +413,6 @@ export function PlanView({
|
|||||||
computed={computed}
|
computed={computed}
|
||||||
diffChanged={!!diff?.profileChanged}
|
diffChanged={!!diff?.profileChanged}
|
||||||
onEdit={() => setPanel({ kind: "profile" })}
|
onEdit={() => setPanel({ kind: "profile" })}
|
||||||
onRetirement={(role) => setRetirementRole(role)}
|
|
||||||
/>
|
/>
|
||||||
<FiguresTile plan={plan} computed={computed} />
|
<FiguresTile plan={plan} computed={computed} />
|
||||||
<QuickActionsTile
|
<QuickActionsTile
|
||||||
@@ -964,31 +962,18 @@ export function PlanView({
|
|||||||
onChanged={onChanged}
|
onChanged={onChanged}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{assistantStep === 1 && <Step2Timeline plan={plan} computed={computed} onChanged={onChanged} />}
|
{assistantStep === 1 && (
|
||||||
{assistantStep === 2 && <Step3Phases plan={plan} onChanged={onChanged} />}
|
<Step2FirstPhase
|
||||||
{assistantStep === 3 && (
|
plan={plan}
|
||||||
<StepWalkthrough plan={plan} computed={computed} scope="WORK" onChanged={onChanged} />
|
computed={computed}
|
||||||
)}
|
draft={step2Draft}
|
||||||
{assistantStep === 4 && (
|
setDraft={(fn) => setStep2Draft((d) => fn(d))}
|
||||||
<StepWalkthrough plan={plan} computed={computed} scope="RETIRE" onChanged={onChanged} />
|
onChanged={onChanged}
|
||||||
)}
|
/>
|
||||||
{assistantStep === 5 && (
|
|
||||||
<StepWalkthrough plan={plan} computed={computed} scope="PENSION" onChanged={onChanged} />
|
|
||||||
)}
|
)}
|
||||||
</AssistantStepDialog>
|
</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 && (() => {
|
{reviewFromPhaseId && (() => {
|
||||||
const fromPhase = computed.phases.find((p) => p.id === reviewFromPhaseId);
|
const fromPhase = computed.phases.find((p) => p.id === reviewFromPhaseId);
|
||||||
if (!fromPhase) return null;
|
if (!fromPhase) return null;
|
||||||
@@ -2455,16 +2440,18 @@ function SettingsTile({
|
|||||||
computed,
|
computed,
|
||||||
diffChanged,
|
diffChanged,
|
||||||
onEdit,
|
onEdit,
|
||||||
onRetirement,
|
|
||||||
}: {
|
}: {
|
||||||
plan: PlanInput;
|
plan: PlanInput;
|
||||||
computed: PlanComputed;
|
computed: PlanComputed;
|
||||||
diffChanged: boolean;
|
diffChanged: boolean;
|
||||||
onEdit: () => void;
|
onEdit: () => void;
|
||||||
onRetirement: (role: PersonRole) => void;
|
|
||||||
}) {
|
}) {
|
||||||
const horizon = plan.planningHorizonYears ?? null;
|
// Der Planungshorizont wird ABGELEITET -- er ist die Summe der Lebensphasen. Bis 0.37 war
|
||||||
const endYear = plan.startYear && horizon ? plan.startYear + horizon - 1 : null;
|
// er eine eigene Zahl, die man setzte und der die letzte Phase folgte. Das war eine zweite
|
||||||
|
// Wahrheit über dieselbe Sache: Wer die Phasen ohnehin einzeln plant, hat den Horizont
|
||||||
|
// damit schon bestimmt.
|
||||||
|
const horizon = computed.phases.reduce((n, ph) => n + ph.durationYears, 0);
|
||||||
|
const endYear = plan.startYear && horizon > 0 ? plan.startYear + horizon - 1 : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tile
|
<Tile
|
||||||
@@ -2491,29 +2478,28 @@ function SettingsTile({
|
|||||||
<div>Start {plan.startYear ?? "—"} · Inflation {plan.inflationRateDefault} %</div>
|
<div>Start {plan.startYear ?? "—"} · Inflation {plan.inflationRateDefault} %</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Szenario-eigen: das, was man zwischen Varianten unterscheidet. */}
|
{/* Szenario-eigen: Laufzeit und Pensionsalter. */}
|
||||||
<div className="mt-1 border-t border-border pt-1 text-[11px] text-muted">
|
<div className="mt-1 border-t border-border pt-1 text-[11px] text-muted">
|
||||||
<div>
|
<div>
|
||||||
Horizont {horizon ? `${horizon} Jahre` : "—"}
|
Planungshorizont {horizon > 0 ? `${horizon} Jahre` : "—"}
|
||||||
{endYear ? ` · bis ${endYear}` : ""}
|
{endYear ? ` · bis ${endYear}` : ""}
|
||||||
</div>
|
</div>
|
||||||
{plan.persons.map((p) => (
|
{plan.persons.map((p) => (
|
||||||
<div key={p.role} className="mt-0.5 flex items-center gap-1">
|
<div key={p.role} className="mt-0.5 truncate">
|
||||||
<span className="truncate">
|
Pensionsalter {ownerLabel(plan.persons, p.role)}: {p.retirementAge}
|
||||||
Pension {p.retirementAge}
|
{horizon > 0 ? ` · Endalter ${p.age + horizon}` : ""}
|
||||||
{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>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Im Basisszenario wird mit dem Referenzalter gerechnet. Abweichende Pensionsmodelle
|
||||||
|
(Vorbezug, Aufschub, gestaffelte Kapitalbezüge) sind im Rechenkern vorhanden, werden
|
||||||
|
hier aber bewusst nicht angeboten -- sie gehören zu einer eigenen Szenario-Art. */}
|
||||||
|
<p className="mt-1 border-t border-border pt-1 text-[10px] leading-snug text-faint">
|
||||||
|
Im Basisszenario wird mit Pensionsalter {AHV_REFERENCE_AGE} gerechnet. Abweichende Pensionsmodelle folgen
|
||||||
|
als eigene Szenario-Art.
|
||||||
|
</p>
|
||||||
|
|
||||||
{computed.retirement.gapAnnual !== null && (
|
{computed.retirement.gapAnnual !== null && (
|
||||||
<div className="mt-1 text-[11px] text-faint">
|
<div className="mt-1 text-[11px] text-faint">
|
||||||
Rentenlücke {formatChf(computed.retirement.gapAnnual)}/Jahr
|
Rentenlücke {formatChf(computed.retirement.gapAnnual)}/Jahr
|
||||||
@@ -2664,7 +2650,7 @@ function BaseDataPanel({
|
|||||||
Planung. Abweichende Werte für einzelne Phasen erfasst du in der jeweiligen Zelle.
|
Planung. Abweichende Werte für einzelne Phasen erfasst du in der jeweiligen Zelle.
|
||||||
</p>
|
</p>
|
||||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
<BaseFields category={el.category} values={draft} set={set} plan={plan} />
|
<BaseFields category={el.category} values={draft} set={set} />
|
||||||
</div>
|
</div>
|
||||||
{error && <p className="text-sm text-danger">{error}</p>}
|
{error && <p className="text-sm text-danger">{error}</p>}
|
||||||
<div className="flex gap-2 pt-1">
|
<div className="flex gap-2 pt-1">
|
||||||
|
|||||||
@@ -1,266 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
// Der Pensionierungs-Bildschirm.
|
|
||||||
//
|
|
||||||
// Bis 0.33 lagen die Entscheide, die inhaltlich EINE Frage sind, in drei weit auseinander
|
|
||||||
// liegenden Matrix-Zellen (AHV, PK, 3a) am Pensions-Übergang. Hier stehen sie beieinander --
|
|
||||||
// in der Reihenfolge, in der man tatsächlich darüber nachdenkt:
|
|
||||||
//
|
|
||||||
// Wann höre ich auf? → Was kommt dann rein? → Was habe ich auf einen Schlag?
|
|
||||||
// → Reicht das? → Was mache ich mit dem Haufen?
|
|
||||||
//
|
|
||||||
// Die Leitzahl ganz oben ist die RENTENLÜCKE. Sie ist keine neue Rechnung, sondern die
|
|
||||||
// Verzehrquote im ersten voll pensionierten Jahr -- es fehlte bisher nur der Name dafür.
|
|
||||||
// Gerechnet wird sie im Rechenkern (`computed.retirement`), damit Bildschirm und PDF-Bericht
|
|
||||||
// nicht auseinanderlaufen können.
|
|
||||||
//
|
|
||||||
// Gestaltungsprinzip: KEIN leeres Formular, sondern ein vollständiger Vorschlag, den man
|
|
||||||
// korrigiert. Sonst müsste man am Anfang Fragen beantworten, die man erst am Ende beantworten
|
|
||||||
// kann -- und ohne Vorgaben wäre der Plan bis dahin gar nicht rechenbar.
|
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import { AlertTriangle } from "lucide-react";
|
|
||||||
import { api } from "@/lib/api-client";
|
|
||||||
import { Button, useToast } from "@/components/ui";
|
|
||||||
import { InfoBubble } from "@/components/InfoBubble";
|
|
||||||
import { RetirementAdjuster } from "@/components/RetirementAdjuster";
|
|
||||||
import { RetirementFields } from "@/components/RetirementFields";
|
|
||||||
import { formatChf } from "@/lib/format";
|
|
||||||
import { ownerLabel } from "@/lib/elements";
|
|
||||||
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";
|
|
||||||
|
|
||||||
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();
|
|
||||||
const [draft, setDraft] = useState<Draft>({});
|
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
// Effektiver Entscheid je Element: gespeicherter Wert, überlagert vom Entwurf, aufgefüllt
|
|
||||||
// mit den Vorgaben. Genau das rechnet auch der Rechenkern.
|
|
||||||
const effective = (el: ElementInput): RetirementDecision => {
|
|
||||||
const person = plan.persons.find((p) => p.role === el.ownerRole);
|
|
||||||
return withRetirementDefaults(el.category, person?.retirementAge ?? AHV_REFERENCE_AGE, {
|
|
||||||
...(el.retirementDecision ?? {}),
|
|
||||||
...(draft[el.id] ?? {}),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const patch = (elementId: string, p: Partial<RetirementDecision>) =>
|
|
||||||
setDraft((d) => ({ ...d, [elementId]: { ...(d[elementId] ?? {}), ...p } }));
|
|
||||||
|
|
||||||
const dirty = Object.keys(draft).length > 0;
|
|
||||||
|
|
||||||
async function save() {
|
|
||||||
setSaving(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
for (const [elementId, changes] of Object.entries(draft)) {
|
|
||||||
const el = plan.elements.find((x) => x.id === elementId);
|
|
||||||
if (!el) continue;
|
|
||||||
await api.put(`/api/elements/${elementId}/retirement`, {
|
|
||||||
...(el.retirementDecision ?? {}),
|
|
||||||
...changes,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
setDraft({});
|
|
||||||
toast("success", "Pensionierung gespeichert.");
|
|
||||||
onSaved();
|
|
||||||
} catch (e) {
|
|
||||||
setError(e instanceof Error ? e.message : "Speichern fehlgeschlagen.");
|
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-5">
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<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 && (
|
|
||||||
<div className="sticky bottom-0 flex items-center gap-3 border-t border-border bg-surface px-1 py-3">
|
|
||||||
<Button disabled={saving} onClick={save}>
|
|
||||||
{saving ? "…" : "Änderungen speichern"}
|
|
||||||
</Button>
|
|
||||||
<Button variant="ghost" disabled={saving} onClick={() => setDraft({})}>
|
|
||||||
Verwerfen
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- ein Block je Person -------------------------------------------------------------------
|
|
||||||
|
|
||||||
function PersonBlock({
|
|
||||||
plan,
|
|
||||||
computed,
|
|
||||||
role,
|
|
||||||
summary,
|
|
||||||
effective,
|
|
||||||
patch,
|
|
||||||
onSaved,
|
|
||||||
}: {
|
|
||||||
plan: PlanInput;
|
|
||||||
computed: PlanComputed;
|
|
||||||
role: PersonRole;
|
|
||||||
summary: RetirementPersonSummary | undefined;
|
|
||||||
effective: (el: ElementInput) => RetirementDecision;
|
|
||||||
patch: (elementId: string, p: Partial<RetirementDecision>) => void;
|
|
||||||
onSaved: () => void;
|
|
||||||
}) {
|
|
||||||
const person = plan.persons.find((p) => p.role === role)!;
|
|
||||||
const label = ownerLabel(plan.persons, role);
|
|
||||||
const own = (cat: string) => plan.elements.filter((e) => e.category === cat && e.ownerRole === role);
|
|
||||||
const ahvEl = own("AHV")[0];
|
|
||||||
const pkEls = own("PENSION_FUND");
|
|
||||||
const a3Els = own("PILLAR_3A");
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="rounded-2xl border border-border">
|
|
||||||
<header className="border-b border-border px-4 py-3">
|
|
||||||
<div className="flex flex-wrap items-baseline justify-between gap-2">
|
|
||||||
<h3 className="text-base font-semibold text-fg">
|
|
||||||
{label} · Pensionierung mit {person.retirementAge}
|
|
||||||
</h3>
|
|
||||||
{person.retirementAge < PK_MIN_RETIREMENT_AGE && (
|
|
||||||
<span className="flex items-center gap-1 text-xs text-attention-fg">
|
|
||||||
<AlertTriangle className="h-3.5 w-3.5" />
|
|
||||||
Vor {PK_MIN_RETIREMENT_AGE} lässt kaum eine Pensionskasse eine Pensionierung zu.
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div className="px-4 py-4">
|
|
||||||
<RetirementAdjuster plan={plan} onSaved={onSaved} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<GapBox plan={plan} computed={computed} summary={summary} />
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-2 px-4 pb-4">
|
|
||||||
{[ahvEl, ...pkEls, ...a3Els].filter(Boolean).map((el) => (
|
|
||||||
<RetirementFields key={el!.id} plan={plan} el={el!} rd={effective(el!)} patch={patch} summary={summary} />
|
|
||||||
))}
|
|
||||||
{!ahvEl && pkEls.length === 0 && a3Els.length === 0 && (
|
|
||||||
<p className="rounded-lg border border-dashed border-border bg-surface-2 p-3 text-sm text-muted">
|
|
||||||
Für {label} sind noch keine Vorsorge-Elemente erfasst. Lege in der Matrix AHV, Pensionskasse oder Säule 3a
|
|
||||||
an – die Entscheide dazu erscheinen dann hier.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Leitzahlen ----------------------------------------------------------------------------
|
|
||||||
|
|
||||||
function GapBox({
|
|
||||||
plan,
|
|
||||||
computed,
|
|
||||||
summary,
|
|
||||||
}: {
|
|
||||||
plan: PlanInput;
|
|
||||||
computed: PlanComputed;
|
|
||||||
summary: RetirementPersonSummary | undefined;
|
|
||||||
}) {
|
|
||||||
const r = computed.retirement;
|
|
||||||
const startYear = plan.startYear ?? null;
|
|
||||||
const horizon = summary?.planningHorizonAge ?? null;
|
|
||||||
const reachesEnd = computed.ruinAge === null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mx-4 mb-4 rounded-xl border border-border bg-surface-2 p-4">
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
|
||||||
<div className="flex flex-col gap-1 text-sm">
|
|
||||||
<Line label="AHV-Rente" value={summary?.ahvAnnual ?? 0} note={summary?.ahvDraw} />
|
|
||||||
<Line label="PK-Rente" value={summary?.pkPensionAnnual ?? 0} />
|
|
||||||
<div className="my-1 border-t border-border" />
|
|
||||||
<Line label="Renteneinkommen (Haushalt)" value={r.pensionIncome ?? 0} strong />
|
|
||||||
<Line label="Ausgaben (Haushalt)" value={r.expenses ?? 0} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col justify-center gap-3">
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center text-xs uppercase tracking-wide text-faint">
|
|
||||||
Rentenlücke
|
|
||||||
<InfoBubble text="Renteneinkommen minus Ausgaben im ersten Jahr, in dem niemand mehr arbeitet. Ein negativer Wert ist normal – er wird aus dem Vermögen gedeckt. Entscheidend ist, wie lange das trägt." />
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className={`text-2xl font-semibold tabular-nums ${
|
|
||||||
(r.gapAnnual ?? 0) < 0 ? "text-danger" : "text-success"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{r.gapAnnual === null ? "–" : `${formatChf(r.gapAnnual)} / Jahr`}
|
|
||||||
</div>
|
|
||||||
{r.firstRetirementYear !== null && startYear && (
|
|
||||||
<div className="text-xs text-faint">
|
|
||||||
gerechnet für {startYear + r.firstRetirementYear - 1}, das erste voll pensionierte Jahr
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-xs uppercase tracking-wide text-faint">Vermögen reicht</div>
|
|
||||||
<div className={`text-lg font-semibold ${reachesEnd ? "text-success" : "text-danger"}`}>
|
|
||||||
{reachesEnd
|
|
||||||
? horizon
|
|
||||||
? `bis zum Horizont (Alter ${horizon})`
|
|
||||||
: "über die ganze Planung"
|
|
||||||
: `bis Alter ${computed.ruinAge}`}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{summary && summary.capitalAtRetirement > 0 && (
|
|
||||||
<div className="text-xs text-muted">
|
|
||||||
Einmalig verfügbar: <strong className="text-fg">{formatChf(summary.capitalAtRetirement)}</strong> netto
|
|
||||||
aus PK und Säule 3a
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Line({ label, value, note, strong }: { label: string; value: number; note?: string; strong?: boolean }) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-baseline justify-between gap-3">
|
|
||||||
<span className={strong ? "font-semibold text-fg" : "text-muted"}>
|
|
||||||
{label}
|
|
||||||
{note && <span className="ml-1 text-xs text-faint">({note})</span>}
|
|
||||||
</span>
|
|
||||||
<span className={`tabular-nums ${strong ? "font-semibold text-fg" : "text-fg"}`}>{formatChf(value)}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
+10
-20
@@ -25,7 +25,6 @@ function plan(over: Partial<PlanInput> = {}): PlanInput {
|
|||||||
inflationRateDefault: 0,
|
inflationRateDefault: 0,
|
||||||
initialCash: 0,
|
initialCash: 0,
|
||||||
startYear: 2026,
|
startYear: 2026,
|
||||||
planningHorizonYears: null,
|
|
||||||
persons: [{ id: "A", role: "PERSON_A", name: null, age: 40, retirementAge: 65 }],
|
persons: [{ id: "A", role: "PERSON_A", name: null, age: 40, retirementAge: 65 }],
|
||||||
phases: [],
|
phases: [],
|
||||||
elements: [],
|
elements: [],
|
||||||
@@ -47,7 +46,7 @@ function el(category: string, baseData: PhaseData = {}, phaseValues: Record<stri
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("Assistenten-Fortschritt", () => {
|
describe("Assistenten-Fortschritt", () => {
|
||||||
it("startet mit sieben offenen Schritten", () => {
|
it("startet mit lauter offenen Schritten", () => {
|
||||||
expect(emptyProgress()).toHaveLength(ASSISTANT_STEP_COUNT);
|
expect(emptyProgress()).toHaveLength(ASSISTANT_STEP_COUNT);
|
||||||
expect(emptyProgress().some(Boolean)).toBe(false);
|
expect(emptyProgress().some(Boolean)).toBe(false);
|
||||||
expect(ASSISTANT_STEPS).toHaveLength(ASSISTANT_STEP_COUNT);
|
expect(ASSISTANT_STEPS).toHaveLength(ASSISTANT_STEP_COUNT);
|
||||||
@@ -57,9 +56,10 @@ describe("Assistenten-Fortschritt", () => {
|
|||||||
// Der Fortschritt liegt als JSON in der Datenbank -- ein alter oder manipulierter Stand
|
// Der Fortschritt liegt als JSON in der Datenbank -- ein alter oder manipulierter Stand
|
||||||
// darf die Ansicht nicht zerlegen.
|
// darf die Ansicht nicht zerlegen.
|
||||||
expect(normalizeProgress(null)).toEqual(emptyProgress());
|
expect(normalizeProgress(null)).toEqual(emptyProgress());
|
||||||
expect(normalizeProgress([true, false])).toEqual(emptyProgress()); // falsche Länge
|
expect(normalizeProgress([true])).toEqual(emptyProgress()); // falsche Länge
|
||||||
|
expect(normalizeProgress([true, false, true])).toEqual(emptyProgress()); // ebenso
|
||||||
expect(normalizeProgress("kaputt")).toEqual(emptyProgress());
|
expect(normalizeProgress("kaputt")).toEqual(emptyProgress());
|
||||||
const gut = [true, false, true, false, true, false, true];
|
const gut = [true, false];
|
||||||
expect(normalizeProgress(gut)).toEqual(gut);
|
expect(normalizeProgress(gut)).toEqual(gut);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -73,36 +73,26 @@ describe("Abgeleiteter Stand neben dem Haken", () => {
|
|||||||
expect(stepStatus(plan({ elements: [el("INCOME"), el("EXPENSE")] }), 0)).toBe("2 Elemente");
|
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", () => {
|
it("zählt die Lebensphasen -- auch wenn es keine gibt", () => {
|
||||||
expect(stepStatus(plan(), 2)).toBe("0 Lebensphasen");
|
expect(stepStatus(plan(), 1)).toBe("0 Lebensphasen");
|
||||||
expect(
|
expect(
|
||||||
stepStatus(
|
stepStatus(
|
||||||
plan({ phases: [{ id: "p1", sequenceNumber: 1, name: "p1", durationYears: 5, cashTransition: {} }] }),
|
plan({ phases: [{ id: "p1", sequenceNumber: 1, name: "p1", durationYears: 5, cashTransition: {} }] }),
|
||||||
2
|
1
|
||||||
)
|
)
|
||||||
).toBe("1 Lebensphase");
|
).toBe("1 Lebensphase");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Voraussetzungen je Schritt", () => {
|
describe("Voraussetzungen je Schritt", () => {
|
||||||
it("sperrt die Phasenplanung, solange der Horizont fehlt", () => {
|
it("sperrt die Lebensphase, solange keine Elemente erfasst sind", () => {
|
||||||
expect(stepBlockedReason(plan(), 2)).toContain("Planungshorizont");
|
// Eine Phase ohne Elemente waere ein leerer Zeitraum -- es gaebe nichts zu planen.
|
||||||
expect(stepBlockedReason(plan({ planningHorizonYears: 40 }), 2)).toBeNull();
|
expect(stepBlockedReason(plan(), 1)).toContain("finanziellen Elemente");
|
||||||
});
|
expect(stepBlockedReason(plan({ elements: [el("INCOME")] }), 1)).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", () => {
|
it("lässt die Bestandsaufnahme immer zu -- sie braucht keine Zeitachse", () => {
|
||||||
expect(stepBlockedReason(plan(), 0)).toBeNull();
|
expect(stepBlockedReason(plan(), 0)).toBeNull();
|
||||||
expect(stepBlockedReason(plan(), 1)).toBeNull();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+19
-62
@@ -1,4 +1,4 @@
|
|||||||
// Der FPT-Assistent: sieben Schritte von der leeren Matrix zum fertigen Plan.
|
// Der FPT-Assistent: der Weg von der leeren Matrix zum Plan.
|
||||||
//
|
//
|
||||||
// Er ersetzt die frueheren "Naechsten Schritte". Der Unterschied ist nicht kosmetisch: Die
|
// 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
|
// alte Karte leitete AB, was zu tun waere; hier fuehrt jeder Schritt sein eigenes Werkzeug
|
||||||
@@ -8,11 +8,15 @@
|
|||||||
// Damit der Assistent trotzdem nichts Falsches behauptet, steht neben jedem Haken der
|
// Damit der Assistent trotzdem nichts Falsches behauptet, steht neben jedem Haken der
|
||||||
// ABGELEITETE Stand ("0 Lebensphasen"). Ein abgehakter Schritt ohne Substanz faellt so auf,
|
// ABGELEITETE Stand ("0 Lebensphasen"). Ein abgehakter Schritt ohne Substanz faellt so auf,
|
||||||
// ohne dass das Tool den Haken verweigert.
|
// ohne dass das Tool den Haken verweigert.
|
||||||
|
//
|
||||||
|
// Stand 0.38: Der Assistent fuehrt bis zur ERSTEN Lebensphase. Alles danach (weitere Phasen,
|
||||||
|
// Uebergaenge, Pensionierung) wird als eigener Ablauf nachgezogen -- lieber zwei Schritte,
|
||||||
|
// die tragen, als sieben, die nur behaupten zu fuehren.
|
||||||
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import type { PlanInput } from "@/lib/types";
|
import type { PlanInput } from "@/lib/types";
|
||||||
|
|
||||||
export const ASSISTANT_STEP_COUNT = 7;
|
export const ASSISTANT_STEP_COUNT = 2;
|
||||||
|
|
||||||
export type AssistantProgress = boolean[];
|
export type AssistantProgress = boolean[];
|
||||||
|
|
||||||
@@ -47,52 +51,17 @@ export const ASSISTANT_STEPS: AssistantStep[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
index: 1,
|
index: 1,
|
||||||
title: "Eckdaten und Pensionsplanung",
|
title: "Erste Lebensphase",
|
||||||
short: "Wann was passiert",
|
short: "Die ersten Jahre",
|
||||||
lead: "Wie weit du planst – und wann du aufhörst zu arbeiten und deine Renten beziehst.",
|
lead: "Wie lange die erste Phase dauert, womit du in dieser Zeit rechnest – und wohin dein Sparbetrag fliesst.",
|
||||||
guided: true,
|
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 -----------------------------------------------------------
|
// --- Abgeleiteter Stand je Schritt -----------------------------------------------------------
|
||||||
//
|
//
|
||||||
// Bewusst NICHT zum Erzwingen des Hakens, sondern als Gegenprobe daneben. Wer "Lebensphasen"
|
// Bewusst NICHT zum Erzwingen des Hakens, sondern als Gegenprobe daneben. Wer "Erste
|
||||||
// abhakt, ohne eine anzulegen, sieht "0 Lebensphasen" -- das reicht.
|
// Lebensphase" abhakt, ohne eine anzulegen, sieht "0 Lebensphasen" -- das reicht.
|
||||||
export function stepStatus(plan: PlanInput, index: number): string {
|
export function stepStatus(plan: PlanInput, index: number): string {
|
||||||
const phases = plan.phases.length;
|
const phases = plan.phases.length;
|
||||||
const elements = plan.elements.length;
|
const elements = plan.elements.length;
|
||||||
@@ -101,32 +70,20 @@ export function stepStatus(plan: PlanInput, index: number): string {
|
|||||||
return elements === 0
|
return elements === 0
|
||||||
? "noch nichts erfasst"
|
? "noch nichts erfasst"
|
||||||
: `${elements} ${elements === 1 ? "Element" : "Elemente"}`;
|
: `${elements} ${elements === 1 ? "Element" : "Elemente"}`;
|
||||||
case 1: {
|
case 1:
|
||||||
const geplant = plan.elements.filter(
|
if (phases === 0) return "0 Lebensphasen";
|
||||||
(e) => e.category === "AHV" && e.retirementDecision?.confirmed === true
|
return `${phases} ${phases === 1 ? "Lebensphase" : "Lebensphasen"}`;
|
||||||
).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:
|
default:
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ein Schritt lässt sich erst sinnvoll öffnen, wenn seine Voraussetzung erfüllt ist. Der Haken
|
// Ein Schritt laesst sich erst sinnvoll oeffnen, wenn seine Voraussetzung erfuellt ist. Der
|
||||||
// bleibt trotzdem jederzeit setzbar -- gesperrt wird nur das Werkzeug, nicht die Selbstauskunft.
|
// Haken bleibt trotzdem jederzeit setzbar -- gesperrt wird nur das Werkzeug, nicht die
|
||||||
|
// Selbstauskunft.
|
||||||
export function stepBlockedReason(plan: PlanInput, index: number): string | null {
|
export function stepBlockedReason(plan: PlanInput, index: number): string | null {
|
||||||
const hasHorizon = typeof plan.planningHorizonYears === "number" && plan.planningHorizonYears > 0;
|
if (index === 1 && plan.elements.length === 0) {
|
||||||
if (index === 2 && !hasHorizon) {
|
return "Erfasse zuerst deine finanziellen Elemente – ohne sie gibt es in einer Lebensphase nichts zu planen.";
|
||||||
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1989,8 +1989,8 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
|
|||||||
personId: p.id,
|
personId: p.id,
|
||||||
role: p.role,
|
role: p.role,
|
||||||
retirementAge: retirementAge.get(p.id) ?? AHV_REFERENCE_AGE,
|
retirementAge: retirementAge.get(p.id) ?? AHV_REFERENCE_AGE,
|
||||||
// Alter am Planende -- abgeleitet aus dem Horizont, nicht erfasst.
|
// Alter am Planende -- abgeleitet aus der Summe der Lebensphasen.
|
||||||
planningHorizonAge: typeof plan.planningHorizonYears === "number" ? p.age + plan.planningHorizonYears : null,
|
planningHorizonAge: totalYears > 0 ? p.age + totalYears : null,
|
||||||
ahvAnnual: Math.round(ahvFinalByPerson.get(p.id) ?? 0),
|
ahvAnnual: Math.round(ahvFinalByPerson.get(p.id) ?? 0),
|
||||||
ahvFromAge: Math.round(ahvStartAge(rd)),
|
ahvFromAge: Math.round(ahvStartAge(rd)),
|
||||||
ahvDraw: ahvDrawLabel(rd),
|
ahvDraw: ahvDrawLabel(rd),
|
||||||
|
|||||||
@@ -144,7 +144,6 @@ export function computeScenarioDiff(scenario: PlanInput, base: PlanInput | null)
|
|||||||
d.profileChanged =
|
d.profileChanged =
|
||||||
scenario.householdType !== base.householdType ||
|
scenario.householdType !== base.householdType ||
|
||||||
scenario.inflationRateDefault !== base.inflationRateDefault ||
|
scenario.inflationRateDefault !== base.inflationRateDefault ||
|
||||||
(scenario.planningHorizonYears ?? null) !== (base.planningHorizonYears ?? null) ||
|
|
||||||
scenario.persons.map(personKey).sort().join(";") !== base.persons.map(personKey).sort().join(";");
|
scenario.persons.map(personKey).sort().join(";") !== base.persons.map(personKey).sort().join(";");
|
||||||
|
|
||||||
d.total =
|
d.total =
|
||||||
|
|||||||
@@ -77,7 +77,6 @@ describe("Datenbank-Migrationen", () => {
|
|||||||
expect(personCols).not.toContain("age");
|
expect(personCols).not.toContain("age");
|
||||||
|
|
||||||
const scenCols2 = await cols("Scenario");
|
const scenCols2 = await cols("Scenario");
|
||||||
expect(scenCols2).toContain("planningHorizonYears");
|
|
||||||
expect(scenCols2).toContain("assistantProgress");
|
expect(scenCols2).toContain("assistantProgress");
|
||||||
|
|
||||||
// Der Pensionierungs-Entscheid hängt am ELEMENT und bewusst an keiner Phase (0.34) --
|
// Der Pensionierungs-Entscheid hängt am ELEMENT und bewusst an keiner Phase (0.34) --
|
||||||
|
|||||||
@@ -51,7 +51,6 @@ export function toPlanInput(plan: PlanWithRelations): PlanInput {
|
|||||||
inflationRateDefault: plan.inflationRateDefault,
|
inflationRateDefault: plan.inflationRateDefault,
|
||||||
initialCash: plan.initialCash,
|
initialCash: plan.initialCash,
|
||||||
startYear: plan.plan.startYear,
|
startYear: plan.plan.startYear,
|
||||||
planningHorizonYears: plan.planningHorizonYears,
|
|
||||||
assistantProgress: normalizeProgress(plan.assistantProgress),
|
assistantProgress: normalizeProgress(plan.assistantProgress),
|
||||||
// Name und Alter vom Plan, Pensionsalter vom Szenario. Fehlt zu einer Rolle die
|
// Name und Alter vom Plan, Pensionsalter vom Szenario. Fehlt zu einer Rolle die
|
||||||
// Plan-Person, greift ein Notbehelf -- die Berechnung darf daran nicht scheitern.
|
// Plan-Person, greift ein Notbehelf -- die Berechnung darf daran nicht scheitern.
|
||||||
|
|||||||
@@ -64,7 +64,6 @@ function plan(opts: {
|
|||||||
durationYears: p.durationYears,
|
durationYears: p.durationYears,
|
||||||
cashTransition: p.cashTransition ?? {},
|
cashTransition: p.cashTransition ?? {},
|
||||||
})),
|
})),
|
||||||
planningHorizonYears: opts.horizon ?? null,
|
|
||||||
elements: opts.elements,
|
elements: opts.elements,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-35
@@ -53,13 +53,14 @@ export function sortedPhases(plan: PlanInput) {
|
|||||||
// konnten dadurch unbemerkt verschieden weit rechnen -- und waren dann nicht vergleichbar,
|
// konnten dadurch unbemerkt verschieden weit rechnen -- und waren dann nicht vergleichbar,
|
||||||
// obwohl genau das ihr Zweck ist.
|
// obwohl genau das ihr Zweck ist.
|
||||||
//
|
//
|
||||||
// Seit 0.36 ist der Horizont eine Zahl in JAHREN am Szenario. Vorher stand er als Endalter je
|
// Seit 0.38 wird der Horizont ABGELEITET: Er ist die Summe der Lebensphasen. Zwei Anlaeufe
|
||||||
// PERSON -- bei einem Paar zwei Zahlen, die auseinanderlaufen konnten und aus denen sich das
|
// davor -- Endalter je Person (0.35), dann eine eigene Jahreszahl am Szenario (0.36) -- waren
|
||||||
// Planende erst per Maximum ergab. Eine Laufzeit ist eine Eigenschaft der PLANUNG, nicht der
|
// beide eine zweite Wahrheit ueber dieselbe Sache. Wer die Phasen einzeln plant, hat den
|
||||||
// Person; die Endalter sind die Ableitung davon, nicht umgekehrt.
|
// Horizont damit bereits bestimmt; ihn zusaetzlich zu erfassen hiess nur, zwei Zahlen
|
||||||
|
// synchron halten zu muessen.
|
||||||
export function planEndYear(plan: PlanInput): number | null {
|
export function planEndYear(plan: PlanInput): number | null {
|
||||||
const y = plan.planningHorizonYears;
|
const total = plan.phases.reduce((n, p) => n + Math.max(0, p.durationYears), 0);
|
||||||
return typeof y === "number" && y > 0 ? y : null;
|
return total > 0 ? total : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Alter, das eine Person am Ende der Planung erreicht. Read-only-Anzeige neben dem Horizont.
|
// Alter, das eine Person am Ende der Planung erreicht. Read-only-Anzeige neben dem Horizont.
|
||||||
@@ -69,35 +70,6 @@ export function endAgeOf(plan: PlanInput, role: PersonRole): number | null {
|
|||||||
return years !== null && person ? person.age + years : null;
|
return years !== null && person ? person.age + years : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HorizonChange {
|
|
||||||
lastPhaseId: string;
|
|
||||||
oldDuration: number;
|
|
||||||
newDuration: number;
|
|
||||||
blocked: string | 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 before = phases.slice(0, -1).reduce((s, p) => s + p.durationYears, 0);
|
|
||||||
const newDuration = horizonYears - before;
|
|
||||||
const change: HorizonChange = {
|
|
||||||
lastPhaseId: last.id,
|
|
||||||
oldDuration: last.durationYears,
|
|
||||||
newDuration,
|
|
||||||
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 oder verlängere den Horizont.";
|
|
||||||
}
|
|
||||||
return change;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Analysiert für JEDE Person, ob und wie weit sich ihr Pensionsalter verschieben lässt.
|
// Analysiert für JEDE Person, ob und wie weit sich ihr Pensionsalter verschieben lässt.
|
||||||
export function retirementBoundaries(plan: PlanInput): RetirementBoundary[] {
|
export function retirementBoundaries(plan: PlanInput): RetirementBoundary[] {
|
||||||
const phases = sortedPhases(plan);
|
const phases = sortedPhases(plan);
|
||||||
|
|||||||
@@ -83,9 +83,6 @@ export interface PlanInput {
|
|||||||
// Kalenderjahr des Planbeginns (Jahr 1) -- nur für die Darstellung, nicht für die
|
// Kalenderjahr des Planbeginns (Jahr 1) -- nur für die Darstellung, nicht für die
|
||||||
// Berechnung. Optional, damit Berechnungs-Fixtures es nicht setzen müssen.
|
// Berechnung. Optional, damit Berechnungs-Fixtures es nicht setzen müssen.
|
||||||
startYear?: number | null;
|
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).
|
// Fortschritt des FPT-Assistenten (sieben Schritte).
|
||||||
assistantProgress?: AssistantProgress | null;
|
assistantProgress?: AssistantProgress | null;
|
||||||
persons: PersonInput[];
|
persons: PersonInput[];
|
||||||
|
|||||||
Reference in New Issue
Block a user