Modul-Review 4: Matrix -- Kapitalverwendung, Phasendauer, Bedienung
Deploy App / deploy (push) Successful in 1m10s
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:
+87
-44
@@ -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` (v1–v5) im Ordner `Info Dateien` – diese sind ab Version 0.1 dieses Dokuments obsolet |
|
| **Ersetzt** | `FDD_TDD_FPT.docx` (v1–v5) im Ordner `Info Dateien` – diese sind ab Version 0.1 dieses Dokuments obsolet |
|
||||||
| **Geltungsbereich** | Gesamter Code im Verzeichnis `FPT` |
|
| **Geltungsbereich** | Gesamter Code im Verzeichnis `FPT` |
|
||||||
|
|
||||||
@@ -17,6 +17,7 @@
|
|||||||
|
|
||||||
| Version | Datum | Autor | Änderung |
|
| Version | Datum | Autor | Änderung |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
|
| 0.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.2–3.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.2–3.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 55–77.
|
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 19–23, `src/app/api/plans/[planId]/elements/route.ts` Zeilen 43–54.
|
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 799–947.
|
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 124–340.
|
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 665–699.
|
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 | 0–100 |
|
| `saleTaxRate` | REAL_ESTATE | 0–100 |
|
||||||
| `immediateRepayment` | OTHER_DEBT | ≥ 0 |
|
| `immediateRepayment` | OTHER_DEBT | ≥ 0 |
|
||||||
|
| `capitalUseAmortizationPct` | PENSION_FUND (Kapitalbezug), PILLAR_3A – Anteil des bezogenen Kapitals in die Amortisation | 0–100 |
|
||||||
|
| `capitalUseInvestPct` | dito – Anteil in eine Anlage | 0–100 |
|
||||||
|
| `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 % | 0–100 |
|
| `inflowTaxRate` | Steuer auf den Zufluss, Default 0 % | 0–100 |
|
||||||
| `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) | 0–100 |
|
|
||||||
| `capitalUseInvestPct` | Anteil des Kapitalzuflusses in eine Anlage | 0–100 |
|
|
||||||
| `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
|
||||||
|
|
||||||
|
|||||||
@@ -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 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) => {
|
||||||
where: { id: phaseId },
|
const updated = await tx.phase.update({
|
||||||
data: {
|
where: { id: phaseId },
|
||||||
name: parsed.data.name ?? undefined,
|
data: {
|
||||||
durationYears: duration ?? undefined,
|
name: parsed.data.name ?? 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).
|
||||||
|
|||||||
@@ -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%">
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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,28 +484,41 @@ 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 key={key(t)} className="rounded-xl border border-border p-3">
|
<div className="flex flex-col gap-4">
|
||||||
<div className="mb-2 flex flex-wrap items-center gap-2 text-sm">
|
{(["out", "in"] as const).map((dir) => {
|
||||||
<span className="font-semibold text-fg">{t.name}</span>
|
const rows = targets.filter((t) => t.direction === dir);
|
||||||
<span className="text-xs text-faint">{CATEGORY_LABELS[t.category as keyof typeof CATEGORY_LABELS]}</span>
|
if (rows.length === 0) return null;
|
||||||
<span
|
return (
|
||||||
className={`rounded px-1.5 py-0.5 text-[10px] ${
|
<div key={dir} className="flex flex-col gap-2">
|
||||||
t.direction === "in" ? "bg-success/15 text-success" : "bg-surface-2 text-muted"
|
<div
|
||||||
|
className={`flex items-center gap-2 text-xs font-semibold uppercase tracking-wide ${
|
||||||
|
dir === "in" ? "text-success" : "text-muted"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{t.direction === "in" ? "ins Cash" : "vom Cash"}
|
{dir === "in" ? "Ins Cash (Entnahmen)" : "Vom Cash (Einzahlungen und Tilgungen)"}
|
||||||
</span>
|
</div>
|
||||||
<InfoBubble text={t.hint} />
|
{rows.map((t) => (
|
||||||
|
<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">
|
||||||
|
<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="rounded bg-surface-2 px-1.5 py-0.5 text-[10px] text-muted">{t.owner}</span>
|
||||||
|
<InfoBubble text={t.hint} />
|
||||||
|
</div>
|
||||||
|
<MoneyField
|
||||||
|
label={`${t.label} (CHF/Jahr)`}
|
||||||
|
value={draft[key(t)] ?? 0}
|
||||||
|
onChange={(v) => setDraft((prev) => ({ ...prev, [key(t)]: v }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
<MoneyField
|
);
|
||||||
label={`${t.label} (CHF/Jahr)`}
|
})}
|
||||||
value={draft[key(t)] ?? 0}
|
|
||||||
onChange={(v) => setDraft((prev) => ({ ...prev, [key(t)]: v }))}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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 (
|
||||||
<NumberField
|
<>
|
||||||
label="Kapitalbezugssteuer (%)"
|
<p className="col-span-2 rounded-lg bg-surface-2 px-3 py-2 text-xs text-muted">
|
||||||
help="Die Säule 3a wird bei Pensionierung vollständig bezogen."
|
Die Säule 3a wird bei der Pensionierung <strong className="text-fg">vollständig bezogen</strong>. Zu
|
||||||
step={0.5}
|
entscheiden sind der Steuersatz und die Verwendung des Geldes.
|
||||||
value={num(td.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE)}
|
</p>
|
||||||
onChange={(v) => setT({ capitalTaxRate: v })}
|
<DerivedField label="Bezug brutto" value={brutto} help="Das Guthaben am Ende der Vorphase." />
|
||||||
/>
|
<NumberField
|
||||||
|
label="Kapitalbezugssteuer (%)"
|
||||||
|
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}
|
||||||
|
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="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);
|
||||||
|
|||||||
@@ -1,28 +1,97 @@
|
|||||||
"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" &&
|
||||||
{text}
|
createPortal(
|
||||||
</span>
|
<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}
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+62
-40
@@ -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,48 +1677,50 @@ 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);
|
|
||||||
for (const e of orderedElements) {
|
|
||||||
if (amortBudget <= 0) break;
|
|
||||||
if (e.category !== "REAL_ESTATE") continue;
|
|
||||||
const c = carries.get(e.id)!;
|
|
||||||
if (c.status !== "ACTIVE" || c.mortgage <= 0) continue;
|
|
||||||
const pay = Math.min(amortBudget, c.mortgage);
|
|
||||||
c.mortgage -= pay;
|
|
||||||
amortBudget -= pay;
|
|
||||||
txImmediateRepay += pay;
|
|
||||||
}
|
|
||||||
|
|
||||||
const investBudget = Math.round((txInflow * investPct) / 100);
|
let amortBudget = Math.round((use.net * amortPct) / 100);
|
||||||
if (investBudget > 0) {
|
for (const e of orderedElements) {
|
||||||
const target =
|
if (amortBudget <= 0) break;
|
||||||
orderedElements.find(
|
if (e.category !== "REAL_ESTATE") continue;
|
||||||
(e) =>
|
const c = carries.get(e.id)!;
|
||||||
e.id === ct.capitalUseTargetElementId &&
|
if (c.status !== "ACTIVE" || c.mortgage <= 0) continue;
|
||||||
e.category === "OTHER_ASSET" &&
|
const pay = Math.min(amortBudget, c.mortgage);
|
||||||
carries.get(e.id)!.status === "ACTIVE"
|
c.mortgage -= pay;
|
||||||
) ??
|
amortBudget -= pay;
|
||||||
orderedElements.find((e) => e.category === "OTHER_ASSET" && carries.get(e.id)!.status === "ACTIVE");
|
txImmediateRepay += pay;
|
||||||
if (target) {
|
}
|
||||||
carries.get(target.id)!.value += investBudget;
|
|
||||||
txImmediateRepay += investBudget;
|
const investBudget = Math.round((use.net * investPct) / 100);
|
||||||
}
|
if (investBudget > 0) {
|
||||||
}
|
const target =
|
||||||
|
orderedElements.find(
|
||||||
|
(e) =>
|
||||||
|
e.id === use.td.capitalUseTargetElementId &&
|
||||||
|
e.category === "OTHER_ASSET" &&
|
||||||
|
carries.get(e.id)!.status === "ACTIVE"
|
||||||
|
) ??
|
||||||
|
orderedElements.find((e) => e.category === "OTHER_ASSET" && carries.get(e.id)!.status === "ACTIVE");
|
||||||
|
// Der Betrag wandert NICHT direkt in den Bestand, sondern über `capitalIn` in die
|
||||||
|
// Zusatzeinlage der Folgephase -- dadurch wird er im UI überall sichtbar.
|
||||||
|
if (target) carries.get(target.id)!.capitalIn += investBudget;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+17
-11
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user