Bestandsaufnahme: Kategorienamen, Sammel-Speichern, Matrix-Spalte Start
Deploy App / deploy (push) Successful in 1m39s
Deploy App / deploy (push) Successful in 1m39s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+17
-5
@@ -4,10 +4,10 @@
|
|||||||
| | |
|
| | |
|
||||||
|---|---|
|
|---|---|
|
||||||
| **Dokument** | Funktionale und Technische Spezifikation FPT |
|
| **Dokument** | Funktionale und Technische Spezifikation FPT |
|
||||||
| **Version** | 0.36 |
|
| **Version** | 0.37 |
|
||||||
| **Datum** | 2026-07-25 |
|
| **Datum** | 2026-07-25 |
|
||||||
| **Status** | Lebendes Dokument |
|
| **Status** | Lebendes Dokument |
|
||||||
| **Codestand** | Arbeitsstand nach `c440063` inkl. Assistent als Herzstück (Branch `main`) |
|
| **Codestand** | Arbeitsstand nach `42d5585` inkl. Nachbesserungen zu Schritt 1 (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.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). |
|
||||||
| 0.34 | 2026-07-25 | Claude (Opus 5) | **Modul-Review 4, Nachbesserungen: die Übergangs-Entscheide bis ans Ende durchgezogen.** (1) **Zuordnung überall dort, wo Elemente über ihren Namen angeboten werden.** Zwei Personen nennen ihre Guthaben typischerweise gleich («Säule 3a», «ETF»); ohne die Person wählt man im Dropdown blind. Betroffen waren das **Ziel der Anlage-Quote** beim Kapitalbezug (dort mit hoher Folgewirkung: Ein Fehlgriff leitet das Alterskapital in das Depot der falschen Person) und die Zeilen im Dialog **«Kapital verteilen»**. Die Klartext-Zuordnung liegt neu als `ownerLabel` in `src/lib/elements.ts` und wird von allen drei Stellen genutzt. (2) **Herkunft des umgeleiteten Alterskapitals wird ausgewiesen.** Fliessen PK **und** 3a in dasselbe Vermögens-Element, stand dort bisher nur eine Summe – ob wirklich beide angekommen sind, liess sich nicht prüfen. `Carry` und `ElementPhaseComputed` führen neu `capitalInSources` bzw. `capitalFromTransferSources` mit: Betrag **je Quelle**, benannt mit Element **und** Person. Sichtbar am Ziel-Element und im Dialog «Kapital verteilen». Das Feld heisst neu **«Zusatzinvestition aus Kapitalbezug»** (vorher «Davon aus Kapitalbezug (PK/3a)» – irreführend, weil es kein Anteil an der manuell erfassten Zusatzinvestition ist, sondern ein zweiter, davon unabhängiger Betrag). (3) **Der Dialog «Kapital verteilen» zeigt das bereits Zugeteilte.** Vorher stand dort eine **0**, obwohl die Quote geflossen war – das Feld führt nur den manuell erfassten Teil. Neu erscheint darüber eine read-only Zeile mit dem aus dem Bezugs-Entscheid stammenden Betrag samt Aufschlüsselung, darunter das editierbare Feld und die Summe beider. (4) **Bezogene Vorsorge-Guthaben werden in beiden Verteil-Dialogen nicht mehr angeboten.** Nach der Pensionierung ignoriert die Rechnung Beiträge und Zusatzeinlagen in PK und Säule 3a – die Dialoge boten sie trotzdem an, inklusive eines aus der Vorphase geerbten 3a-Beitrags, der dort als aktive Rate erschien. Der Filter prüfte nur den `status` (`ACTIVE`), und der bleibt nach dem Bezug bestehen. Neu setzt die Rechnung selbst das Kennzeichen `acceptsCapital: false`; die Dialoge lesen es, statt die Regel ein zweites Mal nachzubauen. 4 Tests ergänzt (288 → 292). |
|
| 0.34 | 2026-07-25 | Claude (Opus 5) | **Modul-Review 4, Nachbesserungen: die Übergangs-Entscheide bis ans Ende durchgezogen.** (1) **Zuordnung überall dort, wo Elemente über ihren Namen angeboten werden.** Zwei Personen nennen ihre Guthaben typischerweise gleich («Säule 3a», «ETF»); ohne die Person wählt man im Dropdown blind. Betroffen waren das **Ziel der Anlage-Quote** beim Kapitalbezug (dort mit hoher Folgewirkung: Ein Fehlgriff leitet das Alterskapital in das Depot der falschen Person) und die Zeilen im Dialog **«Kapital verteilen»**. Die Klartext-Zuordnung liegt neu als `ownerLabel` in `src/lib/elements.ts` und wird von allen drei Stellen genutzt. (2) **Herkunft des umgeleiteten Alterskapitals wird ausgewiesen.** Fliessen PK **und** 3a in dasselbe Vermögens-Element, stand dort bisher nur eine Summe – ob wirklich beide angekommen sind, liess sich nicht prüfen. `Carry` und `ElementPhaseComputed` führen neu `capitalInSources` bzw. `capitalFromTransferSources` mit: Betrag **je Quelle**, benannt mit Element **und** Person. Sichtbar am Ziel-Element und im Dialog «Kapital verteilen». Das Feld heisst neu **«Zusatzinvestition aus Kapitalbezug»** (vorher «Davon aus Kapitalbezug (PK/3a)» – irreführend, weil es kein Anteil an der manuell erfassten Zusatzinvestition ist, sondern ein zweiter, davon unabhängiger Betrag). (3) **Der Dialog «Kapital verteilen» zeigt das bereits Zugeteilte.** Vorher stand dort eine **0**, obwohl die Quote geflossen war – das Feld führt nur den manuell erfassten Teil. Neu erscheint darüber eine read-only Zeile mit dem aus dem Bezugs-Entscheid stammenden Betrag samt Aufschlüsselung, darunter das editierbare Feld und die Summe beider. (4) **Bezogene Vorsorge-Guthaben werden in beiden Verteil-Dialogen nicht mehr angeboten.** Nach der Pensionierung ignoriert die Rechnung Beiträge und Zusatzeinlagen in PK und Säule 3a – die Dialoge boten sie trotzdem an, inklusive eines aus der Vorphase geerbten 3a-Beitrags, der dort als aktive Rate erschien. Der Filter prüfte nur den `status` (`ACTIVE`), und der bleibt nach dem Bezug bestehen. Neu setzt die Rechnung selbst das Kennzeichen `acceptsCapital: false`; die Dialoge lesen es, statt die Regel ein zweites Mal nachzubauen. 4 Tests ergänzt (288 → 292). |
|
||||||
@@ -2170,8 +2171,19 @@ erben können, und fiel auf 0. Die Stammdaten sind jetzt die **Wurzel** dieser K
|
|||||||
eigener Phasenwert -> aus der Vorphase geerbt -> Stammdaten -> 0
|
eigener Phasenwert -> aus der Vorphase geerbt -> Stammdaten -> 0
|
||||||
```
|
```
|
||||||
|
|
||||||
In der Matrix erscheinen Elemente ohne Lebensphasen mit ihren Stammdaten; Endwerte gibt es
|
**Die Matrix erscheint, sobald es Elemente gibt** -- Lebensphasen sind dafür nicht nötig. Ihre
|
||||||
erst, wenn eine Phase eine Dauer vorgibt.
|
erste Spalte heisst **«Start»** und zeigt den Bestand bei Planbeginn; Endwerte gibt es erst,
|
||||||
|
wenn eine Phase eine Dauer vorgibt. Solange keine Phasen bestehen, steht rechts davon der
|
||||||
|
Hinweis auf den nächsten Schritt.
|
||||||
|
|
||||||
|
Die Spalte ist auch mit Phasen dauerhaft sichtbar, und zwar aus einem zweiten Grund: Sie ist
|
||||||
|
der **einzige Ort, an dem ein Startwert änderbar ist**. Bearbeitete man ihn stattdessen in
|
||||||
|
Phase 1, entstünde dort ein Phasenwert, der die Stammdaten überdeckt -- dieselbe Zahl an zwei
|
||||||
|
Orten, genau das Muster, das dieser Umbau beseitigt hat.
|
||||||
|
|
||||||
|
> **0.36 hielt das nicht.** Die Stammdaten waren eingeführt, die Matrix hing aber weiterhin
|
||||||
|
> vollständig an den Phasen -- die Bestandsaufnahme verschwand nach dem Speichern im Nichts.
|
||||||
|
> Behoben in 0.37.
|
||||||
|
|
||||||
### 3.14.4 Fixpunkte: jeder Bezugsbeginn erzwingt eine Phasengrenze
|
### 3.14.4 Fixpunkte: jeder Bezugsbeginn erzwingt eine Phasengrenze
|
||||||
|
|
||||||
@@ -4306,7 +4318,7 @@ Include `src/**/*.test.ts`). Es gibt **keine** Komponenten-, API- oder E2E-Tests
|
|||||||
| `migrations.test.ts` | 3 | spielt alle Migrationen gegen echtes PostgreSQL (PGlite) ein; prüft zusätzlich die V7-**Datenübernahme** (Basisszenario gewinnt, Pensionsalter bleiben szenario-eigen) |
|
| `migrations.test.ts` | 3 | spielt alle Migrationen gegen echtes PostgreSQL (PGlite) ein; prüft zusätzlich die V7-**Datenübernahme** (Basisszenario gewinnt, Pensionsalter bleiben szenario-eigen) |
|
||||||
| `rate-limit.test.ts` | 6 | Fixed-Window: erlaubt bis Limit, blockt danach, startet nach Fensterablauf neu, trennt je Schlüssel; Client-IP aus X-Forwarded-For / X-Real-IP |
|
| `rate-limit.test.ts` | 6 | Fixed-Window: erlaubt bis Limit, blockt danach, startet nach Fensterablauf neu, trennt je Schlüssel; Client-IP aus X-Forwarded-For / X-Real-IP |
|
||||||
| `csv.test.ts` | 7 | BOM, alle vier Blöcke, jedes Element als Zeile, Beginn-/Ende-/Übergangsspalten, Entscheid im Klartext, ein Eintrag je Planjahr, Maskierung von `;` und `"` |
|
| `csv.test.ts` | 7 | BOM, alle vier Blöcke, jedes Element als Zeile, Beginn-/Ende-/Übergangsspalten, Entscheid im Klartext, ein Eintrag je Planjahr, Maskierung von `;` und `"` |
|
||||||
| **Total** | **334** | |
|
| **Total** | **336** | |
|
||||||
|
|
||||||
## 8.2 Testfälle
|
## 8.2 Testfälle
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { ArrowLeft, ArrowRight, Check, Lock } from "lucide-react";
|
import { ArrowLeft, ArrowRight, Check, Lock } from "lucide-react";
|
||||||
import { Button, Modal } from "@/components/ui";
|
import { Button, Modal, useConfirm } from "@/components/ui";
|
||||||
import { ASSISTANT_STEPS, stepBlockedReason } from "@/lib/assistant";
|
import { ASSISTANT_STEPS, stepBlockedReason } from "@/lib/assistant";
|
||||||
import type { PlanInput } from "@/lib/types";
|
import type { PlanInput } from "@/lib/types";
|
||||||
|
|
||||||
@@ -188,6 +188,12 @@ export function AssistantStepDialog({
|
|||||||
isDone,
|
isDone,
|
||||||
onToggleDone,
|
onToggleDone,
|
||||||
onClose,
|
onClose,
|
||||||
|
// Gibt es ungespeicherte Eingaben? Der Dialog fragt dann beim Schliessen nach, statt sie
|
||||||
|
// still zu verwerfen -- ohne Speichern-Knopf je Feld wäre das sonst die einzige Stelle,
|
||||||
|
// an der Arbeit verloren gehen kann.
|
||||||
|
dirty = false,
|
||||||
|
// Wird beim Abschliessen aufgerufen und schreibt, was das Werkzeug gesammelt hat.
|
||||||
|
onCommit,
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
plan: PlanInput;
|
plan: PlanInput;
|
||||||
@@ -195,6 +201,8 @@ export function AssistantStepDialog({
|
|||||||
isDone: boolean;
|
isDone: boolean;
|
||||||
onToggleDone: (done: boolean) => void;
|
onToggleDone: (done: boolean) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
dirty?: boolean;
|
||||||
|
onCommit?: () => Promise<void>;
|
||||||
// Das Werkzeug des Schritts. Fehlt es, ist der Schritt reine Information.
|
// Das Werkzeug des Schritts. Fehlt es, ist der Schritt reine Information.
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
@@ -202,30 +210,57 @@ export function AssistantStepDialog({
|
|||||||
const explainer = STEP_EXPLAINERS[stepIndex];
|
const explainer = STEP_EXPLAINERS[stepIndex];
|
||||||
const blocked = stepBlockedReason(plan, stepIndex);
|
const blocked = stepBlockedReason(plan, stepIndex);
|
||||||
const [showTool, setShowTool] = useState(false);
|
const [showTool, setShowTool] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const confirm = useConfirm();
|
||||||
|
|
||||||
const canGuide = step.guided && !blocked && !!children;
|
const canGuide = step.guided && !blocked && !!children;
|
||||||
|
|
||||||
|
// Schliessen mit ungespeicherten Eingaben: nachfragen statt still verwerfen.
|
||||||
|
async function closeGuarded() {
|
||||||
|
if (!dirty) return onClose();
|
||||||
|
const ok = await confirm({
|
||||||
|
title: "Eingaben verwerfen?",
|
||||||
|
message:
|
||||||
|
"Du hast Angaben erfasst, die noch nicht gespeichert sind. Schliesst du jetzt, gehen sie verloren. " +
|
||||||
|
"Mit «Schritt abschliessen» werden sie übernommen.",
|
||||||
|
confirmLabel: "Verwerfen",
|
||||||
|
danger: true,
|
||||||
|
});
|
||||||
|
if (ok) onClose();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function finish() {
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
if (onCommit) await onCommit();
|
||||||
|
onToggleDone(true);
|
||||||
|
onClose();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "Speichern fehlgeschlagen.");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
title={`Schritt ${stepIndex + 1}: ${step.title}`}
|
title={`Schritt ${stepIndex + 1}: ${step.title}`}
|
||||||
subtitle={showTool ? undefined : step.lead}
|
subtitle={showTool ? undefined : step.lead}
|
||||||
onClose={onClose}
|
onClose={closeGuarded}
|
||||||
xwide
|
xwide
|
||||||
>
|
>
|
||||||
{showTool ? (
|
{showTool ? (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
{children}
|
{children}
|
||||||
|
{error && <p className="text-sm text-danger">{error}</p>}
|
||||||
<div className="flex items-center justify-between border-t border-border pt-3">
|
<div className="flex items-center justify-between border-t border-border pt-3">
|
||||||
<Button variant="ghost" onClick={() => setShowTool(false)}>
|
<Button variant="ghost" onClick={() => setShowTool(false)}>
|
||||||
<ArrowLeft className="h-4 w-4" /> Zurück zur Erklärung
|
<ArrowLeft className="h-4 w-4" /> Zurück zur Erklärung
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button disabled={saving} onClick={finish}>
|
||||||
onClick={() => {
|
<Check className="h-4 w-4" /> {saving ? "Wird gespeichert…" : "Schritt abschliessen"}
|
||||||
onToggleDone(true);
|
|
||||||
onClose();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Check className="h-4 w-4" /> Schritt abschliessen
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -270,7 +305,7 @@ export function AssistantStepDialog({
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onToggleDone(!isDone);
|
onToggleDone(!isDone);
|
||||||
onClose();
|
void closeGuarded();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{isDone ? "Wieder als offen markieren" : "Selbst erledigen"}
|
{isDone ? "Wieder als offen markieren" : "Selbst erledigen"}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
// 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, Check, Plus, Trash2 } from "lucide-react";
|
import { AlertTriangle, ArrowLeft, ArrowRight, 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";
|
||||||
@@ -19,7 +19,6 @@ import {
|
|||||||
DEFAULT_PLANNING_HORIZON_YEARS,
|
DEFAULT_PLANNING_HORIZON_YEARS,
|
||||||
MAX_PLANNING_HORIZON_YEARS,
|
MAX_PLANNING_HORIZON_YEARS,
|
||||||
MIN_PLANNING_HORIZON_YEARS,
|
MIN_PLANNING_HORIZON_YEARS,
|
||||||
PILLAR_3A_MAX_ANNUAL,
|
|
||||||
} from "@/lib/constants";
|
} from "@/lib/constants";
|
||||||
import { planSegments, defaultOpenDuration, type PlanSegment } from "@/lib/phaseplan";
|
import { planSegments, defaultOpenDuration, type PlanSegment } from "@/lib/phaseplan";
|
||||||
import { fixpointPersonsOf, type PlanComputed } from "@/lib/calculations";
|
import { fixpointPersonsOf, type PlanComputed } from "@/lib/calculations";
|
||||||
@@ -38,7 +37,13 @@ type Scope = "HOUSEHOLD" | "PERSON_A" | "PERSON_B";
|
|||||||
interface Kind {
|
interface Kind {
|
||||||
key: string;
|
key: string;
|
||||||
category: string;
|
category: string;
|
||||||
|
// Beschriftung des Knopfs. Bewusst der KATEGORIENAME und keine erfundene Umgangssprache:
|
||||||
|
// Die Matrix, die Rechenwege und der PDF-Bericht sprechen dieselbe Sprache -- wer hier eine
|
||||||
|
// andere Vokabel lernt, sucht sie spaeter vergeblich. Die Alltagssprache steht in `hint`.
|
||||||
label: string;
|
label: string;
|
||||||
|
// Vorschlag fuer den Namen des angelegten Elements. Hier darf es konkret werden: Die
|
||||||
|
// Kategorie ist "Ausgaben", die Zeile heisst "Lebenshaltung".
|
||||||
|
defaultName: string;
|
||||||
hint: string;
|
hint: string;
|
||||||
personOnly?: boolean;
|
personOnly?: boolean;
|
||||||
fields: (v: PhaseData, set: (p: Partial<PhaseData>) => void, plan: PlanInput) => React.ReactNode;
|
fields: (v: PhaseData, set: (p: Partial<PhaseData>) => void, plan: PlanInput) => React.ReactNode;
|
||||||
@@ -48,8 +53,9 @@ const KINDS: Kind[] = [
|
|||||||
{
|
{
|
||||||
key: "income",
|
key: "income",
|
||||||
category: "INCOME",
|
category: "INCOME",
|
||||||
label: "Erwerbseinkommen",
|
label: "Einkommen",
|
||||||
hint: "Dein Nettolohn – also das, was auf dem Konto ankommt.",
|
defaultName: "Lohn",
|
||||||
|
hint: "Dein Nettolohn – das, was auf dem Konto ankommt.",
|
||||||
personOnly: true,
|
personOnly: true,
|
||||||
fields: (v, set) => (
|
fields: (v, set) => (
|
||||||
<>
|
<>
|
||||||
@@ -72,7 +78,8 @@ const KINDS: Kind[] = [
|
|||||||
{
|
{
|
||||||
key: "expense",
|
key: "expense",
|
||||||
category: "EXPENSE",
|
category: "EXPENSE",
|
||||||
label: "Lebenshaltung",
|
label: "Ausgaben",
|
||||||
|
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) => (
|
fields: (v, set) => (
|
||||||
<MoneyField
|
<MoneyField
|
||||||
@@ -87,6 +94,7 @@ const KINDS: Kind[] = [
|
|||||||
key: "pk",
|
key: "pk",
|
||||||
category: "PENSION_FUND",
|
category: "PENSION_FUND",
|
||||||
label: "Pensionskasse",
|
label: "Pensionskasse",
|
||||||
|
defaultName: "Pensionskasse",
|
||||||
hint: "Dein Altersguthaben laut PK-Ausweis.",
|
hint: "Dein Altersguthaben laut PK-Ausweis.",
|
||||||
personOnly: true,
|
personOnly: true,
|
||||||
fields: (v, set) => (
|
fields: (v, set) => (
|
||||||
@@ -111,6 +119,7 @@ const KINDS: Kind[] = [
|
|||||||
key: "p3a",
|
key: "p3a",
|
||||||
category: "PILLAR_3A",
|
category: "PILLAR_3A",
|
||||||
label: "Säule 3a",
|
label: "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) => (
|
fields: (v, set) => (
|
||||||
@@ -133,7 +142,8 @@ const KINDS: Kind[] = [
|
|||||||
{
|
{
|
||||||
key: "etf",
|
key: "etf",
|
||||||
category: "OTHER_ASSET",
|
category: "OTHER_ASSET",
|
||||||
label: "Wertschriften und Sparguthaben",
|
label: "Sonstiges Vermögen",
|
||||||
|
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) => (
|
fields: (v, set) => (
|
||||||
<>
|
<>
|
||||||
@@ -151,7 +161,8 @@ const KINDS: Kind[] = [
|
|||||||
{
|
{
|
||||||
key: "re",
|
key: "re",
|
||||||
category: "REAL_ESTATE",
|
category: "REAL_ESTATE",
|
||||||
label: "Wohneigentum",
|
label: "Immobilie",
|
||||||
|
defaultName: "Wohneigentum",
|
||||||
hint: "Haus oder Wohnung samt Hypothek.",
|
hint: "Haus oder Wohnung samt Hypothek.",
|
||||||
fields: (v, set) => (
|
fields: (v, set) => (
|
||||||
<>
|
<>
|
||||||
@@ -197,7 +208,8 @@ const KINDS: Kind[] = [
|
|||||||
{
|
{
|
||||||
key: "debt",
|
key: "debt",
|
||||||
category: "OTHER_DEBT",
|
category: "OTHER_DEBT",
|
||||||
label: "Schulden",
|
label: "Sonstige Schulden",
|
||||||
|
defaultName: "Schulden",
|
||||||
hint: "Kredite ausserhalb der Hypothek.",
|
hint: "Kredite ausserhalb der Hypothek.",
|
||||||
fields: (v, set) => (
|
fields: (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 })} />
|
||||||
@@ -205,12 +217,71 @@ const KINDS: Kind[] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export function Step1Elements({ plan, onChanged }: { plan: PlanInput; onChanged: () => void }) {
|
// Der Entwurf des GANZEN Schritts, nicht der einzelnen Karte.
|
||||||
|
//
|
||||||
|
// Zwei Gruende dafuer, dass er hier oben liegt und nicht in der Karte: Beim Wechsel des
|
||||||
|
// Reiters verschwinden die Karten des vorigen Bereichs vom Bildschirm -- mit ihrem eigenen
|
||||||
|
// Zustand waeren alle getippten Zahlen still weg. Und die Summe "Vermoegen heute" laesst sich
|
||||||
|
// nur berechnen, wenn alle Entwuerfe an EINEM Ort liegen.
|
||||||
|
export interface Step1Draft {
|
||||||
|
base: Record<string, PhaseData>;
|
||||||
|
names: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function emptyStep1Draft(): Step1Draft {
|
||||||
|
return { base: {}, names: {} };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bestand eines Elements aus Stammdaten -- dieselbe Rechnung wie in der Kachel «Kennzahlen»,
|
||||||
|
// hier nur auf dem Entwurf statt auf dem gespeicherten Stand.
|
||||||
|
function wealthOf(category: string, d: PhaseData): number {
|
||||||
|
if (category === "OTHER_ASSET") return num(d.startValue);
|
||||||
|
if (category === "PENSION_FUND" || category === "PILLAR_3A") return num(d.currentValue);
|
||||||
|
if (category === "REAL_ESTATE") return num(d.purchasePrice) - num(d.mortgage);
|
||||||
|
if (category === "OTHER_DEBT") return -num(d.startValue);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Die Stammdaten-Felder einer Kategorie. Eigene Komponente, weil sie an ZWEI Orten
|
||||||
|
// erscheinen: im Assistenten-Schritt 1 (als Liste) und in der Matrix-Spalte «Start» (einzeln).
|
||||||
|
// Zwei Implementierungen derselben Felder liefen garantiert auseinander.
|
||||||
|
export function BaseFields({
|
||||||
|
category,
|
||||||
|
values,
|
||||||
|
set,
|
||||||
|
plan,
|
||||||
|
}: {
|
||||||
|
category: string;
|
||||||
|
values: PhaseData;
|
||||||
|
set: (patch: Partial<PhaseData>) => void;
|
||||||
|
plan: PlanInput;
|
||||||
|
}) {
|
||||||
|
const kind = KINDS.find((k) => k.category === category);
|
||||||
|
return <>{kind?.fields(values, set, plan)}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Step1Elements({
|
||||||
|
plan,
|
||||||
|
draft,
|
||||||
|
setDraft,
|
||||||
|
onChanged,
|
||||||
|
}: {
|
||||||
|
plan: PlanInput;
|
||||||
|
draft: Step1Draft;
|
||||||
|
setDraft: (fn: (d: Step1Draft) => Step1Draft) => void;
|
||||||
|
onChanged: () => void;
|
||||||
|
}) {
|
||||||
const scopes: Scope[] =
|
const scopes: Scope[] =
|
||||||
plan.householdType === "COUPLE" ? ["HOUSEHOLD", "PERSON_A", "PERSON_B"] : ["PERSON_A"];
|
plan.householdType === "COUPLE" ? ["HOUSEHOLD", "PERSON_A", "PERSON_B"] : ["PERSON_A"];
|
||||||
const [page, setPage] = useState(0);
|
const [page, setPage] = useState(0);
|
||||||
const scope = scopes[page];
|
const scope = scopes[page];
|
||||||
|
|
||||||
|
// Live-Summe aus dem ENTWURF: Sie soll sich beim Tippen bewegen, nicht erst beim Speichern.
|
||||||
|
const today = plan.elements.reduce(
|
||||||
|
(sum, e) => sum + wealthOf(e.category, draft.base[e.id] ?? e.baseData ?? {}),
|
||||||
|
plan.initialCash || 0
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
{scopes.length > 1 && (
|
{scopes.length > 1 && (
|
||||||
@@ -230,22 +301,14 @@ export function Step1Elements({ plan, onChanged }: { plan: PlanInput; onChanged:
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<ScopeElements plan={plan} scope={scope} onChanged={onChanged} />
|
<ScopeElements plan={plan} scope={scope} draft={draft} setDraft={setDraft} onChanged={onChanged} />
|
||||||
|
|
||||||
{scopes.length > 1 && (
|
{/* Die einzige Zahl, die schon vor jeder Zeitplanung etwas aussagt -- und die erste
|
||||||
<div className="flex justify-between">
|
Rueckmeldung auf die Bestandsaufnahme. */}
|
||||||
<Button variant="ghost" disabled={page === 0} onClick={() => setPage((p) => p - 1)}>
|
<div className="sticky bottom-0 flex items-baseline justify-between gap-3 border-t border-border bg-surface px-1 pt-3">
|
||||||
<ArrowLeft className="h-4 w-4" /> Zurück
|
<span className="text-xs font-semibold uppercase tracking-wide text-faint">Vermögen heute</span>
|
||||||
</Button>
|
<span className="text-lg font-semibold tabular-nums text-fg">{formatChf(today)}</span>
|
||||||
<Button
|
</div>
|
||||||
variant="secondary"
|
|
||||||
disabled={page === scopes.length - 1}
|
|
||||||
onClick={() => setPage((p) => p + 1)}
|
|
||||||
>
|
|
||||||
Weiter <ArrowRight className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -253,10 +316,14 @@ export function Step1Elements({ plan, onChanged }: { plan: PlanInput; onChanged:
|
|||||||
function ScopeElements({
|
function ScopeElements({
|
||||||
plan,
|
plan,
|
||||||
scope,
|
scope,
|
||||||
|
draft,
|
||||||
|
setDraft,
|
||||||
onChanged,
|
onChanged,
|
||||||
}: {
|
}: {
|
||||||
plan: PlanInput;
|
plan: PlanInput;
|
||||||
scope: Scope;
|
scope: Scope;
|
||||||
|
draft: Step1Draft;
|
||||||
|
setDraft: (fn: (d: Step1Draft) => Step1Draft) => void;
|
||||||
onChanged: () => void;
|
onChanged: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [busy, setBusy] = useState<string | null>(null);
|
const [busy, setBusy] = useState<string | null>(null);
|
||||||
@@ -264,14 +331,17 @@ function ScopeElements({
|
|||||||
const kinds = KINDS.filter((k) => (scope === "HOUSEHOLD" ? !k.personOnly : true));
|
const kinds = KINDS.filter((k) => (scope === "HOUSEHOLD" ? !k.personOnly : true));
|
||||||
const mine = plan.elements.filter((e) => (e.ownerRole ?? "HOUSEHOLD") === scope);
|
const mine = plan.elements.filter((e) => (e.ownerRole ?? "HOUSEHOLD") === scope);
|
||||||
|
|
||||||
|
// Das Element entsteht SOFORT -- es braucht eine Id, an der die Werte haengen. Nur die
|
||||||
|
// Zahlen werden bis zum Abschluss des Schritts zurueckgehalten.
|
||||||
async function add(kind: Kind) {
|
async function add(kind: Kind) {
|
||||||
setBusy(kind.key);
|
setBusy(kind.key);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const label = plan.householdType === "COUPLE" && scope !== "HOUSEHOLD" ? ` ${ownerLabel(plan.persons, scope)}` : "";
|
const suffix =
|
||||||
|
plan.householdType === "COUPLE" && scope !== "HOUSEHOLD" ? ` ${ownerLabel(plan.persons, scope)}` : "";
|
||||||
await api.post(`/api/scenarios/${plan.id}/elements`, {
|
await api.post(`/api/scenarios/${plan.id}/elements`, {
|
||||||
category: kind.category,
|
category: kind.category,
|
||||||
name: `${kind.label}${label}`,
|
name: `${kind.defaultName}${suffix}`,
|
||||||
ownerRole: scope,
|
ownerRole: scope,
|
||||||
});
|
});
|
||||||
onChanged();
|
onChanged();
|
||||||
@@ -284,16 +354,22 @@ function ScopeElements({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<div className="flex flex-wrap gap-2">
|
{/* Der Knopf traegt den Kategorienamen, die Erklaerung steht darunter -- so lernt man
|
||||||
|
beim ersten Kontakt die Vokabel, die auch die Matrix verwendet. */}
|
||||||
|
<div className="grid gap-1.5 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{kinds.map((k) => (
|
{kinds.map((k) => (
|
||||||
<button
|
<button
|
||||||
key={k.key}
|
key={k.key}
|
||||||
type="button"
|
type="button"
|
||||||
disabled={busy === k.key}
|
disabled={busy === k.key}
|
||||||
onClick={() => add(k)}
|
onClick={() => add(k)}
|
||||||
className="flex items-center gap-1.5 rounded-lg border border-dashed border-border px-2.5 py-1.5 text-xs font-medium text-muted transition-colors hover:border-accent hover:text-accent-soft-fg"
|
className="flex items-start gap-1.5 rounded-lg border border-dashed border-border px-2.5 py-1.5 text-left transition-colors hover:border-accent hover:bg-accent-soft/20"
|
||||||
>
|
>
|
||||||
<Plus className="h-3.5 w-3.5" /> {k.label}
|
<Plus className="mt-0.5 h-3.5 w-3.5 shrink-0 text-accent" />
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="block text-xs font-semibold text-fg">{k.label}</span>
|
||||||
|
<span className="block text-[11px] leading-snug text-muted">{k.hint}</span>
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -305,7 +381,14 @@ function ScopeElements({
|
|||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
{mine.map((el) => (
|
{mine.map((el) => (
|
||||||
<BaseDataCard key={el.id} plan={plan} elementId={el.id} onChanged={onChanged} />
|
<BaseDataCard
|
||||||
|
key={el.id}
|
||||||
|
plan={plan}
|
||||||
|
elementId={el.id}
|
||||||
|
draft={draft}
|
||||||
|
setDraft={setDraft}
|
||||||
|
onChanged={onChanged}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -317,43 +400,36 @@ function ScopeElements({
|
|||||||
function BaseDataCard({
|
function BaseDataCard({
|
||||||
plan,
|
plan,
|
||||||
elementId,
|
elementId,
|
||||||
|
draft,
|
||||||
|
setDraft,
|
||||||
onChanged,
|
onChanged,
|
||||||
}: {
|
}: {
|
||||||
plan: PlanInput;
|
plan: PlanInput;
|
||||||
elementId: string;
|
elementId: string;
|
||||||
|
draft: Step1Draft;
|
||||||
|
setDraft: (fn: (d: Step1Draft) => Step1Draft) => void;
|
||||||
onChanged: () => void;
|
onChanged: () => void;
|
||||||
}) {
|
}) {
|
||||||
const el = plan.elements.find((x) => x.id === elementId)!;
|
const el = plan.elements.find((x) => x.id === elementId)!;
|
||||||
const kind = KINDS.find((k) => k.category === el.category);
|
const kind = KINDS.find((k) => k.category === el.category);
|
||||||
const [draft, setDraft] = useState<PhaseData>(el.baseData ?? {});
|
const values = draft.base[el.id] ?? el.baseData ?? {};
|
||||||
const [saving, setSaving] = useState(false);
|
const name = draft.names[el.id] ?? el.name;
|
||||||
const [saved, setSaved] = useState(false);
|
|
||||||
const [name, setName] = useState(el.name);
|
|
||||||
|
|
||||||
async function save() {
|
const set = (patch: Partial<PhaseData>) =>
|
||||||
setSaving(true);
|
setDraft((d) => ({ ...d, base: { ...d.base, [el.id]: { ...(d.base[el.id] ?? el.baseData ?? {}), ...patch } } }));
|
||||||
try {
|
|
||||||
if (name.trim() && name.trim() !== el.name) {
|
|
||||||
await api.patch(`/api/elements/${el.id}`, { name: name.trim() });
|
|
||||||
}
|
|
||||||
await api.put(`/api/elements/${el.id}/base`, draft);
|
|
||||||
setSaved(true);
|
|
||||||
onChanged();
|
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function remove() {
|
async function remove() {
|
||||||
await api.delete(`/api/elements/${el.id}`);
|
await api.delete(`/api/elements/${el.id}`);
|
||||||
|
setDraft((d) => {
|
||||||
|
const base = { ...d.base };
|
||||||
|
const names = { ...d.names };
|
||||||
|
delete base[el.id];
|
||||||
|
delete names[el.id];
|
||||||
|
return { base, names };
|
||||||
|
});
|
||||||
onChanged();
|
onChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
const set = (p: Partial<PhaseData>) => {
|
|
||||||
setDraft((d) => ({ ...d, ...p }));
|
|
||||||
setSaved(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border border-border p-3">
|
<div className="rounded-xl border border-border p-3">
|
||||||
<div className="mb-2 flex items-center gap-2">
|
<div className="mb-2 flex items-center gap-2">
|
||||||
@@ -371,26 +447,31 @@ function BaseDataCard({
|
|||||||
</div>
|
</div>
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
<div className="sm:col-span-2">
|
<div className="sm:col-span-2">
|
||||||
<TextField label="Bezeichnung" value={name} onChange={setName} />
|
<TextField
|
||||||
|
label="Bezeichnung"
|
||||||
|
value={name}
|
||||||
|
onChange={(v) => setDraft((d) => ({ ...d, names: { ...d.names, [el.id]: v } }))}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
{kind?.fields(draft, set, plan)}
|
{kind?.fields(values, set, plan)}
|
||||||
{el.category === "PILLAR_3A" && (
|
|
||||||
<p className="sm:col-span-2 text-xs text-faint">
|
|
||||||
Hinweis: Der jährliche Höchstbetrag liegt bei {formatChf(PILLAR_3A_MAX_ANNUAL)}. Wie viel du einzahlst,
|
|
||||||
legst du in Schritt 4 fest.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="mt-3 flex items-center gap-2">
|
|
||||||
<Button size="sm" disabled={saving} onClick={save}>
|
|
||||||
{saving ? "…" : saved ? "Gespeichert" : "Speichern"}
|
|
||||||
</Button>
|
|
||||||
{saved && <Check className="h-4 w-4 text-success" />}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Schreibt den gesammelten Entwurf. Wird vom Schritt-Dialog beim Abschliessen aufgerufen --
|
||||||
|
// es gibt bewusst keinen Speichern-Knopf je Karte mehr.
|
||||||
|
export async function saveStep1Draft(plan: PlanInput, draft: Step1Draft): Promise<void> {
|
||||||
|
for (const el of plan.elements) {
|
||||||
|
const nextName = draft.names[el.id]?.trim();
|
||||||
|
if (nextName && nextName !== el.name) {
|
||||||
|
await api.patch(`/api/elements/${el.id}`, { name: nextName });
|
||||||
|
}
|
||||||
|
const base = draft.base[el.id];
|
||||||
|
if (base) await api.put(`/api/elements/${el.id}/base`, base);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================================
|
// ============================================================================================
|
||||||
// Schritt 2: Eckdaten und Pensionsplanung
|
// Schritt 2: Eckdaten und Pensionsplanung
|
||||||
// ============================================================================================
|
// ============================================================================================
|
||||||
@@ -435,14 +516,8 @@ export function Step2Timeline({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex justify-between">
|
{/* Kein Zurueck/Weiter: Die Reiter oben sind der Weg durch die Bereiche. Zwei
|
||||||
<Button variant="ghost" disabled={page === 0} onClick={() => setPage((p) => p - 1)}>
|
Navigationen fuer dieselbe Bewegung sind eine zu viel. */}
|
||||||
<ArrowLeft className="h-4 w-4" /> Zurück
|
|
||||||
</Button>
|
|
||||||
<Button variant="secondary" disabled={page === pages.length - 1} onClick={() => setPage((p) => p + 1)}>
|
|
||||||
Weiter <ArrowRight className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+204
-9
@@ -13,6 +13,7 @@ import {
|
|||||||
Pencil,
|
Pencil,
|
||||||
Maximize2,
|
Maximize2,
|
||||||
PiggyBank,
|
PiggyBank,
|
||||||
|
Sparkles,
|
||||||
Copy,
|
Copy,
|
||||||
HelpCircle,
|
HelpCircle,
|
||||||
History as HistoryIcon,
|
History as HistoryIcon,
|
||||||
@@ -50,6 +51,10 @@ import {
|
|||||||
Step2Timeline,
|
Step2Timeline,
|
||||||
Step3Phases,
|
Step3Phases,
|
||||||
StepWalkthrough,
|
StepWalkthrough,
|
||||||
|
BaseFields,
|
||||||
|
emptyStep1Draft,
|
||||||
|
saveStep1Draft,
|
||||||
|
type Step1Draft,
|
||||||
} from "@/components/AssistantSteps";
|
} from "@/components/AssistantSteps";
|
||||||
import { normalizeProgress } from "@/lib/assistant";
|
import { normalizeProgress } from "@/lib/assistant";
|
||||||
import { RetirementPanel } from "@/components/RetirementPanel";
|
import { RetirementPanel } from "@/components/RetirementPanel";
|
||||||
@@ -125,6 +130,8 @@ type Panel =
|
|||||||
| { kind: "transition"; elementId: string; fromPhaseId: string }
|
| { kind: "transition"; elementId: string; fromPhaseId: string }
|
||||||
| { kind: "cashTransition"; fromPhaseId: string }
|
| { kind: "cashTransition"; fromPhaseId: string }
|
||||||
| { kind: "cashInitial" }
|
| { kind: "cashInitial" }
|
||||||
|
// Stammdaten eines Elements: der Bestand bei Planbeginn (Spalte «Start»).
|
||||||
|
| { kind: "base"; elementId: string }
|
||||||
| { kind: "profile" };
|
| { kind: "profile" };
|
||||||
|
|
||||||
export function PlanView({
|
export function PlanView({
|
||||||
@@ -204,6 +211,10 @@ export function PlanView({
|
|||||||
const [valueMode, setValueMode] = useState<ValueMode>("nominal");
|
const [valueMode, setValueMode] = useState<ValueMode>("nominal");
|
||||||
// Offener Assistenten-Schritt (Index) bzw. offene Pensionsplanung (Rolle).
|
// Offener Assistenten-Schritt (Index) bzw. offene Pensionsplanung (Rolle).
|
||||||
const [assistantStep, setAssistantStep] = useState<number | null>(null);
|
const [assistantStep, setAssistantStep] = useState<number | null>(null);
|
||||||
|
// Der Entwurf der Bestandsaufnahme. Er liegt bewusst HIER und nicht in den einzelnen
|
||||||
|
// 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.
|
||||||
|
const [step1Draft, setStep1Draft] = useState<Step1Draft>(emptyStep1Draft);
|
||||||
const [retirementRole, setRetirementRole] = useState<PersonRole | null>(null);
|
const [retirementRole, setRetirementRole] = useState<PersonRole | null>(null);
|
||||||
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
|
||||||
@@ -432,14 +443,14 @@ export function PlanView({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!hasPhases && (
|
{!hasPhases && plan.elements.length === 0 && (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={<Plus className="h-6 w-6" />}
|
icon={<Plus className="h-6 w-6" />}
|
||||||
title="Noch keine Lebensphasen"
|
title="Noch nichts erfasst"
|
||||||
text="Deine Planung besteht aus Lebensphasen – Abschnitten, in denen deine Finanzen ähnlich laufen (z. B. «bis zur Pension»). Leg die erste an, danach kommen die finanziellen Elemente."
|
text="Fang mit dem an, was du heute hast – Einkommen, Ausgaben, Guthaben, Immobilien. Die Lebensphasen kommen danach; sie ergeben sich aus deiner Pensionsplanung."
|
||||||
>
|
>
|
||||||
<Button onClick={() => setShowAddPhase(true)}>
|
<Button onClick={() => setAssistantStep(0)}>
|
||||||
<Plus className="h-4 w-4" /> Erste Lebensphase
|
<Sparkles className="h-4 w-4" /> Mit dem Assistenten starten
|
||||||
</Button>
|
</Button>
|
||||||
</EmptyState>
|
</EmptyState>
|
||||||
)}
|
)}
|
||||||
@@ -463,8 +474,12 @@ export function PlanView({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Matrix: eigener Scrollbereich, damit Phasen-Köpfe (oben) UND Elementnamen (links)
|
{/* Matrix: eigener Scrollbereich, damit Phasen-Köpfe (oben) UND Elementnamen (links)
|
||||||
beim Scrollen sichtbar bleiben. */}
|
beim Scrollen sichtbar bleiben.
|
||||||
{hasPhases && (
|
|
||||||
|
Sie erscheint, sobald es ELEMENTE gibt -- Lebensphasen sind dafür nicht nötig. Bis
|
||||||
|
0.36 hing sie an den Phasen, wodurch die Bestandsaufnahme aus Schritt 1 im
|
||||||
|
Nichts verschwand: erfasst, gespeichert, unsichtbar. */}
|
||||||
|
{(hasPhases || plan.elements.length > 0) && (
|
||||||
<div data-tour="matrix" className="max-h-[75vh] overflow-auto rounded-xl border border-border bg-surface shadow-sm">
|
<div data-tour="matrix" className="max-h-[75vh] overflow-auto rounded-xl border border-border bg-surface shadow-sm">
|
||||||
{/* Feste Spaltenbreiten: Alle Phasenspalten sind gleich breit -- bei einer einzigen
|
{/* Feste Spaltenbreiten: Alle Phasenspalten sind gleich breit -- bei einer einzigen
|
||||||
Phase bleibt die Tabelle dadurch schmal, bei vielen wird horizontal gescrollt.
|
Phase bleibt die Tabelle dadurch schmal, bei vielen wird horizontal gescrollt.
|
||||||
@@ -557,6 +572,17 @@ export function PlanView({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</th>
|
</th>
|
||||||
|
{/* Die STAMMDATEN-Spalte. Sie zeigt den Bestand bei Planbeginn -- die einzige
|
||||||
|
Grösse, die keiner Phase gehört (SPEZIFIKATION 3.14.3). Ohne sie gäbe es
|
||||||
|
keinen Ort, an dem sich ein Startwert ändern liesse, sobald Phasen
|
||||||
|
existieren: Man bearbeitete ihn in Phase 1 und schriebe dabei still einen
|
||||||
|
Phasenwert, der die Stammdaten überdeckt. */}
|
||||||
|
<th className="sticky top-0 z-30 w-28 min-w-28 border-b border-r border-border bg-surface-2 px-2 py-2 text-center align-top">
|
||||||
|
<div className="text-xs font-semibold text-fg">Start</div>
|
||||||
|
<div className="text-[10px] font-normal text-faint">
|
||||||
|
{plan.startYear ? plan.startYear : "bei Planbeginn"}
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
{columns.map((col) =>
|
{columns.map((col) =>
|
||||||
col.kind === "phase" ? (
|
col.kind === "phase" ? (
|
||||||
<PhaseHeader
|
<PhaseHeader
|
||||||
@@ -586,6 +612,18 @@ export function PlanView({
|
|||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
|
{!hasPhases && (
|
||||||
|
<th className="sticky top-0 z-30 border-b border-border bg-surface-2 px-3 py-2 text-left align-top">
|
||||||
|
<div className="text-xs font-semibold text-fg">Noch keine Lebensphasen</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setAssistantStep(1)}
|
||||||
|
className="mt-1 rounded-lg border border-dashed border-accent px-2 py-1 text-[11px] font-normal text-accent-soft-fg hover:bg-accent-soft/30"
|
||||||
|
>
|
||||||
|
Weiter mit der Pensionsplanung
|
||||||
|
</button>
|
||||||
|
</th>
|
||||||
|
)}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -597,6 +635,16 @@ export function PlanView({
|
|||||||
</span>
|
</span>
|
||||||
<span className="text-[10px] text-faint">verfügbares Kapital</span>
|
<span className="text-[10px] text-faint">verfügbares Kapital</span>
|
||||||
</td>
|
</td>
|
||||||
|
<td
|
||||||
|
onClick={() => setPanel({ kind: "cashInitial" })}
|
||||||
|
title="Cash-Anfangswert bearbeiten"
|
||||||
|
className={`cursor-pointer border-b border-r border-border px-2 py-1.5 text-center text-xs text-fg hover:bg-accent-soft ${
|
||||||
|
diff?.cashInitialChanged ? "bg-diff-soft ring-1 ring-inset ring-diff/40" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{formatChf(Math.round(plan.initialCash || 0))}
|
||||||
|
</td>
|
||||||
|
{!hasPhases && <td className="border-b border-border" />}
|
||||||
{columns.map((col) => {
|
{columns.map((col) => {
|
||||||
const isFirst = col.kind === "phase" && col.phase.sequenceNumber === 1;
|
const isFirst = col.kind === "phase" && col.phase.sequenceNumber === 1;
|
||||||
return col.kind === "phase" ? (
|
return col.kind === "phase" ? (
|
||||||
@@ -665,6 +713,16 @@ export function PlanView({
|
|||||||
{CATEGORY_LABELS[cat]}
|
{CATEGORY_LABELS[cat]}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
|
{/* Stammdaten-Summe der Kategorie. */}
|
||||||
|
<td className="border-b border-r border-border bg-surface-2 px-2 py-1.5 text-center text-xs font-semibold text-muted">
|
||||||
|
{collapsed
|
||||||
|
? (() => {
|
||||||
|
const sum = els.reduce((n, e) => n + baseAmount(e), 0);
|
||||||
|
return sum !== 0 ? formatChf(sum) : "";
|
||||||
|
})()
|
||||||
|
: ""}
|
||||||
|
</td>
|
||||||
|
{!hasPhases && <td className="border-b border-border bg-surface-2" />}
|
||||||
{/* Zugeklappt: Summe der Elementwerte je Phase -- sonst verschwindet die
|
{/* Zugeklappt: Summe der Elementwerte je Phase -- sonst verschwindet die
|
||||||
Information beim Zuklappen. Aufgeklappt bleibt die Zeile leer, weil
|
Information beim Zuklappen. Aufgeklappt bleibt die Zeile leer, weil
|
||||||
man die Einzelwerte darunter sieht. */}
|
man die Einzelwerte darunter sieht. */}
|
||||||
@@ -757,6 +815,16 @@ export function PlanView({
|
|||||||
className="mt-0.5"
|
className="mt-0.5"
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
|
{/* Stammdaten: der Bestand bei Planbeginn. Ein Klick öffnet dieselbe
|
||||||
|
Bearbeitung wie im Assistenten-Schritt 1. */}
|
||||||
|
<td
|
||||||
|
onClick={() => setPanel({ kind: "base", elementId: el.id })}
|
||||||
|
title="Bestand bei Planbeginn bearbeiten"
|
||||||
|
className="cursor-pointer border-b border-r border-border px-2 py-1.5 text-center text-xs text-fg hover:bg-accent-soft"
|
||||||
|
>
|
||||||
|
{baseCellText(el)}
|
||||||
|
</td>
|
||||||
|
{!hasPhases && <td className="border-b border-border" />}
|
||||||
{columns.map((col) => {
|
{columns.map((col) => {
|
||||||
if (col.kind === "phase") {
|
if (col.kind === "phase") {
|
||||||
const ce = computedElement(col.phase.id, el.id);
|
const ce = computedElement(col.phase.id, el.id);
|
||||||
@@ -873,9 +941,29 @@ export function PlanView({
|
|||||||
await api.post(`/api/scenarios/${plan.id}/assistant`, { step: assistantStep, done });
|
await api.post(`/api/scenarios/${plan.id}/assistant`, { step: assistantStep, done });
|
||||||
onChanged();
|
onChanged();
|
||||||
}}
|
}}
|
||||||
onClose={() => setAssistantStep(null)}
|
onClose={() => {
|
||||||
|
setAssistantStep(null);
|
||||||
|
setStep1Draft(emptyStep1Draft());
|
||||||
|
}}
|
||||||
|
dirty={
|
||||||
|
assistantStep === 0 &&
|
||||||
|
(Object.keys(step1Draft.base).length > 0 || Object.keys(step1Draft.names).length > 0)
|
||||||
|
}
|
||||||
|
onCommit={async () => {
|
||||||
|
if (assistantStep === 0) {
|
||||||
|
await saveStep1Draft(plan, step1Draft);
|
||||||
|
setStep1Draft(emptyStep1Draft());
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{assistantStep === 0 && <Step1Elements plan={plan} onChanged={onChanged} />}
|
{assistantStep === 0 && (
|
||||||
|
<Step1Elements
|
||||||
|
plan={plan}
|
||||||
|
draft={step1Draft}
|
||||||
|
setDraft={(fn) => setStep1Draft((d) => fn(d))}
|
||||||
|
onChanged={onChanged}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{assistantStep === 1 && <Step2Timeline plan={plan} computed={computed} onChanged={onChanged} />}
|
{assistantStep === 1 && <Step2Timeline plan={plan} computed={computed} onChanged={onChanged} />}
|
||||||
{assistantStep === 2 && <Step3Phases plan={plan} onChanged={onChanged} />}
|
{assistantStep === 2 && <Step3Phases plan={plan} onChanged={onChanged} />}
|
||||||
{assistantStep === 3 && (
|
{assistantStep === 3 && (
|
||||||
@@ -1144,6 +1232,16 @@ export function PlanView({
|
|||||||
}
|
}
|
||||||
case "cashInitial":
|
case "cashInitial":
|
||||||
return <CashInitialPanel key="cash-init" plan={plan} onClose={close} onSaved={closeAndRefresh} />;
|
return <CashInitialPanel key="cash-init" plan={plan} onClose={close} onSaved={closeAndRefresh} />;
|
||||||
|
case "base":
|
||||||
|
return (
|
||||||
|
<BaseDataPanel
|
||||||
|
key={`base-${panel.elementId}`}
|
||||||
|
plan={plan}
|
||||||
|
elementId={panel.elementId}
|
||||||
|
onClose={close}
|
||||||
|
onSaved={closeAndRefresh}
|
||||||
|
/>
|
||||||
|
);
|
||||||
case "profile":
|
case "profile":
|
||||||
return <ProfilePanel key="profile" plan={plan} onClose={close} onSaved={closeAndRefresh} />;
|
return <ProfilePanel key="profile" plan={plan} onClose={close} onSaved={closeAndRefresh} />;
|
||||||
}
|
}
|
||||||
@@ -2291,6 +2389,39 @@ function ElementMetaPanel({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Bestand eines Elements laut Stammdaten. Schulden zaehlen negativ, eine Immobilie mit ihrem
|
||||||
|
// Eigenkapital (Kaufpreis minus Hypothek) -- dieselbe Rechnung wie in der Kachel «Kennzahlen».
|
||||||
|
function baseAmount(el: ElementInput): number {
|
||||||
|
const b = el.baseData ?? {};
|
||||||
|
switch (el.category) {
|
||||||
|
case "OTHER_ASSET":
|
||||||
|
return Math.round(num(b.startValue));
|
||||||
|
case "PENSION_FUND":
|
||||||
|
case "PILLAR_3A":
|
||||||
|
return Math.round(num(b.currentValue));
|
||||||
|
case "REAL_ESTATE":
|
||||||
|
return Math.round(num(b.purchasePrice) - num(b.mortgage));
|
||||||
|
case "OTHER_DEBT":
|
||||||
|
return -Math.round(num(b.startValue));
|
||||||
|
case "INCOME":
|
||||||
|
case "EXPENSE":
|
||||||
|
return Math.round(num(b.amount));
|
||||||
|
default:
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Was in der Zelle steht. Einkommen und Ausgaben sind FLUESSE, keine Bestaende -- sie tragen
|
||||||
|
// deshalb den Zusatz «/Jahr», damit die Spalte nicht zwei verschiedene Dinge gleich aussehen
|
||||||
|
// laesst. Die AHV hat bei Planbeginn schlicht nichts.
|
||||||
|
function baseCellText(el: ElementInput): string {
|
||||||
|
if (el.category === "AHV") return "–";
|
||||||
|
const v = baseAmount(el);
|
||||||
|
if (v === 0) return "–";
|
||||||
|
const s = formatChf(v);
|
||||||
|
return el.category === "INCOME" || el.category === "EXPENSE" ? `${s}/J.` : s;
|
||||||
|
}
|
||||||
|
|
||||||
// --- Kacheln des oberen Bereichs -----------------------------------------------------------
|
// --- Kacheln des oberen Bereichs -----------------------------------------------------------
|
||||||
//
|
//
|
||||||
// Vier gleichrangige Blöcke: was gilt (Grundeinstellungen), was herauskommt (Kennzahlen),
|
// Vier gleichrangige Blöcke: was gilt (Grundeinstellungen), was herauskommt (Kennzahlen),
|
||||||
@@ -2484,3 +2615,67 @@ function QuickActionsTile({
|
|||||||
</Tile>
|
</Tile>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Panel: Stammdaten eines Elements (Spalte «Start») -------------------------------------
|
||||||
|
//
|
||||||
|
// Dieselben Felder wie im Assistenten-Schritt 1, nur einzeln statt als Liste. Bewusst EIN
|
||||||
|
// Speicherort: Wer den Startwert stattdessen in Phase 1 bearbeitete, schriebe einen Phasenwert,
|
||||||
|
// der die Stammdaten überdeckt -- dieselbe Zahl an zwei Orten.
|
||||||
|
function BaseDataPanel({
|
||||||
|
plan,
|
||||||
|
elementId,
|
||||||
|
onClose,
|
||||||
|
onSaved,
|
||||||
|
}: {
|
||||||
|
plan: PlanInput;
|
||||||
|
elementId: string;
|
||||||
|
onClose: () => void;
|
||||||
|
onSaved: () => void;
|
||||||
|
}) {
|
||||||
|
const el = plan.elements.find((x) => x.id === elementId);
|
||||||
|
const [draft, setDraft] = useState<PhaseData>(el?.baseData ?? {});
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
if (!el) return null;
|
||||||
|
|
||||||
|
const set = (patch: Partial<PhaseData>) => setDraft((d) => ({ ...d, ...patch }));
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await api.put(`/api/elements/${elementId}/base`, draft);
|
||||||
|
onSaved();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "Speichern fehlgeschlagen.");
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<InspectorShell
|
||||||
|
title={el.name}
|
||||||
|
subtitle={`${CATEGORY_LABELS[el.category]} · Bestand bei Planbeginn${plan.startYear ? " " + plan.startYear : ""}`}
|
||||||
|
onClose={onClose}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<p className="rounded-lg bg-surface-2 px-3 py-2 text-xs text-muted">
|
||||||
|
Diese Werte gehören zum Element, nicht zu einer Lebensphase – sie beschreiben den Stand am Anfang deiner
|
||||||
|
Planung. Abweichende Werte für einzelne Phasen erfasst du in der jeweiligen Zelle.
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
|
<BaseFields category={el.category} values={draft} set={set} plan={plan} />
|
||||||
|
</div>
|
||||||
|
{error && <p className="text-sm text-danger">{error}</p>}
|
||||||
|
<div className="flex gap-2 pt-1">
|
||||||
|
<Button disabled={saving} onClick={save}>
|
||||||
|
{saving ? "…" : "Speichern"}
|
||||||
|
</Button>
|
||||||
|
<Button variant="secondary" onClick={onClose}>
|
||||||
|
Abbrechen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</InspectorShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -143,3 +143,42 @@ describe("Element-Stammdaten (baseData)", () => {
|
|||||||
expect(asset.endValue).toBe(100000);
|
expect(asset.endValue).toBe(100000);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Stammdaten sind ein eigener Speicherort (0.37)", () => {
|
||||||
|
// Der Fund aus dem Memo: Nach der Bestandsaufnahme blieb die Matrix leer. Die Ursache lag
|
||||||
|
// nicht in der Rechnung, sondern in der Darstellung -- die Werte waren da, nur unsichtbar.
|
||||||
|
// Der Test nagelt fest, dass sie ohne jede Lebensphase abrufbar sind.
|
||||||
|
it("liefert Bestände auch ohne Lebensphasen", () => {
|
||||||
|
const p = plan({
|
||||||
|
elements: [
|
||||||
|
el("OTHER_ASSET", { startValue: 120000 }),
|
||||||
|
el("PENSION_FUND", { currentValue: 310000 }),
|
||||||
|
el("OTHER_DEBT", { startValue: 40000 }),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(p.phases).toHaveLength(0);
|
||||||
|
// Genau die Rechnung, die Matrix und Kennzahlen-Kachel anzeigen.
|
||||||
|
const sum = p.elements.reduce((n, e) => {
|
||||||
|
const b = e.baseData ?? {};
|
||||||
|
if (e.category === "OTHER_ASSET") return n + (b.startValue ?? 0);
|
||||||
|
if (e.category === "PENSION_FUND") return n + (b.currentValue ?? 0);
|
||||||
|
if (e.category === "OTHER_DEBT") return n - (b.startValue ?? 0);
|
||||||
|
return n;
|
||||||
|
}, 0);
|
||||||
|
expect(sum).toBe(390000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("bleibt erhalten, wenn später Phasen dazukommen", () => {
|
||||||
|
// Die Stammdaten dürfen von einer Phase nicht überschrieben, sondern nur überlagert
|
||||||
|
// werden -- sonst gäbe es dieselbe Zahl an zwei Orten.
|
||||||
|
const base = el("OTHER_ASSET", { startValue: 120000, expectedReturn: 4 });
|
||||||
|
const p = plan({
|
||||||
|
phases: [{ id: "p1", sequenceNumber: 1, name: "p1", durationYears: 5, cashTransition: {} }],
|
||||||
|
elements: [{ ...base, phaseValues: { p1: {} } }],
|
||||||
|
});
|
||||||
|
const asset = computePlan(p).phases[0].elements.find((e) => e.category === "OTHER_ASSET")!;
|
||||||
|
expect(asset.startValue).toBe(120000);
|
||||||
|
// Und die Stammdaten selbst sind unverändert geblieben.
|
||||||
|
expect(p.elements[0].baseData?.startValue).toBe(120000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user