Modul-Review 4: Matrix -- Kapitalverwendung, Phasendauer, Bedienung
Deploy App / deploy (push) Successful in 1m10s

Kapitalverwendung (Punkt C) am richtigen Ort:
- Prozent-Aufteilung des bezogenen Alterskapitals wandert vom Cash-Uebergang
  zum Bezugs-Entscheid der PK (nur bei Kapitalbezug) bzw. der Saeule 3a --
  mit zwei Guthaben liess sie sich vorher gar nicht getrennt beantworten
- Dialoge fuehren neu brutto -> Steuersatz -> netto -> Verteilung
- BUGFIX: Der zugeteilte Betrag erhoehte still den internen Bestand, deshalb
  zeigten Ziel-Element und "Kapital verteilen" eine 0. Er laeuft jetzt ueber
  Carry.capitalIn als Zusatzeinlage der Folgephase und ist ueberall sichtbar
- Saeule 3a ist am Pensions-Uebergang neu ein offener Entscheid

Phasendauer (gemeldeter Fehler):
- Die Folgephase gleicht eine geaenderte Dauer aus; Gesamtdauer bleibt gleich
- Vorher kappte das Tool nur die bearbeitete Phase -> Phase 2 ueberspannte
  danach die Pensionierung und die Invariante aus Punkt 44 kippte
- Rueckfrage vorher, Blockade wenn die Folgephase unter 1 Jahr fiele
- neue reine Funktion planDurationChange

Bedienung:
- Element-Zeile und Phasenkopf: Stift (umbenennen, beim Element inkl.
  Zuordnung), Papierkorb, Expand -- alle immer sichtbar
- PATCH /api/elements/<id> nimmt neu auch ownerRole
- Hilfetexte via Portal (wurden in scrollenden Dialogen abgeschnitten)
- Verteil-Dialoge: Zuordnung je Zeile, nach vom/ins Cash gruppiert,
  Vorbelegung mit dem EFFEKTIVEN Wert inkl. Vererbung (zeigte vorher 0)
- Matrix: gleiche Spaltenbreiten + horizontales Scrollen, "Alle auf-/
  zuklappen", Kategorie-Summe in der zugeklappten Zeile
- Phasen-Detailansicht nutzt die neue Aufteilungs-Grafik
- Uebersicht: "Leer starten" auch im leeren Zustand

Nebenbei: dritte verstuemmelte Hex-Farbe (#7c3aed) repariert, Phasen-Panel
nutzt den eigenen Bestaetigungs-Dialog statt window.confirm; mehrere veraltete
Referenzen und die buildCarryData-Tabelle in der Spez nachgezogen.

SPEZIFIKATION 0.33. 278 -> 288 Tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-25 23:09:03 +02:00
parent 0761f6b3e2
commit 0953880a80
17 changed files with 1040 additions and 267 deletions
+87 -44
View File
@@ -4,10 +4,10 @@
| | | | | |
|---|---| |---|---|
| **Dokument** | Funktionale und Technische Spezifikation FPT | | **Dokument** | Funktionale und Technische Spezifikation FPT |
| **Version** | 0.32 | | **Version** | 0.33 |
| **Datum** | 2026-07-25 | | **Datum** | 2026-07-25 |
| **Status** | Lebendes Dokument | | **Status** | Lebendes Dokument |
| **Codestand** | Arbeitsstand nach `b42f9b3` inkl. Modul-Review 3 (Nachbesserungen) (Branch `main`) | | **Codestand** | Arbeitsstand nach `0761f6b` inkl. Modul-Review 4 (Matrix) (Branch `main`) |
| **Ersetzt** | `FDD_TDD_FPT.docx` (v1v5) im Ordner `Info Dateien` diese sind ab Version 0.1 dieses Dokuments obsolet | | **Ersetzt** | `FDD_TDD_FPT.docx` (v1v5) im Ordner `Info Dateien` diese sind ab Version 0.1 dieses Dokuments obsolet |
| **Geltungsbereich** | Gesamter Code im Verzeichnis `FPT` | | **Geltungsbereich** | Gesamter Code im Verzeichnis `FPT` |
@@ -17,6 +17,7 @@
| Version | Datum | Autor | Änderung | | Version | Datum | Autor | Änderung |
|---|---|---|---| |---|---|---|---|
| 0.33 | 2026-07-25 | Claude (Opus 5) | **Modul-Review 4 (Matrix: Phasen und Elemente).** (1) **Kapitalverwendung neu am Vorsorge-Element** (Punkt C aus Roadmap Nr. 44): Die Prozent-Aufteilung des bezogenen Alterskapitals hing am **Cash-Übergang** dem falschen Ort, denn mit zwei Guthaben (PK und 3a) liess sie sich dort gar nicht getrennt beantworten. Sie steht jetzt beim **Bezugs-Entscheid** der Pensionskasse (nur bei Kapitalbezug) bzw. der **Säule 3a**. Beide Dialoge führen neu **brutto → Steuersatz → netto** und darunter die Verteilung. Der zugeteilte Betrag fliesst über den regulären Weg (`Carry.capitalIn` → Zusatzeinlage der Folgephase) und ist damit **überall sichtbar**: am Ziel-Element, in der Cash-Brücke als Investition und im «Kapital verteilen»-Dialog. Vorher erhöhte er still den Bestand, weshalb Element und Dialog eine **0** zeigten. Die **Säule 3a** ist am Pensions-Übergang neu ein **offener Entscheid** (Steuersatz und Verwendung); vorher galt sie als automatisch beantwortet. (2) **Phasendauer: die Folgephase gleicht aus** (Kap. 3.3.2). Bis 0.32 prüfte die Kappung nur die **bearbeitete** Phase wurde Phase 1 von 10 auf 12 Jahre verlängert, überspannte danach Phase 2 die Pensionierung, und die tragende Invariante aus Roadmap Nr. 44 kippte. Neu trägt die Folgephase die Differenz (Gesamtdauer bleibt gleich, wie beim Verschieben des Pensionsalters); passt sie nicht, wird blockiert; vorher erscheint eine Rückfrage. Neue reine Funktion `planDurationChange`. (3) **Element und Phase direkt bedienbar:** In der Matrix tragen Element-Zeile und Phasenkopf neu **Stift** (umbenennen, beim Element inkl. **Zuordnung**) und **Papierkorb**; das Expand-Symbol ist **immer** sichtbar statt nur bei Mouseover. `PATCH /api/elements/<id>` nimmt dafür neu auch `ownerRole` (bleibt für AHV/PK/3a personengebunden). (4) **Hilfetexte** werden über ein **Portal** gezeichnet in scrollenden Dialogen schnitt der Container sie vorher ab; sie klappen nach oben, wenn unten kein Platz ist. (5) **Verteil-Dialoge:** Zeilen zeigen die **Zuordnung** (Person A/B/Gemeinsam) und sind nach **«vom Cash»/«ins Cash»** gruppiert; die Vorbelegung nutzt neu den **effektiven** Wert inklusive Vererbung aus der Vorphase ein geerbter 3a-Beitrag erschien vorher als 0. (6) **Matrix:** alle Phasenspalten **gleich breit**, bei vielen Phasen wird horizontal gescrollt; **«Alle auf-/zuklappen»**; eine zugeklappte Kategorie zeigt je Phase die **Summe** ihrer Elemente. (7) **Phasen-Detailansicht** nutzt die neue Aufteilungs-Grafik (Fläche + Ring) statt der alten Balken. (8) **Übersicht:** «Leer starten» steht neu auch im leeren Zustand zur Wahl. (9) Nebenbei: dritte vom Umlaut-Sweep verstümmelte Hex-Farbe (`#7c3äd`) repariert, das Phasen-Panel nutzt den eigenen Bestätigungs-Dialog statt `window.confirm`. 10 Tests ergänzt (278 → 288). |
| 0.32 | 2026-07-25 | Claude (Opus 5) | **Modul-Review 3, Nachbesserungen darunter ein gravierender Rechenfehler bei den effektiven Werten.** (1) **Immobilien-Bugfix (Kap. 3.9):** Der Ist-Wizard belegte den Immobilienwert mit dem **Eigenkapital** vor (`ElementYearPoint.value`), während Erfassung und Rechenkern den **Verkehrswert** erwarten. Der Rechenkern setzte den vorbelegten Wert als Verkehrswert ein, liess die Hypothek aber stehen das Eigenkapital brach im Ist-Jahr schlagartig ein, typischerweise ins Negative. Sichtbar wurde das als **negative Gesamt-Abweichung, obwohl nur ein Lohn erhöht** wurde, und als «wegbrechendes» Wohneigentum in der Vermögensaufteilung. Neu wird `propertyValue` vorbelegt; das Feld ist als «Verkehrswert + Restschuld» beschriftet. Drei Regressionstests. (2) **Ist-Datensätze bearbeitbar:** Ein Klick auf die Zeile (oder «Bearbeiten») öffnet den erfassten Satz erneut; neuer Endpunkt `PUT /api/plans/<id>/actuals/<setId>`. Beim Bearbeiten überschreiben die Planwerte die erfassten Zahlen nicht mehr. (3) **Ring-Klick in der Vermögensaufteilung repariert:** Recharts 3 reicht im Klick-Parameter **kein `activePayload`** mehr durch (nur noch `activeIndex`) der Handler feuerte nie, der Ring zeigte immer das Planende. (4) **Seitenleiste sauber dreistufig:** Ebene 1 Pläne, Ebene 2 die vier Bereiche (Szenarien, Effektive Werte, Analysen, Berichte) mit **bündigen Symbolen**, Ebene 3 nur die Szenarien verschachtelt nach Herkunft. (5) Die **Szenario-Liste** zeigt neben der Version deren **Kommentar**. | | 0.32 | 2026-07-25 | Claude (Opus 5) | **Modul-Review 3, Nachbesserungen darunter ein gravierender Rechenfehler bei den effektiven Werten.** (1) **Immobilien-Bugfix (Kap. 3.9):** Der Ist-Wizard belegte den Immobilienwert mit dem **Eigenkapital** vor (`ElementYearPoint.value`), während Erfassung und Rechenkern den **Verkehrswert** erwarten. Der Rechenkern setzte den vorbelegten Wert als Verkehrswert ein, liess die Hypothek aber stehen das Eigenkapital brach im Ist-Jahr schlagartig ein, typischerweise ins Negative. Sichtbar wurde das als **negative Gesamt-Abweichung, obwohl nur ein Lohn erhöht** wurde, und als «wegbrechendes» Wohneigentum in der Vermögensaufteilung. Neu wird `propertyValue` vorbelegt; das Feld ist als «Verkehrswert + Restschuld» beschriftet. Drei Regressionstests. (2) **Ist-Datensätze bearbeitbar:** Ein Klick auf die Zeile (oder «Bearbeiten») öffnet den erfassten Satz erneut; neuer Endpunkt `PUT /api/plans/<id>/actuals/<setId>`. Beim Bearbeiten überschreiben die Planwerte die erfassten Zahlen nicht mehr. (3) **Ring-Klick in der Vermögensaufteilung repariert:** Recharts 3 reicht im Klick-Parameter **kein `activePayload`** mehr durch (nur noch `activeIndex`) der Handler feuerte nie, der Ring zeigte immer das Planende. (4) **Seitenleiste sauber dreistufig:** Ebene 1 Pläne, Ebene 2 die vier Bereiche (Szenarien, Effektive Werte, Analysen, Berichte) mit **bündigen Symbolen**, Ebene 3 nur die Szenarien verschachtelt nach Herkunft. (5) Die **Szenario-Liste** zeigt neben der Version deren **Kommentar**. |
| 0.31 | 2026-07-25 | Claude (Opus 5) | **Modul-Review 3 (Plan-/Szenario-Struktur, Dashboard, Grafiken).** (1) **Versionierung startet bei 0.1** statt 1.0 (Kap. 3.8): Ein Szenario läuft in der 0er-Reihe (0.1, 0.2, … 0.137), bis eine **Hauptversion** gesetzt wird erst dann entsteht 1.0. Vorher begann jedes Szenario bereits bei 1.0, wodurch die Hauptversion ihre Bedeutung verlor. Die Szenario-Liste zeigt neu die **echte** Version statt «1.x», dazu eine Spalte **Phasen**. (2) **Plan-Dashboard:** Kacheln sind **anklickbar** und führen in ihren Bereich, neu inkl. **Berichte**; der Plan lässt sich über ein Stift-Symbol **umbenennen**; die Ist-Abweichung nennt das **Jahr** des jüngsten Ist-Datensatzes und ist bei einer positiven Abweichung **grün** statt rot. (3) **Szenario-Liste:** Ein Klick auf die **Zeile** öffnet die Matrix (der «Matrix»-Knopf entfällt), dazu je Zeile **Kopie** und **Löschen**; die Kopiervorlage ist damit frei wählbar und nicht mehr auf das Basisszenario festgelegt. Nach einer Löschung lädt die Liste neu (zeigte vorher den alten Stand). (4) **Seitenleiste:** Szenarien werden wieder **verschachtelt** dargestellt (Tiefe = Herkunftskette); «Effektive Werte», «Analysen» und «Berichte» stehen neu **bündig zum Knoten «Szenarien»** statt auf Höhe der einzelnen Szenarien. (5) **CSV-Export vollständig neu** (Kap. 3.6.5, neues Modul `csv.ts`): vier Blöcke Kopf, Lebensphasen, **die ganze Matrix** (Elemente × Phasen mit Beginn/Ende und den Übergangs-Entscheiden im Klartext) und **Jahreswerte**; mit BOM, damit Excel die Umlaute erkennt. Vorher enthielt die Datei kein einziges finanzielles Element. (6) **Grafiken:** Ein **Szenario-Wähler** gilt neu für **alle drei** Grafiken (vorher nur der Vermögensverlauf, und der nur additiv). Der **Vergleichs-Fehler** ist behoben: `WealthChart` benutzte den Szenario-**Namen** als Datenschlüssel, wodurch sich gleichnamige Szenarien gegenseitig überschrieben (Legende zeigte beide, der Chart nur eine) neu die **ID**; die stille Deckelung auf vier Serien entfällt. Die **Legende** ist eigenständig, erlaubt eine **freie Farbwahl je Serie** und erklärt den Linienstil (gestrichelt = Plan, durchgezogen = effektiv). Die **Vermögensaufteilung** ist neu eine **gestapelte Fläche über alle Planjahre** plus ein **Ring** für die relative Aufteilung zu einem wählbaren Zeitpunkt (vorher gestapelte Balken je Phase mit schräger Beschriftung). **Alle Diagrammfarben** kommen aus neuen Theme-Tokens (`--chart-1``--chart-6`, `--chart-grid`) statt fester Hex-Werte. (7) **Dokumentation nachgezogen:** Die Kapitel 2.1, 3.2.23.2.7 und 3.10 beschrieben noch den Stand **vor V7** (Grundprofil am Szenario, `parentPlanId`, `Scenario.startYear`, `window.confirm`, drei Sidebar-Unterpunkte). (8) Nebenbei: verstümmelte Hex-Farbe `--danger-soft` im Warm-Schema repariert, deutsche Plural-/Umlautfehler in den Übersichts-Kacheln, Dateiname des CSV-Exports transliteriert Umlaute statt sie zu `_` zu machen. 8 Tests ergänzt (267 → 275). | | 0.31 | 2026-07-25 | Claude (Opus 5) | **Modul-Review 3 (Plan-/Szenario-Struktur, Dashboard, Grafiken).** (1) **Versionierung startet bei 0.1** statt 1.0 (Kap. 3.8): Ein Szenario läuft in der 0er-Reihe (0.1, 0.2, … 0.137), bis eine **Hauptversion** gesetzt wird erst dann entsteht 1.0. Vorher begann jedes Szenario bereits bei 1.0, wodurch die Hauptversion ihre Bedeutung verlor. Die Szenario-Liste zeigt neu die **echte** Version statt «1.x», dazu eine Spalte **Phasen**. (2) **Plan-Dashboard:** Kacheln sind **anklickbar** und führen in ihren Bereich, neu inkl. **Berichte**; der Plan lässt sich über ein Stift-Symbol **umbenennen**; die Ist-Abweichung nennt das **Jahr** des jüngsten Ist-Datensatzes und ist bei einer positiven Abweichung **grün** statt rot. (3) **Szenario-Liste:** Ein Klick auf die **Zeile** öffnet die Matrix (der «Matrix»-Knopf entfällt), dazu je Zeile **Kopie** und **Löschen**; die Kopiervorlage ist damit frei wählbar und nicht mehr auf das Basisszenario festgelegt. Nach einer Löschung lädt die Liste neu (zeigte vorher den alten Stand). (4) **Seitenleiste:** Szenarien werden wieder **verschachtelt** dargestellt (Tiefe = Herkunftskette); «Effektive Werte», «Analysen» und «Berichte» stehen neu **bündig zum Knoten «Szenarien»** statt auf Höhe der einzelnen Szenarien. (5) **CSV-Export vollständig neu** (Kap. 3.6.5, neues Modul `csv.ts`): vier Blöcke Kopf, Lebensphasen, **die ganze Matrix** (Elemente × Phasen mit Beginn/Ende und den Übergangs-Entscheiden im Klartext) und **Jahreswerte**; mit BOM, damit Excel die Umlaute erkennt. Vorher enthielt die Datei kein einziges finanzielles Element. (6) **Grafiken:** Ein **Szenario-Wähler** gilt neu für **alle drei** Grafiken (vorher nur der Vermögensverlauf, und der nur additiv). Der **Vergleichs-Fehler** ist behoben: `WealthChart` benutzte den Szenario-**Namen** als Datenschlüssel, wodurch sich gleichnamige Szenarien gegenseitig überschrieben (Legende zeigte beide, der Chart nur eine) neu die **ID**; die stille Deckelung auf vier Serien entfällt. Die **Legende** ist eigenständig, erlaubt eine **freie Farbwahl je Serie** und erklärt den Linienstil (gestrichelt = Plan, durchgezogen = effektiv). Die **Vermögensaufteilung** ist neu eine **gestapelte Fläche über alle Planjahre** plus ein **Ring** für die relative Aufteilung zu einem wählbaren Zeitpunkt (vorher gestapelte Balken je Phase mit schräger Beschriftung). **Alle Diagrammfarben** kommen aus neuen Theme-Tokens (`--chart-1``--chart-6`, `--chart-grid`) statt fester Hex-Werte. (7) **Dokumentation nachgezogen:** Die Kapitel 2.1, 3.2.23.2.7 und 3.10 beschrieben noch den Stand **vor V7** (Grundprofil am Szenario, `parentPlanId`, `Scenario.startYear`, `window.confirm`, drei Sidebar-Unterpunkte). (8) Nebenbei: verstümmelte Hex-Farbe `--danger-soft` im Warm-Schema repariert, deutsche Plural-/Umlautfehler in den Übersichts-Kacheln, Dateiname des CSV-Exports transliteriert Umlaute statt sie zu `_` zu machen. 8 Tests ergänzt (267 → 275). |
| 0.30 | 2026-07-24 | Claude (Opus 4.8) | **Tour-Korrekturen und 3a-Verschiebung.** (1) Der Tour-**Spotlight** wird neu aus **vier fixed-Flächen** um die Bounding-Box des Ziels gezeichnet (Kap. 9.24) statt aus einem `box-shadow`-Trick. Grund: Der Schatten liess sticky Matrix-Köpfe (hoher z-index) hell durchscheinen und wurde im Matrix-Scrollbereich abgeschnitten (dann blieb fast alles hell). Die vier Flächen funktionieren unabhängig von z-index und overflow und folgen dem Ziel per `requestAnimationFrame`. (2) **Tour-Schritte** überarbeitet: neu erklärt sind **Zeitachse** und **Endvermögen**; der vormals «Analysen»-Schritt beschreibt jetzt korrekt die **obere Funktions-Leiste** (die Analyse-Werkzeuge sind dort nicht mehr), und ein neuer Schritt zeigt das **linke Menü** (Analysen, Berichte, Effektive Werte). Unsichtbare Ziele (z. B. das Menü auf schmalen Screens) werden übersprungen. (3) Der Schalter **«Selbstständig grosse Säule 3a»** wandert im Assistenten von Schritt 4 zu **Schritt 5**, weil er die 3a-Einzahlung (also die Sparraten-Verteilung) betrifft. Kein Eingriff in den Rechenkern; 267 Tests unverändert grün. | | 0.30 | 2026-07-24 | Claude (Opus 4.8) | **Tour-Korrekturen und 3a-Verschiebung.** (1) Der Tour-**Spotlight** wird neu aus **vier fixed-Flächen** um die Bounding-Box des Ziels gezeichnet (Kap. 9.24) statt aus einem `box-shadow`-Trick. Grund: Der Schatten liess sticky Matrix-Köpfe (hoher z-index) hell durchscheinen und wurde im Matrix-Scrollbereich abgeschnitten (dann blieb fast alles hell). Die vier Flächen funktionieren unabhängig von z-index und overflow und folgen dem Ziel per `requestAnimationFrame`. (2) **Tour-Schritte** überarbeitet: neu erklärt sind **Zeitachse** und **Endvermögen**; der vormals «Analysen»-Schritt beschreibt jetzt korrekt die **obere Funktions-Leiste** (die Analyse-Werkzeuge sind dort nicht mehr), und ein neuer Schritt zeigt das **linke Menü** (Analysen, Berichte, Effektive Werte). Unsichtbare Ziele (z. B. das Menü auf schmalen Screens) werden übersprungen. (3) Der Schalter **«Selbstständig grosse Säule 3a»** wandert im Assistenten von Schritt 4 zu **Schritt 5**, weil er die 3a-Einzahlung (also die Sparraten-Verteilung) betrifft. Kein Eingriff in den Rechenkern; 267 Tests unverändert grün. |
@@ -528,32 +529,47 @@ und die AHV-/PK-Renten am Phasenbeginn ausgewertet werden.
mindestens eine Person pensioniert ist, sonst „Erwerbsphase". mindestens eine Person pensioniert ist, sonst „Erwerbsphase".
**Vorbelegung der Elemente:** Beim Anlegen einer Phase wird für jedes noch aktive Element **Vorbelegung der Elemente:** Beim Anlegen einer Phase wird für jedes noch aktive Element
(nicht `SOLD`, nicht `SETTLED`) ein `ElementPhaseValue` mit den **editierbaren** Feldern der (nicht `SOLD`, nicht `SETTLED`) ein `ElementPhaseValue` erzeugt seit 0.28 aber fast **leer**
Vorphase erzeugt (`buildCarryData`): (`buildCarryData`):
| Kategorie | Übernommene Felder | | Kategorie | Kopierte Felder |
|---|---| |---|---|
| `INCOME`, `EXPENSE` | nur `teuerungsausgleich` (Basis wird live fortgeschrieben) | | `AHV` | `gapYears: 0` Ausfalljahre gelten für genau eine Phase, ein geerbter Wert würde eine Lücke erfinden |
| `AHV` | `gapYears: 0` | | `REAL_ESTATE` | `purchasePrice` (eine Tatsache, Basis der Grundstückgewinnsteuer) und `interestHandling` (ein Schalter ohne Zahlenwert) |
| `PENSION_FUND`, `PILLAR_3A`, `OTHER_ASSET` | `annualContribution`, `expectedReturn` | | alle übrigen | **nichts** |
| `REAL_ESTATE` | `purchasePrice`, `amortization` (Resthypothek wird live fortgeschrieben) |
| `OTHER_DEBT` | `annualRepayment` |
Bestände (PK-/3a-/Vermögenswert, Resthypothek, Restschuld) werden **bewusst nicht als Snapshot Alles andere wird **nicht kopiert, sondern live vererbt**: Beträge und Bestände werden aus der
gespeichert**, sondern in jeder Berechnung live aus der Vorphase fortgeschrieben. Damit wirken Vorphase fortgeschrieben, die **Wiederkehr-Parameter** (Raten, Beiträge, Amortisation,
sich nachträgliche Änderungen an frühen Phasen automatisch auf alle Folgephasen aus. Wertsteigerung, Zinssatz) gelten weiter, solange das Feld leer bleibt
([3.12.4](#3124-punkt-a-aus-vorphase-übernehmen)). Der Unterschied zeigt sich, sobald man eine
frühe Phase nachträglich ändert: Eine Kopie bliebe stehen, die Vererbung zieht mit.
Referenz: `src/app/api/plans/[planId]/phases/route.ts`. Referenz: `src/app/api/scenarios/[scenarioId]/phases/route.ts`.
### 3.3.2 Phase bearbeiten ### 3.3.2 Phase bearbeiten
Klick auf einen Phasenkopf öffnet ein **Popup** („Lebensphase: <Name>") mit Bezeichnung und Klick auf einen Phasenkopf (oder auf dessen **Stift-Symbol**) öffnet das Panel „Lebensphase:
Dauer konsistent zu allen anderen Eingaben (Element-Zellen, Übergänge). Speichern schliesst <Name>" mit Bezeichnung und Dauer. Eine phasenspezifische Inflationsrate gibt es nicht; das
das Popup. Die Dauer wird auch hier gekappt. Eine phasenspezifische Inflationsrate gibt es nicht; Panel weist darauf hin.
das Panel weist darauf hin: „Die Inflationsrate gilt plan-weit und wird in den Plan-Einstellungen
gesetzt."
Referenz: `src/components/PhaseDetail.tsx`. **Die Folgephase gleicht eine geänderte Dauer aus.** Wird eine Phase um N Jahre verlängert, wird
die **nächste** um N Jahre kürzer die Gesamtdauer des Plans bleibt gleich. Das Panel zeigt die
Auswirkung live und fragt vor dem Speichern nach.
Warum das nötig ist: Bis 0.32 kappte das Tool nur die **bearbeitete** Phase am nächsten
Pensionsereignis. Das ist richtig, aber nicht ausreichend. Beispiel: Phase 1 (10 J.) + Phase 2
(10 J.), Pensionierung im Jahr 20. Eine Verlängerung von Phase 1 auf 12 Jahre ist für Phase 1
zulässig danach lief Phase 2 aber von Jahr 12 bis 22 und **überspannte die Pensionierung**.
Da `computePlan` die Dauer nicht nachkappt und den Phasentyp am Phasen*beginn* ableitet, wurde
die Pensionierung faktisch übersprungen; die tragende Invariante aus
[4.16.1](#4161-die-tragende-invariante) war verletzt.
Passt der Ausgleich nicht (die Folgephase fiele unter ein Jahr), wird die Änderung **blockiert**
mit dem Hinweis, um wie viel sie höchstens möglich wäre. Die **letzte** Phase hat keine
Nachfolgerin sie verlängert oder verkürzt den Plan tatsächlich.
Referenz: `planDurationChange` in `src/lib/phaseplan.ts`, `src/components/PhaseDetail.tsx`,
`src/app/api/phases/[phaseId]/route.ts` (setzt beide Dauern in einer Transaktion).
### 3.3.3 Phase löschen ### 3.3.3 Phase löschen
@@ -561,7 +577,7 @@ Referenz: `src/components/PhaseDetail.tsx`.
Phase kann gelöscht werden."). Damit bleibt die Kette der `sequenceNumber` lückenlos. Der Phase kann gelöscht werden."). Damit bleibt die Kette der `sequenceNumber` lückenlos. Der
Löschen-Button erscheint im Detail-Panel nur bei der letzten Phase. Löschen-Button erscheint im Detail-Panel nur bei der letzten Phase.
Referenz: `src/app/api/phases/[phaseId]/route.ts` Zeilen 5577. Referenz: `src/app/api/phases/[phaseId]/route.ts`.
## 3.4 Finanzielle Elemente ## 3.4 Finanzielle Elemente
@@ -584,7 +600,7 @@ Kategorien kein `PERSON_A`/`PERSON_B` übergeben → HTTP 400.
Für die übrigen Kategorien gilt: fehlt die Zuordnung, wird serverseitig `HOUSEHOLD` gesetzt. Für die übrigen Kategorien gilt: fehlt die Zuordnung, wird serverseitig `HOUSEHOLD` gesetzt.
Referenz: `src/lib/elements.ts` Zeilen 1923, `src/app/api/plans/[planId]/elements/route.ts` Zeilen 4354. Referenz: `src/lib/elements.ts` (`PERSON_ONLY_CATEGORIES`), `src/app/api/scenarios/[scenarioId]/elements/route.ts`.
### 3.4.2 Element anlegen ### 3.4.2 Element anlegen
@@ -595,17 +611,28 @@ Bezeichnungs-Default ist das Kategorie-Label.
`orderIndex` = bisheriges Maximum + 1; bestimmt die Reihenfolge innerhalb der Kategoriegruppe. `orderIndex` = bisheriges Maximum + 1; bestimmt die Reihenfolge innerhalb der Kategoriegruppe.
Referenz: `src/components/PlanView.tsx` Zeilen 799947. Referenz: `AddElementDialog` in `src/components/PlanView.tsx`.
### 3.4.3 Element bearbeiten und löschen ### 3.4.3 Element bearbeiten und löschen
Ein Klick auf eine **Phasenzelle** öffnet den Dialog „Lebensphase: <Name>" mit den Ein Klick auf eine **Phasenzelle** öffnet das Panel mit den kategorie- und kontextabhängigen
kategorie- und kontextabhängigen Feldern (siehe [3.4.4](#344-feldkatalog-je-kategorie)) sowie Feldern (siehe [3.4.4](#344-feldkatalog-je-kategorie)) dort stehen die Werte **dieser Phase**.
dem Button „Element löschen". Löschen entfernt das Element **aus allen Phasen**
(Browser-`confirm()`, dann Cascade auf `ElementPhaseValue` und `ElementTransitionValue`).
Umbenennen ist per API möglich (`PATCH /api/elements/<id>`), im aktuellen UI aber nicht Die Eigenschaften des **Elements selbst** (Bezeichnung, Zuordnung) sind phasenunabhängig und
angebunden. haben deshalb ihren eigenen Ort: In der Element-Zeile ganz links stehen drei Symbole
**Stift** (umbenennen und Zuordnung ändern), **Papierkorb** (löschen) und **Expand**
(Detailansicht). Alle drei sind **immer** sichtbar, nicht erst bei Mouseover; auf einem
Touch-Gerät gäbe es sonst keinen Weg dorthin. Denselben Aufbau trägt der **Phasenkopf**.
Die Zuordnung läuft über `ownerRole`, nicht über eine Person-ID deshalb übersteht sie auch
das Neuanlegen der Personen im Profil-Dialog. Für AHV, Pensionskasse und Säule 3a bleibt sie
zwingend personengebunden (HTTP 400 bei „Gemeinsam").
Löschen entfernt das Element **aus allen Phasen** (eigener Bestätigungs-Dialog, dann Cascade auf
`ElementPhaseValue` und `ElementTransitionValue`).
Referenz: `PATCH /api/elements/<id>` (Felder `name`, `ownerRole`), `ElementMetaPanel` in
`src/components/PlanView.tsx`.
### 3.4.4 Feldkatalog je Kategorie ### 3.4.4 Feldkatalog je Kategorie
@@ -614,7 +641,7 @@ Die angezeigten Felder hängen von drei Kontextgrössen ab:
- **`ownerWorking`** ob der zugeordnete Besitzer in dieser Phase erwerbstätig ist - **`ownerWorking`** ob der zugeordnete Besitzer in dieser Phase erwerbstätig ist
- **`durationYears`** Phasendauer (begrenzt z. B. die Ausfalljahre) - **`durationYears`** Phasendauer (begrenzt z. B. die Ausfalljahre)
Referenz: `src/components/ElementDetail.tsx` Zeilen 124340. Referenz: `ElementPhaseFields` in `src/components/ElementDetail.tsx`.
#### INCOME (Einkommen) #### INCOME (Einkommen)
@@ -669,7 +696,7 @@ Ausgaben beruecksichtigt)" Lohnabzüge sind im Nettoeinkommen bereits weg.
Wie PK, aber: Wie PK, aber:
- Die Einzahlung **zählt** zur Sparquote (verlässt das Cash). - Die Einzahlung **zählt** zur Sparquote (verlässt das Cash).
- Das Feld ist auf `PILLAR_3A_MAX_ANNUAL` = **7'258 CHF** (2026, mit PK) hart geklammert. - Das Feld ist auf `PILLAR_3A_MAX_ANNUAL` = **7'258 CHF** (2026, mit PK) hart geklammert mit dem Schalter «Selbstständig ohne PK» auf `PILLAR_3A_MAX_SELF_EMPLOYED` (Feld `selfEmployed3a`, siehe 4.11).
- Bei Pensionierung: „Die Saeule 3a wird beim Pensions-Uebergang vollstaendig bezogen." - Bei Pensionierung: „Die Saeule 3a wird beim Pensions-Uebergang vollstaendig bezogen."
#### REAL_ESTATE (Immobilie) #### REAL_ESTATE (Immobilie)
@@ -947,7 +974,7 @@ in den Modi «Nominal» und «Real» bleibt alles einzeilig.
Es werden zwei verschiedene Deflatoren verwendet siehe [4.5.3](#453-die-drei-deflatoren). Es werden zwei verschiedene Deflatoren verwendet siehe [4.5.3](#453-die-drei-deflatoren).
Referenz: `src/components/PlanView.tsx` Zeilen 665699. Referenz: `src/components/PlanView.tsx` (Anzeige-Umschalter über der Matrix).
### 3.6.2 Zeitachse ### 3.6.2 Zeitachse
@@ -1922,12 +1949,18 @@ Annahme) und die **Zins-Behandlung** (ein Schalter ohne Zahlenwert).
Bestehende Pläne verhalten sich unverändert: Dort sind die Werte gespeichert und gewinnen daher Bestehende Pläne verhalten sich unverändert: Dort sind die Werte gespeichert und gewinnen daher
gegen die Vererbung, bis man das Häkchen aktiv setzt. gegen die Vererbung, bis man das Häkchen aktiv setzt.
### 3.12.5 Punkt C: Verwendung des Kapitalzuflusses ### 3.12.5 Punkt C: Verwendung des bezogenen Alterskapitals
Am Pensions-Übergang kommt oft ein grosser Betrag auf einmal herein (PK-Kapital, Säule 3a, Am Pensions-Übergang kommt oft ein grosser Betrag auf einmal herein (PK-Kapital, Säule 3a).
Verkaufserlös). Ihn vollständig als Cash liegen zu lassen ist selten die Absicht. Im Ihn vollständig als Cash liegen zu lassen ist selten die Absicht. Beim **Bezugs-Entscheid des
Cash-Übergang lässt sich deshalb erfassen, wie viel **in Prozent** in die Amortisation der jeweiligen Guthabens** lässt sich deshalb erfassen, wie viel **in Prozent** in die Amortisation
Hypothek und in eine Anlage fliesst; der Rest bleibt Cash. der Hypothek und in eine Anlage fliesst; der Rest bleibt Cash.
Die Frage steht seit 0.33 dort, wo der Bezug entschieden wird bei der **Pensionskasse** (nur
wenn Kapital bezogen wird) und bei der **Säule 3a**. Bis 0.32 hing sie am **Cash-Übergang**:
Das war der falsche Ort, weil sich mit zwei Guthaben nicht getrennt festlegen liess, welches
wohin fliesst. Beide Dialoge führen deshalb neu in der Reihenfolge, in der man tatsächlich
entscheidet: **Bezugsart → Betrag brutto → Steuersatz → Betrag netto → Verteilung**.
**Warum Prozent und nicht Franken:** Verschiebt man das Pensionsalter, ändert sich das bezogene **Warum Prozent und nicht Franken:** Verschiebt man das Pensionsalter, ändert sich das bezogene
Kapital. Ein Frankenbetrag müsste von Hand nachgezogen werden und würde bis dahin still eine Kapital. Ein Frankenbetrag müsste von Hand nachgezogen werden und würde bis dahin still eine
@@ -1936,7 +1969,17 @@ falsche Aufteilung rechnen. Eine Quote skaliert mit.
Die Amortisations-Quote ist am Restsaldo der Hypothek gekappt; ist sie grösser, bleibt der Rest Die Amortisations-Quote ist am Restsaldo der Hypothek gekappt; ist sie grösser, bleibt der Rest
Cash. Die Anlage-Quote fliesst in ein wählbares Vermögens-Element (Vorgabe: das erste aktive). Cash. Die Anlage-Quote fliesst in ein wählbares Vermögens-Element (Vorgabe: das erste aktive).
Beide sind mechanisch nichts Neues die eine wirkt wie eine Sonderamortisation, die andere wie Beide sind mechanisch nichts Neues die eine wirkt wie eine Sonderamortisation, die andere wie
eine Zusatzinvestition, und beide laufen dadurch korrekt durch die zwei Wasserfall-Brücken. eine Zusatzeinlage, und beide laufen dadurch korrekt durch die zwei Wasserfall-Brücken.
**Der zugeteilte Betrag ist überall sichtbar.** Er wandert über `Carry.capitalIn` in die
**Zusatzeinlage der Folgephase** und erscheint dadurch am Ziel-Element (als eigene, read-only
Zeile «Davon aus Kapitalbezug»), in der Cash-Brücke als **Investition** und im Dialog «Kapital
verteilen». Bis 0.32 erhöhte die Verteilung direkt den internen Bestand Element und Dialog
zeigten deshalb eine **0**, obwohl das Geld geflossen war.
**Die Säule 3a ist am Pensions-Übergang ein offener Entscheid.** Der Bezug selbst steht fest
(sie wird immer ausbezahlt), zu entscheiden sind Steuersatz und Verwendung. Vorher galt der
Übergang als automatisch beantwortet, wodurch die Verwendungsfrage nie gestellt wurde.
Referenz: `src/lib/retirement.ts`, `src/components/RetirementAdjuster.tsx`, Referenz: `src/lib/retirement.ts`, `src/components/RetirementAdjuster.tsx`,
`src/components/FormField.tsx` (`InheritableField`). `src/components/FormField.tsx` (`InheritableField`).
@@ -3529,6 +3572,9 @@ den Wert der Vorphase. Alle übrigen Felder bedeuten «nicht gesetzt = 0» wie b
| `extraAmortization` | REAL_ESTATE (Sonderamortisation) | ≥ 0 | | `extraAmortization` | REAL_ESTATE (Sonderamortisation) | ≥ 0 |
| `saleTaxRate` | REAL_ESTATE | 0100 | | `saleTaxRate` | REAL_ESTATE | 0100 |
| `immediateRepayment` | OTHER_DEBT | ≥ 0 | | `immediateRepayment` | OTHER_DEBT | ≥ 0 |
| `capitalUseAmortizationPct` | PENSION_FUND (Kapitalbezug), PILLAR_3A Anteil des bezogenen Kapitals in die Amortisation | 0100 |
| `capitalUseInvestPct` | dito Anteil in eine Anlage | 0100 |
| `capitalUseTargetElementId` | Ziel der Anlage-Quote; ohne Angabe das erste aktive Sonstige Vermögen | ≤ 60 Zeichen |
### 5.4.5 JSON-Payload `CashTransitionData` ### 5.4.5 JSON-Payload `CashTransitionData`
@@ -3542,9 +3588,6 @@ Liegt in `Phase.cashTransition`. Validierung über `cashTransitionSchema`.
| `inflowTaxRate` | Steuer auf den Zufluss, Default 0 % | 0100 | | `inflowTaxRate` | Steuer auf den Zufluss, Default 0 % | 0100 |
| `outflowLabel` | Bezeichnung der Kosten (z. B. „Poolbau") | ≤ 120 Zeichen | | `outflowLabel` | Bezeichnung der Kosten (z. B. „Poolbau") | ≤ 120 Zeichen |
| `outflowAmount` | Betrag **real** (heutige Kaufkraft) | ≥ 0 | | `outflowAmount` | Betrag **real** (heutige Kaufkraft) | ≥ 0 |
| `capitalUseAmortizationPct` | Anteil des Kapitalzuflusses in die Amortisation (Kap. 3.12.5) | 0100 |
| `capitalUseInvestPct` | Anteil des Kapitalzuflusses in eine Anlage | 0100 |
| `capitalUseTargetElementId` | Ziel der Anlage-Quote; ohne Angabe das erste aktive Sonstige Vermögen | ≤ 60 Zeichen |
Pro Übergang ist **genau ein** Zufluss und **eine** Kostenposition möglich siehe Pro Übergang ist **genau ein** Zufluss und **eine** Kostenposition möglich siehe
[9.7](#97-nur-ein-zufluss-und-eine-kostenposition-pro-übergang). [9.7](#97-nur-ein-zufluss-und-eine-kostenposition-pro-übergang).
@@ -3950,15 +3993,15 @@ Include `src/**/*.test.ts`). Es gibt **keine** Komponenten-, API- oder E2E-Tests
| `versioning-coverage.test.ts` | 3 | statischer Wächter: jeder schreibende Endpunkt löst eine Version aus | | `versioning-coverage.test.ts` | 3 | statischer Wächter: jeder schreibende Endpunkt löst eine Version aus |
| `report.test.ts` | 9 | Zusammenfassung, Basis-Angabe zu JEDER Kennzahl, Szenario-Deckelung, Plan/Ist-Block, Haftungsausschluss, gültige PDF-Datei | | `report.test.ts` | 9 | Zusammenfassung, Basis-Angabe zu JEDER Kennzahl, Szenario-Deckelung, Plan/Ist-Block, Haftungsausschluss, gültige PDF-Datei |
| `livesim.test.ts` | 15 | Element-Regler bewegt genau ein Element; Aufschlüsselung deckt sich mit dem Sammelregler; neutrale Stellung verändert den Plan nicht; Ruinmeldung | | `livesim.test.ts` | 15 | Element-Regler bewegt genau ein Element; Aufschlüsselung deckt sich mit dem Sammelregler; neutrale Stellung verändert den Plan nicht; Ruinmeldung |
| `phaseplan.test.ts` | 8 | Ableitung der Lebensabschnitte aus den Pensionierungszeitpunkten (Einzel/Paar/bereits pensioniert); letzter Teil immer offen | | `phaseplan.test.ts` | 14 | Ableitung der Lebensabschnitte aus den Pensionierungszeitpunkten (Einzel/Paar/bereits pensioniert); letzter Teil immer offen |
| `bridges.test.ts` | 13 | Vermögens- und Cash-Brücke gehen über acht Plankonstellationen ohne Restgrösse auf; Umbuchungen bleiben aus der Vermögensbrücke heraus; Kapitalverwendung nach Quote (Punkt C) | | `bridges.test.ts` | 17 | Vermögens- und Cash-Brücke gehen über acht Plankonstellationen ohne Restgrösse auf; Umbuchungen bleiben aus der Vermögensbrücke heraus; Kapitalverwendung nach Quote (Punkt C) |
| `retirement.test.ts` | 16 | Spielraum und Sperren je Person, Verschiebung ohne Änderung der Gesamtdauer, Wegfall einer Phase, Zusammenführung der Übergangs-Entscheide | | `retirement.test.ts` | 16 | Spielraum und Sperren je Person, Verschiebung ohne Änderung der Gesamtdauer, Wegfall einer Phase, Zusammenführung der Übergangs-Entscheide |
| `server-boundary.test.ts` | 1 | statischer Wächter: kein Modul unter `src/lib` importiert aus `src/components` | | `server-boundary.test.ts` | 1 | statischer Wächter: kein Modul unter `src/lib` importiert aus `src/components` |
| `diff.test.ts` | 9 | Abweichungs-Erkennung gegen das Eltern-Szenario | | `diff.test.ts` | 9 | Abweichungs-Erkennung gegen das Eltern-Szenario |
| `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** | **278** | | | **Total** | **288** | |
## 8.2 Testfälle ## 8.2 Testfälle
+21 -2
View File
@@ -4,8 +4,14 @@ import { prisma } from "@/lib/db";
import { getOwnedElement } from "@/lib/queries"; import { getOwnedElement } from "@/lib/queries";
import { getCurrentUserId } from "@/lib/session"; import { getCurrentUserId } from "@/lib/session";
import { touchScenario } from "@/lib/versioning-db"; import { touchScenario } from "@/lib/versioning-db";
import { PERSON_ONLY_CATEGORIES } from "@/lib/elements";
const patchSchema = z.object({ name: z.string().min(1).max(120) }); // Name UND Zuordnung sind änderbar. Die Zuordnung läuft über `ownerRole` (nicht über eine
// Person-ID), deshalb überlebt sie auch ein Neuanlegen der Personen im Profil-Dialog.
const patchSchema = z.object({
name: z.string().min(1).max(120).optional(),
ownerRole: z.enum(["PERSON_A", "PERSON_B", "HOUSEHOLD"]).optional(),
});
export async function PATCH( export async function PATCH(
request: NextRequest, request: NextRequest,
@@ -22,7 +28,20 @@ export async function PATCH(
const parsed = patchSchema.safeParse(body); const parsed = patchSchema.safeParse(body);
if (!parsed.success) return NextResponse.json({ error: "Ungültige Eingabe." }, { status: 400 }); if (!parsed.success) return NextResponse.json({ error: "Ungültige Eingabe." }, { status: 400 });
await prisma.financialElement.update({ where: { id: element.id }, data: { name: parsed.data.name } }); // AHV, PK und 3a hängen am individuellen Pensionierungszeitpunkt und müssen deshalb einer
// Person gehören -- «Gemeinsam» wäre dort fachlich sinnlos.
const nextOwner = parsed.data.ownerRole;
if (nextOwner === "HOUSEHOLD" && PERSON_ONLY_CATEGORIES.includes(element.category)) {
return NextResponse.json(
{ error: "Diese Kategorie muss einer Person zugeordnet bleiben." },
{ status: 400 }
);
}
await prisma.financialElement.update({
where: { id: element.id },
data: { name: parsed.data.name ?? undefined, ownerRole: nextOwner ?? undefined },
});
await touchScenario(element.scenarioId, userId); await touchScenario(element.scenarioId, userId);
return NextResponse.json({ ok: true }); return NextResponse.json({ ok: true });
} }
+20 -3
View File
@@ -5,6 +5,7 @@ import { getOwnedPhase, getOwnedScenario, toPlanInput } from "@/lib/queries";
import { getCurrentUserId } from "@/lib/session"; import { getCurrentUserId } from "@/lib/session";
import { touchScenario } from "@/lib/versioning-db"; import { touchScenario } from "@/lib/versioning-db";
import { maxPhaseDuration } from "@/lib/calculations"; import { maxPhaseDuration } from "@/lib/calculations";
import { planDurationChange } from "@/lib/phaseplan";
const updatePhaseSchema = z.object({ const updatePhaseSchema = z.object({
name: z.string().min(1).max(120).optional(), name: z.string().min(1).max(120).optional(),
@@ -27,29 +28,45 @@ export async function PUT(
if (!parsed.success) return NextResponse.json({ error: "Ungültige Eingabe." }, { status: 400 }); if (!parsed.success) return NextResponse.json({ error: "Ungültige Eingabe." }, { status: 400 });
let duration = parsed.data.durationYears; let duration = parsed.data.durationYears;
// Die Folgephase gleicht eine geänderte Dauer aus -- sonst verschieben sich alle späteren
// Phasengrenzen und eine davon überspannt eine Pensionierung (siehe lib/phaseplan.ts).
let neighbour: { id: string; durationYears: number } | null = null;
if (duration != null) { if (duration != null) {
// Dauer ans nächste Pensionsereignis kappen (Jahre vor dieser Phase).
const scenario = await getOwnedScenario(existing.scenarioId, userId); const scenario = await getOwnedScenario(existing.scenarioId, userId);
if (scenario) { if (scenario) {
const planInput = toPlanInput(scenario); const planInput = toPlanInput(scenario);
// Dauer ans nächste Pensionsereignis kappen (Jahre vor dieser Phase).
const yearsBefore = planInput.phases const yearsBefore = planInput.phases
.filter((p) => p.sequenceNumber < existing.sequenceNumber) .filter((p) => p.sequenceNumber < existing.sequenceNumber)
.reduce((s, p) => s + p.durationYears, 0); .reduce((s, p) => s + p.durationYears, 0);
const cap = maxPhaseDuration(planInput.persons, yearsBefore); const cap = maxPhaseDuration(planInput.persons, yearsBefore);
if (cap != null) duration = Math.min(duration, cap); if (cap != null) duration = Math.min(duration, cap);
duration = Math.max(1, duration); duration = Math.max(1, duration);
const change = planDurationChange(planInput.phases, phaseId, duration);
if (!change.ok) return NextResponse.json({ error: change.error }, { status: 400 });
if (change.neighbourId && change.neighbourNewDuration != null) {
neighbour = { id: change.neighbourId, durationYears: change.neighbourNewDuration };
}
} }
} }
const phase = await prisma.phase.update({ const phase = await prisma.$transaction(async (tx) => {
const updated = await tx.phase.update({
where: { id: phaseId }, where: { id: phaseId },
data: { data: {
name: parsed.data.name ?? undefined, name: parsed.data.name ?? undefined,
durationYears: duration ?? undefined, durationYears: duration ?? undefined,
}, },
}); });
if (neighbour) {
await tx.phase.update({ where: { id: neighbour.id }, data: { durationYears: neighbour.durationYears } });
}
return updated;
});
await touchScenario(existing.scenarioId, userId); await touchScenario(existing.scenarioId, userId);
return NextResponse.json({ phase: { id: phase.id } }); return NextResponse.json({ phase: { id: phase.id }, adjustedNeighbour: neighbour });
} }
// Nur die letzte Phase kann gelöscht werden (Verkettung bleibt intakt). // Nur die letzte Phase kann gelöscht werden (Verkettung bleibt intakt).
+24 -8
View File
@@ -48,12 +48,26 @@ const ASSET_CATS = ["PENSION_FUND", "PILLAR_3A", "REAL_ESTATE", "OTHER_ASSET"];
// Phasengrenzen als feine senkrechte Linien, // Phasengrenzen als feine senkrechte Linien,
// * daneben ein RING für ein wählbares Jahr, der die RELATIVE Aufteilung zeigt. // * daneben ein RING für ein wählbares Jahr, der die RELATIVE Aufteilung zeigt.
// Ein Klick in die Fläche wählt das Jahr des Rings. // Ein Klick in die Fläche wählt das Jahr des Rings.
export function AllocationChart({ computed, height = 300 }: { computed: PlanComputed; height?: number }) { export function AllocationChart({
computed,
height = 300,
// Auf eine einzelne Lebensphase einschraenken (Detailansicht der Phase). Ohne Angabe wird
// der ganze Plan gezeigt.
onlyPhaseId,
}: {
computed: PlanComputed;
height?: number;
onlyPhaseId?: string;
}) {
const phases = useMemo(
() => (onlyPhaseId ? computed.phases.filter((p) => p.id === onlyPhaseId) : computed.phases),
[computed, onlyPhaseId]
);
// Asset-Elemente (nach id, damit gleiche Namen nicht kollidieren), die irgendwann einen // Asset-Elemente (nach id, damit gleiche Namen nicht kollidieren), die irgendwann einen
// positiven Wert haben -- in Reihenfolge ihres ersten Auftretens. // positiven Wert haben -- in Reihenfolge ihres ersten Auftretens.
const assetEls = useMemo(() => { const assetEls = useMemo(() => {
const info = new Map<string, { name: string; any: boolean }>(); const info = new Map<string, { name: string; any: boolean }>();
for (const phase of computed.phases) { for (const phase of phases) {
for (const el of phase.elements) { for (const el of phase.elements) {
if (!ASSET_CATS.includes(el.category)) continue; if (!ASSET_CATS.includes(el.category)) continue;
const cur = info.get(el.elementId) ?? { name: el.name, any: false }; const cur = info.get(el.elementId) ?? { name: el.name, any: false };
@@ -63,14 +77,14 @@ export function AllocationChart({ computed, height = 300 }: { computed: PlanComp
} }
} }
return [...info.entries()].filter(([, v]) => v.any).map(([id, v]) => ({ id, name: v.name })); return [...info.entries()].filter(([, v]) => v.any).map(([id, v]) => ({ id, name: v.name }));
}, [computed]); }, [phases]);
// Je Planjahr eine Zeile mit dem Wert jedes Elements. Die Jahreswerte liegen bereits in // Je Planjahr eine Zeile mit dem Wert jedes Elements. Die Jahreswerte liegen bereits in
// `ElementPhaseComputed.yearly` (seit 0.11) -- hier wird nur umsortiert, nichts gerechnet. // `ElementPhaseComputed.yearly` (seit 0.11) -- hier wird nur umsortiert, nichts gerechnet.
const areaData = useMemo(() => { const areaData = useMemo(() => {
const byYear = new Map<number, Record<string, number>>(); const byYear = new Map<number, Record<string, number>>();
const ageOf = new Map<number, number>(); const ageOf = new Map<number, number>();
for (const phase of computed.phases) { for (const phase of phases) {
for (const el of phase.elements) { for (const el of phase.elements) {
if (!ASSET_CATS.includes(el.category)) continue; if (!ASSET_CATS.includes(el.category)) continue;
for (const y of el.yearly) { for (const y of el.yearly) {
@@ -84,10 +98,11 @@ export function AllocationChart({ computed, height = 300 }: { computed: PlanComp
return [...byYear.entries()] return [...byYear.entries()]
.sort(([a], [b]) => a - b) .sort(([a], [b]) => a - b)
.map(([year, values]) => ({ year, age: ageOf.get(year) ?? 0, ...values })); .map(([year, values]) => ({ year, age: ageOf.get(year) ?? 0, ...values }));
}, [computed]); }, [phases]);
// Phasengrenzen (kumulierte Dauer) für die Trennlinien. // Phasengrenzen (kumulierte Dauer) für die Trennlinien. Bei einer einzelnen Phase gibt es keine.
const boundaryAges = useMemo(() => { const boundaryAges = useMemo(() => {
if (onlyPhaseId) return [];
const out: number[] = []; const out: number[] = [];
let acc = 0; let acc = 0;
for (let i = 0; i < computed.phases.length - 1; i++) { for (let i = 0; i < computed.phases.length - 1; i++) {
@@ -96,7 +111,7 @@ export function AllocationChart({ computed, height = 300 }: { computed: PlanComp
if (typeof age === "number") out.push(age); if (typeof age === "number") out.push(age);
} }
return out; return out;
}, [computed, areaData]); }, [computed, areaData, onlyPhaseId]);
// Gewähltes Jahr für den Ring -- Vorgabe: das letzte (Endzustand). // Gewähltes Jahr für den Ring -- Vorgabe: das letzte (Endzustand).
const [pickedYear, setPickedYear] = useState<number | null>(null); const [pickedYear, setPickedYear] = useState<number | null>(null);
@@ -119,7 +134,8 @@ export function AllocationChart({ computed, height = 300 }: { computed: PlanComp
{/* Verlauf */} {/* Verlauf */}
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="mb-1 text-xs font-medium text-muted"> <div className="mb-1 text-xs font-medium text-muted">
Verlauf über alle Planjahre <span className="text-faint">· Klick wählt das Jahr für den Ring</span> {onlyPhaseId ? "Verlauf in dieser Lebensphase" : "Verlauf über alle Planjahre"}{" "}
<span className="text-faint">· Klick wählt das Jahr für den Ring</span>
</div> </div>
<div style={{ height }}> <div style={{ height }}>
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
+7
View File
@@ -635,6 +635,7 @@ function AppShellInner({ username }: { username: string }) {
onOpenPlan={(id) => openPlanTab(id, "dashboard")} onOpenPlan={(id) => openPlanTab(id, "dashboard")}
onCreateGuided={() => setShowWizard(true)} onCreateGuided={() => setShowWizard(true)}
onCreateChooser={() => setShowNewPlanChooser(true)} onCreateChooser={() => setShowNewPlanChooser(true)}
onCreateEmpty={() => setShowEmptyPlanDialog(true)}
onCreateDemo={handleCreateDemo} onCreateDemo={handleCreateDemo}
onDelete={handleDeletePlan} onDelete={handleDeletePlan}
/> />
@@ -1060,6 +1061,7 @@ function DashboardHome({
onCreateGuided, onCreateGuided,
onCreateChooser, onCreateChooser,
onCreateDemo, onCreateDemo,
onCreateEmpty,
onDelete, onDelete,
}: { }: {
username: string; username: string;
@@ -1069,6 +1071,7 @@ function DashboardHome({
onCreateGuided: () => void; onCreateGuided: () => void;
onCreateChooser: () => void; onCreateChooser: () => void;
onCreateDemo: () => void; onCreateDemo: () => void;
onCreateEmpty: () => void;
onDelete: (id: string) => void; onDelete: (id: string) => void;
}) { }) {
return ( return (
@@ -1093,6 +1096,10 @@ function DashboardHome({
<Button variant="secondary" onClick={onCreateDemo} disabled={creatingDemo}> <Button variant="secondary" onClick={onCreateDemo} disabled={creatingDemo}>
<PiggyBank className="h-4 w-4" /> {creatingDemo ? "Wird angelegt…" : "Beispielplan ansehen"} <PiggyBank className="h-4 w-4" /> {creatingDemo ? "Wird angelegt…" : "Beispielplan ansehen"}
</Button> </Button>
{/* Dritter Weg -- sonst gaebe es "Leer starten" nur ueber das Plus in der Seitenleiste. */}
<Button variant="ghost" onClick={onCreateEmpty}>
<Plus className="h-4 w-4" /> Leer starten
</Button>
</EmptyState> </EmptyState>
) : ( ) : (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
+7 -25
View File
@@ -2,8 +2,6 @@
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { import {
Bar,
BarChart,
CartesianGrid, CartesianGrid,
Legend, Legend,
Line, Line,
@@ -14,6 +12,7 @@ import {
YAxis, YAxis,
} from "recharts"; } from "recharts";
import { BookOpen, X } from "lucide-react"; import { BookOpen, X } from "lucide-react";
import { AllocationChart } from "@/components/AllocationChart";
import { formatChf } from "@/lib/format"; import { formatChf } from "@/lib/format";
import { CATEGORY_LABELS } from "@/lib/elements"; import { CATEGORY_LABELS } from "@/lib/elements";
import type { ElementCategory } from "@/lib/elements"; import type { ElementCategory } from "@/lib/elements";
@@ -523,26 +522,21 @@ export function ElementDetailDialog({
// --- Phasen-Detailansicht ------------------------------------------------------------------ // --- Phasen-Detailansicht ------------------------------------------------------------------
const ASSET_CATS: ElementCategory[] = ["PENSION_FUND", "PILLAR_3A", "REAL_ESTATE", "OTHER_ASSET"]; const ASSET_CATS: ElementCategory[] = ["PENSION_FUND", "PILLAR_3A", "REAL_ESTATE", "OTHER_ASSET"];
const ALLOC_PALETTE = ["#4f46e5", "#0ea5e9", "#16a34a", "#d97706", "#dc2626", "#7c3äd"];
export function PhaseDetailDialog({ export function PhaseDetailDialog({
phase, phase,
computed,
isFirst, isFirst,
onClose, onClose,
onOpenSpec, onOpenSpec,
}: { }: {
phase: PhaseComputed; phase: PhaseComputed;
// Der ganze Plan -- die Aufteilungs-Grafik zeichnet daraus die Jahreswerte dieser Phase.
computed: PlanComputed;
isFirst: boolean; isFirst: boolean;
onClose: () => void; onClose: () => void;
onOpenSpec?: (anchor: string) => void; onOpenSpec?: (anchor: string) => void;
}) { }) {
const alloc = useMemo(() => {
const rows = phase.elements.filter((e) => ASSET_CATS.includes(e.category) && (e.startValue > 0 || e.endValue > 0));
return [
{ label: "Beginn", ...Object.fromEntries(rows.map((r) => [r.elementId, Math.max(0, r.startValue)])) },
{ label: "Ende", ...Object.fromEntries(rows.map((r) => [r.elementId, Math.max(0, r.endValue)])) },
];
}, [phase]);
const allocEls = useMemo( const allocEls = useMemo(
() => phase.elements.filter((e) => ASSET_CATS.includes(e.category) && (e.startValue > 0 || e.endValue > 0)), () => phase.elements.filter((e) => ASSET_CATS.includes(e.category) && (e.startValue > 0 || e.endValue > 0)),
[phase] [phase]
@@ -559,24 +553,12 @@ export function PhaseDetailDialog({
<section> <section>
<h3 className="mb-1 text-xs font-semibold text-fg">Vermögensaufteilung</h3> <h3 className="mb-1 text-xs font-semibold text-fg">Vermögensaufteilung</h3>
<p className="mb-2 text-[11px] text-muted">Zusammensetzung des Anlagevermögens zu Beginn und am Ende dieser Phase.</p> <p className="mb-2 text-[11px] text-muted">Zusammensetzung des Anlagevermögens über die Jahre dieser Phase; rechts die relative Aufteilung zu einem wählbaren Zeitpunkt.</p>
{allocEls.length === 0 ? ( {allocEls.length === 0 ? (
<p className="text-sm text-muted">In dieser Phase gibt es kein Anlagevermögen.</p> <p className="text-sm text-muted">In dieser Phase gibt es kein Anlagevermögen.</p>
) : ( ) : (
<div className="h-56 w-full"> // Dieselbe Darstellung wie im Grafiken-Bereich, nur auf diese Phase eingeschränkt.
<ResponsiveContainer width="100%" height="100%"> <AllocationChart computed={computed} onlyPhaseId={phase.id} height={240} />
<BarChart data={alloc} margin={{ top: 8, right: 16, left: 8, bottom: 8 }}>
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
<XAxis dataKey="label" tick={{ fontSize: 11 }} />
<YAxis tick={{ fontSize: 11 }} tickFormatter={(v) => Intl.NumberFormat("de-CH", { notation: "compact" }).format(v)} />
<Tooltip formatter={(v) => (typeof v === "number" ? formatChf(v) : v)} />
<Legend wrapperStyle={{ fontSize: 12 }} />
{allocEls.map((el, i) => (
<Bar key={el.elementId} dataKey={el.elementId} name={el.name} stackId="a" fill={ALLOC_PALETTE[i % ALLOC_PALETTE.length]} isAnimationActive={false} />
))}
</BarChart>
</ResponsiveContainer>
</div>
)} )}
</section> </section>
</> </>
+49 -16
View File
@@ -21,7 +21,7 @@ import { CarryWarning } from "@/components/ElementDetail";
import { api } from "@/lib/api-client"; import { api } from "@/lib/api-client";
import { formatChf } from "@/lib/format"; import { formatChf } from "@/lib/format";
import { computePlan } from "@/lib/calculations"; import { computePlan } from "@/lib/calculations";
import { CATEGORY_LABELS, num, type PhaseData, type TransitionData } from "@/lib/elements"; import { CATEGORY_LABELS, inheritedPhaseValues, num, type PhaseData, type TransitionData } from "@/lib/elements";
import { import {
applyPhasePatches, applyPhasePatches,
applyTransitionPatches, applyTransitionPatches,
@@ -313,12 +313,22 @@ interface RateTarget {
elementId: string; elementId: string;
name: string; name: string;
category: string; category: string;
// Zuordnung als Klartext -- ohne sie sind zwei gleichnamige Elemente derselben Kategorie
// (z. B. "ETF" bei Person A und Person B) in der Liste nicht unterscheidbar.
owner: string;
field: "annualContribution" | "annualWithdrawal" | "amortization" | "annualRepayment"; field: "annualContribution" | "annualWithdrawal" | "amortization" | "annualRepayment";
label: string; label: string;
direction: "out" | "in"; direction: "out" | "in";
hint: string; hint: string;
} }
// Zuordnung eines Elements als Klartext.
function ownerLabel(plan: PlanInput, role: string | null): string {
if (role !== "PERSON_A" && role !== "PERSON_B") return "Gemeinsam";
const p = plan.persons.find((x) => x.role === role);
return p?.name?.trim() || (role === "PERSON_A" ? "Person A" : "Person B");
}
export function RateDistributionDialog({ export function RateDistributionDialog({
plan, plan,
computed, computed,
@@ -343,30 +353,30 @@ export function RateDistributionDialog({
// das Cash-Konto nicht -- er lässt sich also gar nicht aus der Quote verteilen. // das Cash-Konto nicht -- er lässt sich also gar nicht aus der Quote verteilen.
if (e.category === "PILLAR_3A") { if (e.category === "PILLAR_3A") {
out.push({ out.push({
elementId: e.id, name: e.name, category: e.category, field: "annualContribution", elementId: e.id, name: e.name, category: e.category, owner: ownerLabel(plan, e.ownerRole), field: "annualContribution",
label: "Jährliche Einzahlung", direction: "out", label: "Jährliche Einzahlung", direction: "out",
hint: "Fliesst jährlich vom Cash in die Säule 3a.", hint: "Fliesst jährlich vom Cash in die Säule 3a.",
}); });
} else if (e.category === "OTHER_ASSET") { } else if (e.category === "OTHER_ASSET") {
out.push({ out.push({
elementId: e.id, name: e.name, category: e.category, field: "annualContribution", elementId: e.id, name: e.name, category: e.category, owner: ownerLabel(plan, e.ownerRole), field: "annualContribution",
label: "Jährlicher Sparbeitrag", direction: "out", label: "Jährlicher Sparbeitrag", direction: "out",
hint: "Fliesst jährlich vom Cash ins Vermögen.", hint: "Fliesst jährlich vom Cash ins Vermögen.",
}); });
out.push({ out.push({
elementId: e.id, name: e.name, category: e.category, field: "annualWithdrawal", elementId: e.id, name: e.name, category: e.category, owner: ownerLabel(plan, e.ownerRole), field: "annualWithdrawal",
label: "Jährliche Bezugsrate", direction: "in", label: "Jährliche Bezugsrate", direction: "in",
hint: "Entnahme aus dem Vermögen ins Cash. Wird jährlich am vorhandenen Bestand gekappt.", hint: "Entnahme aus dem Vermögen ins Cash. Wird jährlich am vorhandenen Bestand gekappt.",
}); });
} else if (e.category === "REAL_ESTATE") { } else if (e.category === "REAL_ESTATE") {
out.push({ out.push({
elementId: e.id, name: e.name, category: e.category, field: "amortization", elementId: e.id, name: e.name, category: e.category, owner: ownerLabel(plan, e.ownerRole), field: "amortization",
label: "Amortisation pro Jahr", direction: "out", label: "Amortisation pro Jahr", direction: "out",
hint: "Reduziert die Hypothek. Endet automatisch, sobald sie abbezahlt ist.", hint: "Reduziert die Hypothek. Endet automatisch, sobald sie abbezahlt ist.",
}); });
} else if (e.category === "OTHER_DEBT") { } else if (e.category === "OTHER_DEBT") {
out.push({ out.push({
elementId: e.id, name: e.name, category: e.category, field: "annualRepayment", elementId: e.id, name: e.name, category: e.category, owner: ownerLabel(plan, e.ownerRole), field: "annualRepayment",
label: "Tilgung pro Jahr", direction: "out", label: "Tilgung pro Jahr", direction: "out",
hint: "Reduziert die Restschuld. Endet automatisch, sobald sie getilgt ist.", hint: "Reduziert die Restschuld. Endet automatisch, sobald sie getilgt ist.",
}); });
@@ -378,11 +388,21 @@ export function RateDistributionDialog({
const key = (t: RateTarget) => `${t.elementId}:${t.field}`; const key = (t: RateTarget) => `${t.elementId}:${t.field}`;
// Vorbelegung mit dem EFFEKTIVEN Wert -- also inklusive der Vererbung aus der Vorphase
// (Roadmap Nr. 44, Punkt A). Bis 0.32 wurde nur der roh gespeicherte Wert der Phase
// gelesen: Eine 3a-Einzahlung von 7'000, die aus Phase 1 geerbt wurde, erschien hier als 0,
// obwohl die Rechnung korrekt mit 7'000 lief.
const orderedPhaseIds = useMemo(
() => [...plan.phases].sort((a, b) => a.sequenceNumber - b.sequenceNumber).map((p) => p.id),
[plan.phases]
);
const [draft, setDraft] = useState<Record<string, number>>(() => { const [draft, setDraft] = useState<Record<string, number>>(() => {
const d: Record<string, number> = {}; const d: Record<string, number> = {};
for (const t of targets) { for (const t of targets) {
const e = plan.elements.find((x) => x.id === t.elementId)!; const e = plan.elements.find((x) => x.id === t.elementId)!;
d[key(t)] = Math.round(num((e.phaseValues[phaseId] ?? {})[t.field])); const own = (e.phaseValues[phaseId] ?? {})[t.field];
const inherited = inheritedPhaseValues(e.phaseValues, orderedPhaseIds, phaseId)[t.field];
d[key(t)] = Math.round(typeof own === "number" ? own : num(inherited));
} }
return d; return d;
}); });
@@ -464,19 +484,29 @@ export function RateDistributionDialog({
In dieser Phase gibt es keine Elemente mit jährlichen Raten. Die ganze Quote läuft aufs Cash-Konto. In dieser Phase gibt es keine Elemente mit jährlichen Raten. Die ganze Quote läuft aufs Cash-Konto.
</p> </p>
) : ( ) : (
<div className="flex flex-col gap-2"> // Bewusst nach RICHTUNG gruppiert: erst alles, was das Cash verlässt, dann alles, was
{targets.map((t) => ( // hineinfliesst. Gemischt war die Liste schwer zu lesen.
<div className="flex flex-col gap-4">
{(["out", "in"] as const).map((dir) => {
const rows = targets.filter((t) => t.direction === dir);
if (rows.length === 0) return null;
return (
<div key={dir} className="flex flex-col gap-2">
<div
className={`flex items-center gap-2 text-xs font-semibold uppercase tracking-wide ${
dir === "in" ? "text-success" : "text-muted"
}`}
>
{dir === "in" ? "Ins Cash (Entnahmen)" : "Vom Cash (Einzahlungen und Tilgungen)"}
</div>
{rows.map((t) => (
<div key={key(t)} className="rounded-xl border border-border p-3"> <div key={key(t)} className="rounded-xl border border-border p-3">
<div className="mb-2 flex flex-wrap items-center gap-2 text-sm"> <div className="mb-2 flex flex-wrap items-center gap-2 text-sm">
<span className="font-semibold text-fg">{t.name}</span> <span className="font-semibold text-fg">{t.name}</span>
<span className="text-xs text-faint">{CATEGORY_LABELS[t.category as keyof typeof CATEGORY_LABELS]}</span> <span className="text-xs text-faint">
<span {CATEGORY_LABELS[t.category as keyof typeof CATEGORY_LABELS]}
className={`rounded px-1.5 py-0.5 text-[10px] ${
t.direction === "in" ? "bg-success/15 text-success" : "bg-surface-2 text-muted"
}`}
>
{t.direction === "in" ? "ins Cash" : "vom Cash"}
</span> </span>
<span className="rounded bg-surface-2 px-1.5 py-0.5 text-[10px] text-muted">{t.owner}</span>
<InfoBubble text={t.hint} /> <InfoBubble text={t.hint} />
</div> </div>
<MoneyField <MoneyField
@@ -487,6 +517,9 @@ export function RateDistributionDialog({
</div> </div>
))} ))}
</div> </div>
);
})}
</div>
)} )}
{/* Live-Bilanz über die ganze Phase */} {/* Live-Bilanz über die ganze Phase */}
+101 -39
View File
@@ -68,6 +68,11 @@ export interface CellContext {
// Alter des Element-Besitzers im ersten Jahr dieser Phase. Entscheidet bei der AHV, ob // Alter des Element-Besitzers im ersten Jahr dieser Phase. Entscheidet bei der AHV, ob
// das Referenzalter in diese Phase fällt (Kap. 4.4.6). // das Referenzalter in diese Phase fällt (Kap. 4.4.6).
ownerAgeStart: number; ownerAgeStart: number;
// Mögliche Ziele der Anlage-Quote beim Kapitalbezug (Punkt C): die in der FOLGEphase noch
// aktiven Vermögens-Elemente.
investTargets: { id: string; name: string }[];
// Betrag, der aus einem Kapitalbezug in DIESES Element umgeleitet wurde (Punkt C).
capitalFromTransfer: number;
} }
interface Props { interface Props {
@@ -253,18 +258,10 @@ export function CashTransitionFields({
ct, ct,
setC, setC,
deflatorEnd, deflatorEnd,
// Roadmap Nr. 44, Punkt C: Nur am Pensions-Übergang gefragt -- dort kommt das Kapital
// (PK, 3a, Immobilienverkauf) auf einmal herein und will verwendet werden.
showCapitalUse = false,
capitalInflow = 0,
investTargets = [],
}: { }: {
ct: CashTransitionData; ct: CashTransitionData;
setC: (patch: Partial<CashTransitionData>) => void; setC: (patch: Partial<CashTransitionData>) => void;
deflatorEnd: number; // Bestands-Deflator an der Phasengrenze deflatorEnd: number; // Bestands-Deflator an der Phasengrenze
showCapitalUse?: boolean;
capitalInflow?: number;
investTargets?: { id: string; name: string }[];
}) { }) {
const mode = ct.mode ?? "NONE"; const mode = ct.mode ?? "NONE";
const showIn = mode === "INFLOW" || mode === "BOTH"; const showIn = mode === "INFLOW" || mode === "BOTH";
@@ -365,39 +362,41 @@ export function CashTransitionFields({
</> </>
)} )}
{showCapitalUse && <CapitalUseFields ct={ct} setC={setC} inflow={capitalInflow} targets={investTargets} />}
</> </>
); );
} }
// Verwendung des Kapitalzuflusses am Pensions-Übergang (Roadmap Nr. 44, Punkt C). // Verwendung des BEZOGENEN ALTERSKAPITALS (Roadmap Nr. 44, Punkt C).
//
// Steht am Bezugs-Entscheid des jeweiligen Guthabens (PK bzw. Säule 3a) -- bis 0.32 hing die
// Frage am Cash-Übergang, wo sie sich mit zwei Guthaben gar nicht getrennt beantworten liess.
// //
// In PROZENT und nicht in Franken: Verschiebt man das Pensionsalter, ändert sich das bezogene // In PROZENT und nicht in Franken: Verschiebt man das Pensionsalter, ändert sich das bezogene
// Kapital. Ein Frankenbetrag müsste dann von Hand nachgezogen werden -- und würde bis dahin // Kapital. Ein Frankenbetrag müsste dann von Hand nachgezogen werden -- und würde bis dahin
// still eine falsche Aufteilung rechnen. Eine Quote skaliert mit. // still eine falsche Aufteilung rechnen. Eine Quote skaliert mit.
function CapitalUseFields({ export function CapitalUseFields({
ct, td,
setC, setT,
inflow, netAmount,
targets, targets,
}: { }: {
ct: CashTransitionData; td: TransitionData;
setC: (patch: Partial<CashTransitionData>) => void; setT: (patch: Partial<TransitionData>) => void;
inflow: number; // Netto-Auszahlung, auf die sich die Prozente beziehen.
netAmount: number;
targets: { id: string; name: string }[]; targets: { id: string; name: string }[];
}) { }) {
const amort = Math.max(0, Math.min(100, num(ct.capitalUseAmortizationPct))); const amort = Math.max(0, Math.min(100, num(td.capitalUseAmortizationPct)));
const invest = Math.max(0, Math.min(100 - amort, num(ct.capitalUseInvestPct))); const invest = Math.max(0, Math.min(100 - amort, num(td.capitalUseInvestPct)));
const cash = Math.max(0, 100 - amort - invest); const cash = Math.max(0, 100 - amort - invest);
return ( return (
<> <>
<div className="sm:col-span-2 rounded-lg bg-surface-2 px-3 py-2 text-xs text-muted"> <div className="sm:col-span-2 rounded-lg border border-border bg-surface-2 px-3 py-2 text-xs text-muted">
<strong className="text-fg">Verwendung des Kapitalzuflusses</strong> (PK-Kapital, Säule 3a, <strong className="text-fg">Wohin fliesst das bezogene Kapital?</strong> Von den{" "}
Verkaufserlös). Bei diesem Übergang fliessen{" "} <strong className="text-fg">{formatChf(netAmount)}</strong> netto teilst du hier in Prozent zu
<strong className="text-fg">{formatChf(inflow)}</strong> netto herein. Erfasst wird die so bleibt die Aufteilung richtig, wenn sich der Betrag durch ein anderes Pensionsalter ändert.
Aufteilung in Prozent so bleibt sie richtig, wenn du das Pensionsalter verschiebst und Was nicht zugeteilt ist, bleibt auf dem Cash-Konto.
sich der Betrag dadurch ändert. Was nicht zugeteilt ist, bleibt Cash.
</div> </div>
<NumberField <NumberField
label="… in die Hypothek (Amortisation, %)" label="… in die Hypothek (Amortisation, %)"
@@ -406,31 +405,38 @@ function CapitalUseFields({
min={0} min={0}
max={100} max={100}
value={amort} value={amort}
onChange={(v) => setC({ capitalUseAmortizationPct: Math.max(0, Math.min(100, v)) })} onChange={(v) => setT({ capitalUseAmortizationPct: Math.max(0, Math.min(100, v)) })}
/> />
<NumberField <NumberField
label="… in die Anlage (%)" label="… in die Anlage (%)"
help="Fliesst als Zusatzinvestition in das gewählte Vermögens-Element und wächst dort mit dessen Rendite weiter." help="Fliesst als Zusatzeinlage in das gewählte Vermögens-Element der Folgephase und wächst dort mit dessen Rendite weiter."
step={5} step={5}
min={0} min={0}
max={100 - amort} max={100 - amort}
value={invest} value={invest}
onChange={(v) => setC({ capitalUseInvestPct: Math.max(0, Math.min(100 - amort, v)) })} onChange={(v) => setT({ capitalUseInvestPct: Math.max(0, Math.min(100 - amort, v)) })}
/> />
{invest > 0 && targets.length > 0 && ( {invest > 0 && targets.length > 0 && (
<div className="sm:col-span-2"> <div className="sm:col-span-2">
<SelectField <SelectField
label="Ziel der Anlage-Quote" label="Ziel der Anlage-Quote"
help="Welches Vermögens-Element wird aufgestockt?" help="Welches Vermögens-Element wird aufgestockt?"
value={ct.capitalUseTargetElementId ?? targets[0].id} value={td.capitalUseTargetElementId ?? targets[0].id}
onChange={(v: string) => setC({ capitalUseTargetElementId: v })} onChange={(v: string) => setT({ capitalUseTargetElementId: v })}
options={targets.map((t) => ({ value: t.id, label: t.name }))} options={targets.map((t) => ({ value: t.id, label: t.name }))}
/> />
</div> </div>
)} )}
{invest > 0 && (
<DerivedField
label="… in die Anlage (Betrag)"
value={Math.round((netAmount * invest) / 100)}
help="Erscheint in der Folgephase als Zusatzeinlage beim gewählten Element."
/>
)}
<DerivedField <DerivedField
label={`… bleibt Cash (${cash} %)`} label={`… bleibt Cash (${cash} %)`}
value={Math.round((inflow * cash) / 100)} value={Math.round((netAmount * cash) / 100)}
help="Der nicht zugeteilte Rest. Wird automatisch berechnet." help="Der nicht zugeteilte Rest. Wird automatisch berechnet."
/> />
</> </>
@@ -795,9 +801,17 @@ export function ElementPhaseFields({
label="Zusatzinvestition aus Kapital (CHF)" label="Zusatzinvestition aus Kapital (CHF)"
help="Neuinvestition aus dem verfügbaren Kapital dieser Phase." help="Neuinvestition aus dem verfügbaren Kapital dieser Phase."
value={num(pd.additionalInvestment)} value={num(pd.additionalInvestment)}
onChange={(v) => setP({ additionalInvestment: v })} onChange={(v) => setP({ additionalInvestment: v })}
/> />
{/* Aus einem Kapitalbezug (PK/3a) am letzten Übergang umgeleitet -- nicht hier
erfasst, sondern dort als Quote entschieden (Punkt C). */}
{context.capitalFromTransfer > 0 && (
<DerivedField
label="Davon aus Kapitalbezug (PK/3a)"
value={context.capitalFromTransfer}
help="Beim letzten Übergang wurde ein Teil des bezogenen Alterskapitals hierher umgeleitet. Die Quote änderst du im Bezugs-Entscheid der Pensionskasse bzw. der Säule 3a."
/>
)}
</> </>
) : ( ) : (
<MoneyField label="Startwert (CHF)" value={num(pd.startValue)} onChange={(v) => setP({ startValue: v })} /> <MoneyField label="Startwert (CHF)" value={num(pd.startValue)} onChange={(v) => setP({ startValue: v })} />
@@ -873,9 +887,16 @@ export function ElementTransitionFields({
Die Beitragskarriere wird erst beim Übergang in die Pensionierung geprüft. Die Beitragskarriere wird erst beim Übergang in die Pensionierung geprüft.
</p> </p>
); );
case "PENSION_FUND": case "PENSION_FUND": {
if (context.isRetirementTransition) { if (context.isRetirementTransition) {
const mode = td.payoutMode ?? "PENSION"; const mode = td.payoutMode ?? "PENSION";
const taxRate = num(td.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE);
// Brutto = ganzes Guthaben (Kapitalbezug) bzw. der gewählte Teil (Kombination).
const brutto =
mode === "COMBI"
? Math.min(context.carriedEndValue, Math.round(num(td.capitalAmount)))
: context.carriedEndValue;
const netto = Math.round(brutto * (1 - taxRate / 100));
return ( return (
<> <>
<SelectField <SelectField
@@ -897,9 +918,6 @@ export function ElementTransitionFields({
onChange={(v) => setT({ conversionRate: v })} onChange={(v) => setT({ conversionRate: v })}
/> />
)} )}
{(mode === "CAPITAL" || mode === "COMBI") && (
<NumberField label="Kapitalbezugssteuer (%)" step={0.5} value={num(td.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE)} onChange={(v) => setT({ capitalTaxRate: v })} />
)}
{mode === "COMBI" && ( {mode === "COMBI" && (
<MoneyField <MoneyField
label="Davon Kapitalbezug (CHF)" label="Davon Kapitalbezug (CHF)"
@@ -909,23 +927,67 @@ export function ElementTransitionFields({
onChange={(v) => setT({ capitalAmount: v })} onChange={(v) => setT({ capitalAmount: v })}
/> />
)} )}
{/* Erst der Betrag, dann die Steuer, dann die Verwendung -- in der Reihenfolge,
in der man die Entscheidung tatsächlich trifft. */}
{(mode === "CAPITAL" || mode === "COMBI") && (
<>
<DerivedField
label="Kapitalbezug brutto"
value={brutto}
help="Das Guthaben am Ende der Vorphase (bei einer Kombination der oben gewählte Anteil)."
/>
<NumberField
label="Kapitalbezugssteuer (%)"
help="Pauschalsatz die tatsächliche Steuer ist kantonal und progressiv."
step={0.5}
value={taxRate}
onChange={(v) => setT({ capitalTaxRate: v })}
/>
<DerivedField
label="Auszahlung netto"
value={netto}
help="Brutto abzüglich Kapitalbezugssteuer. Dieser Betrag wird unten verteilt."
/>
<CapitalUseFields td={td} setT={setT} netAmount={netto} targets={context.investTargets} />
</>
)}
</> </>
); );
} }
return <WithdrawalDecision td={td} setT={setT} max={context.carriedEndValue} label="PK-Bezug (CHF)" />; return <WithdrawalDecision td={td} setT={setT} max={context.carriedEndValue} label="PK-Bezug (CHF)" />;
case "PILLAR_3A": }
case "PILLAR_3A": {
if (context.isRetirementTransition) { if (context.isRetirementTransition) {
// Die Säule 3a wird bei der Pensionierung IMMER vollständig bezogen -- entschieden
// wird hier der Steuersatz und die Verwendung des Geldes.
const taxRate = num(td.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE);
const brutto = context.carriedEndValue;
const netto = Math.round(brutto * (1 - taxRate / 100));
return ( return (
<>
<p className="col-span-2 rounded-lg bg-surface-2 px-3 py-2 text-xs text-muted">
Die Säule 3a wird bei der Pensionierung <strong className="text-fg">vollständig bezogen</strong>. Zu
entscheiden sind der Steuersatz und die Verwendung des Geldes.
</p>
<DerivedField label="Bezug brutto" value={brutto} help="Das Guthaben am Ende der Vorphase." />
<NumberField <NumberField
label="Kapitalbezugssteuer (%)" label="Kapitalbezugssteuer (%)"
help="Die Säule 3a wird bei Pensionierung vollständig bezogen." help="Pauschalsatz die tatsächliche Steuer ist kantonal und progressiv. Gestaffelte Bezüge (PK und 3a in verschiedenen Jahren) senken sie; das bildet das Tool noch nicht ab."
step={0.5} step={0.5}
value={num(td.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE)} value={taxRate}
onChange={(v) => setT({ capitalTaxRate: v })} onChange={(v) => setT({ capitalTaxRate: v })}
/> />
<DerivedField
label="Auszahlung netto"
value={netto}
help="Brutto abzüglich Kapitalbezugssteuer. Dieser Betrag wird unten verteilt."
/>
<CapitalUseFields td={td} setT={setT} netAmount={netto} targets={context.investTargets} />
</>
); );
} }
return <WithdrawalDecision td={td} setT={setT} max={context.carriedEndValue} label="3a-Bezug (CHF)" />; return <WithdrawalDecision td={td} setT={setT} max={context.carriedEndValue} label="3a-Bezug (CHF)" />;
}
case "REAL_ESTATE": { case "REAL_ESTATE": {
const decision = td.decision ?? "HOLD"; const decision = td.decision ?? "HOLD";
const marktwert = Math.round(context.propertyValueEnd); const marktwert = Math.round(context.propertyValueEnd);
+74 -5
View File
@@ -1,27 +1,96 @@
"use client"; "use client";
import { useState } from "react"; import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { Info } from "lucide-react"; import { Info } from "lucide-react";
// Hilfetext-Bubble.
//
// Der Text wird über ein PORTAL an den <body> gehängt und `fixed` positioniert. Vorher lag er
// als `absolute` Kind im Fluss -- in einem scrollenden Dialog (Verteil-Dialoge, Assistent)
// schnitt dessen `overflow` die Box ab, teilweise nach wenigen Zeilen. Fix positioniert
// bezieht sich auf den Viewport und entkommt damit jedem Overflow-Container.
//
// Zusätzlich: Die Box klappt nach OBEN, wenn unten kein Platz mehr ist, und wird an den
// Bildschirmrändern geklemmt, damit sie nie halb ausserhalb steht.
const WIDTH = 256; // entspricht w-64
const MARGIN = 8;
export function InfoBubble({ text }: { text: string }) { export function InfoBubble({ text }: { text: string }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [pos, setPos] = useState<{ top: number; left: number } | null>(null);
const btnRef = useRef<HTMLButtonElement | null>(null);
const boxRef = useRef<HTMLDivElement | null>(null);
// Position aus der Lage des Knopfes berechnen -- erst wenn geöffnet, und erneut, sobald die
// tatsächliche Höhe der Box bekannt ist (für das Hochklappen).
useEffect(() => {
if (!open) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- Position gilt nur im offenen Zustand
setPos(null);
return;
}
const place = () => {
const btn = btnRef.current;
if (!btn) return;
const r = btn.getBoundingClientRect();
const height = boxRef.current?.offsetHeight ?? 0;
const spaceBelow = window.innerHeight - r.bottom;
const above = height > 0 && spaceBelow < height + MARGIN && r.top > height + MARGIN;
const left = Math.min(
Math.max(MARGIN, r.left + r.width / 2 - WIDTH / 2),
Math.max(MARGIN, window.innerWidth - WIDTH - MARGIN)
);
setPos({ top: above ? r.top - height - 6 : r.bottom + 6, left });
};
place();
// Nach dem ersten Zeichnen erneut -- dann steht die Höhe fest.
const raf = requestAnimationFrame(place);
window.addEventListener("scroll", place, true);
window.addEventListener("resize", place);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener("scroll", place, true);
window.removeEventListener("resize", place);
};
}, [open]);
return ( return (
<span className="relative inline-flex align-middle ml-1"> <span className="relative inline-flex align-middle ml-1">
<button <button
ref={btnRef}
type="button" type="button"
aria-label="Hilfe anzeigen" aria-label="Hilfe anzeigen"
onMouseEnter={() => setOpen(true)} onMouseEnter={() => setOpen(true)}
onMouseLeave={() => setOpen(false)} onMouseLeave={() => setOpen(false)}
onClick={() => setOpen((o) => !o)} onClick={(e) => {
e.stopPropagation();
setOpen((o) => !o);
}}
className="flex h-4 w-4 items-center justify-center rounded-full bg-accent-soft text-accent-soft-fg hover:opacity-80" className="flex h-4 w-4 items-center justify-center rounded-full bg-accent-soft text-accent-soft-fg hover:opacity-80"
> >
<Info className="h-2.5 w-2.5" strokeWidth={2.5} /> <Info className="h-2.5 w-2.5" strokeWidth={2.5} />
</button> </button>
{open && ( {open &&
<span className="absolute left-1/2 top-6 z-20 w-64 -translate-x-1/2 rounded-lg border border-border bg-surface p-2.5 text-xs leading-snug text-fg shadow-lg"> typeof document !== "undefined" &&
createPortal(
<div
ref={boxRef}
role="tooltip"
style={{
position: "fixed",
top: pos?.top ?? -9999,
left: pos?.left ?? -9999,
width: WIDTH,
// Vor der ersten Messung unsichtbar, damit die Box nicht kurz aufblitzt.
visibility: pos ? "visible" : "hidden",
}}
className="pointer-events-none z-[60] rounded-lg border border-border bg-surface p-2.5 text-xs leading-snug text-fg shadow-lg"
>
{text} {text}
</span> </div>,
document.body
)} )}
</span> </span>
); );
+55 -14
View File
@@ -4,11 +4,14 @@ import { useState } from "react";
import { Trash2 } from "lucide-react"; import { Trash2 } from "lucide-react";
import { NumberField, TextField } from "@/components/FormField"; import { NumberField, TextField } from "@/components/FormField";
import { CarryWarning } from "@/components/ElementDetail"; import { CarryWarning } from "@/components/ElementDetail";
import { Button, useConfirm, useToast } from "@/components/ui";
import { api } from "@/lib/api-client"; import { api } from "@/lib/api-client";
import { planDurationChange } from "@/lib/phaseplan";
import type { PhaseInput } from "@/lib/types"; import type { PhaseInput } from "@/lib/types";
export function PhaseDetail({ export function PhaseDetail({
phase, phase,
allPhases,
maxDurationYears, maxDurationYears,
isLast, isLast,
laterPhaseCount, laterPhaseCount,
@@ -16,6 +19,9 @@ export function PhaseDetail({
onDeleted, onDeleted,
}: { }: {
phase: PhaseInput; phase: PhaseInput;
// Alle Phasen des Szenarios -- nötig, um die Auswirkung einer geänderten Dauer auf die
// Folgephase VORHER zu zeigen (siehe planDurationChange).
allPhases: PhaseInput[];
maxDurationYears: number | null; maxDurationYears: number | null;
isLast: boolean; isLast: boolean;
laterPhaseCount: number; laterPhaseCount: number;
@@ -26,10 +32,32 @@ export function PhaseDetail({
const [durationYears, setDurationYears] = useState(phase.durationYears); const [durationYears, setDurationYears] = useState(phase.durationYears);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const confirm = useConfirm();
const toast = useToast();
const cap = maxDurationYears; const cap = maxDurationYears;
// Vorschau: Was passiert mit der Folgephase? Wird live berechnet, damit die Auswirkung
// sichtbar ist, BEVOR gespeichert wird.
const change = planDurationChange(allPhases, phase.id, durationYears);
async function save() { async function save() {
// Verschiebt sich eine Folgephase, wird das vorher bestätigt -- sonst ändert ein Klick
// stillschweigend zwei Phasen.
if (change.ok && change.neighbourId && change.delta !== 0) {
const jahre = Math.abs(change.delta);
const ok = await confirm({
title: "Folgephase wird angepasst",
message:
`Wenn du «${phase.name}» um ${jahre} Jahr${jahre === 1 ? "" : "e"} ` +
`${change.delta > 0 ? "verlängerst" : "verkürzst"}, wird «${change.neighbourName}» automatisch auf ` +
`${change.neighbourNewDuration} Jahr${change.neighbourNewDuration === 1 ? "" : "e"} ` +
`${change.delta > 0 ? "gekürzt" : "verlängert"} die Gesamtdauer des Plans bleibt gleich. ` +
`Ohne diesen Ausgleich würde eine spätere Lebensphase eine Pensionierung überspannen.`,
confirmLabel: "Fortfahren",
});
if (!ok) return;
}
setSaving(true); setSaving(true);
setError(null); setError(null);
try { try {
@@ -43,21 +71,25 @@ export function PhaseDetail({
} }
async function remove() { async function remove() {
if (!confirm(`Phase "${phase.name}" wirklich löschen?`)) return; const ok = await confirm({
title: "Lebensphase löschen?",
message: `«${phase.name}» wird mit allen erfassten Werten und Übergangs-Entscheiden entfernt.`,
confirmLabel: "Endgültig löschen",
danger: true,
});
if (!ok) return;
try { try {
await api.delete(`/api/phases/${phase.id}`); await api.delete(`/api/phases/${phase.id}`);
onDeleted(); onDeleted();
} catch (e) { } catch (e) {
alert(e instanceof Error ? e.message : "Löschen fehlgeschlagen."); toast("error", e instanceof Error ? e.message : "Löschen fehlgeschlagen.");
} }
} }
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="text-xs font-semibold uppercase tracking-wide text-accent-soft-fg"> <div className="text-xs font-semibold uppercase tracking-wide text-accent-soft-fg">Lebensphase</div>
Lebensphase
</div>
{isLast && ( {isLast && (
<button <button
type="button" type="button"
@@ -82,18 +114,27 @@ export function PhaseDetail({
onChange={(v) => setDurationYears(cap != null ? Math.min(v, cap) : v)} onChange={(v) => setDurationYears(cap != null ? Math.min(v, cap) : v)}
/> />
</div> </div>
<p className="text-xs text-faint">Die Inflationsrate gilt plan-weit und wird in den Plan-Einstellungen gesetzt.</p>
{/* Auswirkung auf die Folgephase -- sichtbar, bevor gespeichert wird. */}
{change.error && (
<p className="rounded-lg border border-danger bg-danger-soft px-3 py-2 text-xs text-danger">{change.error}</p>
)}
{change.ok && change.neighbourId && change.delta !== 0 && (
<p className="rounded-lg border border-attention bg-attention-soft px-3 py-2 text-xs text-attention-soft-fg">
«{change.neighbourName}» wird dadurch auf <strong>{change.neighbourNewDuration} Jahre</strong>{" "}
{change.delta > 0 ? "gekürzt" : "verlängert"} die Gesamtdauer des Plans bleibt gleich.
</p>
)}
<p className="text-xs text-faint">
Die Inflationsrate gilt für das ganze Szenario und wird im Grundprofil gesetzt.
</p>
{error && <p className="text-sm text-danger">{error}</p>} {error && <p className="text-sm text-danger">{error}</p>}
<div> <div>
<button <Button disabled={saving || !!change.error} onClick={save}>
type="button" {saving ? "Speichern…" : "Speichern"}
disabled={saving} </Button>
onClick={save}
className="rounded-lg bg-accent px-4 py-2 text-sm font-medium text-accent-fg shadow-sm hover:bg-accent-hover disabled:opacity-50"
>
{saving ? "Speichern..." : "Speichern"}
</button>
</div> </div>
</div> </div>
); );
+285 -30
View File
@@ -12,12 +12,14 @@ import {
Home, Home,
Landmark, Landmark,
ListTodo, ListTodo,
Pencil,
Maximize2, Maximize2,
PiggyBank, PiggyBank,
Plus, Plus,
Settings2, Settings2,
ShoppingCart, ShoppingCart,
Tornado, Tornado,
Trash2,
TrendingUp, TrendingUp,
Wallet, Wallet,
} from "lucide-react"; } from "lucide-react";
@@ -48,7 +50,7 @@ import {
import { PhaseDetail } from "@/components/PhaseDetail"; import { PhaseDetail } from "@/components/PhaseDetail";
import { PlanProfileFields, type ProfileDraft } from "@/components/PlanProfileFields"; import { PlanProfileFields, type ProfileDraft } from "@/components/PlanProfileFields";
import { RetirementAdjuster } from "@/components/RetirementAdjuster"; import { RetirementAdjuster } from "@/components/RetirementAdjuster";
import { MoneyField } from "@/components/FormField"; import { MoneyField, SelectField, TextField } from "@/components/FormField";
import { api } from "@/lib/api-client"; import { api } from "@/lib/api-client";
import { formatChf } from "@/lib/format"; import { formatChf } from "@/lib/format";
import { DATA_SOURCE_OPTIONS, type DataSource } from "@/lib/dataview"; import { DATA_SOURCE_OPTIONS, type DataSource } from "@/lib/dataview";
@@ -97,6 +99,7 @@ type Column =
// Genau ein Panel kann offen sein -- deshalb eine Union statt sechs einzelner Zustände. // Genau ein Panel kann offen sein -- deshalb eine Union statt sechs einzelner Zustände.
type Panel = type Panel =
| { kind: "phase"; phaseId: string } | { kind: "phase"; phaseId: string }
| { kind: "elementMeta"; elementId: string }
| { kind: "cell"; elementId: string; phaseId: string } | { kind: "cell"; elementId: string; phaseId: string }
| { kind: "transition"; elementId: string; fromPhaseId: string } | { kind: "transition"; elementId: string; fromPhaseId: string }
| { kind: "cashTransition"; fromPhaseId: string } | { kind: "cashTransition"; fromPhaseId: string }
@@ -279,6 +282,8 @@ export function PlanView({
ahvCareer: careerFor(element), ahvCareer: careerFor(element),
inheritedValues: inheritedPhaseValues(element.phaseValues, orderedPhaseIds, phase.id), inheritedValues: inheritedPhaseValues(element.phaseValues, orderedPhaseIds, phase.id),
ownerAgeStart: ownerAgeAtPhaseStart(phase, element), ownerAgeStart: ownerAgeAtPhaseStart(phase, element),
investTargets: [],
capitalFromTransfer: ce?.capitalFromTransfer ?? 0,
}; };
} }
@@ -303,6 +308,9 @@ export function PlanView({
ahvCareer: careerFor(element), ahvCareer: careerFor(element),
inheritedValues: {}, inheritedValues: {},
ownerAgeStart: ownerAgeAtPhaseStart(fromPhase, element), ownerAgeStart: ownerAgeAtPhaseStart(fromPhase, element),
// Ziele fuer die Anlage-Quote beim Kapitalbezug (Punkt C).
investTargets: investTargetsOf(toPhase),
capitalFromTransfer: 0,
}; };
} }
@@ -500,6 +508,26 @@ export function PlanView({
> >
<Plus className="h-4 w-4" /> Lebensphase <Plus className="h-4 w-4" /> Lebensphase
</button> </button>
{/* Alle Kategoriegruppen auf einen Schlag auf- oder zuklappen. */}
<button
type="button"
onClick={() =>
setCollapsedCats((prev) =>
prev.size === 0 ? new Set(CATEGORY_ORDER.filter((c) => (elementsByCategory.get(c)?.length ?? 0) > 0)) : new Set()
)
}
className="ml-auto flex items-center gap-1.5 rounded-lg border border-border px-2.5 py-1.5 text-xs font-medium text-muted transition-colors hover:bg-surface-2 hover:text-fg"
>
{collapsedCats.size === 0 ? (
<>
<ChevronRight className="h-3.5 w-3.5" /> Alle zuklappen
</>
) : (
<>
<ChevronDown className="h-3.5 w-3.5" /> Alle aufklappen
</>
)}
</button>
</div> </div>
)} )}
@@ -525,10 +553,13 @@ export function PlanView({
beim Scrollen sichtbar bleiben. */} beim Scrollen sichtbar bleiben. */}
{hasPhases && ( {hasPhases && (
<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">
<table className="w-full border-collapse text-sm"> {/* Feste Spaltenbreiten: Alle Phasenspalten sind gleich breit -- bei einer einzigen
Phase bleibt die Tabelle dadurch schmal, bei vielen wird horizontal gescrollt.
Ein w-full haette die Spalten je nach Anzahl unterschiedlich gedehnt. */}
<table className="border-collapse text-sm">
<thead> <thead>
<tr> <tr>
<th className="sticky left-0 top-0 z-40 min-w-44 border-b border-r border-border bg-surface-2 px-3 py-2 text-left text-xs font-semibold uppercase tracking-wide text-faint"> <th className="sticky left-0 top-0 z-40 w-56 min-w-56 border-b border-r border-border bg-surface-2 px-3 py-2 text-left text-xs font-semibold uppercase tracking-wide text-faint">
Finanzielle Elemente Finanzielle Elemente
</th> </th>
{columns.map((col) => {columns.map((col) =>
@@ -545,6 +576,11 @@ export function PlanView({
col.phase.sequenceNumber > 1 ? () => setDistribute({ kind: "capital", phaseId: col.phase.id }) : null col.phase.sequenceNumber > 1 ? () => setDistribute({ kind: "capital", phaseId: col.phase.id }) : null
} }
onDistributeRates={() => setDistribute({ kind: "rates", phaseId: col.phase.id })} onDistributeRates={() => setDistribute({ kind: "rates", phaseId: col.phase.id })}
onDelete={
col.phase.sequenceNumber === computed.phases.length
? () => void deletePhase(col.phase.id, col.phase.name)
: null
}
active={panel?.kind === "phase" && panel.phaseId === col.phase.id} active={panel?.kind === "phase" && panel.phaseId === col.phase.id}
/> />
) : ( ) : (
@@ -634,7 +670,37 @@ export function PlanView({
{CATEGORY_LABELS[cat]} {CATEGORY_LABELS[cat]}
</span> </span>
</td> </td>
<td colSpan={columns.length} className="border-b border-border" /> {/* Zugeklappt: Summe der Elementwerte je Phase -- sonst verschwindet die
Information beim Zuklappen. Aufgeklappt bleibt die Zeile leer, weil
man die Einzelwerte darunter sieht. */}
{collapsed
? columns.map((col) =>
col.kind === "phase" ? (
<td
key={`sum-${cat}-${col.phase.id}`}
className="border-b border-r border-border bg-surface-2 px-2 py-1.5 text-center text-xs font-semibold text-muted"
>
{(() => {
const rows = els
.map((e) => computedElement(col.phase.id, e.id))
.filter((ce): ce is NonNullable<typeof ce> => !!ce && ce.status === "ACTIVE");
if (rows.length === 0) return "";
const start = rows.reduce((sum, ce) => sum + ce.startValue, 0);
const end = rows.reduce((sum, ce) => sum + ce.endValue, 0);
return (
<span className="whitespace-nowrap">
{valStr(start, col.phase.cumulativeInflationStart, valueMode)}{" "}
<span className="text-faint"></span>{" "}
{valStr(end, col.phase.cumulativeInflationEnd, valueMode)}
</span>
);
})()}
</td>
) : (
<td key={`sum-t-${cat}-${col.fromPhase.id}`} className="border-b border-r border-border bg-surface-2" />
)
)
: <td colSpan={columns.length} className="border-b border-border" />}
</tr> </tr>
{!collapsed && {!collapsed &&
els.map((el) => ( els.map((el) => (
@@ -645,6 +711,33 @@ export function PlanView({
{diff?.elementRow.get(el.id) === "added" && ( {diff?.elementRow.get(el.id) === "added" && (
<span className="rounded bg-diff-added px-1 text-[9px] font-semibold uppercase text-white">neu</span> <span className="rounded bg-diff-added px-1 text-[9px] font-semibold uppercase text-white">neu</span>
)} )}
{/* Aktionen zum Element selbst (phasenunabhängig): umbenennen,
löschen, Detailansicht -- bewusst IMMER sichtbar, damit sie
auch ohne Maus (Touch) erreichbar sind. */}
<button
type="button"
aria-label={`${el.name} umbenennen`}
title="Umbenennen und Zuordnung ändern"
onClick={(e) => {
e.stopPropagation();
setPanel({ kind: "elementMeta", elementId: el.id });
}}
className="shrink-0 rounded p-0.5 text-faint hover:bg-accent-soft hover:text-accent"
>
<Pencil className="h-3 w-3" />
</button>
<button
type="button"
aria-label={`${el.name} löschen`}
title="Element löschen"
onClick={(e) => {
e.stopPropagation();
void deleteElement(el.id, el.name);
}}
className="shrink-0 rounded p-0.5 text-faint hover:bg-danger-soft hover:text-danger"
>
<Trash2 className="h-3 w-3" />
</button>
<button <button
type="button" type="button"
aria-label={`Detailansicht ${el.name}`} aria-label={`Detailansicht ${el.name}`}
@@ -653,7 +746,7 @@ export function PlanView({
e.stopPropagation(); e.stopPropagation();
setDetailFor({ kind: "element", id: el.id }); setDetailFor({ kind: "element", id: el.id });
}} }}
className="shrink-0 rounded p-0.5 text-faint opacity-0 hover:bg-accent-soft hover:text-accent group-hover/row:opacity-100" className="shrink-0 rounded p-0.5 text-faint hover:bg-accent-soft hover:text-accent"
> >
<Maximize2 className="h-3 w-3" /> <Maximize2 className="h-3 w-3" />
</button> </button>
@@ -822,6 +915,7 @@ export function PlanView({
<PhaseDetailDialog <PhaseDetailDialog
key={`detph-${detailFor.id}`} key={`detph-${detailFor.id}`}
phase={ph} phase={ph}
computed={computed}
isFirst={ph.sequenceNumber === 1} isFirst={ph.sequenceNumber === 1}
onClose={() => setDetailFor(null)} onClose={() => setDetailFor(null)}
onOpenSpec={onOpenSpec} onOpenSpec={onOpenSpec}
@@ -862,6 +956,43 @@ export function PlanView({
); );
// Nächste Phasen-Kappung (für das Phase-Popup). // Nächste Phasen-Kappung (für das Phase-Popup).
// Element loeschen -- neu auch direkt aus der Zeile heraus.
async function deleteElement(elementId: string, elementName: string) {
const ok = await confirmDialog({
title: "Element löschen?",
message: `«${elementName}» wird aus ALLEN Lebensphasen entfernt, samt Werten und Übergangs-Entscheiden.`,
confirmLabel: "Endgültig löschen",
danger: true,
});
if (!ok) return;
try {
await api.delete(`/api/elements/${elementId}`);
setPanel(null);
onChanged();
toast("success", "Element gelöscht.");
} catch (e) {
toast("error", e instanceof Error ? e.message : "Löschen fehlgeschlagen.");
}
}
async function deletePhase(phaseId: string, phaseName: string) {
const ok = await confirmDialog({
title: "Lebensphase löschen?",
message: `«${phaseName}» wird mit allen erfassten Werten und Übergangs-Entscheiden entfernt.`,
confirmLabel: "Endgültig löschen",
danger: true,
});
if (!ok) return;
try {
await api.delete(`/api/phases/${phaseId}`);
setPanel(null);
onChanged();
toast("success", "Lebensphase gelöscht.");
} catch (e) {
toast("error", e instanceof Error ? e.message : "Löschen fehlgeschlagen.");
}
}
function nextPhaseCap(): number | null { function nextPhaseCap(): number | null {
// Simpel aus den Personen ableiten (Jahre nach Planbeginn = Summe der Dauern). // Simpel aus den Personen ableiten (Jahre nach Planbeginn = Summe der Dauern).
const yearsBefore = plan.phases.reduce((s, p) => s + p.durationYears, 0); const yearsBefore = plan.phases.reduce((s, p) => s + p.durationYears, 0);
@@ -881,6 +1012,21 @@ export function PlanView({
}; };
switch (panel.kind) { switch (panel.kind) {
case "elementMeta": {
const el = plan.elements.find((e) => e.id === panel.elementId);
if (!el) return null;
return (
<InspectorShell key={`meta-${panel.elementId}`} title={`Element: ${el.name}`} onClose={close}>
<ElementMetaPanel
element={el}
plan={plan}
personLabel={personLabel}
onSaved={closeAndRefresh}
onDelete={() => deleteElement(el.id, el.name)}
/>
</InspectorShell>
);
}
case "phase": { case "phase": {
const phase = computed.phases.find((p) => p.id === panel.phaseId); const phase = computed.phases.find((p) => p.id === panel.phaseId);
const phaseInput = plan.phases.find((p) => p.id === panel.phaseId); const phaseInput = plan.phases.find((p) => p.id === panel.phaseId);
@@ -889,6 +1035,7 @@ export function PlanView({
<InspectorShell key={`ph-${panel.phaseId}`} title={`Lebensphase: ${phase.name}`} onClose={close}> <InspectorShell key={`ph-${panel.phaseId}`} title={`Lebensphase: ${phase.name}`} onClose={close}>
<PhaseDetail <PhaseDetail
phase={phaseInput} phase={phaseInput}
allPhases={plan.phases}
maxDurationYears={phase.maxDurationYears} maxDurationYears={phase.maxDurationYears}
isLast={phase.sequenceNumber === computed.phases.length} isLast={phase.sequenceNumber === computed.phases.length}
laterPhaseCount={laterPhaseCount(phase)} laterPhaseCount={laterPhaseCount(phase)}
@@ -921,7 +1068,7 @@ export function PlanView({
onSaved={closeAndRefresh} onSaved={closeAndRefresh}
onDeleteElement={() => { onDeleteElement={() => {
setPanel(null); setPanel(null);
deleteElement(element.id); deleteElement(element.id, element.name);
}} }}
/> />
</InspectorShell> </InspectorShell>
@@ -966,19 +1113,6 @@ export function PlanView({
} }
} }
async function deleteElement(id: string) {
const ok = await confirmDialog({
title: "Element löschen?",
message: "Das Element wird aus ALLEN Lebensphasen entfernt samt aller Werte und Übergangs-Entscheide.",
confirmLabel: "Endgültig löschen",
danger: true,
});
if (!ok) return;
await api.delete(`/api/elements/${id}`);
toast("success", "Element gelöscht.");
setPanel(null);
onChanged();
}
function transitionSummary(el: ElementInput, fromPhase: PhaseComputed, toPhase: PhaseComputed): string { function transitionSummary(el: ElementInput, fromPhase: PhaseComputed, toPhase: PhaseComputed): string {
const td = el.transitionValues[fromPhase.id] ?? {}; const td = el.transitionValues[fromPhase.id] ?? {};
@@ -1138,6 +1272,7 @@ function PhaseHeader({
onExpand, onExpand,
onDistributeCapital, onDistributeCapital,
onDistributeRates, onDistributeRates,
onDelete,
active, active,
}: { }: {
phase: PhaseComputed; phase: PhaseComputed;
@@ -1150,6 +1285,8 @@ function PhaseHeader({
// wird in der ersten Phase von der Berechnung ignoriert, dort zählt der Startwert). // wird in der ersten Phase von der Berechnung ignoriert, dort zählt der Startwert).
onDistributeCapital: (() => void) | null; onDistributeCapital: (() => void) | null;
onDistributeRates: () => void; onDistributeRates: () => void;
// null = nicht die letzte Phase (nur die letzte lässt sich löschen).
onDelete: (() => void) | null;
active: boolean; active: boolean;
}) { }) {
const quotaLabel = phase.isConsumption ? "Verzehrquote" : "Sparquote"; const quotaLabel = phase.isConsumption ? "Verzehrquote" : "Sparquote";
@@ -1160,7 +1297,7 @@ function PhaseHeader({
return ( return (
<th <th
onClick={onClick} onClick={onClick}
className={`group/ph sticky top-0 z-30 min-w-44 cursor-pointer border-b border-r border-border px-2 py-2 text-left align-top ${ className={`group/ph sticky top-0 z-30 w-52 min-w-52 max-w-52 cursor-pointer border-b border-r border-border px-2 py-2 text-left align-top ${
active active
? "bg-accent-soft" ? "bg-accent-soft"
: diffKind === "added" : diffKind === "added"
@@ -1180,8 +1317,35 @@ function PhaseHeader({
) : ( ) : (
<CheckCircle2 className="h-3.5 w-3.5 shrink-0 text-success" /> <CheckCircle2 className="h-3.5 w-3.5 shrink-0 text-success" />
)} )}
{/* Der Kopf öffnet per Klick das Bearbeiten-Popup -- das Expand-Icon muss das Event {/* Aktionen zur Phase. Der Kopf selbst öffnet ebenfalls das Bearbeiten-Panel; die
deshalb stoppen, sonst gingen beide Dialoge gleichzeitig auf. */} Knöpfe müssen das Event deshalb stoppen, sonst gingen zwei Dinge gleichzeitig auf.
Bewusst IMMER sichtbar (Touch hat kein Hover). */}
<button
type="button"
aria-label={`${phase.name} bearbeiten`}
title="Bezeichnung und Dauer ändern"
onClick={(e) => {
e.stopPropagation();
onClick();
}}
className="shrink-0 rounded p-0.5 text-faint hover:bg-accent-soft hover:text-accent"
>
<Pencil className="h-3 w-3" />
</button>
{onDelete && (
<button
type="button"
aria-label={`${phase.name} löschen`}
title="Lebensphase löschen"
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
className="shrink-0 rounded p-0.5 text-faint hover:bg-danger-soft hover:text-danger"
>
<Trash2 className="h-3 w-3" />
</button>
)}
<button <button
type="button" type="button"
aria-label={`Detailansicht ${phase.name}`} aria-label={`Detailansicht ${phase.name}`}
@@ -1190,7 +1354,7 @@ function PhaseHeader({
e.stopPropagation(); e.stopPropagation();
onExpand(); onExpand();
}} }}
className="shrink-0 rounded p-0.5 text-faint opacity-60 transition-opacity hover:bg-accent-soft hover:text-accent group-hover/ph:opacity-100" className="shrink-0 rounded p-0.5 text-faint hover:bg-accent-soft hover:text-accent"
> >
<Maximize2 className="h-3 w-3" /> <Maximize2 className="h-3 w-3" />
</button> </button>
@@ -1290,7 +1454,7 @@ function TransitionHeader({ openCount, onClick }: { openCount: number; onClick:
<th <th
onClick={onClick} onClick={onClick}
data-tour="transition" data-tour="transition"
className={`sticky top-0 z-30 cursor-pointer border-b border-r border-border px-2 py-2 text-center align-top text-[11px] font-medium transition-colors ${ className={`sticky top-0 z-30 w-24 min-w-24 cursor-pointer border-b border-r border-border px-2 py-2 text-center align-top text-[11px] font-medium transition-colors ${
done ? "bg-surface-2 text-success" : "bg-attention text-attention-fg" done ? "bg-surface-2 text-success" : "bg-attention text-attention-fg"
}`} }`}
> >
@@ -1491,6 +1655,8 @@ function AddElementDialog({
// Beim Anlegen gibt es keine Vorphase, aus der etwas zu erben waere. // Beim Anlegen gibt es keine Vorphase, aus der etwas zu erben waere.
inheritedValues: {}, inheritedValues: {},
ownerAgeStart: firstPhase.persons.find((p) => p.role === (owner ?? "PERSON_A"))?.startAge ?? 0, ownerAgeStart: firstPhase.persons.find((p) => p.role === (owner ?? "PERSON_A"))?.startAge ?? 0,
investTargets: [],
capitalFromTransfer: 0,
}; };
async function create() { async function create() {
@@ -1763,9 +1929,6 @@ function TransitionReviewDialog({
ct={ct} ct={ct}
setC={(patch) => setCt((prev) => ({ ...prev, ...patch }))} setC={(patch) => setCt((prev) => ({ ...prev, ...patch }))}
deflatorEnd={fromPhase.cumulativeInflationEnd} deflatorEnd={fromPhase.cumulativeInflationEnd}
showCapitalUse={(toPhase?.capitalInflow ?? 0) > 0}
capitalInflow={toPhase?.capitalInflow ?? 0}
investTargets={investTargetsOf(toPhase)}
/> />
</div> </div>
</div> </div>
@@ -1857,9 +2020,6 @@ function CashTransitionPanel({
ct={ct} ct={ct}
setC={(patch) => setCt((prev) => ({ ...prev, ...patch }))} setC={(patch) => setCt((prev) => ({ ...prev, ...patch }))}
deflatorEnd={fromPhase.cumulativeInflationEnd} deflatorEnd={fromPhase.cumulativeInflationEnd}
showCapitalUse={(toPhase?.capitalInflow ?? 0) > 0}
capitalInflow={toPhase?.capitalInflow ?? 0}
investTargets={investTargetsOf(toPhase)}
/> />
</div> </div>
{error && <p className="mt-3 text-sm text-danger">{error}</p>} {error && <p className="mt-3 text-sm text-danger">{error}</p>}
@@ -2024,3 +2184,98 @@ function PanelActions({
</div> </div>
); );
} }
// --- Panel: Element umbenennen und zuordnen ----------------------------------------------
// Beides sind Eigenschaften des ELEMENTS, nicht einer Phase -- deshalb ein eigenes Panel,
// erreichbar über das Stift-Symbol in der Element-Zeile.
function ElementMetaPanel({
element,
plan,
personLabel,
onSaved,
onDelete,
}: {
element: ElementInput;
plan: PlanInput;
personLabel: (role: string) => string;
onSaved: () => void;
onDelete: () => void;
}) {
const [name, setName] = useState(element.name);
const [ownerRole, setOwnerRole] = useState<string>(element.ownerRole ?? "HOUSEHOLD");
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const needsPerson = PERSON_ONLY_CATEGORIES.includes(element.category);
const isCouple = plan.householdType === "COUPLE";
const options = needsPerson
? isCouple
? [
{ value: "PERSON_A", label: personLabel("PERSON_A") },
{ value: "PERSON_B", label: personLabel("PERSON_B") },
]
: [{ value: "PERSON_A", label: personLabel("PERSON_A") }]
: isCouple
? [
{ value: "HOUSEHOLD", label: "Gemeinsam" },
{ value: "PERSON_A", label: personLabel("PERSON_A") },
{ value: "PERSON_B", label: personLabel("PERSON_B") },
]
: [
{ value: "HOUSEHOLD", label: "Gemeinsam" },
{ value: "PERSON_A", label: personLabel("PERSON_A") },
];
async function save() {
setSaving(true);
setError(null);
try {
await api.patch(`/api/elements/${element.id}`, { name: name.trim() || element.name, ownerRole });
onSaved();
} catch (e) {
setError(e instanceof Error ? e.message : "Speichern fehlgeschlagen.");
} finally {
setSaving(false);
}
}
return (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<div className="text-xs font-semibold uppercase tracking-wide text-accent-soft-fg">
{CATEGORY_LABELS[element.category]}
</div>
<button
type="button"
onClick={onDelete}
className="flex items-center gap-1 rounded-lg border border-border px-2 py-1 text-xs text-muted hover:border-danger hover:bg-danger-soft hover:text-danger"
>
<Trash2 className="h-3.5 w-3.5" /> Element löschen
</button>
</div>
<TextField label="Bezeichnung" value={name} onChange={setName} />
<SelectField
label="Zuordnung"
help={
needsPerson
? "AHV, Pensionskasse und Säule 3a hängen am individuellen Pensionierungszeitpunkt und gehören deshalb immer genau einer Person."
: "Gemeinsame Elemente zählen zum Haushalt. Bei Paaren wird ein gemeinsames Einkommen NICHT für die AHV-Rente angerechnet."
}
value={ownerRole}
onChange={setOwnerRole}
options={options}
/>
<p className="text-xs text-faint">
Die Werte je Lebensphase bearbeitest du direkt in den Zellen der Matrix.
</p>
{error && <p className="text-sm text-danger">{error}</p>}
<div className="flex gap-2">
<Button disabled={saving} onClick={save}>
{saving ? "Speichern…" : "Speichern"}
</Button>
</div>
</div>
);
}
+84 -5
View File
@@ -230,7 +230,7 @@ const konstellationen: { name: string; build: () => PlanInput }[] = [
retirementAge: 65, retirementAge: 65,
initialCash: 50000, initialCash: 50000,
phases: [ phases: [
{ id: "p1", durationYears: 5, cashTransition: { capitalUseAmortizationPct: 20, capitalUseInvestPct: 70 } }, { id: "p1", durationYears: 5 },
{ id: "p2", durationYears: 10 }, { id: "p2", durationYears: 10 },
], ],
elements: [ elements: [
@@ -240,7 +240,7 @@ const konstellationen: { name: string; build: () => PlanInput }[] = [
"PENSION_FUND", "PENSION_FUND",
"PERSON_A", "PERSON_A",
{ p1: { currentValue: 600000, annualContribution: 20000, expectedReturn: 2 }, p2: {} }, { p1: { currentValue: 600000, annualContribution: 20000, expectedReturn: 2 }, p2: {} },
{ p1: { payoutMode: "CAPITAL", capitalTaxRate: 5 } } { p1: { payoutMode: "CAPITAL", capitalTaxRate: 5, capitalUseAmortizationPct: 20, capitalUseInvestPct: 70 } }
), ),
el( el(
"REAL_ESTATE", "REAL_ESTATE",
@@ -310,10 +310,17 @@ describe("Kapitalverwendung am Pensions-Uebergang (Roadmap Nr. 44, Punkt C)", ()
it("leitet den Zufluss gemaess Quote in Hypothek und Anlage statt ins Cash", () => { it("leitet den Zufluss gemaess Quote in Hypothek und Anlage statt ins Cash", () => {
const mit = computePlan(withQuoten.build()); const mit = computePlan(withQuoten.build());
// Vergleichsplan OHNE Verwendungs-Quoten: Die Quoten haengen seit 0.33 am PK-Element,
// nicht mehr am Cash-Uebergang.
const raw = withQuoten.build();
const ohne = computePlan({ const ohne = computePlan({
...withQuoten.build(), ...raw,
phases: withQuoten.build().phases.map((p) => ({ ...p, cashTransition: {} })), elements: raw.elements.map((e) =>
}); e.category === "PENSION_FUND"
? { ...e, transitionValues: { p1: { payoutMode: "CAPITAL", capitalTaxRate: 5 } } }
: e
),
} as PlanInput);
const p2Mit = mit.phases[1]; const p2Mit = mit.phases[1];
const p2Ohne = ohne.phases[1]; const p2Ohne = ohne.phases[1];
@@ -345,3 +352,75 @@ describe("Kapitalverwendung am Pensions-Uebergang (Roadmap Nr. 44, Punkt C)", ()
expect(b.mortgageStart).toBeLessThan(a.mortgageStart); expect(b.mortgageStart).toBeLessThan(a.mortgageStart);
}); });
}); });
describe("Kapitalverwendung: Sichtbarkeit am Ziel-Element (0.33)", () => {
// Genau der gemeldete Fehler: 100 % des PK-Kapitals auf den ETF -- danach zeigte das
// Element als Zusatzinvestition eine 0, obwohl der Betrag eingeflossen war.
function p(): PlanInput {
return plan({
age: 62,
retirementAge: 65,
initialCash: 0,
phases: [
{ id: "p1", durationYears: 3 },
{ id: "p2", durationYears: 10 },
],
elements: [
el("INCOME", "PERSON_A", { p1: { amount: 100000 } }),
el("EXPENSE", "HOUSEHOLD", { p1: { amount: 90000 }, p2: { amount: 90000 } }),
el(
"PENSION_FUND",
"PERSON_A",
{ p1: { currentValue: 400000, expectedReturn: 0 }, p2: {} },
{ p1: { payoutMode: "CAPITAL", capitalTaxRate: 0, capitalUseInvestPct: 100 } }
),
el("OTHER_ASSET", "HOUSEHOLD", { p1: { startValue: 50000, expectedReturn: 0 }, p2: {} }),
],
});
}
it("weist den umgeleiteten Betrag am Ziel-Element aus", () => {
const c = computePlan(p());
const etf = c.phases[1].elements.find((e) => e.category === "OTHER_ASSET")!;
// 400'000 brutto, 0 % Steuer -> 400'000 netto, davon 100 % in den ETF.
expect(etf.capitalFromTransfer).toBe(400000);
expect(etf.startValue).toBe(450000); // 50'000 fortgeschrieben + 400'000
});
it("zaehlt ihn als Investition in der Cash-Bruecke (nicht als Tilgung)", () => {
const c = computePlan(p());
expect(c.phases[1].cashBridge.investments).toBe(400000);
expect(c.phases[1].cashBridge.residual).toBe(0);
});
it("fliesst NUR einmal -- nicht in jeder Folgephase erneut", () => {
const base = p();
const drei: PlanInput = {
...base,
phases: [...base.phases, { id: "p3", sequenceNumber: 3, name: "p3", durationYears: 5, cashTransition: {} }],
} as PlanInput;
const c = computePlan(drei);
const etfP3 = c.phases[2].elements.find((e) => e.category === "OTHER_ASSET")!;
expect(etfP3.capitalFromTransfer ?? 0).toBe(0);
});
it("verteilt zwei Guthaben getrennt (PK und 3a je eigene Quote)", () => {
const base = p();
const mit3a: PlanInput = {
...base,
elements: [
...base.elements,
el(
"PILLAR_3A",
"PERSON_A",
{ p1: { currentValue: 100000, expectedReturn: 0 }, p2: {} },
{ p1: { capitalTaxRate: 0, capitalUseInvestPct: 50 } }
),
],
} as PlanInput;
const c = computePlan(mit3a);
const etf = c.phases[1].elements.find((e) => e.category === "OTHER_ASSET")!;
// PK: 100 % von 400'000 = 400'000; 3a: 50 % von 100'000 = 50'000.
expect(etf.capitalFromTransfer).toBe(450000);
});
});
+44 -22
View File
@@ -13,7 +13,7 @@ import {
} from "@/lib/constants"; } from "@/lib/constants";
import { num } from "@/lib/elements"; import { num } from "@/lib/elements";
import { actualsForYear, rebaseFlow, type ResolvedActuals } from "@/lib/actuals"; import { actualsForYear, rebaseFlow, type ResolvedActuals } from "@/lib/actuals";
import type { ElementCategory } from "@/lib/elements"; import type { ElementCategory, TransitionData } from "@/lib/elements";
import type { PersonRole, PlanInput } from "@/lib/types"; import type { PersonRole, PlanInput } from "@/lib/types";
export type PhaseType = "ERWERB" | "PENSION" | "MIXED"; export type PhaseType = "ERWERB" | "PENSION" | "MIXED";
@@ -89,6 +89,9 @@ export interface ElementPhaseComputed {
endValue: number; // Wert/Flow am Phasenende (letztes Jahr) endValue: number; // Wert/Flow am Phasenende (letztes Jahr)
summary: string; summary: string;
note: string | null; note: string | null;
// Betrag, der aus einem Kapitalbezug (PK/3a) des vorigen Übergangs in dieses Element
// umgeleitet wurde (Punkt C). Nur bei OTHER_ASSET und nur ab Phase 2 > 0.
capitalFromTransfer?: number;
yearly: ElementYearPoint[]; // Verlauf innerhalb dieser Phase yearly: ElementYearPoint[]; // Verlauf innerhalb dieser Phase
trace?: Trace; // Rechenweg der Phasenwerte (nur mit explain) trace?: Trace; // Rechenweg der Phasenwerte (nur mit explain)
transitionTrace?: Trace; // Rechenweg des Übergangs NACH dieser Phase (nur mit explain) transitionTrace?: Trace; // Rechenweg des Übergangs NACH dieser Phase (nur mit explain)
@@ -293,6 +296,11 @@ interface Carry {
owed: number; // Schulden: Restschuld (positiv) owed: number; // Schulden: Restschuld (positiv)
pkPensionAnnual: number; // PK: jährliche Rente nach Verrentung pkPensionAnnual: number; // PK: jährliche Rente nach Verrentung
flowBasis: number; // Einkommen/Ausgaben: indexierter Basiswert der nächsten Phase flowBasis: number; // Einkommen/Ausgaben: indexierter Basiswert der nächsten Phase
// Betrag, der am letzten Übergang aus einem Kapitalbezug (PK/3a) in dieses Element
// umgeleitet wurde (Roadmap Nr. 44, Punkt C). Wird in der Folgephase wie eine
// Zusatzeinlage behandelt -- dadurch erscheint er im Element, in der Cash-Brücke und im
// Verteil-Dialog, statt unsichtbar im Bestand zu verschwinden.
capitalIn: number;
// Punkt A (Roadmap Nr. 44): zuletzt verwendete Wiederkehr-Parameter (Raten, Beiträge, // Punkt A (Roadmap Nr. 44): zuletzt verwendete Wiederkehr-Parameter (Raten, Beiträge,
// Amortisation). Fehlt der Wert in einer Phase, gilt der aus der Vorphase. // Amortisation). Fehlt der Wert in einer Phase, gilt der aus der Vorphase.
rates: Record<string, number>; rates: Record<string, number>;
@@ -309,6 +317,7 @@ function emptyCarry(): Carry {
owed: 0, owed: 0,
pkPensionAnnual: 0, pkPensionAnnual: 0,
flowBasis: 0, flowBasis: 0,
capitalIn: 0,
rates: {}, rates: {},
hasCarry: false, hasCarry: false,
}; };
@@ -697,7 +706,12 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
} }
case "OTHER_ASSET": { case "OTHER_ASSET": {
const base = carry.hasCarry ? carry.value : Math.round(num(pd.startValue)); const base = carry.hasCarry ? carry.value : Math.round(num(pd.startValue));
const topUp = carry.hasCarry ? Math.round(num(pd.additionalInvestment)) : 0; // Zusatzeinlage = manuell erfasst PLUS das am Übergang umgeleitete Alterskapital.
// Beides ist mechanisch dasselbe (Cash -> Vermögen), deshalb derselbe Weg.
const fromTransfer = carry.hasCarry ? carry.capitalIn : 0;
const topUp = (carry.hasCarry ? Math.round(num(pd.additionalInvestment)) : 0) + fromTransfer;
ec.capitalFromTransfer = fromTransfer;
carry.capitalIn = 0; // verbraucht -- sonst flösse er in jeder Folgephase erneut
const start = base + topUp; const start = base + topUp;
const rate = Math.round(inherited("annualContribution")); const rate = Math.round(inherited("annualContribution"));
const withdrawal = Math.round(inherited("annualWithdrawal")); const withdrawal = Math.round(inherited("annualWithdrawal"));
@@ -1411,6 +1425,9 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
// --- Übergang: Carry aktualisieren, Cash der Folgephase bilden --- // --- Übergang: Carry aktualisieren, Cash der Folgephase bilden ---
let txInflow = 0; let txInflow = 0;
let txImmediateRepay = 0; let txImmediateRepay = 0;
// Kapitalbezüge, deren Verwendung je Element geregelt ist (Punkt C). Gesammelt WÄHREND
// der Übergangs-Schleife, angewendet danach -- die Zielelemente werden erst dort bekannt.
const capitalUses: { net: number; td: TransitionData }[] = [];
// Echte Vermögensänderungen an dieser Grenze (für die Brücke der Folgephase). // Echte Vermögensänderungen an dieser Grenze (für die Brücke der Folgephase).
// Verkäufe, Bezüge und Tilgungen sind für sich Umbuchungen -- vermögenswirksam sind // Verkäufe, Bezüge und Tilgungen sind für sich Umbuchungen -- vermögenswirksam sind
// nur die Steuer, die Verrentung (Kapital verlässt die Bilanz) und die Differenz // nur die Steuer, die Verrentung (Kapital verlässt die Bilanz) und die Differenz
@@ -1490,6 +1507,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
const net = Math.round(value * (1 - num(td.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE) / 100)); const net = Math.round(value * (1 - num(td.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE) / 100));
txInflow += net; txInflow += net;
txTax += value - net; txTax += value - net;
capitalUses.push({ net, td });
carry.value = 0; carry.value = 0;
carry.pkPensionAnnual = 0; carry.pkPensionAnnual = 0;
} else if (mode === "PENSION") { } else if (mode === "PENSION") {
@@ -1501,6 +1519,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
const net = Math.round(capital * (1 - num(td.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE) / 100)); const net = Math.round(capital * (1 - num(td.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE) / 100));
txInflow += net; txInflow += net;
txTax += capital - net; txTax += capital - net;
capitalUses.push({ net, td });
carry.pkPensionAnnual = Math.round(((value - capital) * num(td.conversionRate, DEFAULT_PK_CONVERSION_RATE)) / 100); carry.pkPensionAnnual = Math.round(((value - capital) * num(td.conversionRate, DEFAULT_PK_CONVERSION_RATE)) / 100);
txPensionConversion += value - capital; txPensionConversion += value - capital;
carry.value = 0; carry.value = 0;
@@ -1521,6 +1540,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
const net = Math.round(ec.endValue * (1 - num(td.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE) / 100)); const net = Math.round(ec.endValue * (1 - num(td.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE) / 100));
txInflow += net; txInflow += net;
txTax += ec.endValue - net; txTax += ec.endValue - net;
capitalUses.push({ net, td });
carry.value = 0; carry.value = 0;
} else { } else {
const withdrawal = Math.min(ec.endValue, Math.round(num(td.withdrawal))); const withdrawal = Math.min(ec.endValue, Math.round(num(td.withdrawal)));
@@ -1657,22 +1677,26 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
carry.hasCarry = true; carry.hasCarry = true;
} }
// --- Punkt C (Roadmap Nr. 44): Verwendung des Kapitalzuflusses ----------------------- // --- Punkt C (Roadmap Nr. 44): Verwendung des bezogenen Alterskapitals ---------------
// //
// Bei der Pensionierung fliesst oft ein grosser Betrag auf einmal (PK-Kapital, 3a, // Bei der Pensionierung fliesst oft ein grosser Betrag auf einmal (PK-Kapital, Säule 3a).
// Immobilienverkauf). Ihn vollständig als Cash liegen zu lassen ist selten die Absicht. // Ihn vollständig als Cash liegen zu lassen ist selten die Absicht. Die Verwendung wird
// Die Verwendung wird deshalb als QUOTE erfasst: Verschiebt man das Pensionsalter, ändert // als QUOTE erfasst: Verschiebt man das Pensionsalter, ändert sich der Betrag -- die
// sich der Betrag -- die Aufteilung skaliert mit, statt still falsch zu werden. // Aufteilung skaliert mit, statt still falsch zu werden.
//
// Seit 0.33 hängt die Quote am jeweiligen VORSORGE-ELEMENT (PK bzw. 3a) statt am
// Cash-Übergang: Nur so lassen sich zwei Guthaben getrennt verwenden, und die Frage steht
// dort, wo der Bezugs-Entscheid fällt.
// //
// Mechanisch nichts Neues: Die Amortisations-Quote wirkt wie eine Sonderamortisation, die // Mechanisch nichts Neues: Die Amortisations-Quote wirkt wie eine Sonderamortisation, die
// Anlage-Quote wie eine Zusatzinvestition. Beide sind schon heute Cash-Abflüsse an der // Anlage-Quote wie eine Zusatzeinlage in der Folgephase. Beide sind Cash-Abflüsse und
// Grenze und laufen damit korrekt durch beide Brücken. // laufen damit korrekt durch beide Brücken.
if (txInflow > 0) { for (const use of capitalUses) {
const ct = phase.cashTransition ?? {}; const amortPct = Math.max(0, Math.min(100, num(use.td.capitalUseAmortizationPct)));
const amortPct = Math.max(0, Math.min(100, num(ct.capitalUseAmortizationPct))); const investPct = Math.max(0, Math.min(100 - amortPct, num(use.td.capitalUseInvestPct)));
const investPct = Math.max(0, Math.min(100 - amortPct, num(ct.capitalUseInvestPct))); if (use.net <= 0 || (amortPct === 0 && investPct === 0)) continue;
if (amortPct > 0 || investPct > 0) {
let amortBudget = Math.round((txInflow * amortPct) / 100); let amortBudget = Math.round((use.net * amortPct) / 100);
for (const e of orderedElements) { for (const e of orderedElements) {
if (amortBudget <= 0) break; if (amortBudget <= 0) break;
if (e.category !== "REAL_ESTATE") continue; if (e.category !== "REAL_ESTATE") continue;
@@ -1684,21 +1708,19 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
txImmediateRepay += pay; txImmediateRepay += pay;
} }
const investBudget = Math.round((txInflow * investPct) / 100); const investBudget = Math.round((use.net * investPct) / 100);
if (investBudget > 0) { if (investBudget > 0) {
const target = const target =
orderedElements.find( orderedElements.find(
(e) => (e) =>
e.id === ct.capitalUseTargetElementId && e.id === use.td.capitalUseTargetElementId &&
e.category === "OTHER_ASSET" && e.category === "OTHER_ASSET" &&
carries.get(e.id)!.status === "ACTIVE" carries.get(e.id)!.status === "ACTIVE"
) ?? ) ??
orderedElements.find((e) => e.category === "OTHER_ASSET" && carries.get(e.id)!.status === "ACTIVE"); orderedElements.find((e) => e.category === "OTHER_ASSET" && carries.get(e.id)!.status === "ACTIVE");
if (target) { // Der Betrag wandert NICHT direkt in den Bestand, sondern über `capitalIn` in die
carries.get(target.id)!.value += investBudget; // Zusatzeinlage der Folgephase -- dadurch wird er im UI überall sichtbar.
txImmediateRepay += investBudget; if (target) carries.get(target.id)!.capitalIn += investBudget;
}
}
} }
} }
+17 -11
View File
@@ -122,6 +122,20 @@ export interface TransitionData {
extraAmortization?: number; extraAmortization?: number;
// OTHER_DEBT // OTHER_DEBT
immediateRepayment?: number; immediateRepayment?: number;
// Verwendung des BEZOGENEN KAPITALS (Roadmap Nr. 44, Punkt C).
//
// Gilt für PENSION_FUND (nur bei Kapitalbezug) und PILLAR_3A am Pensions-Übergang: Wohin
// fliesst das ausbezahlte Alterskapital? Bewusst in PROZENT und nicht in Franken -- wird
// das Pensionsalter verschoben, ändert sich der Betrag, und eine Quote skaliert mit,
// während eine Frankenzahl still falsch würde. Der nicht zugeteilte Rest bleibt Cash.
//
// Bis 0.32 lagen diese Felder am CASH-Übergang. Das war der falsche Ort: Die Frage gehört
// zum Bezugs-Entscheid des jeweiligen Vorsorgeguthabens, nicht zum Cash-Konto -- und mit
// zwei Guthaben (PK und 3a) liess sie sich dort gar nicht getrennt beantworten.
capitalUseAmortizationPct?: number;
capitalUseInvestPct?: number;
// Ziel der Anlage-Quote; ohne Angabe das erste aktive «Sonstiges Vermögen».
capitalUseTargetElementId?: string;
} }
// --- Cash-Übergang: einmalige Sonderein-/ausgaben --- // --- Cash-Übergang: einmalige Sonderein-/ausgaben ---
@@ -140,14 +154,6 @@ export interface CashTransitionData {
// Einmalige Kosten (z. B. Poolbau): REAL erfasst (heutige Kaufkraft). // Einmalige Kosten (z. B. Poolbau): REAL erfasst (heutige Kaufkraft).
outflowLabel?: string; outflowLabel?: string;
outflowAmount?: number; outflowAmount?: number;
// Verwendung des Kapitalzuflusses am Pensions-Übergang (Roadmap Nr. 44, Punkt C).
// Bewusst in PROZENT und nicht in Franken: Verschiebt man das Pensionsalter, ändert sich
// das bezogene Kapital -- eine Franken-Angabe müsste man dann von Hand nachziehen, eine
// Quote skaliert mit. Der nicht zugeteilte Rest bleibt Cash.
capitalUseAmortizationPct?: number;
capitalUseInvestPct?: number;
// Ziel der Anlage-Quote; ohne Angabe das erste aktive «Sonstiges Vermögen».
capitalUseTargetElementId?: string;
} }
// --- Zod-Schemas (nachsichtig: unbekannte Felder werden verworfen) --- // --- Zod-Schemas (nachsichtig: unbekannte Felder werden verworfen) ---
@@ -162,9 +168,6 @@ export const cashTransitionSchema = z
inflowTaxRate: z.number().min(0).max(100).optional(), inflowTaxRate: z.number().min(0).max(100).optional(),
outflowLabel: z.string().max(120).optional(), outflowLabel: z.string().max(120).optional(),
outflowAmount: nonNeg.optional(), outflowAmount: nonNeg.optional(),
capitalUseAmortizationPct: z.number().min(0).max(100).optional(),
capitalUseInvestPct: z.number().min(0).max(100).optional(),
capitalUseTargetElementId: z.string().max(60).optional(),
}) })
.strip(); .strip();
@@ -210,6 +213,9 @@ export const transitionDataSchema = z
partialSaleAmount: nonNeg.optional(), partialSaleAmount: nonNeg.optional(),
extraAmortization: nonNeg.optional(), extraAmortization: nonNeg.optional(),
immediateRepayment: nonNeg.optional(), immediateRepayment: nonNeg.optional(),
capitalUseAmortizationPct: z.number().min(0).max(100).optional(),
capitalUseInvestPct: z.number().min(0).max(100).optional(),
capitalUseTargetElementId: z.string().max(60).optional(),
}) })
.strip(); .strip();
+50 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { planSegments, defaultOpenDuration } from "@/lib/phaseplan"; import { planSegments, defaultOpenDuration, planDurationChange } from "@/lib/phaseplan";
describe("planSegments", () => { describe("planSegments", () => {
it("Einzelperson: genau zwei Teile (Erwerb fix, Pension offen)", () => { it("Einzelperson: genau zwei Teile (Erwerb fix, Pension offen)", () => {
@@ -78,3 +78,52 @@ describe("planSegments", () => {
expect(defaultOpenDuration([{ age: 50 }, { age: 60 }], 5)).toBe(25); // ältester zählt: 90-(60+5) expect(defaultOpenDuration([{ age: 50 }, { age: 60 }], 5)).toBe(25); // ältester zählt: 90-(60+5)
}); });
}); });
describe("planDurationChange", () => {
const phases = [
{ id: "p1", name: "Erwerb", sequenceNumber: 1, durationYears: 10 },
{ id: "p2", name: "Erwerb 2", sequenceNumber: 2, durationYears: 10 },
{ id: "p3", name: "Pension", sequenceNumber: 3, durationYears: 25 },
];
it("laesst die Folgephase den Ausgleich tragen", () => {
// Genau der gemeldete Fall: Phase 1 von 10 auf 12 -- ohne Ausgleich wuerde Phase 2 die
// Pensionierung ueberspannen.
const r = planDurationChange(phases, "p1", 12);
expect(r.ok).toBe(true);
expect(r.delta).toBe(2);
expect(r.neighbourId).toBe("p2");
expect(r.neighbourNewDuration).toBe(8);
});
it("verlaengert die Folgephase beim Verkuerzen", () => {
const r = planDurationChange(phases, "p1", 7);
expect(r.neighbourNewDuration).toBe(13);
});
it("blockt, wenn die Folgephase unter ein Jahr fiele", () => {
const r = planDurationChange(phases, "p1", 20); // p2 haette dann 0
expect(r.ok).toBe(false);
expect(r.error).toContain("Erwerb 2");
});
it("laesst die LETZTE Phase frei -- sie verlaengert den Plan", () => {
const r = planDurationChange(phases, "p3", 30);
expect(r.ok).toBe(true);
expect(r.neighbourId).toBeNull();
});
it("meldet keine Aenderung bei gleicher Dauer", () => {
const r = planDurationChange(phases, "p2", 10);
expect(r.ok).toBe(true);
expect(r.delta).toBe(0);
expect(r.neighbourId).toBeNull();
});
it("haelt die Gesamtdauer konstant", () => {
const before = phases.reduce((s, p) => s + p.durationYears, 0);
const r = planDurationChange(phases, "p1", 15);
const after = 15 + r.neighbourNewDuration! + phases[2].durationYears;
expect(after).toBe(before);
});
});
+63
View File
@@ -48,3 +48,66 @@ export function defaultOpenDuration(persons: { age: number }[], fromYear: number
const oldestNow = persons.length > 0 ? Math.max(...persons.map((p) => p.age)) : 65; const oldestNow = persons.length > 0 ? Math.max(...persons.map((p) => p.age)) : 65;
return Math.max(5, 90 - (oldestNow + fromYear)); return Math.max(5, 90 - (oldestNow + fromYear));
} }
// --- Phasendauer ändern: die Folgephase gleicht aus --------------------------------------
//
// Wird eine Phase verlängert, muss die NÄCHSTE um denselben Betrag kürzer werden -- sonst
// verschieben sich alle folgenden Phasengrenzen, und eine davon überspannt am Ende eine
// Pensionierung. Genau das passierte bis 0.32: Die Kappung prüfte nur die BEARBEITETE Phase
// («Phase 1 darf höchstens bis zur Pensionierung laufen»), nicht die Folgen für Phase 2.
//
// Dieselbe Mechanik wie beim Verschieben des Pensionsalters (lib/retirement.ts): Die
// Gesamtdauer des Plans bleibt gleich, es wird nur Zeit umverteilt. Nur die LETZTE Phase hat
// keine Nachfolgerin -- sie verlängert oder verkürzt den Plan tatsächlich.
export interface DurationChange {
ok: boolean;
// Die Folgephase, die den Ausgleich trägt (null bei der letzten Phase).
neighbourId: string | null;
neighbourName: string | null;
neighbourNewDuration: number | null;
delta: number; // Änderung der bearbeiteten Phase in Jahren
error: string | null;
}
export function planDurationChange(
phases: { id: string; name: string; sequenceNumber: number; durationYears: number }[],
phaseId: string,
newDuration: number
): DurationChange {
const sorted = [...phases].sort((a, b) => a.sequenceNumber - b.sequenceNumber);
const i = sorted.findIndex((p) => p.id === phaseId);
const none: DurationChange = {
ok: false, neighbourId: null, neighbourName: null, neighbourNewDuration: null, delta: 0, error: null,
};
if (i < 0) return { ...none, error: "Diese Lebensphase gibt es nicht." };
const delta = Math.round(newDuration) - sorted[i].durationYears;
if (newDuration < 1) return { ...none, delta, error: "Eine Lebensphase muss mindestens ein Jahr dauern." };
if (delta === 0) return { ...none, ok: true };
const next = sorted[i + 1];
// Letzte Phase: kein Ausgleich nötig, der Plan wird einfach länger oder kürzer.
if (!next) return { ...none, ok: true, delta };
const neighbourNewDuration = next.durationYears - delta;
if (neighbourNewDuration < 1) {
return {
...none,
delta,
error:
`«${next.name}» dauert nur ${next.durationYears} Jahr${next.durationYears === 1 ? "" : "e"} und müsste den ` +
`Ausgleich tragen. Verlängere diese Phase um höchstens ${next.durationYears - 1} Jahr` +
`${next.durationYears - 1 === 1 ? "" : "e"} oder passe zuerst die Folgephase an.`,
};
}
return {
ok: true,
neighbourId: next.id,
neighbourName: next.name,
neighbourNewDuration,
delta,
error: null,
};
}
+12 -2
View File
@@ -6,6 +6,7 @@
// the client.» Deshalb liegen sie hier, wo beide Seiten sie benutzen dürfen. // the client.» Deshalb liegen sie hier, wo beide Seiten sie benutzen dürfen.
import { num } from "@/lib/elements"; import { num } from "@/lib/elements";
import { DEFAULT_CAPITAL_TAX_RATE } from "@/lib/constants";
import { formatChf } from "@/lib/format"; import { formatChf } from "@/lib/format";
import type { CashTransitionData, ElementCategory, TransitionData } from "@/lib/elements"; import type { CashTransitionData, ElementCategory, TransitionData } from "@/lib/elements";
@@ -29,7 +30,14 @@ export function withTransitionDefaults(
out.withdrawalMode = "NONE"; out.withdrawalMode = "NONE";
} }
} else if (category === "PILLAR_3A") { } else if (category === "PILLAR_3A") {
if (!isRetirement && out.withdrawalMode === undefined) out.withdrawalMode = "NONE"; if (isRetirement) {
// Der Bezug selbst steht fest (die 3a wird bei Pensionierung immer ausbezahlt) -- zu
// entscheiden sind Steuersatz und Verwendung. Ein Speichern setzt den Satz und
// markiert den Entscheid damit als getroffen.
if (out.capitalTaxRate === undefined) out.capitalTaxRate = DEFAULT_CAPITAL_TAX_RATE;
} else if (out.withdrawalMode === undefined) {
out.withdrawalMode = "NONE";
}
} }
return out; return out;
} }
@@ -50,7 +58,9 @@ export function isTransitionAnswered(
case "PENSION_FUND": case "PENSION_FUND":
return isRetirement ? td.payoutMode !== undefined : td.withdrawalMode !== undefined; return isRetirement ? td.payoutMode !== undefined : td.withdrawalMode !== undefined;
case "PILLAR_3A": case "PILLAR_3A":
return isRetirement ? true : td.withdrawalMode !== undefined; // Bei der Pensionierung gilt der Entscheid als getroffen, sobald der Steuersatz
// erfasst ist -- vorher steht die Verwendung des Kapitals offen (Punkt C).
return isRetirement ? td.capitalTaxRate !== undefined : td.withdrawalMode !== undefined;
default: default:
return true; return true;
} }