Phasenkopf-Ueberarbeitung: zweizeilige Werte, Verfuegbares Kapital, Verteil-Werkzeuge
Deploy App / deploy (push) Successful in 1m5s
Deploy App / deploy (push) Successful in 1m5s
Rein an der Oberflaeche und als neue Bearbeitungswerkzeuge -- keine Aenderung
an Berechnung, Datenmodell oder API. Beide Verteil-Dialoge schreiben nur
bestehende Felder ueber bestehende Endpunkte.
1) Zweizeilige Wertdarstellung im Modus "Beide": Realwert in Klammern in
eigener Zeile UNTER dem nominalen Wert (Kopf + Matrix-Zellen), Pfeil auf
beiden Zeilen. Dadurch schmalere Spalten und jede Kennzahl umbruchfrei.
2) "Sparquote" / "Verzehrquote" statt "Quote" / "Verzehr".
3) Neuer Kopf-Block "Verfuegbares Kapital" (ab Phase 2, nur wenn > 0):
Topf, davon verteilt, Rest auf Cash -- vollstaendig aus der Cash-Bruecke
abgeleitet (capitalPot).
4) Zwei Verteil-Popups mit Live-Vorschau (erneutes computePlan im Browser):
- "Kapital verteilen": Zusatzeinlage (PK/3a/Vermoegen, Phasenwert) +
Sonderamortisation/Sofort-Tilgung (Uebergangswert der Vorphase);
Rest bleibt automatisch auf Cash, Ueberverteilung wird als Luecke gemeldet
- "Sparquote/Bezug verteilen": jaehrliche Raten; zeigt Quote erstes Jahr,
letztes Jahr UND absolut ueber die Phase; warnt, wenn die Quote sinkt
(flache Rate wuerde spaeter Cash-Loch reissen). PK bewusst ausgeschlossen
(Beitrag aus Bruttolohn, belastet Cash nicht).
Neues reines Modul distribution.ts (capitalPot, quotaSummary, applyPatches).
8 Tests (103 -> 111) -- u.a. residual-Kontrolle gegen die Cash-Bruecke und
Nachweis, dass die Quote ueber die Phase sinkt.
SPEZIFIKATION auf 0.14: neue Kapitel 3.6.9, 3.6.10, 9.25; 3.6.1 und 3.6.3
ueberarbeitet.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+121
-7
@@ -4,10 +4,10 @@
|
|||||||
| | |
|
| | |
|
||||||
|---|---|
|
|---|---|
|
||||||
| **Dokument** | Funktionale und Technische Spezifikation FPT |
|
| **Dokument** | Funktionale und Technische Spezifikation FPT |
|
||||||
| **Version** | 0.13 |
|
| **Version** | 0.14 |
|
||||||
| **Datum** | 2026-07-18 |
|
| **Datum** | 2026-07-18 |
|
||||||
| **Status** | Lebendes Dokument |
|
| **Status** | Lebendes Dokument |
|
||||||
| **Codestand** | Arbeitsstand nach `e1f74fc` inkl. UI-Gesamtumbau (Pakete A–D) und geführtem Onboarding (Branch `main`) |
|
| **Codestand** | Arbeitsstand nach `c2fb82b` inkl. Phasenkopf-Überarbeitung und Verteil-Werkzeugen (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.14 | 2026-07-18 | Claude (Opus 4.8) | **Phasenkopf überarbeitet und zwei Verteil-Werkzeuge.** (1) **Zweizeilige Wertdarstellung:** Im Anzeigemodus «Beide» steht der Realwert neu in Klammern in einer **eigenen Zeile** unter dem nominalen Wert statt daneben – im Phasenkopf *und* in den Matrix-Zellen. Der Pfeil wiederholt sich auf der zweiten Zeile, damit der Bezug Start → Ende erhalten bleibt. Nebeneffekt: Die Spalten werden schmaler, wodurch **jede Kennzahl umbruchfrei** (`whitespace-nowrap`) dargestellt werden kann. (2) Die Kennzahl heisst korrekt **«Sparquote»** bzw. **«Verzehrquote»** statt «Quote»/«Verzehr». (3) Neuer Block **«Verfügbares Kapital»** im Phasenkopf (ab Phase 2, nur wenn > 0): der beim Übergang zugeflossene Topf mit «davon verteilt» und «Rest auf Cash». (4) Zwei neue Werkzeuge als eigene Popups: **«Kapital verteilen»** (Zusatzeinlagen in PK/3a/Vermögen, Sonderamortisation, Sofort-Tilgung) und **«Sparquote/Bezug verteilen»** (jährliche Raten), beide mit **Live-Vorschau** über eine erneute `computePlan`-Rechnung im Browser – die angezeigte Wirkung ist dadurch per Konstruktion exakt die spätere, inklusive aller Kappungen. Der Quoten-Dialog weist neben erstem und letztem Jahr die **absolute Quote über die ganze Phase** aus und warnt, wenn die Quote über die Phase sinkt. Neues reines Modul `distribution.ts`. Neue Kapitel 3.6.9, 3.6.10, 9.25; 3.6.1 und 3.6.3 überarbeitet. 8 Tests ergänzt (103 → 111). **Keine Änderung an Berechnung, Datenmodell oder API** – beide Werkzeuge schreiben ausschliesslich bestehende Felder über bestehende Endpunkte. |
|
||||||
| 0.13 | 2026-07-18 | Claude (Fable 5) | **UI-Gesamtumbau** – rein an der Oberfläche, Berechnung, Datenmodell und API-Semantik unverändert. **(A) Fundament:** durchgehende **Du-Form** und **echte Umlaute** in allen sichtbaren Texten (inkl. API-Fehlermeldungen); neue UI-Primitiven in `ui.tsx` (Button, Modal mit ESC/Fokus-Falle/Animation, Bestätigungs-Dialog statt `window.confirm`, Toasts statt `alert`, Skeleton-Loader, EmptyState); eigene **Attention-Farbe** (Amber) für offene Entscheide, getrennt vom Akzent; Micro-Interactions mit `prefers-reduced-motion`-Fallback. **(B) Onboarding (Roadmap Nr. 10):** geführter **Plan-Assistent** in fünf Schritten (reine Orchestrierung bestehender Endpunkte, Einkommen bewusst pro Person – räumt die 9.9-Falle aus), **Beispielplan mit einem Klick** (Übergänge absichtlich offen – die Ampel lehrt sich selbst), **interaktive Tour** über die Planansicht, abgeleitete **«Nächste Schritte»**-Karte. **(C) Struktur:** Einzel-Bearbeitungen laufen neu über ein rechtes **Inspector-Panel** statt Modals (Matrix bleibt sichtbar; Klick auf andere Zelle wechselt den Inhalt); **Phasenkopf entschlackt** auf vier Kern-Infos (Rest wohnt in der Detailansicht aus 0.11); Matrix mit eigenem Scrollbereich und **beidachsig fixierten Köpfen**; Sidebar-Gruppen «Meine Pläne»/«Wissen» («So rechnet FPT», Systemparameter); Terminologie-Fix «Szenario-Profil» statt «Plan-Einstellungen»; Aktions-Icons auch ohne Hover sichtbar (Touch). **(D) Extras:** **Sparklines** je Element-Zeile (aus den 0.11-Verlaufswerten, keine Neuberechnung), **Befehls-Palette** (Ctrl/Cmd+K), Ruin-Banner verlinkt auf die Einflussfaktoren. Neue Kapitel 3.2.8, 3.7.6–3.7.9, 9.23, 9.24; 9.17 bereinigt (der `Selection`-Rest und der ProfileMenu-Lint-Fehler sind behoben – `npm run lint` ist erstmals fehlerfrei). Testbestand unverändert 103. |
|
| 0.13 | 2026-07-18 | Claude (Fable 5) | **UI-Gesamtumbau** – rein an der Oberfläche, Berechnung, Datenmodell und API-Semantik unverändert. **(A) Fundament:** durchgehende **Du-Form** und **echte Umlaute** in allen sichtbaren Texten (inkl. API-Fehlermeldungen); neue UI-Primitiven in `ui.tsx` (Button, Modal mit ESC/Fokus-Falle/Animation, Bestätigungs-Dialog statt `window.confirm`, Toasts statt `alert`, Skeleton-Loader, EmptyState); eigene **Attention-Farbe** (Amber) für offene Entscheide, getrennt vom Akzent; Micro-Interactions mit `prefers-reduced-motion`-Fallback. **(B) Onboarding (Roadmap Nr. 10):** geführter **Plan-Assistent** in fünf Schritten (reine Orchestrierung bestehender Endpunkte, Einkommen bewusst pro Person – räumt die 9.9-Falle aus), **Beispielplan mit einem Klick** (Übergänge absichtlich offen – die Ampel lehrt sich selbst), **interaktive Tour** über die Planansicht, abgeleitete **«Nächste Schritte»**-Karte. **(C) Struktur:** Einzel-Bearbeitungen laufen neu über ein rechtes **Inspector-Panel** statt Modals (Matrix bleibt sichtbar; Klick auf andere Zelle wechselt den Inhalt); **Phasenkopf entschlackt** auf vier Kern-Infos (Rest wohnt in der Detailansicht aus 0.11); Matrix mit eigenem Scrollbereich und **beidachsig fixierten Köpfen**; Sidebar-Gruppen «Meine Pläne»/«Wissen» («So rechnet FPT», Systemparameter); Terminologie-Fix «Szenario-Profil» statt «Plan-Einstellungen»; Aktions-Icons auch ohne Hover sichtbar (Touch). **(D) Extras:** **Sparklines** je Element-Zeile (aus den 0.11-Verlaufswerten, keine Neuberechnung), **Befehls-Palette** (Ctrl/Cmd+K), Ruin-Banner verlinkt auf die Einflussfaktoren. Neue Kapitel 3.2.8, 3.7.6–3.7.9, 9.23, 9.24; 9.17 bereinigt (der `Selection`-Rest und der ProfileMenu-Lint-Fehler sind behoben – `npm run lint` ist erstmals fehlerfrei). Testbestand unverändert 103. |
|
||||||
| 0.12 | 2026-07-18 | Claude (Opus 4.8) | **Lesbarkeit der Wasserfälle, Verkaufspreis-Abgleich und Erklärung wirkungsloser Tornado-Treiber.** (1) Die beiden Wasserfälle werden **nicht mehr mit Recharts** gezeichnet, sondern als eigene liegende Darstellung: Verbindungslinien zwischen den Balken, Wertbeschriftung an jedem Schritt, Abschnitts-Überschriften („Am Übergang" / „Innerhalb der Phase") und eine aufklappbare Tabelle mit **laufendem Zwischenstand**. Anlass war, dass die bisherige Darstellung faktisch nicht lesbar war – die Zahlen waren korrekt, die Grafik nicht. (2) Der Restposten beider Brücken wird bei Abweichung neu als **Fehlermeldung** ausgewiesen statt als beiläufige „Rundungsdifferenz"; eine nicht aufgehende Zerlegung ist ein Rechenfehler und kein Schönheitsproblem. (3) **Verkaufspreis einer Immobilie** wird beim Wechsel auf „Verkaufen" neu mit dem **modellierten Verkehrswert** vorbelegt; der Dialog weist Verkehrswert und Abweichung aus und warnt ab 10 % Differenz (Kap. 3.5.8, 9.22). Damit fällt auf, wenn angenommene Wertsteigerung und erwarteter Verkaufspreis nicht zusammenpassen. (4) Der Tornado erklärt neu **Nullbalken** statt sie stumm zu zeigen – insbesondere den Fall, dass die Immobilien-Wertsteigerung bei einem Verkauf nachweislich wirkungslos ist (`ineffectiveReason`, Kap. 4.13.5). Neue Kapitel 3.5.8, 4.13.5, 9.22; 11 Tests ergänzt (92 → 103), darunter die Invariante `residual === 0` über sieben Plankonstellationen. Keine DB-Änderung, keine Änderung an der Berechnung. |
|
| 0.12 | 2026-07-18 | Claude (Opus 4.8) | **Lesbarkeit der Wasserfälle, Verkaufspreis-Abgleich und Erklärung wirkungsloser Tornado-Treiber.** (1) Die beiden Wasserfälle werden **nicht mehr mit Recharts** gezeichnet, sondern als eigene liegende Darstellung: Verbindungslinien zwischen den Balken, Wertbeschriftung an jedem Schritt, Abschnitts-Überschriften („Am Übergang" / „Innerhalb der Phase") und eine aufklappbare Tabelle mit **laufendem Zwischenstand**. Anlass war, dass die bisherige Darstellung faktisch nicht lesbar war – die Zahlen waren korrekt, die Grafik nicht. (2) Der Restposten beider Brücken wird bei Abweichung neu als **Fehlermeldung** ausgewiesen statt als beiläufige „Rundungsdifferenz"; eine nicht aufgehende Zerlegung ist ein Rechenfehler und kein Schönheitsproblem. (3) **Verkaufspreis einer Immobilie** wird beim Wechsel auf „Verkaufen" neu mit dem **modellierten Verkehrswert** vorbelegt; der Dialog weist Verkehrswert und Abweichung aus und warnt ab 10 % Differenz (Kap. 3.5.8, 9.22). Damit fällt auf, wenn angenommene Wertsteigerung und erwarteter Verkaufspreis nicht zusammenpassen. (4) Der Tornado erklärt neu **Nullbalken** statt sie stumm zu zeigen – insbesondere den Fall, dass die Immobilien-Wertsteigerung bei einem Verkauf nachweislich wirkungslos ist (`ineffectiveReason`, Kap. 4.13.5). Neue Kapitel 3.5.8, 4.13.5, 9.22; 11 Tests ergänzt (92 → 103), darunter die Invariante `residual === 0` über sieben Plankonstellationen. Keine DB-Änderung, keine Änderung an der Berechnung. |
|
||||||
| 0.11 | 2026-07-18 | Claude (Opus 4.8) | **Detailansichten (Roadmap Nr. 43)** und **vollständige Offenlegung der Berechnungslogiken (Roadmap Nr. 41)**. (1) Neue **Systemparameter-Ansicht** in der Seitenleiste: alle fest hinterlegten Grössen mit Wert, Bedeutung, Herleitung, Quelle und Stand – als strukturierte Daten aus `constants.ts`, also aus derselben Quelle, aus der gerechnet wird. (2) **Nur-Lese-Detailansicht** je Element und je Lebensphase über ein Expand-Icon: Element mit Verlaufsgrafik über **alle Planjahre** (dafür führt `computePlan` neu `ElementPhaseComputed.yearly` je Element mit), Phase mit Vermögensaufteilung und **zwei Wasserfällen**. (3) Die **Wasserfälle** sind bewusst getrennt: Der Vermögens-Wasserfall zeigt nur echte Zu- und Abgänge (Quote, Kapitalerträge, Wertsteigerung, PK-Beiträge, Steuern, Verrentung, Einmalposten); Sparraten, Amortisationen und Investitionen sind **Umbuchungen** und erscheinen ausschliesslich im Cash-Wasserfall – als Vermögensabgang gezeichnet würden sie einen Verlust vortäuschen, den es nicht gibt. Neue Strukturen `WealthBridge` / `CashBridge` inkl. Restposten als Kontrollgrösse. (4) **Rechenweg-Protokoll**: `computePlan(plan, sample?, { explain })` protokolliert die Schritte, die es ohnehin ausführt – Formel, eingesetzte Zahlen, Ergebnis und Hinweis auf geltende Vereinfachungen. Abdeckung über **alle** Ebenen (Element je Phase, Element je Übergang, Phasen-Kennzahlen, Plan-Ebene). Standardmässig aus, damit die Monte-Carlo-Simulation unberührt bleibt. Jeder Rechenweg verlinkt in das passende Kapitel dieser Spezifikation; ein Test prüft, dass alle Verweise eine existierende Überschrift treffen. Neue Kapitel 3.6.7, 3.6.8, 4.14, 9.20, 9.21; 12 Tests ergänzt (80 → 92). Keine DB-Änderung; die 43 Golden Tests laufen unverändert. |
|
| 0.11 | 2026-07-18 | Claude (Opus 4.8) | **Detailansichten (Roadmap Nr. 43)** und **vollständige Offenlegung der Berechnungslogiken (Roadmap Nr. 41)**. (1) Neue **Systemparameter-Ansicht** in der Seitenleiste: alle fest hinterlegten Grössen mit Wert, Bedeutung, Herleitung, Quelle und Stand – als strukturierte Daten aus `constants.ts`, also aus derselben Quelle, aus der gerechnet wird. (2) **Nur-Lese-Detailansicht** je Element und je Lebensphase über ein Expand-Icon: Element mit Verlaufsgrafik über **alle Planjahre** (dafür führt `computePlan` neu `ElementPhaseComputed.yearly` je Element mit), Phase mit Vermögensaufteilung und **zwei Wasserfällen**. (3) Die **Wasserfälle** sind bewusst getrennt: Der Vermögens-Wasserfall zeigt nur echte Zu- und Abgänge (Quote, Kapitalerträge, Wertsteigerung, PK-Beiträge, Steuern, Verrentung, Einmalposten); Sparraten, Amortisationen und Investitionen sind **Umbuchungen** und erscheinen ausschliesslich im Cash-Wasserfall – als Vermögensabgang gezeichnet würden sie einen Verlust vortäuschen, den es nicht gibt. Neue Strukturen `WealthBridge` / `CashBridge` inkl. Restposten als Kontrollgrösse. (4) **Rechenweg-Protokoll**: `computePlan(plan, sample?, { explain })` protokolliert die Schritte, die es ohnehin ausführt – Formel, eingesetzte Zahlen, Ergebnis und Hinweis auf geltende Vereinfachungen. Abdeckung über **alle** Ebenen (Element je Phase, Element je Übergang, Phasen-Kennzahlen, Plan-Ebene). Standardmässig aus, damit die Monte-Carlo-Simulation unberührt bleibt. Jeder Rechenweg verlinkt in das passende Kapitel dieser Spezifikation; ein Test prüft, dass alle Verweise eine existierende Überschrift treffen. Neue Kapitel 3.6.7, 3.6.8, 4.14, 9.20, 9.21; 12 Tests ergänzt (80 → 92). Keine DB-Änderung; die 43 Golden Tests laufen unverändert. |
|
||||||
@@ -805,9 +806,23 @@ Phasenköpfen:
|
|||||||
| Modus | Darstellung |
|
| Modus | Darstellung |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Nominal | `1'234'567` |
|
| Nominal | `1'234'567` |
|
||||||
| Beide | `1'234'567 (890'123)` – nominal, real in Klammern |
|
| Beide | nominal oben, real in Klammern **darunter** (siehe unten) |
|
||||||
| Real | `890'123` |
|
| Real | `890'123` |
|
||||||
|
|
||||||
|
Im Modus **Beide** steht der Realwert seit 0.14 in einer **eigenen Zeile** unter dem nominalen
|
||||||
|
Wert, nicht mehr daneben. Bei Start-/Endwerten wiederholt sich der Pfeil, damit der zeitliche
|
||||||
|
Bezug erhalten bleibt:
|
||||||
|
|
||||||
|
```
|
||||||
|
30'000 → 10'000
|
||||||
|
(29'557) → (7'430)
|
||||||
|
```
|
||||||
|
|
||||||
|
Grund: Nebeneinander wird die Zeile so lang, dass die Spalten unnötig breit werden und
|
||||||
|
Kennzahlen umbrechen. Untereinander bleiben die Spalten schmal – erst dadurch lässt sich jede
|
||||||
|
Kennzahl **umbruchfrei** darstellen. Die Regel gilt im Phasenkopf **und** in den Matrix-Zellen;
|
||||||
|
in den Modi «Nominal» und «Real» bleibt alles einzeilig.
|
||||||
|
|
||||||
„real" bedeutet kaufkraftbereinigt auf den **Planbeginn**: `nominal / Deflator`. Die Wahl wird in
|
„real" bedeutet kaufkraftbereinigt auf den **Planbeginn**: `nominal / Deflator`. Die Wahl wird in
|
||||||
`localStorage` unter `fpt-value-mode` gespeichert.
|
`localStorage` unter `fpt-value-mode` gespeichert.
|
||||||
|
|
||||||
@@ -840,10 +855,14 @@ niemand (Progressive Disclosure):
|
|||||||
| Name + Status-Icon | grünes Häkchen oder rotes Warnsymbol (Liquiditätslücke) |
|
| Name + Status-Icon | grünes Häkchen oder rotes Warnsymbol (Liquiditätslücke) |
|
||||||
| Typ-Badge + Dauer | Erwerb / Pension / Misch, „N J." |
|
| Typ-Badge + Dauer | Erwerb / Pension / Misch, „N J." |
|
||||||
| Alter je Person | `<Name> <StartAlter> → <EndAlter>` |
|
| Alter je Person | `<Name> <StartAlter> → <EndAlter>` |
|
||||||
| **Quote** bzw. **Verzehr** | Einkommen − Ausgaben; Label wechselt auf „Verzehr", wenn Jahr 1 negativ; rot bei Verzehr |
|
| **Verfügbares Kapital** | ab Phase 2 und nur wenn > 0: Topf, davon verteilt, Rest auf Cash ([3.6.9](#369-verfügbares-kapital-im-phasenkopf)) |
|
||||||
|
| **Sparquote** bzw. **Verzehrquote** | Einkommen − Ausgaben; Label wechselt auf „Verzehrquote", wenn Jahr 1 negativ; rot bei Verzehr |
|
||||||
| Vermögen | Start → Ende (inkl. Cash), hervorgehoben |
|
| Vermögen | Start → Ende (inkl. Cash), hervorgehoben |
|
||||||
| Einmalposten | nur als Kurzhinweis (Bezeichnung), wenn vorhanden |
|
| Einmalposten | nur als Kurzhinweis (Bezeichnung), wenn vorhanden |
|
||||||
|
|
||||||
|
Die beiden Blöcke «Verfügbares Kapital» und «Sparquote» tragen je einen Knopf, der das passende
|
||||||
|
Verteil-Werkzeug öffnet ([3.6.10](#3610-verteil-werkzeuge)).
|
||||||
|
|
||||||
Alles Weitere – Einkommen/Ausgaben Jahr 1 → letztes Jahr, geplante Spar-/Verzehrrate,
|
Alles Weitere – Einkommen/Ausgaben Jahr 1 → letztes Jahr, geplante Spar-/Verzehrrate,
|
||||||
Kapitalzufluss und -investitionen, die vollen Einmalposten – wohnt in der
|
Kapitalzufluss und -investitionen, die vollen Einmalposten – wohnt in der
|
||||||
**Phasen-Detailansicht** (seit 0.11, [3.6.7](#367-detailansichten-je-element-und-je-lebensphase)),
|
**Phasen-Detailansicht** (seit 0.11, [3.6.7](#367-detailansichten-je-element-und-je-lebensphase)),
|
||||||
@@ -958,6 +977,77 @@ Herleitung (`2 × R0 × 13`) statt nur das Ergebnis.
|
|||||||
|
|
||||||
Referenz: `src/components/SystemParametersView.tsx`, `src/lib/constants.ts`.
|
Referenz: `src/components/SystemParametersView.tsx`, `src/lib/constants.ts`.
|
||||||
|
|
||||||
|
### 3.6.9 Verfügbares Kapital im Phasenkopf
|
||||||
|
|
||||||
|
Für die Planung einer Phase ist die zentrale Frage: **Wie viel Kapital steht überhaupt zur
|
||||||
|
Verfügung?** Der Phasenkopf weist das ab Phase 2 als eigenen Block aus (nur wenn > 0):
|
||||||
|
|
||||||
|
| Zeile | Bedeutung |
|
||||||
|
|---|---|
|
||||||
|
| **Verfügbares Kapital** | der gesamte Topf beim Übergang in diese Phase |
|
||||||
|
| davon verteilt | Zusatzeinlagen + Sonderamortisation + Sofort-Tilgung |
|
||||||
|
| Rest auf Cash | was auf dem Cash-Konto liegen bleibt (= `cashStart`) |
|
||||||
|
|
||||||
|
Der Topf ist vollständig aus der **Cash-Brücke** ([4.14.2](#4142-die-beiden-wasserfälle))
|
||||||
|
ableitbar – es braucht keine zusätzliche Berechnung:
|
||||||
|
|
||||||
|
```
|
||||||
|
Topf = Cash-Ende der Vorphase + Kapitalzufluss + einmaliger Zufluss − einmalige Kosten
|
||||||
|
= cashStart + Investitionen + Sofort-Tilgungen
|
||||||
|
```
|
||||||
|
|
||||||
|
**Nur ab Phase 2:** In der ersten Phase ignoriert die Berechnung `additionalInvestment` – dort
|
||||||
|
tragen die Elemente ihren Startwert direkt ([4.6.3](#463-pension_fund)). Ein «Verteilen» hätte
|
||||||
|
dort eine andere Bedeutung, deshalb wird es gar nicht erst angeboten.
|
||||||
|
|
||||||
|
Referenz: `src/lib/distribution.ts` (`capitalPot`).
|
||||||
|
|
||||||
|
### 3.6.10 Verteil-Werkzeuge
|
||||||
|
|
||||||
|
Zwei Popups, erreichbar über je einen Knopf im Phasenkopf. Beide schreiben **ausschliesslich
|
||||||
|
bestehende Felder** über die bestehenden Endpunkte – an der Berechnung ändert sich nichts.
|
||||||
|
|
||||||
|
**«Kapital verteilen»** verteilt den Topf aus 3.6.9 auf:
|
||||||
|
|
||||||
|
| Ziel | geschriebenes Feld | liegt an |
|
||||||
|
|---|---|---|
|
||||||
|
| PK, Säule 3a, Sonstiges Vermögen | `additionalInvestment` | **dieser** Phase |
|
||||||
|
| Immobilie | `extraAmortization` | dem **Übergang davor** |
|
||||||
|
| Sonstige Schulden | `immediateRepayment` | dem **Übergang davor** |
|
||||||
|
|
||||||
|
Dass zwei verschiedene Objekte beschrieben werden, ist eine Folge des Datenmodells: Die
|
||||||
|
Zusatzeinlage ist ein Phasenwert, Sonderamortisation und Sofort-Tilgung sind Übergangs-Entscheide.
|
||||||
|
Beide zehren aber vom selben Topf. Betragsfelder werden dabei in die bestehenden Daten
|
||||||
|
**hineingemischt** – vorhandene Entscheide (`decision`, `salePrice`, `payoutMode` …) bleiben
|
||||||
|
erhalten.
|
||||||
|
|
||||||
|
Was nicht verteilt wird, **bleibt automatisch auf dem Cash** – dafür braucht es keine Logik, das
|
||||||
|
ist das Verhalten des Modells. Wird mehr verteilt als vorhanden, startet die Folgephase mit
|
||||||
|
negativem Cash; der Dialog weist das als Liquiditätslücke aus.
|
||||||
|
|
||||||
|
**«Sparquote verteilen»** (bzw. **«Bezug verteilen»** bei Verzehr) verteilt die laufende Quote auf
|
||||||
|
jährliche Raten: `annualContribution` (3a, Sonstiges Vermögen), `annualWithdrawal` (Sonstiges
|
||||||
|
Vermögen), `amortization` (Immobilie), `annualRepayment` (Schulden).
|
||||||
|
|
||||||
|
> **Die Pensionskasse fehlt hier bewusst.** Ihr Beitrag stammt aus dem Bruttolohn und belastet
|
||||||
|
> das Cash-Konto nicht ([4.6.3](#463-pension_fund)) – er lässt sich also gar nicht aus der Quote
|
||||||
|
> verteilen.
|
||||||
|
|
||||||
|
Der Dialog weist **drei** Bezugsgrössen aus: Quote im ersten Jahr, im letzten Jahr und –
|
||||||
|
entscheidend – die **absolute Quote über die ganze Phase**. Letztere ist die Grösse, gegen die
|
||||||
|
sich eine flache Jahresrate sinnvoll verteilen lässt (siehe [9.25](#925-die-quote-ist-kein-fester-betrag)).
|
||||||
|
|
||||||
|
**Live-Vorschau:** Beide Dialoge kopieren den Plan mit den Entwurfswerten und rechnen ihn erneut
|
||||||
|
durch `computePlan` – im Browser, ohne API-Aufruf. Die angezeigte Wirkung ist dadurch **per
|
||||||
|
Konstruktion exakt die spätere**, inklusive aller Kappungen (Bezugsrate am Bestand, Amortisation
|
||||||
|
an der Restschuld). Eine Nebenrechnung im UI hätte hier dieselbe Driftgefahr wie bei den
|
||||||
|
Rechenwegen ([4.14.3](#4143-rechenweg-protokoll)).
|
||||||
|
|
||||||
|
Beide Dialoge zeigen den **Fortschreibungs-Warnhinweis** ([3.5.7](#357-warnhinweis-bei-änderungen-in-früheren-phasen)),
|
||||||
|
wenn Folgephasen existieren.
|
||||||
|
|
||||||
|
Referenz: `src/components/DistributionDialogs.tsx`, `src/lib/distribution.ts`.
|
||||||
|
|
||||||
## 3.7 Bedienoberfläche
|
## 3.7 Bedienoberfläche
|
||||||
|
|
||||||
### 3.7.1 Layout
|
### 3.7.1 Layout
|
||||||
@@ -2147,7 +2237,7 @@ FPT/
|
|||||||
│ │ ├── page.tsx Einstiegsseite (lädt /api/auth/me → AppShell)
|
│ │ ├── page.tsx Einstiegsseite (lädt /api/auth/me → AppShell)
|
||||||
│ │ ├── layout.tsx Root-Layout, Theme-Init-Script, Metadata
|
│ │ ├── layout.tsx Root-Layout, Theme-Init-Script, Metadata
|
||||||
│ │ └── globals.css Tailwind + semantische Farb-Tokens (3 Themes)
|
│ │ └── globals.css Tailwind + semantische Farb-Tokens (3 Themes)
|
||||||
│ ├── components/ 22 React-Komponenten (alle "use client")
|
│ ├── components/ 23 React-Komponenten (alle "use client")
|
||||||
│ ├── generated/prisma/ Generierter Prisma-Client (nicht editieren)
|
│ ├── generated/prisma/ Generierter Prisma-Client (nicht editieren)
|
||||||
│ ├── lib/ Domänenlogik (siehe 5.3)
|
│ ├── lib/ Domänenlogik (siehe 5.3)
|
||||||
│ └── middleware.ts Zugriffsschutz (Edge-Runtime)
|
│ └── middleware.ts Zugriffsschutz (Edge-Runtime)
|
||||||
@@ -2180,6 +2270,7 @@ PlanComputed ← an den Client geliefert
|
|||||||
| `constants.ts` | Schweizer Systemparameter |
|
| `constants.ts` | Schweizer Systemparameter |
|
||||||
| `montecarlo.ts` | Monte-Carlo-Simulation (Sampler + Treiber), Szenario-Vergleich und Element-Gruppierung über die Herkunfts-Kette. Keine I/O, läuft im Browser. |
|
| `montecarlo.ts` | Monte-Carlo-Simulation (Sampler + Treiber), Szenario-Vergleich und Element-Gruppierung über die Herkunfts-Kette. Keine I/O, läuft im Browser. |
|
||||||
| `sensitivity.ts` | Sensitivitätsanalyse / Tornado: Treiber-Katalog, Parameter-Transformationen, `computeTornado`. Rein, läuft im Browser. |
|
| `sensitivity.ts` | Sensitivitätsanalyse / Tornado: Treiber-Katalog, Parameter-Transformationen, `computeTornado`. Rein, läuft im Browser. |
|
||||||
|
| `distribution.ts` | Kapitaltopf und Quoten-Zerlegung, Anwenden von Entwurfswerten für die Verteil-Werkzeuge (Kap. 3.6.9/3.6.10). Rein. |
|
||||||
| `constants.ts` (erweitert) | zusätzlich `SYSTEM_PARAMETERS`: dieselben Werte maschinenlesbar mit Bedeutung, Herleitung, Quelle und Stand – Grundlage der Systemparameter-Ansicht |
|
| `constants.ts` (erweitert) | zusätzlich `SYSTEM_PARAMETERS`: dieselben Werte maschinenlesbar mit Bedeutung, Herleitung, Quelle und Stand – Grundlage der Systemparameter-Ansicht |
|
||||||
| `diff.ts` | Abweichungs-Erkennung eines Szenarios gegen sein Eltern-Szenario (Kap. 3.2.6) |
|
| `diff.ts` | Abweichungs-Erkennung eines Szenarios gegen sein Eltern-Szenario (Kap. 3.2.6) |
|
||||||
| `queries.ts` | Prisma-Includes, `toPlanInput()`, Ownership-Abfragen |
|
| `queries.ts` | Prisma-Includes, `toPlanInput()`, Ownership-Abfragen |
|
||||||
@@ -2444,6 +2535,7 @@ wird der Plan neu geladen; die Berechnung kommt immer vom Server.
|
|||||||
| `PlanWizard` | ~400 | Geführter Plan-Assistent in fünf Schritten ([3.2.8](#328-geführter-assistent-und-beispielplan)) |
|
| `PlanWizard` | ~400 | Geführter Plan-Assistent in fünf Schritten ([3.2.8](#328-geführter-assistent-und-beispielplan)) |
|
||||||
| `Tour` | ~140 | Interaktive Kurz-Tour über die Planansicht ([3.7.8](#378-tour-und-nächste-schritte)) |
|
| `Tour` | ~140 | Interaktive Kurz-Tour über die Planansicht ([3.7.8](#378-tour-und-nächste-schritte)) |
|
||||||
| `CommandPalette` | ~130 | Befehls-Palette Ctrl/Cmd+K ([3.7.9](#379-befehls-palette-und-sparklines)) |
|
| `CommandPalette` | ~130 | Befehls-Palette Ctrl/Cmd+K ([3.7.9](#379-befehls-palette-und-sparklines)) |
|
||||||
|
| `DistributionDialogs` | ~460 | Verteil-Werkzeuge für Kapital und Spar-/Verzehrquote ([3.6.10](#3610-verteil-werkzeuge)) |
|
||||||
| `Sparkline` | ~45 | Mini-Verlaufskurve je Element-Zeile ([3.7.9](#379-befehls-palette-und-sparklines)) |
|
| `Sparkline` | ~45 | Mini-Verlaufskurve je Element-Zeile ([3.7.9](#379-befehls-palette-und-sparklines)) |
|
||||||
|
|
||||||
### 5.5.3 Wiederverwendungsmuster
|
### 5.5.3 Wiederverwendungsmuster
|
||||||
@@ -2675,10 +2767,11 @@ Include `src/**/*.test.ts`). Es gibt **keine** Komponenten-, API- oder E2E-Tests
|
|||||||
| `sensitivity.test.ts` | 15 | Treiber-Transformationen (Reinheit, Einheiten, Kappung), Verfügbarkeit, Tornado-Sortierung und -Richtung, Erklärung wirkungsloser Treiber |
|
| `sensitivity.test.ts` | 15 | Treiber-Transformationen (Reinheit, Einheiten, Kappung), Verfügbarkeit, Tornado-Sortierung und -Richtung, Erklärung wirkungsloser Treiber |
|
||||||
| `explain.test.ts` | 12 | Verlaufswerte je Element, Vollständigkeit beider Wasserfall-Zerlegungen, Rechenweg-Protokoll, Gültigkeit der Spezifikations-Verweise |
|
| `explain.test.ts` | 12 | Verlaufswerte je Element, Vollständigkeit beider Wasserfall-Zerlegungen, Rechenweg-Protokoll, Gültigkeit der Spezifikations-Verweise |
|
||||||
| `montecarlo.test.ts` | 13 | Determinismus, Volatilität/Vol-Drag, Böden, Reproduzierbarkeit; Element-Gruppierung und Szenario-Vergleich |
|
| `montecarlo.test.ts` | 13 | Determinismus, Volatilität/Vol-Drag, Böden, Reproduzierbarkeit; Element-Gruppierung und Szenario-Vergleich |
|
||||||
|
| `distribution.test.ts` | 8 | Kapitaltopf und Quoten-Zerlegung gegen die Cash-Brücke; Entwurfswerte anwenden ohne Verlust bestehender Felder |
|
||||||
| `bridges.test.ts` | 10 | Vermögens- und Cash-Brücke gehen über sieben Plankonstellationen ohne Restgrösse auf; Umbuchungen bleiben aus der Vermögensbrücke heraus |
|
| `bridges.test.ts` | 10 | Vermögens- und Cash-Brücke gehen über sieben Plankonstellationen ohne Restgrösse auf; Umbuchungen bleiben aus der Vermögensbrücke heraus |
|
||||||
| `diff.test.ts` | 9 | Abweichungs-Erkennung gegen das Eltern-Szenario |
|
| `diff.test.ts` | 9 | Abweichungs-Erkennung gegen das Eltern-Szenario |
|
||||||
| `migrations.test.ts` | 1 | spielt alle Migrationen gegen echtes PostgreSQL (PGlite) ein |
|
| `migrations.test.ts` | 1 | spielt alle Migrationen gegen echtes PostgreSQL (PGlite) ein |
|
||||||
| **Total** | **103** | |
|
| **Total** | **111** | |
|
||||||
|
|
||||||
## 8.2 Testfälle
|
## 8.2 Testfälle
|
||||||
|
|
||||||
@@ -3059,6 +3152,27 @@ verschachtelten Scroll-Situationen kann das Ziel teilweise verdeckt sein. Der Ei
|
|||||||
halber in Kauf genommen; eine echte Coach-Mark-Bibliothek wäre der nächste Schritt, wenn die
|
halber in Kauf genommen; eine echte Coach-Mark-Bibliothek wäre der nächste Schritt, wenn die
|
||||||
Tour sich bewährt.
|
Tour sich bewährt.
|
||||||
|
|
||||||
|
## 9.25 Die Quote ist kein fester Betrag
|
||||||
|
|
||||||
|
Die Spar-/Verzehrquote **verändert sich über die Phasenjahre**: Das Einkommen wächst mit der
|
||||||
|
Lohnerhöhung, die real erfassten Ausgaben mit der Inflation. Bei 0 % Lohnerhöhung und 1.5 %
|
||||||
|
Inflation sinkt eine Quote von 20'000 über fünf Jahre auf rund 15'100 – ohne dass der Nutzer
|
||||||
|
etwas geändert hätte.
|
||||||
|
|
||||||
|
`annualContribution` und die übrigen Raten sind dagegen **flache Jahresbeträge**
|
||||||
|
([9.6](#96-spar--und-bezugsraten-werden-nicht-indexiert)). Wer die Quote des **ersten** Jahres
|
||||||
|
als Rate verteilt, erzeugt sich damit in den späteren Jahren eine Liquiditätslücke.
|
||||||
|
|
||||||
|
Der Verteil-Dialog begegnet dem auf drei Arten, statt es zu verstecken:
|
||||||
|
- Er zeigt Quote **erstes Jahr**, **letztes Jahr** und **absolut über die Phase**.
|
||||||
|
- Er warnt ausdrücklich, wenn die Quote über die Phase sinkt.
|
||||||
|
- Die Live-Vorschau rechnet den ganzen Plan neu und meldet eine entstehende Liquiditätslücke
|
||||||
|
sofort, statt sie erst nach dem Speichern sichtbar zu machen.
|
||||||
|
|
||||||
|
Bewusst **nicht** umgesetzt ist eine automatische Deckelung: Es gibt legitime Gründe, mehr zu
|
||||||
|
sparen als die laufende Quote hergibt (etwa wenn ein Cash-Polster aus der Vorphase abgebaut
|
||||||
|
werden soll). Das Werkzeug informiert, es bevormundet nicht.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# 10. Glossar
|
# 10. Glossar
|
||||||
@@ -3101,4 +3215,4 @@ Tour sich bewährt.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*Ende der Spezifikation v0.13*
|
*Ende der Spezifikation v0.14*
|
||||||
|
|||||||
@@ -0,0 +1,550 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
// Zwei Verteil-Werkzeuge im Phasenkopf:
|
||||||
|
// 1. "Kapital verteilen" -- der beim Übergang zugeflossene Topf (Verkäufe, Bezüge,
|
||||||
|
// Erbschaft, Cash der Vorphase) auf Zusatzeinlagen, Sonderamortisation und Sofort-
|
||||||
|
// Tilgung verteilen; der Rest bleibt automatisch auf dem Cash.
|
||||||
|
// 2. "Sparquote verteilen" -- die laufende Spar- bzw. Verzehrquote auf jährliche Raten
|
||||||
|
// verteilen.
|
||||||
|
//
|
||||||
|
// Beide schreiben ausschliesslich BESTEHENDE Felder über die bestehenden Endpunkte. Die
|
||||||
|
// Live-Vorschau entsteht, indem der Plan mit den Entwurfswerten kopiert und erneut durch
|
||||||
|
// computePlan geschickt wird -- die angezeigte Wirkung ist dadurch per Konstruktion exakt
|
||||||
|
// die spätere, inklusive aller Kappungen.
|
||||||
|
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { AlertTriangle, Coins, PiggyBank } from "lucide-react";
|
||||||
|
import { Button, Modal } from "@/components/ui";
|
||||||
|
import { MoneyField } from "@/components/FormField";
|
||||||
|
import { InfoBubble } from "@/components/InfoBubble";
|
||||||
|
import { CarryWarning } from "@/components/ElementDetail";
|
||||||
|
import { api } from "@/lib/api-client";
|
||||||
|
import { formatChf } from "@/lib/format";
|
||||||
|
import { computePlan } from "@/lib/calculations";
|
||||||
|
import { CATEGORY_LABELS, num, type PhaseData, type TransitionData } from "@/lib/elements";
|
||||||
|
import {
|
||||||
|
applyPhasePatches,
|
||||||
|
applyTransitionPatches,
|
||||||
|
capitalPot,
|
||||||
|
quotaSummary,
|
||||||
|
type PhasePatch,
|
||||||
|
type TransitionPatch,
|
||||||
|
} from "@/lib/distribution";
|
||||||
|
import type { PlanInput } from "@/lib/types";
|
||||||
|
|
||||||
|
// --- gemeinsame Bausteine -----------------------------------------------------------------
|
||||||
|
|
||||||
|
function SummaryRow({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
help,
|
||||||
|
strong,
|
||||||
|
tone,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
help?: string;
|
||||||
|
strong?: boolean;
|
||||||
|
tone?: "danger" | "success" | "muted";
|
||||||
|
}) {
|
||||||
|
const color = tone === "danger" ? "text-danger" : tone === "success" ? "text-success" : strong ? "text-fg" : "text-muted";
|
||||||
|
return (
|
||||||
|
<div className="flex items-baseline justify-between gap-3 whitespace-nowrap">
|
||||||
|
<span className={`flex items-center text-xs ${strong ? "font-semibold text-fg" : "text-muted"}`}>
|
||||||
|
{label}
|
||||||
|
{help && <InfoBubble text={help} />}
|
||||||
|
</span>
|
||||||
|
<span className={`tabular-nums ${strong ? "text-sm font-semibold" : "text-xs"} ${color}`}>
|
||||||
|
{formatChf(value)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 1. Kapital verteilen -----------------------------------------------------------------
|
||||||
|
|
||||||
|
interface CapitalTarget {
|
||||||
|
elementId: string;
|
||||||
|
name: string;
|
||||||
|
category: string;
|
||||||
|
kind: "investment" | "amortization" | "repayment";
|
||||||
|
max: number | undefined; // Kappung (Restschuld); bei Investitionen unbegrenzt
|
||||||
|
hint: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CapitalDistributionDialog({
|
||||||
|
plan,
|
||||||
|
computed,
|
||||||
|
phaseId,
|
||||||
|
onClose,
|
||||||
|
onSaved,
|
||||||
|
}: {
|
||||||
|
plan: PlanInput;
|
||||||
|
computed: ReturnType<typeof computePlan>;
|
||||||
|
phaseId: string;
|
||||||
|
onClose: () => void;
|
||||||
|
onSaved: () => void;
|
||||||
|
}) {
|
||||||
|
const phaseIndex = computed.phases.findIndex((p) => p.id === phaseId);
|
||||||
|
const phase = computed.phases[phaseIndex];
|
||||||
|
const prevPhase = computed.phases[phaseIndex - 1];
|
||||||
|
|
||||||
|
// Ziele bestimmen. Zusatzeinlagen liegen in DIESER Phase, Sonderamortisation und
|
||||||
|
// Sofort-Tilgung dagegen am ÜBERGANG davor (also an der Vorphase) -- deshalb werden beim
|
||||||
|
// Speichern zwei verschiedene Endpunkte angesprochen.
|
||||||
|
const targets = useMemo<CapitalTarget[]>(() => {
|
||||||
|
const out: CapitalTarget[] = [];
|
||||||
|
for (const e of plan.elements) {
|
||||||
|
const ce = phase?.elements.find((x) => x.elementId === e.id);
|
||||||
|
const prevCe = prevPhase?.elements.find((x) => x.elementId === e.id);
|
||||||
|
if (!ce || ce.status !== "ACTIVE") continue;
|
||||||
|
|
||||||
|
if (e.category === "PENSION_FUND" || e.category === "PILLAR_3A" || e.category === "OTHER_ASSET") {
|
||||||
|
out.push({
|
||||||
|
elementId: e.id,
|
||||||
|
name: e.name,
|
||||||
|
category: e.category,
|
||||||
|
kind: "investment",
|
||||||
|
max: undefined,
|
||||||
|
hint: "Zusatzeinlage aus dem verfügbaren Kapital – erhöht den Startwert in dieser Phase.",
|
||||||
|
});
|
||||||
|
} else if (e.category === "REAL_ESTATE") {
|
||||||
|
const rest = prevCe?.mortgageEnd ?? 0;
|
||||||
|
// Beim Verkauf gibt es nichts mehr zu amortisieren.
|
||||||
|
const sold = e.transitionValues[prevPhase?.id ?? ""]?.decision === "SELL";
|
||||||
|
if (rest > 0 && !sold) {
|
||||||
|
out.push({
|
||||||
|
elementId: e.id,
|
||||||
|
name: e.name,
|
||||||
|
category: e.category,
|
||||||
|
kind: "amortization",
|
||||||
|
max: rest,
|
||||||
|
hint: `Sonderamortisation: Einmaltilgung der Hypothek am Übergang. Maximal ${formatChf(rest)} (Resthypothek).`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (e.category === "OTHER_DEBT") {
|
||||||
|
const owed = prevCe ? Math.abs(Math.min(0, prevCe.endValue)) : 0;
|
||||||
|
if (owed > 0) {
|
||||||
|
out.push({
|
||||||
|
elementId: e.id,
|
||||||
|
name: e.name,
|
||||||
|
category: e.category,
|
||||||
|
kind: "repayment",
|
||||||
|
max: owed,
|
||||||
|
hint: `Sofortige Tilgung am Übergang. Maximal ${formatChf(owed)} (Restschuld).`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- phase/prevPhase folgen phaseId
|
||||||
|
}, [plan.elements, phaseId]);
|
||||||
|
|
||||||
|
// Entwurf mit den aktuell gespeicherten Werten vorbelegen.
|
||||||
|
const [draft, setDraft] = useState<Record<string, number>>(() => {
|
||||||
|
const d: Record<string, number> = {};
|
||||||
|
for (const t of targets) {
|
||||||
|
const e = plan.elements.find((x) => x.id === t.elementId)!;
|
||||||
|
d[t.elementId] =
|
||||||
|
t.kind === "investment"
|
||||||
|
? Math.round(num(e.phaseValues[phaseId]?.additionalInvestment))
|
||||||
|
: t.kind === "amortization"
|
||||||
|
? Math.round(num(e.transitionValues[prevPhase?.id ?? ""]?.extraAmortization))
|
||||||
|
: Math.round(num(e.transitionValues[prevPhase?.id ?? ""]?.immediateRepayment));
|
||||||
|
}
|
||||||
|
return d;
|
||||||
|
});
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Live-Vorschau: Plan mit den Entwurfswerten neu rechnen.
|
||||||
|
const preview = useMemo(() => {
|
||||||
|
const phasePatches: PhasePatch[] = targets
|
||||||
|
.filter((t) => t.kind === "investment")
|
||||||
|
.map((t) => ({ elementId: t.elementId, field: "additionalInvestment" as const, value: draft[t.elementId] ?? 0 }));
|
||||||
|
const transPatches: TransitionPatch[] = targets
|
||||||
|
.filter((t) => t.kind !== "investment")
|
||||||
|
.map((t) => ({
|
||||||
|
elementId: t.elementId,
|
||||||
|
field: (t.kind === "amortization" ? "extraAmortization" : "immediateRepayment") as keyof TransitionData,
|
||||||
|
value: draft[t.elementId] ?? 0,
|
||||||
|
}));
|
||||||
|
let next = applyPhasePatches(plan, phaseId, phasePatches);
|
||||||
|
if (prevPhase) next = applyTransitionPatches(next, prevPhase.id, transPatches);
|
||||||
|
const c = computePlan(next);
|
||||||
|
return c.phases.find((p) => p.id === phaseId)!;
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [draft, plan, phaseId, targets]);
|
||||||
|
|
||||||
|
const pot = capitalPot(preview);
|
||||||
|
const laterPhases = computed.phases.length - (phase?.sequenceNumber ?? 0);
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
for (const t of targets) {
|
||||||
|
const e = plan.elements.find((x) => x.id === t.elementId)!;
|
||||||
|
const value = draft[t.elementId] ?? 0;
|
||||||
|
if (t.kind === "investment") {
|
||||||
|
// Bestehende Felder der Phase erhalten -- der Endpunkt ersetzt den ganzen Satz.
|
||||||
|
const merged: PhaseData = { ...(e.phaseValues[phaseId] ?? {}), additionalInvestment: value };
|
||||||
|
await api.put(`/api/elements/${e.id}/phase/${phaseId}`, merged);
|
||||||
|
} else if (prevPhase) {
|
||||||
|
const existing: TransitionData = e.transitionValues[prevPhase.id] ?? {};
|
||||||
|
const merged: TransitionData =
|
||||||
|
t.kind === "amortization"
|
||||||
|
? { ...existing, extraAmortization: value }
|
||||||
|
: { ...existing, immediateRepayment: value };
|
||||||
|
await api.put(`/api/elements/${e.id}/transition/${prevPhase.id}`, merged);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onSaved();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "Speichern fehlgeschlagen.");
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title="Kapital verteilen"
|
||||||
|
subtitle={phase ? `${phase.name} · verfügbares Kapital beim Übergang in diese Phase` : undefined}
|
||||||
|
onClose={onClose}
|
||||||
|
wide
|
||||||
|
>
|
||||||
|
{/* Herkunft des Topfs */}
|
||||||
|
<div className="rounded-xl border border-border bg-surface-2 p-3">
|
||||||
|
<div className="mb-2 flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-faint">
|
||||||
|
<Coins className="h-4 w-4" /> Woher das Kapital kommt
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<SummaryRow label="Cash-Ende der Vorphase" value={pot.fromPreviousCash} />
|
||||||
|
{pot.capitalInflow !== 0 && (
|
||||||
|
<SummaryRow label="Kapitalzufluss (Verkäufe, PK-/3a-Bezüge)" value={pot.capitalInflow} />
|
||||||
|
)}
|
||||||
|
{pot.oneOffInflow !== 0 && <SummaryRow label="Einmaliger Zufluss" value={pot.oneOffInflow} />}
|
||||||
|
{pot.oneOffOutflow !== 0 && <SummaryRow label="Einmalige Kosten" value={-pot.oneOffOutflow} tone="danger" />}
|
||||||
|
<div className="my-1 border-t border-border" />
|
||||||
|
<SummaryRow label="Verfügbares Kapital" value={pot.total} strong />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{targets.length === 0 ? (
|
||||||
|
<p className="rounded-lg border border-dashed border-border bg-surface-2 p-3 text-sm text-muted">
|
||||||
|
In dieser Phase gibt es keine Ziele, auf die sich Kapital verteilen lässt. Das Kapital bleibt vollständig
|
||||||
|
auf dem Cash-Konto.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{targets.map((t) => (
|
||||||
|
<div key={t.elementId} 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.kind === "investment" ? "Zusatzeinlage" : t.kind === "amortization" ? "Sonderamortisation" : "Sofortige Tilgung"}
|
||||||
|
</span>
|
||||||
|
<InfoBubble text={t.hint} />
|
||||||
|
</div>
|
||||||
|
<MoneyField
|
||||||
|
label="Betrag (CHF)"
|
||||||
|
value={draft[t.elementId] ?? 0}
|
||||||
|
max={t.max}
|
||||||
|
onChange={(v) => setDraft((prev) => ({ ...prev, [t.elementId]: v }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Live-Bilanz */}
|
||||||
|
<div
|
||||||
|
className={`rounded-xl border p-3 ${
|
||||||
|
pot.rest < 0 ? "border-danger bg-danger-soft" : "border-border bg-surface-2"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<SummaryRow label="Verfügbares Kapital" value={pot.total} />
|
||||||
|
{pot.allocatedInvestments !== 0 && <SummaryRow label="davon investiert" value={-pot.allocatedInvestments} />}
|
||||||
|
{pot.allocatedRepayments !== 0 && (
|
||||||
|
<SummaryRow label="davon in Schulden getilgt" value={-pot.allocatedRepayments} />
|
||||||
|
)}
|
||||||
|
<div className="my-1 border-t border-border" />
|
||||||
|
<SummaryRow
|
||||||
|
label={pot.rest < 0 ? "Fehlbetrag (Cash wird negativ)" : "Rest bleibt auf dem Cash"}
|
||||||
|
value={pot.rest}
|
||||||
|
strong
|
||||||
|
tone={pot.rest < 0 ? "danger" : undefined}
|
||||||
|
help="Was du nicht verteilst, bleibt automatisch auf dem Cash-Konto – dort steht es weiterhin zur Verfügung."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{pot.rest < 0 && (
|
||||||
|
<p className="mt-2 flex items-start gap-2 text-xs text-danger">
|
||||||
|
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
||||||
|
Du verteilst mehr, als zur Verfügung steht. Das Cash-Konto startet negativ – das Tool meldet die Phase
|
||||||
|
als Liquiditätslücke.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{laterPhases > 0 && (
|
||||||
|
<div className="grid grid-cols-1">
|
||||||
|
<CarryWarning laterPhaseCount={laterPhases} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && <p className="text-sm text-danger">{error}</p>}
|
||||||
|
<div className="flex gap-2 border-t border-border pt-3">
|
||||||
|
<Button disabled={saving} onClick={save}>
|
||||||
|
{saving ? "…" : "Verteilung speichern"}
|
||||||
|
</Button>
|
||||||
|
<Button variant="secondary" onClick={onClose}>
|
||||||
|
Abbrechen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 2. Spar-/Verzehrquote verteilen ------------------------------------------------------
|
||||||
|
|
||||||
|
interface RateTarget {
|
||||||
|
elementId: string;
|
||||||
|
name: string;
|
||||||
|
category: string;
|
||||||
|
field: "annualContribution" | "annualWithdrawal" | "amortization" | "annualRepayment";
|
||||||
|
label: string;
|
||||||
|
direction: "out" | "in";
|
||||||
|
hint: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RateDistributionDialog({
|
||||||
|
plan,
|
||||||
|
computed,
|
||||||
|
phaseId,
|
||||||
|
onClose,
|
||||||
|
onSaved,
|
||||||
|
}: {
|
||||||
|
plan: PlanInput;
|
||||||
|
computed: ReturnType<typeof computePlan>;
|
||||||
|
phaseId: string;
|
||||||
|
onClose: () => void;
|
||||||
|
onSaved: () => void;
|
||||||
|
}) {
|
||||||
|
const phase = computed.phases.find((p) => p.id === phaseId)!;
|
||||||
|
|
||||||
|
const targets = useMemo<RateTarget[]>(() => {
|
||||||
|
const out: RateTarget[] = [];
|
||||||
|
for (const e of plan.elements) {
|
||||||
|
const ce = phase?.elements.find((x) => x.elementId === e.id);
|
||||||
|
if (!ce || ce.status !== "ACTIVE") continue;
|
||||||
|
// Die PK ist bewusst NICHT dabei: Ihr Beitrag stammt aus dem Bruttolohn und belastet
|
||||||
|
// das Cash-Konto nicht -- er lässt sich also gar nicht aus der Quote verteilen.
|
||||||
|
if (e.category === "PILLAR_3A") {
|
||||||
|
out.push({
|
||||||
|
elementId: e.id, name: e.name, category: e.category, field: "annualContribution",
|
||||||
|
label: "Jährliche Einzahlung", direction: "out",
|
||||||
|
hint: "Fliesst jährlich vom Cash in die Säule 3a.",
|
||||||
|
});
|
||||||
|
} else if (e.category === "OTHER_ASSET") {
|
||||||
|
out.push({
|
||||||
|
elementId: e.id, name: e.name, category: e.category, field: "annualContribution",
|
||||||
|
label: "Jährlicher Sparbeitrag", direction: "out",
|
||||||
|
hint: "Fliesst jährlich vom Cash ins Vermögen.",
|
||||||
|
});
|
||||||
|
out.push({
|
||||||
|
elementId: e.id, name: e.name, category: e.category, field: "annualWithdrawal",
|
||||||
|
label: "Jährliche Bezugsrate", direction: "in",
|
||||||
|
hint: "Entnahme aus dem Vermögen ins Cash. Wird jährlich am vorhandenen Bestand gekappt.",
|
||||||
|
});
|
||||||
|
} else if (e.category === "REAL_ESTATE") {
|
||||||
|
out.push({
|
||||||
|
elementId: e.id, name: e.name, category: e.category, field: "amortization",
|
||||||
|
label: "Amortisation pro Jahr", direction: "out",
|
||||||
|
hint: "Reduziert die Hypothek. Endet automatisch, sobald sie abbezahlt ist.",
|
||||||
|
});
|
||||||
|
} else if (e.category === "OTHER_DEBT") {
|
||||||
|
out.push({
|
||||||
|
elementId: e.id, name: e.name, category: e.category, field: "annualRepayment",
|
||||||
|
label: "Tilgung pro Jahr", direction: "out",
|
||||||
|
hint: "Reduziert die Restschuld. Endet automatisch, sobald sie getilgt ist.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- phase folgt phaseId
|
||||||
|
}, [plan.elements, phaseId]);
|
||||||
|
|
||||||
|
const key = (t: RateTarget) => `${t.elementId}:${t.field}`;
|
||||||
|
|
||||||
|
const [draft, setDraft] = useState<Record<string, number>>(() => {
|
||||||
|
const d: Record<string, number> = {};
|
||||||
|
for (const t of targets) {
|
||||||
|
const e = plan.elements.find((x) => x.id === t.elementId)!;
|
||||||
|
d[key(t)] = Math.round(num((e.phaseValues[phaseId] ?? {})[t.field]));
|
||||||
|
}
|
||||||
|
return d;
|
||||||
|
});
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const preview = useMemo(() => {
|
||||||
|
const patches: PhasePatch[] = targets.map((t) => ({
|
||||||
|
elementId: t.elementId,
|
||||||
|
field: t.field,
|
||||||
|
value: draft[key(t)] ?? 0,
|
||||||
|
}));
|
||||||
|
return computePlan(applyPhasePatches(plan, phaseId, patches)).phases.find((p) => p.id === phaseId)!;
|
||||||
|
}, [draft, plan, phaseId, targets]);
|
||||||
|
|
||||||
|
const q = quotaSummary(preview);
|
||||||
|
const laterPhases = computed.phases.length - phase.sequenceNumber;
|
||||||
|
const titel = q.isConsumption ? "Verzehrquote verteilen" : "Sparquote verteilen";
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
// Je Element EIN Aufruf, auch wenn mehrere Felder betroffen sind.
|
||||||
|
const byElement = new Map<string, PhaseData>();
|
||||||
|
for (const t of targets) {
|
||||||
|
const e = plan.elements.find((x) => x.id === t.elementId)!;
|
||||||
|
const merged = byElement.get(t.elementId) ?? { ...(e.phaseValues[phaseId] ?? {}) };
|
||||||
|
(merged as Record<string, number>)[t.field] = draft[key(t)] ?? 0;
|
||||||
|
byElement.set(t.elementId, merged);
|
||||||
|
}
|
||||||
|
for (const [elementId, data] of byElement) {
|
||||||
|
await api.put(`/api/elements/${elementId}/phase/${phaseId}`, data);
|
||||||
|
}
|
||||||
|
onSaved();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "Speichern fehlgeschlagen.");
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal title={titel} subtitle={`${phase.name} · ${phase.durationYears} Jahre`} onClose={onClose} wide>
|
||||||
|
{/* Die drei Bezugsgrössen */}
|
||||||
|
<div className="rounded-xl border border-border bg-surface-2 p-3">
|
||||||
|
<div className="mb-2 flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-faint">
|
||||||
|
<PiggyBank className="h-4 w-4" /> {q.isConsumption ? "Deine Verzehrquote" : "Deine Sparquote"}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<SummaryRow
|
||||||
|
label="Erstes Phasenjahr"
|
||||||
|
value={q.start}
|
||||||
|
help="Einkommen minus nominale Ausgaben im ersten Jahr dieser Phase."
|
||||||
|
/>
|
||||||
|
<SummaryRow
|
||||||
|
label="Letztes Phasenjahr"
|
||||||
|
value={q.end}
|
||||||
|
help="Die Quote verändert sich über die Phase: Das Einkommen wächst mit der Lohnerhöhung, die real erfassten Ausgaben mit der Inflation."
|
||||||
|
/>
|
||||||
|
<div className="my-1 border-t border-border" />
|
||||||
|
<SummaryRow
|
||||||
|
label={q.isConsumption ? "Absolut über die ganze Phase (Bedarf)" : "Absolut über die ganze Phase"}
|
||||||
|
value={q.total}
|
||||||
|
strong
|
||||||
|
help="Summe über alle Phasenjahre – der Betrag, der insgesamt zur Verfügung steht bzw. fehlt. Bewusst nominal summiert, genau wie das Cash-Konto rechnet."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{!q.isConsumption && q.end < q.start && (
|
||||||
|
<p className="mt-2 text-[11px] text-muted">
|
||||||
|
Achtung: Die Quote <strong className="text-fg">sinkt</strong> von {formatChf(q.start)} auf{" "}
|
||||||
|
{formatChf(q.end)}. Eine flache Jahresrate über {formatChf(q.end)} lässt sich in den späteren Jahren
|
||||||
|
nicht mehr aus der laufenden Quote decken – sie zehrt dann am Cash-Bestand.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{targets.length === 0 ? (
|
||||||
|
<p className="rounded-lg border border-dashed border-border bg-surface-2 p-3 text-sm text-muted">
|
||||||
|
In dieser Phase gibt es keine Elemente mit jährlichen Raten. Die ganze Quote läuft aufs Cash-Konto.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{targets.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 px-1.5 py-0.5 text-[10px] ${
|
||||||
|
t.direction === "in" ? "bg-success/15 text-success" : "bg-surface-2 text-muted"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t.direction === "in" ? "ins Cash" : "vom Cash"}
|
||||||
|
</span>
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Live-Bilanz über die ganze Phase */}
|
||||||
|
<div
|
||||||
|
className={`rounded-xl border p-3 ${
|
||||||
|
preview.cashNegative ? "border-danger bg-danger-soft" : "border-border bg-surface-2"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<SummaryRow label={q.isConsumption ? "Absoluter Bedarf" : "Absolut verfügbar"} value={q.total} />
|
||||||
|
{q.allocatedOut !== 0 && (
|
||||||
|
<SummaryRow
|
||||||
|
label="davon in Raten verplant"
|
||||||
|
value={-q.allocatedOut}
|
||||||
|
help="Summe aller jährlichen Raten über die Phasenjahre – inklusive der automatischen Kappung, sobald eine Schuld abbezahlt ist."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{q.allocatedIn !== 0 && (
|
||||||
|
<SummaryRow
|
||||||
|
label="Bezugsraten ins Cash"
|
||||||
|
value={q.allocatedIn}
|
||||||
|
tone="success"
|
||||||
|
help="Tatsächlich entnommener Betrag – am vorhandenen Bestand gekappt."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="my-1 border-t border-border" />
|
||||||
|
<SummaryRow
|
||||||
|
label={q.netToCash < 0 ? "Zehrt am Cash-Bestand" : "Geht aufs Cash-Konto"}
|
||||||
|
value={q.netToCash}
|
||||||
|
strong
|
||||||
|
tone={q.netToCash < 0 ? "danger" : undefined}
|
||||||
|
help="Was nicht in Raten verplant ist, landet auf dem Cash-Konto. Ein negativer Wert bedeutet, dass der Cash-Bestand in dieser Phase abnimmt."
|
||||||
|
/>
|
||||||
|
<SummaryRow label="Cash am Phasenende" value={preview.cashEnd} tone={preview.cashEnd < 0 ? "danger" : undefined} />
|
||||||
|
</div>
|
||||||
|
{preview.cashNegative && (
|
||||||
|
<p className="mt-2 flex items-start gap-2 text-xs text-danger">
|
||||||
|
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
||||||
|
Mit dieser Verteilung fällt das Cash-Konto in dieser Phase unter 0 – das ist eine Liquiditätslücke.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{laterPhases > 0 && (
|
||||||
|
<div className="grid grid-cols-1">
|
||||||
|
<CarryWarning laterPhaseCount={laterPhases} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && <p className="text-sm text-danger">{error}</p>}
|
||||||
|
<div className="flex gap-2 border-t border-border pt-3">
|
||||||
|
<Button disabled={saving} onClick={save}>
|
||||||
|
{saving ? "…" : "Verteilung speichern"}
|
||||||
|
</Button>
|
||||||
|
<Button variant="secondary" onClick={onClose}>
|
||||||
|
Abbrechen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
+170
-16
@@ -24,6 +24,8 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Timeline } from "@/components/Timeline";
|
import { Timeline } from "@/components/Timeline";
|
||||||
import { Sparkline } from "@/components/Sparkline";
|
import { Sparkline } from "@/components/Sparkline";
|
||||||
|
import { CapitalDistributionDialog, RateDistributionDialog } from "@/components/DistributionDialogs";
|
||||||
|
import { capitalPot } from "@/lib/distribution";
|
||||||
import { Tour, TOUR_DONE_KEY } from "@/components/Tour";
|
import { Tour, TOUR_DONE_KEY } from "@/components/Tour";
|
||||||
import { Button, EmptyState, InspectorShell, Modal, useConfirm, useToast } from "@/components/ui";
|
import { Button, EmptyState, InspectorShell, Modal, useConfirm, useToast } from "@/components/ui";
|
||||||
import { ElementDetailDialog, PhaseDetailDialog } from "@/components/DetailView";
|
import { ElementDetailDialog, PhaseDetailDialog } from "@/components/DetailView";
|
||||||
@@ -141,6 +143,9 @@ export function PlanView({
|
|||||||
const [showAddPhase, setShowAddPhase] = useState(false);
|
const [showAddPhase, setShowAddPhase] = useState(false);
|
||||||
const [reviewFromPhaseId, setReviewFromPhaseId] = useState<string | null>(null);
|
const [reviewFromPhaseId, setReviewFromPhaseId] = useState<string | null>(null);
|
||||||
const [showTour, setShowTour] = useState(false);
|
const [showTour, setShowTour] = useState(false);
|
||||||
|
// Offener Verteil-Dialog (Kapital bzw. Spar-/Verzehrquote) -- bewusst ein eigenes Popup,
|
||||||
|
// weil beide mehrere Elemente auf einmal bearbeiten.
|
||||||
|
const [distribute, setDistribute] = useState<{ kind: "capital" | "rates"; phaseId: string } | null>(null);
|
||||||
const [valueMode, setValueMode] = useState<ValueMode>("nominal");
|
const [valueMode, setValueMode] = useState<ValueMode>("nominal");
|
||||||
|
|
||||||
// Tour beim ersten Besuch eines Plans mit Phasen automatisch starten.
|
// Tour beim ersten Besuch eines Plans mit Phasen automatisch starten.
|
||||||
@@ -484,6 +489,10 @@ export function PlanView({
|
|||||||
diffKind={diff?.phaseHeader.get(col.phase.id) ?? null}
|
diffKind={diff?.phaseHeader.get(col.phase.id) ?? null}
|
||||||
onClick={() => setPanel({ kind: "phase", phaseId: col.phase.id })}
|
onClick={() => setPanel({ kind: "phase", phaseId: col.phase.id })}
|
||||||
onExpand={() => setDetailFor({ kind: "phase", id: col.phase.id })}
|
onExpand={() => setDetailFor({ kind: "phase", id: col.phase.id })}
|
||||||
|
onDistributeCapital={
|
||||||
|
col.phase.sequenceNumber > 1 ? () => setDistribute({ kind: "capital", phaseId: col.phase.id }) : null
|
||||||
|
}
|
||||||
|
onDistributeRates={() => setDistribute({ kind: "rates", phaseId: col.phase.id })}
|
||||||
active={panel?.kind === "phase" && panel.phaseId === col.phase.id}
|
active={panel?.kind === "phase" && panel.phaseId === col.phase.id}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
@@ -768,6 +777,36 @@ export function PlanView({
|
|||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
|
|
||||||
|
{distribute?.kind === "capital" && (
|
||||||
|
<CapitalDistributionDialog
|
||||||
|
key={`cap-${distribute.phaseId}`}
|
||||||
|
plan={plan}
|
||||||
|
computed={computed}
|
||||||
|
phaseId={distribute.phaseId}
|
||||||
|
onClose={() => setDistribute(null)}
|
||||||
|
onSaved={() => {
|
||||||
|
setDistribute(null);
|
||||||
|
toast("success", "Kapital verteilt.");
|
||||||
|
onChanged();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{distribute?.kind === "rates" && (
|
||||||
|
<RateDistributionDialog
|
||||||
|
key={`rate-${distribute.phaseId}`}
|
||||||
|
plan={plan}
|
||||||
|
computed={computed}
|
||||||
|
phaseId={distribute.phaseId}
|
||||||
|
onClose={() => setDistribute(null)}
|
||||||
|
onSaved={() => {
|
||||||
|
setDistribute(null);
|
||||||
|
toast("success", "Raten gespeichert.");
|
||||||
|
onChanged();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{showTour && <Tour onClose={() => setShowTour(false)} />}
|
{showTour && <Tour onClose={() => setShowTour(false)} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -932,12 +971,60 @@ const VALUE_MODE_KEY = "fpt-value-mode";
|
|||||||
function realOf(nominal: number, deflator: number): number {
|
function realOf(nominal: number, deflator: number): number {
|
||||||
return Math.round(nominal / (deflator || 1));
|
return Math.round(nominal / (deflator || 1));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reiner Text (fuer Titel/Tooltips und einzeilige Faelle).
|
||||||
function valStr(nominal: number, deflator: number, mode: ValueMode): string {
|
function valStr(nominal: number, deflator: number, mode: ValueMode): string {
|
||||||
if (mode === "real") return formatChf(realOf(nominal, deflator));
|
if (mode === "real") return formatChf(realOf(nominal, deflator));
|
||||||
if (mode === "both") return `${formatChf(nominal)} (${formatChf(realOf(nominal, deflator))})`;
|
if (mode === "both") return `${formatChf(nominal)} (${formatChf(realOf(nominal, deflator))})`;
|
||||||
return formatChf(nominal);
|
return formatChf(nominal);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Im Modus "Beide" steht der Realwert in Klammern in einer EIGENEN Zeile unter dem nominalen
|
||||||
|
// Wert -- nebeneinander wird die Zeile zu lang und die Spalten unnoetig breit. Der Pfeil
|
||||||
|
// wiederholt sich auf der zweiten Zeile, damit der Zeitbezug Start -> Ende erhalten bleibt.
|
||||||
|
function ValuePair({
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
deflatorStart,
|
||||||
|
deflatorEnd,
|
||||||
|
mode,
|
||||||
|
}: {
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
deflatorStart: number;
|
||||||
|
deflatorEnd: number;
|
||||||
|
mode: ValueMode;
|
||||||
|
}) {
|
||||||
|
const arrow = <span className="text-faint">→</span>;
|
||||||
|
return (
|
||||||
|
<span className="inline-flex flex-col">
|
||||||
|
<span className="whitespace-nowrap tabular-nums">
|
||||||
|
{mode === "real" ? formatChf(realOf(start, deflatorStart)) : formatChf(start)} {arrow}{" "}
|
||||||
|
{mode === "real" ? formatChf(realOf(end, deflatorEnd)) : formatChf(end)}
|
||||||
|
</span>
|
||||||
|
{mode === "both" && (
|
||||||
|
<span className="whitespace-nowrap tabular-nums text-faint">
|
||||||
|
({formatChf(realOf(start, deflatorStart))}) {arrow} ({formatChf(realOf(end, deflatorEnd))})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Einzelwert, gleiche Konvention.
|
||||||
|
function ValueSingle({ value, deflator, mode }: { value: number; deflator: number; mode: ValueMode }) {
|
||||||
|
return (
|
||||||
|
<span className="inline-flex flex-col">
|
||||||
|
<span className="whitespace-nowrap tabular-nums">
|
||||||
|
{mode === "real" ? formatChf(realOf(value, deflator)) : formatChf(value)}
|
||||||
|
</span>
|
||||||
|
{mode === "both" && (
|
||||||
|
<span className="whitespace-nowrap tabular-nums text-faint">({formatChf(realOf(value, deflator))})</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function phaseCellContent(
|
function phaseCellContent(
|
||||||
ce: ReturnType<PhaseComputed["elements"]["find"]> | undefined,
|
ce: ReturnType<PhaseComputed["elements"]["find"]> | undefined,
|
||||||
phase: PhaseComputed,
|
phase: PhaseComputed,
|
||||||
@@ -949,14 +1036,17 @@ function phaseCellContent(
|
|||||||
const dS = phase.cumulativeInflationStart;
|
const dS = phase.cumulativeInflationStart;
|
||||||
const dE = isFlow ? phase.flowDeflatorEnd : phase.cumulativeInflationEnd;
|
const dE = isFlow ? phase.flowDeflatorEnd : phase.cumulativeInflationEnd;
|
||||||
if (START_END_CATEGORIES.includes(ce.category) && (ce.startValue !== 0 || ce.endValue !== 0)) {
|
if (START_END_CATEGORIES.includes(ce.category) && (ce.startValue !== 0 || ce.endValue !== 0)) {
|
||||||
return (
|
return <ValuePair start={ce.startValue} end={ce.endValue} deflatorStart={dS} deflatorEnd={dE} mode={mode} />;
|
||||||
<span className="whitespace-nowrap">
|
|
||||||
{valStr(ce.startValue, dS, mode)} <span className="text-faint">→</span> {valStr(ce.endValue, dE, mode)}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
if ((ce.category === "AHV" || ce.category === "PENSION_FUND") && ce.startValue !== 0) {
|
if ((ce.category === "AHV" || ce.category === "PENSION_FUND") && ce.startValue !== 0) {
|
||||||
return `Rente ${valStr(ce.startValue, dS, mode)}`;
|
return (
|
||||||
|
<span className="inline-flex flex-col">
|
||||||
|
<span className="whitespace-nowrap">Rente {mode === "real" ? formatChf(realOf(ce.startValue, dS)) : formatChf(ce.startValue)}</span>
|
||||||
|
{mode === "both" && (
|
||||||
|
<span className="whitespace-nowrap tabular-nums text-faint">({formatChf(realOf(ce.startValue, dS))})</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return ce.summary || "–";
|
return ce.summary || "–";
|
||||||
}
|
}
|
||||||
@@ -968,6 +1058,8 @@ function PhaseHeader({
|
|||||||
diffKind,
|
diffKind,
|
||||||
onClick,
|
onClick,
|
||||||
onExpand,
|
onExpand,
|
||||||
|
onDistributeCapital,
|
||||||
|
onDistributeRates,
|
||||||
active,
|
active,
|
||||||
}: {
|
}: {
|
||||||
phase: PhaseComputed;
|
phase: PhaseComputed;
|
||||||
@@ -976,9 +1068,14 @@ function PhaseHeader({
|
|||||||
diffKind: "changed" | "added" | "removed" | null;
|
diffKind: "changed" | "added" | "removed" | null;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
onExpand: () => void;
|
onExpand: () => void;
|
||||||
|
// null = Phase 1: dort gibt es kein verteilbares Übergangs-Kapital (additionalInvestment
|
||||||
|
// wird in der ersten Phase von der Berechnung ignoriert, dort zählt der Startwert).
|
||||||
|
onDistributeCapital: (() => void) | null;
|
||||||
|
onDistributeRates: () => void;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
}) {
|
}) {
|
||||||
const quotaLabel = phase.isConsumption ? "Verzehr" : "Quote";
|
const quotaLabel = phase.isConsumption ? "Verzehrquote" : "Sparquote";
|
||||||
|
const pot = capitalPot(phase);
|
||||||
const dS = phase.cumulativeInflationStart;
|
const dS = phase.cumulativeInflationStart;
|
||||||
const dE = phase.cumulativeInflationEnd; // Bestandswerte (Cash, Vermögen)
|
const dE = phase.cumulativeInflationEnd; // Bestandswerte (Cash, Vermögen)
|
||||||
const dF = phase.flowDeflatorEnd; // Flow-Werte (Einkommen, Ausgaben, Quote)
|
const dF = phase.flowDeflatorEnd; // Flow-Werte (Einkommen, Ausgaben, Quote)
|
||||||
@@ -1026,27 +1123,84 @@ function PhaseHeader({
|
|||||||
</span>
|
</span>
|
||||||
<span className="text-[10px] text-faint">{phase.durationYears} J.</span>
|
<span className="text-[10px] text-faint">{phase.durationYears} J.</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 space-y-0.5 text-[10px] leading-tight text-muted">
|
<div className="mt-1 flex flex-col gap-1 text-[10px] leading-tight text-muted">
|
||||||
|
<div>
|
||||||
{phase.persons.map((p) => (
|
{phase.persons.map((p) => (
|
||||||
<div key={p.role} className="text-faint">
|
<div key={p.role} className="whitespace-nowrap text-faint">
|
||||||
{personLabel(p.role)} {p.startAge} → {p.endAge}
|
{personLabel(p.role)} {p.startAge} → {p.endAge}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
<div className={phase.isConsumption ? "text-danger" : undefined}>
|
|
||||||
{quotaLabel} {valStr(phase.quotaStart, dS, mode)} → {valStr(phase.quotaEnd, dF, mode)}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="font-medium text-fg">
|
|
||||||
Vermögen {valStr(phase.startWealthNominal, dS, mode)} → {valStr(phase.endWealthNominal, dE, mode)}
|
{/* Verfügbares Kapital -- die Grösse, die man für die Planung DIESER Phase braucht.
|
||||||
|
Nur zeigen, wenn überhaupt etwas da ist. */}
|
||||||
|
{onDistributeCapital && pot.total > 0 && (
|
||||||
|
<div className="rounded-md bg-surface-2 px-1.5 py-1">
|
||||||
|
<div className="flex items-baseline justify-between gap-2 whitespace-nowrap">
|
||||||
|
<span className="font-medium text-fg">Verfügbares Kapital</span>
|
||||||
|
<ValueSingle value={pot.total} deflator={dS} mode={mode} />
|
||||||
</div>
|
</div>
|
||||||
|
{pot.allocatedInvestments + pot.allocatedRepayments > 0 && (
|
||||||
|
<div className="flex items-baseline justify-between gap-2 whitespace-nowrap pl-2 text-faint">
|
||||||
|
<span>davon verteilt</span>
|
||||||
|
<ValueSingle value={pot.allocatedInvestments + pot.allocatedRepayments} deflator={dS} mode={mode} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex items-baseline justify-between gap-2 whitespace-nowrap pl-2 text-faint">
|
||||||
|
<span>Rest auf Cash</span>
|
||||||
|
<ValueSingle value={pot.rest} deflator={dS} mode={mode} />
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onDistributeCapital();
|
||||||
|
}}
|
||||||
|
className="mt-1 w-full rounded border border-accent px-1.5 py-0.5 text-[10px] font-semibold text-accent transition-colors hover:bg-accent hover:text-accent-fg"
|
||||||
|
>
|
||||||
|
Kapital verteilen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Spar- bzw. Verzehrquote */}
|
||||||
|
<div className="rounded-md bg-surface-2 px-1.5 py-1">
|
||||||
|
<div className="flex items-baseline justify-between gap-2 whitespace-nowrap">
|
||||||
|
<span className={`font-medium ${phase.isConsumption ? "text-danger" : "text-fg"}`}>{quotaLabel}</span>
|
||||||
|
<ValuePair start={phase.quotaStart} end={phase.quotaEnd} deflatorStart={dS} deflatorEnd={dF} mode={mode} />
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onDistributeRates();
|
||||||
|
}}
|
||||||
|
className="mt-1 w-full rounded border border-accent px-1.5 py-0.5 text-[10px] font-semibold text-accent transition-colors hover:bg-accent hover:text-accent-fg"
|
||||||
|
>
|
||||||
|
{phase.isConsumption ? "Bezug verteilen" : "Sparquote verteilen"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-baseline justify-between gap-2 whitespace-nowrap font-medium text-fg">
|
||||||
|
<span>Vermögen</span>
|
||||||
|
<ValuePair
|
||||||
|
start={phase.startWealthNominal}
|
||||||
|
end={phase.endWealthNominal}
|
||||||
|
deflatorStart={dS}
|
||||||
|
deflatorEnd={dE}
|
||||||
|
mode={mode}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{(phase.oneOffInflow > 0 || phase.oneOffOutflow > 0) && (
|
{(phase.oneOffInflow > 0 || phase.oneOffOutflow > 0) && (
|
||||||
<div className={phase.oneOffOutflow > phase.oneOffInflow ? "text-danger" : "text-success"}>
|
<div className={`whitespace-nowrap ${phase.oneOffOutflow > phase.oneOffInflow ? "text-danger" : "text-success"}`}>
|
||||||
{phase.oneOffInflow > 0 ? `+ ${phase.oneOffInflowLabel ?? "Zufluss"}` : ""}
|
{phase.oneOffInflow > 0 ? `+ ${phase.oneOffInflowLabel ?? "Zufluss"}` : ""}
|
||||||
{phase.oneOffInflow > 0 && phase.oneOffOutflow > 0 ? " · " : ""}
|
{phase.oneOffInflow > 0 && phase.oneOffOutflow > 0 ? " · " : ""}
|
||||||
{phase.oneOffOutflow > 0 ? `− ${phase.oneOffOutflowLabel ?? "Kosten"}` : ""}
|
{phase.oneOffOutflow > 0 ? `− ${phase.oneOffOutflowLabel ?? "Kosten"}` : ""}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* Alles Weitere (Einkommen, Ausgaben, Raten, Kapitalflüsse) steht in der
|
{/* Alles Weitere (Einkommen, Ausgaben, Raten im Detail, Kapitalinvestitionen) steht
|
||||||
Detailansicht -- erreichbar über das Expand-Icon oben. */}
|
in der Detailansicht -- erreichbar über das Expand-Icon oben. */}
|
||||||
</div>
|
</div>
|
||||||
</th>
|
</th>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,211 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { computePlan } from "@/lib/calculations";
|
||||||
|
import {
|
||||||
|
applyPhasePatches,
|
||||||
|
applyTransitionPatches,
|
||||||
|
capitalPot,
|
||||||
|
quotaSummary,
|
||||||
|
} from "@/lib/distribution";
|
||||||
|
import type { CashTransitionData, ElementCategory, PhaseData, TransitionData } from "@/lib/elements";
|
||||||
|
import type { PlanInput } from "@/lib/types";
|
||||||
|
|
||||||
|
let idc = 0;
|
||||||
|
const nid = () => `d${idc++}`;
|
||||||
|
|
||||||
|
function el(
|
||||||
|
category: ElementCategory,
|
||||||
|
ownerRole: string | null,
|
||||||
|
phaseValues: Record<string, PhaseData>,
|
||||||
|
transitionValues: Record<string, TransitionData> = {}
|
||||||
|
) {
|
||||||
|
return { id: nid(), category, name: category, ownerRole: ownerRole as never, orderIndex: idc, phaseValues, transitionValues };
|
||||||
|
}
|
||||||
|
|
||||||
|
function plan(opts: {
|
||||||
|
age: number;
|
||||||
|
retirementAge: number;
|
||||||
|
initialCash?: number;
|
||||||
|
phases: { id: string; durationYears: number; cashTransition?: CashTransitionData }[];
|
||||||
|
elements: ReturnType<typeof el>[];
|
||||||
|
}): PlanInput {
|
||||||
|
return {
|
||||||
|
id: "plan",
|
||||||
|
name: "T",
|
||||||
|
householdType: "SINGLE",
|
||||||
|
inflationRateDefault: 1.5,
|
||||||
|
initialCash: opts.initialCash ?? 0,
|
||||||
|
persons: [{ id: "A", role: "PERSON_A", name: null, age: opts.age, retirementAge: opts.retirementAge }],
|
||||||
|
phases: opts.phases.map((p, i) => ({
|
||||||
|
id: p.id,
|
||||||
|
sequenceNumber: i + 1,
|
||||||
|
name: p.id,
|
||||||
|
durationYears: p.durationYears,
|
||||||
|
cashTransition: p.cashTransition ?? {},
|
||||||
|
})),
|
||||||
|
elements: opts.elements,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zwei Phasen; am Übergang wird Vermögen verkauft und eine Erbschaft fliesst zu -- damit
|
||||||
|
// entsteht in Phase 2 ein verteilbarer Kapitaltopf.
|
||||||
|
function basePlan() {
|
||||||
|
const asset = el("OTHER_ASSET", "HOUSEHOLD", {
|
||||||
|
p1: { startValue: 200000, expectedReturn: 0 },
|
||||||
|
p2: { expectedReturn: 0 },
|
||||||
|
}, { p1: { decision: "SELL" } });
|
||||||
|
const debt = el("OTHER_DEBT", "HOUSEHOLD", { p1: { startValue: 60000 }, p2: {} }, { p1: {} });
|
||||||
|
const invest = el("OTHER_ASSET", "HOUSEHOLD", { p1: { startValue: 0, expectedReturn: 0 }, p2: { expectedReturn: 0 } });
|
||||||
|
return {
|
||||||
|
p: plan({
|
||||||
|
age: 40,
|
||||||
|
retirementAge: 70,
|
||||||
|
initialCash: 10000,
|
||||||
|
phases: [
|
||||||
|
{ id: "p1", durationYears: 5, cashTransition: { mode: "INFLOW", inflowAmount: 100000, inflowTaxRate: 0 } },
|
||||||
|
{ id: "p2", durationYears: 5 },
|
||||||
|
],
|
||||||
|
elements: [
|
||||||
|
el("INCOME", "PERSON_A", { p1: { amount: 100000, teuerungsausgleich: 0 }, p2: {} }),
|
||||||
|
el("EXPENSE", "HOUSEHOLD", { p1: { amount: 80000, teuerungsausgleich: 0 }, p2: {} }),
|
||||||
|
asset,
|
||||||
|
debt,
|
||||||
|
invest,
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
assetId: asset.id,
|
||||||
|
debtId: debt.id,
|
||||||
|
investId: invest.id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Verteilung: verfügbares Kapital", () => {
|
||||||
|
it("Topf setzt sich aus Vorphasen-Cash, Kapitalzufluss und Einmalzufluss zusammen", () => {
|
||||||
|
const { p } = basePlan();
|
||||||
|
const r = computePlan(p);
|
||||||
|
const pot = capitalPot(r.phases[1]);
|
||||||
|
|
||||||
|
// Der Topf ist die Summe der Zuflüsse minus einmaliger Kosten.
|
||||||
|
expect(pot.total).toBe(pot.fromPreviousCash + pot.capitalInflow + pot.oneOffInflow - pot.oneOffOutflow);
|
||||||
|
expect(pot.capitalInflow).toBe(200000); // Verkauf des Vermögens
|
||||||
|
expect(pot.oneOffInflow).toBe(100000); // Erbschaft, steuerfrei
|
||||||
|
// Nichts verteilt -> alles bleibt auf dem Cash.
|
||||||
|
expect(pot.allocatedInvestments).toBe(0);
|
||||||
|
expect(pot.allocatedRepayments).toBe(0);
|
||||||
|
expect(pot.rest).toBe(pot.total);
|
||||||
|
expect(pot.rest).toBe(r.phases[1].cashStart);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Zusatzeinlage verschiebt den Topf von Cash ins Vermögen, ohne ihn zu verändern", () => {
|
||||||
|
const { p, investId } = basePlan();
|
||||||
|
const vorher = capitalPot(computePlan(p).phases[1]);
|
||||||
|
|
||||||
|
const nachher = capitalPot(
|
||||||
|
computePlan(applyPhasePatches(p, "p2", [{ elementId: investId, field: "additionalInvestment", value: 150000 }]))
|
||||||
|
.phases[1]
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(nachher.total).toBe(vorher.total); // der Topf selbst bleibt gleich gross
|
||||||
|
expect(nachher.allocatedInvestments).toBe(150000);
|
||||||
|
expect(nachher.rest).toBe(vorher.rest - 150000);
|
||||||
|
// Kontrolle: Topf = verteilt + Rest
|
||||||
|
expect(nachher.allocatedInvestments + nachher.allocatedRepayments + nachher.rest).toBe(nachher.total);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Sofort-Tilgung am Übergang zählt als verteiltes Kapital", () => {
|
||||||
|
const { p, debtId } = basePlan();
|
||||||
|
const nachher = capitalPot(
|
||||||
|
computePlan(applyTransitionPatches(p, "p1", [{ elementId: debtId, field: "immediateRepayment", value: 40000 }]))
|
||||||
|
.phases[1]
|
||||||
|
);
|
||||||
|
expect(nachher.allocatedRepayments).toBe(40000);
|
||||||
|
expect(nachher.allocatedInvestments + nachher.allocatedRepayments + nachher.rest).toBe(nachher.total);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Überverteilung drückt den Rest ins Minus (Liquiditätslücke)", () => {
|
||||||
|
const { p, investId } = basePlan();
|
||||||
|
const r = computePlan(
|
||||||
|
applyPhasePatches(p, "p2", [{ elementId: investId, field: "additionalInvestment", value: 999999 }])
|
||||||
|
);
|
||||||
|
const pot = capitalPot(r.phases[1]);
|
||||||
|
expect(pot.rest).toBeLessThan(0);
|
||||||
|
expect(r.phases[1].cashNegative).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Verteilung: Spar-/Verzehrquote", () => {
|
||||||
|
it("absolute Quote ist die Summe über alle Phasenjahre -- und die Quote SINKT", () => {
|
||||||
|
const { p } = basePlan();
|
||||||
|
const r = computePlan(p);
|
||||||
|
const q = quotaSummary(r.phases[0]);
|
||||||
|
|
||||||
|
// Genau der Grund, warum eine flache Jahresrate gefährlich ist: Das Einkommen bleibt bei
|
||||||
|
// 0 % Lohnerhöhung nominal konstant, die real erfassten Ausgaben wachsen aber mit der
|
||||||
|
// Inflation. Die Quote schrumpft dadurch über die Phase.
|
||||||
|
expect(q.start).toBe(20000);
|
||||||
|
expect(q.end).toBeLessThan(q.start);
|
||||||
|
expect(q.end).toBe(Math.round(100000 - 80000 * Math.pow(1.015, 4)));
|
||||||
|
|
||||||
|
// Die absolute Quote liegt folgerichtig zwischen "5 x letztes Jahr" und "5 x erstes Jahr".
|
||||||
|
expect(q.total).toBeGreaterThan(5 * q.end);
|
||||||
|
expect(q.total).toBeLessThan(5 * q.start);
|
||||||
|
// Ohne verteilte Raten geht die ganze Quote aufs Cash.
|
||||||
|
expect(q.netToCash).toBe(q.total);
|
||||||
|
expect(q.netToCash).toBe(r.phases[0].cashEnd - r.phases[0].cashStart);
|
||||||
|
expect(q.isConsumption).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("verteilte Sparraten mindern den Rest, der aufs Cash geht", () => {
|
||||||
|
const { p, investId } = basePlan();
|
||||||
|
const r = computePlan(
|
||||||
|
applyPhasePatches(p, "p1", [{ elementId: investId, field: "annualContribution", value: 15000 }])
|
||||||
|
);
|
||||||
|
const q = quotaSummary(r.phases[0]);
|
||||||
|
expect(q.allocatedOut).toBe(75000); // 15'000 x 5 Jahre
|
||||||
|
expect(q.netToCash).toBe(q.total - q.allocatedOut + q.allocatedIn);
|
||||||
|
// Kontrolle gegen die offiziellen Kennzahlen der Phase.
|
||||||
|
expect(q.netToCash).toBe(r.phases[0].cashEnd - r.phases[0].cashStart);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Bezugsraten zählen als Zufluss und werden am Bestand gekappt", () => {
|
||||||
|
// Verzehrphase: Ausgaben über Einkommen, finanziert aus dem Vermögen.
|
||||||
|
const p = plan({
|
||||||
|
age: 66,
|
||||||
|
retirementAge: 65,
|
||||||
|
initialCash: 0,
|
||||||
|
phases: [{ id: "p1", durationYears: 5 }],
|
||||||
|
elements: [
|
||||||
|
el("INCOME", "PERSON_A", { p1: { amount: 40000, teuerungsausgleich: 0 } }),
|
||||||
|
el("EXPENSE", "HOUSEHOLD", { p1: { amount: 70000, teuerungsausgleich: 0 } }),
|
||||||
|
el("OTHER_ASSET", "HOUSEHOLD", { p1: { startValue: 50000, expectedReturn: 0, annualWithdrawal: 30000 } }),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const r = computePlan(p);
|
||||||
|
const q = quotaSummary(r.phases[0]);
|
||||||
|
expect(q.isConsumption).toBe(true);
|
||||||
|
expect(q.total).toBeLessThan(0);
|
||||||
|
// Der Bestand von 50'000 lässt nur 50'000 Entnahme zu, nicht 5 x 30'000.
|
||||||
|
expect(q.allocatedIn).toBe(50000);
|
||||||
|
expect(q.netToCash).toBe(r.phases[0].cashEnd - r.phases[0].cashStart);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Verteilung: Entwurfswerte anwenden", () => {
|
||||||
|
it("lässt den Ausgangsplan unberührt und erhält bestehende Felder", () => {
|
||||||
|
const { p, assetId, investId } = basePlan();
|
||||||
|
const snapshot = JSON.stringify(p);
|
||||||
|
|
||||||
|
const geaendert = applyPhasePatches(p, "p2", [
|
||||||
|
{ elementId: investId, field: "additionalInvestment", value: 1000 },
|
||||||
|
]);
|
||||||
|
expect(JSON.stringify(p)).toBe(snapshot);
|
||||||
|
// Bestehendes Feld derselben Phase bleibt erhalten.
|
||||||
|
expect(geaendert.elements.find((e) => e.id === investId)!.phaseValues.p2.expectedReturn).toBe(0);
|
||||||
|
|
||||||
|
// Übergangs-Entscheide dürfen nicht verloren gehen.
|
||||||
|
const mitTilgung = applyTransitionPatches(p, "p1", [
|
||||||
|
{ elementId: assetId, field: "partialSaleAmount", value: 500 },
|
||||||
|
]);
|
||||||
|
expect(mitTilgung.elements.find((e) => e.id === assetId)!.transitionValues.p1.decision).toBe("SELL");
|
||||||
|
expect(mitTilgung.elements.find((e) => e.id === assetId)!.transitionValues.p1.partialSaleAmount).toBe(500);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
// Verteilung von verfügbarem Kapital und laufender Spar-/Verzehrquote.
|
||||||
|
//
|
||||||
|
// Reine Orchestrierung: Beide Werkzeuge schreiben ausschliesslich Felder, die es längst gibt
|
||||||
|
// (additionalInvestment, extraAmortization, immediateRepayment, annualContribution,
|
||||||
|
// annualWithdrawal, amortization, annualRepayment). An der Berechnung ändert sich nichts --
|
||||||
|
// die Vorschau entsteht, indem der Plan mit den Entwurfswerten kopiert und erneut durch
|
||||||
|
// computePlan geschickt wird. Dadurch ist die angezeigte Wirkung per Konstruktion exakt die
|
||||||
|
// spätere, inklusive aller Kappungen (z. B. Bezugsrate am Bestand, Amortisation an der
|
||||||
|
// Restschuld).
|
||||||
|
|
||||||
|
import type { PhaseComputed } from "@/lib/calculations";
|
||||||
|
import type { PhaseData, TransitionData } from "@/lib/elements";
|
||||||
|
import type { PlanInput } from "@/lib/types";
|
||||||
|
|
||||||
|
// --- Verfügbares Kapital ------------------------------------------------------------------
|
||||||
|
//
|
||||||
|
// Der "Topf" ist das Kapital, das beim Übergang IN diese Phase zur Verfügung steht -- also
|
||||||
|
// bevor etwas davon investiert oder in Schulden gesteckt wurde. Alle Bestandteile stammen aus
|
||||||
|
// der Cash-Brücke (seit 0.11), es braucht keine zusätzliche Berechnung:
|
||||||
|
//
|
||||||
|
// Topf = Cash-Ende der Vorphase + Kapitalzufluss + einmaliger Zufluss − einmalige Kosten
|
||||||
|
// = cashStart + Investitionen + Sofort-Tilgungen
|
||||||
|
//
|
||||||
|
// Die zweite Form ist die, die hier verwendet wird: Sie zerlegt den Topf direkt in
|
||||||
|
// "schon verteilt" und "Rest auf Cash".
|
||||||
|
export interface CapitalPot {
|
||||||
|
total: number; // gesamter verteilbarer Betrag
|
||||||
|
fromPreviousCash: number; // Cash-Endbestand der Vorphase
|
||||||
|
capitalInflow: number; // Verkäufe, PK-/3a-Bezüge
|
||||||
|
oneOffInflow: number; // Erbschaft o. ä. (netto nach Steuer)
|
||||||
|
oneOffOutflow: number; // einmalige Kosten (mindern den Topf)
|
||||||
|
allocatedInvestments: number; // Zusatzeinlagen in PK/3a/Vermögen
|
||||||
|
allocatedRepayments: number; // Sonderamortisation + Sofort-Tilgung
|
||||||
|
rest: number; // bleibt auf dem Cash-Konto (= cashStart)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function capitalPot(phase: PhaseComputed): CapitalPot {
|
||||||
|
const c = phase.cashBridge;
|
||||||
|
return {
|
||||||
|
total: c.cashStart + c.investments + c.immediateRepay,
|
||||||
|
fromPreviousCash: c.openingCash,
|
||||||
|
capitalInflow: c.capitalInflow,
|
||||||
|
oneOffInflow: c.oneOffInflow,
|
||||||
|
oneOffOutflow: c.oneOffOutflow,
|
||||||
|
allocatedInvestments: c.investments,
|
||||||
|
allocatedRepayments: c.immediateRepay,
|
||||||
|
rest: c.cashStart,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Spar-/Verzehrquote -------------------------------------------------------------------
|
||||||
|
//
|
||||||
|
// Die Quote ist KEIN fester Betrag: Einkommen wächst mit der Lohnerhöhung, Ausgaben mit der
|
||||||
|
// Inflation. Deshalb werden drei Grössen ausgewiesen -- erstes Jahr, letztes Jahr und die
|
||||||
|
// Summe über die ganze Phase ("absolute Quote"). Die Summe ist die Grösse, gegen die sich
|
||||||
|
// eine flache Jahresrate sinnvoll verteilen lässt.
|
||||||
|
//
|
||||||
|
// Hinweis zur Einheit: `total` summiert nominale Franken verschiedener Jahre -- exakt so, wie
|
||||||
|
// das Cash-Konto im Modell funktioniert. Für die Frage "wie viel Cash steht über die Phase
|
||||||
|
// zur Verfügung" ist das die richtige Grösse, als Kaufkraft-Aussage taugt sie nicht.
|
||||||
|
export interface QuotaSummary {
|
||||||
|
start: number; // Quote im ersten Phasenjahr
|
||||||
|
end: number; // Quote im letzten Phasenjahr
|
||||||
|
total: number; // Summe über alle Phasenjahre
|
||||||
|
allocatedOut: number; // Sparbeiträge + Amortisationen + Tilgungen, über die Phase summiert
|
||||||
|
allocatedIn: number; // Bezugsraten, über die Phase summiert
|
||||||
|
netToCash: number; // was unter dem Strich aufs Cash geht (= cashEnd − cashStart)
|
||||||
|
isConsumption: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function quotaSummary(phase: PhaseComputed): QuotaSummary {
|
||||||
|
const c = phase.cashBridge;
|
||||||
|
const allocatedOut = c.savingRates + c.debtRates;
|
||||||
|
return {
|
||||||
|
start: phase.quotaStart,
|
||||||
|
end: phase.quotaEnd,
|
||||||
|
total: c.quotaTotal,
|
||||||
|
allocatedOut,
|
||||||
|
allocatedIn: c.withdrawals,
|
||||||
|
netToCash: c.quotaTotal - allocatedOut + c.withdrawals,
|
||||||
|
isConsumption: c.quotaTotal < 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Entwurfswerte auf einen Plan anwenden (für die Live-Vorschau) ------------------------
|
||||||
|
// Beide Funktionen sind rein und lassen den Ausgangsplan unberührt.
|
||||||
|
|
||||||
|
export interface PhasePatch {
|
||||||
|
elementId: string;
|
||||||
|
field: keyof PhaseData;
|
||||||
|
value: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyPhasePatches(plan: PlanInput, phaseId: string, patches: PhasePatch[]): PlanInput {
|
||||||
|
if (patches.length === 0) return plan;
|
||||||
|
const byElement = new Map<string, PhasePatch[]>();
|
||||||
|
for (const p of patches) {
|
||||||
|
const list = byElement.get(p.elementId) ?? [];
|
||||||
|
list.push(p);
|
||||||
|
byElement.set(p.elementId, list);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...plan,
|
||||||
|
elements: plan.elements.map((e) => {
|
||||||
|
const list = byElement.get(e.id);
|
||||||
|
if (!list) return e;
|
||||||
|
const merged: PhaseData = { ...(e.phaseValues[phaseId] ?? {}) };
|
||||||
|
for (const p of list) (merged as Record<string, number>)[p.field as string] = p.value;
|
||||||
|
return { ...e, phaseValues: { ...e.phaseValues, [phaseId]: merged } };
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TransitionPatch {
|
||||||
|
elementId: string;
|
||||||
|
field: keyof TransitionData;
|
||||||
|
value: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyTransitionPatches(
|
||||||
|
plan: PlanInput,
|
||||||
|
fromPhaseId: string,
|
||||||
|
patches: TransitionPatch[]
|
||||||
|
): PlanInput {
|
||||||
|
if (patches.length === 0) return plan;
|
||||||
|
const byElement = new Map<string, TransitionPatch[]>();
|
||||||
|
for (const p of patches) {
|
||||||
|
const list = byElement.get(p.elementId) ?? [];
|
||||||
|
list.push(p);
|
||||||
|
byElement.set(p.elementId, list);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...plan,
|
||||||
|
elements: plan.elements.map((e) => {
|
||||||
|
const list = byElement.get(e.id);
|
||||||
|
if (!list) return e;
|
||||||
|
// Bestehende Entscheide (decision, salePrice, payoutMode …) bleiben erhalten -- hier
|
||||||
|
// werden nur die Betragsfelder überschrieben.
|
||||||
|
const merged: TransitionData = { ...(e.transitionValues[fromPhaseId] ?? {}) };
|
||||||
|
for (const p of list) (merged as Record<string, number>)[p.field as string] = p.value;
|
||||||
|
return { ...e, transitionValues: { ...e.transitionValues, [fromPhaseId]: merged } };
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user