UI-Gesamtumbau: Du-Form, Inspector-Panel, Onboarding-Assistent, Tour, Palette
Deploy App / deploy (push) Successful in 1m1s
Deploy App / deploy (push) Successful in 1m1s
Rein an der Oberflaeche -- Berechnung, Datenmodell und API-Semantik unveraendert (103 Tests unveraendert gruen). Paket A (Fundament): - durchgehend Du-Form und echte Umlaute in allen sichtbaren Texten, inkl. API-Fehlermeldungen (vorher Mix aus Sie/Du und ae/oe/ue) - neue UI-Primitiven (ui.tsx): Button, Modal mit ESC/Fokus-Falle/Animation, Bestaetigungs-Dialog statt window.confirm, Toasts statt alert, Skeleton-Loader, EmptyState - eigene Attention-Farbe (Amber) fuer offene Entscheide, getrennt vom Akzent - Micro-Interactions mit prefers-reduced-motion-Fallback Paket B (Onboarding, Roadmap Nr. 10): - gefuehrter Plan-Assistent in 5 Schritten; Einkommen bewusst pro Person (raeumt die 9.9-AHV-Falle aus); reine Orchestrierung bestehender Endpunkte - Beispielplan mit einem Klick; Uebergaenge absichtlich offen - interaktive Tour ueber die Planansicht (localStorage, jederzeit neu startbar) - abgeleitete "Naechste Schritte"-Karte (offene Entscheide, fehlende Elemente, fehlende Pensionsphase, Ruin -> Einflussfaktoren) Paket C (Struktur): - Inspector-Panel rechts statt Modals fuer alle Einzel-Bearbeitungen; Matrix bleibt sichtbar, Zellklick wechselt den Inhalt - Phasenkopf auf vier Kern-Infos entschlackt (Rest in der 0.11-Detailansicht) - Matrix mit eigenem Scrollbereich, Koepfe beidachsig fixiert - Sidebar-Gruppen "Meine Plaene" / "Wissen"; "So rechnet FPT" statt SPEZIFIKATION; "Szenario-Profil" statt "Plan-Einstellungen" - Aktions-Icons ohne Hover sichtbar (Touch) Paket D (Extras): - Sparklines je Element-Zeile aus den 0.11-Verlaufswerten - Befehls-Palette (Ctrl/Cmd+K) - Ruin-Banner verlinkt auf die Einflussfaktoren Nebenbei: der ProfileMenu-Lint-Fehler und der Selection-Rest (9.17) sind behoben -- npm run lint laeuft erstmals fehlerfrei. SPEZIFIKATION auf 0.13: neue Kapitel 3.2.8, 3.7.6-3.7.9, 9.23, 9.24; 3.6.3 und 3.7.1 ueberarbeitet, 9.17 bereinigt. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+156
-31
@@ -4,10 +4,10 @@
|
|||||||
| | |
|
| | |
|
||||||
|---|---|
|
|---|---|
|
||||||
| **Dokument** | Funktionale und Technische Spezifikation FPT |
|
| **Dokument** | Funktionale und Technische Spezifikation FPT |
|
||||||
| **Version** | 0.12 |
|
| **Version** | 0.13 |
|
||||||
| **Datum** | 2026-07-18 |
|
| **Datum** | 2026-07-18 |
|
||||||
| **Status** | Lebendes Dokument |
|
| **Status** | Lebendes Dokument |
|
||||||
| **Codestand** | Arbeitsstand nach `4791dcc` inkl. lesbarer Wasserfälle und Verkaufspreis-Abgleich (Branch `main`) |
|
| **Codestand** | Arbeitsstand nach `e1f74fc` inkl. UI-Gesamtumbau (Pakete A–D) und geführtem Onboarding (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.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. |
|
||||||
| 0.10 | 2026-07-18 | Claude (Opus 4.8) | **Szenario-Vergleich in der Monte-Carlo-Simulation** und **Sensitivitätsanalyse / Tornado** (Roadmap Nr. 20). (1) Die MC-Simulation rechnet neu **mehrere Szenarien desselben Plans in einem Lauf**. Die historischen Annahmen werden dabei nur **einmal je logischem Element** erfasst – die Zuordnung über die Herkunfts-Kette `sourceElementId`, dieselbe Grundlage wie beim Diff (neue Funktionen `resolveRootElementId`, `buildElementGroups`, `paramsForScenario`, `runMonteCarloMulti`). Alle Szenarien laufen mit **demselben Seed** (Common Random Numbers), damit Unterschiede strukturell und nicht zufällig sind. Der **Zielbetrag bleibt szenario-eigen** (vorbelegt mit dem jeweils geplanten Endvermögen) – nur so misst die Erfolgswahrscheinlichkeit, wie oft ein Szenario sein *eigenes* Versprechen hält. Ergebnis als Vergleichstabelle plus Median-Linien je Szenario; bei einem einzelnen Szenario unverändert der bisherige Fächer. (2) Neuer Bereich **Einflussfaktoren** (eigener Button, eigener Dialog) mit einem **Tornado-Chart** nach dem One-at-a-time-Verfahren: neues reines Modul `sensitivity.ts` mit sieben Treibern, je Treiber an-/abwählbar und mit **pflichtiger, frei definierbarer Bandbreite ohne Default**. Das **Pensionsalter ist bewusst nicht enthalten** (Begründung: 9.18). Neue Kapitel 3.6.6, 4.12.6, 4.13, 9.18; 20 Tests ergänzt (60 → 80). Keine DB-Änderung, keine Verhaltensänderung bestehender Pläne. **Ausserdem vier Dokumentationsfehler korrigiert:** Kap. 1.2 nannte noch Monte-Carlo, Hypothekarzinsen und Immobilien-Wertsteigerung als nicht umgesetzt (seit 0.5/0.7 vorhanden); Kap. 4.6.5 endete mit einem widersprüchlichen Restsatz zur fehlenden Wertsteigerung; Kap. 5.2/5.3/5.5.2/8.1 waren bei Migrationszahl, Dateiliste, Komponentenliste und Testzahlen veraltet; der Glossar-Eintrag „Szenario" beschrieb noch das Modell vor V6. |
|
| 0.10 | 2026-07-18 | Claude (Opus 4.8) | **Szenario-Vergleich in der Monte-Carlo-Simulation** und **Sensitivitätsanalyse / Tornado** (Roadmap Nr. 20). (1) Die MC-Simulation rechnet neu **mehrere Szenarien desselben Plans in einem Lauf**. Die historischen Annahmen werden dabei nur **einmal je logischem Element** erfasst – die Zuordnung über die Herkunfts-Kette `sourceElementId`, dieselbe Grundlage wie beim Diff (neue Funktionen `resolveRootElementId`, `buildElementGroups`, `paramsForScenario`, `runMonteCarloMulti`). Alle Szenarien laufen mit **demselben Seed** (Common Random Numbers), damit Unterschiede strukturell und nicht zufällig sind. Der **Zielbetrag bleibt szenario-eigen** (vorbelegt mit dem jeweils geplanten Endvermögen) – nur so misst die Erfolgswahrscheinlichkeit, wie oft ein Szenario sein *eigenes* Versprechen hält. Ergebnis als Vergleichstabelle plus Median-Linien je Szenario; bei einem einzelnen Szenario unverändert der bisherige Fächer. (2) Neuer Bereich **Einflussfaktoren** (eigener Button, eigener Dialog) mit einem **Tornado-Chart** nach dem One-at-a-time-Verfahren: neues reines Modul `sensitivity.ts` mit sieben Treibern, je Treiber an-/abwählbar und mit **pflichtiger, frei definierbarer Bandbreite ohne Default**. Das **Pensionsalter ist bewusst nicht enthalten** (Begründung: 9.18). Neue Kapitel 3.6.6, 4.12.6, 4.13, 9.18; 20 Tests ergänzt (60 → 80). Keine DB-Änderung, keine Verhaltensänderung bestehender Pläne. **Ausserdem vier Dokumentationsfehler korrigiert:** Kap. 1.2 nannte noch Monte-Carlo, Hypothekarzinsen und Immobilien-Wertsteigerung als nicht umgesetzt (seit 0.5/0.7 vorhanden); Kap. 4.6.5 endete mit einem widersprüchlichen Restsatz zur fehlenden Wertsteigerung; Kap. 5.2/5.3/5.5.2/8.1 waren bei Migrationszahl, Dateiliste, Komponentenliste und Testzahlen veraltet; der Glossar-Eintrag „Szenario" beschrieb noch das Modell vor V6. |
|
||||||
@@ -245,7 +246,15 @@ Zweistufig:
|
|||||||
|
|
||||||
### 3.2.1 Plan erstellen
|
### 3.2.1 Plan erstellen
|
||||||
|
|
||||||
Dialog mit Name plus dem vollständigen Grundprofil:
|
Der «+»-Knopf öffnet eine Auswahl mit drei Wegen:
|
||||||
|
|
||||||
|
| Weg | Für wen | Was passiert |
|
||||||
|
|---|---|---|
|
||||||
|
| **Geführt erstellen** (empfohlen) | neue Nutzer | der Assistent aus [3.2.8](#328-geführter-assistent-und-beispielplan) |
|
||||||
|
| **Leer starten** | geübte Nutzer | der bisherige Dialog (unten) – nur Grundprofil, keine Phasen/Elemente |
|
||||||
|
| **Beispielplan ansehen** | Erkunden | legt einen fiktiven, voll ausgefüllten Plan an ([3.2.8](#328-geführter-assistent-und-beispielplan)) |
|
||||||
|
|
||||||
|
Der Dialog «Leer starten» fragt Name plus das vollständige Grundprofil:
|
||||||
|
|
||||||
| Feld | Typ | Default | Wertebereich |
|
| Feld | Typ | Default | Wertebereich |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
@@ -353,6 +362,32 @@ und Grafiken beschriften damit Jahre statt nur Alter. Die Berechnung rechnet unv
|
|||||||
Beim Anlegen wird das laufende Jahr vorbelegt; bestehende Szenarien wurden per Migration darauf
|
Beim Anlegen wird das laufende Jahr vorbelegt; bestehende Szenarien wurden per Migration darauf
|
||||||
gesetzt. Kalenderjahr eines Planjahrs: `startYear + (Jahr − 1)`.
|
gesetzt. Kalenderjahr eines Planjahrs: `startYear + (Jahr − 1)`.
|
||||||
|
|
||||||
|
### 3.2.8 Geführter Assistent und Beispielplan
|
||||||
|
|
||||||
|
**Der Plan-Assistent** (Roadmap Nr. 10: «Schritt für Schritt statt leerer Matrix») fragt in
|
||||||
|
fünf Schritten in Alltagssprache: (1) Grundprofil, (2) Lebensphasen – vom Tool anhand des
|
||||||
|
Pensionsalters **vorgeschlagen** (dieselbe Kappungslogik `maxPhaseDuration` wie beim manuellen
|
||||||
|
Anlegen; bei Paaren mit unterschiedlichem Pensionsalter inkl. Übergangsphase), Dauer editierbar,
|
||||||
|
(3) Einkommen und Ausgaben plus Kontostand, (4) Vorsorge und Vermögen als Checkliste (PK, 3a,
|
||||||
|
Wertschriften, Wohneigentum, Schulden – nur Angehaktes fragt Details; die AHV wird automatisch
|
||||||
|
je Person angelegt), (5) Zusammenfassung.
|
||||||
|
|
||||||
|
Zwei bewusste Entscheide:
|
||||||
|
- **Einkommen wird pro Person erfasst**, nie als «Gemeinsam» – in Paar-Plänen zählt
|
||||||
|
Haushalts-Einkommen nicht für die AHV ([9.9](#99-gemeinsames-einkommen-zählt-bei-paaren-nicht-für-die-ahv));
|
||||||
|
der Assistent räumt diese Falle von Anfang an aus.
|
||||||
|
- Technisch ist der Assistent **reine Orchestrierung bestehender Endpunkte** (Plan → Phase 1 →
|
||||||
|
Elemente samt Werten → Folgephasen; die Reihenfolge stellt sicher, dass die Phasen-Route die
|
||||||
|
Folgephasen korrekt vorbelegt). Kein neuer Endpunkt, keine Berechnungsänderung. Grenze: 9.23.
|
||||||
|
|
||||||
|
**Der Beispielplan** («Beispiel: Alex Muster», `src/lib/demoplan.ts`) ist ein fiktiver, voll
|
||||||
|
ausgefüllter Plan per Ein-Klick – ebenfalls reine Orchestrierung. Die Übergangs-Entscheide
|
||||||
|
bleiben **absichtlich offen**: Der neue Nutzer sieht die Ampel («N offen») in Aktion und lernt
|
||||||
|
das wichtigste Konzept am Beispiel statt aus einer Erklärung.
|
||||||
|
|
||||||
|
Nach dem ersten Öffnen eines Plans mit Phasen startet einmalig die **Tour**
|
||||||
|
([3.7.8](#378-tour-und-nächste-schritte)).
|
||||||
|
|
||||||
## 3.3 Lebensphasen
|
## 3.3 Lebensphasen
|
||||||
|
|
||||||
### 3.3.1 Phase anlegen
|
### 3.3.1 Phase anlegen
|
||||||
@@ -403,7 +438,7 @@ Referenz: `src/components/PhaseDetail.tsx`.
|
|||||||
### 3.3.3 Phase löschen
|
### 3.3.3 Phase löschen
|
||||||
|
|
||||||
**Nur die letzte Phase** kann gelöscht werden – serverseitig geprüft (HTTP 400: „Nur die letzte
|
**Nur die letzte Phase** kann gelöscht werden – serverseitig geprüft (HTTP 400: „Nur die letzte
|
||||||
Phase kann geloescht 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` Zeilen 55–77.
|
||||||
@@ -797,27 +832,23 @@ Referenz: `src/components/Timeline.tsx`.
|
|||||||
|
|
||||||
### 3.6.3 Phasenkopf-Kennzahlen
|
### 3.6.3 Phasenkopf-Kennzahlen
|
||||||
|
|
||||||
Jeder Phasenkopf zeigt kompakt:
|
Seit 0.13 ist der Phasenkopf bewusst **schlank** – zwölf Kennzahlen in 10-Pixel-Schrift liest
|
||||||
|
niemand (Progressive Disclosure):
|
||||||
|
|
||||||
| Kennzahl | Bedeutung |
|
| Kennzahl | Bedeutung |
|
||||||
|---|---|
|
|---|---|
|
||||||
| 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>` |
|
||||||
| Einkommen | Jahr 1 → letztes Jahr |
|
| **Quote** bzw. **Verzehr** | Einkommen − Ausgaben; Label wechselt auf „Verzehr", wenn Jahr 1 negativ; rot bei Verzehr |
|
||||||
| Ausgaben | Jahr 1 → letztes Jahr (nominal) |
|
| Vermögen | Start → Ende (inkl. Cash), hervorgehoben |
|
||||||
| **Quote** bzw. **Verzehr** | Einkommen − Ausgaben; Label wechselt auf „Verzehr", wenn Jahr 1 negativ |
|
| Einmalposten | nur als Kurzhinweis (Bezeichnung), wenn vorhanden |
|
||||||
| Geplante Sparrate | 3a + Sparbeitrag + Amortisation + Tilgung, im **ersten** Phasenjahr |
|
|
||||||
| Geplante Verzehrrate | Summe der Bezugsraten |
|
|
||||||
| Kapitalzufluss | nur wenn > 0: Verkäufe + PK-/3a-Bezüge aus dem Übergang **in** diese Phase |
|
|
||||||
| Kapitalinvestitionen | nur wenn > 0: Zusatzinvestitionen + Sofort-Tilgungen |
|
|
||||||
| **Einmaliger Zufluss** | nur wenn > 0: Bezeichnung + Betrag (grün), aus dem Übergang in diese Phase |
|
|
||||||
| **Einmalige Kosten** | nur wenn > 0: Bezeichnung + Betrag (rot) |
|
|
||||||
| Vermögen | Start → Ende (inkl. Cash) |
|
|
||||||
|
|
||||||
Die Einmalposten stehen bewusst **getrennt** von Kapitalzufluss/-investitionen: Eine Erbschaft
|
Alles Weitere – Einkommen/Ausgaben Jahr 1 → letztes Jahr, geplante Spar-/Verzehrrate,
|
||||||
ist kein Verkaufserlös und ein Poolbau keine Kapitalinvestition – eine Vermischung würde die
|
Kapitalzufluss und -investitionen, die vollen Einmalposten – wohnt in der
|
||||||
Kennzahl falsch beschriften.
|
**Phasen-Detailansicht** (seit 0.11, [3.6.7](#367-detailansichten-je-element-und-je-lebensphase)),
|
||||||
|
erreichbar über das Expand-Icon im Kopf. Die Kennzahlen selbst sind unverändert berechnet; nur
|
||||||
|
der Anzeigeort hat gewechselt.
|
||||||
|
|
||||||
Referenz: `src/components/PlanView.tsx` Zeilen 701–769.
|
Referenz: `src/components/PlanView.tsx` Zeilen 701–769.
|
||||||
|
|
||||||
@@ -931,11 +962,17 @@ Referenz: `src/components/SystemParametersView.tsx`, `src/lib/constants.ts`.
|
|||||||
|
|
||||||
### 3.7.1 Layout
|
### 3.7.1 Layout
|
||||||
|
|
||||||
- **Sidebar** (Desktop ab `lg` fix, mobil als Overlay): Logo, „Übersicht", Planliste mit
|
- **Sidebar** (Desktop ab `lg` fix, mobil als Overlay): Logo, Ctrl-K-Hinweis, „Übersicht",
|
||||||
Phasenanzahl, „+"-Button für neuen Plan.
|
Gruppe **«Meine Pläne»** (Szenario-Baum, «+»), Gruppe **«Wissen»** («So rechnet FPT» =
|
||||||
|
gerenderte Spezifikation, «Systemparameter»).
|
||||||
- **Header**: Menü-Button (mobil), Plantitel, Profilmenü.
|
- **Header**: Menü-Button (mobil), Plantitel, Profilmenü.
|
||||||
- **Hauptbereich**: Übersicht (Plan-Kacheln) oder Planansicht (Umschalter, Zeitachse,
|
- **Hauptbereich**: Übersicht (Plan-Kacheln bzw. EmptyState mit «Geführt starten» /
|
||||||
Grundprofil-Leiste, Aktionsbuttons, Ruin-Banner, Matrix, Detail-Panel, Dashboard).
|
«Beispielplan») oder Planansicht (Umschalter, Zeitachse, Szenario-Profil-Leiste,
|
||||||
|
«Nächste Schritte», Ruin-Banner, Matrix, Inspector-Panel rechts).
|
||||||
|
- Die **Matrix** hat einen eigenen Scrollbereich (max. 75 vh): Phasen-Köpfe (oben) und
|
||||||
|
Elementnamen (links) bleiben beim Scrollen in **beiden Achsen** fixiert.
|
||||||
|
- Alle Aktions-Icons (Kopieren, Löschen, Detailansicht) sind auch ohne Hover leicht sichtbar –
|
||||||
|
auf Touch-Geräten gibt es kein Hover.
|
||||||
|
|
||||||
### 3.7.2 Farbschemata
|
### 3.7.2 Farbschemata
|
||||||
|
|
||||||
@@ -975,7 +1012,75 @@ gefolgt von `onChanged()` → `loadDetail(planId, silent = true)`. Der „silent
|
|||||||
und Berechnung neu, ohne die `PlanView` zu demontieren – so bleibt die Scrollposition nach dem
|
und Berechnung neu, ohne die `PlanView` zu demontieren – so bleibt die Scrollposition nach dem
|
||||||
Schliessen eines Popups erhalten.
|
Schliessen eines Popups erhalten.
|
||||||
|
|
||||||
Referenz: `src/components/AppShell.tsx` Zeilen 46–74.
|
Referenz: `src/components/AppShell.tsx`.
|
||||||
|
|
||||||
|
### 3.7.6 Sprache und UI-Primitiven
|
||||||
|
|
||||||
|
Seit 0.13 gilt durchgehend die **Du-Form** (wie moderne CH-Finanz-Apps) und es werden überall
|
||||||
|
**echte Umlaute** verwendet – zuvor mischte das UI «Übergang prüfen» mit «Uebersicht» und
|
||||||
|
Sie- mit Du-Anrede. Der Sweep umfasst auch die API-Fehlermeldungen (z. B. „Nur die letzte
|
||||||
|
Phase kann gelöscht werden.").
|
||||||
|
|
||||||
|
Alle Bausteine liegen zentral in `src/components/ui.tsx`:
|
||||||
|
|
||||||
|
| Baustein | Ersetzt / leistet |
|
||||||
|
|---|---|
|
||||||
|
| `Button` | vier Varianten (primary/secondary/danger/ghost), einheitliche Hover-/Active-Zustände |
|
||||||
|
| `Modal` | Dialog mit ESC, Fokus-Falle, `aria-modal`, Einblend-Animation |
|
||||||
|
| `ConfirmProvider` / `useConfirm` | ersetzt `window.confirm()` – Browser-Systemdialoge folgen keinem Farbschema |
|
||||||
|
| `ToastProvider` / `useToast` | ersetzt `alert()`; Erfolgs- und Fehlermeldungen unten rechts |
|
||||||
|
| `Skeleton` / `PlanSkeleton` | Struktur-Platzhalter statt «Lädt…»-Text |
|
||||||
|
| `EmptyState` | einheitliche leere Zustände mit Erklärung und Aktion |
|
||||||
|
| `InspectorShell` | das rechte Bearbeiten-Panel (3.7.7) |
|
||||||
|
|
||||||
|
Mehrere Schichten (Panel + Dialog) teilen sich einen **ESC-Stapel**: ESC schliesst immer nur
|
||||||
|
die oberste. Animationen (150–220 ms) respektieren `prefers-reduced-motion`.
|
||||||
|
|
||||||
|
**Attention-Farbe:** Offene Entscheide tragen neu einen eigenen Amber-Ton (`--attention`,
|
||||||
|
je Farbschema abgestimmt) statt der Akzentfarbe – «hier fehlt eine Eingabe» und «hier kannst
|
||||||
|
du klicken» sind damit unterscheidbar.
|
||||||
|
|
||||||
|
### 3.7.7 Inspector-Panel statt Modals
|
||||||
|
|
||||||
|
Alle **Einzel-Bearbeitungen** – Phasenzelle, Übergangszelle, Cash-Übergang, Cash-Anfangswert,
|
||||||
|
Phase (Name/Dauer), Szenario-Profil – öffnen sich als rechtes Seitenpanel (`InspectorShell`,
|
||||||
|
26 rem, mobil volle Breite) statt als Modal. Bewusst **ohne Backdrop**: Die Matrix bleibt
|
||||||
|
sichtbar und klickbar, ein Klick auf eine andere Zelle wechselt einfach den Panel-Inhalt –
|
||||||
|
der Kontext, den Modals nehmen, bleibt erhalten. Genau ein Panel kann offen sein (eine
|
||||||
|
`Panel`-Union ersetzt die früheren Einzel-Zustände); der `key` erzwingt beim Wechsel den
|
||||||
|
Neuaufbau des Formulars wie zuvor bei den Dialogen.
|
||||||
|
|
||||||
|
**Modals bleiben** für Erstell-Flows (Element, Phase, Plan, Assistent), den geführten
|
||||||
|
Übergang (mehrere Objekte auf einmal), die Analysen und die Detailansichten.
|
||||||
|
|
||||||
|
Der frühere Dialog «Plan-Einstellungen» heisst im Panel korrekt **«Szenario-Profil»** – er
|
||||||
|
bearbeitet seit V6 das Szenario, nicht den Plan.
|
||||||
|
|
||||||
|
### 3.7.8 Tour und «Nächste Schritte»
|
||||||
|
|
||||||
|
Die **Tour** (`Tour.tsx`) startet einmalig beim ersten Öffnen eines Plans mit Phasen
|
||||||
|
(localStorage `fpt-tour-done`) und führt in bis zu sechs Schritten über Profil, Zeitachse,
|
||||||
|
Matrix, Übergänge, Cash und Analysen – als Karte am unteren Rand plus pulsierender Rahmen um
|
||||||
|
das Ziel (`data-tour`-Attribute). Schritte ohne vorhandenes Ziel werden übersprungen; der
|
||||||
|
«Tour»-Knopf in der Werkzeugleiste startet sie jederzeit neu. Grenze: 9.24.
|
||||||
|
|
||||||
|
Die Karte **«Nächste Schritte»** über der Matrix leitet aus den vorhandenen Daten ab, was
|
||||||
|
sinnvollerweise als Nächstes ansteht – offene Übergangs-Entscheide (mit Direktsprung in den
|
||||||
|
geführten Übergang), fehlendes Einkommen/fehlende Ausgaben, ein Plan, der vor der
|
||||||
|
Pensionierung endet, und bei Ruin der Sprung zu den Einflussfaktoren. Kein eigener Zustand,
|
||||||
|
kein API-Aufruf; die Karte verschwindet von selbst, wenn nichts offen ist. Auch das
|
||||||
|
**Ruin-Banner** verlinkt neu auf die Einflussfaktoren («Wirksamsten Hebel finden»).
|
||||||
|
|
||||||
|
### 3.7.9 Befehls-Palette und Sparklines
|
||||||
|
|
||||||
|
**Ctrl/Cmd+K** öffnet die Befehls-Palette (`CommandPalette.tsx`): Springen zu jedem
|
||||||
|
Plan/Szenario und die wichtigsten Aktionen (Analysen, neues Szenario, neuer Plan), mit
|
||||||
|
Tastatur-Navigation – der Effizienz-Pfad für geübte Nutzer.
|
||||||
|
|
||||||
|
Jede Element-Zeile der Matrix trägt eine **Sparkline** (`Sparkline.tsx`): der Wertverlauf
|
||||||
|
über alle Planjahre als Mini-SVG, gespeist aus den per-Jahr-Werten der Detailansichten
|
||||||
|
(`ElementPhaseComputed.yearly`, seit 0.11) – keine Neuberechnung, reine Darstellung. Flache
|
||||||
|
Verläufe werden nicht gezeichnet (keine Information).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -2042,7 +2147,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/ 17 React-Komponenten (alle "use client")
|
│ ├── components/ 22 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)
|
||||||
@@ -2335,6 +2440,11 @@ wird der Plan neu geladen; die Berechnung kommt immer vom Server.
|
|||||||
| `SystemParametersView` | ~90 | Systemparameter mit Wert, Bedeutung, Herleitung, Quelle und Stand |
|
| `SystemParametersView` | ~90 | Systemparameter mit Wert, Bedeutung, Herleitung, Quelle und Stand |
|
||||||
| `SpecView` | 65 | Rendert `SPEZIFIKATION.md` (via `/api/spec`) als lesbares Dokument, inkl. Sprungmarken aus den Rechenwegen |
|
| `SpecView` | 65 | Rendert `SPEZIFIKATION.md` (via `/api/spec`) als lesbares Dokument, inkl. Sprungmarken aus den Rechenwegen |
|
||||||
| `InfoBubble` | 28 | Hilfe-Tooltip |
|
| `InfoBubble` | 28 | Hilfe-Tooltip |
|
||||||
|
| `ui` | ~370 | UI-Primitiven: Button, Modal, InspectorShell, Confirm, Toast, Skeleton, EmptyState ([3.7.6](#376-sprache-und-ui-primitiven)/[3.7.7](#377-inspector-panel-statt-modals)) |
|
||||||
|
| `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)) |
|
||||||
|
| `CommandPalette` | ~130 | Befehls-Palette Ctrl/Cmd+K ([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
|
||||||
|
|
||||||
@@ -2470,7 +2580,7 @@ Hängt eine Phase am Ende an, kappt die Dauer, vergibt Default-Name, legt vorbel
|
|||||||
→ 200 `{ phase: { id } }`
|
→ 200 `{ phase: { id } }`
|
||||||
|
|
||||||
### `DELETE /api/phases/<phaseId>`
|
### `DELETE /api/phases/<phaseId>`
|
||||||
Nur die letzte Phase. → 200 `{ ok }` · 400 „Nur die letzte Phase kann geloescht werden."
|
Nur die letzte Phase. → 200 `{ ok }` · 400 „Nur die letzte Phase kann gelöscht werden."
|
||||||
|
|
||||||
### `PUT /api/phases/<phaseId>/cash-transition`
|
### `PUT /api/phases/<phaseId>/cash-transition`
|
||||||
Body = `CashTransitionData` (siehe 5.4.5). Speichert den Cash-Entscheid für den Übergang **nach**
|
Body = `CashTransitionData` (siehe 5.4.5). Speichert den Cash-Entscheid für den Übergang **nach**
|
||||||
@@ -2834,15 +2944,13 @@ Verwandt: Wird ein Element in der Vorlage gelöscht, gilt das Gegenstück im Kin
|
|||||||
## 9.17 Kleinere Beobachtungen
|
## 9.17 Kleinere Beobachtungen
|
||||||
|
|
||||||
- `planToCsv(plan, computed)` erhält den `plan`-Parameter, verwendet ihn aber nicht.
|
- `planToCsv(plan, computed)` erhält den `plan`-Parameter, verwendet ihn aber nicht.
|
||||||
- Der Typ `Selection` in `PlanView` hat nur eine Variante (`{ type: "phase" }`) – ein Rest der
|
|
||||||
früheren Struktur mit mehreren Auswahlarten.
|
|
||||||
- `Plan.branchFromPhaseId` hat keine Fremdschlüssel-Constraint; die Phase kann gelöscht werden,
|
- `Plan.branchFromPhaseId` hat keine Fremdschlüssel-Constraint; die Phase kann gelöscht werden,
|
||||||
ohne dass das Feld bereinigt wird.
|
ohne dass das Feld bereinigt wird.
|
||||||
- Die Element-Umbenennung ist als API vorhanden, im UI aber nicht erreichbar.
|
- Die Element-Umbenennung ist als API vorhanden, im UI aber nicht erreichbar.
|
||||||
- Die `README.md` ist noch der unveränderte `create-next-app`-Text.
|
- Die `README.md` ist noch der unveränderte `create-next-app`-Text.
|
||||||
- `npm run lint` meldet einen bestehenden Fehler in `ProfileMenu.tsx` Zeile 15
|
|
||||||
(`react-hooks/set-state-in-effect`); an vergleichbaren Stellen ist die Regel andernorts
|
(Der frühere `Selection`-Rest in `PlanView` und der ProfileMenu-Lint-Fehler sind seit 0.13
|
||||||
bewusst per `eslint-disable` deaktiviert.
|
behoben – `npm run lint` läuft fehlerfrei.)
|
||||||
|
|
||||||
## 9.18 Tornado: was der Chart nicht leistet
|
## 9.18 Tornado: was der Chart nicht leistet
|
||||||
|
|
||||||
@@ -2934,6 +3042,23 @@ seit 0.12 erklärt wird ([4.13.5](#4135-wirkungslose-treiber-werden-erklärt));
|
|||||||
Vermögensbrücke erscheint stattdessen die Differenz `Verkaufspreis − Verkehrswert` als eigener
|
Vermögensbrücke erscheint stattdessen die Differenz `Verkaufspreis − Verkehrswert` als eigener
|
||||||
Posten.
|
Posten.
|
||||||
|
|
||||||
|
## 9.23 Assistent: Teilzustand bei Abbruch
|
||||||
|
|
||||||
|
Der Plan-Assistent und der Beispielplan senden am Ende eine **Sequenz** bestehender API-Aufrufe
|
||||||
|
(Plan → Phase 1 → Elemente → Folgephasen). Bricht die Sequenz mittendrin ab (Netzfehler),
|
||||||
|
existiert ein **Teil-Plan**. Der ist normal weiterbearbeitbar und der Fehlerhinweis sagt das
|
||||||
|
auch – aber es gibt kein automatisches Rollback. Das wäre nur mit Backend-Unterstützung
|
||||||
|
(Transaktion über mehrere Requests oder Batch-Endpunkt) sauber lösbar und ist bewusst nicht
|
||||||
|
gebaut: Der seltene Fehlerfall rechtfertigt keinen neuen Endpunkt.
|
||||||
|
|
||||||
|
## 9.24 Tour ohne Spotlight-Engine
|
||||||
|
|
||||||
|
Die Tour hebt ihr Ziel per Rahmen-Puls und `scrollIntoView` hervor – bewusst ohne
|
||||||
|
Spotlight-Overlay und Positionierungs-Engine (die Karte sitzt fix unten). Bei stark
|
||||||
|
verschachtelten Scroll-Situationen kann das Ziel teilweise verdeckt sein. Der Einfachheit
|
||||||
|
halber in Kauf genommen; eine echte Coach-Mark-Bibliothek wäre der nächste Schritt, wenn die
|
||||||
|
Tour sich bewährt.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# 10. Glossar
|
# 10. Glossar
|
||||||
@@ -2976,4 +3101,4 @@ Posten.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*Ende der Spezifikation v0.12*
|
*Ende der Spezifikation v0.13*
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export async function POST(request: NextRequest) {
|
|||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const parsed = changePasswordSchema.safeParse(body);
|
const parsed = changePasswordSchema.safeParse(body);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
const message = parsed.error.issues[0]?.message ?? "Ungueltige Eingabe.";
|
const message = parsed.error.issues[0]?.message ?? "Ungültige Eingabe.";
|
||||||
return NextResponse.json({ error: message }, { status: 400 });
|
return NextResponse.json({ error: message }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,7 +25,7 @@ export async function POST(request: NextRequest) {
|
|||||||
await changeUserPassword(userId, parsed.data.currentPassword, parsed.data.newPassword);
|
await changeUserPassword(userId, parsed.data.currentPassword, parsed.data.newPassword);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: e instanceof Error ? e.message : "Passwort aendern fehlgeschlagen." },
|
{ error: e instanceof Error ? e.message : "Passwort ändern fehlgeschlagen." },
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export async function POST(request: NextRequest) {
|
|||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const parsed = registerSchema.safeParse(body);
|
const parsed = registerSchema.safeParse(body);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
const message = parsed.error.issues[0]?.message ?? "Ungueltige Eingabe.";
|
const message = parsed.error.issues[0]?.message ?? "Ungültige Eingabe.";
|
||||||
return NextResponse.json({ error: message }, { status: 400 });
|
return NextResponse.json({ error: message }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export async function PUT(
|
|||||||
|
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const parsed = phaseDataSchema.safeParse(body);
|
const parsed = phaseDataSchema.safeParse(body);
|
||||||
if (!parsed.success) return NextResponse.json({ error: "Ungueltige Eingabe." }, { status: 400 });
|
if (!parsed.success) return NextResponse.json({ error: "Ungültige Eingabe." }, { status: 400 });
|
||||||
|
|
||||||
await prisma.elementPhaseValue.upsert({
|
await prisma.elementPhaseValue.upsert({
|
||||||
where: { elementId_phaseId: { elementId, phaseId } },
|
where: { elementId_phaseId: { elementId, phaseId } },
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export async function PATCH(
|
|||||||
|
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const parsed = patchSchema.safeParse(body);
|
const parsed = patchSchema.safeParse(body);
|
||||||
if (!parsed.success) return NextResponse.json({ error: "Ungueltige 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 } });
|
await prisma.financialElement.update({ where: { id: element.id }, data: { name: parsed.data.name } });
|
||||||
return NextResponse.json({ ok: true });
|
return NextResponse.json({ ok: true });
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { getOwnedElement } from "@/lib/queries";
|
|||||||
import { getCurrentUserId } from "@/lib/session";
|
import { getCurrentUserId } from "@/lib/session";
|
||||||
import { transitionDataSchema } from "@/lib/elements";
|
import { transitionDataSchema } from "@/lib/elements";
|
||||||
|
|
||||||
// Speichert den Uebergangs-Entscheid eines Elements nach der Phase fromPhase (Upsert).
|
// Speichert den Übergangs-Entscheid eines Elements nach der Phase fromPhase (Upsert).
|
||||||
export async function PUT(
|
export async function PUT(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
{ params }: { params: Promise<{ elementId: string; fromPhaseId: string }> }
|
{ params }: { params: Promise<{ elementId: string; fromPhaseId: string }> }
|
||||||
@@ -21,7 +21,7 @@ export async function PUT(
|
|||||||
|
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const parsed = transitionDataSchema.safeParse(body);
|
const parsed = transitionDataSchema.safeParse(body);
|
||||||
if (!parsed.success) return NextResponse.json({ error: "Ungueltige Eingabe." }, { status: 400 });
|
if (!parsed.success) return NextResponse.json({ error: "Ungültige Eingabe." }, { status: 400 });
|
||||||
|
|
||||||
await prisma.elementTransitionValue.upsert({
|
await prisma.elementTransitionValue.upsert({
|
||||||
where: { elementId_fromPhaseId: { elementId, fromPhaseId } },
|
where: { elementId_fromPhaseId: { elementId, fromPhaseId } },
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { getOwnedPhase } from "@/lib/queries";
|
|||||||
import { getCurrentUserId } from "@/lib/session";
|
import { getCurrentUserId } from "@/lib/session";
|
||||||
import { cashTransitionSchema } from "@/lib/elements";
|
import { cashTransitionSchema } from "@/lib/elements";
|
||||||
|
|
||||||
// Speichert den Cash-Entscheid beim UEBERGANG nach dieser Phase: 1:1 uebernehmen oder
|
// Speichert den Cash-Entscheid beim UEBERGANG nach dieser Phase: 1:1 übernehmen oder
|
||||||
// einmaliger Zufluss / einmalige Kosten (Roadmap Nr. 1). Cash ist kein FinancialElement,
|
// einmaliger Zufluss / einmalige Kosten (Roadmap Nr. 1). Cash ist kein FinancialElement,
|
||||||
// deshalb liegt der Entscheid direkt an der Von-Phase.
|
// deshalb liegt der Entscheid direkt an der Von-Phase.
|
||||||
export async function PUT(
|
export async function PUT(
|
||||||
@@ -20,7 +20,7 @@ export async function PUT(
|
|||||||
|
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const parsed = cashTransitionSchema.safeParse(body);
|
const parsed = cashTransitionSchema.safeParse(body);
|
||||||
if (!parsed.success) return NextResponse.json({ error: "Ungueltige Eingabe." }, { status: 400 });
|
if (!parsed.success) return NextResponse.json({ error: "Ungültige Eingabe." }, { status: 400 });
|
||||||
|
|
||||||
await prisma.phase.update({
|
await prisma.phase.update({
|
||||||
where: { id: phase.id },
|
where: { id: phase.id },
|
||||||
|
|||||||
@@ -23,11 +23,11 @@ export async function PUT(
|
|||||||
|
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const parsed = updatePhaseSchema.safeParse(body);
|
const parsed = updatePhaseSchema.safeParse(body);
|
||||||
if (!parsed.success) return NextResponse.json({ error: "Ungueltige 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;
|
||||||
if (duration != null) {
|
if (duration != null) {
|
||||||
// Dauer ans naechste Pensionsereignis kappen (Jahre vor dieser Phase).
|
// 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);
|
||||||
@@ -50,7 +50,7 @@ export async function PUT(
|
|||||||
return NextResponse.json({ phase: { id: phase.id } });
|
return NextResponse.json({ phase: { id: phase.id } });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Nur die letzte Phase kann geloescht werden (Verkettung bleibt intakt).
|
// Nur die letzte Phase kann gelöscht werden (Verkettung bleibt intakt).
|
||||||
export async function DELETE(
|
export async function DELETE(
|
||||||
_request: NextRequest,
|
_request: NextRequest,
|
||||||
{ params }: { params: Promise<{ phaseId: string }> }
|
{ params }: { params: Promise<{ phaseId: string }> }
|
||||||
@@ -66,7 +66,7 @@ export async function DELETE(
|
|||||||
where: { scenarioId: phase.scenarioId, sequenceNumber: { gt: phase.sequenceNumber } },
|
where: { scenarioId: phase.scenarioId, sequenceNumber: { gt: phase.sequenceNumber } },
|
||||||
});
|
});
|
||||||
if (later) {
|
if (later) {
|
||||||
return NextResponse.json({ error: "Nur die letzte Phase kann geloescht werden." }, { status: 400 });
|
return NextResponse.json({ error: "Nur die letzte Phase kann gelöscht werden." }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
await prisma.phase.delete({ where: { id: phaseId } });
|
await prisma.phase.delete({ where: { id: phaseId } });
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { z } from "zod";
|
|||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { getCurrentUserId } from "@/lib/session";
|
import { getCurrentUserId } from "@/lib/session";
|
||||||
|
|
||||||
// Der Plan ist nur noch der Behaelter: er traegt den Namen; die Finanzdaten liegen im Szenario.
|
// Der Plan ist nur noch der Behälter: er trägt den Namen; die Finanzdaten liegen im Szenario.
|
||||||
const patchSchema = z.object({ name: z.string().min(1).max(120) });
|
const patchSchema = z.object({ name: z.string().min(1).max(120) });
|
||||||
|
|
||||||
export async function PATCH(request: NextRequest, { params }: { params: Promise<{ planId: string }> }) {
|
export async function PATCH(request: NextRequest, { params }: { params: Promise<{ planId: string }> }) {
|
||||||
@@ -15,13 +15,13 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
|
|||||||
if (!plan) return NextResponse.json({ error: "Plan nicht gefunden." }, { status: 404 });
|
if (!plan) return NextResponse.json({ error: "Plan nicht gefunden." }, { status: 404 });
|
||||||
|
|
||||||
const parsed = patchSchema.safeParse(await request.json());
|
const parsed = patchSchema.safeParse(await request.json());
|
||||||
if (!parsed.success) return NextResponse.json({ error: "Ungueltige Eingabe." }, { status: 400 });
|
if (!parsed.success) return NextResponse.json({ error: "Ungültige Eingabe." }, { status: 400 });
|
||||||
|
|
||||||
const updated = await prisma.plan.update({ where: { id: plan.id }, data: { name: parsed.data.name } });
|
const updated = await prisma.plan.update({ where: { id: plan.id }, data: { name: parsed.data.name } });
|
||||||
return NextResponse.json({ plan: { id: updated.id, name: updated.name } });
|
return NextResponse.json({ plan: { id: updated.id, name: updated.name } });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Loescht den Plan inkl. aller Szenarien (Cascade).
|
// Löscht den Plan inkl. aller Szenarien (Cascade).
|
||||||
export async function DELETE(_request: NextRequest, { params }: { params: Promise<{ planId: string }> }) {
|
export async function DELETE(_request: NextRequest, { params }: { params: Promise<{ planId: string }> }) {
|
||||||
const userId = await getCurrentUserId();
|
const userId = await getCurrentUserId();
|
||||||
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
|
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
|
||||||
|
|||||||
@@ -23,15 +23,15 @@ const createPlanSchema = z.object({ name: z.string().min(1).max(120) }).and(scen
|
|||||||
|
|
||||||
export function validatePersonsForType(data: z.infer<typeof scenarioProfileSchema>): string | null {
|
export function validatePersonsForType(data: z.infer<typeof scenarioProfileSchema>): string | null {
|
||||||
if (data.householdType === "SINGLE" && data.persons.length !== 1) {
|
if (data.householdType === "SINGLE" && data.persons.length !== 1) {
|
||||||
return "Einzelperson-Plan benoetigt genau eine Person.";
|
return "Einzelperson-Plan benötigt genau eine Person.";
|
||||||
}
|
}
|
||||||
if (data.householdType === "COUPLE" && data.persons.length !== 2) {
|
if (data.householdType === "COUPLE" && data.persons.length !== 2) {
|
||||||
return "Paar-Plan benoetigt genau zwei Personen (Person A und Person B).";
|
return "Paar-Plan benötigt genau zwei Personen (Person A und Person B).";
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Liste der Plaene mit ihrem Szenario-Baum (nur Kopfdaten).
|
// Liste der Pläne mit ihrem Szenario-Baum (nur Kopfdaten).
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
const userId = await getCurrentUserId();
|
const userId = await getCurrentUserId();
|
||||||
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
|
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
|
||||||
@@ -52,7 +52,7 @@ export async function GET() {
|
|||||||
return NextResponse.json({ plans });
|
return NextResponse.json({ plans });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Legt einen Plan an -- zusammen mit seinem Basisszenario, das das Grundprofil traegt.
|
// Legt einen Plan an -- zusammen mit seinem Basisszenario, das das Grundprofil trägt.
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
const userId = await getCurrentUserId();
|
const userId = await getCurrentUserId();
|
||||||
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
|
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
|
||||||
|
|||||||
@@ -6,17 +6,17 @@ import { getCurrentUserId } from "@/lib/session";
|
|||||||
|
|
||||||
const copySchema = z.object({ name: z.string().min(1).max(120) });
|
const copySchema = z.object({ name: z.string().min(1).max(120) });
|
||||||
|
|
||||||
// Erstellt ein neues Szenario als vollstaendige Kopie eines bestehenden. Das Original wird
|
// Erstellt ein neues Szenario als vollständige Kopie eines bestehenden. Das Original wird
|
||||||
// zum Elternteil -- damit haengt der Baum in der Seitenleiste und der Diff hat seine Basis.
|
// zum Elternteil -- damit hängt der Baum in der Seitenleiste und der Diff hat seine Basis.
|
||||||
// Jede kopierte Phase/jedes kopierte Element traegt einen Herkunfts-Verweis auf sein
|
// Jede kopierte Phase/jedes kopierte Element trägt einen Herkunfts-Verweis auf sein
|
||||||
// Gegenstueck im Original; darauf beruht die Abweichungs-Markierung im UI.
|
// Gegenstück im Original; darauf beruht die Abweichungs-Markierung im UI.
|
||||||
export async function POST(request: NextRequest, { params }: { params: Promise<{ scenarioId: string }> }) {
|
export async function POST(request: NextRequest, { params }: { params: Promise<{ scenarioId: string }> }) {
|
||||||
const userId = await getCurrentUserId();
|
const userId = await getCurrentUserId();
|
||||||
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
|
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
|
||||||
const { scenarioId } = await params;
|
const { scenarioId } = await params;
|
||||||
|
|
||||||
const parsed = copySchema.safeParse(await request.json());
|
const parsed = copySchema.safeParse(await request.json());
|
||||||
if (!parsed.success) return NextResponse.json({ error: "Ungueltige Eingabe." }, { status: 400 });
|
if (!parsed.success) return NextResponse.json({ error: "Ungültige Eingabe." }, { status: 400 });
|
||||||
|
|
||||||
const source = await getOwnedScenario(scenarioId, userId);
|
const source = await getOwnedScenario(scenarioId, userId);
|
||||||
if (!source) return NextResponse.json({ error: "Ursprungs-Szenario nicht gefunden." }, { status: 404 });
|
if (!source) return NextResponse.json({ error: "Ursprungs-Szenario nicht gefunden." }, { status: 404 });
|
||||||
@@ -43,7 +43,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Phasen kopieren (alte -> neue Id merken, fuer die Werte-Zuordnung).
|
// Phasen kopieren (alte -> neue Id merken, für die Werte-Zuordnung).
|
||||||
const phaseIdMap = new Map<string, string>();
|
const phaseIdMap = new Map<string, string>();
|
||||||
for (const phase of source.phases) {
|
for (const phase of source.phases) {
|
||||||
const p = await tx.phase.create({
|
const p = await tx.phase.create({
|
||||||
@@ -59,7 +59,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
|||||||
phaseIdMap.set(phase.id, p.id);
|
phaseIdMap.set(phase.id, p.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Elemente inkl. Phasen- und Uebergangswerten kopieren.
|
// Elemente inkl. Phasen- und Übergangswerten kopieren.
|
||||||
for (const el of source.elements) {
|
for (const el of source.elements) {
|
||||||
const newEl = await tx.financialElement.create({
|
const newEl = await tx.financialElement.create({
|
||||||
data: {
|
data: {
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export async function POST(
|
|||||||
|
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const parsed = createSchema.safeParse(body);
|
const parsed = createSchema.safeParse(body);
|
||||||
if (!parsed.success) return NextResponse.json({ error: "Ungueltige Eingabe." }, { status: 400 });
|
if (!parsed.success) return NextResponse.json({ error: "Ungültige Eingabe." }, { status: 400 });
|
||||||
|
|
||||||
const { category, name } = parsed.data;
|
const { category, name } = parsed.data;
|
||||||
let ownerRole = parsed.data.ownerRole ?? null;
|
let ownerRole = parsed.data.ownerRole ?? null;
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ const createPhaseSchema = z.object({
|
|||||||
durationYears: z.number().int().min(1).max(80).optional(),
|
durationYears: z.number().int().min(1).max(80).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Legt eine neue Lebensphase am Ende der Kette an. Die Dauer wird ans naechste
|
// Legt eine neue Lebensphase am Ende der Kette an. Die Dauer wird ans nächste
|
||||||
// Pensionsereignis gekappt. Fuer bestehende Elemente werden die editierbaren Felder
|
// Pensionsereignis gekappt. Für bestehende Elemente werden die editierbaren Felder
|
||||||
// vorbelegt; die Startwerte werden in der Berechnung live aus der Vorphase fortgeschrieben.
|
// vorbelegt; die Startwerte werden in der Berechnung live aus der Vorphase fortgeschrieben.
|
||||||
export async function POST(
|
export async function POST(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
@@ -28,7 +28,7 @@ export async function POST(
|
|||||||
|
|
||||||
const body = await request.json().catch(() => ({}));
|
const body = await request.json().catch(() => ({}));
|
||||||
const parsed = createPhaseSchema.safeParse(body);
|
const parsed = createPhaseSchema.safeParse(body);
|
||||||
if (!parsed.success) return NextResponse.json({ error: "Ungueltige Eingabe." }, { status: 400 });
|
if (!parsed.success) return NextResponse.json({ error: "Ungültige Eingabe." }, { status: 400 });
|
||||||
|
|
||||||
const planInput = toPlanInput(scenario);
|
const planInput = toPlanInput(scenario);
|
||||||
const yearsBefore = planInput.phases.reduce((s, p) => s + p.durationYears, 0);
|
const yearsBefore = planInput.phases.reduce((s, p) => s + p.durationYears, 0);
|
||||||
@@ -40,12 +40,12 @@ export async function POST(
|
|||||||
|
|
||||||
const nextSequence = planInput.phases.length + 1;
|
const nextSequence = planInput.phases.length + 1;
|
||||||
|
|
||||||
// Phasentyp der neuen Phase fuer den Default-Namen bestimmen.
|
// Phasentyp der neuen Phase für den Default-Namen bestimmen.
|
||||||
const anyRetiredAtStart = planInput.persons.some((p) => p.age + yearsBefore >= p.retirementAge);
|
const anyRetiredAtStart = planInput.persons.some((p) => p.age + yearsBefore >= p.retirementAge);
|
||||||
const defaultName =
|
const defaultName =
|
||||||
parsed.data.name ?? (nextSequence === 1 ? "Erste Lebensphase" : anyRetiredAtStart ? "Pensionsphase" : "Erwerbsphase");
|
parsed.data.name ?? (nextSequence === 1 ? "Erste Lebensphase" : anyRetiredAtStart ? "Pensionsphase" : "Erwerbsphase");
|
||||||
|
|
||||||
// Status der Elemente in der bisher letzten Phase (verkauft/getilgt nicht fortfuehren).
|
// Status der Elemente in der bisher letzten Phase (verkauft/getilgt nicht fortführen).
|
||||||
const prevComputed = planInput.phases.length > 0 ? computePlan(planInput) : null;
|
const prevComputed = planInput.phases.length > 0 ? computePlan(planInput) : null;
|
||||||
const lastPhaseId = planInput.phases.at(-1)?.id;
|
const lastPhaseId = planInput.phases.at(-1)?.id;
|
||||||
const prevPhase = prevComputed?.phases.find((p) => p.id === lastPhaseId) ?? null;
|
const prevPhase = prevComputed?.phases.find((p) => p.id === lastPhaseId) ?? null;
|
||||||
@@ -77,14 +77,14 @@ export async function POST(
|
|||||||
return NextResponse.json({ phase: { id: phase.id } }, { status: 201 });
|
return NextResponse.json({ phase: { id: phase.id } }, { status: 201 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Vorbelegung fuer eine neue Phase: nur die editierbaren Felder werden uebernommen. Start-
|
// Vorbelegung für eine neue Phase: nur die editierbaren Felder werden übernommen. Start-
|
||||||
// bzw. Restwerte (PK/3a/Vermoegen/Hypothek/Schuld) werden in der Berechnung live aus der
|
// bzw. Restwerte (PK/3a/Vermögen/Hypothek/Schuld) werden in der Berechnung live aus der
|
||||||
// Vorphase fortgeschrieben und deshalb hier NICHT als Snapshot gespeichert.
|
// Vorphase fortgeschrieben und deshalb hier NICHT als Snapshot gespeichert.
|
||||||
function buildCarryData(category: string, prev: PhaseData): PhaseData {
|
function buildCarryData(category: string, prev: PhaseData): PhaseData {
|
||||||
switch (category) {
|
switch (category) {
|
||||||
case "INCOME":
|
case "INCOME":
|
||||||
case "EXPENSE":
|
case "EXPENSE":
|
||||||
// Basis wird live indexiert fortgeschrieben; nur der Teuerungsausgleich wird uebernommen.
|
// Basis wird live indexiert fortgeschrieben; nur der Teuerungsausgleich wird übernommen.
|
||||||
return prev.teuerungsausgleich != null ? { teuerungsausgleich: prev.teuerungsausgleich } : {};
|
return prev.teuerungsausgleich != null ? { teuerungsausgleich: prev.teuerungsausgleich } : {};
|
||||||
case "AHV":
|
case "AHV":
|
||||||
return { gapYears: 0 };
|
return { gapYears: 0 };
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { getCurrentUserId } from "@/lib/session";
|
|||||||
import { computePlan } from "@/lib/calculations";
|
import { computePlan } from "@/lib/calculations";
|
||||||
import { scenarioProfileSchema, validatePersonsForType } from "@/app/api/plans/route";
|
import { scenarioProfileSchema, validatePersonsForType } from "@/app/api/plans/route";
|
||||||
|
|
||||||
// Liefert das Szenario samt Berechnung -- und zusaetzlich das ELTERN-Szenario als
|
// Liefert das Szenario samt Berechnung -- und zusätzlich das ELTERN-Szenario als
|
||||||
// Vergleichsbasis, damit das UI die Abweichungen markieren kann (Basisszenario: null).
|
// Vergleichsbasis, damit das UI die Abweichungen markieren kann (Basisszenario: null).
|
||||||
export async function GET(_request: NextRequest, { params }: { params: Promise<{ scenarioId: string }> }) {
|
export async function GET(_request: NextRequest, { params }: { params: Promise<{ scenarioId: string }> }) {
|
||||||
const userId = await getCurrentUserId();
|
const userId = await getCurrentUserId();
|
||||||
@@ -58,7 +58,7 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
|
|||||||
if (!scenario) return NextResponse.json({ error: "Szenario nicht gefunden." }, { status: 404 });
|
if (!scenario) return NextResponse.json({ error: "Szenario nicht gefunden." }, { status: 404 });
|
||||||
|
|
||||||
const parsed = patchSchema.safeParse(await request.json());
|
const parsed = patchSchema.safeParse(await request.json());
|
||||||
if (!parsed.success) return NextResponse.json({ error: "Ungueltige Eingabe." }, { status: 400 });
|
if (!parsed.success) return NextResponse.json({ error: "Ungültige Eingabe." }, { status: 400 });
|
||||||
|
|
||||||
const data = parsed.data;
|
const data = parsed.data;
|
||||||
if ("householdType" in data) {
|
if ("householdType" in data) {
|
||||||
@@ -91,7 +91,7 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
|
|||||||
return NextResponse.json({ scenario: { id: updated.id, name: updated.name } });
|
return NextResponse.json({ scenario: { id: updated.id, name: updated.name } });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Loescht ein Szenario. Das Basisszenario kann nicht geloescht werden (dafuer den Plan loeschen).
|
// Löscht ein Szenario. Das Basisszenario kann nicht gelöscht werden (dafür den Plan löschen).
|
||||||
export async function DELETE(_request: NextRequest, { params }: { params: Promise<{ scenarioId: string }> }) {
|
export async function DELETE(_request: NextRequest, { params }: { params: Promise<{ scenarioId: string }> }) {
|
||||||
const userId = await getCurrentUserId();
|
const userId = await getCurrentUserId();
|
||||||
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
|
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
|
||||||
@@ -101,7 +101,7 @@ export async function DELETE(_request: NextRequest, { params }: { params: Promis
|
|||||||
if (!scenario) return NextResponse.json({ error: "Szenario nicht gefunden." }, { status: 404 });
|
if (!scenario) return NextResponse.json({ error: "Szenario nicht gefunden." }, { status: 404 });
|
||||||
if (scenario.isBase) {
|
if (scenario.isBase) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Das Basisszenario kann nicht geloescht werden. Loeschen Sie stattdessen den Plan." },
|
{ error: "Das Basisszenario kann nicht gelöscht werden. Löschen Sie stattdessen den Plan." },
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+65
-6
@@ -1,7 +1,7 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
/* -------------------------------------------------------------------------
|
/* -------------------------------------------------------------------------
|
||||||
Semantische Farb-Tokens. Drei umschaltbare Schemata ueber data-theme am
|
Semantische Farb-Tokens. Drei umschaltbare Schemata über data-theme am
|
||||||
<html>: "light", "dark", "warm". Ohne explizite Wahl folgt das Standard-
|
<html>: "light", "dark", "warm". Ohne explizite Wahl folgt das Standard-
|
||||||
:root der OS-Einstellung (prefers-color-scheme). Umschaltung: lib/theme.ts.
|
:root der OS-Einstellung (prefers-color-scheme). Umschaltung: lib/theme.ts.
|
||||||
------------------------------------------------------------------------- */
|
------------------------------------------------------------------------- */
|
||||||
@@ -33,6 +33,12 @@
|
|||||||
--diff-removed: #71717a;
|
--diff-removed: #71717a;
|
||||||
--diff-removed-soft: #f4f4f5;
|
--diff-removed-soft: #f4f4f5;
|
||||||
--person-b: #0ea5e9;
|
--person-b: #0ea5e9;
|
||||||
|
/* Attention: "hier fehlt eine Eingabe" -- bewusst getrennt vom Akzent, damit
|
||||||
|
"offen" und "klickbar" nicht dieselbe Farbe tragen. */
|
||||||
|
--attention: #d97706;
|
||||||
|
--attention-fg: #ffffff;
|
||||||
|
--attention-soft: #fef3c7;
|
||||||
|
--attention-soft-fg: #92400e;
|
||||||
}
|
}
|
||||||
|
|
||||||
:root[data-theme="dark"] {
|
:root[data-theme="dark"] {
|
||||||
@@ -61,9 +67,13 @@
|
|||||||
--diff-removed: #a1a1aa;
|
--diff-removed: #a1a1aa;
|
||||||
--diff-removed-soft: rgba(161, 161, 170, 0.12);
|
--diff-removed-soft: rgba(161, 161, 170, 0.12);
|
||||||
--person-b: #38bdf8;
|
--person-b: #38bdf8;
|
||||||
|
--attention: #f59e0b;
|
||||||
|
--attention-fg: #1c1400;
|
||||||
|
--attention-soft: rgba(245, 158, 11, 0.16);
|
||||||
|
--attention-soft-fg: #fcd34d;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Warm / Sunset: cremefarbener Grund, Koralle-Akzent, Amber-Sekundaerton. */
|
/* Warm / Sunset: cremefarbener Grund, Koralle-Akzent, Amber-Sekundärton. */
|
||||||
:root[data-theme="warm"] {
|
:root[data-theme="warm"] {
|
||||||
--bg: #fbf7f2;
|
--bg: #fbf7f2;
|
||||||
--surface: #fffdfa;
|
--surface: #fffdfa;
|
||||||
@@ -80,7 +90,7 @@
|
|||||||
--accent-soft: #fcebe2;
|
--accent-soft: #fcebe2;
|
||||||
--accent-soft-fg: #b24521;
|
--accent-soft-fg: #b24521;
|
||||||
--danger: #c0392b;
|
--danger: #c0392b;
|
||||||
--danger-soft: #fbeae7;
|
--danger-soft: #fbeä7;
|
||||||
--success: #2e9e7b;
|
--success: #2e9e7b;
|
||||||
--person-a: #e8663c;
|
--person-a: #e8663c;
|
||||||
--diff: #b45309;
|
--diff: #b45309;
|
||||||
@@ -90,6 +100,10 @@
|
|||||||
--diff-removed: #8a7f72;
|
--diff-removed: #8a7f72;
|
||||||
--diff-removed-soft: #f0e8dd;
|
--diff-removed-soft: #f0e8dd;
|
||||||
--person-b: #f2a93b;
|
--person-b: #f2a93b;
|
||||||
|
--attention: #c77414;
|
||||||
|
--attention-fg: #ffffff;
|
||||||
|
--attention-soft: #fdeed3;
|
||||||
|
--attention-soft-fg: #8f5410;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Ohne gespeicherte Wahl der Dunkel-OS-Einstellung folgen. */
|
/* Ohne gespeicherte Wahl der Dunkel-OS-Einstellung folgen. */
|
||||||
@@ -120,6 +134,10 @@
|
|||||||
--diff-removed: #a1a1aa;
|
--diff-removed: #a1a1aa;
|
||||||
--diff-removed-soft: rgba(161, 161, 170, 0.12);
|
--diff-removed-soft: rgba(161, 161, 170, 0.12);
|
||||||
--person-b: #38bdf8;
|
--person-b: #38bdf8;
|
||||||
|
--attention: #f59e0b;
|
||||||
|
--attention-fg: #1c1400;
|
||||||
|
--attention-soft: rgba(245, 158, 11, 0.16);
|
||||||
|
--attention-soft-fg: #fcd34d;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,6 +165,10 @@
|
|||||||
--color-diff-added-soft: var(--diff-added-soft);
|
--color-diff-added-soft: var(--diff-added-soft);
|
||||||
--color-diff-removed: var(--diff-removed);
|
--color-diff-removed: var(--diff-removed);
|
||||||
--color-diff-removed-soft: var(--diff-removed-soft);
|
--color-diff-removed-soft: var(--diff-removed-soft);
|
||||||
|
--color-attention: var(--attention);
|
||||||
|
--color-attention-fg: var(--attention-fg);
|
||||||
|
--color-attention-soft: var(--attention-soft);
|
||||||
|
--color-attention-soft-fg: var(--attention-soft-fg);
|
||||||
--color-person-a: var(--person-a);
|
--color-person-a: var(--person-a);
|
||||||
--color-person-b: var(--person-b);
|
--color-person-b: var(--person-b);
|
||||||
--font-sans: var(--font-geist-sans);
|
--font-sans: var(--font-geist-sans);
|
||||||
@@ -156,12 +178,49 @@
|
|||||||
body {
|
body {
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
color: var(--fg);
|
color: var(--fg);
|
||||||
font-family: Arial, Helvetica, sans-serif;
|
font-family: var(--font-geist-sans), Arial, Helvetica, sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* -------------------------------------------------------------------------
|
/* -------------------------------------------------------------------------
|
||||||
Dokument-Darstellung fuer gerenderten Markdown (Spezifikation, /spezifikation).
|
Micro-Interactions: kurze, dezente Übergänge für Dialoge, Panels, Toasts.
|
||||||
Bewusst ueber die semantischen Tokens, damit alle drei Farbschemata greifen.
|
150-220 ms -- schnell genug, um nie zu bremsen (Doherty-Schwelle).
|
||||||
|
------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
@keyframes ui-fade-kf {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
@keyframes ui-pop-kf {
|
||||||
|
from { opacity: 0; transform: translateY(6px) scale(0.985); }
|
||||||
|
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||||
|
}
|
||||||
|
@keyframes ui-slide-in-kf {
|
||||||
|
from { opacity: 0.4; transform: translateX(24px); }
|
||||||
|
to { opacity: 1; transform: translateX(0); }
|
||||||
|
}
|
||||||
|
.ui-fade { animation: ui-fade-kf 150ms ease-out; }
|
||||||
|
.ui-pop { animation: ui-pop-kf 180ms cubic-bezier(0.2, 0.9, 0.3, 1); }
|
||||||
|
.ui-slide-in { animation: ui-slide-in-kf 200ms cubic-bezier(0.2, 0.9, 0.3, 1); }
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.ui-fade, .ui-pop, .ui-slide-in { animation: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hervorhebung des aktuellen Tour-Ziels. */
|
||||||
|
@keyframes tour-pulse-kf {
|
||||||
|
0%, 100% { box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 45%, transparent); }
|
||||||
|
50% { box-shadow: 0 0 0 7px color-mix(in srgb, var(--accent) 20%, transparent); }
|
||||||
|
}
|
||||||
|
.tour-highlight {
|
||||||
|
position: relative;
|
||||||
|
z-index: 35;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
animation: tour-pulse-kf 1.6s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------------------
|
||||||
|
Dokument-Darstellung für gerenderten Markdown (Spezifikation, /spezifikation).
|
||||||
|
Bewusst über die semantischen Tokens, damit alle drei Farbschemata greifen.
|
||||||
------------------------------------------------------------------------- */
|
------------------------------------------------------------------------- */
|
||||||
|
|
||||||
.md-doc {
|
.md-doc {
|
||||||
|
|||||||
+1
-1
@@ -14,7 +14,7 @@ const geistMono = Geist_Mono({
|
|||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "FPT — Financial Planning Tool",
|
title: "FPT — Financial Planning Tool",
|
||||||
description: "Persoenliche Finanzplanung ueber Lebensabschnittsphasen (AICDS)",
|
description: "Persönliche Finanzplanung über Lebensabschnittsphasen (AICDS)",
|
||||||
};
|
};
|
||||||
|
|
||||||
// Setzt data-theme aus localStorage noch vor dem ersten Paint (verhindert FOUC).
|
// Setzt data-theme aus localStorage noch vor dem ersten Paint (verhindert FOUC).
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ function LoginForm() {
|
|||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
if (mode === "register" && password !== passwordConfirm) {
|
if (mode === "register" && password !== passwordConfirm) {
|
||||||
setError("Die Passwoerter stimmen nicht ueberein.");
|
setError("Die Passwörter stimmen nicht überein.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,7 +59,7 @@ function LoginForm() {
|
|||||||
Financial Planning Tool
|
Financial Planning Tool
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-xs text-muted">
|
<p className="text-xs text-muted">
|
||||||
Persoenliche Finanzplanung ueber Lebensphasen
|
Persönliche Finanzplanung über Lebensphasen
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -116,7 +116,7 @@ function LoginForm() {
|
|||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
placeholder="Passwort bestaetigen"
|
placeholder="Passwort bestätigen"
|
||||||
value={passwordConfirm}
|
value={passwordConfirm}
|
||||||
onChange={(e) => setPasswordConfirm(e.target.value)}
|
onChange={(e) => setPasswordConfirm(e.target.value)}
|
||||||
className={inputClass}
|
className={inputClass}
|
||||||
|
|||||||
+2
-2
@@ -12,7 +12,7 @@ export default function Home() {
|
|||||||
.get<{ user: { username: string } }>("/api/auth/me")
|
.get<{ user: { username: string } }>("/api/auth/me")
|
||||||
.then((data) => setUsername(data.user.username))
|
.then((data) => setUsername(data.user.username))
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
// Session abgelaufen/ungueltig -- Middleware leitet beim naechsten Request um.
|
// Session abgelaufen/ungültig -- Middleware leitet beim nächsten Request um.
|
||||||
window.location.href = "/login";
|
window.location.href = "/login";
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
@@ -20,7 +20,7 @@ export default function Home() {
|
|||||||
if (username === null) {
|
if (username === null) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-1 items-center justify-center">
|
<div className="flex flex-1 items-center justify-center">
|
||||||
<p className="text-sm text-muted">Laedt…</p>
|
<p className="text-sm text-muted">Lädt…</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+330
-137
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import {
|
import {
|
||||||
BarChart3,
|
BarChart3,
|
||||||
BookOpen,
|
BookOpen,
|
||||||
@@ -13,7 +13,9 @@ import {
|
|||||||
Menu,
|
Menu,
|
||||||
PiggyBank,
|
PiggyBank,
|
||||||
Plus,
|
Plus,
|
||||||
|
Search,
|
||||||
SlidersHorizontal,
|
SlidersHorizontal,
|
||||||
|
Sparkles,
|
||||||
Tornado,
|
Tornado,
|
||||||
Trash2,
|
Trash2,
|
||||||
X,
|
X,
|
||||||
@@ -27,8 +29,21 @@ import { SystemParametersView } from "@/components/SystemParametersView";
|
|||||||
import { PlanTraceDialog } from "@/components/DetailView";
|
import { PlanTraceDialog } from "@/components/DetailView";
|
||||||
import { computePlan } from "@/lib/calculations";
|
import { computePlan } from "@/lib/calculations";
|
||||||
import { ProfileMenu } from "@/components/ProfileMenu";
|
import { ProfileMenu } from "@/components/ProfileMenu";
|
||||||
|
import { PlanWizard } from "@/components/PlanWizard";
|
||||||
|
import { CommandPalette, type PaletteAction } from "@/components/CommandPalette";
|
||||||
import { PlanProfileFields, emptyProfileDraft, type ProfileDraft } from "@/components/PlanProfileFields";
|
import { PlanProfileFields, emptyProfileDraft, type ProfileDraft } from "@/components/PlanProfileFields";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
ConfirmProvider,
|
||||||
|
EmptyState,
|
||||||
|
Modal,
|
||||||
|
PlanSkeleton,
|
||||||
|
ToastProvider,
|
||||||
|
useConfirm,
|
||||||
|
useToast,
|
||||||
|
} from "@/components/ui";
|
||||||
import { api } from "@/lib/api-client";
|
import { api } from "@/lib/api-client";
|
||||||
|
import { createDemoPlan } from "@/lib/demoplan";
|
||||||
import { computeScenarioDiff } from "@/lib/diff";
|
import { computeScenarioDiff } from "@/lib/diff";
|
||||||
import type { PlanInput, PlanListItem, ScenarioMeta } from "@/lib/types";
|
import type { PlanInput, PlanListItem, ScenarioMeta } from "@/lib/types";
|
||||||
import type { PlanComputed } from "@/lib/calculations";
|
import type { PlanComputed } from "@/lib/calculations";
|
||||||
@@ -40,13 +55,31 @@ interface ScenarioDetail {
|
|||||||
meta: ScenarioMeta & { planName: string };
|
meta: ScenarioMeta & { planName: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Die Provider (Toast, Bestätigung) müssen UM die Shell liegen, damit deren Hooks
|
||||||
|
// innerhalb funktionieren.
|
||||||
export function AppShell({ username }: { username: string }) {
|
export function AppShell({ username }: { username: string }) {
|
||||||
|
return (
|
||||||
|
<ToastProvider>
|
||||||
|
<ConfirmProvider>
|
||||||
|
<AppShellInner username={username} />
|
||||||
|
</ConfirmProvider>
|
||||||
|
</ToastProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AppShellInner({ username }: { username: string }) {
|
||||||
|
const toast = useToast();
|
||||||
|
const confirm = useConfirm();
|
||||||
|
|
||||||
const [plans, setPlans] = useState<PlanListItem[]>([]);
|
const [plans, setPlans] = useState<PlanListItem[]>([]);
|
||||||
const [selectedScenarioId, setSelectedScenarioId] = useState<string | null>(null);
|
const [selectedScenarioId, setSelectedScenarioId] = useState<string | null>(null);
|
||||||
const [detail, setDetail] = useState<ScenarioDetail | null>(null);
|
const [detail, setDetail] = useState<ScenarioDetail | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
const [showNewPlan, setShowNewPlan] = useState(false);
|
const [showNewPlanChooser, setShowNewPlanChooser] = useState(false);
|
||||||
|
const [showWizard, setShowWizard] = useState(false);
|
||||||
|
const [showEmptyPlanDialog, setShowEmptyPlanDialog] = useState(false);
|
||||||
|
const [creatingDemo, setCreatingDemo] = useState(false);
|
||||||
const [copyFrom, setCopyFrom] = useState<ScenarioMeta | null>(null);
|
const [copyFrom, setCopyFrom] = useState<ScenarioMeta | null>(null);
|
||||||
const [showSpec, setShowSpec] = useState(false);
|
const [showSpec, setShowSpec] = useState(false);
|
||||||
const [showCharts, setShowCharts] = useState(false);
|
const [showCharts, setShowCharts] = useState(false);
|
||||||
@@ -54,6 +87,7 @@ export function AppShell({ username }: { username: string }) {
|
|||||||
const [showSensitivity, setShowSensitivity] = useState(false);
|
const [showSensitivity, setShowSensitivity] = useState(false);
|
||||||
const [showSystemParams, setShowSystemParams] = useState(false);
|
const [showSystemParams, setShowSystemParams] = useState(false);
|
||||||
const [showPlanTraces, setShowPlanTraces] = useState(false);
|
const [showPlanTraces, setShowPlanTraces] = useState(false);
|
||||||
|
const [showPalette, setShowPalette] = useState(false);
|
||||||
// Sprungmarke in die SPEZIFIKATION, gesetzt aus einem Rechenweg heraus.
|
// Sprungmarke in die SPEZIFIKATION, gesetzt aus einem Rechenweg heraus.
|
||||||
const [specAnchor, setSpecAnchor] = useState<string | null>(null);
|
const [specAnchor, setSpecAnchor] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -71,7 +105,7 @@ export function AppShell({ username }: { username: string }) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// silent = Hintergrund-Refresh ohne Loading-Umschaltung: die PlanView bleibt montiert,
|
// silent = Hintergrund-Refresh ohne Loading-Umschaltung: die PlanView bleibt montiert,
|
||||||
// damit die Scrollposition (z. B. nach dem Schliessen eines Popups) erhalten bleibt.
|
// damit die Scrollposition (z. B. nach dem Schliessen eines Panels) erhalten bleibt.
|
||||||
const loadDetail = useCallback(async (scenarioId: string, silent = false) => {
|
const loadDetail = useCallback(async (scenarioId: string, silent = false) => {
|
||||||
if (!silent) setLoading(true);
|
if (!silent) setLoading(true);
|
||||||
try {
|
try {
|
||||||
@@ -95,6 +129,18 @@ export function AppShell({ username }: { username: string }) {
|
|||||||
}
|
}
|
||||||
}, [selectedScenarioId, loadDetail]);
|
}, [selectedScenarioId, loadDetail]);
|
||||||
|
|
||||||
|
// Cmd/Ctrl+K öffnet die Befehls-Palette.
|
||||||
|
useEffect(() => {
|
||||||
|
function onKey(e: KeyboardEvent) {
|
||||||
|
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
|
||||||
|
e.preventDefault();
|
||||||
|
setShowPalette((v) => !v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener("keydown", onKey);
|
||||||
|
return () => window.removeEventListener("keydown", onKey);
|
||||||
|
}, []);
|
||||||
|
|
||||||
function refreshCurrent() {
|
function refreshCurrent() {
|
||||||
if (selectedScenarioId) loadDetail(selectedScenarioId, true);
|
if (selectedScenarioId) loadDetail(selectedScenarioId, true);
|
||||||
}
|
}
|
||||||
@@ -107,8 +153,16 @@ export function AppShell({ username }: { username: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleDeletePlan(id: string) {
|
async function handleDeletePlan(id: string) {
|
||||||
if (!confirm("Diesen Plan mit ALLEN Szenarien wirklich loeschen?")) return;
|
const plan = plans.find((p) => p.id === id);
|
||||||
|
const ok = await confirm({
|
||||||
|
title: "Plan löschen?",
|
||||||
|
message: `«${plan?.name ?? "Plan"}» wird mit allen Szenarien unwiderruflich gelöscht.`,
|
||||||
|
confirmLabel: "Endgültig löschen",
|
||||||
|
danger: true,
|
||||||
|
});
|
||||||
|
if (!ok) return;
|
||||||
await api.delete(`/api/plans/${id}`);
|
await api.delete(`/api/plans/${id}`);
|
||||||
|
toast("success", "Plan gelöscht.");
|
||||||
const rest = await loadPlans();
|
const rest = await loadPlans();
|
||||||
if (!rest.some((p) => p.scenarios.some((s) => s.id === selectedScenarioId))) {
|
if (!rest.some((p) => p.scenarios.some((s) => s.id === selectedScenarioId))) {
|
||||||
setSelectedScenarioId(null);
|
setSelectedScenarioId(null);
|
||||||
@@ -116,19 +170,61 @@ export function AppShell({ username }: { username: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleDeleteScenario(s: ScenarioMeta) {
|
async function handleDeleteScenario(s: ScenarioMeta) {
|
||||||
if (!confirm(`Szenario "${s.name}" wirklich loeschen?`)) return;
|
const ok = await confirm({
|
||||||
|
title: "Szenario löschen?",
|
||||||
|
message: `«${s.name}» wird unwiderruflich gelöscht. Untergeordnete Szenarien bleiben bestehen.`,
|
||||||
|
confirmLabel: "Endgültig löschen",
|
||||||
|
danger: true,
|
||||||
|
});
|
||||||
|
if (!ok) return;
|
||||||
try {
|
try {
|
||||||
await api.delete(`/api/scenarios/${s.id}`);
|
await api.delete(`/api/scenarios/${s.id}`);
|
||||||
|
toast("success", "Szenario gelöscht.");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert(e instanceof Error ? e.message : "Loeschen fehlgeschlagen.");
|
toast("error", e instanceof Error ? e.message : "Löschen fehlgeschlagen.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await loadPlans();
|
await loadPlans();
|
||||||
if (selectedScenarioId === s.id) setSelectedScenarioId(null);
|
if (selectedScenarioId === s.id) setSelectedScenarioId(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleCreateDemo() {
|
||||||
|
setCreatingDemo(true);
|
||||||
|
try {
|
||||||
|
const sid = await createDemoPlan();
|
||||||
|
await loadPlans();
|
||||||
|
setShowNewPlanChooser(false);
|
||||||
|
openScenario(sid);
|
||||||
|
toast("success", "Beispielplan angelegt – schau dich um!");
|
||||||
|
} catch (e) {
|
||||||
|
toast("error", e instanceof Error ? e.message : "Beispielplan konnte nicht angelegt werden.");
|
||||||
|
} finally {
|
||||||
|
setCreatingDemo(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const activePlan = plans.find((p) => p.scenarios.some((s) => s.id === selectedScenarioId)) ?? null;
|
const activePlan = plans.find((p) => p.scenarios.some((s) => s.id === selectedScenarioId)) ?? null;
|
||||||
|
|
||||||
|
// Aktionen der Befehls-Palette -- kontextabhängig (Analysen nur bei offenem Szenario).
|
||||||
|
const paletteActions = useMemo<PaletteAction[]>(() => {
|
||||||
|
const base: PaletteAction[] = [
|
||||||
|
{ id: "a-new", label: "Neuen Plan erstellen", hint: "Aktion", run: () => setShowNewPlanChooser(true) },
|
||||||
|
{ id: "a-home", label: "Übersicht öffnen", hint: "Aktion", run: () => { setSelectedScenarioId(null); setShowSpec(false); setShowSystemParams(false); } },
|
||||||
|
{ id: "a-spec", label: "So rechnet FPT (Spezifikation)", hint: "Aktion", run: () => { setShowSpec(true); setSpecAnchor(null); setShowSystemParams(false); setSelectedScenarioId(null); } },
|
||||||
|
{ id: "a-params", label: "Systemparameter", hint: "Aktion", run: () => { setShowSystemParams(true); setShowSpec(false); setSelectedScenarioId(null); } },
|
||||||
|
];
|
||||||
|
if (detail && selectedScenarioId) {
|
||||||
|
base.unshift(
|
||||||
|
{ id: "a-charts", label: "Grafiken öffnen", hint: "Aktion", run: () => setShowCharts(true) },
|
||||||
|
{ id: "a-mc", label: "Monte-Carlo-Simulation", hint: "Aktion", run: () => setShowMonteCarlo(true) },
|
||||||
|
{ id: "a-sens", label: "Einflussfaktoren berechnen", hint: "Aktion", run: () => setShowSensitivity(true) },
|
||||||
|
{ id: "a-traces", label: "Rechenwege anzeigen", hint: "Aktion", run: () => setShowPlanTraces(true) },
|
||||||
|
{ id: "a-copy", label: "Neues Szenario aus diesem", hint: "Aktion", run: () => setCopyFrom(detail.meta) }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return base;
|
||||||
|
}, [detail, selectedScenarioId]);
|
||||||
|
|
||||||
const sidebar = (
|
const sidebar = (
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col">
|
||||||
<div className="flex items-center gap-2 px-4 py-4">
|
<div className="flex items-center gap-2 px-4 py-4">
|
||||||
@@ -136,6 +232,15 @@ export function AppShell({ username }: { username: string }) {
|
|||||||
<PiggyBank className="h-5 w-5 text-accent-fg" />
|
<PiggyBank className="h-5 w-5 text-accent-fg" />
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm font-semibold text-fg">FPT</span>
|
<span className="text-sm font-semibold text-fg">FPT</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPalette(true)}
|
||||||
|
title="Suchen und springen (Ctrl+K)"
|
||||||
|
className="ml-auto flex items-center gap-1 rounded-lg border border-border px-2 py-1 text-[10px] text-faint transition-colors hover:bg-surface-2 hover:text-fg"
|
||||||
|
>
|
||||||
|
<Search className="h-3 w-3" />
|
||||||
|
Ctrl K
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav className="flex flex-1 flex-col gap-1 overflow-y-auto px-3 pb-4">
|
<nav className="flex flex-1 flex-col gap-1 overflow-y-auto px-3 pb-4">
|
||||||
@@ -147,26 +252,26 @@ export function AppShell({ username }: { username: string }) {
|
|||||||
setShowSystemParams(false);
|
setShowSystemParams(false);
|
||||||
setSidebarOpen(false);
|
setSidebarOpen(false);
|
||||||
}}
|
}}
|
||||||
className={`flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium ${
|
className={`flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium transition-colors ${
|
||||||
selectedScenarioId === null && !showSpec && !showSystemParams ? "bg-accent-soft text-accent-soft-fg" : "text-muted hover:bg-surface-2"
|
selectedScenarioId === null && !showSpec && !showSystemParams ? "bg-accent-soft text-accent-soft-fg" : "text-muted hover:bg-surface-2"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<LayoutDashboard className="h-4 w-4" />
|
<LayoutDashboard className="h-4 w-4" />
|
||||||
Uebersicht
|
Übersicht
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="mt-4 flex items-center justify-between px-3">
|
<div className="mt-4 flex items-center justify-between px-3">
|
||||||
<span className="text-[11px] font-semibold uppercase tracking-wide text-faint">Plaene</span>
|
<span className="text-[11px] font-semibold uppercase tracking-wide text-faint">Meine Pläne</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowNewPlan(true)}
|
onClick={() => setShowNewPlanChooser(true)}
|
||||||
aria-label="Neuen Plan erstellen"
|
aria-label="Neuen Plan erstellen"
|
||||||
className="rounded-md p-1 text-accent hover:bg-accent-soft"
|
className="rounded-md p-1 text-accent transition-colors hover:bg-accent-soft"
|
||||||
>
|
>
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{plans.length === 0 && <p className="px-3 py-2 text-xs text-faint">Noch keine Plaene.</p>}
|
{plans.length === 0 && <p className="px-3 py-2 text-xs text-faint">Noch keine Pläne.</p>}
|
||||||
|
|
||||||
{plans.map((p) => (
|
{plans.map((p) => (
|
||||||
<div key={p.id} className="mb-1">
|
<div key={p.id} className="mb-1">
|
||||||
@@ -175,9 +280,9 @@ export function AppShell({ username }: { username: string }) {
|
|||||||
<span className="min-w-0 flex-1 truncate" title={p.name}>{p.name}</span>
|
<span className="min-w-0 flex-1 truncate" title={p.name}>{p.name}</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="Plan loeschen"
|
aria-label="Plan löschen"
|
||||||
onClick={() => handleDeletePlan(p.id)}
|
onClick={() => handleDeletePlan(p.id)}
|
||||||
className="rounded p-0.5 text-faint opacity-0 hover:bg-danger-soft hover:text-danger group-hover:opacity-100"
|
className="rounded p-0.5 text-faint opacity-60 transition-opacity hover:bg-danger-soft hover:text-danger group-hover:opacity-100"
|
||||||
>
|
>
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
</button>
|
</button>
|
||||||
@@ -195,21 +300,7 @@ export function AppShell({ username }: { username: string }) {
|
|||||||
))}
|
))}
|
||||||
|
|
||||||
<div className="mt-4 flex flex-col gap-1 border-t border-border pt-3">
|
<div className="mt-4 flex flex-col gap-1 border-t border-border pt-3">
|
||||||
<button
|
<span className="px-3 pb-1 text-[11px] font-semibold uppercase tracking-wide text-faint">Wissen</span>
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
setShowSystemParams(true);
|
|
||||||
setShowSpec(false);
|
|
||||||
setSelectedScenarioId(null);
|
|
||||||
setSidebarOpen(false);
|
|
||||||
}}
|
|
||||||
className={`flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm font-medium ${
|
|
||||||
showSystemParams ? "bg-accent-soft text-accent-soft-fg" : "text-muted hover:bg-surface-2"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<SlidersHorizontal className="h-4 w-4 shrink-0" />
|
|
||||||
Systemparameter
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -219,12 +310,27 @@ export function AppShell({ username }: { username: string }) {
|
|||||||
setSelectedScenarioId(null);
|
setSelectedScenarioId(null);
|
||||||
setSidebarOpen(false);
|
setSidebarOpen(false);
|
||||||
}}
|
}}
|
||||||
className={`flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm font-medium ${
|
className={`flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm font-medium transition-colors ${
|
||||||
showSpec ? "bg-accent-soft text-accent-soft-fg" : "text-muted hover:bg-surface-2"
|
showSpec ? "bg-accent-soft text-accent-soft-fg" : "text-muted hover:bg-surface-2"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<FileText className="h-4 w-4 shrink-0" />
|
<FileText className="h-4 w-4 shrink-0" />
|
||||||
SPEZIFIKATION
|
So rechnet FPT
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setShowSystemParams(true);
|
||||||
|
setShowSpec(false);
|
||||||
|
setSelectedScenarioId(null);
|
||||||
|
setSidebarOpen(false);
|
||||||
|
}}
|
||||||
|
className={`flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm font-medium transition-colors ${
|
||||||
|
showSystemParams ? "bg-accent-soft text-accent-soft-fg" : "text-muted hover:bg-surface-2"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<SlidersHorizontal className="h-4 w-4 shrink-0" />
|
||||||
|
Systemparameter
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
@@ -239,12 +345,12 @@ export function AppShell({ username }: { username: string }) {
|
|||||||
|
|
||||||
{sidebarOpen && (
|
{sidebarOpen && (
|
||||||
<div className="fixed inset-0 z-40 lg:hidden">
|
<div className="fixed inset-0 z-40 lg:hidden">
|
||||||
<div className="absolute inset-0 bg-black/40" onClick={() => setSidebarOpen(false)} />
|
<div className="ui-fade absolute inset-0 bg-black/40" onClick={() => setSidebarOpen(false)} />
|
||||||
<aside className="absolute left-0 top-0 h-full w-72 border-r border-border bg-surface shadow-xl">
|
<aside className="ui-slide-in absolute left-0 top-0 h-full w-72 border-r border-border bg-surface shadow-xl">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setSidebarOpen(false)}
|
onClick={() => setSidebarOpen(false)}
|
||||||
aria-label="Menue schliessen"
|
aria-label="Menü schliessen"
|
||||||
className="absolute right-2 top-3 rounded-md p-1.5 text-faint hover:bg-surface-2"
|
className="absolute right-2 top-3 rounded-md p-1.5 text-faint hover:bg-surface-2"
|
||||||
>
|
>
|
||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
@@ -259,7 +365,7 @@ export function AppShell({ username }: { username: string }) {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setSidebarOpen(true)}
|
onClick={() => setSidebarOpen(true)}
|
||||||
aria-label="Menue oeffnen"
|
aria-label="Menü öffnen"
|
||||||
className="rounded-lg border border-border p-2 text-muted lg:hidden"
|
className="rounded-lg border border-border p-2 text-muted lg:hidden"
|
||||||
>
|
>
|
||||||
<Menu className="h-4 w-4" />
|
<Menu className="h-4 w-4" />
|
||||||
@@ -268,10 +374,10 @@ export function AppShell({ username }: { username: string }) {
|
|||||||
{showSystemParams
|
{showSystemParams
|
||||||
? "Systemparameter"
|
? "Systemparameter"
|
||||||
: showSpec
|
: showSpec
|
||||||
? "Spezifikation"
|
? "So rechnet FPT"
|
||||||
: detail
|
: detail
|
||||||
? `${detail.meta.planName} · ${detail.meta.name}`
|
? `${detail.meta.planName} · ${detail.meta.name}`
|
||||||
: "Uebersicht"}
|
: "Übersicht"}
|
||||||
</h1>
|
</h1>
|
||||||
<ProfileMenu username={username} />
|
<ProfileMenu username={username} />
|
||||||
</header>
|
</header>
|
||||||
@@ -281,82 +387,64 @@ export function AppShell({ username }: { username: string }) {
|
|||||||
|
|
||||||
{showSpec && <SpecView anchor={specAnchor} />}
|
{showSpec && <SpecView anchor={specAnchor} />}
|
||||||
|
|
||||||
{!showSpec && !showSystemParams && loading && <p className="text-sm text-muted">Laedt…</p>}
|
{!showSpec && !showSystemParams && loading && <PlanSkeleton />}
|
||||||
|
|
||||||
{!showSpec && !showSystemParams && !loading && selectedScenarioId === null && (
|
{!showSpec && !showSystemParams && !loading && selectedScenarioId === null && (
|
||||||
<DashboardHome
|
<DashboardHome
|
||||||
username={username}
|
username={username}
|
||||||
plans={plans}
|
plans={plans}
|
||||||
|
creatingDemo={creatingDemo}
|
||||||
onSelect={openScenario}
|
onSelect={openScenario}
|
||||||
onCreate={() => setShowNewPlan(true)}
|
onCreateGuided={() => setShowWizard(true)}
|
||||||
|
onCreateChooser={() => setShowNewPlanChooser(true)}
|
||||||
|
onCreateDemo={handleCreateDemo}
|
||||||
onDelete={handleDeletePlan}
|
onDelete={handleDeletePlan}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!showSpec && !showSystemParams && !loading && detail && selectedScenarioId && (
|
{!showSpec && !showSystemParams && !loading && detail && selectedScenarioId && (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2" data-tour="analysen">
|
||||||
<button
|
<Button variant="secondary" onClick={() => setCopyFrom(detail.meta)}>
|
||||||
type="button"
|
|
||||||
onClick={() => setCopyFrom(detail.meta)}
|
|
||||||
className="flex items-center gap-1.5 rounded-lg border border-border px-3 py-1.5 text-sm font-medium text-muted hover:bg-surface-2"
|
|
||||||
>
|
|
||||||
<Copy className="h-4 w-4" />
|
<Copy className="h-4 w-4" />
|
||||||
Neues Szenario aus diesem
|
Neues Szenario aus diesem
|
||||||
</button>
|
</Button>
|
||||||
{!detail.meta.isBase && (
|
{!detail.meta.isBase && (
|
||||||
<button
|
<Button variant="danger" onClick={() => handleDeleteScenario(detail.meta)}>
|
||||||
type="button"
|
|
||||||
onClick={() => handleDeleteScenario(detail.meta)}
|
|
||||||
className="flex items-center gap-1.5 rounded-lg border border-border px-3 py-1.5 text-sm font-medium text-muted hover:border-danger hover:bg-danger-soft hover:text-danger"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
Szenario loeschen
|
Szenario löschen
|
||||||
</button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{detail.plan.phases.length > 0 && (
|
{detail.plan.phases.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<button
|
<Button variant="secondary" onClick={() => setShowCharts(true)}>
|
||||||
type="button"
|
|
||||||
onClick={() => setShowCharts(true)}
|
|
||||||
className="flex items-center gap-1.5 rounded-lg border border-border px-3 py-1.5 text-sm font-medium text-muted hover:bg-surface-2"
|
|
||||||
>
|
|
||||||
<BarChart3 className="h-4 w-4" />
|
<BarChart3 className="h-4 w-4" />
|
||||||
Grafiken
|
Grafiken
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button variant="secondary" onClick={() => setShowMonteCarlo(true)}>
|
||||||
type="button"
|
|
||||||
onClick={() => setShowMonteCarlo(true)}
|
|
||||||
className="flex items-center gap-1.5 rounded-lg border border-border px-3 py-1.5 text-sm font-medium text-muted hover:bg-surface-2"
|
|
||||||
>
|
|
||||||
<Dices className="h-4 w-4" />
|
<Dices className="h-4 w-4" />
|
||||||
Monte-Carlo-Simulation
|
Monte-Carlo-Simulation
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button variant="secondary" onClick={() => setShowSensitivity(true)}>
|
||||||
type="button"
|
|
||||||
onClick={() => setShowSensitivity(true)}
|
|
||||||
className="flex items-center gap-1.5 rounded-lg border border-border px-3 py-1.5 text-sm font-medium text-muted hover:bg-surface-2"
|
|
||||||
>
|
|
||||||
<Tornado className="h-4 w-4" />
|
<Tornado className="h-4 w-4" />
|
||||||
Einflussfaktoren berechnen
|
Einflussfaktoren
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button
|
||||||
type="button"
|
variant="secondary"
|
||||||
onClick={() => setShowPlanTraces(true)}
|
onClick={() => setShowPlanTraces(true)}
|
||||||
title="Wie wird gerechnet? Plan-weite Grössen wie Deflatoren, AHV-Karriere und Ruinalter"
|
title="Wie wird gerechnet? Plan-weite Grössen wie Deflatoren, AHV-Karriere und Ruinalter"
|
||||||
className="flex items-center gap-1.5 rounded-lg border border-border px-3 py-1.5 text-sm font-medium text-muted hover:bg-surface-2"
|
|
||||||
>
|
>
|
||||||
<BookOpen className="h-4 w-4" />
|
<BookOpen className="h-4 w-4" />
|
||||||
Rechenwege
|
Rechenwege
|
||||||
</button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{diff && detail.base && (
|
{diff && detail.base && (
|
||||||
<span className="ml-auto flex items-center gap-1.5 rounded-lg border border-diff bg-diff-soft px-3 py-1.5 text-xs font-medium text-diff">
|
<span className="ml-auto flex items-center gap-1.5 rounded-lg border border-diff bg-diff-soft px-3 py-1.5 text-xs font-medium text-diff">
|
||||||
<GitBranch className="h-3.5 w-3.5" />
|
<GitBranch className="h-3.5 w-3.5" />
|
||||||
{diff.total === 0
|
{diff.total === 0
|
||||||
? "Unveraendert gegenueber der Vorlage"
|
? "Unverändert gegenüber der Vorlage"
|
||||||
: `${diff.total} Abweichung${diff.total === 1 ? "" : "en"} gegenueber der Vorlage`}
|
: `${diff.total} Abweichung${diff.total === 1 ? "" : "en"} gegenüber der Vorlage`}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -367,24 +455,54 @@ export function AppShell({ username }: { username: string }) {
|
|||||||
diff={diff}
|
diff={diff}
|
||||||
onChanged={refreshCurrent}
|
onChanged={refreshCurrent}
|
||||||
onOpenSpec={openSpecAt}
|
onOpenSpec={openSpecAt}
|
||||||
|
onOpenSensitivity={() => setShowSensitivity(true)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showNewPlan && (
|
{showNewPlanChooser && (
|
||||||
|
<NewPlanChooser
|
||||||
|
creatingDemo={creatingDemo}
|
||||||
|
onGuided={() => {
|
||||||
|
setShowNewPlanChooser(false);
|
||||||
|
setShowWizard(true);
|
||||||
|
}}
|
||||||
|
onEmpty={() => {
|
||||||
|
setShowNewPlanChooser(false);
|
||||||
|
setShowEmptyPlanDialog(true);
|
||||||
|
}}
|
||||||
|
onDemo={handleCreateDemo}
|
||||||
|
onClose={() => setShowNewPlanChooser(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showWizard && (
|
||||||
|
<PlanWizard
|
||||||
|
onCreated={async (sid) => {
|
||||||
|
setShowWizard(false);
|
||||||
|
await loadPlans();
|
||||||
|
openScenario(sid);
|
||||||
|
toast("success", "Plan erstellt – die Tour zeigt dir gleich die wichtigsten Stellen.");
|
||||||
|
}}
|
||||||
|
onClose={() => setShowWizard(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showEmptyPlanDialog && (
|
||||||
<PlanDialog
|
<PlanDialog
|
||||||
onCreate={async (name, profile) => {
|
onCreate={async (name, profile) => {
|
||||||
const { scenario } = await api.post<{ plan: { id: string }; scenario: { id: string } }>(
|
const { scenario } = await api.post<{ plan: { id: string }; scenario: { id: string } }>(
|
||||||
"/api/plans",
|
"/api/plans",
|
||||||
{ name, ...profile }
|
{ name, ...profile }
|
||||||
);
|
);
|
||||||
setShowNewPlan(false);
|
setShowEmptyPlanDialog(false);
|
||||||
await loadPlans();
|
await loadPlans();
|
||||||
openScenario(scenario.id);
|
openScenario(scenario.id);
|
||||||
|
toast("success", "Plan erstellt.");
|
||||||
}}
|
}}
|
||||||
onClose={() => setShowNewPlan(false)}
|
onClose={() => setShowEmptyPlanDialog(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -432,14 +550,90 @@ export function AppShell({ username }: { username: string }) {
|
|||||||
setCopyFrom(null);
|
setCopyFrom(null);
|
||||||
await loadPlans();
|
await loadPlans();
|
||||||
openScenario(scenarioId);
|
openScenario(scenarioId);
|
||||||
|
toast("success", "Szenario erstellt.");
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<CommandPalette
|
||||||
|
open={showPalette}
|
||||||
|
onClose={() => setShowPalette(false)}
|
||||||
|
plans={plans}
|
||||||
|
actions={paletteActions}
|
||||||
|
onOpenScenario={openScenario}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Breiter Dialog fuer den Analyse-Bereich (Grafiken).
|
// Auswahl beim Anlegen eines Plans: geführt (empfohlen), leer oder Beispielplan.
|
||||||
|
function NewPlanChooser({
|
||||||
|
creatingDemo,
|
||||||
|
onGuided,
|
||||||
|
onEmpty,
|
||||||
|
onDemo,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
creatingDemo: boolean;
|
||||||
|
onGuided: () => void;
|
||||||
|
onEmpty: () => void;
|
||||||
|
onDemo: () => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const options = [
|
||||||
|
{
|
||||||
|
icon: <Sparkles className="h-5 w-5" />,
|
||||||
|
title: "Geführt erstellen",
|
||||||
|
badge: "Empfohlen",
|
||||||
|
text: "Der Assistent fragt Schritt für Schritt – in fünf Minuten steht dein Grundgerüst.",
|
||||||
|
run: onGuided,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <Plus className="h-5 w-5" />,
|
||||||
|
title: "Leer starten",
|
||||||
|
badge: null,
|
||||||
|
text: "Nur das Grundprofil erfassen, alles Weitere selbst aufbauen – für geübte Nutzer.",
|
||||||
|
run: onEmpty,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <PiggyBank className="h-5 w-5" />,
|
||||||
|
title: creatingDemo ? "Beispielplan wird angelegt…" : "Beispielplan ansehen",
|
||||||
|
badge: null,
|
||||||
|
text: "Ein fertig ausgefüllter, fiktiver Plan zum Erkunden – so siehst du das Tool zuerst in Aktion.",
|
||||||
|
run: onDemo,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<Modal title="Neuer Plan" onClose={onClose}>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{options.map((o) => (
|
||||||
|
<button
|
||||||
|
key={o.title}
|
||||||
|
type="button"
|
||||||
|
disabled={creatingDemo}
|
||||||
|
onClick={o.run}
|
||||||
|
className="flex items-start gap-3 rounded-xl border border-border p-3.5 text-left transition-colors hover:border-accent hover:bg-accent-soft/30 disabled:opacity-60"
|
||||||
|
>
|
||||||
|
<span className="mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-accent-soft text-accent-soft-fg">
|
||||||
|
{o.icon}
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="flex items-center gap-2 text-sm font-semibold text-fg">
|
||||||
|
{o.title}
|
||||||
|
{o.badge && (
|
||||||
|
<span className="rounded bg-accent px-1.5 py-0.5 text-[9px] font-semibold uppercase text-accent-fg">{o.badge}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className="mt-0.5 block text-xs text-muted">{o.text}</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Breiter Dialog für den Analyse-Bereich (Grafiken).
|
||||||
function ChartsDialog({
|
function ChartsDialog({
|
||||||
title,
|
title,
|
||||||
children,
|
children,
|
||||||
@@ -450,10 +644,10 @@ function ChartsDialog({
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-40 flex items-start justify-center overflow-y-auto bg-black/40 px-4 py-8" onClick={onClose}>
|
<div className="ui-fade fixed inset-0 z-40 flex items-start justify-center overflow-y-auto bg-black/40 px-4 py-8" onClick={onClose}>
|
||||||
<div
|
<div
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
className="flex w-full max-w-5xl flex-col gap-4 rounded-2xl border border-border bg-surface p-6 shadow-xl"
|
className="ui-pop flex w-full max-w-5xl flex-col gap-4 rounded-2xl border border-border bg-surface p-6 shadow-xl"
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h2 className="flex items-center gap-2 text-base font-semibold text-fg">
|
<h2 className="flex items-center gap-2 text-base font-semibold text-fg">
|
||||||
@@ -469,7 +663,9 @@ function ChartsDialog({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rekursiver Szenario-Baum: Kinder werden eingerueckt, damit Sub-Szenarien sichtbar sind.
|
// Rekursiver Szenario-Baum: Kinder werden eingerückt, damit Sub-Szenarien sichtbar sind.
|
||||||
|
// Die Aktions-Icons sind immer leicht sichtbar (nicht nur bei Hover) -- auf Touch-Geräten
|
||||||
|
// gibt es kein Hover.
|
||||||
function ScenarioTree({
|
function ScenarioTree({
|
||||||
scenarios,
|
scenarios,
|
||||||
parentId,
|
parentId,
|
||||||
@@ -494,7 +690,7 @@ function ScenarioTree({
|
|||||||
{level.map((s) => (
|
{level.map((s) => (
|
||||||
<div key={s.id}>
|
<div key={s.id}>
|
||||||
<div
|
<div
|
||||||
className={`group flex items-center gap-1.5 rounded-lg py-1.5 pr-1.5 text-sm ${
|
className={`group flex items-center gap-1.5 rounded-lg py-1.5 pr-1.5 text-sm transition-colors ${
|
||||||
selectedId === s.id ? "bg-accent-soft font-medium text-accent-soft-fg" : "text-muted hover:bg-surface-2"
|
selectedId === s.id ? "bg-accent-soft font-medium text-accent-soft-fg" : "text-muted hover:bg-surface-2"
|
||||||
}`}
|
}`}
|
||||||
style={{ paddingLeft: `${12 + depth * 14}px` }}
|
style={{ paddingLeft: `${12 + depth * 14}px` }}
|
||||||
@@ -512,16 +708,16 @@ function ScenarioTree({
|
|||||||
aria-label="Kopie erstellen"
|
aria-label="Kopie erstellen"
|
||||||
title="Neues Szenario aus diesem"
|
title="Neues Szenario aus diesem"
|
||||||
onClick={() => onCopy(s)}
|
onClick={() => onCopy(s)}
|
||||||
className="rounded p-0.5 text-faint opacity-0 hover:bg-accent-soft hover:text-accent group-hover:opacity-100"
|
className="rounded p-0.5 text-faint opacity-60 transition-opacity hover:bg-accent-soft hover:text-accent group-hover:opacity-100"
|
||||||
>
|
>
|
||||||
<Copy className="h-3.5 w-3.5" />
|
<Copy className="h-3.5 w-3.5" />
|
||||||
</button>
|
</button>
|
||||||
{!s.isBase && (
|
{!s.isBase && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="Szenario loeschen"
|
aria-label="Szenario löschen"
|
||||||
onClick={() => onDelete(s)}
|
onClick={() => onDelete(s)}
|
||||||
className="rounded p-0.5 text-faint opacity-0 hover:bg-danger-soft hover:text-danger group-hover:opacity-100"
|
className="rounded p-0.5 text-faint opacity-60 transition-opacity hover:bg-danger-soft hover:text-danger group-hover:opacity-100"
|
||||||
>
|
>
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
</button>
|
</button>
|
||||||
@@ -542,18 +738,24 @@ function ScenarioTree({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Startansicht: Begruessung + Plan-Kacheln (Klick oeffnet das Basisszenario).
|
// Startansicht: Begrüssung + Plan-Kacheln (Klick öffnet das Basisszenario).
|
||||||
function DashboardHome({
|
function DashboardHome({
|
||||||
username,
|
username,
|
||||||
plans,
|
plans,
|
||||||
|
creatingDemo,
|
||||||
onSelect,
|
onSelect,
|
||||||
onCreate,
|
onCreateGuided,
|
||||||
|
onCreateChooser,
|
||||||
|
onCreateDemo,
|
||||||
onDelete,
|
onDelete,
|
||||||
}: {
|
}: {
|
||||||
username: string;
|
username: string;
|
||||||
plans: PlanListItem[];
|
plans: PlanListItem[];
|
||||||
|
creatingDemo: boolean;
|
||||||
onSelect: (scenarioId: string) => void;
|
onSelect: (scenarioId: string) => void;
|
||||||
onCreate: () => void;
|
onCreateGuided: () => void;
|
||||||
|
onCreateChooser: () => void;
|
||||||
|
onCreateDemo: () => void;
|
||||||
onDelete: (id: string) => void;
|
onDelete: (id: string) => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
@@ -561,11 +763,25 @@ function DashboardHome({
|
|||||||
<div>
|
<div>
|
||||||
<h2 className="text-xl font-semibold text-fg">Willkommen, {username}</h2>
|
<h2 className="text-xl font-semibold text-fg">Willkommen, {username}</h2>
|
||||||
<p className="mt-1 text-sm text-muted">
|
<p className="mt-1 text-sm text-muted">
|
||||||
Waehlen Sie einen Plan oder erstellen Sie einen neuen. Jeder Plan enthaelt ein Basisszenario
|
Wähle einen Plan oder erstelle einen neuen. Jeder Plan enthält ein Basisszenario und beliebig
|
||||||
und beliebig viele Varianten davon.
|
viele Varianten davon.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{plans.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
icon={<Sparkles className="h-6 w-6" />}
|
||||||
|
title="Deine erste Finanzplanung"
|
||||||
|
text="Am schnellsten geht es geführt: Der Assistent fragt dich Schritt für Schritt und baut dein Grundgerüst – in rund fünf Minuten. Oder schau dir zuerst den Beispielplan an."
|
||||||
|
>
|
||||||
|
<Button onClick={onCreateGuided}>
|
||||||
|
<Sparkles className="h-4 w-4" /> Geführt starten
|
||||||
|
</Button>
|
||||||
|
<Button variant="secondary" onClick={onCreateDemo} disabled={creatingDemo}>
|
||||||
|
<PiggyBank className="h-4 w-4" /> {creatingDemo ? "Wird angelegt…" : "Beispielplan ansehen"}
|
||||||
|
</Button>
|
||||||
|
</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">
|
||||||
{plans.map((p) => {
|
{plans.map((p) => {
|
||||||
const base = p.scenarios.find((s) => s.isBase) ?? p.scenarios[0];
|
const base = p.scenarios.find((s) => s.isBase) ?? p.scenarios[0];
|
||||||
@@ -588,7 +804,7 @@ function DashboardHome({
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="Plan loeschen"
|
aria-label="Plan löschen"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onDelete(p.id);
|
onDelete(p.id);
|
||||||
@@ -604,13 +820,14 @@ function DashboardHome({
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onCreate}
|
onClick={onCreateChooser}
|
||||||
className="flex min-h-20 items-center justify-center gap-2 rounded-xl border border-dashed border-accent bg-accent-soft text-sm font-medium text-accent-soft-fg hover:bg-accent-soft"
|
className="flex min-h-20 items-center justify-center gap-2 rounded-xl border border-dashed border-accent bg-accent-soft text-sm font-medium text-accent-soft-fg transition-colors hover:bg-accent-soft"
|
||||||
>
|
>
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
Neuer Plan
|
Neuer Plan
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -627,15 +844,10 @@ function PlanDialog({
|
|||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-40 flex items-start justify-center overflow-y-auto bg-black/40 px-4 py-8" onClick={onClose}>
|
<Modal title="Neuen Plan erstellen" onClose={onClose}>
|
||||||
<div
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
className="flex w-full max-w-md flex-col gap-4 rounded-2xl border border-border bg-surface p-6 shadow-xl"
|
|
||||||
>
|
|
||||||
<h2 className="text-base font-semibold text-fg">Neuen Plan erstellen</h2>
|
|
||||||
<p className="text-xs text-muted">
|
<p className="text-xs text-muted">
|
||||||
Es wird automatisch ein <strong className="text-fg">Basisszenario</strong> angelegt. Weitere
|
Es wird automatisch ein <strong className="text-fg">Basisszenario</strong> angelegt. Weitere
|
||||||
Szenarien entstehen spaeter als Kopien davon.
|
Szenarien entstehen später als Kopien davon.
|
||||||
</p>
|
</p>
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 block text-xs font-medium text-muted">Name des Plans</label>
|
<label className="mb-1 block text-xs font-medium text-muted">Name des Plans</label>
|
||||||
@@ -649,27 +861,20 @@ function PlanDialog({
|
|||||||
</div>
|
</div>
|
||||||
<PlanProfileFields draft={draft} onChange={setDraft} />
|
<PlanProfileFields draft={draft} onChange={setDraft} />
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button
|
<Button
|
||||||
type="button"
|
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
onCreate(name.trim() || "Plan", draft);
|
onCreate(name.trim() || "Plan", draft);
|
||||||
}}
|
}}
|
||||||
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 ? "..." : "Erstellen"}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onClose}
|
|
||||||
className="rounded-lg border border-border px-4 py-2 text-sm font-medium text-muted hover:bg-surface-2"
|
|
||||||
>
|
>
|
||||||
|
{saving ? "…" : "Erstellen"}
|
||||||
|
</Button>
|
||||||
|
<Button variant="secondary" onClick={onClose}>
|
||||||
Abbrechen
|
Abbrechen
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -685,14 +890,9 @@ function CopyScenarioDialog({
|
|||||||
const [name, setName] = useState(`${source.name} – Variante`);
|
const [name, setName] = useState(`${source.name} – Variante`);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-40 flex items-center justify-center bg-black/40 px-4" onClick={onClose}>
|
<Modal title="Neues Szenario" onClose={onClose}>
|
||||||
<div
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
className="flex w-full max-w-sm flex-col gap-3 rounded-2xl border border-border bg-surface p-6 shadow-xl"
|
|
||||||
>
|
|
||||||
<h2 className="text-base font-semibold text-fg">Neues Szenario</h2>
|
|
||||||
<p className="text-xs text-muted">
|
<p className="text-xs text-muted">
|
||||||
Vollstaendige Kopie von <strong className="text-fg">{source.name}</strong>. Aenderungen darin
|
Vollständige Kopie von <strong className="text-fg">{source.name}</strong>. Änderungen darin
|
||||||
werden anschliessend farblich hervorgehoben.
|
werden anschliessend farblich hervorgehoben.
|
||||||
</p>
|
</p>
|
||||||
<input
|
<input
|
||||||
@@ -703,26 +903,19 @@ function CopyScenarioDialog({
|
|||||||
placeholder="Name des Szenarios"
|
placeholder="Name des Szenarios"
|
||||||
/>
|
/>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button
|
<Button
|
||||||
type="button"
|
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
onCreate(name.trim() || "Szenario");
|
onCreate(name.trim() || "Szenario");
|
||||||
}}
|
}}
|
||||||
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 ? "..." : "Erstellen"}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onClose}
|
|
||||||
className="rounded-lg border border-border px-4 py-2 text-sm font-medium text-muted hover:bg-surface-2"
|
|
||||||
>
|
>
|
||||||
|
{saving ? "…" : "Erstellen"}
|
||||||
|
</Button>
|
||||||
|
<Button variant="secondary" onClick={onClose}>
|
||||||
Abbrechen
|
Abbrechen
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
// Befehls-Palette (Cmd/Ctrl+K): schnelles Springen zwischen Plänen und Szenarien plus
|
||||||
|
// die wichtigsten Aktionen -- der Effizienz-Pfad für geübte Nutzer, ohne der Maus-
|
||||||
|
// Bedienung etwas wegzunehmen.
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { CornerDownLeft, Search } from "lucide-react";
|
||||||
|
import type { PlanListItem } from "@/lib/types";
|
||||||
|
|
||||||
|
export interface PaletteAction {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
hint?: string;
|
||||||
|
run: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CommandPalette({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
plans,
|
||||||
|
actions,
|
||||||
|
onOpenScenario,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
plans: PlanListItem[];
|
||||||
|
actions: PaletteAction[];
|
||||||
|
onOpenScenario: (id: string) => void;
|
||||||
|
}) {
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [index, setIndex] = useState(0);
|
||||||
|
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
|
const items = useMemo<PaletteAction[]>(() => {
|
||||||
|
const scenarioItems: PaletteAction[] = plans.flatMap((p) =>
|
||||||
|
p.scenarios.map((s) => ({
|
||||||
|
id: `s-${s.id}`,
|
||||||
|
label: `${p.name} · ${s.name}`,
|
||||||
|
hint: "Szenario öffnen",
|
||||||
|
run: () => onOpenScenario(s.id),
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
const all = [...actions, ...scenarioItems];
|
||||||
|
const q = query.trim().toLowerCase();
|
||||||
|
if (!q) return all;
|
||||||
|
return all.filter((i) => i.label.toLowerCase().includes(q));
|
||||||
|
}, [plans, actions, query, onOpenScenario]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect -- Reset beim Öffnen
|
||||||
|
setQuery("");
|
||||||
|
setIndex(0);
|
||||||
|
requestAnimationFrame(() => inputRef.current?.focus());
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect -- Auswahl folgt der Eingabe
|
||||||
|
setIndex(0);
|
||||||
|
}, [query]);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
function choose(item: PaletteAction | undefined) {
|
||||||
|
if (!item) return;
|
||||||
|
onClose();
|
||||||
|
item.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="ui-fade fixed inset-0 z-50 flex items-start justify-center bg-black/40 px-4 pt-[15vh]" onClick={onClose}>
|
||||||
|
<div
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="ui-pop w-full max-w-lg overflow-hidden rounded-2xl border border-border bg-surface shadow-2xl"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 border-b border-border px-4 py-3">
|
||||||
|
<Search className="h-4 w-4 shrink-0 text-faint" />
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "ArrowDown") {
|
||||||
|
e.preventDefault();
|
||||||
|
setIndex((i) => Math.min(i + 1, items.length - 1));
|
||||||
|
} else if (e.key === "ArrowUp") {
|
||||||
|
e.preventDefault();
|
||||||
|
setIndex((i) => Math.max(i - 1, 0));
|
||||||
|
} else if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
choose(items[index]);
|
||||||
|
} else if (e.key === "Escape") {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="Plan, Szenario oder Aktion suchen…"
|
||||||
|
className="w-full bg-transparent text-sm text-fg outline-none placeholder:text-faint"
|
||||||
|
/>
|
||||||
|
<kbd className="rounded border border-border bg-surface-2 px-1.5 py-0.5 text-[10px] text-faint">ESC</kbd>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-80 overflow-y-auto py-1">
|
||||||
|
{items.length === 0 && <p className="px-4 py-3 text-sm text-muted">Nichts gefunden.</p>}
|
||||||
|
{items.slice(0, 40).map((item, i) => (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
type="button"
|
||||||
|
onMouseEnter={() => setIndex(i)}
|
||||||
|
onClick={() => choose(item)}
|
||||||
|
className={`flex w-full items-center justify-between gap-3 px-4 py-2 text-left text-sm ${
|
||||||
|
i === index ? "bg-accent-soft text-accent-soft-fg" : "text-fg"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="min-w-0 flex-1 truncate">{item.label}</span>
|
||||||
|
<span className="flex items-center gap-1.5 text-[10px] text-faint">
|
||||||
|
{item.hint}
|
||||||
|
{i === index && <CornerDownLeft className="h-3 w-3" />}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,7 +10,7 @@ import { formatChf } from "@/lib/format";
|
|||||||
import type { PlanComputed } from "@/lib/calculations";
|
import type { PlanComputed } from "@/lib/calculations";
|
||||||
import type { PlanInput } from "@/lib/types";
|
import type { PlanInput } from "@/lib/types";
|
||||||
|
|
||||||
const PALETTE = ["#4f46e5", "#0ea5e9", "#16a34a", "#d97706", "#dc2626", "#7c3aed"];
|
const PALETTE = ["#4f46e5", "#0ea5e9", "#16a34a", "#d97706", "#dc2626", "#7c3äd"];
|
||||||
|
|
||||||
interface PlanListItem {
|
interface PlanListItem {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -24,7 +24,7 @@ export function Dashboard({
|
|||||||
}: {
|
}: {
|
||||||
plan: PlanInput;
|
plan: PlanInput;
|
||||||
computed: PlanComputed;
|
computed: PlanComputed;
|
||||||
// Die uebrigen Szenarien desselben Plans -- nur die sind sinnvoll vergleichbar.
|
// Die übrigen Szenarien desselben Plans -- nur die sind sinnvoll vergleichbar.
|
||||||
siblings: PlanListItem[];
|
siblings: PlanListItem[];
|
||||||
}) {
|
}) {
|
||||||
const [compareIds, setCompareIds] = useState<string[]>([]);
|
const [compareIds, setCompareIds] = useState<string[]>([]);
|
||||||
@@ -94,8 +94,8 @@ export function Dashboard({
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
<StatCard label="Endvermoegen (nominal)" value={lastPhase ? lastPhase.endWealthNominal : 0} />
|
<StatCard label="Endvermögen (nominal)" value={lastPhase ? lastPhase.endWealthNominal : 0} />
|
||||||
<StatCard label="Endvermoegen (real, kaufkraftbereinigt)" value={lastPhase ? lastPhase.endWealthReal : 0} />
|
<StatCard label="Endvermögen (real, kaufkraftbereinigt)" value={lastPhase ? lastPhase.endWealthReal : 0} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section className="rounded-xl border border-border bg-surface p-4 shadow-sm">
|
<section className="rounded-xl border border-border bg-surface p-4 shadow-sm">
|
||||||
@@ -104,7 +104,7 @@ export function Dashboard({
|
|||||||
Einkommen vs. Ausgaben pro Jahr
|
Einkommen vs. Ausgaben pro Jahr
|
||||||
</h3>
|
</h3>
|
||||||
<p className="mb-3 text-xs text-muted">
|
<p className="mb-3 text-xs text-muted">
|
||||||
Die Flaeche zwischen Einkommen und nominalen Ausgaben ist die Spar- (gruen) bzw. Verzehrquote (rot).
|
Die Fläche zwischen Einkommen und nominalen Ausgaben ist die Spar- (grün) bzw. Verzehrquote (rot).
|
||||||
Die blasse Linie sind die realen Ausgaben – der Abstand zur nominalen Linie ist der Inflationsanteil.
|
Die blasse Linie sind die realen Ausgaben – der Abstand zur nominalen Linie ist der Inflationsanteil.
|
||||||
</p>
|
</p>
|
||||||
<SparquoteChart computed={computed} />
|
<SparquoteChart computed={computed} />
|
||||||
@@ -114,7 +114,7 @@ export function Dashboard({
|
|||||||
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
|
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||||
<h3 className="flex items-center gap-1.5 text-sm font-semibold text-fg">
|
<h3 className="flex items-center gap-1.5 text-sm font-semibold text-fg">
|
||||||
<LineChartIcon className="h-4 w-4 text-accent" />
|
<LineChartIcon className="h-4 w-4 text-accent" />
|
||||||
Vermoegensverlauf nach Alter
|
Vermögensverlauf nach Alter
|
||||||
</h3>
|
</h3>
|
||||||
<a
|
<a
|
||||||
href={`/api/scenarios/${plan.id}/export`}
|
href={`/api/scenarios/${plan.id}/export`}
|
||||||
@@ -145,11 +145,11 @@ export function Dashboard({
|
|||||||
<section className="rounded-xl border border-border bg-surface p-4 shadow-sm">
|
<section className="rounded-xl border border-border bg-surface p-4 shadow-sm">
|
||||||
<h3 className="mb-1 flex items-center gap-1.5 text-sm font-semibold text-fg">
|
<h3 className="mb-1 flex items-center gap-1.5 text-sm font-semibold text-fg">
|
||||||
<BarChart3 className="h-4 w-4 text-accent" />
|
<BarChart3 className="h-4 w-4 text-accent" />
|
||||||
Vermoegensaufteilung pro Phase (Beginn & Ende)
|
Vermögensaufteilung pro Phase (Beginn & Ende)
|
||||||
</h3>
|
</h3>
|
||||||
<p className="mb-3 text-xs text-muted">
|
<p className="mb-3 text-xs text-muted">
|
||||||
Je Phase links die Aufteilung zu Beginn, rechts am Ende. Das Ende einer Phase entspricht im
|
Je Phase links die Aufteilung zu Beginn, rechts am Ende. Das Ende einer Phase entspricht im
|
||||||
Gesamtvolumen dem Beginn der naechsten – die Aufteilung kann durch Umschichtung abweichen.
|
Gesamtvolumen dem Beginn der nächsten – die Aufteilung kann durch Umschichtung abweichen.
|
||||||
</p>
|
</p>
|
||||||
<div className="h-72 w-full">
|
<div className="h-72 w-full">
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
|||||||
@@ -30,18 +30,18 @@ import type {
|
|||||||
// --- Wasserfall ---------------------------------------------------------------------------
|
// --- Wasserfall ---------------------------------------------------------------------------
|
||||||
// Bewusst NICHT mit Recharts, sondern als eigene HTML/CSS-Darstellung. Ein Wasserfall lebt
|
// Bewusst NICHT mit Recharts, sondern als eigene HTML/CSS-Darstellung. Ein Wasserfall lebt
|
||||||
// von drei Dingen, die Recharts hier nicht hergibt: Verbindungslinien zwischen den Balken
|
// von drei Dingen, die Recharts hier nicht hergibt: Verbindungslinien zwischen den Balken
|
||||||
// (ohne sie sieht man nicht, dass jeder Balken dort ansetzt, wo der vorherige aufhoert),
|
// (ohne sie sieht man nicht, dass jeder Balken dort ansetzt, wo der vorherige aufhört),
|
||||||
// Wertbeschriftung an jedem Balken, und eine klare optische Trennung von Zwischenstaenden
|
// Wertbeschriftung an jedem Balken, und eine klare optische Trennung von Zwischenständen
|
||||||
// und Veraenderungen.
|
// und Veränderungen.
|
||||||
//
|
//
|
||||||
// Liegend statt stehend: Die Beschriftungen sind lang ("Wertsteigerung Immobilie"), stehend
|
// Liegend statt stehend: Die Beschriftungen sind lang ("Wertsteigerung Immobilie"), stehend
|
||||||
// muessten sie gedreht werden. Liegend ist es ausserdem konsistent zum Tornado.
|
// müssten sie gedreht werden. Liegend ist es ausserdem konsistent zum Tornado.
|
||||||
|
|
||||||
interface WaterfallItem {
|
interface WaterfallItem {
|
||||||
label: string;
|
label: string;
|
||||||
value: number;
|
value: number;
|
||||||
total?: boolean; // Zwischen-/Endsumme: absoluter Stand statt Veraenderung
|
total?: boolean; // Zwischen-/Endsumme: absoluter Stand statt Veränderung
|
||||||
section?: string; // optionale Abschnitts-Ueberschrift VOR diesem Eintrag
|
section?: string; // optionale Abschnitts-Überschrift VOR diesem Eintrag
|
||||||
}
|
}
|
||||||
|
|
||||||
interface WaterfallRow {
|
interface WaterfallRow {
|
||||||
@@ -130,7 +130,7 @@ function Waterfall({ items }: { items: WaterfallItem[] }) {
|
|||||||
}}
|
}}
|
||||||
title={`${r.label}: ${formatChf(r.value)}`}
|
title={`${r.label}: ${formatChf(r.value)}`}
|
||||||
/>
|
/>
|
||||||
{/* Verbindungslinie zum naechsten Balken: auf dem Stand NACH diesem Schritt */}
|
{/* Verbindungslinie zum nächsten Balken: auf dem Stand NACH diesem Schritt */}
|
||||||
{!isLast && (
|
{!isLast && (
|
||||||
<div
|
<div
|
||||||
className="absolute border-l border-dotted border-faint"
|
className="absolute border-l border-dotted border-faint"
|
||||||
@@ -155,8 +155,8 @@ function Waterfall({ items }: { items: WaterfallItem[] }) {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Zahlen mit laufendem Zwischenstand -- bei stark unterschiedlichen Groessenordnungen
|
{/* Zahlen mit laufendem Zwischenstand -- bei stark unterschiedlichen Grössenordnungen
|
||||||
ist die Tabelle der Grafik ueberlegen. */}
|
ist die Tabelle der Grafik überlegen. */}
|
||||||
<details className="mt-2">
|
<details className="mt-2">
|
||||||
<summary className="cursor-pointer text-[11px] text-muted hover:text-fg">Zahlen mit Zwischenstand anzeigen</summary>
|
<summary className="cursor-pointer text-[11px] text-muted hover:text-fg">Zahlen mit Zwischenstand anzeigen</summary>
|
||||||
<div className="mt-2 overflow-x-auto rounded-lg border border-border">
|
<div className="mt-2 overflow-x-auto rounded-lg border border-border">
|
||||||
@@ -190,9 +190,9 @@ function Waterfall({ items }: { items: WaterfallItem[] }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Kontrollgroesse: Ist die Zerlegung vollstaendig, muss die Differenz zwischen Endwert und
|
// Kontrollgrösse: Ist die Zerlegung vollständig, muss die Differenz zwischen Endwert und
|
||||||
// der Summe der Schritte 0 sein. Sichtbar machen statt verstecken -- ein Wasserfall, der
|
// der Summe der Schritte 0 sein. Sichtbar machen statt verstecken -- ein Wasserfall, der
|
||||||
// nicht aufgeht, ist ein Fehler und kein Schoenheitsproblem.
|
// nicht aufgeht, ist ein Fehler und kein Schönheitsproblem.
|
||||||
function ResidualNote({ residual }: { residual: number }) {
|
function ResidualNote({ residual }: { residual: number }) {
|
||||||
if (Math.abs(residual) <= 2) return null;
|
if (Math.abs(residual) <= 2) return null;
|
||||||
return (
|
return (
|
||||||
@@ -367,7 +367,7 @@ export function ElementDetailDialog({
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onOpenSpec?: (anchor: string) => void;
|
onOpenSpec?: (anchor: string) => void;
|
||||||
}) {
|
}) {
|
||||||
// Verlauf ueber ALLE Phasen zusammensetzen.
|
// Verlauf über ALLE Phasen zusammensetzen.
|
||||||
const points = useMemo(() => {
|
const points = useMemo(() => {
|
||||||
const out: ElementYearPoint[] = [];
|
const out: ElementYearPoint[] = [];
|
||||||
for (const ph of computed.phases) {
|
for (const ph of computed.phases) {
|
||||||
@@ -479,7 +479,7 @@ 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", "#7c3aed"];
|
const ALLOC_PALETTE = ["#4f46e5", "#0ea5e9", "#16a34a", "#d97706", "#dc2626", "#7c3äd"];
|
||||||
|
|
||||||
export function PhaseDetailDialog({
|
export function PhaseDetailDialog({
|
||||||
phase,
|
phase,
|
||||||
@@ -504,7 +504,7 @@ export function PhaseDetailDialog({
|
|||||||
[phase]
|
[phase]
|
||||||
);
|
);
|
||||||
|
|
||||||
const uebersicht = (
|
const übersicht = (
|
||||||
<>
|
<>
|
||||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||||
<Kpi label="Typ" text={phase.type === "ERWERB" ? "Erwerb" : phase.type === "PENSION" ? "Pension" : "Misch"} />
|
<Kpi label="Typ" text={phase.type === "ERWERB" ? "Erwerb" : phase.type === "PENSION" ? "Pension" : "Misch"} />
|
||||||
@@ -578,7 +578,7 @@ export function PhaseDetailDialog({
|
|||||||
subtitle={`Lebensphase ${phase.sequenceNumber} · ${phase.durationYears} Jahre`}
|
subtitle={`Lebensphase ${phase.sequenceNumber} · ${phase.durationYears} Jahre`}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
tabs={[
|
tabs={[
|
||||||
{ key: "uebersicht", label: "Übersicht", content: uebersicht },
|
{ key: "übersicht", label: "Übersicht", content: übersicht },
|
||||||
{ key: "wasserfall", label: "Wasserfall", content: wasserfall },
|
{ key: "wasserfall", label: "Wasserfall", content: wasserfall },
|
||||||
{ key: "rechenweg", label: "Rechenweg", content: rechenweg },
|
{ key: "rechenweg", label: "Rechenweg", content: rechenweg },
|
||||||
]}
|
]}
|
||||||
|
|||||||
@@ -29,18 +29,18 @@ export interface CellContext {
|
|||||||
isConsumption: boolean;
|
isConsumption: boolean;
|
||||||
durationYears: number;
|
durationYears: number;
|
||||||
isRetirementTransition: boolean;
|
isRetirementTransition: boolean;
|
||||||
carriedEndValue: number; // Endwert des Elements in der (Vor-)Phase, fuer Bezugs-Maxima
|
carriedEndValue: number; // Endwert des Elements in der (Vor-)Phase, für Bezugs-Maxima
|
||||||
carried: boolean; // Phase >= 2: Basiswert wird aus der Vorphase fortgeschrieben
|
carried: boolean; // Phase >= 2: Basiswert wird aus der Vorphase fortgeschrieben
|
||||||
derivedStart: number; // fortgeschriebener Basiswert (read-only Anzeige)
|
derivedStart: number; // fortgeschriebener Basiswert (read-only Anzeige)
|
||||||
derivedMortgage: number; // nur Immobilie: fortgeschriebene Resthypothek zu Phasenbeginn
|
derivedMortgage: number; // nur Immobilie: fortgeschriebene Resthypothek zu Phasenbeginn
|
||||||
mortgageEnd: number; // nur Immobilie: Resthypothek am Phasenende (fuer die Sonderamortisation)
|
mortgageEnd: number; // nur Immobilie: Resthypothek am Phasenende (für die Sonderamortisation)
|
||||||
// nur Immobilie: modellierter VERKEHRSWERT am Phasenende (Eigenkapital + Resthypothek),
|
// nur Immobilie: modellierter VERKEHRSWERT am Phasenende (Eigenkapital + Resthypothek),
|
||||||
// inkl. aufgelaufener Wertsteigerung. Vorbelegung und Vergleichswert fuer den Verkaufspreis.
|
// inkl. aufgelaufener Wertsteigerung. Vorbelegung und Vergleichswert für den Verkaufspreis.
|
||||||
propertyValueEnd: number;
|
propertyValueEnd: number;
|
||||||
deflatorStart: number; // Kaufkraft-Deflator zu Phasenbeginn (real <-> nominal, erstes Jahr)
|
deflatorStart: number; // Kaufkraft-Deflator zu Phasenbeginn (real <-> nominal, erstes Jahr)
|
||||||
// Warnhinweis: Anzahl Phasen NACH dieser (Aenderungen schreiben sich dorthin fort).
|
// Warnhinweis: Anzahl Phasen NACH dieser (Änderungen schreiben sich dorthin fort).
|
||||||
laterPhaseCount: number;
|
laterPhaseCount: number;
|
||||||
// AHV-Beitragskarriere des Element-Besitzers (fuer die Pruefung am Pensions-Uebergang).
|
// AHV-Beitragskarriere des Element-Besitzers (für die Prüfung am Pensions-Übergang).
|
||||||
ahvCareer: AhvCareer | null;
|
ahvCareer: AhvCareer | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,8 +54,8 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Warnhinweis beim Bearbeiten einer Phase, der noch Phasen folgen. Seit V3 werden Werte live
|
// Warnhinweis beim Bearbeiten einer Phase, der noch Phasen folgen. Seit V3 werden Werte live
|
||||||
// fortgeschrieben (Endwert = Startwert der Folgephase) -- eine Aenderung hier wirkt also bis
|
// fortgeschrieben (Endwert = Startwert der Folgephase) -- eine Änderung hier wirkt also bis
|
||||||
// ans Planende durch, was ohne Hinweis leicht uebersehen wird.
|
// ans Planende durch, was ohne Hinweis leicht übersehen wird.
|
||||||
export function CarryWarning({ laterPhaseCount }: { laterPhaseCount: number }) {
|
export function CarryWarning({ laterPhaseCount }: { laterPhaseCount: number }) {
|
||||||
if (laterPhaseCount <= 0) return null;
|
if (laterPhaseCount <= 0) return null;
|
||||||
const phasen = laterPhaseCount === 1 ? "die nachfolgende Lebensphase" : `die ${laterPhaseCount} nachfolgenden Lebensphasen`;
|
const phasen = laterPhaseCount === 1 ? "die nachfolgende Lebensphase" : `die ${laterPhaseCount} nachfolgenden Lebensphasen`;
|
||||||
@@ -64,8 +64,8 @@ export function CarryWarning({ laterPhaseCount }: { laterPhaseCount: number }) {
|
|||||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
|
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||||
<span>
|
<span>
|
||||||
<strong>Achtung: Diese Lebensphase ist nicht die letzte.</strong> Werte werden fortgeschrieben
|
<strong>Achtung: Diese Lebensphase ist nicht die letzte.</strong> Werte werden fortgeschrieben
|
||||||
(Endwert = Startwert der Folgephase). Eine Aenderung hier wirkt sich auf {phasen} aus und
|
(Endwert = Startwert der Folgephase). Eine Änderung hier wirkt sich auf {phasen} aus und
|
||||||
veraendert deren Startwerte, Kennzahlen sowie Endvermoegen, Cash-Verlauf und Ruinalter.
|
verändert deren Startwerte, Kennzahlen sowie Endvermögen, Cash-Verlauf und Ruinalter.
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -83,7 +83,7 @@ function DerivedField({ label, value, help }: { label: string; value: number; he
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// PK/3a-Bezugs-Entscheid im normalen Uebergang (Vorbezug): Kein Bezug / Bezug (+ Betrag +
|
// PK/3a-Bezugs-Entscheid im normalen Übergang (Vorbezug): Kein Bezug / Bezug (+ Betrag +
|
||||||
// Kapitalbezugssteuer). Der Bezugsbetrag wird brutto dem Kapital entnommen; ins Cash fliesst
|
// Kapitalbezugssteuer). Der Bezugsbetrag wird brutto dem Kapital entnommen; ins Cash fliesst
|
||||||
// der Betrag nach Abzug der Steuer.
|
// der Betrag nach Abzug der Steuer.
|
||||||
function WithdrawalDecision({
|
function WithdrawalDecision({
|
||||||
@@ -130,7 +130,7 @@ function WithdrawalDecision({
|
|||||||
<DerivedField
|
<DerivedField
|
||||||
label="Auszahlung netto (ins Cash)"
|
label="Auszahlung netto (ins Cash)"
|
||||||
value={Math.round(gross * (1 - taxRate / 100))}
|
value={Math.round(gross * (1 - taxRate / 100))}
|
||||||
help="Bruttobezug abzueglich Kapitalbezugssteuer. Wird automatisch berechnet."
|
help="Bruttobezug abzüglich Kapitalbezugssteuer. Wird automatisch berechnet."
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -139,9 +139,9 @@ function WithdrawalDecision({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Vorbelegung expliziter Entscheide, damit ein blosses "Speichern" den sichtbaren Default
|
// Vorbelegung expliziter Entscheide, damit ein blosses "Speichern" den sichtbaren Default
|
||||||
// (Halten / Kein Bezug / Rente) auch tatsaechlich persistiert.
|
// (Halten / Kein Bezug / Rente) auch tatsächlich persistiert.
|
||||||
// AHV-Pruefung am Pensions-Uebergang: geplantes Durchschnittseinkommen aus dem Plan plus --
|
// AHV-Prüfung am Pensions-Übergang: geplantes Durchschnittseinkommen aus dem Plan plus --
|
||||||
// nur falls der Plan nicht bis zum Beitragsbeginn (Alter 21) zurueckreicht -- die Jahre davor.
|
// nur falls der Plan nicht bis zum Beitragsbeginn (Alter 21) zurückreicht -- die Jahre davor.
|
||||||
export function AhvReviewFields({
|
export function AhvReviewFields({
|
||||||
career,
|
career,
|
||||||
td,
|
td,
|
||||||
@@ -162,39 +162,39 @@ export function AhvReviewFields({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<p className="col-span-full rounded-lg bg-surface-2 px-3 py-2 text-xs text-muted">
|
<p className="col-span-full rounded-lg bg-surface-2 px-3 py-2 text-xs text-muted">
|
||||||
Die AHV-Rente haengt vom <strong className="text-fg">massgebenden durchschnittlichen Jahreseinkommen</strong>{" "}
|
Die AHV-Rente hängt vom <strong className="text-fg">massgebenden durchschnittlichen Jahreseinkommen</strong>{" "}
|
||||||
ueber die ganze Beitragsdauer (ab Alter 21) ab. Massgebend ist der <strong className="text-fg">Bruttolohn</strong>;
|
über die ganze Beitragsdauer (ab Alter 21) ab. Massgebend ist der <strong className="text-fg">Bruttolohn</strong>;
|
||||||
das Tool rechnet die im Plan erfassten Nettoeinkommen dafuer automatisch hoch. Alle Betraege sind
|
das Tool rechnet die im Plan erfassten Nettöinkommen dafür automatisch hoch. Alle Beträge sind
|
||||||
REAL (heutige Kaufkraft) – die AHV wertet vergangene Einkommen auf und indexiert die Schwellen,
|
REAL (heutige Kaufkraft) – die AHV wertet vergangene Einkommen auf und indexiert die Schwellen,
|
||||||
was sich real weitgehend aufhebt.
|
was sich real weitgehend aufhebt.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<DerivedField
|
<DerivedField
|
||||||
label="Geplantes Bruttoeinkommen (aus dem Plan)"
|
label="Geplantes Bruttöinkommen (aus dem Plan)"
|
||||||
value={Math.round(career.plannedAvgGrossIncome)}
|
value={Math.round(career.plannedAvgGrossIncome)}
|
||||||
help={`Reales Mittel ueber ${career.planYears} Beitragsjahre der Erwerbsphasen dieses Plans, hochgerechnet vom Netto- auf den Bruttolohn. Nur Einkommen, die dieser Person zugeordnet sind.`}
|
help={`Reales Mittel über ${career.planYears} Beitragsjahre der Erwerbsphasen dieses Plans, hochgerechnet vom Netto- auf den Bruttolohn. Nur Einkommen, die dieser Person zugeordnet sind.`}
|
||||||
/>
|
/>
|
||||||
<DerivedField
|
<DerivedField
|
||||||
label="Beitragsjahre im Plan"
|
label="Beitragsjahre im Plan"
|
||||||
value={career.planYears}
|
value={career.planYears}
|
||||||
help="Dauer der Erwerbsphasen abzueglich der dort erfassten Ausfalljahre."
|
help="Dauer der Erwerbsphasen abzüglich der dort erfassten Ausfalljahre."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{brauchtVorgeschichte && (
|
{brauchtVorgeschichte && (
|
||||||
<>
|
<>
|
||||||
<p className="col-span-full rounded-lg border border-dashed border-border px-3 py-2 text-xs text-muted">
|
<p className="col-span-full rounded-lg border border-dashed border-border px-3 py-2 text-xs text-muted">
|
||||||
Der Plan beginnt erst im Alter {career.yearsBeforePlan + 21}. Die {career.yearsBeforePlan} Beitragsjahre
|
Der Plan beginnt erst im Alter {career.yearsBeforePlan + 21}. Die {career.yearsBeforePlan} Beitragsjahre
|
||||||
davor kennt das Tool nicht – bitte ergaenzen.
|
davor kennt das Tool nicht – bitte ergänzen.
|
||||||
</p>
|
</p>
|
||||||
<MoneyField
|
<MoneyField
|
||||||
label="Durchschnittliches Bruttoeinkommen vor Planbeginn (real)"
|
label="Durchschnittliches Bruttöinkommen vor Planbeginn (real)"
|
||||||
help="BRUTTO und in HEUTIGER Kaufkraft. Am besten aus der AHV-Rentenvorausberechnung uebernehmen – dort ist die Aufwertung bereits enthalten. Die Rohbetraege im Kontoauszug sind historisch-nominal und daher zu tief."
|
help="BRUTTO und in HEUTIGER Kaufkraft. Am besten aus der AHV-Rentenvorausberechnung übernehmen – dort ist die Aufwertung bereits enthalten. Die Rohbeträge im Kontoauszug sind historisch-nominal und daher zu tief."
|
||||||
value={avgBefore}
|
value={avgBefore}
|
||||||
onChange={(v) => setT({ avgIncomeBefore: v })}
|
onChange={(v) => setT({ avgIncomeBefore: v })}
|
||||||
/>
|
/>
|
||||||
<NumberField
|
<NumberField
|
||||||
label="Ausfalljahre vor Planbeginn"
|
label="Ausfalljahre vor Planbeginn"
|
||||||
help="Jahre ohne AHV-Beitraege zwischen Alter 21 und Planbeginn (z. B. Ausland, Studium). Jedes kuerzt die Rente um 1/44."
|
help="Jahre ohne AHV-Beiträge zwischen Alter 21 und Planbeginn (z. B. Ausland, Studium). Jedes kürzt die Rente um 1/44."
|
||||||
value={gapBefore}
|
value={gapBefore}
|
||||||
min={0}
|
min={0}
|
||||||
max={career.yearsBeforePlan}
|
max={career.yearsBeforePlan}
|
||||||
@@ -206,12 +206,12 @@ export function AhvReviewFields({
|
|||||||
<DerivedField
|
<DerivedField
|
||||||
label="Massgebendes durchschnittliches Jahreseinkommen"
|
label="Massgebendes durchschnittliches Jahreseinkommen"
|
||||||
value={Math.round(mdJE)}
|
value={Math.round(mdJE)}
|
||||||
help="Gewichtetes Mittel ueber alle Beitragsjahre (vor Planbeginn und im Plan). Ab 90'720 gibt es die Maximalrente."
|
help="Gewichtetes Mittel über alle Beitragsjahre (vor Planbeginn und im Plan). Ab 90'720 gibt es die Maximalrente."
|
||||||
/>
|
/>
|
||||||
<DerivedField
|
<DerivedField
|
||||||
label="Resultierende AHV-Rente pro Jahr"
|
label="Resultierende AHV-Rente pro Jahr"
|
||||||
value={rente}
|
value={rente}
|
||||||
help="Vollrente zum mdJE (Skala 44), inkl. 13. Altersrente, gekuerzt um die Ausfalljahre. Bei Ehepaaren zusaetzlich plafoniert."
|
help="Vollrente zum mdJE (Skala 44), inkl. 13. Altersrente, gekürzt um die Ausfalljahre. Bei Ehepaaren zusätzlich plafoniert."
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -220,7 +220,7 @@ export function AhvReviewFields({
|
|||||||
export function withTransitionDefaults(category: ElementCategory, isRetirement: boolean, td: TransitionData): TransitionData {
|
export function withTransitionDefaults(category: ElementCategory, isRetirement: boolean, td: TransitionData): TransitionData {
|
||||||
const out = { ...td };
|
const out = { ...td };
|
||||||
if (category === "AHV") {
|
if (category === "AHV") {
|
||||||
// Ein Speichern der Pruefung markiert sie als erledigt (gleiches Muster wie bei PK/3a).
|
// Ein Speichern der Prüfung markiert sie als erledigt (gleiches Muster wie bei PK/3a).
|
||||||
if (isRetirement && out.reviewed === undefined) out.reviewed = true;
|
if (isRetirement && out.reviewed === undefined) out.reviewed = true;
|
||||||
} else if (category === "REAL_ESTATE" || category === "OTHER_ASSET") {
|
} else if (category === "REAL_ESTATE" || category === "OTHER_ASSET") {
|
||||||
if (out.decision === undefined) out.decision = "HOLD";
|
if (out.decision === undefined) out.decision = "HOLD";
|
||||||
@@ -236,7 +236,7 @@ export function withTransitionDefaults(category: ElementCategory, isRetirement:
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Cash-Uebergang: einmalige Sonderein-/ausgaben ---
|
// --- Cash-Übergang: einmalige Sonderein-/ausgaben ---
|
||||||
|
|
||||||
export function withCashTransitionDefaults(ct: CashTransitionData): CashTransitionData {
|
export function withCashTransitionDefaults(ct: CashTransitionData): CashTransitionData {
|
||||||
return ct.mode === undefined ? { ...ct, mode: "NONE" } : { ...ct };
|
return ct.mode === undefined ? { ...ct, mode: "NONE" } : { ...ct };
|
||||||
@@ -246,7 +246,7 @@ export function isCashTransitionAnswered(ct: CashTransitionData): boolean {
|
|||||||
return ct.mode !== undefined;
|
return ct.mode !== undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Kurzfassung fuer die Uebergangszelle der Cash-Zeile.
|
// Kurzfassung für die Übergangszelle der Cash-Zeile.
|
||||||
export function cashTransitionSummary(ct: CashTransitionData): string {
|
export function cashTransitionSummary(ct: CashTransitionData): string {
|
||||||
const mode = ct.mode;
|
const mode = ct.mode;
|
||||||
if (mode === undefined) return "?";
|
if (mode === undefined) return "?";
|
||||||
@@ -264,7 +264,7 @@ export function cashTransitionSummary(ct: CashTransitionData): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Eingabefelder fuer den Cash-Entscheid. Erfassungs-Konventionen bewusst wie bei den
|
// Eingabefelder für den Cash-Entscheid. Erfassungs-Konventionen bewusst wie bei den
|
||||||
// laufenden Flows: Zufluss nominal (wie Einkommen), Kosten real (wie Ausgaben).
|
// laufenden Flows: Zufluss nominal (wie Einkommen), Kosten real (wie Ausgaben).
|
||||||
export function CashTransitionFields({
|
export function CashTransitionFields({
|
||||||
ct,
|
ct,
|
||||||
@@ -287,8 +287,8 @@ export function CashTransitionFields({
|
|||||||
<>
|
<>
|
||||||
<div className="sm:col-span-2">
|
<div className="sm:col-span-2">
|
||||||
<SelectField
|
<SelectField
|
||||||
label="Entscheid fuer das Cash-Konto"
|
label="Entscheid für das Cash-Konto"
|
||||||
help="Was passiert beim Uebergang in die naechste Lebensphase mit dem Cash-Bestand?"
|
help="Was passiert beim Übergang in die nächste Lebensphase mit dem Cash-Bestand?"
|
||||||
value={mode}
|
value={mode}
|
||||||
onChange={(v: CashTransitionMode) =>
|
onChange={(v: CashTransitionMode) =>
|
||||||
setC(
|
setC(
|
||||||
@@ -302,7 +302,7 @@ export function CashTransitionFields({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
options={[
|
options={[
|
||||||
{ value: "NONE", label: "1:1 uebernehmen" },
|
{ value: "NONE", label: "1:1 übernehmen" },
|
||||||
{ value: "INFLOW", label: "Einmaliger Zufluss" },
|
{ value: "INFLOW", label: "Einmaliger Zufluss" },
|
||||||
{ value: "OUTFLOW", label: "Einmalige Kosten" },
|
{ value: "OUTFLOW", label: "Einmalige Kosten" },
|
||||||
{ value: "BOTH", label: "Zufluss und Kosten" },
|
{ value: "BOTH", label: "Zufluss und Kosten" },
|
||||||
@@ -314,7 +314,7 @@ export function CashTransitionFields({
|
|||||||
<>
|
<>
|
||||||
<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 bg-surface-2 px-3 py-2 text-xs text-muted">
|
||||||
<strong className="text-fg">Einmaliger Zufluss</strong> (z. B. Erbschaft). Wird NOMINAL erfasst –
|
<strong className="text-fg">Einmaliger Zufluss</strong> (z. B. Erbschaft). Wird NOMINAL erfasst –
|
||||||
der Betrag, der zu diesem Zeitpunkt tatsaechlich aufs Konto kommt.
|
der Betrag, der zu diesem Zeitpunkt tatsächlich aufs Konto kommt.
|
||||||
</div>
|
</div>
|
||||||
<TextField
|
<TextField
|
||||||
label="Bezeichnung"
|
label="Bezeichnung"
|
||||||
@@ -337,13 +337,13 @@ export function CashTransitionFields({
|
|||||||
<DerivedField
|
<DerivedField
|
||||||
label="≈ real (heutige Kaufkraft)"
|
label="≈ real (heutige Kaufkraft)"
|
||||||
value={Math.round(inflowNet / d)}
|
value={Math.round(inflowNet / d)}
|
||||||
help="Netto-Zufluss nach Steuer, zurueckgerechnet auf die Kaufkraft bei Planbeginn. Nur zur Info."
|
help="Netto-Zufluss nach Steuer, zurückgerechnet auf die Kaufkraft bei Planbeginn. Nur zur Info."
|
||||||
/>
|
/>
|
||||||
{taxRate > 0 && (
|
{taxRate > 0 && (
|
||||||
<DerivedField
|
<DerivedField
|
||||||
label="Netto aufs Cash (nominal)"
|
label="Netto aufs Cash (nominal)"
|
||||||
value={inflowNet}
|
value={inflowNet}
|
||||||
help="Betrag abzueglich Steuer. Wird automatisch berechnet."
|
help="Betrag abzüglich Steuer. Wird automatisch berechnet."
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@@ -369,7 +369,7 @@ export function CashTransitionFields({
|
|||||||
<DerivedField
|
<DerivedField
|
||||||
label="≈ nominal (zum Zeitpunkt)"
|
label="≈ nominal (zum Zeitpunkt)"
|
||||||
value={Math.round(num(ct.outflowAmount) * d)}
|
value={Math.round(num(ct.outflowAmount) * d)}
|
||||||
help="Der Betrag, der zu diesem Zeitpunkt tatsaechlich faellig ist. Nur zur Info."
|
help="Der Betrag, der zu diesem Zeitpunkt tatsächlich fällig ist. Nur zur Info."
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -381,7 +381,7 @@ export function CashTransitionFields({
|
|||||||
export function isTransitionAnswered(category: ElementCategory, isRetirement: boolean, td: TransitionData): boolean {
|
export function isTransitionAnswered(category: ElementCategory, isRetirement: boolean, td: TransitionData): boolean {
|
||||||
switch (category) {
|
switch (category) {
|
||||||
case "AHV":
|
case "AHV":
|
||||||
// Nur bei der Pensionierung ist eine Pruefung der Beitragskarriere noetig.
|
// Nur bei der Pensionierung ist eine Prüfung der Beitragskarriere nötig.
|
||||||
return isRetirement ? td.reviewed === true : true;
|
return isRetirement ? td.reviewed === true : true;
|
||||||
case "REAL_ESTATE":
|
case "REAL_ESTATE":
|
||||||
case "OTHER_ASSET":
|
case "OTHER_ASSET":
|
||||||
@@ -395,7 +395,7 @@ export function isTransitionAnswered(category: ElementCategory, isRetirement: bo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Wiederverwendbare Feldgruppen (Detail-Panel, Erstell-Popup, Uebergangs-Review) ---
|
// --- Wiederverwendbare Feldgruppen (Detail-Panel, Erstell-Popup, Übergangs-Review) ---
|
||||||
|
|
||||||
export function ElementPhaseFields({
|
export function ElementPhaseFields({
|
||||||
element,
|
element,
|
||||||
@@ -414,7 +414,7 @@ export function ElementPhaseFields({
|
|||||||
case "EXPENSE": {
|
case "EXPENSE": {
|
||||||
const isIncome = element.category === "INCOME";
|
const isIncome = element.category === "INCOME";
|
||||||
// Basiswert (erstes Jahr). Ab Phase 2 mit dem fortgeschriebenen Wert der Vorphase
|
// Basiswert (erstes Jahr). Ab Phase 2 mit dem fortgeschriebenen Wert der Vorphase
|
||||||
// vorbelegt, aber bewusst aenderbar (Teilzeit, Befoerderung, Jobwechsel …).
|
// vorbelegt, aber bewusst änderbar (Teilzeit, Beförderung, Jobwechsel …).
|
||||||
const baseValue = typeof pd.amount === "number" ? pd.amount : carried ? context.derivedStart : 0;
|
const baseValue = typeof pd.amount === "number" ? pd.amount : carried ? context.derivedStart : 0;
|
||||||
const rate = num(pd.teuerungsausgleich, 0);
|
const rate = num(pd.teuerungsausgleich, 0);
|
||||||
const d = context.deflatorStart || 1;
|
const d = context.deflatorStart || 1;
|
||||||
@@ -424,12 +424,12 @@ export function ElementPhaseFields({
|
|||||||
<>
|
<>
|
||||||
<p className="col-span-2 rounded-lg bg-surface-2 px-3 py-2 text-xs text-muted">
|
<p className="col-span-2 rounded-lg bg-surface-2 px-3 py-2 text-xs text-muted">
|
||||||
{isIncome
|
{isIncome
|
||||||
? "Bitte das NETTO-Einkommen erfassen: der Betrag, der nach allen Lohnabzuegen (AHV/ALV, Pensionskasse, NBU) tatsaechlich aufs Konto kommt – nicht der Bruttolohn. Erfasst wird NOMINAL; der reale Wert (heutige Kaufkraft) erscheint nur zur Info. Fuer die AHV-Rente rechnet das Tool intern auf den Bruttolohn zurueck."
|
? "Bitte das NETTO-Einkommen erfassen: der Betrag, der nach allen Lohnabzügen (AHV/ALV, Pensionskasse, NBU) tatsächlich aufs Konto kommt – nicht der Bruttolohn. Erfasst wird NOMINAL; der reale Wert (heutige Kaufkraft) erscheint nur zur Info. Für die AHV-Rente rechnet das Tool intern auf den Bruttolohn zurück."
|
||||||
: "Ausgaben werden REAL erfasst (in heutiger Kaufkraft). Die Inflation (plan-weit) rechnet daraus automatisch die nominalen Ausgaben – nur zur Info."}
|
: "Ausgaben werden REAL erfasst (in heutiger Kaufkraft). Die Inflation (plan-weit) rechnet daraus automatisch die nominalen Ausgaben – nur zur Info."}
|
||||||
</p>
|
</p>
|
||||||
<MoneyField
|
<MoneyField
|
||||||
label={isIncome ? "Jahreseinkommen NETTO, nominal (erstes Jahr)" : "Jahresausgaben REAL (erstes Jahr, heutige Kaufkraft)"}
|
label={isIncome ? "Jahreseinkommen NETTO, nominal (erstes Jahr)" : "Jahresausgaben REAL (erstes Jahr, heutige Kaufkraft)"}
|
||||||
help={carried ? "Vorbelegt mit dem fortgeschriebenen Wert der Vorphase; hier bewusst aenderbar." : undefined}
|
help={carried ? "Vorbelegt mit dem fortgeschriebenen Wert der Vorphase; hier bewusst änderbar." : undefined}
|
||||||
value={baseValue}
|
value={baseValue}
|
||||||
onChange={(v) => setP({ amount: v })}
|
onChange={(v) => setP({ amount: v })}
|
||||||
/>
|
/>
|
||||||
@@ -439,11 +439,11 @@ export function ElementPhaseFields({
|
|||||||
help="Nur zur Info, wird automatisch berechnet."
|
help="Nur zur Info, wird automatisch berechnet."
|
||||||
/>
|
/>
|
||||||
<NumberField
|
<NumberField
|
||||||
label={isIncome ? "Nominale Lohnerhoehung (%/Jahr)" : "Reale Mehrausgaben (%/Jahr)"}
|
label={isIncome ? "Nominale Lohnerhöhung (%/Jahr)" : "Reale Mehrausgaben (%/Jahr)"}
|
||||||
help={
|
help={
|
||||||
isIncome
|
isIncome
|
||||||
? "Geschaetzte jaehrliche Lohnerhoehung. 0% = Lohn bleibt nominal gleich und verliert real an Kaufkraft."
|
? "Geschätzte jährliche Lohnerhöhung. 0% = Lohn bleibt nominal gleich und verliert real an Kaufkraft."
|
||||||
: "Zusaetzliche reale Mehrausgaben pro Jahr (z. B. weil man sich mehr goennt). Die Inflation kommt separat automatisch dazu. 0% = gleicher Lebensstandard."
|
: "Zusätzliche reale Mehrausgaben pro Jahr (z. B. weil man sich mehr gönnt). Die Inflation kommt separat automatisch dazu. 0% = gleicher Lebensstandard."
|
||||||
}
|
}
|
||||||
step={0.1}
|
step={0.1}
|
||||||
value={rate}
|
value={rate}
|
||||||
@@ -454,25 +454,25 @@ export function ElementPhaseFields({
|
|||||||
}
|
}
|
||||||
case "AHV":
|
case "AHV":
|
||||||
if (!context.ownerWorking) {
|
if (!context.ownerWorking) {
|
||||||
// Sonderfall: bei Planbeginn bereits pensioniert -> es gibt keinen Pensions-Uebergang,
|
// Sonderfall: bei Planbeginn bereits pensioniert -> es gibt keinen Pensions-Übergang,
|
||||||
// an dem die Karriere geprueft werden koennte. Dann hier erfassen (nur erste Phase).
|
// an dem die Karriere geprüft werden könnte. Dann hier erfassen (nur erste Phase).
|
||||||
if (!carried && context.ahvCareer && context.ahvCareer.planYears === 0) {
|
if (!carried && context.ahvCareer && context.ahvCareer.planYears === 0) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<p className="col-span-2 rounded-lg bg-surface-2 px-3 py-2 text-xs text-muted">
|
<p className="col-span-2 rounded-lg bg-surface-2 px-3 py-2 text-xs text-muted">
|
||||||
Diese Person ist bei Planbeginn bereits pensioniert. Die AHV-Rente haengt vom
|
Diese Person ist bei Planbeginn bereits pensioniert. Die AHV-Rente hängt vom
|
||||||
massgebenden durchschnittlichen Jahreseinkommen ueber die ganze Beitragsdauer ab –
|
massgebenden durchschnittlichen Jahreseinkommen über die ganze Beitragsdauer ab –
|
||||||
bitte hier erfassen (REAL, heutige Kaufkraft).
|
bitte hier erfassen (REAL, heutige Kaufkraft).
|
||||||
</p>
|
</p>
|
||||||
<MoneyField
|
<MoneyField
|
||||||
label="Durchschnittliches Bruttoeinkommen der Beitragsjahre (real)"
|
label="Durchschnittliches Bruttöinkommen der Beitragsjahre (real)"
|
||||||
help="BRUTTO und in heutiger Kaufkraft. Am besten aus der AHV-Rentenvorausberechnung bzw. der Rentenverfuegung. Ab 90'720 gibt es die Maximalrente."
|
help="BRUTTO und in heutiger Kaufkraft. Am besten aus der AHV-Rentenvorausberechnung bzw. der Rentenverfügung. Ab 90'720 gibt es die Maximalrente."
|
||||||
value={num(pd.avgIncomeBefore)}
|
value={num(pd.avgIncomeBefore)}
|
||||||
onChange={(v) => setP({ avgIncomeBefore: v })}
|
onChange={(v) => setP({ avgIncomeBefore: v })}
|
||||||
/>
|
/>
|
||||||
<NumberField
|
<NumberField
|
||||||
label="Ausfalljahre total"
|
label="Ausfalljahre total"
|
||||||
help="Jahre ohne AHV-Beitraege. Jedes kuerzt die Rente um 1/44."
|
help="Jahre ohne AHV-Beiträge. Jedes kürzt die Rente um 1/44."
|
||||||
value={num(pd.gapYearsBefore)}
|
value={num(pd.gapYearsBefore)}
|
||||||
min={0}
|
min={0}
|
||||||
onChange={(v) => setP({ gapYearsBefore: Math.max(0, Math.round(v)) })}
|
onChange={(v) => setP({ gapYearsBefore: Math.max(0, Math.round(v)) })}
|
||||||
@@ -480,14 +480,14 @@ export function ElementPhaseFields({
|
|||||||
<DerivedField
|
<DerivedField
|
||||||
label="Resultierende AHV-Rente pro Jahr"
|
label="Resultierende AHV-Rente pro Jahr"
|
||||||
value={ahvAnnualPension(num(pd.avgIncomeBefore), num(pd.gapYearsBefore))}
|
value={ahvAnnualPension(num(pd.avgIncomeBefore), num(pd.gapYearsBefore))}
|
||||||
help="Inkl. 13. Altersrente. Bei Ehepaaren zusaetzlich plafoniert."
|
help="Inkl. 13. Altersrente. Bei Ehepaaren zusätzlich plafoniert."
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<p className="col-span-2 text-sm text-muted">
|
<p className="col-span-2 text-sm text-muted">
|
||||||
Die AHV-Rente wird aus der beim Pensions-Uebergang geprueften Beitragskarriere berechnet
|
Die AHV-Rente wird aus der beim Pensions-Übergang geprüften Beitragskarriere berechnet
|
||||||
(massgebendes Durchschnittseinkommen und Ausfalljahre). Bei Ehepaaren greift die
|
(massgebendes Durchschnittseinkommen und Ausfalljahre). Bei Ehepaaren greift die
|
||||||
Plafonierung auf 150% der Maximalrente.
|
Plafonierung auf 150% der Maximalrente.
|
||||||
</p>
|
</p>
|
||||||
@@ -496,7 +496,7 @@ export function ElementPhaseFields({
|
|||||||
return (
|
return (
|
||||||
<NumberField
|
<NumberField
|
||||||
label="Geplante Ausfalljahre"
|
label="Geplante Ausfalljahre"
|
||||||
help="Jahre ohne AHV-Beitraege in dieser Phase. Jedes Ausfalljahr kuerzt die spaetere Rente um 1/44."
|
help="Jahre ohne AHV-Beiträge in dieser Phase. Jedes Ausfalljahr kürzt die spätere Rente um 1/44."
|
||||||
value={num(pd.gapYears)}
|
value={num(pd.gapYears)}
|
||||||
min={0}
|
min={0}
|
||||||
max={context.durationYears}
|
max={context.durationYears}
|
||||||
@@ -507,8 +507,8 @@ export function ElementPhaseFields({
|
|||||||
if (!context.ownerWorking) {
|
if (!context.ownerWorking) {
|
||||||
return (
|
return (
|
||||||
<p className="col-span-2 text-sm text-muted">
|
<p className="col-span-2 text-sm text-muted">
|
||||||
Die PK-Rente wird aus dem beim Pensions-Uebergang gewaehlten Umwandlungssatz berechnet (siehe
|
Die PK-Rente wird aus dem beim Pensions-Übergang gewählten Umwandlungssatz berechnet (siehe
|
||||||
Kennzahl). Bei reinem Kapitalbezug erscheint hier "Vollstaendig bezogen".
|
Kennzahl). Bei reinem Kapitalbezug erscheint hier "Vollständig bezogen".
|
||||||
</p>
|
</p>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -519,7 +519,7 @@ export function ElementPhaseFields({
|
|||||||
<DerivedField label="Startwert (fortgeschrieben)" value={context.derivedStart} help="Endwert der Vorphase, fortgeschrieben." />
|
<DerivedField label="Startwert (fortgeschrieben)" value={context.derivedStart} help="Endwert der Vorphase, fortgeschrieben." />
|
||||||
<MoneyField
|
<MoneyField
|
||||||
label="Zusatzeinlage aus Kapital (CHF)"
|
label="Zusatzeinlage aus Kapital (CHF)"
|
||||||
help="Aufstockung aus dem verfuegbaren Kapital dieser Phase."
|
help="Aufstockung 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 })}
|
||||||
@@ -529,8 +529,8 @@ export function ElementPhaseFields({
|
|||||||
<MoneyField label="Aktueller PK-Wert (CHF)" value={num(pd.currentValue)} onChange={(v) => setP({ currentValue: v })} />
|
<MoneyField label="Aktueller PK-Wert (CHF)" value={num(pd.currentValue)} onChange={(v) => setP({ currentValue: v })} />
|
||||||
)}
|
)}
|
||||||
<MoneyField
|
<MoneyField
|
||||||
label="Jaehrliche Einzahlung (CHF)"
|
label="Jährliche Einzahlung (CHF)"
|
||||||
help="Arbeitnehmer- und Arbeitgeberbeitraege. Fliesst NICHT in die Sparquote ein (bereits in den Ausgaben beruecksichtigt)."
|
help="Arbeitnehmer- und Arbeitgeberbeiträge. Fliesst NICHT in die Sparquote ein (bereits in den Ausgaben berücksichtigt)."
|
||||||
value={num(pd.annualContribution)}
|
value={num(pd.annualContribution)}
|
||||||
onChange={(v) => setP({ annualContribution: v })}
|
onChange={(v) => setP({ annualContribution: v })}
|
||||||
/>
|
/>
|
||||||
@@ -539,7 +539,7 @@ export function ElementPhaseFields({
|
|||||||
);
|
);
|
||||||
case "PILLAR_3A":
|
case "PILLAR_3A":
|
||||||
if (!context.ownerWorking) {
|
if (!context.ownerWorking) {
|
||||||
return <p className="col-span-2 text-sm text-muted">Die Saeule 3a wird beim Pensions-Uebergang vollstaendig bezogen.</p>;
|
return <p className="col-span-2 text-sm text-muted">Die Säule 3a wird beim Pensions-Übergang vollständig bezogen.</p>;
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -548,7 +548,7 @@ export function ElementPhaseFields({
|
|||||||
<DerivedField label="Startwert (fortgeschrieben)" value={context.derivedStart} help="Endwert der Vorphase, fortgeschrieben." />
|
<DerivedField label="Startwert (fortgeschrieben)" value={context.derivedStart} help="Endwert der Vorphase, fortgeschrieben." />
|
||||||
<MoneyField
|
<MoneyField
|
||||||
label="Zusatzeinlage aus Kapital (CHF)"
|
label="Zusatzeinlage aus Kapital (CHF)"
|
||||||
help="Aufstockung aus dem verfuegbaren Kapital dieser Phase."
|
help="Aufstockung 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 })}
|
||||||
@@ -558,8 +558,8 @@ export function ElementPhaseFields({
|
|||||||
<MoneyField label="Aktueller 3a-Wert (CHF)" value={num(pd.currentValue)} onChange={(v) => setP({ currentValue: v })} />
|
<MoneyField label="Aktueller 3a-Wert (CHF)" value={num(pd.currentValue)} onChange={(v) => setP({ currentValue: v })} />
|
||||||
)}
|
)}
|
||||||
<MoneyField
|
<MoneyField
|
||||||
label="Jaehrliche Einzahlung (CHF)"
|
label="Jährliche Einzahlung (CHF)"
|
||||||
help={`Maximal CHF ${PILLAR_3A_MAX_ANNUAL.toLocaleString("de-CH")} (2026, mit PK) und hoechstens die Sparquote. Wird von der Sparquote abgezogen.`}
|
help={`Maximal CHF ${PILLAR_3A_MAX_ANNUAL.toLocaleString("de-CH")} (2026, mit PK) und höchstens die Sparquote. Wird von der Sparquote abgezogen.`}
|
||||||
value={num(pd.annualContribution)}
|
value={num(pd.annualContribution)}
|
||||||
max={PILLAR_3A_MAX_ANNUAL}
|
max={PILLAR_3A_MAX_ANNUAL}
|
||||||
onChange={(v) => setP({ annualContribution: v })}
|
onChange={(v) => setP({ annualContribution: v })}
|
||||||
@@ -594,7 +594,7 @@ export function ElementPhaseFields({
|
|||||||
)}
|
)}
|
||||||
<MoneyField
|
<MoneyField
|
||||||
label="Amortisation (CHF/Jahr)"
|
label="Amortisation (CHF/Jahr)"
|
||||||
help="Jaehrliche Reduktion der Hypothek. Zaehlt gegen die Sparquote und endet, sobald die Hypothek abbezahlt ist."
|
help="Jährliche Reduktion der Hypothek. Zählt gegen die Sparquote und endet, sobald die Hypothek abbezahlt ist."
|
||||||
value={num(pd.amortization)}
|
value={num(pd.amortization)}
|
||||||
onChange={(v) => setP({ amortization: v })}
|
onChange={(v) => setP({ amortization: v })}
|
||||||
/>
|
/>
|
||||||
@@ -615,7 +615,7 @@ export function ElementPhaseFields({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<NumberField
|
<NumberField
|
||||||
label="Geschaetzte Wertsteigerung (%/Jahr)"
|
label="Geschätzte Wertsteigerung (%/Jahr)"
|
||||||
help="Wirkt auf den Wert der LIEGENSCHAFT, nicht auf das Eigenkapital. 1% von 1 Mio sind 10'000 pro Jahr – bei 100'000 Eigenkapital also 10% darauf (Hebel)."
|
help="Wirkt auf den Wert der LIEGENSCHAFT, nicht auf das Eigenkapital. 1% von 1 Mio sind 10'000 pro Jahr – bei 100'000 Eigenkapital also 10% darauf (Hebel)."
|
||||||
step={0.1}
|
step={0.1}
|
||||||
value={num(pd.valueGrowth)}
|
value={num(pd.valueGrowth)}
|
||||||
@@ -624,12 +624,12 @@ export function ElementPhaseFields({
|
|||||||
<div className="sm:col-span-2">
|
<div className="sm:col-span-2">
|
||||||
<SelectField
|
<SelectField
|
||||||
label="Sind die Hypothekarzinsen bereits in den Ausgaben enthalten?"
|
label="Sind die Hypothekarzinsen bereits in den Ausgaben enthalten?"
|
||||||
help="Damit die Zinsen nicht doppelt zaehlen. Standard: bereits enthalten – so verhalten sich bestehende Plaene unveraendert."
|
help="Damit die Zinsen nicht doppelt zählen. Standard: bereits enthalten – so verhalten sich bestehende Pläne unverändert."
|
||||||
value={handling}
|
value={handling}
|
||||||
onChange={(v: "INCLUDED" | "ADD") => setP({ interestHandling: v })}
|
onChange={(v: "INCLUDED" | "ADD") => setP({ interestHandling: v })}
|
||||||
options={[
|
options={[
|
||||||
{ value: "INCLUDED", label: "Ja – bereits im Ausgaben-Element beruecksichtigt" },
|
{ value: "INCLUDED", label: "Ja – bereits im Ausgaben-Element berücksichtigt" },
|
||||||
{ value: "ADD", label: "Nein – bitte zu den Ausgaben dazuzaehlen" },
|
{ value: "ADD", label: "Nein – bitte zu den Ausgaben dazuzählen" },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -644,7 +644,7 @@ export function ElementPhaseFields({
|
|||||||
<DerivedField label="Startwert (fortgeschrieben)" value={context.derivedStart} help="Endwert der Vorphase, fortgeschrieben." />
|
<DerivedField label="Startwert (fortgeschrieben)" value={context.derivedStart} help="Endwert der Vorphase, fortgeschrieben." />
|
||||||
<MoneyField
|
<MoneyField
|
||||||
label="Zusatzinvestition aus Kapital (CHF)"
|
label="Zusatzinvestition aus Kapital (CHF)"
|
||||||
help="Neuinvestition aus dem verfuegbaren 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 })}
|
||||||
@@ -655,14 +655,14 @@ export function ElementPhaseFields({
|
|||||||
)}
|
)}
|
||||||
<NumberField label="Erwartete Rendite (%/Jahr)" step={0.1} value={num(pd.expectedReturn)} onChange={(v) => setP({ expectedReturn: v })} />
|
<NumberField label="Erwartete Rendite (%/Jahr)" step={0.1} value={num(pd.expectedReturn)} onChange={(v) => setP({ expectedReturn: v })} />
|
||||||
<MoneyField
|
<MoneyField
|
||||||
label="Jaehrlicher Sparbeitrag (CHF)"
|
label="Jährlicher Sparbeitrag (CHF)"
|
||||||
help="Flacher Jahresbetrag, der ins Vermoegen fliesst und vom Cash abgezogen wird (geplante Sparrate)."
|
help="Flacher Jahresbetrag, der ins Vermögen fliesst und vom Cash abgezogen wird (geplante Sparrate)."
|
||||||
value={num(pd.annualContribution)}
|
value={num(pd.annualContribution)}
|
||||||
onChange={(v) => setP({ annualContribution: v })}
|
onChange={(v) => setP({ annualContribution: v })}
|
||||||
/>
|
/>
|
||||||
<MoneyField
|
<MoneyField
|
||||||
label="Jaehrliche Bezugsrate (CHF)"
|
label="Jährliche Bezugsrate (CHF)"
|
||||||
help="Entnahme aus dem Vermoegen (z. B. laufende Renten-Entnahme im Alter). Mindert das Vermoegen und fliesst jaehrlich ins Cash (geplante Verzehrrate)."
|
help="Entnahme aus dem Vermögen (z. B. laufende Renten-Entnahme im Alter). Mindert das Vermögen und fliesst jährlich ins Cash (geplante Verzehrrate)."
|
||||||
value={num(pd.annualWithdrawal)}
|
value={num(pd.annualWithdrawal)}
|
||||||
onChange={(v) => setP({ annualWithdrawal: v })}
|
onChange={(v) => setP({ annualWithdrawal: v })}
|
||||||
/>
|
/>
|
||||||
@@ -677,8 +677,8 @@ export function ElementPhaseFields({
|
|||||||
<MoneyField label="Restschuld (CHF)" value={num(pd.startValue)} onChange={(v) => setP({ startValue: v })} />
|
<MoneyField label="Restschuld (CHF)" value={num(pd.startValue)} onChange={(v) => setP({ startValue: v })} />
|
||||||
)}
|
)}
|
||||||
<MoneyField
|
<MoneyField
|
||||||
label="Jaehrliche Tilgung (CHF)"
|
label="Jährliche Tilgung (CHF)"
|
||||||
help="Zaehlt gegen die Sparquote (max. die Sparquote)."
|
help="Zählt gegen die Sparquote (max. die Sparquote)."
|
||||||
value={num(pd.annualRepayment)}
|
value={num(pd.annualRepayment)}
|
||||||
|
|
||||||
onChange={(v) => setP({ annualRepayment: v })}
|
onChange={(v) => setP({ annualRepayment: v })}
|
||||||
@@ -704,8 +704,8 @@ export function ElementTransitionFields({
|
|||||||
case "EXPENSE":
|
case "EXPENSE":
|
||||||
return (
|
return (
|
||||||
<p className="col-span-2 text-sm text-muted">
|
<p className="col-span-2 text-sm text-muted">
|
||||||
Fuer diese Kategorie gibt es im Uebergang keine Eingaben. Die Werte werden 1:1 in die naechste
|
Für diese Kategorie gibt es im Übergang keine Eingaben. Die Werte werden 1:1 in die nächste
|
||||||
Lebensphase uebernommen und koennen dort angepasst werden.
|
Lebensphase übernommen und können dort angepasst werden.
|
||||||
</p>
|
</p>
|
||||||
);
|
);
|
||||||
case "AHV":
|
case "AHV":
|
||||||
@@ -714,7 +714,7 @@ export function ElementTransitionFields({
|
|||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<p className="col-span-2 text-sm text-muted">
|
<p className="col-span-2 text-sm text-muted">
|
||||||
Die Beitragskarriere wird erst beim Uebergang in die Pensionierung geprueft.
|
Die Beitragskarriere wird erst beim Übergang in die Pensionierung geprüft.
|
||||||
</p>
|
</p>
|
||||||
);
|
);
|
||||||
case "PENSION_FUND":
|
case "PENSION_FUND":
|
||||||
@@ -735,7 +735,7 @@ export function ElementTransitionFields({
|
|||||||
{(mode === "PENSION" || mode === "COMBI") && (
|
{(mode === "PENSION" || mode === "COMBI") && (
|
||||||
<NumberField
|
<NumberField
|
||||||
label="Umwandlungssatz (%)"
|
label="Umwandlungssatz (%)"
|
||||||
help="Jaehrliche Rente = verrentetes Kapital x Umwandlungssatz."
|
help="Jährliche Rente = verrentetes Kapital x Umwandlungssatz."
|
||||||
step={0.1}
|
step={0.1}
|
||||||
value={num(td.conversionRate, DEFAULT_PK_CONVERSION_RATE)}
|
value={num(td.conversionRate, DEFAULT_PK_CONVERSION_RATE)}
|
||||||
onChange={(v) => setT({ conversionRate: v })}
|
onChange={(v) => setT({ conversionRate: v })}
|
||||||
@@ -762,7 +762,7 @@ export function ElementTransitionFields({
|
|||||||
return (
|
return (
|
||||||
<NumberField
|
<NumberField
|
||||||
label="Kapitalbezugssteuer (%)"
|
label="Kapitalbezugssteuer (%)"
|
||||||
help="Die Saeule 3a wird bei Pensionierung vollstaendig bezogen."
|
help="Die Säule 3a wird bei Pensionierung vollständig bezogen."
|
||||||
step={0.5}
|
step={0.5}
|
||||||
value={num(td.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE)}
|
value={num(td.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE)}
|
||||||
onChange={(v) => setT({ capitalTaxRate: v })}
|
onChange={(v) => setT({ capitalTaxRate: v })}
|
||||||
@@ -775,7 +775,7 @@ export function ElementTransitionFields({
|
|||||||
const marktwert = Math.round(context.propertyValueEnd);
|
const marktwert = Math.round(context.propertyValueEnd);
|
||||||
const preis = num(td.salePrice);
|
const preis = num(td.salePrice);
|
||||||
// Abweichung zwischen erfasstem Verkaufspreis und modelliertem Verkehrswert. Beide
|
// Abweichung zwischen erfasstem Verkaufspreis und modelliertem Verkehrswert. Beide
|
||||||
// Groessen sind unabhaengig erfassbar -- ohne diesen Vergleich koennte man 2 %
|
// Grössen sind unabhängig erfassbar -- ohne diesen Vergleich könnte man 2 %
|
||||||
// Wertsteigerung annehmen und trotzdem zum Kaufpreis verkaufen, ohne es zu merken.
|
// Wertsteigerung annehmen und trotzdem zum Kaufpreis verkaufen, ohne es zu merken.
|
||||||
const abweichung = preis - marktwert;
|
const abweichung = preis - marktwert;
|
||||||
const abweichungPct = marktwert > 0 ? (abweichung / marktwert) * 100 : 0;
|
const abweichungPct = marktwert > 0 ? (abweichung / marktwert) * 100 : 0;
|
||||||
@@ -833,13 +833,13 @@ export function ElementTransitionFields({
|
|||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<NumberField label="Grundstueckgewinnsteuer (%)" step={1} value={num(td.saleTaxRate, DEFAULT_PROPERTY_GAINS_TAX_RATE)} onChange={(v) => setT({ saleTaxRate: v })} />
|
<NumberField label="Grundstückgewinnsteuer (%)" step={1} value={num(td.saleTaxRate, DEFAULT_PROPERTY_GAINS_TAX_RATE)} onChange={(v) => setT({ saleTaxRate: v })} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{decision !== "SELL" && context.mortgageEnd > 0 && (
|
{decision !== "SELL" && context.mortgageEnd > 0 && (
|
||||||
<MoneyField
|
<MoneyField
|
||||||
label="Sonderamortisation (CHF)"
|
label="Sonderamortisation (CHF)"
|
||||||
help={`Einmaltilgung der Hypothek aus dem Cash der naechsten Phase (z. B. aus einem 3a-Bezug). Senkt Restschuld und kuenftige Zinsen. Maximal ${formatChf(context.mortgageEnd)} (Resthypothek am Phasenende).`}
|
help={`Einmaltilgung der Hypothek aus dem Cash der nächsten Phase (z. B. aus einem 3a-Bezug). Senkt Restschuld und künftige Zinsen. Maximal ${formatChf(context.mortgageEnd)} (Resthypothek am Phasenende).`}
|
||||||
value={num(td.extraAmortization)}
|
value={num(td.extraAmortization)}
|
||||||
max={context.mortgageEnd}
|
max={context.mortgageEnd}
|
||||||
onChange={(v) => setT({ extraAmortization: v })}
|
onChange={(v) => setT({ extraAmortization: v })}
|
||||||
@@ -865,7 +865,7 @@ export function ElementTransitionFields({
|
|||||||
{decision === "PARTIAL" && (
|
{decision === "PARTIAL" && (
|
||||||
<MoneyField
|
<MoneyField
|
||||||
label="Teilverkauf (CHF)"
|
label="Teilverkauf (CHF)"
|
||||||
help={`Betrag, der am Uebergang ins Cash fliesst. Der Rest bleibt investiert. Maximal ${formatChf(context.carriedEndValue)} (Endwert der Vorphase).`}
|
help={`Betrag, der am Übergang ins Cash fliesst. Der Rest bleibt investiert. Maximal ${formatChf(context.carriedEndValue)} (Endwert der Vorphase).`}
|
||||||
value={num(td.partialSaleAmount)}
|
value={num(td.partialSaleAmount)}
|
||||||
max={context.carriedEndValue}
|
max={context.carriedEndValue}
|
||||||
onChange={(v) => setT({ partialSaleAmount: v })}
|
onChange={(v) => setT({ partialSaleAmount: v })}
|
||||||
@@ -878,7 +878,7 @@ export function ElementTransitionFields({
|
|||||||
return (
|
return (
|
||||||
<MoneyField
|
<MoneyField
|
||||||
label="Sofortige Tilgung (CHF)"
|
label="Sofortige Tilgung (CHF)"
|
||||||
help="Wird sofort getilgt und vom verfuegbaren Kapital der naechsten Phase abgezogen."
|
help="Wird sofort getilgt und vom verfügbaren Kapital der nächsten Phase abgezogen."
|
||||||
value={num(td.immediateRepayment)}
|
value={num(td.immediateRepayment)}
|
||||||
onChange={(v) => setT({ immediateRepayment: v })}
|
onChange={(v) => setT({ immediateRepayment: v })}
|
||||||
/>
|
/>
|
||||||
@@ -903,8 +903,8 @@ export function ElementDetail({ element, context, phaseData, transitionData, onS
|
|||||||
} else {
|
} else {
|
||||||
const payload: PhaseData = { ...pd };
|
const payload: PhaseData = { ...pd };
|
||||||
// Einkommen/Ausgaben ab Phase 2: entspricht der Basiswert dem fortgeschriebenen Wert
|
// Einkommen/Ausgaben ab Phase 2: entspricht der Basiswert dem fortgeschriebenen Wert
|
||||||
// der Vorphase, KEINEN Override speichern -> Wert bleibt live vererbt (Aenderungen in
|
// der Vorphase, KEINEN Override speichern -> Wert bleibt live vererbt (Änderungen in
|
||||||
// frueheren Phasen wirken sich weiter aus). Nur ein bewusst abweichender Wert wird fix.
|
// früheren Phasen wirken sich weiter aus). Nur ein bewusst abweichender Wert wird fix.
|
||||||
if (
|
if (
|
||||||
(element.category === "INCOME" || element.category === "EXPENSE") &&
|
(element.category === "INCOME" || element.category === "EXPENSE") &&
|
||||||
context.carried &&
|
context.carried &&
|
||||||
@@ -936,7 +936,7 @@ export function ElementDetail({ element, context, phaseData, transitionData, onS
|
|||||||
<div>
|
<div>
|
||||||
<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">
|
||||||
{CATEGORY_LABELS[element.category]}
|
{CATEGORY_LABELS[element.category]}
|
||||||
{isTransition ? " · Uebergang" : ""}
|
{isTransition ? " · Übergang" : ""}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-base font-semibold text-fg">{element.name}</div>
|
<div className="text-base font-semibold text-fg">{element.name}</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -945,12 +945,12 @@ export function ElementDetail({ element, context, phaseData, transitionData, onS
|
|||||||
onClick={onDeleteElement}
|
onClick={onDeleteElement}
|
||||||
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"
|
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 loeschen
|
<Trash2 className="h-3.5 w-3.5" /> Element löschen
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
{/* Punkt 4: Warnung, dass Aenderungen sich in die Folgephasen fortschreiben. */}
|
{/* Punkt 4: Warnung, dass Änderungen sich in die Folgephasen fortschreiben. */}
|
||||||
{!isTransition && <CarryWarning laterPhaseCount={context.laterPhaseCount} />}
|
{!isTransition && <CarryWarning laterPhaseCount={context.laterPhaseCount} />}
|
||||||
{isTransition ? (
|
{isTransition ? (
|
||||||
<ElementTransitionFields element={element} context={context} td={td} setT={setT} />
|
<ElementTransitionFields element={element} context={context} td={td} setT={setT} />
|
||||||
|
|||||||
@@ -51,10 +51,10 @@ export function NumberField({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pflicht-Zahlenfeld, das wirklich LEER sein kann (NumberField erzwingt eine Zahl und haette
|
// Pflicht-Zahlenfeld, das wirklich LEER sein kann (NumberField erzwingt eine Zahl und hätte
|
||||||
// damit immer einen Default -- genau das soll bei Annahmen vermieden werden, siehe
|
// damit immer einen Default -- genau das soll bei Annahmen vermieden werden, siehe
|
||||||
// SPEZIFIKATION 9.15/9.18: ein stiller Default wird nicht hinterfragt). Der Wert wird als
|
// SPEZIFIKATION 9.15/9.18: ein stiller Default wird nicht hinterfragt). Der Wert wird als
|
||||||
// String gefuehrt; die Validierung liegt beim Aufrufer.
|
// String geführt; die Validierung liegt beim Aufrufer.
|
||||||
export function RequiredNumberField({
|
export function RequiredNumberField({
|
||||||
label,
|
label,
|
||||||
help,
|
help,
|
||||||
@@ -148,7 +148,7 @@ export function MoneyInput({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Klick = 1 Schritt. Halten: nach 400 ms Wiederholung im 70-ms-Takt, wobei die
|
// Klick = 1 Schritt. Halten: nach 400 ms Wiederholung im 70-ms-Takt, wobei die
|
||||||
// Schrittweite mit der Haltedauer waechst (immer schneller).
|
// Schrittweite mit der Haltedauer wächst (immer schneller).
|
||||||
function startHold(sign: number) {
|
function startHold(sign: number) {
|
||||||
stepBy(sign);
|
stepBy(sign);
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
@@ -177,7 +177,7 @@ export function MoneyInput({
|
|||||||
onFocus={() => {
|
onFocus={() => {
|
||||||
setFocused(true);
|
setFocused(true);
|
||||||
const current = Math.round(value || 0);
|
const current = Math.round(value || 0);
|
||||||
// Default-0 sofort leeren; sonst Wert markieren, damit man ihn ueberschreiben kann.
|
// Default-0 sofort leeren; sonst Wert markieren, damit man ihn überschreiben kann.
|
||||||
setText(current === 0 ? "" : String(current));
|
setText(current === 0 ? "" : String(current));
|
||||||
requestAnimationFrame(() => inputRef.current?.select());
|
requestAnimationFrame(() => inputRef.current?.select());
|
||||||
}}
|
}}
|
||||||
@@ -200,7 +200,7 @@ export function MoneyInput({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
aria-label="Erhoehen"
|
aria-label="Erhöhen"
|
||||||
onMouseDown={(e) => {
|
onMouseDown={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
startHold(1);
|
startHold(1);
|
||||||
|
|||||||
@@ -27,8 +27,8 @@ import { CATEGORY_LABELS } from "@/lib/elements";
|
|||||||
import type { PlanInput, ScenarioMeta } from "@/lib/types";
|
import type { PlanInput, ScenarioMeta } from "@/lib/types";
|
||||||
import type { PlanComputed } from "@/lib/calculations";
|
import type { PlanComputed } from "@/lib/calculations";
|
||||||
|
|
||||||
// Farben der Szenario-Serien -- wie im Vermoegensverlauf, damit die Zuordnung vertraut bleibt.
|
// Farben der Szenario-Serien -- wie im Vermögensverlauf, damit die Zuordnung vertraut bleibt.
|
||||||
const PALETTE = ["#4f46e5", "#0ea5e9", "#16a34a", "#d97706", "#dc2626", "#7c3aed"];
|
const PALETTE = ["#4f46e5", "#0ea5e9", "#16a34a", "#d97706", "#dc2626", "#7c3äd"];
|
||||||
|
|
||||||
const RETURN_LEVEL_OPTIONS: { value: ReturnVolatilityLevel; label: string }[] = [
|
const RETURN_LEVEL_OPTIONS: { value: ReturnVolatilityLevel; label: string }[] = [
|
||||||
{ value: "sehr_niedrig", label: "Sehr niedrig" },
|
{ value: "sehr_niedrig", label: "Sehr niedrig" },
|
||||||
@@ -92,8 +92,8 @@ export function MonteCarloDialog({
|
|||||||
plan: PlanInput;
|
plan: PlanInput;
|
||||||
computed: PlanComputed;
|
computed: PlanComputed;
|
||||||
meta: ScenarioMeta;
|
meta: ScenarioMeta;
|
||||||
// Alle Szenarien dieses Plans (auch nicht ausgewaehlte) -- sie werden geladen, damit sich
|
// Alle Szenarien dieses Plans (auch nicht ausgewählte) -- sie werden geladen, damit sich
|
||||||
// die Herkunfts-Kette der Elemente auch ueber uebersprungene Zwischen-Szenarien aufloest.
|
// die Herkunfts-Kette der Elemente auch über übersprungene Zwischen-Szenarien auflöst.
|
||||||
scenarios: ScenarioMeta[];
|
scenarios: ScenarioMeta[];
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}) {
|
}) {
|
||||||
@@ -119,8 +119,8 @@ export function MonteCarloDialog({
|
|||||||
|
|
||||||
const scenarioKey = scenarios.map((s) => s.id).join(",");
|
const scenarioKey = scenarios.map((s) => s.id).join(",");
|
||||||
|
|
||||||
// Die uebrigen Szenarien einmalig nachladen. Ein Plan hat realistisch eine Handvoll
|
// Die übrigen Szenarien einmalig nachladen. Ein Plan hat realistisch eine Handvoll
|
||||||
// Szenarien -- gegenueber tausenden Simulationslaeufen faellt das nicht ins Gewicht.
|
// Szenarien -- gegenüber tausenden Simulationsläufen fällt das nicht ins Gewicht.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const missing = scenarios.filter((s) => s.id !== meta.id);
|
const missing = scenarios.filter((s) => s.id !== meta.id);
|
||||||
if (missing.length === 0) return;
|
if (missing.length === 0) return;
|
||||||
@@ -156,7 +156,7 @@ export function MonteCarloDialog({
|
|||||||
[scenarios, loaded]
|
[scenarios, loaded]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Ein Parametersatz je LOGISCHEM Element (ueber die sourceElementId-Kette zusammengefasst).
|
// Ein Parametersatz je LOGISCHEM Element (über die sourceElementId-Kette zusammengefasst).
|
||||||
const groups = useMemo(
|
const groups = useMemo(
|
||||||
() => buildElementGroups(allLoaded, selectedIds),
|
() => buildElementGroups(allLoaded, selectedIds),
|
||||||
[allLoaded, selectedIds]
|
[allLoaded, selectedIds]
|
||||||
@@ -195,8 +195,8 @@ export function MonteCarloDialog({
|
|||||||
return [g.rootId, { mean: Number(d.mean) || 0, sigma: returnSigma(d), floor: floorFor(g.category) }];
|
return [g.rootId, { mean: Number(d.mean) || 0, sigma: returnSigma(d), floor: floorFor(g.category) }];
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
// EIN Seed fuer alle Szenarien: strukturgleiche Szenarien teilen damit dieselben
|
// EIN Seed für alle Szenarien: strukturgleiche Szenarien teilen damit dieselben
|
||||||
// Marktpfade, und die Unterschiede sind strukturell statt zufaellig.
|
// Marktpfade, und die Unterschiede sind strukturell statt zufällig.
|
||||||
const seed = (Math.random() * 2 ** 32) >>> 0;
|
const seed = (Math.random() * 2 ** 32) >>> 0;
|
||||||
const runInputs: ScenarioRunInput[] = selected.map((s) => ({
|
const runInputs: ScenarioRunInput[] = selected.map((s) => ({
|
||||||
scenarioId: s.id,
|
scenarioId: s.id,
|
||||||
@@ -239,7 +239,7 @@ export function MonteCarloDialog({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Erklaerung */}
|
{/* Erklärung */}
|
||||||
<div className="rounded-xl border border-border bg-surface-2 p-4 text-xs leading-relaxed text-muted">
|
<div className="rounded-xl border border-border bg-surface-2 p-4 text-xs leading-relaxed text-muted">
|
||||||
<p className="mb-2">
|
<p className="mb-2">
|
||||||
<strong className="text-fg">Was ist das?</strong> Dein Plan rechnet mit einer festen Rendite und Inflation
|
<strong className="text-fg">Was ist das?</strong> Dein Plan rechnet mit einer festen Rendite und Inflation
|
||||||
@@ -479,7 +479,7 @@ function MonteCarloResults({
|
|||||||
}) {
|
}) {
|
||||||
const single = results.length === 1;
|
const single = results.length === 1;
|
||||||
|
|
||||||
// Einzelnes Szenario: der gewohnte Faecher (Band + Median + Planungslinie).
|
// Einzelnes Szenario: der gewohnte Fächer (Band + Median + Planungslinie).
|
||||||
const singleData = useMemo(() => {
|
const singleData = useMemo(() => {
|
||||||
if (!single) return [];
|
if (!single) return [];
|
||||||
return results[0].bands.map((b) => ({
|
return results[0].bands.map((b) => ({
|
||||||
@@ -490,7 +490,7 @@ function MonteCarloResults({
|
|||||||
}));
|
}));
|
||||||
}, [single, results, detPoints]);
|
}, [single, results, detPoints]);
|
||||||
|
|
||||||
// Mehrere Szenarien: nur die Median-Linien -- uebereinandergelegte Baender waeren Farbbrei.
|
// Mehrere Szenarien: nur die Median-Linien -- übereinandergelegte Bänder wären Farbbrei.
|
||||||
const compareData = useMemo(() => {
|
const compareData = useMemo(() => {
|
||||||
if (single) return [];
|
if (single) return [];
|
||||||
const ages = Array.from(new Set(results.flatMap((r) => r.bands.map((b) => b.age)))).sort((a, b) => a - b);
|
const ages = Array.from(new Set(results.flatMap((r) => r.bands.map((b) => b.age)))).sort((a, b) => a - b);
|
||||||
|
|||||||
@@ -43,12 +43,12 @@ export function PhaseDetail({
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function remove() {
|
async function remove() {
|
||||||
if (!confirm(`Phase "${phase.name}" wirklich loeschen?`)) return;
|
if (!confirm(`Phase "${phase.name}" wirklich löschen?`)) 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 : "Loeschen fehlgeschlagen.");
|
alert(e instanceof Error ? e.message : "Löschen fehlgeschlagen.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,18 +64,18 @@ export function PhaseDetail({
|
|||||||
onClick={remove}
|
onClick={remove}
|
||||||
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"
|
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" /> Phase loeschen
|
<Trash2 className="h-3.5 w-3.5" /> Phase löschen
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
{/* Eine geaenderte Dauer verschiebt alle Folgephasen (Alter, Renten, Vermoegen). */}
|
{/* Eine geänderte Dauer verschiebt alle Folgephasen (Alter, Renten, Vermögen). */}
|
||||||
<CarryWarning laterPhaseCount={laterPhaseCount} />
|
<CarryWarning laterPhaseCount={laterPhaseCount} />
|
||||||
<TextField label="Bezeichnung" value={name} onChange={setName} />
|
<TextField label="Bezeichnung" value={name} onChange={setName} />
|
||||||
<NumberField
|
<NumberField
|
||||||
label={`Dauer (Jahre)${cap != null ? ` · max. ${cap}` : ""}`}
|
label={`Dauer (Jahre)${cap != null ? ` · max. ${cap}` : ""}`}
|
||||||
help={cap != null ? "Die Dauer ist ans naechste Pensionsereignis gekappt." : undefined}
|
help={cap != null ? "Die Dauer ist ans nächste Pensionsereignis gekappt." : undefined}
|
||||||
value={durationYears}
|
value={durationYears}
|
||||||
min={1}
|
min={1}
|
||||||
max={cap ?? undefined}
|
max={cap ?? undefined}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export function emptyProfileDraft(): ProfileDraft {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Gemeinsame Formularfelder fuer das Grundprofil eines Plans (Haushaltsform, Personen,
|
// Gemeinsame Formularfelder für das Grundprofil eines Plans (Haushaltsform, Personen,
|
||||||
// Inflation). Wird beim Plan-Erstellen und in den Plan-Einstellungen verwendet.
|
// Inflation). Wird beim Plan-Erstellen und in den Plan-Einstellungen verwendet.
|
||||||
export function PlanProfileFields({
|
export function PlanProfileFields({
|
||||||
draft,
|
draft,
|
||||||
@@ -51,7 +51,7 @@ export function PlanProfileFields({
|
|||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<SelectField
|
<SelectField
|
||||||
label="Haushaltsform"
|
label="Haushaltsform"
|
||||||
help="Waehlen Sie, ob Sie alleine oder gemeinsam mit einer Partnerin / einem Partner planen."
|
help="Planst du alleine oder gemeinsam mit einer Partnerin / einem Partner?"
|
||||||
value={draft.householdType}
|
value={draft.householdType}
|
||||||
onChange={setType}
|
onChange={setType}
|
||||||
options={[
|
options={[
|
||||||
@@ -63,7 +63,7 @@ export function PlanProfileFields({
|
|||||||
{draft.persons.map((person, index) => (
|
{draft.persons.map((person, index) => (
|
||||||
<div key={person.role} className="grid grid-cols-2 gap-3 rounded-xl border border-border bg-surface-2 p-3">
|
<div key={person.role} className="grid grid-cols-2 gap-3 rounded-xl border border-border bg-surface-2 p-3">
|
||||||
<div className="col-span-2 text-xs font-semibold uppercase tracking-wide text-faint">
|
<div className="col-span-2 text-xs font-semibold uppercase tracking-wide text-faint">
|
||||||
{draft.householdType === "COUPLE" ? (person.role === "PERSON_A" ? "Person A" : "Person B") : "Ihre Angaben"}
|
{draft.householdType === "COUPLE" ? (person.role === "PERSON_A" ? "Person A" : "Person B") : "Deine Angaben"}
|
||||||
</div>
|
</div>
|
||||||
<div className="col-span-2">
|
<div className="col-span-2">
|
||||||
<TextField
|
<TextField
|
||||||
@@ -102,7 +102,7 @@ export function PlanProfileFields({
|
|||||||
|
|
||||||
<NumberField
|
<NumberField
|
||||||
label="Erwartete Inflationsrate (%)"
|
label="Erwartete Inflationsrate (%)"
|
||||||
help="Langfristige Annahme zur jaehrlichen Teuerung. Gilt plan-weit fuer alle Lebensphasen."
|
help="Langfristige Annahme zur jährlichen Teuerung. Gilt plan-weit für alle Lebensphasen."
|
||||||
value={draft.inflationRateDefault}
|
value={draft.inflationRateDefault}
|
||||||
step={0.1}
|
step={0.1}
|
||||||
onChange={(v) => onChange({ ...draft, inflationRateDefault: v })}
|
onChange={(v) => onChange({ ...draft, inflationRateDefault: v })}
|
||||||
|
|||||||
+445
-318
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,419 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
// Geführter Plan-Assistent (Roadmap Nr. 10): "Schritt für Schritt statt leerer Matrix".
|
||||||
|
// Der Wizard fragt in Alltagssprache und erzeugt am Ende Plan, Phasen und Elemente über
|
||||||
|
// die BESTEHENDEN API-Endpunkte -- reine Orchestrierung, keine Änderung an Berechnung
|
||||||
|
// oder Datenmodell.
|
||||||
|
//
|
||||||
|
// Reihenfolge beim Anlegen (wichtig): erst Phase 1, dann Elemente samt Phase-1-Werten,
|
||||||
|
// dann die Folgephasen -- so belegt die Phasen-Route die Folgephasen korrekt vor.
|
||||||
|
//
|
||||||
|
// Ehrliche Grenze: Bricht die Sequenz mittendrin ab (Netzfehler), existiert ein Teil-Plan.
|
||||||
|
// Der ist normal weiterbearbeitbar; ein Rollback bräuchte Backend-Unterstützung und ist
|
||||||
|
// bewusst nicht gebaut (siehe SPEZIFIKATION 9.23).
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { ArrowLeft, ArrowRight, Sparkles } from "lucide-react";
|
||||||
|
import { Modal, Button } from "@/components/ui";
|
||||||
|
import { MoneyField, NumberField, TextField } from "@/components/FormField";
|
||||||
|
import { InfoBubble } from "@/components/InfoBubble";
|
||||||
|
import { PlanProfileFields, emptyProfileDraft, type ProfileDraft } from "@/components/PlanProfileFields";
|
||||||
|
import { api } from "@/lib/api-client";
|
||||||
|
import { maxPhaseDuration } from "@/lib/calculations";
|
||||||
|
import { PILLAR_3A_MAX_ANNUAL } from "@/lib/constants";
|
||||||
|
import type { PhaseData } from "@/lib/elements";
|
||||||
|
|
||||||
|
interface PhaseDraft {
|
||||||
|
name: string;
|
||||||
|
durationYears: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AssetToggles {
|
||||||
|
pk: boolean;
|
||||||
|
p3a: boolean;
|
||||||
|
etf: boolean;
|
||||||
|
realEstate: boolean;
|
||||||
|
debt: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STEP_TITLES = ["Über dich", "Deine Lebensphasen", "Einkommen & Ausgaben", "Vorsorge & Vermögen", "Fertig"];
|
||||||
|
|
||||||
|
// Vorschlag für die Phasenkette: Erwerbsphase bis zum nächsten Pensionsereignis
|
||||||
|
// (dieselbe Kappungslogik wie beim manuellen Anlegen), danach eine Pensionsphase bis ~90.
|
||||||
|
function suggestPhases(draft: ProfileDraft): PhaseDraft[] {
|
||||||
|
const persons = draft.persons.map((p) => ({ role: p.role, age: p.age, retirementAge: p.retirementAge }));
|
||||||
|
const cap = maxPhaseDuration(persons, 0);
|
||||||
|
const phases: PhaseDraft[] = [];
|
||||||
|
let elapsed = 0;
|
||||||
|
if (cap != null && cap > 0) {
|
||||||
|
phases.push({ name: "Erwerbsphase", durationYears: cap });
|
||||||
|
elapsed = cap;
|
||||||
|
// Bei Paaren mit unterschiedlichem Pensionsalter folgt eine Mischphase bis zur
|
||||||
|
// zweiten Pensionierung.
|
||||||
|
const cap2 = maxPhaseDuration(persons, elapsed);
|
||||||
|
if (cap2 != null && cap2 > 0) {
|
||||||
|
phases.push({ name: "Übergangsphase", durationYears: cap2 });
|
||||||
|
elapsed += cap2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const oldest = Math.max(...draft.persons.map((p) => p.age));
|
||||||
|
const pensionYears = Math.max(5, 90 - (oldest + elapsed));
|
||||||
|
phases.push({ name: "Pensionsphase", durationYears: pensionYears });
|
||||||
|
return phases;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PlanWizard({
|
||||||
|
onCreated,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
onCreated: (scenarioId: string) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const [step, setStep] = useState(0);
|
||||||
|
const [planName, setPlanName] = useState("Meine Planung");
|
||||||
|
const [profile, setProfile] = useState<ProfileDraft>(emptyProfileDraft);
|
||||||
|
const [phases, setPhases] = useState<PhaseDraft[] | null>(null);
|
||||||
|
// Einkommen bewusst PRO PERSON: In Paar-Plänen zählt "Gemeinsam" nicht für die
|
||||||
|
// AHV-Rente (SPEZIFIKATION 9.9) -- der Assistent räumt diese Falle von Anfang an aus.
|
||||||
|
const [incomes, setIncomes] = useState<Record<string, number>>({ PERSON_A: 0 });
|
||||||
|
const [expenses, setExpenses] = useState(0);
|
||||||
|
const [initialCash, setInitialCash] = useState(0);
|
||||||
|
const [toggles, setToggles] = useState<AssetToggles>({ pk: false, p3a: false, etf: false, realEstate: false, debt: false });
|
||||||
|
const [pk, setPk] = useState({ value: 0, contribution: 0 });
|
||||||
|
const [p3a, setP3a] = useState({ value: 0, contribution: 0 });
|
||||||
|
const [etf, setEtf] = useState({ value: 0, contribution: 0 });
|
||||||
|
const [re, setRe] = useState({ price: 0, mortgage: 0, amortization: 0, interest: 1.5 });
|
||||||
|
const [debt, setDebt] = useState({ value: 0, repayment: 0 });
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const personLabel = (role: string) => {
|
||||||
|
const p = profile.persons.find((x) => x.role === role);
|
||||||
|
return p?.name?.trim() || (role === "PERSON_A" ? "Person A" : "Person B");
|
||||||
|
};
|
||||||
|
|
||||||
|
function goNext() {
|
||||||
|
if (step === 0) {
|
||||||
|
// Phasenvorschlag aus dem (evtl. geänderten) Profil neu ableiten.
|
||||||
|
setPhases(suggestPhases(profile));
|
||||||
|
const inc: Record<string, number> = {};
|
||||||
|
for (const p of profile.persons) inc[p.role] = incomes[p.role] ?? 0;
|
||||||
|
setIncomes(inc);
|
||||||
|
}
|
||||||
|
setStep((s) => Math.min(s + 1, STEP_TITLES.length - 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function create() {
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const { scenario } = await api.post<{ plan: { id: string }; scenario: { id: string } }>("/api/plans", {
|
||||||
|
name: planName.trim() || "Meine Planung",
|
||||||
|
householdType: profile.householdType,
|
||||||
|
inflationRateDefault: profile.inflationRateDefault,
|
||||||
|
startYear: profile.startYear,
|
||||||
|
persons: profile.persons.map((p) => ({ role: p.role, name: p.name.trim() || null, age: p.age, retirementAge: p.retirementAge })),
|
||||||
|
});
|
||||||
|
const sid = scenario.id;
|
||||||
|
if (initialCash > 0) await api.patch(`/api/scenarios/${sid}`, { initialCash });
|
||||||
|
|
||||||
|
const phaseList = phases ?? suggestPhases(profile);
|
||||||
|
const first = await api.post<{ phase: { id: string } }>(`/api/scenarios/${sid}/phases`, {
|
||||||
|
name: phaseList[0].name,
|
||||||
|
durationYears: phaseList[0].durationYears,
|
||||||
|
});
|
||||||
|
const firstPhaseId = first.phase.id;
|
||||||
|
|
||||||
|
async function addEl(category: string, name: string, ownerRole: string, values?: PhaseData) {
|
||||||
|
const { element } = await api.post<{ element: { id: string } }>(`/api/scenarios/${sid}/elements`, {
|
||||||
|
category,
|
||||||
|
name,
|
||||||
|
ownerRole,
|
||||||
|
});
|
||||||
|
if (values) await api.put(`/api/elements/${element.id}/phase/${firstPhaseId}`, values);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const p of profile.persons) {
|
||||||
|
const amount = incomes[p.role] ?? 0;
|
||||||
|
if (amount > 0) await addEl("INCOME", `Lohn ${personLabel(p.role)}`, p.role, { amount, teuerungsausgleich: 0 });
|
||||||
|
}
|
||||||
|
if (expenses > 0) await addEl("EXPENSE", "Lebenshaltung", "HOUSEHOLD", { amount: expenses, teuerungsausgleich: 0 });
|
||||||
|
for (const p of profile.persons) {
|
||||||
|
await addEl("AHV", profile.persons.length > 1 ? `AHV ${personLabel(p.role)}` : "AHV", p.role, { gapYears: 0 });
|
||||||
|
}
|
||||||
|
if (toggles.pk) {
|
||||||
|
await addEl("PENSION_FUND", "Pensionskasse", "PERSON_A", {
|
||||||
|
currentValue: pk.value,
|
||||||
|
annualContribution: pk.contribution,
|
||||||
|
expectedReturn: 1.5,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (toggles.p3a) {
|
||||||
|
await addEl("PILLAR_3A", "Säule 3a", "PERSON_A", {
|
||||||
|
currentValue: p3a.value,
|
||||||
|
annualContribution: Math.min(p3a.contribution, PILLAR_3A_MAX_ANNUAL),
|
||||||
|
expectedReturn: 2,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (toggles.etf) {
|
||||||
|
await addEl("OTHER_ASSET", "Wertschriften / ETF", "HOUSEHOLD", {
|
||||||
|
startValue: etf.value,
|
||||||
|
annualContribution: etf.contribution,
|
||||||
|
expectedReturn: 5,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (toggles.realEstate) {
|
||||||
|
await addEl("REAL_ESTATE", "Eigenheim", "HOUSEHOLD", {
|
||||||
|
purchasePrice: re.price,
|
||||||
|
mortgage: re.mortgage,
|
||||||
|
amortization: re.amortization,
|
||||||
|
interestRate: re.interest,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (toggles.debt) {
|
||||||
|
await addEl("OTHER_DEBT", "Kredit", "HOUSEHOLD", { startValue: debt.value, annualRepayment: debt.repayment });
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const ph of phaseList.slice(1)) {
|
||||||
|
await api.post(`/api/scenarios/${sid}/phases`, { name: ph.name, durationYears: ph.durationYears });
|
||||||
|
}
|
||||||
|
|
||||||
|
onCreated(sid);
|
||||||
|
} catch (e) {
|
||||||
|
setError(
|
||||||
|
(e instanceof Error ? e.message : "Erstellen fehlgeschlagen.") +
|
||||||
|
" – Falls der Plan teilweise angelegt wurde, findest du ihn in der Seitenleiste und kannst dort normal weiterarbeiten."
|
||||||
|
);
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const workingPersons = profile.persons.filter((p) => p.age < p.retirementAge);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title="Plan erstellen – geführt"
|
||||||
|
subtitle={`Schritt ${step + 1} von ${STEP_TITLES.length}: ${STEP_TITLES[step]}`}
|
||||||
|
onClose={onClose}
|
||||||
|
wide
|
||||||
|
>
|
||||||
|
{/* Fortschritt */}
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{STEP_TITLES.map((_, i) => (
|
||||||
|
<div key={i} className={`h-1 flex-1 rounded-full ${i <= step ? "bg-accent" : "bg-surface-2"}`} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{step === 0 && (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<p className="text-sm text-muted">
|
||||||
|
Ein paar Angaben zu dir – daraus baut das Tool dein Grundgerüst. Alles lässt sich später ändern.
|
||||||
|
</p>
|
||||||
|
<TextField label="Name des Plans" value={planName} onChange={setPlanName} placeholder="z. B. Meine Planung" />
|
||||||
|
<PlanProfileFields draft={profile} onChange={setProfile} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 1 && phases && (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<p className="text-sm text-muted">
|
||||||
|
Deine Planung besteht aus <strong className="text-fg">Lebensphasen</strong> – Abschnitten, in denen deine
|
||||||
|
Finanzen ähnlich laufen. Das Tool schlägt sie dir anhand deines Pensionsalters vor; die Dauer kannst du
|
||||||
|
anpassen.
|
||||||
|
</p>
|
||||||
|
{/* Mini-Vorschau der Kette */}
|
||||||
|
<div className="flex w-full overflow-hidden rounded-lg border border-border">
|
||||||
|
{phases.map((ph, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
style={{ width: `${(ph.durationYears / phases.reduce((s, x) => s + x.durationYears, 0)) * 100}%` }}
|
||||||
|
className={`px-2 py-1.5 text-[10px] font-semibold ${i === 0 && workingPersons.length > 0 ? "bg-accent/70 text-accent-fg" : "bg-accent-soft text-accent-soft-fg"} ${i > 0 ? "border-l border-border" : ""}`}
|
||||||
|
>
|
||||||
|
<span className="block truncate">{ph.name}</span>
|
||||||
|
<span className="opacity-80">{ph.durationYears} J.</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
|
{phases.map((ph, i) => (
|
||||||
|
<NumberField
|
||||||
|
key={i}
|
||||||
|
label={`${ph.name} – Dauer (Jahre)`}
|
||||||
|
value={ph.durationYears}
|
||||||
|
min={1}
|
||||||
|
max={80}
|
||||||
|
onChange={(v) =>
|
||||||
|
setPhases((prev) => prev!.map((x, j) => (j === i ? { ...x, durationYears: Math.max(1, Math.round(v)) } : x)))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] text-faint">
|
||||||
|
Eine Phase darf keine Pensionierung überspannen – die Dauer der Erwerbsphase ist deshalb ans Pensionsalter
|
||||||
|
gekoppelt und wird beim Anlegen automatisch begrenzt.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 2 && (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<p className="text-sm text-muted">Die zwei wichtigsten Zahlen deiner Planung – grob geschätzt reicht für den Anfang.</p>
|
||||||
|
{profile.persons.map((p) => (
|
||||||
|
<MoneyField
|
||||||
|
key={p.role}
|
||||||
|
label={`Was verdient ${personLabel(p.role)} netto pro Jahr? (CHF)`}
|
||||||
|
help="Der Betrag, der nach allen Lohnabzügen tatsächlich aufs Konto kommt – nicht der Bruttolohn. Für die AHV-Rente rechnet das Tool intern hoch."
|
||||||
|
value={incomes[p.role] ?? 0}
|
||||||
|
onChange={(v) => setIncomes((prev) => ({ ...prev, [p.role]: v }))}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<MoneyField
|
||||||
|
label="Was gebt ihr heute pro Jahr aus? (CHF)"
|
||||||
|
help="Alle Lebenshaltungskosten in heutiger Kaufkraft – Miete/Wohnen, Essen, Mobilität, Versicherungen, Steuern, Freizeit. Die Inflation rechnet das Tool automatisch dazu."
|
||||||
|
value={expenses}
|
||||||
|
onChange={setExpenses}
|
||||||
|
/>
|
||||||
|
<MoneyField
|
||||||
|
label="Wie viel liegt auf dem Konto? (CHF)"
|
||||||
|
help="Dein heutiger Kontostand (Cash). Er ist der Startwert des Ausgleichskontos."
|
||||||
|
value={initialCash}
|
||||||
|
onChange={setInitialCash}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 3 && (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<p className="text-sm text-muted">
|
||||||
|
Was besitzt du? Hake an, was auf dich zutrifft – nur das Angehakte fragt nach Details.
|
||||||
|
{profile.persons.length > 1 && " Vorsorge (PK/3a) wird Person A zugeordnet – weitere Konten kannst du später ergänzen."}
|
||||||
|
</p>
|
||||||
|
<WizardToggle
|
||||||
|
label="Pensionskasse"
|
||||||
|
help="Dein PK-Guthaben laut Vorsorgeausweis und die jährlichen Einzahlungen (Arbeitnehmer + Arbeitgeber)."
|
||||||
|
checked={toggles.pk}
|
||||||
|
onChange={(v) => setToggles((t) => ({ ...t, pk: v }))}
|
||||||
|
>
|
||||||
|
<MoneyField label="Aktuelles Guthaben (CHF)" value={pk.value} onChange={(v) => setPk((s) => ({ ...s, value: v }))} />
|
||||||
|
<MoneyField label="Einzahlung pro Jahr (CHF)" value={pk.contribution} onChange={(v) => setPk((s) => ({ ...s, contribution: v }))} />
|
||||||
|
</WizardToggle>
|
||||||
|
<WizardToggle
|
||||||
|
label="Säule 3a"
|
||||||
|
help={`Dein 3a-Guthaben und die jährliche Einzahlung (max. ${PILLAR_3A_MAX_ANNUAL.toLocaleString("de-CH")} CHF).`}
|
||||||
|
checked={toggles.p3a}
|
||||||
|
onChange={(v) => setToggles((t) => ({ ...t, p3a: v }))}
|
||||||
|
>
|
||||||
|
<MoneyField label="Aktuelles Guthaben (CHF)" value={p3a.value} onChange={(v) => setP3a((s) => ({ ...s, value: v }))} />
|
||||||
|
<MoneyField label="Einzahlung pro Jahr (CHF)" value={p3a.contribution} max={PILLAR_3A_MAX_ANNUAL} onChange={(v) => setP3a((s) => ({ ...s, contribution: v }))} />
|
||||||
|
</WizardToggle>
|
||||||
|
<WizardToggle
|
||||||
|
label="Wertschriften / ETF / Sparen"
|
||||||
|
help="Depots, Fonds, Sparkonten – alles, was Rendite bringt und nicht Vorsorge ist."
|
||||||
|
checked={toggles.etf}
|
||||||
|
onChange={(v) => setToggles((t) => ({ ...t, etf: v }))}
|
||||||
|
>
|
||||||
|
<MoneyField label="Aktueller Wert (CHF)" value={etf.value} onChange={(v) => setEtf((s) => ({ ...s, value: v }))} />
|
||||||
|
<MoneyField label="Sparbeitrag pro Jahr (CHF)" value={etf.contribution} onChange={(v) => setEtf((s) => ({ ...s, contribution: v }))} />
|
||||||
|
</WizardToggle>
|
||||||
|
<WizardToggle
|
||||||
|
label="Wohneigentum"
|
||||||
|
help="Kaufpreis, Hypothek und jährliche Amortisation gemäss Hypothekarvertrag."
|
||||||
|
checked={toggles.realEstate}
|
||||||
|
onChange={(v) => setToggles((t) => ({ ...t, realEstate: v }))}
|
||||||
|
>
|
||||||
|
<MoneyField label="Kaufpreis (CHF)" value={re.price} onChange={(v) => setRe((s) => ({ ...s, price: v }))} />
|
||||||
|
<MoneyField label="Hypothek (CHF)" value={re.mortgage} onChange={(v) => setRe((s) => ({ ...s, mortgage: v }))} />
|
||||||
|
<MoneyField label="Amortisation pro Jahr (CHF)" value={re.amortization} onChange={(v) => setRe((s) => ({ ...s, amortization: v }))} />
|
||||||
|
<NumberField label="Hypothekarzins (%)" step={0.1} value={re.interest} onChange={(v) => setRe((s) => ({ ...s, interest: v }))} />
|
||||||
|
</WizardToggle>
|
||||||
|
<WizardToggle
|
||||||
|
label="Schulden / Kredite"
|
||||||
|
help="Privatkredite, Autoleasing-Restwert und Ähnliches – ohne die Hypothek (die gehört zum Wohneigentum)."
|
||||||
|
checked={toggles.debt}
|
||||||
|
onChange={(v) => setToggles((t) => ({ ...t, debt: v }))}
|
||||||
|
>
|
||||||
|
<MoneyField label="Restschuld (CHF)" value={debt.value} onChange={(v) => setDebt((s) => ({ ...s, value: v }))} />
|
||||||
|
<MoneyField label="Rückzahlung pro Jahr (CHF)" value={debt.repayment} onChange={(v) => setDebt((s) => ({ ...s, repayment: v }))} />
|
||||||
|
</WizardToggle>
|
||||||
|
<p className="mt-1 text-[11px] text-faint">
|
||||||
|
Die AHV wird automatisch für {profile.persons.length > 1 ? "beide Personen" : "dich"} angelegt – die Rente
|
||||||
|
berechnet das Tool aus deinem Einkommen.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 4 && (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<p className="text-sm text-muted">Das wird angelegt:</p>
|
||||||
|
<ul className="flex flex-col gap-1.5 rounded-xl border border-border bg-surface-2 p-4 text-sm text-fg">
|
||||||
|
<li>📋 Plan «{planName.trim() || "Meine Planung"}» mit Basisszenario</li>
|
||||||
|
<li>📅 {(phases ?? []).map((p) => `${p.name} (${p.durationYears} J.)`).join(" → ")}</li>
|
||||||
|
<li>
|
||||||
|
💰 {profile.persons.filter((p) => (incomes[p.role] ?? 0) > 0).length > 0 ? "Einkommen, " : ""}
|
||||||
|
{expenses > 0 ? "Ausgaben, " : ""}AHV
|
||||||
|
{toggles.pk ? ", Pensionskasse" : ""}
|
||||||
|
{toggles.p3a ? ", Säule 3a" : ""}
|
||||||
|
{toggles.etf ? ", Wertschriften" : ""}
|
||||||
|
{toggles.realEstate ? ", Wohneigentum" : ""}
|
||||||
|
{toggles.debt ? ", Schulden" : ""}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p className="text-xs text-muted">
|
||||||
|
Danach zeigt dir eine kurze Tour die Matrix – und die offenen{" "}
|
||||||
|
<strong className="text-fg">Übergangs-Entscheide</strong> (z. B. was bei der Pensionierung mit der PK
|
||||||
|
passiert) gehst du direkt in der Ansicht durch.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && <p className="text-sm text-danger">{error}</p>}
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between border-t border-border pt-3">
|
||||||
|
<Button variant="ghost" size="sm" onClick={onClose}>
|
||||||
|
Abbrechen
|
||||||
|
</Button>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{step > 0 && (
|
||||||
|
<Button variant="secondary" onClick={() => setStep(step - 1)} disabled={saving}>
|
||||||
|
<ArrowLeft className="h-4 w-4" /> Zurück
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{step < STEP_TITLES.length - 1 ? (
|
||||||
|
<Button onClick={goNext}>
|
||||||
|
Weiter <ArrowRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button onClick={create} disabled={saving}>
|
||||||
|
<Sparkles className="h-4 w-4" /> {saving ? "Wird angelegt…" : "Plan erstellen"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function WizardToggle({
|
||||||
|
label,
|
||||||
|
help,
|
||||||
|
checked,
|
||||||
|
onChange,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
help: string;
|
||||||
|
checked: boolean;
|
||||||
|
onChange: (v: boolean) => void;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className={`rounded-xl border p-3 transition-colors ${checked ? "border-accent bg-accent-soft/30" : "border-border bg-surface-2"}`}>
|
||||||
|
<label className="flex cursor-pointer items-center gap-2 text-sm font-medium text-fg">
|
||||||
|
<input type="checkbox" checked={checked} onChange={(e) => onChange(e.target.checked)} />
|
||||||
|
{label}
|
||||||
|
<InfoBubble text={help} />
|
||||||
|
</label>
|
||||||
|
{checked && <div className="mt-3 grid grid-cols-1 gap-3 sm:grid-cols-2">{children}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ export function ProfileMenu({ username }: { username: string }) {
|
|||||||
const menuRef = useRef<HTMLDivElement | null>(null);
|
const menuRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect -- einmalige Übernahme aus localStorage
|
||||||
setThemeState(getEffectiveTheme());
|
setThemeState(getEffectiveTheme());
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -74,7 +75,7 @@ export function ProfileMenu({ username }: { username: string }) {
|
|||||||
|
|
||||||
<MenuItem
|
<MenuItem
|
||||||
icon={<KeyRound className="h-4 w-4" />}
|
icon={<KeyRound className="h-4 w-4" />}
|
||||||
label="Passwort aendern"
|
label="Passwort ändern"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
setShowPasswordDialog(true);
|
setShowPasswordDialog(true);
|
||||||
@@ -121,7 +122,7 @@ function ChangePasswordDialog({ onClose }: { onClose: () => void }) {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setError(null);
|
setError(null);
|
||||||
if (newPassword !== newPasswordConfirm) {
|
if (newPassword !== newPasswordConfirm) {
|
||||||
setError("Die neuen Passwoerter stimmen nicht ueberein.");
|
setError("Die neuen Passwörter stimmen nicht überein.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
@@ -130,7 +131,7 @@ function ChangePasswordDialog({ onClose }: { onClose: () => void }) {
|
|||||||
setDone(true);
|
setDone(true);
|
||||||
setTimeout(onClose, 1200);
|
setTimeout(onClose, 1200);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Passwort aendern fehlgeschlagen.");
|
setError(err instanceof Error ? err.message : "Passwort ändern fehlgeschlagen.");
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
@@ -146,12 +147,12 @@ function ChangePasswordDialog({ onClose }: { onClose: () => void }) {
|
|||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
className="flex w-full max-w-sm flex-col gap-3 rounded-2xl border border-border bg-surface p-6 shadow-xl"
|
className="flex w-full max-w-sm flex-col gap-3 rounded-2xl border border-border bg-surface p-6 shadow-xl"
|
||||||
>
|
>
|
||||||
<h2 className="text-base font-semibold text-fg">Passwort aendern</h2>
|
<h2 className="text-base font-semibold text-fg">Passwort ändern</h2>
|
||||||
<input type="password" placeholder="Aktuelles Passwort" autoComplete="current-password" value={currentPassword} onChange={(e) => setCurrentPassword(e.target.value)} className={inputClass} />
|
<input type="password" placeholder="Aktuelles Passwort" autoComplete="current-password" value={currentPassword} onChange={(e) => setCurrentPassword(e.target.value)} className={inputClass} />
|
||||||
<input type="password" placeholder="Neues Passwort" autoComplete="new-password" value={newPassword} onChange={(e) => setNewPassword(e.target.value)} className={inputClass} />
|
<input type="password" placeholder="Neues Passwort" autoComplete="new-password" value={newPassword} onChange={(e) => setNewPassword(e.target.value)} className={inputClass} />
|
||||||
<input type="password" placeholder="Neues Passwort bestaetigen" autoComplete="new-password" value={newPasswordConfirm} onChange={(e) => setNewPasswordConfirm(e.target.value)} className={inputClass} />
|
<input type="password" placeholder="Neues Passwort bestätigen" autoComplete="new-password" value={newPasswordConfirm} onChange={(e) => setNewPasswordConfirm(e.target.value)} className={inputClass} />
|
||||||
{error && <p className="text-sm text-danger">{error}</p>}
|
{error && <p className="text-sm text-danger">{error}</p>}
|
||||||
{done && <p className="text-sm text-success">Passwort geaendert.</p>}
|
{done && <p className="text-sm text-success">Passwort geändert.</p>}
|
||||||
<div className="flex gap-2 pt-1">
|
<div className="flex gap-2 pt-1">
|
||||||
<button type="submit" disabled={saving} 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">
|
<button type="submit" disabled={saving} 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"}
|
{saving ? "..." : "Speichern"}
|
||||||
|
|||||||
@@ -23,9 +23,9 @@ interface RangeDraft {
|
|||||||
high: string;
|
high: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bewusst OHNE Defaults: Die Balkenlaenge haengt direkt von der eingegebenen Bandbreite ab.
|
// Bewusst OHNE Defaults: Die Balkenlänge hängt direkt von der eingegebenen Bandbreite ab.
|
||||||
// Ein stiller Default wuerde nicht hinterfragt und das Ranking waere dann eine Aussage ueber
|
// Ein stiller Default würde nicht hinterfragt und das Ranking wäre dann eine Aussage über
|
||||||
// unsere Vorgabe statt ueber den Plan (gleiche Begruendung wie bei den Monte-Carlo-Mittelwerten).
|
// unsere Vorgabe statt über den Plan (gleiche Begründung wie bei den Monte-Carlo-Mittelwerten).
|
||||||
const emptyDraft: RangeDraft = { checked: false, low: "", high: "" };
|
const emptyDraft: RangeDraft = { checked: false, low: "", high: "" };
|
||||||
|
|
||||||
function formatRange(low: number, high: number, unit: DriverDef["unit"]): string {
|
function formatRange(low: number, high: number, unit: DriverDef["unit"]): string {
|
||||||
@@ -60,7 +60,7 @@ export function SensitivityDialog({ plan, onClose }: { plan: PlanInput; onClose:
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Mindestens zwei Treiber: Ein Tornado ist eine RANGLISTE. Mit einem einzigen Balken gibt
|
// Mindestens zwei Treiber: Ein Tornado ist eine RANGLISTE. Mit einem einzigen Balken gibt
|
||||||
// es nichts zu ordnen und die Grafik wuerde eine Aussage suggerieren, die sie nicht hat.
|
// es nichts zu ordnen und die Grafik würde eine Aussage suggerieren, die sie nicht hat.
|
||||||
const canRun =
|
const canRun =
|
||||||
checked.length >= 2 && incomplete.length === 0 && degenerate.length === 0 && plan.phases.length > 0;
|
checked.length >= 2 && incomplete.length === 0 && degenerate.length === 0 && plan.phases.length > 0;
|
||||||
|
|
||||||
@@ -79,7 +79,7 @@ export function SensitivityDialog({ plan, onClose }: { plan: PlanInput; onClose:
|
|||||||
|
|
||||||
const chartData = useMemo(() => {
|
const chartData = useMemo(() => {
|
||||||
if (!result) return [];
|
if (!result) return [];
|
||||||
// Von unten nach oben gezeichnet -> fuer die Trichterform (groesster Balken zuoberst)
|
// Von unten nach oben gezeichnet -> für die Trichterform (grösster Balken zuoberst)
|
||||||
// muss die Reihenfolge umgekehrt in die Grafik.
|
// muss die Reihenfolge umgekehrt in die Grafik.
|
||||||
return [...result.bars].reverse().map((b) => ({
|
return [...result.bars].reverse().map((b) => ({
|
||||||
label: b.shortLabel,
|
label: b.shortLabel,
|
||||||
@@ -103,7 +103,7 @@ export function SensitivityDialog({ plan, onClose }: { plan: PlanInput; onClose:
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Erklaerung */}
|
{/* Erklärung */}
|
||||||
<div className="rounded-xl border border-border bg-surface-2 p-4 text-xs leading-relaxed text-muted">
|
<div className="rounded-xl border border-border bg-surface-2 p-4 text-xs leading-relaxed text-muted">
|
||||||
<p className="mb-2">
|
<p className="mb-2">
|
||||||
<strong className="text-fg">Was ist das?</strong> Nicht «wie viel Geld habe ich am Schluss», sondern
|
<strong className="text-fg">Was ist das?</strong> Nicht «wie viel Geld habe ich am Schluss», sondern
|
||||||
@@ -130,7 +130,7 @@ export function SensitivityDialog({ plan, onClose }: { plan: PlanInput; onClose:
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Zielgroesse */}
|
{/* Zielgrösse */}
|
||||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||||
<SelectField
|
<SelectField
|
||||||
label="Zielgrösse"
|
label="Zielgrösse"
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
// Mini-Verlaufskurve für die Matrix-Zeilen: der Trend eines Elements über alle Planjahre
|
||||||
|
// auf einen Blick. Die per-Jahr-Werte existieren seit 0.11 (ElementPhaseComputed.yearly) --
|
||||||
|
// hier werden sie nur gezeichnet, nicht neu gerechnet. Reines SVG, keine Chart-Bibliothek.
|
||||||
|
|
||||||
|
export function Sparkline({
|
||||||
|
values,
|
||||||
|
width = 64,
|
||||||
|
height = 16,
|
||||||
|
className = "",
|
||||||
|
}: {
|
||||||
|
values: number[];
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
if (values.length < 2) return null;
|
||||||
|
const min = Math.min(...values, 0);
|
||||||
|
const max = Math.max(...values, 0);
|
||||||
|
if (max === min) return null; // flache Linie trägt keine Information
|
||||||
|
|
||||||
|
const pad = 1.5;
|
||||||
|
const x = (i: number) => pad + (i / (values.length - 1)) * (width - 2 * pad);
|
||||||
|
const y = (v: number) => pad + (1 - (v - min) / (max - min)) * (height - 2 * pad);
|
||||||
|
const points = values.map((v, i) => `${x(i).toFixed(1)},${y(v).toFixed(1)}`).join(" ");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
|
viewBox={`0 0 ${width} ${height}`}
|
||||||
|
aria-hidden="true"
|
||||||
|
className={`shrink-0 ${className}`}
|
||||||
|
>
|
||||||
|
{/* Nulllinie, falls die Werte das Vorzeichen wechseln (z. B. Schulden). */}
|
||||||
|
{min < 0 && max > 0 && (
|
||||||
|
<line x1={0} x2={width} y1={y(0)} y2={y(0)} stroke="var(--border-strong)" strokeWidth="1" strokeDasharray="2 2" />
|
||||||
|
)}
|
||||||
|
<polyline points={points} fill="none" stroke="var(--accent)" strokeWidth="1.5" strokeLinejoin="round" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -18,8 +18,8 @@ const INCOME_COLOR = "#16a34a";
|
|||||||
const EXPENSE_COLOR = "#dc2626";
|
const EXPENSE_COLOR = "#dc2626";
|
||||||
const REAL_COLOR = "#9ca3af";
|
const REAL_COLOR = "#9ca3af";
|
||||||
|
|
||||||
// Verlauf pro Jahr: Einkommen (nominal, inkl. Renten) vs. nominale Ausgaben; die Flaeche
|
// Verlauf pro Jahr: Einkommen (nominal, inkl. Renten) vs. nominale Ausgaben; die Fläche
|
||||||
// dazwischen ist die Spar-/Verzehrquote (gruen = Sparen, rot = Verzehr). Reale Ausgaben als
|
// dazwischen ist die Spar-/Verzehrquote (grün = Sparen, rot = Verzehr). Reale Ausgaben als
|
||||||
// blasse Referenzlinie -> der Keil zu den nominalen Ausgaben ist "das, was die Inflation frisst".
|
// blasse Referenzlinie -> der Keil zu den nominalen Ausgaben ist "das, was die Inflation frisst".
|
||||||
export function SparquoteChart({ computed }: { computed: PlanComputed }) {
|
export function SparquoteChart({ computed }: { computed: PlanComputed }) {
|
||||||
if (computed.yearly.length === 0) return <p className="text-sm text-muted">Noch keine Phasen vorhanden.</p>;
|
if (computed.yearly.length === 0) return <p className="text-sm text-muted">Noch keine Phasen vorhanden.</p>;
|
||||||
@@ -31,7 +31,7 @@ export function SparquoteChart({ computed }: { computed: PlanComputed }) {
|
|||||||
income: y.income,
|
income: y.income,
|
||||||
expenseNominal: y.expenseNominal,
|
expenseNominal: y.expenseNominal,
|
||||||
expenseReal: y.expenseReal,
|
expenseReal: y.expenseReal,
|
||||||
// Range-Flaechen [unten, oben]; nur dort gesetzt, wo zutreffend (sonst Luecke).
|
// Range-Flächen [unten, oben]; nur dort gesetzt, wo zutreffend (sonst Lücke).
|
||||||
sparBand: surplus ? [y.expenseNominal, y.income] : undefined,
|
sparBand: surplus ? [y.expenseNominal, y.income] : undefined,
|
||||||
verzehrBand: surplus ? undefined : [y.income, y.expenseNominal],
|
verzehrBand: surplus ? undefined : [y.income, y.expenseNominal],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ import rehypeSlug from "rehype-slug";
|
|||||||
import { FileText } from "lucide-react";
|
import { FileText } from "lucide-react";
|
||||||
import { api } from "@/lib/api-client";
|
import { api } from "@/lib/api-client";
|
||||||
|
|
||||||
// Rendert SPEZIFIKATION.md (via /api/spec) als lesbares Dokument. Das Styling laeuft ueber
|
// Rendert SPEZIFIKATION.md (via /api/spec) als lesbares Dokument. Das Styling läuft über
|
||||||
// die Klasse .md-doc in globals.css und folgt damit dem gewaehlten Farbschema.
|
// die Klasse .md-doc in globals.css und folgt damit dem gewählten Farbschema.
|
||||||
export function SpecView({ anchor }: { anchor?: string | null }) {
|
export function SpecView({ anchor }: { anchor?: string | null }) {
|
||||||
const [markdown, setMarkdown] = useState<string | null>(null);
|
const [markdown, setMarkdown] = useState<string | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -21,7 +21,7 @@ export function SpecView({ anchor }: { anchor?: string | null }) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Sprungmarke aus einem Rechenweg: erst nach dem Rendern des Markdowns existiert die
|
// Sprungmarke aus einem Rechenweg: erst nach dem Rendern des Markdowns existiert die
|
||||||
// Ueberschrift mit der von rehype-slug erzeugten id.
|
// Überschrift mit der von rehype-slug erzeugten id.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!anchor || markdown === null) return;
|
if (!anchor || markdown === null) return;
|
||||||
const el = document.getElementById(anchor);
|
const el = document.getElementById(anchor);
|
||||||
@@ -29,7 +29,7 @@ export function SpecView({ anchor }: { anchor?: string | null }) {
|
|||||||
}, [anchor, markdown]);
|
}, [anchor, markdown]);
|
||||||
|
|
||||||
if (error) return <p className="text-sm text-danger">{error}</p>;
|
if (error) return <p className="text-sm text-danger">{error}</p>;
|
||||||
if (markdown === null) return <p className="text-sm text-muted">Laedt…</p>;
|
if (markdown === null) return <p className="text-sm text-muted">Lädt…</p>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ function formatValue(p: SystemParameter): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Zeigt die Systemparameter mit Wert, Bedeutung, Herleitung und Quelle (Roadmap Nr. 41).
|
// Zeigt die Systemparameter mit Wert, Bedeutung, Herleitung und Quelle (Roadmap Nr. 41).
|
||||||
// Die Eintraege stammen direkt aus constants.ts -- dieselbe Datenquelle, aus der auch
|
// Die Einträge stammen direkt aus constants.ts -- dieselbe Datenquelle, aus der auch
|
||||||
// gerechnet wird. Ein Abdriften zwischen Anzeige und Rechnung ist damit ausgeschlossen.
|
// gerechnet wird. Ein Abdriften zwischen Anzeige und Rechnung ist damit ausgeschlossen.
|
||||||
export function SystemParametersView() {
|
export function SystemParametersView() {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ interface PersonAxis {
|
|||||||
|
|
||||||
// Horizontale Zeitachse: die Lebensphasen als aneinandergereihte Segmente (Breite = Dauer),
|
// Horizontale Zeitachse: die Lebensphasen als aneinandergereihte Segmente (Breite = Dauer),
|
||||||
// darunter Alter und -- sofern ein Planstart gesetzt ist -- die Kalenderjahre. Pensionsalter
|
// darunter Alter und -- sofern ein Planstart gesetzt ist -- die Kalenderjahre. Pensionsalter
|
||||||
// je Person und ein allfaelliges Ruinalter sind als Marker eingezeichnet.
|
// je Person und ein allfälliges Ruinalter sind als Marker eingezeichnet.
|
||||||
export function Timeline({
|
export function Timeline({
|
||||||
phases,
|
phases,
|
||||||
persons,
|
persons,
|
||||||
@@ -35,7 +35,7 @@ export function Timeline({
|
|||||||
const pct = (age: number) => `${(Math.max(0, Math.min(span, age - minAge)) / span) * 100}%`;
|
const pct = (age: number) => `${(Math.max(0, Math.min(span, age - minAge)) / span) * 100}%`;
|
||||||
const yearOf = (yearsFromStart: number) => (startYear ? startYear + yearsFromStart : null);
|
const yearOf = (yearsFromStart: number) => (startYear ? startYear + yearsFromStart : null);
|
||||||
|
|
||||||
// Segmente: Start-/Endjahr (relativ zum Planbeginn) je Phase -- ohne Mutation waehrend
|
// Segmente: Start-/Endjahr (relativ zum Planbeginn) je Phase -- ohne Mutation während
|
||||||
// des Renderns, deshalb die kumulierte Summe der vorangehenden Dauern.
|
// des Renderns, deshalb die kumulierte Summe der vorangehenden Dauern.
|
||||||
const segments = phases.map((p, i) => {
|
const segments = phases.map((p, i) => {
|
||||||
const from = phases.slice(0, i).reduce((s, x) => s + x.durationYears, 0);
|
const from = phases.slice(0, i).reduce((s, x) => s + x.durationYears, 0);
|
||||||
@@ -91,7 +91,7 @@ export function Timeline({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Phasen-Segmente: Breite proportional zur Dauer, Einfaerbung nach Phasentyp. */}
|
{/* Phasen-Segmente: Breite proportional zur Dauer, Einfärbung nach Phasentyp. */}
|
||||||
<div className="flex w-full overflow-hidden rounded-lg border border-border">
|
<div className="flex w-full overflow-hidden rounded-lg border border-border">
|
||||||
{segments.map((s, i) => {
|
{segments.map((s, i) => {
|
||||||
const soft = s.phase.type === "PENSION" || s.phase.type === "MIXED";
|
const soft = s.phase.type === "PENSION" || s.phase.type === "MIXED";
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
// Interaktive Kurz-Tour über die Planansicht (Teil von Roadmap Nr. 10). Bewusst simpel
|
||||||
|
// gehalten: eine feste Karte unten rechts plus ein pulsierender Rahmen um das jeweilige
|
||||||
|
// Ziel-Element (per data-tour-Attribut) -- kein Spotlight-Overlay, keine Positionierungs-
|
||||||
|
// Engine. Ziele, die es im aktuellen Plan nicht gibt (z. B. Übergänge bei nur einer
|
||||||
|
// Phase), werden zur Laufzeit übersprungen.
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { Lightbulb, X } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui";
|
||||||
|
|
||||||
|
export const TOUR_DONE_KEY = "fpt-tour-done";
|
||||||
|
|
||||||
|
interface TourStep {
|
||||||
|
target: string; // data-tour="…"
|
||||||
|
title: string;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STEPS: TourStep[] = [
|
||||||
|
{
|
||||||
|
target: "profil",
|
||||||
|
title: "Dein Grundprofil",
|
||||||
|
text: "Alter, Pensionsalter und Inflationsannahme. Alles hier gilt für dieses Szenario – über «Profil» änderst du es jederzeit.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
target: "timeline",
|
||||||
|
title: "Die Zeitachse",
|
||||||
|
text: "Deine Lebensphasen im Überblick: Breite = Dauer, Fähnchen = Pensionierung. Die ganze Planung denkt in diesen Phasen.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
target: "matrix",
|
||||||
|
title: "Die Matrix – das Herzstück",
|
||||||
|
text: "Zeilen sind deine Finanzen, Spalten deine Lebensphasen. Klick auf eine Zelle: rechts öffnet sich das Bearbeiten-Panel, die Matrix bleibt sichtbar.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
target: "transition",
|
||||||
|
title: "Übergänge",
|
||||||
|
text: "Zwischen zwei Phasen triffst du einmalige Entscheide – verkaufen, beziehen, verrenten. Orange heisst: noch offen. Klick auf den Spaltenkopf führt dich durch alle Entscheide.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
target: "cash",
|
||||||
|
title: "Das Cash-Konto",
|
||||||
|
text: "Dein Ausgleichskonto: Was übrig bleibt, landet hier – was fehlt, macht es rot. Negativ = Liquiditätslücke.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
target: "analysen",
|
||||||
|
title: "Analysen",
|
||||||
|
text: "Grafiken, Monte-Carlo-Simulation und Einflussfaktoren: Wie sicher ist dein Plan, und welche Annahme entscheidet wirklich?",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function Tour({ onClose }: { onClose: () => void }) {
|
||||||
|
// Nur Schritte, deren Ziel im DOM existiert (hängt vom Plan ab).
|
||||||
|
const steps = useMemo(
|
||||||
|
() => STEPS.filter((s) => document.querySelector(`[data-tour="${s.target}"]`) !== null),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
const [index, setIndex] = useState(0);
|
||||||
|
const step = steps[index];
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!step) return;
|
||||||
|
const el = document.querySelector(`[data-tour="${step.target}"]`);
|
||||||
|
if (!el) return;
|
||||||
|
el.classList.add("tour-highlight");
|
||||||
|
el.scrollIntoView({ block: "center", behavior: "smooth" });
|
||||||
|
return () => el.classList.remove("tour-highlight");
|
||||||
|
}, [step]);
|
||||||
|
|
||||||
|
if (!step) return null;
|
||||||
|
|
||||||
|
function finish() {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(TOUR_DONE_KEY, "1");
|
||||||
|
} catch {
|
||||||
|
/* localStorage nicht verfügbar -> Tour erscheint beim nächsten Mal erneut */
|
||||||
|
}
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="ui-pop fixed bottom-4 left-1/2 z-40 w-[26rem] max-w-[calc(100vw-2rem)] -translate-x-1/2 rounded-2xl border border-border bg-surface p-4 shadow-2xl">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="flex h-7 w-7 items-center justify-center rounded-lg bg-accent-soft text-accent-soft-fg">
|
||||||
|
<Lightbulb className="h-4 w-4" />
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-semibold text-fg">{step.title}</div>
|
||||||
|
<div className="text-[11px] text-faint">
|
||||||
|
Schritt {index + 1} von {steps.length}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={finish}
|
||||||
|
aria-label="Tour beenden"
|
||||||
|
className="rounded-md p-1 text-faint hover:bg-surface-2 hover:text-fg"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-sm leading-relaxed text-muted">{step.text}</p>
|
||||||
|
<div className="mt-3 flex items-center justify-between">
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{steps.map((_, i) => (
|
||||||
|
<span
|
||||||
|
key={i}
|
||||||
|
className={`h-1.5 w-1.5 rounded-full ${i === index ? "bg-accent" : "bg-border-strong"}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{index > 0 && (
|
||||||
|
<Button variant="secondary" size="sm" onClick={() => setIndex(index - 1)}>
|
||||||
|
Zurück
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{index < steps.length - 1 ? (
|
||||||
|
<Button size="sm" onClick={() => setIndex(index + 1)}>
|
||||||
|
Weiter
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button size="sm" onClick={finish}>
|
||||||
|
Fertig
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -20,7 +20,7 @@ export interface TimelineSeries {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Datenpunkte je Serie: JEDES Planjahr (nicht nur die Phasengrenzen), verortet auf dem Alter
|
// Datenpunkte je Serie: JEDES Planjahr (nicht nur die Phasengrenzen), verortet auf dem Alter
|
||||||
// der Referenzperson (Person A). Der erste Punkt ist das Startvermoegen vor Jahr 1.
|
// der Referenzperson (Person A). Der erste Punkt ist das Startvermögen vor Jahr 1.
|
||||||
function pointsFor(computed: PlanComputed) {
|
function pointsFor(computed: PlanComputed) {
|
||||||
const phases = computed.phases;
|
const phases = computed.phases;
|
||||||
if (phases.length === 0) return [] as { age: number; nominal: number; real: number }[];
|
if (phases.length === 0) return [] as { age: number; nominal: number; real: number }[];
|
||||||
@@ -33,8 +33,8 @@ function pointsFor(computed: PlanComputed) {
|
|||||||
return pts;
|
return pts;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Liniendiagramm: Gesamtvermoegen (nominal + real) ueber das Alter. Unterstuetzt mehrere
|
// Liniendiagramm: Gesamtvermögen (nominal + real) über das Alter. Unterstützt mehrere
|
||||||
// ueberlagerte Plaene fuer den Szenario-Vergleich.
|
// überlagerte Pläne für den Szenario-Vergleich.
|
||||||
export function WealthChart({ series }: { series: TimelineSeries[] }) {
|
export function WealthChart({ series }: { series: TimelineSeries[] }) {
|
||||||
if (series.length === 0 || series[0].computed.phases.length === 0) {
|
if (series.length === 0 || series[0].computed.phases.length === 0) {
|
||||||
return <p className="text-sm text-muted">Noch keine Phasen vorhanden.</p>;
|
return <p className="text-sm text-muted">Noch keine Phasen vorhanden.</p>;
|
||||||
|
|||||||
@@ -0,0 +1,381 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
// UI-Primitiven des Tools. Ein einziger Ort für Buttons, Dialoge, Bestätigungen, Toasts
|
||||||
|
// und Ladezustände -- damit Verhalten (ESC, Fokus-Falle, Animation) und Optik überall
|
||||||
|
// identisch sind, statt in jeder Komponente von Hand nachgebaut zu werden.
|
||||||
|
//
|
||||||
|
// Ersetzt insbesondere window.confirm()/alert(): Browser-Systemdialoge folgen keinem der
|
||||||
|
// drei Farbschemata und wirken wie ein Fremdkörper.
|
||||||
|
|
||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import { AlertTriangle, CheckCircle2, X } from "lucide-react";
|
||||||
|
|
||||||
|
// --- ESC-Verwaltung ----------------------------------------------------------------------
|
||||||
|
// Mehrere Schichten können gleichzeitig offen sein (Inspector + Dialog). ESC soll nur die
|
||||||
|
// OBERSTE schliessen. Jede Schicht registriert sich beim Öffnen; ESC trifft die letzte.
|
||||||
|
const escStack: (() => void)[] = [];
|
||||||
|
let escListenerAttached = false;
|
||||||
|
|
||||||
|
function ensureEscListener() {
|
||||||
|
if (escListenerAttached || typeof window === "undefined") return;
|
||||||
|
escListenerAttached = true;
|
||||||
|
window.addEventListener("keydown", (e) => {
|
||||||
|
if (e.key !== "Escape" || escStack.length === 0) return;
|
||||||
|
e.stopPropagation();
|
||||||
|
escStack[escStack.length - 1]();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function useEscClose(onClose: () => void) {
|
||||||
|
const closeRef = useRef(onClose);
|
||||||
|
useEffect(() => {
|
||||||
|
closeRef.current = onClose;
|
||||||
|
}, [onClose]);
|
||||||
|
useEffect(() => {
|
||||||
|
ensureEscListener();
|
||||||
|
const handler = () => closeRef.current();
|
||||||
|
escStack.push(handler);
|
||||||
|
return () => {
|
||||||
|
const i = escStack.indexOf(handler);
|
||||||
|
if (i >= 0) escStack.splice(i, 1);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Einfache Fokus-Falle: beim Öffnen den ersten fokussierbaren Inhalt fokussieren,
|
||||||
|
// Tab bleibt innerhalb der Schicht, beim Schliessen kehrt der Fokus zurück.
|
||||||
|
function useFocusTrap(ref: React.RefObject<HTMLElement | null>) {
|
||||||
|
useEffect(() => {
|
||||||
|
const el = ref.current;
|
||||||
|
if (!el) return;
|
||||||
|
const previous = document.activeElement as HTMLElement | null;
|
||||||
|
const focusables = () =>
|
||||||
|
[...el.querySelectorAll<HTMLElement>(
|
||||||
|
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
||||||
|
)].filter((x) => !x.hasAttribute("disabled"));
|
||||||
|
(focusables()[0] ?? el).focus({ preventScroll: true });
|
||||||
|
function onKey(e: KeyboardEvent) {
|
||||||
|
if (e.key !== "Tab") return;
|
||||||
|
const f = focusables();
|
||||||
|
if (f.length === 0) return;
|
||||||
|
const first = f[0];
|
||||||
|
const last = f[f.length - 1];
|
||||||
|
if (e.shiftKey && document.activeElement === first) {
|
||||||
|
e.preventDefault();
|
||||||
|
last.focus();
|
||||||
|
} else if (!e.shiftKey && document.activeElement === last) {
|
||||||
|
e.preventDefault();
|
||||||
|
first.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
el.addEventListener("keydown", onKey);
|
||||||
|
return () => {
|
||||||
|
el.removeEventListener("keydown", onKey);
|
||||||
|
previous?.focus?.({ preventScroll: true });
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Button ------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
type ButtonVariant = "primary" | "secondary" | "danger" | "ghost";
|
||||||
|
|
||||||
|
const BUTTON_CLASSES: Record<ButtonVariant, string> = {
|
||||||
|
primary:
|
||||||
|
"bg-accent text-accent-fg shadow-sm hover:bg-accent-hover active:scale-[0.98]",
|
||||||
|
secondary:
|
||||||
|
"border border-border text-muted hover:bg-surface-2 hover:text-fg active:scale-[0.98]",
|
||||||
|
danger:
|
||||||
|
"border border-border text-muted hover:border-danger hover:bg-danger-soft hover:text-danger active:scale-[0.98]",
|
||||||
|
ghost: "text-muted hover:bg-surface-2 hover:text-fg",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Button({
|
||||||
|
variant = "primary",
|
||||||
|
size = "md",
|
||||||
|
className = "",
|
||||||
|
children,
|
||||||
|
...rest
|
||||||
|
}: React.ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||||
|
variant?: ButtonVariant;
|
||||||
|
size?: "sm" | "md";
|
||||||
|
}) {
|
||||||
|
const sizeClass = size === "sm" ? "px-2.5 py-1 text-xs" : "px-4 py-2 text-sm";
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
{...rest}
|
||||||
|
className={`inline-flex items-center justify-center gap-1.5 rounded-lg font-medium transition-all duration-150 disabled:pointer-events-none disabled:opacity-50 ${BUTTON_CLASSES[variant]} ${sizeClass} ${className}`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Modal -------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function Modal({
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
onClose,
|
||||||
|
children,
|
||||||
|
wide,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
onClose: () => void;
|
||||||
|
children: React.ReactNode;
|
||||||
|
wide?: boolean;
|
||||||
|
}) {
|
||||||
|
const panelRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
useEscClose(onClose);
|
||||||
|
useFocusTrap(panelRef);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="ui-fade fixed inset-0 z-40 flex items-start justify-center overflow-y-auto bg-black/40 px-4 py-8"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref={panelRef}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={title}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className={`ui-pop flex w-full ${wide ? "max-w-2xl" : "max-w-md"} flex-col gap-3 rounded-2xl border border-border bg-surface p-6 shadow-xl`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h2 className="text-base font-semibold text-fg">{title}</h2>
|
||||||
|
{subtitle && <p className="mt-0.5 text-xs text-muted">{subtitle}</p>}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="Schliessen"
|
||||||
|
className="rounded-md p-1 text-faint transition-colors hover:bg-surface-2 hover:text-fg"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Inspector ---------------------------------------------------------------------------
|
||||||
|
// Rechtes Seitenpanel für Einzel-Bearbeitungen (Zellen, Übergänge, Phase, Profil).
|
||||||
|
// Bewusst OHNE Backdrop: Die Matrix bleibt sichtbar und klickbar -- ein Klick auf eine
|
||||||
|
// andere Zelle wechselt einfach den Panel-Inhalt. Das hält den Kontext, den Modals nehmen.
|
||||||
|
// Auf schmalen Screens deckt das Panel die volle Breite ab.
|
||||||
|
|
||||||
|
export function InspectorShell({
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
onClose,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
onClose: () => void;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const panelRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
useEscClose(onClose);
|
||||||
|
return (
|
||||||
|
<aside
|
||||||
|
ref={panelRef}
|
||||||
|
role="complementary"
|
||||||
|
aria-label={title}
|
||||||
|
className="ui-slide-in fixed inset-y-0 right-0 z-30 flex w-full flex-col border-l border-border bg-surface shadow-2xl sm:w-[26rem]"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-3 border-b border-border px-5 py-4">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h2 className="truncate text-base font-semibold text-fg">{title}</h2>
|
||||||
|
{subtitle && <p className="mt-0.5 text-xs text-muted">{subtitle}</p>}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="Panel schliessen"
|
||||||
|
className="rounded-md p-1 text-faint transition-colors hover:bg-surface-2 hover:text-fg"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-y-auto px-5 py-4">{children}</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Bestätigungs-Dialog (ersetzt window.confirm) ---------------------------------------
|
||||||
|
|
||||||
|
interface ConfirmOptions {
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
confirmLabel?: string;
|
||||||
|
danger?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ConfirmContext = createContext<((opts: ConfirmOptions) => Promise<boolean>) | null>(null);
|
||||||
|
|
||||||
|
export function useConfirm() {
|
||||||
|
const fn = useContext(ConfirmContext);
|
||||||
|
if (!fn) throw new Error("useConfirm ausserhalb des ConfirmProviders");
|
||||||
|
return fn;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConfirmProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const [pending, setPending] = useState<{
|
||||||
|
opts: ConfirmOptions;
|
||||||
|
resolve: (ok: boolean) => void;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
const confirm = useCallback(
|
||||||
|
(opts: ConfirmOptions) =>
|
||||||
|
new Promise<boolean>((resolve) => setPending({ opts, resolve })),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
function settle(ok: boolean) {
|
||||||
|
pending?.resolve(ok);
|
||||||
|
setPending(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ConfirmContext.Provider value={confirm}>
|
||||||
|
{children}
|
||||||
|
{pending && (
|
||||||
|
<Modal title={pending.opts.title} onClose={() => settle(false)}>
|
||||||
|
<p className="text-sm text-muted">{pending.opts.message}</p>
|
||||||
|
<div className="flex gap-2 pt-1">
|
||||||
|
<Button
|
||||||
|
variant={pending.opts.danger ? "danger" : "primary"}
|
||||||
|
autoFocus
|
||||||
|
onClick={() => settle(true)}
|
||||||
|
className={pending.opts.danger ? "border-danger bg-danger-soft text-danger" : ""}
|
||||||
|
>
|
||||||
|
{pending.opts.confirmLabel ?? "Bestätigen"}
|
||||||
|
</Button>
|
||||||
|
<Button variant="secondary" onClick={() => settle(false)}>
|
||||||
|
Abbrechen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
</ConfirmContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Toasts (ersetzt alert und stumme Erfolge) -------------------------------------------
|
||||||
|
|
||||||
|
interface ToastItem {
|
||||||
|
id: number;
|
||||||
|
kind: "success" | "error";
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ToastContext = createContext<((kind: ToastItem["kind"], text: string) => void) | null>(null);
|
||||||
|
|
||||||
|
export function useToast() {
|
||||||
|
const fn = useContext(ToastContext);
|
||||||
|
if (!fn) throw new Error("useToast ausserhalb des ToastProviders");
|
||||||
|
return fn;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ToastProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const [toasts, setToasts] = useState<ToastItem[]>([]);
|
||||||
|
const nextId = useRef(1);
|
||||||
|
|
||||||
|
const push = useCallback((kind: ToastItem["kind"], text: string) => {
|
||||||
|
const id = nextId.current++;
|
||||||
|
setToasts((prev) => [...prev, { id, kind, text }]);
|
||||||
|
// Fehler bleiben länger stehen als Erfolge.
|
||||||
|
setTimeout(() => setToasts((prev) => prev.filter((t) => t.id !== id)), kind === "error" ? 6000 : 3200);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ToastContext.Provider value={push}>
|
||||||
|
{children}
|
||||||
|
<div className="pointer-events-none fixed bottom-4 right-4 z-50 flex w-80 flex-col gap-2">
|
||||||
|
{toasts.map((t) => (
|
||||||
|
<div
|
||||||
|
key={t.id}
|
||||||
|
className={`ui-pop pointer-events-auto flex items-start gap-2 rounded-xl border px-3.5 py-2.5 text-sm shadow-lg ${
|
||||||
|
t.kind === "error"
|
||||||
|
? "border-danger bg-danger-soft text-danger"
|
||||||
|
: "border-border bg-surface text-fg"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t.kind === "error" ? (
|
||||||
|
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||||
|
) : (
|
||||||
|
<CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0 text-success" />
|
||||||
|
)}
|
||||||
|
<span className="min-w-0 flex-1">{t.text}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Meldung schliessen"
|
||||||
|
onClick={() => setToasts((prev) => prev.filter((x) => x.id !== t.id))}
|
||||||
|
className="rounded p-0.5 text-faint hover:text-fg"
|
||||||
|
>
|
||||||
|
<X className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ToastContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Ladezustände und leere Zustände ---------------------------------------------------
|
||||||
|
|
||||||
|
export function Skeleton({ className = "" }: { className?: string }) {
|
||||||
|
return <div className={`animate-pulse rounded-lg bg-surface-2 ${className}`} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Platzhalter während die Planansicht lädt -- deutet die Struktur an, statt nur
|
||||||
|
// "Lädt…" zu schreiben (wahrgenommene Geschwindigkeit).
|
||||||
|
export function PlanSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-5" aria-busy="true" aria-label="Lädt">
|
||||||
|
<Skeleton className="h-8 w-64" />
|
||||||
|
<Skeleton className="h-24 w-full" />
|
||||||
|
<Skeleton className="h-12 w-full" />
|
||||||
|
<Skeleton className="h-72 w-full" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EmptyState({
|
||||||
|
icon,
|
||||||
|
title,
|
||||||
|
text,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
icon: React.ReactNode;
|
||||||
|
title: string;
|
||||||
|
text: string;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center gap-3 rounded-xl border border-dashed border-border bg-surface p-10 text-center">
|
||||||
|
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-accent-soft text-accent-soft-fg">
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-fg">{title}</h3>
|
||||||
|
<p className="mx-auto mt-1 max-w-md text-sm text-muted">{text}</p>
|
||||||
|
</div>
|
||||||
|
{children && <div className="mt-1 flex flex-wrap justify-center gap-2">{children}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+1
-1
@@ -19,7 +19,7 @@ export async function createSessionToken(userId: string): Promise<string> {
|
|||||||
.sign(getSecretKey());
|
.sign(getSecretKey());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Liefert die User-ID aus einem gueltigen Session-Token, sonst null.
|
// Liefert die User-ID aus einem gültigen Session-Token, sonst null.
|
||||||
export async function verifySessionToken(token: string): Promise<string | null> {
|
export async function verifySessionToken(token: string): Promise<string | null> {
|
||||||
try {
|
try {
|
||||||
const { payload } = await jwtVerify(token, getSecretKey());
|
const { payload } = await jwtVerify(token, getSecretKey());
|
||||||
|
|||||||
+21
-21
@@ -3,10 +3,10 @@ import { computePlan } from "@/lib/calculations";
|
|||||||
import type { CashTransitionData, ElementCategory, PhaseData, TransitionData } from "@/lib/elements";
|
import type { CashTransitionData, ElementCategory, PhaseData, TransitionData } from "@/lib/elements";
|
||||||
import type { PlanInput } from "@/lib/types";
|
import type { PlanInput } from "@/lib/types";
|
||||||
|
|
||||||
// Die zentrale Invariante beider Wasserfall-Bruecken: `residual` ist die Differenz zwischen
|
// Die zentrale Invariante beider Wasserfall-Brücken: `residual` ist die Differenz zwischen
|
||||||
// dem tatsaechlichen Endwert und der Summe der gezeichneten Schritte. Ist die Zerlegung
|
// dem tatsächlichen Endwert und der Summe der gezeichneten Schritte. Ist die Zerlegung
|
||||||
// vollstaendig und richtig, MUSS sie 0 sein -- ein Wasserfall, der nicht aufgeht, waere ein
|
// vollständig und richtig, MUSS sie 0 sein -- ein Wasserfall, der nicht aufgeht, wäre ein
|
||||||
// Fehler in der Berechnung und nicht bloss ein Darstellungsproblem. Deshalb ueber moeglichst
|
// Fehler in der Berechnung und nicht bloss ein Darstellungsproblem. Deshalb über möglichst
|
||||||
// verschiedene Konstellationen festgenagelt statt an einem einzigen Beispiel.
|
// verschiedene Konstellationen festgenagelt statt an einem einzigen Beispiel.
|
||||||
|
|
||||||
let idc = 0;
|
let idc = 0;
|
||||||
@@ -100,7 +100,7 @@ const konstellationen: { name: string; build: () => PlanInput }[] = [
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "PK-Kapitalbezug statt Rente (Steuer am Uebergang)",
|
name: "PK-Kapitalbezug statt Rente (Steuer am Übergang)",
|
||||||
build: () =>
|
build: () =>
|
||||||
plan({
|
plan({
|
||||||
age: 62,
|
age: 62,
|
||||||
@@ -142,7 +142,7 @@ const konstellationen: { name: string; build: () => PlanInput }[] = [
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Immobilie verkauft (Verkaufsdifferenz und Grundstueckgewinnsteuer)",
|
name: "Immobilie verkauft (Verkaufsdifferenz und Grundstückgewinnsteuer)",
|
||||||
build: () =>
|
build: () =>
|
||||||
plan({
|
plan({
|
||||||
age: 45,
|
age: 45,
|
||||||
@@ -164,7 +164,7 @@ const konstellationen: { name: string; build: () => PlanInput }[] = [
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Einmalige Sonderein-/ausgaben am Uebergang",
|
name: "Einmalige Sonderein-/ausgaben am Übergang",
|
||||||
build: () =>
|
build: () =>
|
||||||
plan({
|
plan({
|
||||||
age: 50,
|
age: 50,
|
||||||
@@ -189,7 +189,7 @@ const konstellationen: { name: string; build: () => PlanInput }[] = [
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Sofort-Tilgung und Sonderamortisation am Uebergang",
|
name: "Sofort-Tilgung und Sonderamortisation am Übergang",
|
||||||
build: () =>
|
build: () =>
|
||||||
plan({
|
plan({
|
||||||
age: 50,
|
age: 50,
|
||||||
@@ -222,15 +222,15 @@ const konstellationen: { name: string; build: () => PlanInput }[] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
describe("Wasserfall-Bruecken", () => {
|
describe("Wasserfall-Brücken", () => {
|
||||||
for (const k of konstellationen) {
|
for (const k of konstellationen) {
|
||||||
it(`${k.name}: beide Bruecken gehen ohne Restgroesse auf`, () => {
|
it(`${k.name}: beide Brücken gehen ohne Restgrösse auf`, () => {
|
||||||
const r = computePlan(k.build());
|
const r = computePlan(k.build());
|
||||||
expect(r.phases.length).toBeGreaterThan(0);
|
expect(r.phases.length).toBeGreaterThan(0);
|
||||||
for (const ph of r.phases) {
|
for (const ph of r.phases) {
|
||||||
expect(ph.wealthBridge.residual, `Vermoegensbruecke ${ph.name}`).toBe(0);
|
expect(ph.wealthBridge.residual, `Vermögensbrücke ${ph.name}`).toBe(0);
|
||||||
expect(ph.cashBridge.residual, `Cash-Bruecke ${ph.name}`).toBe(0);
|
expect(ph.cashBridge.residual, `Cash-Brücke ${ph.name}`).toBe(0);
|
||||||
// Die Kontrollpunkte muessen den offiziellen Kennzahlen entsprechen.
|
// Die Kontrollpunkte müssen den offiziellen Kennzahlen entsprechen.
|
||||||
expect(ph.wealthBridge.startWealth).toBe(ph.startWealthNominal);
|
expect(ph.wealthBridge.startWealth).toBe(ph.startWealthNominal);
|
||||||
expect(ph.wealthBridge.endWealth).toBe(ph.endWealthNominal);
|
expect(ph.wealthBridge.endWealth).toBe(ph.endWealthNominal);
|
||||||
expect(ph.cashBridge.cashStart).toBe(ph.cashStart);
|
expect(ph.cashBridge.cashStart).toBe(ph.cashStart);
|
||||||
@@ -239,10 +239,10 @@ describe("Wasserfall-Bruecken", () => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
it("Umbuchungen erscheinen NICHT in der Vermoegensbruecke", () => {
|
it("Umbuchungen erscheinen NICHT in der Vermögensbrücke", () => {
|
||||||
// Sparraten und Amortisationen verschieben Geld vom Cash in einen Vermoegenswert, ohne
|
// Sparraten und Amortisationen verschieben Geld vom Cash in einen Vermögenswert, ohne
|
||||||
// das Vermoegen zu aendern. Sie duerfen deshalb nur in der Cash-Bruecke auftauchen; die
|
// das Vermögen zu ändern. Sie dürfen deshalb nur in der Cash-Brücke auftauchen; die
|
||||||
// Vermoegensaenderung erklaert sich allein aus Quote, Ertraegen und PK-Beitraegen.
|
// Vermögensänderung erklärt sich allein aus Quote, Erträgen und PK-Beiträgen.
|
||||||
const r = computePlan(konstellationen[0].build());
|
const r = computePlan(konstellationen[0].build());
|
||||||
const ph = r.phases[0];
|
const ph = r.phases[0];
|
||||||
expect(ph.cashBridge.savingRates).toBeGreaterThan(0);
|
expect(ph.cashBridge.savingRates).toBeGreaterThan(0);
|
||||||
@@ -253,9 +253,9 @@ describe("Wasserfall-Bruecken", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("PK-Verrentung erscheint als Vermoegensabgang am Uebergang", () => {
|
it("PK-Verrentung erscheint als Vermögensabgang am Übergang", () => {
|
||||||
// Das verrentete Kapital verlaesst die Bilanz und wird zum Rentenstrom -- in der
|
// Das verrentete Kapital verlässt die Bilanz und wird zum Rentenstrom -- in der
|
||||||
// Vermoegensbruecke ein echter Abgang, kein Umbuchungsposten.
|
// Vermögensbrücke ein echter Abgang, kein Umbuchungsposten.
|
||||||
const r = computePlan(konstellationen[1].build());
|
const r = computePlan(konstellationen[1].build());
|
||||||
const pension = r.phases[1];
|
const pension = r.phases[1];
|
||||||
expect(pension.wealthBridge.pensionConversion).toBeGreaterThan(0);
|
expect(pension.wealthBridge.pensionConversion).toBeGreaterThan(0);
|
||||||
@@ -268,7 +268,7 @@ describe("Wasserfall-Bruecken", () => {
|
|||||||
expect(gehalten.saleGainLoss).toBe(0);
|
expect(gehalten.saleGainLoss).toBe(0);
|
||||||
expect(gehalten.transitionTax).toBe(0);
|
expect(gehalten.transitionTax).toBe(0);
|
||||||
expect(verkauft.transitionTax).toBeGreaterThan(0);
|
expect(verkauft.transitionTax).toBeGreaterThan(0);
|
||||||
// Verkaufspreis ueber dem modellierten Verkehrswert -> positive Differenz.
|
// Verkaufspreis über dem modellierten Verkehrswert -> positive Differenz.
|
||||||
expect(verkauft.saleGainLoss).not.toBe(0);
|
expect(verkauft.saleGainLoss).not.toBe(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -45,8 +45,8 @@ function plan(opts: {
|
|||||||
|
|
||||||
const within = (actual: number, expected: number, pct: number) => Math.abs(actual - expected) <= Math.abs(expected) * pct;
|
const within = (actual: number, expected: number, pct: number) => Math.abs(actual - expected) <= Math.abs(expected) * pct;
|
||||||
|
|
||||||
// Stuetzstellen aus der AMTLICHEN Tabelle 318.117.1 "Monatliche Vollrenten, Skala 44"
|
// Stützstellen aus der AMTLICHEN Tabelle 318.117.1 "Monatliche Vollrenten, Skala 44"
|
||||||
// (BSV, gueltig ab 1.1.2025/2026): mdJE -> monatliche Vollrente. Deckt Mindestrente,
|
// (BSV, gültig ab 1.1.2025/2026): mdJE -> monatliche Vollrente. Deckt Mindestrente,
|
||||||
// Formel 1, den Wendepunkt (45'360), Formel 2 und die Maximalrente ab.
|
// Formel 1, den Wendepunkt (45'360), Formel 2 und die Maximalrente ab.
|
||||||
const AHV_AMTLICHE_TABELLE: [number, number][] = [
|
const AHV_AMTLICHE_TABELLE: [number, number][] = [
|
||||||
[15120, 1260], // Mindestrente (12 x R0)
|
[15120, 1260], // Mindestrente (12 x R0)
|
||||||
@@ -72,7 +72,7 @@ describe("AHV-Rentenformel (Skala 44)", () => {
|
|||||||
it("kappt ausserhalb der Schwellen", () => {
|
it("kappt ausserhalb der Schwellen", () => {
|
||||||
expect(ahvMonthlyFullPension(0)).toBe(1260); // unter 12 x R0 -> Mindestrente
|
expect(ahvMonthlyFullPension(0)).toBe(1260); // unter 12 x R0 -> Mindestrente
|
||||||
expect(ahvMonthlyFullPension(10000)).toBe(1260);
|
expect(ahvMonthlyFullPension(10000)).toBe(1260);
|
||||||
expect(ahvMonthlyFullPension(150000)).toBe(2520); // ueber 72 x R0 -> Maximalrente
|
expect(ahvMonthlyFullPension(150000)).toBe(2520); // über 72 x R0 -> Maximalrente
|
||||||
});
|
});
|
||||||
|
|
||||||
it("ist am Wendepunkt stetig (kein Sprung zwischen Formel 1 und 2)", () => {
|
it("ist am Wendepunkt stetig (kein Sprung zwischen Formel 1 und 2)", () => {
|
||||||
@@ -82,12 +82,12 @@ describe("AHV-Rentenformel (Skala 44)", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// V5-Modell: Einkommen = nominale Basis + nominale Lohnerhoehung; Ausgaben = REALE Basis +
|
// V5-Modell: Einkommen = nominale Basis + nominale Lohnerhöhung; Ausgaben = REALE Basis +
|
||||||
// reale Mehrausgaben, nominal = real x (plan-weite Inflation).
|
// reale Mehrausgaben, nominal = real x (plan-weite Inflation).
|
||||||
// Netto -> Brutto fuer die AHV (AHV_GROSS_FROM_NET_FACTOR).
|
// Netto -> Brutto für die AHV (AHV_GROSS_FROM_NET_FACTOR).
|
||||||
const BRUTTO = 1.12;
|
const BRUTTO = 1.12;
|
||||||
|
|
||||||
describe("Teilverkauf Sonstiges Vermoegen (Roadmap 42)", () => {
|
describe("Teilverkauf Sonstiges Vermögen (Roadmap 42)", () => {
|
||||||
function p42(decision: "HOLD" | "SELL" | "PARTIAL", amount?: number) {
|
function p42(decision: "HOLD" | "SELL" | "PARTIAL", amount?: number) {
|
||||||
return plan({
|
return plan({
|
||||||
age: 40, retirementAge: 70, inflation: 0, initialCash: 0,
|
age: 40, retirementAge: 70, inflation: 0, initialCash: 0,
|
||||||
@@ -111,10 +111,10 @@ describe("Teilverkauf Sonstiges Vermoegen (Roadmap 42)", () => {
|
|||||||
const r = computePlan(p42("PARTIAL", 30000));
|
const r = computePlan(p42("PARTIAL", 30000));
|
||||||
expect(r.phases[1].capitalInflow).toBe(30000); // "zu investierendes Kapital" im Phasenkopf
|
expect(r.phases[1].capitalInflow).toBe(30000); // "zu investierendes Kapital" im Phasenkopf
|
||||||
expect(r.phases[1].cashStart).toBe(30000);
|
expect(r.phases[1].cashStart).toBe(30000);
|
||||||
expect(asset(p42("PARTIAL", 30000), 1).startValue).toBe(70000); // Rest laeuft weiter
|
expect(asset(p42("PARTIAL", 30000), 1).startValue).toBe(70000); // Rest läuft weiter
|
||||||
});
|
});
|
||||||
|
|
||||||
it("Teilverkauf am Endwert gekappt; Vollverkauf und Halten unveraendert", () => {
|
it("Teilverkauf am Endwert gekappt; Vollverkauf und Halten unverändert", () => {
|
||||||
expect(computePlan(p42("PARTIAL", 999999)).phases[1].capitalInflow).toBe(100000);
|
expect(computePlan(p42("PARTIAL", 999999)).phases[1].capitalInflow).toBe(100000);
|
||||||
expect(computePlan(p42("SELL")).phases[1].capitalInflow).toBe(100000);
|
expect(computePlan(p42("SELL")).phases[1].capitalInflow).toBe(100000);
|
||||||
expect(computePlan(p42("HOLD")).phases[1].capitalInflow).toBe(0);
|
expect(computePlan(p42("HOLD")).phases[1].capitalInflow).toBe(0);
|
||||||
@@ -203,7 +203,7 @@ describe("Immobilie: Zins und Wertsteigerung", () => {
|
|||||||
expect(computePlan(mit).phases[0].cashEnd).toBe(500000 - 90000);
|
expect(computePlan(mit).phases[0].cashEnd).toBe(500000 - 90000);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("Zinsbetrag sinkt mit der Amortisation und zaehlt in die Quote", () => {
|
it("Zinsbetrag sinkt mit der Amortisation und zählt in die Quote", () => {
|
||||||
const p = immoPlan({
|
const p = immoPlan({
|
||||||
purchasePrice: 1000000, mortgage: 1000000, amortization: 10000, interestRate: 1, interestHandling: "ADD",
|
purchasePrice: 1000000, mortgage: 1000000, amortization: 10000, interestRate: 1, interestHandling: "ADD",
|
||||||
});
|
});
|
||||||
@@ -211,10 +211,10 @@ describe("Immobilie: Zins und Wertsteigerung", () => {
|
|||||||
// Jahr 1: 1% von 1'000'000 = 10'000. Jahr 10: 1% von (1'000'000 - 9 x 10'000) = 9'100.
|
// Jahr 1: 1% von 1'000'000 = 10'000. Jahr 10: 1% von (1'000'000 - 9 x 10'000) = 9'100.
|
||||||
expect(ph.expenseStart).toBe(10000);
|
expect(ph.expenseStart).toBe(10000);
|
||||||
expect(ph.expenseEnd).toBe(9100);
|
expect(ph.expenseEnd).toBe(9100);
|
||||||
expect(ph.quotaStart).toBe(-10000); // Zins schlaegt auf die Quote durch
|
expect(ph.quotaStart).toBe(-10000); // Zins schlägt auf die Quote durch
|
||||||
});
|
});
|
||||||
|
|
||||||
it("Verkauf: Grundstueckgewinnsteuer bemisst sich am urspruenglichen Kaufpreis", () => {
|
it("Verkauf: Grundstückgewinnsteuer bemisst sich am ursprünglichen Kaufpreis", () => {
|
||||||
const p = plan({
|
const p = plan({
|
||||||
age: 40, retirementAge: 70, inflation: 0, initialCash: 0,
|
age: 40, retirementAge: 70, inflation: 0, initialCash: 0,
|
||||||
phases: [
|
phases: [
|
||||||
@@ -231,13 +231,13 @@ describe("Immobilie: Zins und Wertsteigerung", () => {
|
|||||||
});
|
});
|
||||||
const r = computePlan(p);
|
const r = computePlan(p);
|
||||||
// Gewinn = 1'200'000 - 1'000'000 (Kaufpreis!) = 200'000 -> Steuer 40'000.
|
// Gewinn = 1'200'000 - 1'000'000 (Kaufpreis!) = 200'000 -> Steuer 40'000.
|
||||||
// Erloes = 1'200'000 - 800'000 Resthypothek - 40'000 = 360'000.
|
// Erlös = 1'200'000 - 800'000 Resthypothek - 40'000 = 360'000.
|
||||||
expect(r.phases[1].capitalInflow).toBe(360000);
|
expect(r.phases[1].capitalInflow).toBe(360000);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("AHV einkommensabhaengig", () => {
|
describe("AHV einkommensabhängig", () => {
|
||||||
// 60-jaehrig, Pension mit 65: 39 Beitragsjahre vor Planbeginn (ab 21), 5 im Plan.
|
// 60-jährig, Pension mit 65: 39 Beitragsjahre vor Planbeginn (ab 21), 5 im Plan.
|
||||||
function ahvPlan(opts: {
|
function ahvPlan(opts: {
|
||||||
age?: number;
|
age?: number;
|
||||||
income: number;
|
income: number;
|
||||||
@@ -250,7 +250,7 @@ describe("AHV einkommensabhaengig", () => {
|
|||||||
return plan({
|
return plan({
|
||||||
age,
|
age,
|
||||||
retirementAge: 65,
|
retirementAge: 65,
|
||||||
inflation: 0, // real = nominal, damit die Erwartungswerte von Hand pruefbar bleiben
|
inflation: 0, // real = nominal, damit die Erwartungswerte von Hand prüfbar bleiben
|
||||||
phases: [
|
phases: [
|
||||||
{ id: "p1", durationYears: 65 - age },
|
{ id: "p1", durationYears: 65 - age },
|
||||||
{ id: "p2", durationYears: 10 },
|
{ id: "p2", durationYears: 10 },
|
||||||
@@ -282,7 +282,7 @@ describe("AHV einkommensabhaengig", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("mdJE unter der Schwelle -> abgestufte Rente (Formel 2)", () => {
|
it("mdJE unter der Schwelle -> abgestufte Rente (Formel 2)", () => {
|
||||||
// Plan-Einkommen ist NETTO -> fuer die AHV auf brutto hochgerechnet; das Feld
|
// Plan-Einkommen ist NETTO -> für die AHV auf brutto hochgerechnet; das Feld
|
||||||
// "vor Planbeginn" ist bereits brutto. 39 Jahre davor, 5 im Plan.
|
// "vor Planbeginn" ist bereits brutto. 39 Jahre davor, 5 im Plan.
|
||||||
const p = ahvPlan({ income: 60000, avgIncomeBefore: 60000 });
|
const p = ahvPlan({ income: 60000, avgIncomeBefore: 60000 });
|
||||||
const mdJE = (60000 * 39 + 60000 * BRUTTO * 5) / 44;
|
const mdJE = (60000 * 39 + 60000 * BRUTTO * 5) / 44;
|
||||||
@@ -290,8 +290,8 @@ describe("AHV einkommensabhaengig", () => {
|
|||||||
expect(renteIn(p, 1)).toBeLessThan(32760);
|
expect(renteIn(p, 1)).toBeLessThan(32760);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("Nettolohn wird fuer die AHV auf den Bruttolohn hochgerechnet", () => {
|
it("Nettolohn wird für die AHV auf den Bruttolohn hochgerechnet", () => {
|
||||||
// Ohne Vorgeschichte (Alter 21 bei Planbeginn) haengt das mdJE nur am Plan-Einkommen.
|
// Ohne Vorgeschichte (Alter 21 bei Planbeginn) hängt das mdJE nur am Plan-Einkommen.
|
||||||
const p = plan({
|
const p = plan({
|
||||||
age: 21,
|
age: 21,
|
||||||
retirementAge: 65,
|
retirementAge: 65,
|
||||||
@@ -323,7 +323,7 @@ describe("AHV einkommensabhaengig", () => {
|
|||||||
expect(renteIn(p, 1)).toBe(Math.round(ahvMonthlyFullPension(mdJE) * 13));
|
expect(renteIn(p, 1)).toBe(Math.round(ahvMonthlyFullPension(mdJE) * 13));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("Ausfalljahre kuerzen die Rente ueber die Skala 44", () => {
|
it("Ausfalljahre kürzen die Rente über die Skala 44", () => {
|
||||||
const ohne = ahvPlan({ income: 100000, avgIncomeBefore: 100000 });
|
const ohne = ahvPlan({ income: 100000, avgIncomeBefore: 100000 });
|
||||||
const mit = ahvPlan({ income: 100000, avgIncomeBefore: 100000, gapYearsBefore: 4 });
|
const mit = ahvPlan({ income: 100000, avgIncomeBefore: 100000, gapYearsBefore: 4 });
|
||||||
expect(renteIn(mit, 1)).toBe(Math.round(32760 * (40 / 44))); // 4 Ausfalljahre = 4/44 weniger
|
expect(renteIn(mit, 1)).toBe(Math.round(32760 * (40 / 44))); // 4 Ausfalljahre = 4/44 weniger
|
||||||
@@ -336,7 +336,7 @@ describe("AHV einkommensabhaengig", () => {
|
|||||||
expect(renteIn(p, 1)).toBe(Math.round(32760 * (42 / 44)));
|
expect(renteIn(p, 1)).toBe(Math.round(32760 * (42 / 44)));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("ohne Pruefung gilt der geplante Durchschnitt auch fuer die Jahre vor Planbeginn", () => {
|
it("ohne Prüfung gilt der geplante Durchschnitt auch für die Jahre vor Planbeginn", () => {
|
||||||
// Kein avgIncomeBefore erfasst -> darf NICHT als 0 gerechnet werden (sonst mdJE ~45%).
|
// Kein avgIncomeBefore erfasst -> darf NICHT als 0 gerechnet werden (sonst mdJE ~45%).
|
||||||
const p = plan({
|
const p = plan({
|
||||||
age: 60,
|
age: 60,
|
||||||
@@ -348,11 +348,11 @@ describe("AHV einkommensabhaengig", () => {
|
|||||||
],
|
],
|
||||||
elements: [
|
elements: [
|
||||||
el("INCOME", "PERSON_A", { p1: { amount: 80000, teuerungsausgleich: 0 }, p2: {} }),
|
el("INCOME", "PERSON_A", { p1: { amount: 80000, teuerungsausgleich: 0 }, p2: {} }),
|
||||||
el("AHV", "PERSON_A", { p1: {}, p2: {} }), // keine Uebergangsdaten
|
el("AHV", "PERSON_A", { p1: {}, p2: {} }), // keine Übergangsdaten
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
const rente = computePlan(p).phases[1].elements.find((e) => e.category === "AHV")!.startValue;
|
const rente = computePlan(p).phases[1].elements.find((e) => e.category === "AHV")!.startValue;
|
||||||
// mdJE = geplantes Brutto (80'000 x 1.12), nicht ~9'091 (das waere der Fallback auf 0).
|
// mdJE = geplantes Brutto (80'000 x 1.12), nicht ~9'091 (das wäre der Fallback auf 0).
|
||||||
expect(rente).toBe(Math.round(ahvMonthlyFullPension(80000 * BRUTTO) * 13));
|
expect(rente).toBe(Math.round(ahvMonthlyFullPension(80000 * BRUTTO) * 13));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -463,7 +463,7 @@ describe("V5 Golden Tests", () => {
|
|||||||
expect(ph.expenseEnd).toBe(Math.round(100000 * Math.pow(1.02, 4))); // 108'243
|
expect(ph.expenseEnd).toBe(Math.round(100000 * Math.pow(1.02, 4))); // 108'243
|
||||||
});
|
});
|
||||||
|
|
||||||
it("Einkommen nominal flach bei Lohnerhoehung 0%", () => {
|
it("Einkommen nominal flach bei Lohnerhöhung 0%", () => {
|
||||||
const p = plan({
|
const p = plan({
|
||||||
age: 40,
|
age: 40,
|
||||||
retirementAge: 60,
|
retirementAge: 60,
|
||||||
@@ -475,7 +475,7 @@ describe("V5 Golden Tests", () => {
|
|||||||
expect(ph.incomeEnd).toBe(80000);
|
expect(ph.incomeEnd).toBe(80000);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("Bezugsrate aus Sonstigem Vermoegen fliesst ins Cash (Verzehrrate)", () => {
|
it("Bezugsrate aus Sonstigem Vermögen fliesst ins Cash (Verzehrrate)", () => {
|
||||||
const p = plan({
|
const p = plan({
|
||||||
age: 65,
|
age: 65,
|
||||||
retirementAge: 65,
|
retirementAge: 65,
|
||||||
@@ -506,11 +506,11 @@ describe("V5 Golden Tests", () => {
|
|||||||
});
|
});
|
||||||
const r = computePlan(p);
|
const r = computePlan(p);
|
||||||
const p2 = r.phases[1];
|
const p2 = r.phases[1];
|
||||||
expect(p2.capitalInflow).toBe(10000); // Verkaufserloes aus dem Uebergang
|
expect(p2.capitalInflow).toBe(10000); // Verkaufserlös aus dem Übergang
|
||||||
expect(p2.capitalInvest).toBe(10000); // Zusatzinvestition
|
expect(p2.capitalInvest).toBe(10000); // Zusatzinvestition
|
||||||
expect(p2.cashStart).toBe(0); // Startwert bereits nach Abzug der Investition
|
expect(p2.cashStart).toBe(0); // Startwert bereits nach Abzug der Investition
|
||||||
expect(p2.cashEnd).toBe(0);
|
expect(p2.cashEnd).toBe(0);
|
||||||
// Startvermoegen Phase 2: Cash(0) + Vermoegen B(10'000) = 10'000 (kein Doppelzaehlen).
|
// Startvermögen Phase 2: Cash(0) + Vermögen B(10'000) = 10'000 (kein Doppelzählen).
|
||||||
expect(p2.startWealthNominal).toBe(10000);
|
expect(p2.startWealthNominal).toBe(10000);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -597,7 +597,7 @@ describe("V5 Golden Tests", () => {
|
|||||||
expect(pk.startValue).toBe(200000); // brutto 100'000 dem Kapital entnommen
|
expect(pk.startValue).toBe(200000); // brutto 100'000 dem Kapital entnommen
|
||||||
});
|
});
|
||||||
|
|
||||||
it("Einmaliger Zufluss am Uebergang: nominal erfasst, Steuer abgezogen, direkt ins Cash", () => {
|
it("Einmaliger Zufluss am Übergang: nominal erfasst, Steuer abgezogen, direkt ins Cash", () => {
|
||||||
const p = plan({
|
const p = plan({
|
||||||
age: 40,
|
age: 40,
|
||||||
retirementAge: 70,
|
retirementAge: 70,
|
||||||
@@ -610,13 +610,13 @@ describe("V5 Golden Tests", () => {
|
|||||||
elements: [],
|
elements: [],
|
||||||
});
|
});
|
||||||
const r = computePlan(p);
|
const r = computePlan(p);
|
||||||
expect(r.phases[1].oneOffInflow).toBe(90000); // 100'000 abzueglich 10% Steuer
|
expect(r.phases[1].oneOffInflow).toBe(90000); // 100'000 abzüglich 10% Steuer
|
||||||
expect(r.phases[1].oneOffInflowLabel).toBe("Erbschaft");
|
expect(r.phases[1].oneOffInflowLabel).toBe("Erbschaft");
|
||||||
expect(r.phases[1].cashStart).toBe(91000); // 1'000 + 90'000
|
expect(r.phases[1].cashStart).toBe(91000); // 1'000 + 90'000
|
||||||
expect(r.phases[0].oneOffInflow).toBe(0); // Phase 1 hat keinen eingehenden Uebergang
|
expect(r.phases[0].oneOffInflow).toBe(0); // Phase 1 hat keinen eingehenden Übergang
|
||||||
});
|
});
|
||||||
|
|
||||||
it("Einmalige Kosten am Uebergang: real erfasst, mit Inflation aufgewertet", () => {
|
it("Einmalige Kosten am Übergang: real erfasst, mit Inflation aufgewertet", () => {
|
||||||
// Kosten 20'000 real, 2% Inflation, Grenze nach 10 Jahren -> 20'000 x 1.02^10 = 24'380.
|
// Kosten 20'000 real, 2% Inflation, Grenze nach 10 Jahren -> 20'000 x 1.02^10 = 24'380.
|
||||||
const p = plan({
|
const p = plan({
|
||||||
age: 40,
|
age: 40,
|
||||||
@@ -648,7 +648,7 @@ describe("V5 Golden Tests", () => {
|
|||||||
const r1 = computePlan(beide);
|
const r1 = computePlan(beide);
|
||||||
expect(r1.phases[1].cashStart).toBe(30000); // +50'000 -20'000
|
expect(r1.phases[1].cashStart).toBe(30000); // +50'000 -20'000
|
||||||
|
|
||||||
// Betraege sind erfasst, aber der Entscheid lautet "1:1 uebernehmen" -> keine Wirkung.
|
// Beträge sind erfasst, aber der Entscheid lautet "1:1 übernehmen" -> keine Wirkung.
|
||||||
const keine = plan({
|
const keine = plan({
|
||||||
age: 40, retirementAge: 70, inflation: 0, initialCash: 0,
|
age: 40, retirementAge: 70, inflation: 0, initialCash: 0,
|
||||||
phases: [
|
phases: [
|
||||||
@@ -660,7 +660,7 @@ describe("V5 Golden Tests", () => {
|
|||||||
expect(computePlan(keine).phases[1].cashStart).toBe(0);
|
expect(computePlan(keine).phases[1].cashStart).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("Einmalige Kosten koennen eine Liquiditaetsluecke ausloesen", () => {
|
it("Einmalige Kosten können eine Liquiditätslücke auslösen", () => {
|
||||||
const p = plan({
|
const p = plan({
|
||||||
age: 40, retirementAge: 70, inflation: 0, initialCash: 10000,
|
age: 40, retirementAge: 70, inflation: 0, initialCash: 10000,
|
||||||
phases: [
|
phases: [
|
||||||
@@ -675,7 +675,7 @@ describe("V5 Golden Tests", () => {
|
|||||||
expect(p2.incomplete).toBe(true);
|
expect(p2.incomplete).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("Cash-Entscheid der LETZTEN Phase bleibt wirkungslos (kein Uebergang mehr)", () => {
|
it("Cash-Entscheid der LETZTEN Phase bleibt wirkungslos (kein Übergang mehr)", () => {
|
||||||
const p = plan({
|
const p = plan({
|
||||||
age: 40, retirementAge: 70, inflation: 0, initialCash: 5000,
|
age: 40, retirementAge: 70, inflation: 0, initialCash: 5000,
|
||||||
phases: [{ id: "p1", durationYears: 1, cashTransition: { mode: "INFLOW", inflowAmount: 999999 } }],
|
phases: [{ id: "p1", durationYears: 1, cashTransition: { mode: "INFLOW", inflowAmount: 999999 } }],
|
||||||
@@ -686,7 +686,7 @@ describe("V5 Golden Tests", () => {
|
|||||||
expect(r.nachlass).toBe(5000);
|
expect(r.nachlass).toBe(5000);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("Vermoegen wird pro JAHR mitgefuehrt (Grundlage des Verlaufs ueber alle Jahre)", () => {
|
it("Vermögen wird pro JAHR mitgeführt (Grundlage des Verlaufs über alle Jahre)", () => {
|
||||||
const p = plan({
|
const p = plan({
|
||||||
age: 40, retirementAge: 70, inflation: 0, initialCash: 0,
|
age: 40, retirementAge: 70, inflation: 0, initialCash: 0,
|
||||||
phases: [{ id: "p1", durationYears: 3 }],
|
phases: [{ id: "p1", durationYears: 3 }],
|
||||||
@@ -697,12 +697,12 @@ describe("V5 Golden Tests", () => {
|
|||||||
expect(r.yearly[0].wealthNominal).toBe(110000);
|
expect(r.yearly[0].wealthNominal).toBe(110000);
|
||||||
expect(r.yearly[1].wealthNominal).toBe(121000);
|
expect(r.yearly[1].wealthNominal).toBe(121000);
|
||||||
expect(r.yearly[2].wealthNominal).toBe(133100);
|
expect(r.yearly[2].wealthNominal).toBe(133100);
|
||||||
// Ohne Inflation sind real und nominal identisch; Endjahr = Phasen-Endvermoegen.
|
// Ohne Inflation sind real und nominal identisch; Endjahr = Phasen-Endvermögen.
|
||||||
expect(r.yearly[2].wealthReal).toBe(133100);
|
expect(r.yearly[2].wealthReal).toBe(133100);
|
||||||
expect(r.yearly[2].wealthNominal).toBe(r.phases[0].endWealthNominal);
|
expect(r.yearly[2].wealthNominal).toBe(r.phases[0].endWealthNominal);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("Vermoegen real ist bei Inflation kaufkraftbereinigt", () => {
|
it("Vermögen real ist bei Inflation kaufkraftbereinigt", () => {
|
||||||
const p = plan({
|
const p = plan({
|
||||||
age: 40, retirementAge: 70, inflation: 10, initialCash: 100000,
|
age: 40, retirementAge: 70, inflation: 10, initialCash: 100000,
|
||||||
phases: [{ id: "p1", durationYears: 1 }],
|
phases: [{ id: "p1", durationYears: 1 }],
|
||||||
@@ -713,7 +713,7 @@ describe("V5 Golden Tests", () => {
|
|||||||
expect(y.wealthReal).toBe(Math.round(100000 / 1.1)); // 90'909
|
expect(y.wealthReal).toBe(Math.round(100000 / 1.1)); // 90'909
|
||||||
});
|
});
|
||||||
|
|
||||||
it("Fortschreibung: nominaler Einkommens-Basiswert Phase 1 -> Startwert Phase 2; Cash laeuft fort", () => {
|
it("Fortschreibung: nominaler Einkommens-Basiswert Phase 1 -> Startwert Phase 2; Cash läuft fort", () => {
|
||||||
const p = plan({
|
const p = plan({
|
||||||
age: 40,
|
age: 40,
|
||||||
retirementAge: 70,
|
retirementAge: 70,
|
||||||
|
|||||||
+120
-120
@@ -27,13 +27,13 @@ export interface PersonPhaseInfo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- Rechenweg-Protokoll (Roadmap Nr. 41) ------------------------------------------------
|
// --- Rechenweg-Protokoll (Roadmap Nr. 41) ------------------------------------------------
|
||||||
// Ein Schritt haelt die Formel UND die eingesetzten Zahlen fest. Entscheidend: Die Schritte
|
// Ein Schritt hält die Formel UND die eingesetzten Zahlen fest. Entscheidend: Die Schritte
|
||||||
// entstehen INNERHALB der Berechnung, als Nebenprodukt der Rechnung, die ohnehin laeuft.
|
// entstehen INNERHALB der Berechnung, als Nebenprodukt der Rechnung, die ohnehin läuft.
|
||||||
// Damit kann die angezeigte Herleitung nicht von der tatsaechlichen Rechnung abdriften --
|
// Damit kann die angezeigte Herleitung nicht von der tatsächlichen Rechnung abdriften --
|
||||||
// eine zweite Formel-Implementierung im UI waere die gefaehrlichste Variante ueberhaupt.
|
// eine zweite Formel-Implementierung im UI wäre die gefährlichste Variante überhaupt.
|
||||||
export interface TraceStep {
|
export interface TraceStep {
|
||||||
label: string;
|
label: string;
|
||||||
formula?: string; // abstrakt, z. B. "Basis x (1 + Lohnerhoehung)^(t-1)"
|
formula?: string; // abstrakt, z. B. "Basis x (1 + Lohnerhöhung)^(t-1)"
|
||||||
substituted?: string; // mit eingesetzten Zahlen, z. B. "100'000 x (1 + 2%)^4"
|
substituted?: string; // mit eingesetzten Zahlen, z. B. "100'000 x (1 + 2%)^4"
|
||||||
result: number;
|
result: number;
|
||||||
unit?: "CHF" | "%" | "Jahre" | "";
|
unit?: "CHF" | "%" | "Jahre" | "";
|
||||||
@@ -47,15 +47,15 @@ export interface Trace {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ComputeOptions {
|
export interface ComputeOptions {
|
||||||
// Standardmaessig aus: die Monte-Carlo-Simulation ruft computePlan zehntausendfach auf
|
// Standardmässig aus: die Monte-Carlo-Simulation ruft computePlan zehntausendfach auf
|
||||||
// und darf von der Protokollierung nichts merken.
|
// und darf von der Protokollierung nichts merken.
|
||||||
explain?: boolean;
|
explain?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ein Datenpunkt pro Jahr JE ELEMENT -- Grundlage der Detailansicht (Roadmap Nr. 43).
|
// Ein Datenpunkt pro Jahr JE ELEMENT -- Grundlage der Detailansicht (Roadmap Nr. 43).
|
||||||
// Nur die Phasengrenzen zu kennen reicht nicht: Eine Anlage mit 5 % ueber 20 Jahre waechst
|
// Nur die Phasengrenzen zu kennen reicht nicht: Eine Anlage mit 5 % über 20 Jahre wächst
|
||||||
// konvex, eine Gerade von Start zu Ende waere sichtbar falsch (gleiche Ueberlegung wie beim
|
// konvex, eine Gerade von Start zu Ende wäre sichtbar falsch (gleiche Überlegung wie beim
|
||||||
// Vermoegensverlauf in v0.9).
|
// Vermögensverlauf in v0.9).
|
||||||
export interface ElementYearPoint {
|
export interface ElementYearPoint {
|
||||||
year: number; // 1-basiert ab Planbeginn
|
year: number; // 1-basiert ab Planbeginn
|
||||||
age: number; // Alter Person A am Jahresende
|
age: number; // Alter Person A am Jahresende
|
||||||
@@ -81,47 +81,47 @@ export interface ElementPhaseComputed {
|
|||||||
note: string | null;
|
note: string | null;
|
||||||
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 Uebergangs NACH dieser Phase (nur mit explain)
|
transitionTrace?: Trace; // Rechenweg des Übergangs NACH dieser Phase (nur mit explain)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Wasserfall-Zerlegungen (Roadmap Nr. 43) ---------------------------------------------
|
// --- Wasserfall-Zerlegungen (Roadmap Nr. 43) ---------------------------------------------
|
||||||
//
|
//
|
||||||
// WICHTIG -- der Unterschied zwischen den beiden: Sparraten, Amortisationen und
|
// WICHTIG -- der Unterschied zwischen den beiden: Sparraten, Amortisationen und
|
||||||
// Zusatzinvestitionen sind UMBUCHUNGEN. Sie verlassen das Cash und erhoehen im selben Zug
|
// Zusatzinvestitionen sind UMBUCHUNGEN. Sie verlassen das Cash und erhöhen im selben Zug
|
||||||
// einen Vermoegenswert bzw. senken eine Schuld; das Vermoegen bleibt unveraendert. Sie
|
// einen Vermögenswert bzw. senken eine Schuld; das Vermögen bleibt unverändert. Sie
|
||||||
// gehoeren deshalb in die Cash-Bruecke und ausdruecklich NICHT in die Vermoegens-Bruecke --
|
// gehören deshalb in die Cash-Brücke und ausdrücklich NICHT in die Vermögens-Brücke --
|
||||||
// dort als Abgang gezeichnet wuerden sie eine Vermoegensminderung suggerieren, die es nicht
|
// dort als Abgang gezeichnet würden sie eine Vermögensminderung suggerieren, die es nicht
|
||||||
// gibt.
|
// gibt.
|
||||||
export interface WealthBridge {
|
export interface WealthBridge {
|
||||||
openingWealth: number; // Endvermoegen der Vorphase (Phase 1: Startvermoegen)
|
openingWealth: number; // Endvermögen der Vorphase (Phase 1: Startvermögen)
|
||||||
// Uebergang IN diese Phase -- echte Vermoegensaenderungen an der Phasengrenze:
|
// Übergang IN diese Phase -- echte Vermögensänderungen an der Phasengrenze:
|
||||||
oneOffInflow: number; // einmaliger Zufluss (netto nach Steuer)
|
oneOffInflow: number; // einmaliger Zufluss (netto nach Steuer)
|
||||||
oneOffOutflow: number; // einmalige Kosten
|
oneOffOutflow: number; // einmalige Kosten
|
||||||
transitionTax: number; // Kapitalbezugs- und Grundstueckgewinnsteuer (Abgang)
|
transitionTax: number; // Kapitalbezugs- und Grundstückgewinnsteuer (Abgang)
|
||||||
pensionConversion: number; // in eine Rente umgewandeltes PK-Kapital (verlaesst die Bilanz)
|
pensionConversion: number; // in eine Rente umgewandeltes PK-Kapital (verlässt die Bilanz)
|
||||||
saleGainLoss: number; // Verkaufspreis minus Verkehrswert bei einem Immobilienverkauf
|
saleGainLoss: number; // Verkaufspreis minus Verkehrswert bei einem Immobilienverkauf
|
||||||
startWealth: number; // Kontrollpunkt = startWealthNominal
|
startWealth: number; // Kontrollpunkt = startWealthNominal
|
||||||
// Innerhalb der Phase:
|
// Innerhalb der Phase:
|
||||||
quotaTotal: number; // Summe (Einkommen - nominale Ausgaben) ueber alle Phasenjahre
|
quotaTotal: number; // Summe (Einkommen - nominale Ausgaben) über alle Phasenjahre
|
||||||
investmentReturn: number; // Rendite auf PK/3a/Sonstigem Vermoegen
|
investmentReturn: number; // Rendite auf PK/3a/Sonstigem Vermögen
|
||||||
propertyAppreciation: number; // Wertsteigerung der Liegenschaft
|
propertyAppreciation: number; // Wertsteigerung der Liegenschaft
|
||||||
pensionFundContribution: number; // PK-Beitraege: erhoehen das Vermoegen, ohne Cash zu kosten
|
pensionFundContribution: number; // PK-Beiträge: erhöhen das Vermögen, ohne Cash zu kosten
|
||||||
endWealth: number; // = endWealthNominal
|
endWealth: number; // = endWealthNominal
|
||||||
residual: number; // Rundungsdifferenz (Kontrollgroesse, sollte nahe 0 sein)
|
residual: number; // Rundungsdifferenz (Kontrollgrösse, sollte nahe 0 sein)
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CashBridge {
|
export interface CashBridge {
|
||||||
openingCash: number; // Cash-Endbestand der Vorphase (Phase 1: initialCash)
|
openingCash: number; // Cash-Endbestand der Vorphase (Phase 1: initialCash)
|
||||||
capitalInflow: number; // Verkaeufe + PK-/3a-Bezuege aus dem Uebergang
|
capitalInflow: number; // Verkäufe + PK-/3a-Bezüge aus dem Übergang
|
||||||
oneOffInflow: number;
|
oneOffInflow: number;
|
||||||
immediateRepay: number; // Sofort-Tilgungen und Sonderamortisation (Abgang)
|
immediateRepay: number; // Sofort-Tilgungen und Sonderamortisation (Abgang)
|
||||||
oneOffOutflow: number;
|
oneOffOutflow: number;
|
||||||
investments: number; // Zusatz-/Neuinvestitionen am Phasenanfang (Abgang)
|
investments: number; // Zusatz-/Neuinvestitionen am Phasenanfang (Abgang)
|
||||||
cashStart: number; // Kontrollpunkt
|
cashStart: number; // Kontrollpunkt
|
||||||
quotaTotal: number;
|
quotaTotal: number;
|
||||||
savingRates: number; // 3a + Sparbeitraege (Abgang)
|
savingRates: number; // 3a + Sparbeiträge (Abgang)
|
||||||
debtRates: number; // Amortisationen + Tilgungen (Abgang)
|
debtRates: number; // Amortisationen + Tilgungen (Abgang)
|
||||||
withdrawals: number; // Bezugsraten aus Sonstigem Vermoegen (Zugang)
|
withdrawals: number; // Bezugsraten aus Sonstigem Vermögen (Zugang)
|
||||||
cashEnd: number;
|
cashEnd: number;
|
||||||
residual: number;
|
residual: number;
|
||||||
}
|
}
|
||||||
@@ -143,15 +143,15 @@ export interface PhaseComputed {
|
|||||||
quotaStart: number;
|
quotaStart: number;
|
||||||
quotaEnd: number;
|
quotaEnd: number;
|
||||||
isConsumption: boolean;
|
isConsumption: boolean;
|
||||||
// Geplante Sparrate im ERSTEN Phasenjahr: 3a + Sonstiges-Vermoegen-Sparbeitrag + Amort. +
|
// Geplante Sparrate im ERSTEN Phasenjahr: 3a + Sonstiges-Vermögen-Sparbeitrag + Amort. +
|
||||||
// Tilgung. Amortisation/Tilgung entfallen, sobald Hypothek/Schuld abbezahlt sind -- die Rate
|
// Tilgung. Amortisation/Tilgung entfallen, sobald Hypothek/Schuld abbezahlt sind -- die Rate
|
||||||
// kann in spaeteren Phasenjahren also tiefer liegen.
|
// kann in späteren Phasenjahren also tiefer liegen.
|
||||||
plannedSaveRate: number;
|
plannedSaveRate: number;
|
||||||
plannedWithdrawRate: number; // geplante Verzehrrate: Bezugsraten aus Sonstigem Vermoegen
|
plannedWithdrawRate: number; // geplante Verzehrrate: Bezugsraten aus Sonstigem Vermögen
|
||||||
capitalInflow: number; // Kapitalzufluss: PK-/3a-Bezuege + Verkaeufe (aus dem Uebergang in diese Phase)
|
capitalInflow: number; // Kapitalzufluss: PK-/3a-Bezüge + Verkäufe (aus dem Übergang in diese Phase)
|
||||||
capitalInvest: number; // Kapitalinvestitionen: Zusatz-/Neuinvestitionen + sofortige Tilgungen
|
capitalInvest: number; // Kapitalinvestitionen: Zusatz-/Neuinvestitionen + sofortige Tilgungen
|
||||||
// Einmalige Sonderein-/ausgaben aus dem Uebergang in DIESE Phase (nominal, netto nach Steuer).
|
// Einmalige Sonderein-/ausgaben aus dem Übergang in DIESE Phase (nominal, netto nach Steuer).
|
||||||
// Bewusst getrennt von capitalInflow/capitalInvest: eine Erbschaft ist kein Verkaufserloes,
|
// Bewusst getrennt von capitalInflow/capitalInvest: eine Erbschaft ist kein Verkaufserlös,
|
||||||
// ein Poolbau keine Kapitalinvestition.
|
// ein Poolbau keine Kapitalinvestition.
|
||||||
oneOffInflow: number;
|
oneOffInflow: number;
|
||||||
oneOffInflowLabel: string | null;
|
oneOffInflowLabel: string | null;
|
||||||
@@ -159,29 +159,29 @@ export interface PhaseComputed {
|
|||||||
oneOffOutflowLabel: string | null;
|
oneOffOutflowLabel: string | null;
|
||||||
cashStart: number;
|
cashStart: number;
|
||||||
cashEnd: number;
|
cashEnd: number;
|
||||||
cashNegative: boolean; // Cash faellt in dieser Phase (irgendwann) unter 0 -> Liquiditaetsluecke
|
cashNegative: boolean; // Cash fällt in dieser Phase (irgendwann) unter 0 -> Liquiditätslücke
|
||||||
incomplete: boolean; // roter Status = Liquiditaetsluecke
|
incomplete: boolean; // roter Status = Liquiditätslücke
|
||||||
elements: ElementPhaseComputed[];
|
elements: ElementPhaseComputed[];
|
||||||
startWealthNominal: number; // inkl. Cash
|
startWealthNominal: number; // inkl. Cash
|
||||||
endWealthNominal: number; // inkl. Cash
|
endWealthNominal: number; // inkl. Cash
|
||||||
cumulativeInflationStart: number; // Kaufkraft-Deflator zu Phasenbeginn (Bestandswerte)
|
cumulativeInflationStart: number; // Kaufkraft-Deflator zu Phasenbeginn (Bestandswerte)
|
||||||
cumulativeInflationEnd: number; // Kaufkraft-Deflator am Phasenende (Bestandswerte)
|
cumulativeInflationEnd: number; // Kaufkraft-Deflator am Phasenende (Bestandswerte)
|
||||||
flowDeflatorEnd: number; // Deflator fuer den Flow-Endwert (Jahr `duration`)
|
flowDeflatorEnd: number; // Deflator für den Flow-Endwert (Jahr `duration`)
|
||||||
endWealthReal: number;
|
endWealthReal: number;
|
||||||
wealthBridge: WealthBridge;
|
wealthBridge: WealthBridge;
|
||||||
cashBridge: CashBridge;
|
cashBridge: CashBridge;
|
||||||
traces?: Trace[]; // Rechenwege der Phasen-Kennzahlen (nur mit explain)
|
traces?: Trace[]; // Rechenwege der Phasen-Kennzahlen (nur mit explain)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ein Datenpunkt pro Jahr (ueber alle Phasen), fuer die Verlaufsgrafik.
|
// Ein Datenpunkt pro Jahr (über alle Phasen), für die Verlaufsgrafik.
|
||||||
export interface YearPoint {
|
export interface YearPoint {
|
||||||
year: number; // 1-basiert ab Planbeginn
|
year: number; // 1-basiert ab Planbeginn
|
||||||
age: number; // Alter Person A am Jahresende
|
age: number; // Alter Person A am Jahresende
|
||||||
income: number; // Einkommen inkl. Renten (nominal)
|
income: number; // Einkommen inkl. Renten (nominal)
|
||||||
expenseNominal: number;
|
expenseNominal: number;
|
||||||
expenseReal: number;
|
expenseReal: number;
|
||||||
// Gesamtvermoegen am Jahresende (inkl. Cash, abzgl. Schulden) -- fuer den
|
// Gesamtvermögen am Jahresende (inkl. Cash, abzgl. Schulden) -- für den
|
||||||
// Vermoegensverlauf ueber ALLE Jahre statt nur ueber die Phasengrenzen.
|
// Vermögensverlauf über ALLE Jahre statt nur über die Phasengrenzen.
|
||||||
wealthNominal: number;
|
wealthNominal: number;
|
||||||
wealthReal: number;
|
wealthReal: number;
|
||||||
}
|
}
|
||||||
@@ -190,8 +190,8 @@ export interface PlanComputed {
|
|||||||
phases: PhaseComputed[];
|
phases: PhaseComputed[];
|
||||||
yearly: YearPoint[];
|
yearly: YearPoint[];
|
||||||
nachlass: number;
|
nachlass: number;
|
||||||
ruinAge: number | null; // Alter (Person A), in dem das Gesamtvermoegen (inkl. Cash) erstmals < 0 faellt
|
ruinAge: number | null; // Alter (Person A), in dem das Gesamtvermögen (inkl. Cash) erstmals < 0 fällt
|
||||||
ahvCareer: AhvCareer[]; // Beitragskarriere je Person (fuer die AHV-Pruefung am Uebergang)
|
ahvCareer: AhvCareer[]; // Beitragskarriere je Person (für die AHV-Prüfung am Übergang)
|
||||||
traces?: Trace[]; // plan-weite Rechenwege: Deflatoren, AHV-Karriere, Ruinalter (nur mit explain)
|
traces?: Trace[]; // plan-weite Rechenwege: Deflatoren, AHV-Karriere, Ruinalter (nur mit explain)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -203,7 +203,7 @@ export interface PlanComputed {
|
|||||||
// mdJE >= 72 x R0 -> Maximalrente 2 x R0
|
// mdJE >= 72 x R0 -> Maximalrente 2 x R0
|
||||||
// Verifiziert gegen die amtliche Tabelle 318.117.1 (51/51 Zeilen exakt, siehe Tests).
|
// Verifiziert gegen die amtliche Tabelle 318.117.1 (51/51 Zeilen exakt, siehe Tests).
|
||||||
// Bewusst STETIG gerechnet: die amtliche Tabelle stuft das mdJE auf Vielfache von 1.2 x R0;
|
// Bewusst STETIG gerechnet: die amtliche Tabelle stuft das mdJE auf Vielfache von 1.2 x R0;
|
||||||
// fuer eine Planung ist der stetige Wert naeher an der Wahrheit (Abweichung < 20/Monat).
|
// für eine Planung ist der stetige Wert näher an der Wahrheit (Abweichung < 20/Monat).
|
||||||
export function ahvMonthlyFullPension(mdJE: number): number {
|
export function ahvMonthlyFullPension(mdJE: number): number {
|
||||||
const r0 = AHV_MIN_MONTHLY_FULL;
|
const r0 = AHV_MIN_MONTHLY_FULL;
|
||||||
if (mdJE <= 12 * r0) return r0;
|
if (mdJE <= 12 * r0) return r0;
|
||||||
@@ -212,20 +212,20 @@ export function ahvMonthlyFullPension(mdJE: number): number {
|
|||||||
return mdJE <= 36 * r0 ? r0 * (0.74 + 0.26 * x) : r0 * (1.04 + 0.16 * x);
|
return mdJE <= 36 * r0 ? r0 * (0.74 + 0.26 * x) : r0 * (1.04 + 0.16 * x);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Beitragskarriere einer Person fuer die AHV -- akkumuliert ueber die Erwerbsphasen des Plans.
|
// Beitragskarriere einer Person für die AHV -- akkumuliert über die Erwerbsphasen des Plans.
|
||||||
// ACHTUNG: Alle Einkommen sind BRUTTO. Das Tool erfasst netto (so stimmt der Cash-Fluss), die
|
// ACHTUNG: Alle Einkommen sind BRUTTO. Das Tool erfasst netto (so stimmt der Cash-Fluss), die
|
||||||
// AHV bemisst sich aber am Bruttolohn -- die Umrechnung passiert beim Aufbau der Karriere.
|
// AHV bemisst sich aber am Bruttolohn -- die Umrechnung passiert beim Aufbau der Karriere.
|
||||||
export interface AhvCareer {
|
export interface AhvCareer {
|
||||||
personId: string;
|
personId: string;
|
||||||
role: PersonRole;
|
role: PersonRole;
|
||||||
plannedAvgGrossIncome: number; // reales BRUTTO-Durchschnittseinkommen der Beitragsjahre im Plan
|
plannedAvgGrossIncome: number; // reales BRUTTO-Durchschnittseinkommen der Beitragsjahre im Plan
|
||||||
planYears: number; // Beitragsjahre im Plan (Dauer abzueglich Ausfalljahre)
|
planYears: number; // Beitragsjahre im Plan (Dauer abzüglich Ausfalljahre)
|
||||||
yearsBeforePlan: number; // Jahre zwischen Alter 21 und Planbeginn
|
yearsBeforePlan: number; // Jahre zwischen Alter 21 und Planbeginn
|
||||||
gapYearsInPlan: number;
|
gapYearsInPlan: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Massgebendes durchschnittliches Jahreseinkommen: gewichteter Mittelwert der realen
|
// Massgebendes durchschnittliches Jahreseinkommen: gewichteter Mittelwert der realen
|
||||||
// Einkommen ueber ALLE Beitragsjahre (vor Planbeginn + im Plan). REAL gerechnet, weil die
|
// Einkommen über ALLE Beitragsjahre (vor Planbeginn + im Plan). REAL gerechnet, weil die
|
||||||
// AHV vergangene Einkommen aufwertet UND die Schwellen indexiert -- beides hebt sich in
|
// AHV vergangene Einkommen aufwertet UND die Schwellen indexiert -- beides hebt sich in
|
||||||
// realer Betrachtung weitgehend auf.
|
// realer Betrachtung weitgehend auf.
|
||||||
// `avgGrossIncomeBefore` ist -- wie die Karriere -- ein BRUTTO-Wert.
|
// `avgGrossIncomeBefore` ist -- wie die Karriere -- ein BRUTTO-Wert.
|
||||||
@@ -236,7 +236,7 @@ export function ahvMdje(career: AhvCareer, avgGrossIncomeBefore: number, gapYear
|
|||||||
return (avgGrossIncomeBefore * yearsBefore + career.plannedAvgGrossIncome * career.planYears) / totalYears;
|
return (avgGrossIncomeBefore * yearsBefore + career.plannedAvgGrossIncome * career.planYears) / totalYears;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Jaehrliche AHV-Rente: Vollrente zum mdJE, mal 13 Zahlungen, gekuerzt um die Ausfalljahre
|
// Jährliche AHV-Rente: Vollrente zum mdJE, mal 13 Zahlungen, gekürzt um die Ausfalljahre
|
||||||
// (Rentenskala: pro fehlendes Beitragsjahr 1/44).
|
// (Rentenskala: pro fehlendes Beitragsjahr 1/44).
|
||||||
export function ahvAnnualPension(mdJE: number, totalGapYears: number): number {
|
export function ahvAnnualPension(mdJE: number, totalGapYears: number): number {
|
||||||
const factor = Math.max(
|
const factor = Math.max(
|
||||||
@@ -251,7 +251,7 @@ export function ahvYearsBeforePlan(ageAtPlanStart: number): number {
|
|||||||
return Math.max(0, ageAtPlanStart - AHV_CONTRIBUTION_START_AGE);
|
return Math.max(0, ageAtPlanStart - AHV_CONTRIBUTION_START_AGE);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Maximale Dauer einer neuen Phase bis zum naechsten Pensionsereignis (null = unbegrenzt).
|
// Maximale Dauer einer neuen Phase bis zum nächsten Pensionsereignis (null = unbegrenzt).
|
||||||
export function maxPhaseDuration(
|
export function maxPhaseDuration(
|
||||||
persons: { role: PersonRole; age: number; retirementAge: number }[],
|
persons: { role: PersonRole; age: number; retirementAge: number }[],
|
||||||
yearsBefore: number
|
yearsBefore: number
|
||||||
@@ -266,13 +266,13 @@ export function maxPhaseDuration(
|
|||||||
|
|
||||||
interface Carry {
|
interface Carry {
|
||||||
status: ElementStatus;
|
status: ElementStatus;
|
||||||
value: number; // Aktiven-Saldo (PK/3a/Sonstiges Vermoegen) am Ende der Vorphase
|
value: number; // Aktiven-Saldo (PK/3a/Sonstiges Vermögen) am Ende der Vorphase
|
||||||
mortgage: number; // Immobilie: Resthypothek
|
mortgage: number; // Immobilie: Resthypothek
|
||||||
propertyValue: number; // Immobilie: Verkehrswert am Ende der Vorphase (inkl. Wertsteigerung)
|
propertyValue: number; // Immobilie: Verkehrswert am Ende der Vorphase (inkl. Wertsteigerung)
|
||||||
propertyPurchase: number; // Immobilie: urspruenglicher Kaufpreis (Basis der Grundstueckgewinnsteuer)
|
propertyPurchase: number; // Immobilie: ursprünglicher Kaufpreis (Basis der Grundstückgewinnsteuer)
|
||||||
owed: number; // Schulden: Restschuld (positiv)
|
owed: number; // Schulden: Restschuld (positiv)
|
||||||
pkPensionAnnual: number; // PK: jaehrliche Rente nach Verrentung
|
pkPensionAnnual: number; // PK: jährliche Rente nach Verrentung
|
||||||
flowBasis: number; // Einkommen/Ausgaben: indexierter Basiswert der naechsten Phase
|
flowBasis: number; // Einkommen/Ausgaben: indexierter Basiswert der nächsten Phase
|
||||||
hasCarry: boolean;
|
hasCarry: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -296,12 +296,12 @@ function fmt(v: number): string {
|
|||||||
return sign + Math.abs(rounded).toString().replace(/\B(?=(\d{3})+(?!\d))/g, "'");
|
return sign + Math.abs(rounded).toString().replace(/\B(?=(\d{3})+(?!\d))/g, "'");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generisch, damit der Aufrufer den vollen Personen-Typ (inkl. age) behaelt.
|
// Generisch, damit der Aufrufer den vollen Personen-Typ (inkl. age) behält.
|
||||||
function personByRole<T extends { role: PersonRole }>(persons: T[], role: string): T | null {
|
function personByRole<T extends { role: PersonRole }>(persons: T[], role: string): T | null {
|
||||||
return persons.find((p) => p.role === role) ?? null;
|
return persons.find((p) => p.role === role) ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ein Zufalls-Szenario fuer die Monte-Carlo-Simulation: liefert je Jahr eine Inflation und
|
// Ein Zufalls-Szenario für die Monte-Carlo-Simulation: liefert je Jahr eine Inflation und
|
||||||
// je Element/Jahr eine Rendite. Ohne Sample rechnet computePlan rein deterministisch (die
|
// je Element/Jahr eine Rendite. Ohne Sample rechnet computePlan rein deterministisch (die
|
||||||
// geplanten Annahmen), mit Sample einen einzelnen simulierten Pfad. Jahr ist 1-basiert
|
// geplanten Annahmen), mit Sample einen einzelnen simulierten Pfad. Jahr ist 1-basiert
|
||||||
// (ab Planbeginn); der Inflations-Index ist 0-basiert (inflation[0] = Jahr 1).
|
// (ab Planbeginn); der Inflations-Index ist 0-basiert (inflation[0] = Jahr 1).
|
||||||
@@ -310,7 +310,7 @@ export interface PlanSample {
|
|||||||
assetReturn: (elementId: string, year: number) => number;
|
assetReturn: (elementId: string, year: number) => number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Kurzform fuer einen Rechenschritt. `pct` formatiert Prozentsaetze lesbar (2 statt 2.0000001).
|
// Kurzform für einen Rechenschritt. `pct` formatiert Prozentsätze lesbar (2 statt 2.0000001).
|
||||||
function st(
|
function st(
|
||||||
label: string,
|
label: string,
|
||||||
result: number,
|
result: number,
|
||||||
@@ -336,7 +336,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
|
|||||||
|
|
||||||
// Kumulierter Inflations-Deflator je Jahr (cumInfl[0] = 1, cumInfl[k] = Kaufkraftfaktor nach
|
// Kumulierter Inflations-Deflator je Jahr (cumInfl[0] = 1, cumInfl[k] = Kaufkraftfaktor nach
|
||||||
// k Jahren). Deterministisch identisch zur bisherigen (1+infl)^k-Formel; mit Sample variiert
|
// k Jahren). Deterministisch identisch zur bisherigen (1+infl)^k-Formel; mit Sample variiert
|
||||||
// die Inflation pro Jahr. Ersetzt die frueheren geschlossenen Potenz-Ausdruecke.
|
// die Inflation pro Jahr. Ersetzt die früheren geschlossenen Potenz-Ausdrücke.
|
||||||
const totalYears = phases.reduce((s, p) => s + Math.max(1, p.durationYears), 0);
|
const totalYears = phases.reduce((s, p) => s + Math.max(1, p.durationYears), 0);
|
||||||
const inflationOfYear = (year: number) =>
|
const inflationOfYear = (year: number) =>
|
||||||
sample ? sample.inflation[year - 1] ?? plan.inflationRateDefault : plan.inflationRateDefault;
|
sample ? sample.inflation[year - 1] ?? plan.inflationRateDefault : plan.inflationRateDefault;
|
||||||
@@ -347,7 +347,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
|
|||||||
// AHV-Beitragskarriere je Person: reales Einkommen x Beitragsjahre, und Beitragsjahre.
|
// AHV-Beitragskarriere je Person: reales Einkommen x Beitragsjahre, und Beitragsjahre.
|
||||||
const ahvIncomeAccum = new Map<string, number>();
|
const ahvIncomeAccum = new Map<string, number>();
|
||||||
const ahvYearsAccum = new Map<string, number>();
|
const ahvYearsAccum = new Map<string, number>();
|
||||||
// Karriere VOR Planbeginn -- aus der Pruefung am Pensions-Uebergang bzw. (fuer bereits
|
// Karriere VOR Planbeginn -- aus der Prüfung am Pensions-Übergang bzw. (für bereits
|
||||||
// Pensionierte) aus der Phasenzelle der ersten Phase.
|
// Pensionierte) aus der Phasenzelle der ersten Phase.
|
||||||
const ahvBeforeByPerson = new Map<string, { avg: number; gap: number }>();
|
const ahvBeforeByPerson = new Map<string, { avg: number; gap: number }>();
|
||||||
const carries = new Map<string, Carry>();
|
const carries = new Map<string, Carry>();
|
||||||
@@ -358,18 +358,18 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
|
|||||||
let yearsBefore = 0;
|
let yearsBefore = 0;
|
||||||
let cumulativeInflation = 1;
|
let cumulativeInflation = 1;
|
||||||
let cashCarryIn = Math.round(plan.initialCash || 0);
|
let cashCarryIn = Math.round(plan.initialCash || 0);
|
||||||
// Aus dem Uebergang der Vorphase in DIESE Phase fliessende Groessen (Kopf-Kennzahlen).
|
// Aus dem Übergang der Vorphase in DIESE Phase fliessende Grössen (Kopf-Kennzahlen).
|
||||||
let incomingInflow = 0; // Brutto-Zufluss: Verkaeufe + PK-/3a-Bezuege
|
let incomingInflow = 0; // Brutto-Zufluss: Verkäufe + PK-/3a-Bezüge
|
||||||
let incomingImmediateRepay = 0; // sofortige Schuldentilgungen (Abfluss)
|
let incomingImmediateRepay = 0; // sofortige Schuldentilgungen (Abfluss)
|
||||||
let incomingOneOffInflow = 0; // einmaliger Sonderzufluss (netto nach Steuer)
|
let incomingOneOffInflow = 0; // einmaliger Sonderzufluss (netto nach Steuer)
|
||||||
let incomingOneOffInflowLabel: string | null = null;
|
let incomingOneOffInflowLabel: string | null = null;
|
||||||
let incomingOneOffOutflow = 0; // einmalige Sonderkosten (nominal)
|
let incomingOneOffOutflow = 0; // einmalige Sonderkosten (nominal)
|
||||||
let incomingOneOffOutflowLabel: string | null = null;
|
let incomingOneOffOutflowLabel: string | null = null;
|
||||||
// Echte Vermoegensaenderungen am Uebergang IN die Folgephase (fuer die Vermoegens-Bruecke).
|
// Echte Vermögensänderungen am Übergang IN die Folgephase (für die Vermögens-Brücke).
|
||||||
let incomingTax = 0; // Kapitalbezugs- + Grundstueckgewinnsteuer
|
let incomingTax = 0; // Kapitalbezugs- + Grundstückgewinnsteuer
|
||||||
let incomingPensionConversion = 0; // in eine Rente umgewandeltes PK-Kapital
|
let incomingPensionConversion = 0; // in eine Rente umgewandeltes PK-Kapital
|
||||||
let incomingSaleGainLoss = 0; // Verkaufspreis minus Verkehrswert
|
let incomingSaleGainLoss = 0; // Verkaufspreis minus Verkehrswert
|
||||||
let previousEndWealth = 0; // Endvermoegen der Vorphase (Startpunkt der Bruecke)
|
let previousEndWealth = 0; // Endvermögen der Vorphase (Startpunkt der Brücke)
|
||||||
let previousCashEnd = Math.round(plan.initialCash || 0);
|
let previousCashEnd = Math.round(plan.initialCash || 0);
|
||||||
let ruinAge: number | null = null;
|
let ruinAge: number | null = null;
|
||||||
|
|
||||||
@@ -402,7 +402,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
|
|||||||
const maxDurationYears = capsFromWorking.length > 0 ? Math.min(...capsFromWorking) : null;
|
const maxDurationYears = capsFromWorking.length > 0 ? Math.min(...capsFromWorking) : null;
|
||||||
const workingByPerson = new Map(personInfos.map((p) => [p.personId, p.working]));
|
const workingByPerson = new Map(personInfos.map((p) => [p.personId, p.working]));
|
||||||
|
|
||||||
// Ausfalljahre kumulieren (nur waehrend der Erwerbstaetigkeit).
|
// Ausfalljahre kumulieren (nur während der Erwerbstätigkeit).
|
||||||
const gapThisPhase = new Map<string, number>();
|
const gapThisPhase = new Map<string, number>();
|
||||||
for (const e of plan.elements) {
|
for (const e of plan.elements) {
|
||||||
if (e.category !== "AHV" || !e.ownerRole) continue;
|
if (e.category !== "AHV" || !e.ownerRole) continue;
|
||||||
@@ -413,13 +413,13 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
|
|||||||
gapYearsByPerson.set(owner.id, (gapYearsByPerson.get(owner.id) ?? 0) + gy);
|
gapYearsByPerson.set(owner.id, (gapYearsByPerson.get(owner.id) ?? 0) + gy);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bereits bei Planbeginn pensioniert: es gibt keinen Pensions-Uebergang, an dem die
|
// Bereits bei Planbeginn pensioniert: es gibt keinen Pensions-Übergang, an dem die
|
||||||
// Beitragskarriere geprueft werden koennte -- die Werte liegen dann in der Phasenzelle.
|
// Beitragskarriere geprüft werden könnte -- die Werte liegen dann in der Phasenzelle.
|
||||||
for (const e of plan.elements) {
|
for (const e of plan.elements) {
|
||||||
if (e.category !== "AHV" || !e.ownerRole) continue;
|
if (e.category !== "AHV" || !e.ownerRole) continue;
|
||||||
const owner = personByRole(persons, e.ownerRole);
|
const owner = personByRole(persons, e.ownerRole);
|
||||||
if (!owner || workingByPerson.get(owner.id)) continue;
|
if (!owner || workingByPerson.get(owner.id)) continue;
|
||||||
if (ahvBeforeByPerson.has(owner.id)) continue; // aus dem Uebergang bereits gesetzt
|
if (ahvBeforeByPerson.has(owner.id)) continue; // aus dem Übergang bereits gesetzt
|
||||||
const pd = e.phaseValues[phase.id] ?? {};
|
const pd = e.phaseValues[phase.id] ?? {};
|
||||||
ahvBeforeByPerson.set(owner.id, {
|
ahvBeforeByPerson.set(owner.id, {
|
||||||
avg: num(pd.avgIncomeBefore),
|
avg: num(pd.avgIncomeBefore),
|
||||||
@@ -427,7 +427,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// AHV-Renten der Pensionierten: Vollrente zum mdJE, gekuerzt um die Ausfalljahre.
|
// AHV-Renten der Pensionierten: Vollrente zum mdJE, gekürzt um die Ausfalljahre.
|
||||||
const ahvUncapped = new Map<string, number>();
|
const ahvUncapped = new Map<string, number>();
|
||||||
for (const e of plan.elements) {
|
for (const e of plan.elements) {
|
||||||
if (e.category !== "AHV" || !e.ownerRole) continue;
|
if (e.category !== "AHV" || !e.ownerRole) continue;
|
||||||
@@ -446,16 +446,16 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
|
|||||||
if (sum > cap && sum > 0) for (const [pid, v] of ahvUncapped) ahvFinal.set(pid, Math.round(v * (cap / sum)));
|
if (sum > cap && sum > 0) for (const [pid, v] of ahvUncapped) ahvFinal.set(pid, Math.round(v * (cap / sum)));
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Element-Laufzeitzustaende aufbauen ---
|
// --- Element-Laufzeitzustände aufbauen ---
|
||||||
const orderedElements = [...plan.elements].sort((a, b) => a.orderIndex - b.orderIndex);
|
const orderedElements = [...plan.elements].sort((a, b) => a.orderIndex - b.orderIndex);
|
||||||
const ecById = new Map<string, ElementPhaseComputed>();
|
const ecById = new Map<string, ElementPhaseComputed>();
|
||||||
// Reales Durchschnittseinkommen dieser Phase je Person (fuer die AHV-Karriere).
|
// Reales Durchschnittseinkommen dieser Phase je Person (für die AHV-Karriere).
|
||||||
const phaseRealIncomeByPerson = new Map<string, number>();
|
const phaseRealIncomeByPerson = new Map<string, number>();
|
||||||
const incomes: { basis: number; idx: number; ec: ElementPhaseComputed }[] = [];
|
const incomes: { basis: number; idx: number; ec: ElementPhaseComputed }[] = [];
|
||||||
const expenses: { basis: number; idx: number; ec: ElementPhaseComputed }[] = [];
|
const expenses: { basis: number; idx: number; ec: ElementPhaseComputed }[] = [];
|
||||||
let renteTotal = 0; // AHV + PK-Renten (nominal fix)
|
let renteTotal = 0; // AHV + PK-Renten (nominal fix)
|
||||||
// `isPk` fuer die Vermoegens-Bruecke: PK-Beitraege verlassen das Cash NICHT (sie sind im
|
// `isPk` für die Vermögens-Brücke: PK-Beiträge verlassen das Cash NICHT (sie sind im
|
||||||
// Nettolohn bereits abgezogen), erhoehen aber das Vermoegen -- sie sind also ein echter
|
// Nettolohn bereits abgezogen), erhöhen aber das Vermögen -- sie sind also ein echter
|
||||||
// Zugang und keine Umbuchung.
|
// Zugang und keine Umbuchung.
|
||||||
const assets: {
|
const assets: {
|
||||||
value: number;
|
value: number;
|
||||||
@@ -467,7 +467,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
|
|||||||
}[] = [];
|
}[] = [];
|
||||||
// mortgage/owed sind LAUFENDE Salden: sie werden in der Jahresschleife abgebaut und am
|
// mortgage/owed sind LAUFENDE Salden: sie werden in der Jahresschleife abgebaut und am
|
||||||
// Nullpunkt gestoppt (keine Rate mehr, sobald abbezahlt). `value` ist der Verkehrswert der
|
// Nullpunkt gestoppt (keine Rate mehr, sobald abbezahlt). `value` ist der Verkehrswert der
|
||||||
// Liegenschaft (waechst mit valueGrowth), `purchase` der urspruengliche Kaufpreis.
|
// Liegenschaft (wächst mit valueGrowth), `purchase` der ursprüngliche Kaufpreis.
|
||||||
const realEstates: {
|
const realEstates: {
|
||||||
value: number;
|
value: number;
|
||||||
purchase: number;
|
purchase: number;
|
||||||
@@ -479,8 +479,8 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
|
|||||||
ec: ElementPhaseComputed;
|
ec: ElementPhaseComputed;
|
||||||
}[] = [];
|
}[] = [];
|
||||||
const debts: { owed: number; repay: number; ec: ElementPhaseComputed }[] = [];
|
const debts: { owed: number; repay: number; ec: ElementPhaseComputed }[] = [];
|
||||||
let fixedRatesTotal = 0; // Sparraten mit konstantem Jahresbetrag: 3a + Sonstiges Vermoegen
|
let fixedRatesTotal = 0; // Sparraten mit konstantem Jahresbetrag: 3a + Sonstiges Vermögen
|
||||||
let plannedWithdrawTotal = 0; // Bezugsraten (fliessen ins Cash): Sonstiges Vermoegen
|
let plannedWithdrawTotal = 0; // Bezugsraten (fliessen ins Cash): Sonstiges Vermögen
|
||||||
let investmentsFromCash = 0; // Neuinvestitionen/Aufstockungen (ab Phase 2, aus Cash)
|
let investmentsFromCash = 0; // Neuinvestitionen/Aufstockungen (ab Phase 2, aus Cash)
|
||||||
let wealthStart = 0;
|
let wealthStart = 0;
|
||||||
let wealthEnd = 0; // wird nach der Jahresschleife gefuellt
|
let wealthEnd = 0; // wird nach der Jahresschleife gefuellt
|
||||||
@@ -524,10 +524,10 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
|
|||||||
switch (e.category) {
|
switch (e.category) {
|
||||||
case "INCOME":
|
case "INCOME":
|
||||||
case "EXPENSE": {
|
case "EXPENSE": {
|
||||||
// Einkommen: Basis + Rate sind NOMINAL (Lohnerhoehung). Ausgaben: Basis + Rate sind
|
// Einkommen: Basis + Rate sind NOMINAL (Lohnerhöhung). Ausgaben: Basis + Rate sind
|
||||||
// REAL (heutige Kaufkraft; reale Mehrausgaben) -- die Inflation kommt separat dazu.
|
// REAL (heutige Kaufkraft; reale Mehrausgaben) -- die Inflation kommt separat dazu.
|
||||||
// Rate-Default = 0 %. Basiswert ab Phase 2 = fortgeschriebener Wert der Vorphase
|
// Rate-Default = 0 %. Basiswert ab Phase 2 = fortgeschriebener Wert der Vorphase
|
||||||
// (nominal fuer Einkommen, real fuer Ausgaben), ausser bewusst geaendert (pd.amount).
|
// (nominal für Einkommen, real für Ausgaben), ausser bewusst geändert (pd.amount).
|
||||||
const idx = num(pd.teuerungsausgleich, 0);
|
const idx = num(pd.teuerungsausgleich, 0);
|
||||||
ec.baseValue = carry.hasCarry ? Math.round(carry.flowBasis) : Math.round(num(pd.amount));
|
ec.baseValue = carry.hasCarry ? Math.round(carry.flowBasis) : Math.round(num(pd.amount));
|
||||||
const basis = !carry.hasCarry
|
const basis = !carry.hasCarry
|
||||||
@@ -537,15 +537,15 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
|
|||||||
: ec.baseValue;
|
: ec.baseValue;
|
||||||
(e.category === "INCOME" ? incomes : expenses).push({ basis, idx, ec });
|
(e.category === "INCOME" ? incomes : expenses).push({ basis, idx, ec });
|
||||||
|
|
||||||
// AHV: reales Erwerbseinkommen der Person mitfuehren. Nur Einkommen, die einer
|
// AHV: reales Erwerbseinkommen der Person mitführen. Nur Einkommen, die einer
|
||||||
// Person zugeordnet sind -- bei einem Einzelplan zaehlt "Gemeinsam" zur Person A.
|
// Person zugeordnet sind -- bei einem Einzelplan zählt "Gemeinsam" zur Person A.
|
||||||
// Das Feld ist NETTO erfasst; die AHV bemisst sich am Bruttolohn -> hochrechnen.
|
// Das Feld ist NETTO erfasst; die AHV bemisst sich am Bruttolohn -> hochrechnen.
|
||||||
if (e.category === "INCOME" && ownerWorking) {
|
if (e.category === "INCOME" && ownerWorking) {
|
||||||
const attributed =
|
const attributed =
|
||||||
owner ?? (plan.householdType === "SINGLE" && e.ownerRole === "HOUSEHOLD" ? personA : null);
|
owner ?? (plan.householdType === "SINGLE" && e.ownerRole === "HOUSEHOLD" ? personA : null);
|
||||||
if (attributed) {
|
if (attributed) {
|
||||||
// Die AHV-Karriere ist eine Real-Groesse auf Planungsbasis -- bewusst mit der
|
// Die AHV-Karriere ist eine Real-Grösse auf Planungsbasis -- bewusst mit der
|
||||||
// festen Plan-Inflation, nicht der (evtl. gewuerfelten) Sample-Inflation.
|
// festen Plan-Inflation, nicht der (evtl. gewürfelten) Sample-Inflation.
|
||||||
const avgRealGross =
|
const avgRealGross =
|
||||||
avgRealFlow(basis, idx, plan.inflationRateDefault, duration, cumInflStart) * AHV_GROSS_FROM_NET_FACTOR;
|
avgRealFlow(basis, idx, plan.inflationRateDefault, duration, cumInflStart) * AHV_GROSS_FROM_NET_FACTOR;
|
||||||
phaseRealIncomeByPerson.set(
|
phaseRealIncomeByPerson.set(
|
||||||
@@ -555,7 +555,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Basiswert der Folgephase fortschreiben (nominal fuer Einkommen, real fuer Ausgaben).
|
// Basiswert der Folgephase fortschreiben (nominal für Einkommen, real für Ausgaben).
|
||||||
carry.flowBasis = basis * Math.pow(1 + idx / 100, duration);
|
carry.flowBasis = basis * Math.pow(1 + idx / 100, duration);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -579,13 +579,13 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
|
|||||||
ec.endValue = carry.pkPensionAnnual;
|
ec.endValue = carry.pkPensionAnnual;
|
||||||
ec.summary = `Rente ${fmt(carry.pkPensionAnnual)}`;
|
ec.summary = `Rente ${fmt(carry.pkPensionAnnual)}`;
|
||||||
} else if (!ownerWorking) {
|
} else if (!ownerWorking) {
|
||||||
ec.note = "Vollstaendig bezogen";
|
ec.note = "Vollständig bezogen";
|
||||||
ec.summary = "Bezogen";
|
ec.summary = "Bezogen";
|
||||||
} else {
|
} else {
|
||||||
const base = carry.hasCarry ? carry.value : Math.round(num(pd.currentValue));
|
const base = carry.hasCarry ? carry.value : Math.round(num(pd.currentValue));
|
||||||
const topUp = carry.hasCarry ? Math.round(num(pd.additionalInvestment)) : 0;
|
const topUp = carry.hasCarry ? Math.round(num(pd.additionalInvestment)) : 0;
|
||||||
const start = base + topUp;
|
const start = base + topUp;
|
||||||
const rate = Math.round(num(pd.annualContribution)); // PK-Beitrag zaehlt NICHT zur Quote
|
const rate = Math.round(num(pd.annualContribution)); // PK-Beitrag zählt NICHT zur Quote
|
||||||
if (!isFirstPhase) investmentsFromCash += topUp;
|
if (!isFirstPhase) investmentsFromCash += topUp;
|
||||||
ec.baseValue = base;
|
ec.baseValue = base;
|
||||||
ec.startValue = start;
|
ec.startValue = start;
|
||||||
@@ -596,7 +596,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
|
|||||||
}
|
}
|
||||||
case "PILLAR_3A": {
|
case "PILLAR_3A": {
|
||||||
if (!ownerWorking) {
|
if (!ownerWorking) {
|
||||||
ec.note = "Vollstaendig bezogen";
|
ec.note = "Vollständig bezogen";
|
||||||
ec.summary = "Bezogen";
|
ec.summary = "Bezogen";
|
||||||
} else {
|
} else {
|
||||||
const base = carry.hasCarry ? carry.value : Math.round(num(pd.currentValue));
|
const base = carry.hasCarry ? carry.value : Math.round(num(pd.currentValue));
|
||||||
@@ -628,8 +628,8 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "REAL_ESTATE": {
|
case "REAL_ESTATE": {
|
||||||
// Urspruenglicher Kaufpreis (Basis der Grundstueckgewinnsteuer) und Verkehrswert
|
// Ursprünglicher Kaufpreis (Basis der Grundstückgewinnsteuer) und Verkehrswert
|
||||||
// (waechst mit der Wertsteigerung) laufen getrennt.
|
// (wächst mit der Wertsteigerung) laufen getrennt.
|
||||||
const purchase = carry.hasCarry ? carry.propertyPurchase : Math.round(num(pd.purchasePrice));
|
const purchase = carry.hasCarry ? carry.propertyPurchase : Math.round(num(pd.purchasePrice));
|
||||||
const valueStart = carry.hasCarry ? carry.propertyValue : Math.round(num(pd.purchasePrice));
|
const valueStart = carry.hasCarry ? carry.propertyValue : Math.round(num(pd.purchasePrice));
|
||||||
const mortgageStart = carry.hasCarry ? carry.mortgage : Math.round(num(pd.mortgage));
|
const mortgageStart = carry.hasCarry ? carry.mortgage : Math.round(num(pd.mortgage));
|
||||||
@@ -647,7 +647,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
|
|||||||
amort,
|
amort,
|
||||||
growth: num(pd.valueGrowth),
|
growth: num(pd.valueGrowth),
|
||||||
interestRate: num(pd.interestRate),
|
interestRate: num(pd.interestRate),
|
||||||
// Default INCLUDED: bestehende Plaene haben die Zinsen in den Ausgaben -> nicht
|
// Default INCLUDED: bestehende Pläne haben die Zinsen in den Ausgaben -> nicht
|
||||||
// nochmals abziehen. Nur bei bewusstem "ADD" rechnet das Tool sie dazu.
|
// nochmals abziehen. Nur bei bewusstem "ADD" rechnet das Tool sie dazu.
|
||||||
addInterest: pd.interestHandling === "ADD",
|
addInterest: pd.interestHandling === "ADD",
|
||||||
ec,
|
ec,
|
||||||
@@ -677,9 +677,9 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
|
|||||||
ahvYearsAccum.set(p.id, (ahvYearsAccum.get(p.id) ?? 0) + contribYears);
|
ahvYearsAccum.set(p.id, (ahvYearsAccum.get(p.id) ?? 0) + contribYears);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Jahr-fuer-Jahr: indexierte Flows, Cash-Ausgleich, Verzinsung, Ruin ---
|
// --- Jahr-für-Jahr: indexierte Flows, Cash-Ausgleich, Verzinsung, Ruin ---
|
||||||
// Investitionen dieser Phase werden am Phasenanfang abgezogen -> der Cash-Startwert
|
// Investitionen dieser Phase werden am Phasenanfang abgezogen -> der Cash-Startwert
|
||||||
// zeigt den Bestand NACH den Investitionen (kein Doppelzaehlen mit dem Vermoegen).
|
// zeigt den Bestand NACH den Investitionen (kein Doppelzählen mit dem Vermögen).
|
||||||
let cash = cashCarryIn - (isFirstPhase ? 0 : investmentsFromCash);
|
let cash = cashCarryIn - (isFirstPhase ? 0 : investmentsFromCash);
|
||||||
const cashStart = cash;
|
const cashStart = cash;
|
||||||
let cashNegative = cash < 0;
|
let cashNegative = cash < 0;
|
||||||
@@ -689,8 +689,8 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
|
|||||||
let expenseEnd = 0;
|
let expenseEnd = 0;
|
||||||
let quotaStart = 0;
|
let quotaStart = 0;
|
||||||
let quotaEnd = 0;
|
let quotaEnd = 0;
|
||||||
let plannedSaveRate = 0; // Kopf-Kennzahl: die tatsaechliche Sparrate im ersten Phasenjahr
|
let plannedSaveRate = 0; // Kopf-Kennzahl: die tatsächliche Sparrate im ersten Phasenjahr
|
||||||
// Summanden der beiden Wasserfall-Bruecken (ueber alle Phasenjahre kumuliert).
|
// Summanden der beiden Wasserfall-Brücken (über alle Phasenjahre kumuliert).
|
||||||
let quotaTotal = 0;
|
let quotaTotal = 0;
|
||||||
let investmentReturnTotal = 0;
|
let investmentReturnTotal = 0;
|
||||||
let propertyAppreciationTotal = 0;
|
let propertyAppreciationTotal = 0;
|
||||||
@@ -700,7 +700,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
|
|||||||
let withdrawalsTotal = 0;
|
let withdrawalsTotal = 0;
|
||||||
|
|
||||||
for (let t = 1; t <= duration; t++) {
|
for (let t = 1; t <= duration; t++) {
|
||||||
// Einkommen: nominal (Basis x (1+Lohnerhoehung)^(t-1)) + Renten (nominal fix).
|
// Einkommen: nominal (Basis x (1+Lohnerhöhung)^(t-1)) + Renten (nominal fix).
|
||||||
let incomeFlow = renteTotal;
|
let incomeFlow = renteTotal;
|
||||||
for (const inc of incomes) incomeFlow += inc.basis * Math.pow(1 + inc.idx / 100, t - 1);
|
for (const inc of incomes) incomeFlow += inc.basis * Math.pow(1 + inc.idx / 100, t - 1);
|
||||||
// Ausgaben: real (Basis x (1+reale Mehrausgabe)^(t-1)); nominal = real x kumul. Inflation.
|
// Ausgaben: real (Basis x (1+reale Mehrausgabe)^(t-1)); nominal = real x kumul. Inflation.
|
||||||
@@ -709,7 +709,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
|
|||||||
for (const exp of expenses) expenseRealBase += exp.basis * Math.pow(1 + exp.idx / 100, t - 1);
|
for (const exp of expenses) expenseRealBase += exp.basis * Math.pow(1 + exp.idx / 100, t - 1);
|
||||||
|
|
||||||
// Hypothekarzins: NOMINAL aus der Restschuld zu Jahresbeginn -- nicht mit der Inflation
|
// Hypothekarzins: NOMINAL aus der Restschuld zu Jahresbeginn -- nicht mit der Inflation
|
||||||
// hochrechnen. Zaehlt zu den Ausgaben (und damit in die Quote), sofern nicht bereits
|
// hochrechnen. Zählt zu den Ausgaben (und damit in die Quote), sofern nicht bereits
|
||||||
// im Ausgaben-Element enthalten.
|
// im Ausgaben-Element enthalten.
|
||||||
let interestNominal = 0;
|
let interestNominal = 0;
|
||||||
for (const re of realEstates) {
|
for (const re of realEstates) {
|
||||||
@@ -721,8 +721,8 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
|
|||||||
const expenseReal = expenseRealBase + interestNominal / (inflFactor || 1);
|
const expenseReal = expenseRealBase + interestNominal / (inflFactor || 1);
|
||||||
const quote = incomeFlow - expenseNominal;
|
const quote = incomeFlow - expenseNominal;
|
||||||
|
|
||||||
// Der Vermoegenswert wird erst nach Verzinsung/Cash-Fortschreibung bekannt und weiter
|
// Der Vermögenswert wird erst nach Verzinsung/Cash-Fortschreibung bekannt und weiter
|
||||||
// unten nachgetragen (gleiche Groesse, die auch die Ruin-Erkennung verwendet).
|
// unten nachgetragen (gleiche Grösse, die auch die Ruin-Erkennung verwendet).
|
||||||
const yearPoint: YearPoint = {
|
const yearPoint: YearPoint = {
|
||||||
year: yearsBefore + t,
|
year: yearsBefore + t,
|
||||||
age: personA.age + yearsBefore + t,
|
age: personA.age + yearsBefore + t,
|
||||||
@@ -745,11 +745,11 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
|
|||||||
quotaEnd = quote;
|
quotaEnd = quote;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Vermoegen verzinsen + Sparbeitrag; Bezugsrate entnehmen (gekappt am Bestand) und ins Cash.
|
// Vermögen verzinsen + Sparbeitrag; Bezugsrate entnehmen (gekappt am Bestand) und ins Cash.
|
||||||
let cashFromWithdraw = 0;
|
let cashFromWithdraw = 0;
|
||||||
for (const a of assets) {
|
for (const a of assets) {
|
||||||
const r = sample ? sample.assetReturn(a.ec.elementId, yearsBefore + t) : a.r;
|
const r = sample ? sample.assetReturn(a.ec.elementId, yearsBefore + t) : a.r;
|
||||||
// Arithmetik unveraendert lassen; die Rendite wird als Differenz abgeleitet, damit
|
// Arithmetik unverändert lassen; die Rendite wird als Differenz abgeleitet, damit
|
||||||
// die Protokollierung das Ergebnis nicht (per Gleitkomma-Umformung) verschiebt.
|
// die Protokollierung das Ergebnis nicht (per Gleitkomma-Umformung) verschiebt.
|
||||||
const grown = a.value * (1 + r / 100) + a.rate;
|
const grown = a.value * (1 + r / 100) + a.rate;
|
||||||
const growth = grown - a.rate - a.value;
|
const growth = grown - a.rate - a.value;
|
||||||
@@ -761,7 +761,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Amortisation/Tilgung: nur so lange und so viel, wie noch Restschuld besteht. Ist die
|
// Amortisation/Tilgung: nur so lange und so viel, wie noch Restschuld besteht. Ist die
|
||||||
// Hypothek/Schuld abbezahlt, entfaellt die Rate -- sie belastet weder Cash noch Sparquote.
|
// Hypothek/Schuld abbezahlt, entfällt die Rate -- sie belastet weder Cash noch Sparquote.
|
||||||
let debtRates = 0;
|
let debtRates = 0;
|
||||||
for (const re of realEstates) {
|
for (const re of realEstates) {
|
||||||
const pay = Math.min(re.amort, re.mortgage);
|
const pay = Math.min(re.amort, re.mortgage);
|
||||||
@@ -771,7 +771,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
|
|||||||
// Hebel: 1 % von 1 Mio sind 10'000, also 10 % eines Eigenkapitals von 100'000.
|
// Hebel: 1 % von 1 Mio sind 10'000, also 10 % eines Eigenkapitals von 100'000.
|
||||||
const g = sample ? sample.assetReturn(re.ec.elementId, yearsBefore + t) : re.growth;
|
const g = sample ? sample.assetReturn(re.ec.elementId, yearsBefore + t) : re.growth;
|
||||||
const valueBefore = re.value;
|
const valueBefore = re.value;
|
||||||
re.value *= 1 + g / 100; // Arithmetik unveraendert
|
re.value *= 1 + g / 100; // Arithmetik unverändert
|
||||||
propertyAppreciationTotal += re.value - valueBefore;
|
propertyAppreciationTotal += re.value - valueBefore;
|
||||||
}
|
}
|
||||||
for (const d of debts) {
|
for (const d of debts) {
|
||||||
@@ -790,7 +790,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
|
|||||||
withdrawalsTotal += cashFromWithdraw;
|
withdrawalsTotal += cashFromWithdraw;
|
||||||
|
|
||||||
// Verlaufspunkt je Element (Detailansicht). Bewusst NACH Verzinsung und Tilgung, also
|
// Verlaufspunkt je Element (Detailansicht). Bewusst NACH Verzinsung und Tilgung, also
|
||||||
// der Stand am Jahresende -- konsistent zum Gesamtvermoegen im YearPoint.
|
// der Stand am Jahresende -- konsistent zum Gesamtvermögen im YearPoint.
|
||||||
const age = personA.age + yearsBefore + t;
|
const age = personA.age + yearsBefore + t;
|
||||||
const yr = yearsBefore + t;
|
const yr = yearsBefore + t;
|
||||||
for (const inc of incomes) {
|
for (const inc of incomes) {
|
||||||
@@ -821,7 +821,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Gesamtvermoegen zum Jahresende t (fuer Ruin-Erkennung).
|
// Gesamtvermögen zum Jahresende t (für Ruin-Erkennung).
|
||||||
let total = cash;
|
let total = cash;
|
||||||
for (const a of assets) total += a.value;
|
for (const a of assets) total += a.value;
|
||||||
for (const re of realEstates) total += re.value - re.mortgage;
|
for (const re of realEstates) total += re.value - re.mortgage;
|
||||||
@@ -831,7 +831,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
|
|||||||
if (ruinAge === null && total < 0) ruinAge = personA.age + yearsBefore + t;
|
if (ruinAge === null && total < 0) ruinAge = personA.age + yearsBefore + t;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Flow-Deflator fuer den Endwert (Jahr `duration`): eine Kaufkraft-Stufe weniger als der
|
// Flow-Deflator für den Endwert (Jahr `duration`): eine Kaufkraft-Stufe weniger als der
|
||||||
// Bestands-Deflator am Phasenende.
|
// Bestands-Deflator am Phasenende.
|
||||||
const flowDeflatorEnd = cumInfl[yearsBefore + duration - 1];
|
const flowDeflatorEnd = cumInfl[yearsBefore + duration - 1];
|
||||||
|
|
||||||
@@ -1007,7 +1007,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// AHV-/PK-Renten: der Rechenweg der Rentenhoehe selbst liegt auf Plan-Ebene (mdJE).
|
// AHV-/PK-Renten: der Rechenweg der Rentenhöhe selbst liegt auf Plan-Ebene (mdJE).
|
||||||
for (const e of orderedElements) {
|
for (const e of orderedElements) {
|
||||||
const ec = ecById.get(e.id)!;
|
const ec = ecById.get(e.id)!;
|
||||||
if (ec.trace || ec.startValue === 0) continue;
|
if (ec.trace || ec.startValue === 0) continue;
|
||||||
@@ -1232,19 +1232,19 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
|
|||||||
previousEndWealth = endWealthNominal;
|
previousEndWealth = endWealthNominal;
|
||||||
previousCashEnd = cashEnd;
|
previousCashEnd = cashEnd;
|
||||||
|
|
||||||
// --- Uebergang: 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;
|
||||||
// Echte Vermoegensaenderungen an dieser Grenze (fuer die Bruecke der Folgephase).
|
// Echte Vermögensänderungen an dieser Grenze (für die Brücke der Folgephase).
|
||||||
// Verkaeufe, Bezuege und Tilgungen sind fuer sich Umbuchungen -- vermoegenswirksam sind
|
// Verkäufe, Bezüge und Tilgungen sind für sich Umbuchungen -- vermögenswirksam sind
|
||||||
// nur die Steuer, die Verrentung (Kapital verlaesst die Bilanz) und die Differenz
|
// nur die Steuer, die Verrentung (Kapital verlässt die Bilanz) und die Differenz
|
||||||
// zwischen Verkaufspreis und Verkehrswert.
|
// zwischen Verkaufspreis und Verkehrswert.
|
||||||
let txTax = 0;
|
let txTax = 0;
|
||||||
let txPensionConversion = 0;
|
let txPensionConversion = 0;
|
||||||
let txSaleGainLoss = 0;
|
let txSaleGainLoss = 0;
|
||||||
|
|
||||||
// Einmalige Sonderein-/ausgaben auf dem Cash-Konto. Nur sinnvoll, wenn eine Folgephase
|
// Einmalige Sonderein-/ausgaben auf dem Cash-Konto. Nur sinnvoll, wenn eine Folgephase
|
||||||
// existiert -- nach der letzten Phase gibt es keinen Uebergang. Der Wechselkurs zwischen
|
// existiert -- nach der letzten Phase gibt es keinen Übergang. Der Wechselkurs zwischen
|
||||||
// real und nominal ist an dieser Grenze `cumulativeInflation` (Bestands-Deflator am
|
// real und nominal ist an dieser Grenze `cumulativeInflation` (Bestands-Deflator am
|
||||||
// Phasenende), denn Cash ist ein Bestand.
|
// Phasenende), denn Cash ist ein Bestand.
|
||||||
let txOneOffInflow = 0;
|
let txOneOffInflow = 0;
|
||||||
@@ -1281,14 +1281,14 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// AHV: beim Pensions-Uebergang die geprueften Karriere-Werte vor Planbeginn uebernehmen.
|
// AHV: beim Pensions-Übergang die geprüften Karriere-Werte vor Planbeginn übernehmen.
|
||||||
if (e.category === "AHV") {
|
if (e.category === "AHV") {
|
||||||
if (ownerRetiresNext && owner) {
|
if (ownerRetiresNext && owner) {
|
||||||
const career = buildCareer(owner, ahvIncomeAccum, ahvYearsAccum, gapYearsByPerson);
|
const career = buildCareer(owner, ahvIncomeAccum, ahvYearsAccum, gapYearsByPerson);
|
||||||
ahvBeforeByPerson.set(owner.id, {
|
ahvBeforeByPerson.set(owner.id, {
|
||||||
// Ohne erfassten Wert gilt der geplante Durchschnitt als Schaetzung fuer die Jahre
|
// Ohne erfassten Wert gilt der geplante Durchschnitt als Schätzung für die Jahre
|
||||||
// vor Planbeginn -- exakt der Wert, den der Pruef-Dialog vorbelegt. Ein Fallback auf
|
// vor Planbeginn -- exakt der Wert, den der Prüf-Dialog vorbelegt. Ein Fallback auf
|
||||||
// 0 wuerde die Rente still und massiv zu tief rechnen. Beide Werte sind BRUTTO.
|
// 0 würde die Rente still und massiv zu tief rechnen. Beide Werte sind BRUTTO.
|
||||||
avg: num(td.avgIncomeBefore, career.plannedAvgGrossIncome),
|
avg: num(td.avgIncomeBefore, career.plannedAvgGrossIncome),
|
||||||
gap: Math.max(0, Math.round(num(td.gapYearsBefore))),
|
gap: Math.max(0, Math.round(num(td.gapYearsBefore))),
|
||||||
});
|
});
|
||||||
@@ -1330,7 +1330,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
|
|||||||
carry.value = 0;
|
carry.value = 0;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Vorbezug (z. B. Wohneigentum/Selbststaendigkeit): ebenfalls kapitalbezugssteuerpflichtig.
|
// Vorbezug (z. B. Wohneigentum/Selbstständigkeit): ebenfalls kapitalbezugssteuerpflichtig.
|
||||||
// Das Kapital wird brutto entnommen, netto (nach Steuer) fliesst es ins Cash.
|
// Das Kapital wird brutto entnommen, netto (nach Steuer) fliesst es ins Cash.
|
||||||
const withdrawal = Math.min(ec.endValue, Math.round(num(td.withdrawal)));
|
const withdrawal = Math.min(ec.endValue, Math.round(num(td.withdrawal)));
|
||||||
carry.value = ec.endValue - withdrawal;
|
carry.value = ec.endValue - withdrawal;
|
||||||
@@ -1360,7 +1360,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
|
|||||||
txInflow += ec.endValue;
|
txInflow += ec.endValue;
|
||||||
carry.status = "SOLD";
|
carry.status = "SOLD";
|
||||||
} else if (td.decision === "PARTIAL") {
|
} else if (td.decision === "PARTIAL") {
|
||||||
// Teilverkauf: Betrag fliesst ins Cash, der Rest bleibt aktiv und waechst weiter.
|
// Teilverkauf: Betrag fliesst ins Cash, der Rest bleibt aktiv und wächst weiter.
|
||||||
const part = Math.min(ec.endValue, Math.round(num(td.partialSaleAmount)));
|
const part = Math.min(ec.endValue, Math.round(num(td.partialSaleAmount)));
|
||||||
txInflow += part;
|
txInflow += part;
|
||||||
carry.value = ec.endValue - part;
|
carry.value = ec.endValue - part;
|
||||||
@@ -1374,14 +1374,14 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
|
|||||||
if (!re) break;
|
if (!re) break;
|
||||||
if (td.decision === "SELL") {
|
if (td.decision === "SELL") {
|
||||||
const salePrice = Math.round(num(td.salePrice));
|
const salePrice = Math.round(num(td.salePrice));
|
||||||
// Grundstueckgewinnsteuer bemisst sich am urspruenglichen Kaufpreis, nicht am
|
// Grundstückgewinnsteuer bemisst sich am ursprünglichen Kaufpreis, nicht am
|
||||||
// zwischenzeitlich gestiegenen Verkehrswert.
|
// zwischenzeitlich gestiegenen Verkehrswert.
|
||||||
const gain = Math.max(0, salePrice - re.purchase);
|
const gain = Math.max(0, salePrice - re.purchase);
|
||||||
const tax = gain * (num(td.saleTaxRate, DEFAULT_PROPERTY_GAINS_TAX_RATE) / 100);
|
const tax = gain * (num(td.saleTaxRate, DEFAULT_PROPERTY_GAINS_TAX_RATE) / 100);
|
||||||
txInflow += Math.round(salePrice - re.mortgage - tax);
|
txInflow += Math.round(salePrice - re.mortgage - tax);
|
||||||
txTax += Math.round(tax);
|
txTax += Math.round(tax);
|
||||||
// Der Verkaufspreis kann vom fortgeschriebenen Verkehrswert abweichen -- diese
|
// Der Verkaufspreis kann vom fortgeschriebenen Verkehrswert abweichen -- diese
|
||||||
// Differenz ist ein echter Vermoegensgewinn bzw. -verlust an der Grenze.
|
// Differenz ist ein echter Vermögensgewinn bzw. -verlust an der Grenze.
|
||||||
txSaleGainLoss += Math.round(salePrice - re.value);
|
txSaleGainLoss += Math.round(salePrice - re.value);
|
||||||
carry.status = "SOLD";
|
carry.status = "SOLD";
|
||||||
} else {
|
} else {
|
||||||
@@ -1409,7 +1409,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Rechenweg des Uebergangs je Element ---
|
// --- Rechenweg des Übergangs je Element ---
|
||||||
if (explain && nextPhase) {
|
if (explain && nextPhase) {
|
||||||
const inflowHere = txInflow - txStepsBefore;
|
const inflowHere = txInflow - txStepsBefore;
|
||||||
const taxHere = txTax - taxBefore;
|
const taxHere = txTax - taxBefore;
|
||||||
@@ -1492,8 +1492,8 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
|
|||||||
incomingPensionConversion = txPensionConversion;
|
incomingPensionConversion = txPensionConversion;
|
||||||
incomingSaleGainLoss = txSaleGainLoss;
|
incomingSaleGainLoss = txSaleGainLoss;
|
||||||
|
|
||||||
// Rechenweg des Cash-Uebergangs (einmalige Sonderein-/ausgaben). Wird nachtraeglich an
|
// Rechenweg des Cash-Übergangs (einmalige Sonderein-/ausgaben). Wird nachträglich an
|
||||||
// die Trace-Liste dieser Phase gehaengt -- `result` haelt die Referenz auf das Array.
|
// die Trace-Liste dieser Phase gehängt -- `result` hält die Referenz auf das Array.
|
||||||
if (explain && nextPhase && (txOneOffInflow !== 0 || txOneOffOutflow !== 0)) {
|
if (explain && nextPhase && (txOneOffInflow !== 0 || txOneOffOutflow !== 0)) {
|
||||||
const ct = phase.cashTransition ?? {};
|
const ct = phase.cashTransition ?? {};
|
||||||
const steps: TraceStep[] = [];
|
const steps: TraceStep[] = [];
|
||||||
@@ -1541,7 +1541,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
|
|||||||
specAnchor: "442-beitragskarriere-und-mdje",
|
specAnchor: "442-beitragskarriere-und-mdje",
|
||||||
steps: [
|
steps: [
|
||||||
st("Beitragsjahre im Plan", career.planYears, "Summe (Phasendauer − Ausfalljahre)", undefined, undefined, "Jahre"),
|
st("Beitragsjahre im Plan", career.planYears, "Summe (Phasendauer − Ausfalljahre)", undefined, undefined, "Jahre"),
|
||||||
st("Durchschnittliches Bruttoeinkommen im Plan (real)", Math.round(career.plannedAvgGrossIncome), `Nettolohn × ${AHV_GROSS_FROM_NET_FACTOR}`, undefined,
|
st("Durchschnittliches Bruttöinkommen im Plan (real)", Math.round(career.plannedAvgGrossIncome), `Nettolohn × ${AHV_GROSS_FROM_NET_FACTOR}`, undefined,
|
||||||
"Das Tool erfasst netto, die AHV bemisst sich am Brutto. Der Faktor 1.12 ist ein Karriere-Durchschnitt und keine exakte Einzelfall-Umrechnung."),
|
"Das Tool erfasst netto, die AHV bemisst sich am Brutto. Der Faktor 1.12 ist ein Karriere-Durchschnitt und keine exakte Einzelfall-Umrechnung."),
|
||||||
st("Beitragsjahre vor Planbeginn", Math.max(0, career.yearsBeforePlan - before.gap), "Alter bei Planbeginn − 21 − Ausfalljahre davor", undefined, undefined, "Jahre"),
|
st("Beitragsjahre vor Planbeginn", Math.max(0, career.yearsBeforePlan - before.gap), "Alter bei Planbeginn − 21 − Ausfalljahre davor", undefined, undefined, "Jahre"),
|
||||||
st("Durchschnittseinkommen vor Planbeginn (real, brutto)", Math.round(before.avg), undefined, undefined,
|
st("Durchschnittseinkommen vor Planbeginn (real, brutto)", Math.round(before.avg), undefined, undefined,
|
||||||
@@ -1568,7 +1568,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
|
|||||||
return { phases: result, yearly, nachlass, ruinAge, ahvCareer, traces: explain ? planTraces : undefined };
|
return { phases: result, yearly, nachlass, ruinAge, ahvCareer, traces: explain ? planTraces : undefined };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Durchschnittliches REALES Jahreseinkommen ueber eine Phase. Nominal waechst der Flow mit
|
// Durchschnittliches REALES Jahreseinkommen über eine Phase. Nominal wächst der Flow mit
|
||||||
// idx, real wird er mit der Inflation deflationiert -> geometrische Reihe mit q.
|
// idx, real wird er mit der Inflation deflationiert -> geometrische Reihe mit q.
|
||||||
function avgRealFlow(basis: number, idx: number, infl: number, duration: number, cumInflStart: number): number {
|
function avgRealFlow(basis: number, idx: number, infl: number, duration: number, cumInflStart: number): number {
|
||||||
const q = (1 + idx / 100) / (1 + infl / 100);
|
const q = (1 + idx / 100) / (1 + infl / 100);
|
||||||
@@ -1616,8 +1616,8 @@ export function planToCsv(plan: PlanInput, computed: PlanComputed): string {
|
|||||||
"Quote (Beginn)",
|
"Quote (Beginn)",
|
||||||
"Quote (Ende)",
|
"Quote (Ende)",
|
||||||
"Cash (Ende)",
|
"Cash (Ende)",
|
||||||
"Endvermoegen (nominal)",
|
"Endvermögen (nominal)",
|
||||||
"Endvermoegen (real)",
|
"Endvermögen (real)",
|
||||||
];
|
];
|
||||||
const rows = computed.phases.map((p) => [
|
const rows = computed.phases.map((p) => [
|
||||||
p.name,
|
p.name,
|
||||||
|
|||||||
+26
-26
@@ -1,9 +1,9 @@
|
|||||||
// Konfigurierbare Systemparameter (Stand 2026). Aendern sich periodisch durch
|
// Konfigurierbare Systemparameter (Stand 2026). Ändern sich periodisch durch
|
||||||
// Anpassungen des Bundes -- deshalb hier zentral gefuehrt, nicht im Code verteilt.
|
// Anpassungen des Bundes -- deshalb hier zentral geführt, nicht im Code verteilt.
|
||||||
|
|
||||||
// R0 = Mindestbetrag der vollen monatlichen Altersrente (Skala 44). Alle Schwellen der
|
// R0 = Mindestbetrag der vollen monatlichen Altersrente (Skala 44). Alle Schwellen der
|
||||||
// Rentenformel sind Vielfache von R0 -- aendert sich R0, verschiebt sich die ganze Skala.
|
// Rentenformel sind Vielfache von R0 -- ändert sich R0, verschiebt sich die ganze Skala.
|
||||||
// Quelle: BSV/MAS "Berechnungsvorschriften der AHV/IV-Renten", gueltig ab 1.1.2026;
|
// Quelle: BSV/MAS "Berechnungsvorschriften der AHV/IV-Renten", gültig ab 1.1.2026;
|
||||||
// amtliche Tabelle 318.117.1 "Monatliche Vollrenten, Skala 44".
|
// amtliche Tabelle 318.117.1 "Monatliche Vollrenten, Skala 44".
|
||||||
export const AHV_MIN_MONTHLY_FULL = 1260;
|
export const AHV_MIN_MONTHLY_FULL = 1260;
|
||||||
|
|
||||||
@@ -18,48 +18,48 @@ export const AHV_CONTRIBUTION_START_AGE = 21;
|
|||||||
// Abgeleitet, damit eine Anpassung von R0 nicht an zwei Stellen nachgezogen werden muss.
|
// Abgeleitet, damit eine Anpassung von R0 nicht an zwei Stellen nachgezogen werden muss.
|
||||||
export const AHV_MAX_ANNUAL_SINGLE = 2 * AHV_MIN_MONTHLY_FULL * AHV_PENSION_MONTHS;
|
export const AHV_MAX_ANNUAL_SINGLE = 2 * AHV_MIN_MONTHLY_FULL * AHV_PENSION_MONTHS;
|
||||||
|
|
||||||
// Umrechnung Netto- -> Bruttolohn fuer die AHV. Das Tool erfasst das Einkommen NETTO (so
|
// Umrechnung Netto- -> Bruttolohn für die AHV. Das Tool erfasst das Einkommen NETTO (so
|
||||||
// denkt der Nutzer, und so stimmt der Cash-Fluss), die AHV bemisst sich aber am BRUTTOlohn.
|
// denkt der Nutzer, und so stimmt der Cash-Fluss), die AHV bemisst sich aber am BRUTTOlohn.
|
||||||
//
|
//
|
||||||
// Herleitung (Arbeitnehmer-Abzuege in % des Bruttolohns):
|
// Herleitung (Arbeitnehmer-Abzüge in % des Bruttolohns):
|
||||||
// AHV/IV/EO 5.30 % (10.6 % total, haelftig geteilt)
|
// AHV/IV/EO 5.30 % (10.6 % total, hälftig geteilt)
|
||||||
// ALV 1.10 % ( 2.2 % total, haelftig geteilt, bis 148'200)
|
// ALV 1.10 % ( 2.2 % total, hälftig geteilt, bis 148'200)
|
||||||
// NBU ~1.00 % (variiert 0.4-1.6 %, teils vom Arbeitgeber getragen)
|
// NBU ~1.00 % (variiert 0.4-1.6 %, teils vom Arbeitgeber getragen)
|
||||||
// PK ~2-5 % (BVG-Altersgutschrift 7/10/15/18 % auf den KOORDINIERTEN Lohn
|
// PK ~2-5 % (BVG-Altersgutschrift 7/10/15/18 % auf den KOORDINIERTEN Lohn
|
||||||
// [Brutto - 26'460, max. 90'720], Arbeitnehmer >= die Haelfte)
|
// [Brutto - 26'460, max. 90'720], Arbeitnehmer >= die Hälfte)
|
||||||
// -> total ~9-13 % je nach Alter und Lohn; Faktor 1/(1-q) = 1.10 bis 1.16, Mittel ~1.12.
|
// -> total ~9-13 % je nach Alter und Lohn; Faktor 1/(1-q) = 1.10 bis 1.16, Mittel ~1.12.
|
||||||
//
|
//
|
||||||
// Ein fixer Faktor ist hier vertretbar, weil das mdJE selbst ein Durchschnitt ueber die ganze
|
// Ein fixer Faktor ist hier vertretbar, weil das mdJE selbst ein Durchschnitt über die ganze
|
||||||
// Beitragskarriere (44 Jahre) ist: der altersabhaengige PK-Satz mittelt sich dabei heraus.
|
// Beitragskarriere (44 Jahre) ist: der altersabhängige PK-Satz mittelt sich dabei heraus.
|
||||||
// Die Restunschaerfe (~3 %) ist deutlich kleiner als der Fehler, den die Umrechnung behebt (~11 %).
|
// Die Restunschärfe (~3 %) ist deutlich kleiner als der Fehler, den die Umrechnung behebt (~11 %).
|
||||||
//
|
//
|
||||||
// Quellen: BSV "Betraege gueltig ab 1.1.2026" (Koordinationsabzug 26'460, obere Limite 90'720,
|
// Quellen: BSV "Beträge gültig ab 1.1.2026" (Koordinationsabzug 26'460, obere Limite 90'720,
|
||||||
// keine Aenderung gegenueber 2025); AHV-Merkblaetter 2.01 (AHV/IV/EO) und 2.08 (ALV).
|
// keine Änderung gegenüber 2025); AHV-Merkblätter 2.01 (AHV/IV/EO) und 2.08 (ALV).
|
||||||
export const AHV_GROSS_FROM_NET_FACTOR = 1.12;
|
export const AHV_GROSS_FROM_NET_FACTOR = 1.12;
|
||||||
|
|
||||||
// Ehepaar-Plafonierung: die Summe beider Einzelrenten ist auf 150% der Einzel-
|
// Ehepaar-Plafonierung: die Summe beider Einzelrenten ist auf 150% der Einzel-
|
||||||
// Maximalrente begrenzt. Bei Ueberschreitung werden beide Renten proportional gekuerzt.
|
// Maximalrente begrenzt. Bei Überschreitung werden beide Renten proportional gekürzt.
|
||||||
export const AHV_COUPLE_CAP_FACTOR = 1.5;
|
export const AHV_COUPLE_CAP_FACTOR = 1.5;
|
||||||
|
|
||||||
// Volle Beitragsdauer fuer eine ungekuerzte AHV-Rente (Rentenskala 44). Pro fehlendes
|
// Volle Beitragsdauer für eine ungekürzte AHV-Rente (Rentenskala 44). Pro fehlendes
|
||||||
// Beitragsjahr (Ausfalljahr) wird die Rente um 1/44 gekuerzt.
|
// Beitragsjahr (Ausfalljahr) wird die Rente um 1/44 gekürzt.
|
||||||
export const AHV_FULL_CONTRIBUTION_YEARS = 44;
|
export const AHV_FULL_CONTRIBUTION_YEARS = 44;
|
||||||
|
|
||||||
// Maximaler jaehrlicher Saeule-3a-Beitrag fuer PK-Versicherte (2026).
|
// Maximaler jährlicher Säule-3a-Beitrag für PK-Versicherte (2026).
|
||||||
export const PILLAR_3A_MAX_ANNUAL = 7258;
|
export const PILLAR_3A_MAX_ANNUAL = 7258;
|
||||||
|
|
||||||
// Default-Annahmen fuer Uebergangs-Ereignisse (pro Ereignis editierbar). Werden SOWOHL als
|
// Default-Annahmen für Übergangs-Ereignisse (pro Ereignis editierbar). Werden SOWOHL als
|
||||||
// UI-Vorschlag als auch in der Berechnung als Fallback verwendet -- damit ein nicht
|
// UI-Vorschlag als auch in der Berechnung als Fallback verwendet -- damit ein nicht
|
||||||
// angetupfter Wert nicht faelschlich als 0 gerechnet wird.
|
// angetupfter Wert nicht fälschlich als 0 gerechnet wird.
|
||||||
export const DEFAULT_PK_CONVERSION_RATE = 6; // % (jaehrliche Rente = Kapital x Satz)
|
export const DEFAULT_PK_CONVERSION_RATE = 6; // % (jährliche Rente = Kapital x Satz)
|
||||||
export const DEFAULT_CAPITAL_TAX_RATE = 8; // % Kapitalbezugssteuer (PK-/3a-Kapitalbezug)
|
export const DEFAULT_CAPITAL_TAX_RATE = 8; // % Kapitalbezugssteuer (PK-/3a-Kapitalbezug)
|
||||||
export const DEFAULT_PROPERTY_GAINS_TAX_RATE = 20; // % Grundstueckgewinnsteuer
|
export const DEFAULT_PROPERTY_GAINS_TAX_RATE = 20; // % Grundstückgewinnsteuer
|
||||||
|
|
||||||
// --- Strukturierte Fassung fuer die Systemparameter-Ansicht (Roadmap Nr. 41) -------------
|
// --- Strukturierte Fassung für die Systemparameter-Ansicht (Roadmap Nr. 41) -------------
|
||||||
//
|
//
|
||||||
// Dieselben Werte wie oben, nur maschinenlesbar beschrieben: Damit kann die Oberflaeche
|
// Dieselben Werte wie oben, nur maschinenlesbar beschrieben: Damit kann die Oberfläche
|
||||||
// Wert, Bedeutung, Herleitung und Quelle anzeigen, OHNE dass die Zahlen ein zweites Mal
|
// Wert, Bedeutung, Herleitung und Quelle anzeigen, OHNE dass die Zahlen ein zweites Mal
|
||||||
// gepflegt werden muessen -- die Eintraege referenzieren die Konstanten direkt und koennen
|
// gepflegt werden müssen -- die Einträge referenzieren die Konstanten direkt und können
|
||||||
// deshalb nicht von ihnen abdriften.
|
// deshalb nicht von ihnen abdriften.
|
||||||
|
|
||||||
export type ParameterGroup = "AHV" | "Vorsorge" | "Steuern";
|
export type ParameterGroup = "AHV" | "Vorsorge" | "Steuern";
|
||||||
@@ -74,7 +74,7 @@ export interface SystemParameter {
|
|||||||
source: string;
|
source: string;
|
||||||
validFrom: string;
|
validFrom: string;
|
||||||
group: ParameterGroup;
|
group: ParameterGroup;
|
||||||
editablePerEvent?: boolean; // Default-Satz, pro Ereignis ueberschreibbar
|
editablePerEvent?: boolean; // Default-Satz, pro Ereignis überschreibbar
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SYSTEM_PARAMETERS: SystemParameter[] = [
|
export const SYSTEM_PARAMETERS: SystemParameter[] = [
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
// Beispielplan mit einem Klick (Teil von Roadmap Nr. 10). Erzeugt einen realistischen,
|
||||||
|
// vollständig ausgefuellten Plan über die BESTEHENDEN API-Endpunkte -- reine Orchestrierung,
|
||||||
|
// kein neuer Endpunkt, keine Änderung an der Berechnung.
|
||||||
|
//
|
||||||
|
// Didaktischer Entscheid: Die Übergangs-Entscheide bleiben absichtlich OFFEN. Der neue
|
||||||
|
// Nutzer sieht dadurch die Ampel ("N offen") in Aktion und lernt das wichtigste Konzept des
|
||||||
|
// Tools am Beispiel, statt es erklärt zu bekommen.
|
||||||
|
//
|
||||||
|
// WICHTIG zur Reihenfolge: erst Phase 1, dann die Elemente samt Phase-1-Werten, dann die
|
||||||
|
// Folgephase. Die Phasen-Route belegt beim Anlegen einer Phase die editierbaren Felder aus
|
||||||
|
// der Vorphase vor -- existieren die Elemente noch nicht, bleibt die Folgephase leer
|
||||||
|
// (Renditen/Beiträge wären dort 0).
|
||||||
|
|
||||||
|
import { api } from "@/lib/api-client";
|
||||||
|
import type { PhaseData } from "@/lib/elements";
|
||||||
|
|
||||||
|
async function addElement(
|
||||||
|
scenarioId: string,
|
||||||
|
firstPhaseId: string,
|
||||||
|
category: string,
|
||||||
|
name: string,
|
||||||
|
ownerRole: string,
|
||||||
|
values?: PhaseData
|
||||||
|
): Promise<void> {
|
||||||
|
const { element } = await api.post<{ element: { id: string } }>(
|
||||||
|
`/api/scenarios/${scenarioId}/elements`,
|
||||||
|
{ category, name, ownerRole }
|
||||||
|
);
|
||||||
|
if (values && Object.keys(values).length > 0) {
|
||||||
|
await api.put(`/api/elements/${element.id}/phase/${firstPhaseId}`, values);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Liefert die Szenario-Id des angelegten Beispielplans.
|
||||||
|
export async function createDemoPlan(): Promise<string> {
|
||||||
|
const currentYear = new Date().getFullYear();
|
||||||
|
|
||||||
|
const { scenario } = await api.post<{ plan: { id: string }; scenario: { id: string } }>(
|
||||||
|
"/api/plans",
|
||||||
|
{
|
||||||
|
name: "Beispiel: Alex Muster",
|
||||||
|
householdType: "SINGLE",
|
||||||
|
inflationRateDefault: 1.5,
|
||||||
|
startYear: currentYear,
|
||||||
|
persons: [{ role: "PERSON_A", name: "Alex", age: 45, retirementAge: 65 }],
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const sid = scenario.id;
|
||||||
|
|
||||||
|
await api.patch(`/api/scenarios/${sid}`, { initialCash: 40000 });
|
||||||
|
|
||||||
|
// Phase 1: Erwerbsphase bis zur Pension (die Route kappt die Dauer ohnehin am
|
||||||
|
// Pensionsereignis; 20 Jahre = 45 -> 65).
|
||||||
|
const p1 = await api.post<{ phase: { id: string } }>(`/api/scenarios/${sid}/phases`, {
|
||||||
|
name: "Erwerbsphase",
|
||||||
|
durationYears: 20,
|
||||||
|
});
|
||||||
|
const firstPhaseId = p1.phase.id;
|
||||||
|
|
||||||
|
await addElement(sid, firstPhaseId, "INCOME", "Lohn Alex", "PERSON_A", {
|
||||||
|
amount: 110000,
|
||||||
|
teuerungsausgleich: 1,
|
||||||
|
});
|
||||||
|
await addElement(sid, firstPhaseId, "EXPENSE", "Lebenshaltung", "HOUSEHOLD", {
|
||||||
|
amount: 78000,
|
||||||
|
teuerungsausgleich: 0,
|
||||||
|
});
|
||||||
|
await addElement(sid, firstPhaseId, "AHV", "AHV Alex", "PERSON_A", { gapYears: 0 });
|
||||||
|
await addElement(sid, firstPhaseId, "PENSION_FUND", "Pensionskasse", "PERSON_A", {
|
||||||
|
currentValue: 380000,
|
||||||
|
annualContribution: 14000,
|
||||||
|
expectedReturn: 1.5,
|
||||||
|
});
|
||||||
|
await addElement(sid, firstPhaseId, "PILLAR_3A", "Säule 3a", "PERSON_A", {
|
||||||
|
currentValue: 75000,
|
||||||
|
annualContribution: 7000,
|
||||||
|
expectedReturn: 2,
|
||||||
|
});
|
||||||
|
await addElement(sid, firstPhaseId, "OTHER_ASSET", "ETF-Depot", "HOUSEHOLD", {
|
||||||
|
startValue: 120000,
|
||||||
|
annualContribution: 12000,
|
||||||
|
expectedReturn: 5,
|
||||||
|
});
|
||||||
|
await addElement(sid, firstPhaseId, "REAL_ESTATE", "Eigenheim", "HOUSEHOLD", {
|
||||||
|
purchasePrice: 900000,
|
||||||
|
mortgage: 600000,
|
||||||
|
amortization: 12000,
|
||||||
|
interestRate: 1.5,
|
||||||
|
valueGrowth: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Pensionsphase 65 -> 90. Die Route belegt die editierbaren Felder aus Phase 1 vor.
|
||||||
|
await api.post(`/api/scenarios/${sid}/phases`, { name: "Pensionsphase", durationYears: 25 });
|
||||||
|
|
||||||
|
return sid;
|
||||||
|
}
|
||||||
@@ -15,7 +15,7 @@ function base(): PlanInput {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Unveraenderte Kopie.
|
// Unveränderte Kopie.
|
||||||
function copy(): PlanInput {
|
function copy(): PlanInput {
|
||||||
return {
|
return {
|
||||||
id: "S2", name: "Variante", householdType: "SINGLE", inflationRateDefault: 1.5, initialCash: 1000,
|
id: "S2", name: "Variante", householdType: "SINGLE", inflationRateDefault: 1.5, initialCash: 1000,
|
||||||
@@ -40,16 +40,16 @@ describe("Szenario-Diff", () => {
|
|||||||
expect(computeScenarioDiff(base(), null).total).toBe(0);
|
expect(computeScenarioDiff(base(), null).total).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("geaenderter Zellwert wird genau einer Zelle zugeordnet", () => {
|
it("geänderter Zellwert wird genau einer Zelle zugeordnet", () => {
|
||||||
const c = copy();
|
const c = copy();
|
||||||
c.elements[0].phaseValues["c-ph1"] = { amount: 120000 };
|
c.elements[0].phaseValues["c-ph1"] = { amount: 120000 };
|
||||||
const d = computeScenarioDiff(c, base());
|
const d = computeScenarioDiff(c, base());
|
||||||
expect(d.phaseCell.has("c-e1:c-ph1")).toBe(true);
|
expect(d.phaseCell.has("c-e1:c-ph1")).toBe(true);
|
||||||
expect(d.phaseCell.size).toBe(1);
|
expect(d.phaseCell.size).toBe(1);
|
||||||
expect(d.elementRow.size).toBe(0); // Zeile selbst unveraendert
|
expect(d.elementRow.size).toBe(0); // Zeile selbst unverändert
|
||||||
});
|
});
|
||||||
|
|
||||||
it("geaenderter Uebergangs-Entscheid markiert die Uebergangszelle", () => {
|
it("geänderter Übergangs-Entscheid markiert die Übergangszelle", () => {
|
||||||
const c = copy();
|
const c = copy();
|
||||||
c.elements[1].transitionValues["c-ph1"] = { decision: "SELL" };
|
c.elements[1].transitionValues["c-ph1"] = { decision: "SELL" };
|
||||||
const d = computeScenarioDiff(c, base());
|
const d = computeScenarioDiff(c, base());
|
||||||
@@ -57,16 +57,16 @@ describe("Szenario-Diff", () => {
|
|||||||
expect(d.phaseCell.size).toBe(0);
|
expect(d.phaseCell.size).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("neues Element ist 'added', geloeschtes wird als entfernt gemeldet", () => {
|
it("neues Element ist 'added', gelöschtes wird als entfernt gemeldet", () => {
|
||||||
const c = copy();
|
const c = copy();
|
||||||
c.elements.push({ id: "c-e3", category: "OTHER_DEBT", name: "Kredit", ownerRole: "HOUSEHOLD", orderIndex: 3, phaseValues: {}, transitionValues: {}, sourceElementId: null });
|
c.elements.push({ id: "c-e3", category: "OTHER_DEBT", name: "Kredit", ownerRole: "HOUSEHOLD", orderIndex: 3, phaseValues: {}, transitionValues: {}, sourceElementId: null });
|
||||||
c.elements = c.elements.filter((e) => e.id !== "c-e1"); // Lohn geloescht
|
c.elements = c.elements.filter((e) => e.id !== "c-e1"); // Lohn gelöscht
|
||||||
const d = computeScenarioDiff(c, base());
|
const d = computeScenarioDiff(c, base());
|
||||||
expect(d.elementRow.get("c-e3")).toBe("added");
|
expect(d.elementRow.get("c-e3")).toBe("added");
|
||||||
expect(d.removedElements.map((r) => r.name)).toEqual(["Lohn"]);
|
expect(d.removedElements.map((r) => r.name)).toEqual(["Lohn"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("Phasenkopf: Dauer/Name geaendert bzw. Phase neu", () => {
|
it("Phasenkopf: Dauer/Name geändert bzw. Phase neu", () => {
|
||||||
const c = copy();
|
const c = copy();
|
||||||
c.phases[0].durationYears = 15;
|
c.phases[0].durationYears = 15;
|
||||||
c.phases.push({ id: "c-ph2", sequenceNumber: 2, name: "Pension", durationYears: 25, cashTransition: {}, sourcePhaseId: null });
|
c.phases.push({ id: "c-ph2", sequenceNumber: 2, name: "Pension", durationYears: 25, cashTransition: {}, sourcePhaseId: null });
|
||||||
@@ -84,7 +84,7 @@ describe("Szenario-Diff", () => {
|
|||||||
expect(d.cashInitialChanged).toBe(true);
|
expect(d.cashInitialChanged).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("Cash-Uebergang wird erkannt", () => {
|
it("Cash-Übergang wird erkannt", () => {
|
||||||
const c = copy();
|
const c = copy();
|
||||||
c.phases[0].cashTransition = { mode: "INFLOW", inflowAmount: 100000 };
|
c.phases[0].cashTransition = { mode: "INFLOW", inflowAmount: 100000 };
|
||||||
const d = computeScenarioDiff(c, base());
|
const d = computeScenarioDiff(c, base());
|
||||||
|
|||||||
+10
-10
@@ -1,10 +1,10 @@
|
|||||||
// Abweichungs-Erkennung zwischen einem Szenario und seinem ELTERN-Szenario.
|
// Abweichungs-Erkennung zwischen einem Szenario und seinem ELTERN-Szenario.
|
||||||
//
|
//
|
||||||
// Die Zuordnung laeuft ueber die Herkunfts-Verweise, die beim Kopieren gesetzt werden
|
// Die Zuordnung läuft über die Herkunfts-Verweise, die beim Kopieren gesetzt werden
|
||||||
// (Phase.sourcePhaseId, FinancialElement.sourceElementId). Ueber den Namen zu matchen waere
|
// (Phase.sourcePhaseId, FinancialElement.sourceElementId). Über den Namen zu matchen wäre
|
||||||
// fragil: Umbenennen wuerde die Verknuepfung brechen und gleichnamige Elemente kollidieren.
|
// fragil: Umbenennen würde die Verknüpfung brechen und gleichnamige Elemente kollidieren.
|
||||||
//
|
//
|
||||||
// Der Vergleich ist LIVE gegen den aktuellen Stand des Elternteils -- aendert man dort einen
|
// Der Vergleich ist LIVE gegen den aktuellen Stand des Elternteils -- ändert man dort einen
|
||||||
// Wert, verschiebt sich die Markierung im Kind entsprechend.
|
// Wert, verschiebt sich die Markierung im Kind entsprechend.
|
||||||
|
|
||||||
import { num } from "@/lib/elements";
|
import { num } from "@/lib/elements";
|
||||||
@@ -18,15 +18,15 @@ export interface ScenarioDiff {
|
|||||||
elementRow: Map<string, DiffKind>;
|
elementRow: Map<string, DiffKind>;
|
||||||
// `${elementId}:${phaseId}` -> Zelle weicht ab.
|
// `${elementId}:${phaseId}` -> Zelle weicht ab.
|
||||||
phaseCell: Set<string>;
|
phaseCell: Set<string>;
|
||||||
// `${elementId}:${fromPhaseId}` -> Uebergangs-Zelle weicht ab.
|
// `${elementId}:${fromPhaseId}` -> Übergangs-Zelle weicht ab.
|
||||||
transitionCell: Set<string>;
|
transitionCell: Set<string>;
|
||||||
// phaseId -> Phasenkopf weicht ab (Name/Dauer) bzw. Phase ist neu.
|
// phaseId -> Phasenkopf weicht ab (Name/Dauer) bzw. Phase ist neu.
|
||||||
phaseHeader: Map<string, DiffKind>;
|
phaseHeader: Map<string, DiffKind>;
|
||||||
// phaseId -> Cash-Uebergang nach dieser Phase weicht ab.
|
// phaseId -> Cash-Übergang nach dieser Phase weicht ab.
|
||||||
cashTransitionCell: Set<string>;
|
cashTransitionCell: Set<string>;
|
||||||
cashInitialChanged: boolean;
|
cashInitialChanged: boolean;
|
||||||
profileChanged: boolean;
|
profileChanged: boolean;
|
||||||
// Im Elternteil vorhandene, hier geloeschte Elemente (fuer die Geisterzeilen).
|
// Im Elternteil vorhandene, hier gelöschte Elemente (für die Geisterzeilen).
|
||||||
removedElements: { id: string; name: string; category: ElementInput["category"] }[];
|
removedElements: { id: string; name: string; category: ElementInput["category"] }[];
|
||||||
removedPhaseCount: number;
|
removedPhaseCount: number;
|
||||||
total: number;
|
total: number;
|
||||||
@@ -48,7 +48,7 @@ export function emptyDiff(): ScenarioDiff {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Vergleicht zwei JSON-Payloads feldweise; fehlend und 0 gelten als gleich, damit ein
|
// Vergleicht zwei JSON-Payloads feldweise; fehlend und 0 gelten als gleich, damit ein
|
||||||
// nicht gesetztes Feld nicht faelschlich als Aenderung erscheint.
|
// nicht gesetztes Feld nicht fälschlich als Änderung erscheint.
|
||||||
function sameData(a: object = {}, b: object = {}): boolean {
|
function sameData(a: object = {}, b: object = {}): boolean {
|
||||||
const ra = a as Record<string, unknown>;
|
const ra = a as Record<string, unknown>;
|
||||||
const rb = b as Record<string, unknown>;
|
const rb = b as Record<string, unknown>;
|
||||||
@@ -72,7 +72,7 @@ export function computeScenarioDiff(scenario: PlanInput, base: PlanInput | null)
|
|||||||
// --- Phasen ---
|
// --- Phasen ---
|
||||||
const basePhaseById = new Map(base.phases.map((p) => [p.id, p]));
|
const basePhaseById = new Map(base.phases.map((p) => [p.id, p]));
|
||||||
const usedBasePhases = new Set<string>();
|
const usedBasePhases = new Set<string>();
|
||||||
// Zuordnung eigene Phase -> Eltern-Phase (fuer die Zellen-Vergleiche).
|
// Zuordnung eigene Phase -> Eltern-Phase (für die Zellen-Vergleiche).
|
||||||
const phaseToBase = new Map<string, string>();
|
const phaseToBase = new Map<string, string>();
|
||||||
|
|
||||||
for (const ph of scenario.phases) {
|
for (const ph of scenario.phases) {
|
||||||
@@ -108,7 +108,7 @@ export function computeScenarioDiff(scenario: PlanInput, base: PlanInput | null)
|
|||||||
d.elementRow.set(el.id, "changed");
|
d.elementRow.set(el.id, "changed");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Zellen je Phase / Uebergang ueber die Phasen-Zuordnung vergleichen.
|
// Zellen je Phase / Übergang über die Phasen-Zuordnung vergleichen.
|
||||||
for (const ph of scenario.phases) {
|
for (const ph of scenario.phases) {
|
||||||
const basePhaseId = phaseToBase.get(ph.id);
|
const basePhaseId = phaseToBase.get(ph.id);
|
||||||
const mine = el.phaseValues[ph.id];
|
const mine = el.phaseValues[ph.id];
|
||||||
|
|||||||
+22
-22
@@ -1,4 +1,4 @@
|
|||||||
// Typ- und Validierungs-Layer fuer die finanziellen Elemente. Die kategorie- und
|
// Typ- und Validierungs-Layer für die finanziellen Elemente. Die kategorie- und
|
||||||
// kontextspezifischen Felder liegen in der DB als JSON; hier werden sie typisiert und
|
// kontextspezifischen Felder liegen in der DB als JSON; hier werden sie typisiert und
|
||||||
// (an der API-Grenze) mit Zod validiert.
|
// (an der API-Grenze) mit Zod validiert.
|
||||||
|
|
||||||
@@ -19,7 +19,7 @@ export type OwnerRole = "PERSON_A" | "PERSON_B" | "HOUSEHOLD";
|
|||||||
// Kategorien, deren Element zwingend genau einer Person zugeordnet ist.
|
// Kategorien, deren Element zwingend genau einer Person zugeordnet ist.
|
||||||
export const PERSON_ONLY_CATEGORIES: ElementCategory[] = ["AHV", "PENSION_FUND", "PILLAR_3A"];
|
export const PERSON_ONLY_CATEGORIES: ElementCategory[] = ["AHV", "PENSION_FUND", "PILLAR_3A"];
|
||||||
|
|
||||||
// Kategorien, die gemeinsam ODER pro Person erfasst werden koennen (inkl. Einkommen).
|
// Kategorien, die gemeinsam ODER pro Person erfasst werden können (inkl. Einkommen).
|
||||||
export const OWNER_OPTIONAL_CATEGORIES: ElementCategory[] = ["INCOME", "EXPENSE", "REAL_ESTATE", "OTHER_ASSET", "OTHER_DEBT"];
|
export const OWNER_OPTIONAL_CATEGORIES: ElementCategory[] = ["INCOME", "EXPENSE", "REAL_ESTATE", "OTHER_ASSET", "OTHER_DEBT"];
|
||||||
|
|
||||||
export const CATEGORY_LABELS: Record<ElementCategory, string> = {
|
export const CATEGORY_LABELS: Record<ElementCategory, string> = {
|
||||||
@@ -27,9 +27,9 @@ export const CATEGORY_LABELS: Record<ElementCategory, string> = {
|
|||||||
EXPENSE: "Ausgaben",
|
EXPENSE: "Ausgaben",
|
||||||
AHV: "AHV",
|
AHV: "AHV",
|
||||||
PENSION_FUND: "Pensionskasse",
|
PENSION_FUND: "Pensionskasse",
|
||||||
PILLAR_3A: "Saeule 3a",
|
PILLAR_3A: "Säule 3a",
|
||||||
REAL_ESTATE: "Immobilie",
|
REAL_ESTATE: "Immobilie",
|
||||||
OTHER_ASSET: "Sonstiges Vermoegen",
|
OTHER_ASSET: "Sonstiges Vermögen",
|
||||||
OTHER_DEBT: "Sonstige Schulden",
|
OTHER_DEBT: "Sonstige Schulden",
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -52,14 +52,14 @@ export const CATEGORY_ORDER: ElementCategory[] = [
|
|||||||
export interface PhaseData {
|
export interface PhaseData {
|
||||||
// INCOME / EXPENSE
|
// INCOME / EXPENSE
|
||||||
amount?: number;
|
amount?: number;
|
||||||
// INCOME / EXPENSE: jaehrlicher Teuerungsausgleich (%). Indexiert den Flow ueber die
|
// INCOME / EXPENSE: jährlicher Teuerungsausgleich (%). Indexiert den Flow über die
|
||||||
// Phasenjahre (Jahr t = Basis x (1+idx)^(t-1)). Eigenes Feld je Element (Einkommen und
|
// Phasenjahre (Jahr t = Basis x (1+idx)^(t-1)). Eigenes Feld je Element (Einkommen und
|
||||||
// Ausgaben unabhaengig). Default = Phaseninflation.
|
// Ausgaben unabhängig). Default = Phaseninflation.
|
||||||
teuerungsausgleich?: number;
|
teuerungsausgleich?: number;
|
||||||
// AHV
|
// AHV
|
||||||
gapYears?: number;
|
gapYears?: number;
|
||||||
// AHV, nur wenn die Person bei Planbeginn BEREITS pensioniert ist (dann gibt es keinen
|
// AHV, nur wenn die Person bei Planbeginn BEREITS pensioniert ist (dann gibt es keinen
|
||||||
// Pensions-Uebergang, an dem die Karriere geprueft werden koennte): Beitragskarriere.
|
// Pensions-Übergang, an dem die Karriere geprüft werden könnte): Beitragskarriere.
|
||||||
// avgIncomeBefore ist REAL (heutige Kaufkraft).
|
// avgIncomeBefore ist REAL (heutige Kaufkraft).
|
||||||
avgIncomeBefore?: number;
|
avgIncomeBefore?: number;
|
||||||
gapYearsBefore?: number;
|
gapYearsBefore?: number;
|
||||||
@@ -68,10 +68,10 @@ export interface PhaseData {
|
|||||||
startValue?: number;
|
startValue?: number;
|
||||||
expectedReturn?: number;
|
expectedReturn?: number;
|
||||||
annualContribution?: number;
|
annualContribution?: number;
|
||||||
// OTHER_ASSET: jaehrliche Bezugsrate (Entnahme). Mindert das Vermoegen und fliesst ins Cash.
|
// OTHER_ASSET: jährliche Bezugsrate (Entnahme). Mindert das Vermögen und fliesst ins Cash.
|
||||||
annualWithdrawal?: number;
|
annualWithdrawal?: number;
|
||||||
// PENSION_FUND / PILLAR_3A / OTHER_ASSET (ab Phase 2): zusaetzliche Einlage aus dem
|
// PENSION_FUND / PILLAR_3A / OTHER_ASSET (ab Phase 2): zusätzliche Einlage aus dem
|
||||||
// verfuegbaren Kapital der Phase. Der Basis-Startwert wird aus der Vorphase fortgeschrieben.
|
// verfügbaren Kapital der Phase. Der Basis-Startwert wird aus der Vorphase fortgeschrieben.
|
||||||
additionalInvestment?: number;
|
additionalInvestment?: number;
|
||||||
// REAL_ESTATE
|
// REAL_ESTATE
|
||||||
purchasePrice?: number;
|
purchasePrice?: number;
|
||||||
@@ -79,10 +79,10 @@ export interface PhaseData {
|
|||||||
amortization?: number;
|
amortization?: number;
|
||||||
// Hypothekarzins in % der Restschuld. Der Zinsbetrag sinkt dadurch mit der Amortisation.
|
// Hypothekarzins in % der Restschuld. Der Zinsbetrag sinkt dadurch mit der Amortisation.
|
||||||
interestRate?: number;
|
interestRate?: number;
|
||||||
// Steuert die Doppelzaehlung: Sind die Zinsen im Ausgaben-Element bereits enthalten
|
// Steuert die Doppelzählung: Sind die Zinsen im Ausgaben-Element bereits enthalten
|
||||||
// (INCLUDED, Default -- Verhalten bisheriger Plaene) oder soll das Tool sie dazurechnen (ADD)?
|
// (INCLUDED, Default -- Verhalten bisheriger Pläne) oder soll das Tool sie dazurechnen (ADD)?
|
||||||
interestHandling?: "INCLUDED" | "ADD";
|
interestHandling?: "INCLUDED" | "ADD";
|
||||||
// Geschaetzte jaehrliche Wertveraenderung der LIEGENSCHAFT (nicht des Eigenkapitals).
|
// Geschätzte jährliche Wertveränderung der LIEGENSCHAFT (nicht des Eigenkapitals).
|
||||||
valueGrowth?: number;
|
valueGrowth?: number;
|
||||||
// OTHER_DEBT
|
// OTHER_DEBT
|
||||||
annualRepayment?: number;
|
annualRepayment?: number;
|
||||||
@@ -92,36 +92,36 @@ export type TransitionDecision = "HOLD" | "SELL" | "PARTIAL";
|
|||||||
export type PkPayoutMode = "CAPITAL" | "PENSION" | "COMBI";
|
export type PkPayoutMode = "CAPITAL" | "PENSION" | "COMBI";
|
||||||
|
|
||||||
export interface TransitionData {
|
export interface TransitionData {
|
||||||
// AHV (Pensions-Uebergang): Pruefung der Beitragskarriere. Das geplante Durchschnitts-
|
// AHV (Pensions-Übergang): Prüfung der Beitragskarriere. Das geplante Durchschnitts-
|
||||||
// einkommen kommt aus dem Plan; reicht der Plan nicht bis zum Beitragsbeginn (Alter 21)
|
// einkommen kommt aus dem Plan; reicht der Plan nicht bis zum Beitragsbeginn (Alter 21)
|
||||||
// zurueck, ergaenzt der Benutzer die Jahre davor. avgIncomeBefore ist REAL.
|
// zurück, ergänzt der Benutzer die Jahre davor. avgIncomeBefore ist REAL.
|
||||||
reviewed?: boolean;
|
reviewed?: boolean;
|
||||||
avgIncomeBefore?: number;
|
avgIncomeBefore?: number;
|
||||||
gapYearsBefore?: number;
|
gapYearsBefore?: number;
|
||||||
// PENSION_FUND / PILLAR_3A (normaler Uebergang): expliziter Bezugs-Entscheid.
|
// PENSION_FUND / PILLAR_3A (normaler Übergang): expliziter Bezugs-Entscheid.
|
||||||
withdrawalMode?: "NONE" | "AMOUNT";
|
withdrawalMode?: "NONE" | "AMOUNT";
|
||||||
withdrawal?: number;
|
withdrawal?: number;
|
||||||
// PENSION_FUND (Pensions-Uebergang)
|
// PENSION_FUND (Pensions-Übergang)
|
||||||
payoutMode?: PkPayoutMode;
|
payoutMode?: PkPayoutMode;
|
||||||
capitalAmount?: number;
|
capitalAmount?: number;
|
||||||
conversionRate?: number;
|
conversionRate?: number;
|
||||||
// PENSION_FUND (Kapital) / PILLAR_3A (Pensions-Uebergang) / REAL_ESTATE
|
// PENSION_FUND (Kapital) / PILLAR_3A (Pensions-Übergang) / REAL_ESTATE
|
||||||
capitalTaxRate?: number;
|
capitalTaxRate?: number;
|
||||||
saleTaxRate?: number;
|
saleTaxRate?: number;
|
||||||
// REAL_ESTATE / OTHER_ASSET
|
// REAL_ESTATE / OTHER_ASSET
|
||||||
decision?: TransitionDecision;
|
decision?: TransitionDecision;
|
||||||
salePrice?: number;
|
salePrice?: number;
|
||||||
// OTHER_ASSET (Teilverkauf): Betrag, der am Uebergang ins Cash fliesst; der Rest bleibt aktiv.
|
// OTHER_ASSET (Teilverkauf): Betrag, der am Übergang ins Cash fliesst; der Rest bleibt aktiv.
|
||||||
partialSaleAmount?: number;
|
partialSaleAmount?: number;
|
||||||
// REAL_ESTATE (Sonderamortisation): Einmaltilgung der Hypothek am Uebergang, aus dem Cash.
|
// REAL_ESTATE (Sonderamortisation): Einmaltilgung der Hypothek am Übergang, aus dem Cash.
|
||||||
extraAmortization?: number;
|
extraAmortization?: number;
|
||||||
// OTHER_DEBT
|
// OTHER_DEBT
|
||||||
immediateRepayment?: number;
|
immediateRepayment?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Cash-Uebergang: einmalige Sonderein-/ausgaben ---
|
// --- Cash-Übergang: einmalige Sonderein-/ausgaben ---
|
||||||
// Entscheid am UEBERGANG zwischen zwei Phasen, direkt auf dem Cash-Konto (Cash ist kein
|
// Entscheid am UEBERGANG zwischen zwei Phasen, direkt auf dem Cash-Konto (Cash ist kein
|
||||||
// Element, der Entscheid haengt darum an der Von-Phase). Erfassungs-Konventionen analog zu
|
// Element, der Entscheid hängt darum an der Von-Phase). Erfassungs-Konventionen analog zu
|
||||||
// den laufenden Flows: Zufluss NOMINAL (wie Einkommen), Kosten REAL (wie Ausgaben).
|
// den laufenden Flows: Zufluss NOMINAL (wie Einkommen), Kosten REAL (wie Ausgaben).
|
||||||
|
|
||||||
export type CashTransitionMode = "NONE" | "INFLOW" | "OUTFLOW" | "BOTH";
|
export type CashTransitionMode = "NONE" | "INFLOW" | "OUTFLOW" | "BOTH";
|
||||||
|
|||||||
+17
-17
@@ -7,7 +7,7 @@ import type { PlanInput } from "@/lib/types";
|
|||||||
|
|
||||||
// Reichhaltiger Plan: Erwerbsphase mit Einkommen, Ausgaben, PK, 3a, Immobilie (Hypothek,
|
// Reichhaltiger Plan: Erwerbsphase mit Einkommen, Ausgaben, PK, 3a, Immobilie (Hypothek,
|
||||||
// Amortisation, Wertsteigerung) und einer Schuld; danach Pensionsphase mit Verzehr.
|
// Amortisation, Wertsteigerung) und einer Schuld; danach Pensionsphase mit Verzehr.
|
||||||
// Am Uebergang: PK-Verrentung, 3a-Bezug mit Steuer, Sonderamortisation, Einmalposten.
|
// Am Übergang: PK-Verrentung, 3a-Bezug mit Steuer, Sonderamortisation, Einmalposten.
|
||||||
function richPlan(): PlanInput {
|
function richPlan(): PlanInput {
|
||||||
return {
|
return {
|
||||||
id: "p",
|
id: "p",
|
||||||
@@ -93,8 +93,8 @@ describe("Verlaufswerte je Element", () => {
|
|||||||
const etf = c.phases[0].elements.find((e) => e.elementId === "asset")!;
|
const etf = c.phases[0].elements.find((e) => e.elementId === "asset")!;
|
||||||
expect(etf.yearly[0].value).toBe(220000);
|
expect(etf.yearly[0].value).toBe(220000);
|
||||||
expect(etf.yearly[1].value).toBe(Math.round(220000 * 1.05 + 10000));
|
expect(etf.yearly[1].value).toBe(Math.round(220000 * 1.05 + 10000));
|
||||||
// Konvexitaet: die Jahreszuwaechse werden von Jahr zu Jahr GROESSER. Eine Gerade
|
// Konvexität: die Jahreszuwächse werden von Jahr zu Jahr GROESSER. Eine Gerade
|
||||||
// zwischen den Phasengrenzen haette konstante Zuwaechse -- genau der Fehler, den die
|
// zwischen den Phasengrenzen hätte konstante Zuwächse -- genau der Fehler, den die
|
||||||
// per-Jahr-Werte vermeiden.
|
// per-Jahr-Werte vermeiden.
|
||||||
const deltas = etf.yearly.slice(1).map((p, i) => p.value - etf.yearly[i].value);
|
const deltas = etf.yearly.slice(1).map((p, i) => p.value - etf.yearly[i].value);
|
||||||
for (let i = 1; i < deltas.length; i++) {
|
for (let i = 1; i < deltas.length; i++) {
|
||||||
@@ -102,7 +102,7 @@ describe("Verlaufswerte je Element", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("Immobilie fuehrt Verkehrswert und Restschuld getrennt mit", () => {
|
it("Immobilie führt Verkehrswert und Restschuld getrennt mit", () => {
|
||||||
const c = computePlan(richPlan());
|
const c = computePlan(richPlan());
|
||||||
const re = c.phases[0].elements.find((e) => e.elementId === "re")!;
|
const re = c.phases[0].elements.find((e) => e.elementId === "re")!;
|
||||||
const y0 = re.yearly[0];
|
const y0 = re.yearly[0];
|
||||||
@@ -112,8 +112,8 @@ describe("Verlaufswerte je Element", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Wasserfall-Bruecken", () => {
|
describe("Wasserfall-Brücken", () => {
|
||||||
it("Vermoegens-Bruecke geht auf (Restposten ~ 0)", () => {
|
it("Vermögens-Brücke geht auf (Restposten ~ 0)", () => {
|
||||||
const c = computePlan(richPlan());
|
const c = computePlan(richPlan());
|
||||||
for (const ph of c.phases) {
|
for (const ph of c.phases) {
|
||||||
// Rundungen auf ganze Franken je Summand -> ein paar Franken Toleranz.
|
// Rundungen auf ganze Franken je Summand -> ein paar Franken Toleranz.
|
||||||
@@ -123,7 +123,7 @@ describe("Wasserfall-Bruecken", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("Cash-Bruecke geht auf (Restposten ~ 0)", () => {
|
it("Cash-Brücke geht auf (Restposten ~ 0)", () => {
|
||||||
const c = computePlan(richPlan());
|
const c = computePlan(richPlan());
|
||||||
for (const ph of c.phases) {
|
for (const ph of c.phases) {
|
||||||
expect(Math.abs(ph.cashBridge.residual)).toBeLessThanOrEqual(5);
|
expect(Math.abs(ph.cashBridge.residual)).toBeLessThanOrEqual(5);
|
||||||
@@ -132,7 +132,7 @@ describe("Wasserfall-Bruecken", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("Sparraten erscheinen in der CASH-, nicht in der Vermoegens-Bruecke", () => {
|
it("Sparraten erscheinen in der CASH-, nicht in der Vermögens-Brücke", () => {
|
||||||
// Reiner Umbuchungs-Plan: 12'000 Sparbeitrag pro Jahr, 0 % Rendite, keine Flows.
|
// Reiner Umbuchungs-Plan: 12'000 Sparbeitrag pro Jahr, 0 % Rendite, keine Flows.
|
||||||
const plan: PlanInput = {
|
const plan: PlanInput = {
|
||||||
id: "p", name: "T", householdType: "SINGLE", inflationRateDefault: 0, initialCash: 100000,
|
id: "p", name: "T", householdType: "SINGLE", inflationRateDefault: 0, initialCash: 100000,
|
||||||
@@ -149,14 +149,14 @@ describe("Wasserfall-Bruecken", () => {
|
|||||||
// Cash: 5 x 12'000 fliessen ab.
|
// Cash: 5 x 12'000 fliessen ab.
|
||||||
expect(ph.cashBridge.savingRates).toBe(60000);
|
expect(ph.cashBridge.savingRates).toBe(60000);
|
||||||
expect(ph.cashEnd).toBe(100000 - 60000);
|
expect(ph.cashEnd).toBe(100000 - 60000);
|
||||||
// Vermoegen: unveraendert -- das Geld ist nur umgezogen, kein Ertrag, keine Quote.
|
// Vermögen: unverändert -- das Geld ist nur umgezogen, kein Ertrag, keine Quote.
|
||||||
expect(ph.wealthBridge.quotaTotal).toBe(0);
|
expect(ph.wealthBridge.quotaTotal).toBe(0);
|
||||||
expect(ph.wealthBridge.investmentReturn).toBe(0);
|
expect(ph.wealthBridge.investmentReturn).toBe(0);
|
||||||
expect(ph.startWealthNominal).toBe(100000);
|
expect(ph.startWealthNominal).toBe(100000);
|
||||||
expect(ph.endWealthNominal).toBe(100000);
|
expect(ph.endWealthNominal).toBe(100000);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("PK-Beitraege sind ein Vermoegenszugang, ohne das Cash zu belasten", () => {
|
it("PK-Beiträge sind ein Vermögenszugang, ohne das Cash zu belasten", () => {
|
||||||
const plan: PlanInput = {
|
const plan: PlanInput = {
|
||||||
id: "p", name: "T", householdType: "SINGLE", inflationRateDefault: 0, initialCash: 0,
|
id: "p", name: "T", householdType: "SINGLE", inflationRateDefault: 0, initialCash: 0,
|
||||||
persons: [{ id: "A", role: "PERSON_A", name: null, age: 40, retirementAge: 70 }],
|
persons: [{ id: "A", role: "PERSON_A", name: null, age: 40, retirementAge: 70 }],
|
||||||
@@ -174,18 +174,18 @@ describe("Wasserfall-Bruecken", () => {
|
|||||||
expect(ph.endWealthNominal).toBe(40000);
|
expect(ph.endWealthNominal).toBe(40000);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("Verrentung und Kapitalbezugssteuer erscheinen in der Vermoegens-Bruecke der Folgephase", () => {
|
it("Verrentung und Kapitalbezugssteuer erscheinen in der Vermögens-Brücke der Folgephase", () => {
|
||||||
const c = computePlan(richPlan());
|
const c = computePlan(richPlan());
|
||||||
const p2 = c.phases[1];
|
const p2 = c.phases[1];
|
||||||
expect(p2.wealthBridge.pensionConversion).toBeGreaterThan(0); // PK wurde verrentet
|
expect(p2.wealthBridge.pensionConversion).toBeGreaterThan(0); // PK wurde verrentet
|
||||||
expect(p2.wealthBridge.transitionTax).toBeGreaterThan(0); // 8 % auf den 3a-Bezug
|
expect(p2.wealthBridge.transitionTax).toBeGreaterThan(0); // 8 % auf den 3a-Bezug
|
||||||
expect(p2.wealthBridge.oneOffInflow).toBe(90000); // 100'000 abzueglich 10 % Steuer
|
expect(p2.wealthBridge.oneOffInflow).toBe(90000); // 100'000 abzüglich 10 % Steuer
|
||||||
expect(p2.wealthBridge.oneOffOutflow).toBeGreaterThan(20000); // real 20'000, nominal aufgewertet
|
expect(p2.wealthBridge.oneOffOutflow).toBeGreaterThan(20000); // real 20'000, nominal aufgewertet
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Rechenweg (explain)", () => {
|
describe("Rechenweg (explain)", () => {
|
||||||
it("ist standardmaessig aus und aendert das Ergebnis nicht", () => {
|
it("ist standardmässig aus und ändert das Ergebnis nicht", () => {
|
||||||
const plain = computePlan(richPlan());
|
const plain = computePlan(richPlan());
|
||||||
const explained = computePlan(richPlan(), undefined, { explain: true });
|
const explained = computePlan(richPlan(), undefined, { explain: true });
|
||||||
|
|
||||||
@@ -208,7 +208,7 @@ describe("Rechenweg (explain)", () => {
|
|||||||
expect((c.phases[0].traces ?? []).length).toBeGreaterThan(0);
|
expect((c.phases[0].traces ?? []).length).toBeGreaterThan(0);
|
||||||
|
|
||||||
const withTrace = c.phases[0].elements.filter((e) => e.trace);
|
const withTrace = c.phases[0].elements.filter((e) => e.trace);
|
||||||
// Einkommen, Ausgaben, PK, 3a, Immobilie, Vermoegen, Schuld
|
// Einkommen, Ausgaben, PK, 3a, Immobilie, Vermögen, Schuld
|
||||||
expect(withTrace.length).toBeGreaterThanOrEqual(7);
|
expect(withTrace.length).toBeGreaterThanOrEqual(7);
|
||||||
|
|
||||||
const withTxTrace = c.phases[0].elements.filter((e) => e.transitionTrace);
|
const withTxTrace = c.phases[0].elements.filter((e) => e.transitionTrace);
|
||||||
@@ -224,9 +224,9 @@ describe("Rechenweg (explain)", () => {
|
|||||||
expect(endStep.result).toBe(inc.endValue);
|
expect(endStep.result).toBe(inc.endValue);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Der Rechenweg verlinkt in die SPEZIFIKATION. Wird dort eine Ueberschrift umbenannt,
|
// Der Rechenweg verlinkt in die SPEZIFIKATION. Wird dort eine Überschrift umbenannt,
|
||||||
// zeigen die Verweise ins Leere -- und zwar still. Dieser Test faengt das ab.
|
// zeigen die Verweise ins Leere -- und zwar still. Dieser Test fängt das ab.
|
||||||
it("alle Spezifikations-Verweise treffen eine existierende Ueberschrift", () => {
|
it("alle Spezifikations-Verweise treffen eine existierende Überschrift", () => {
|
||||||
const md = readFileSync(path.join(process.cwd(), "SPEZIFIKATION.md"), "utf8");
|
const md = readFileSync(path.join(process.cwd(), "SPEZIFIKATION.md"), "utf8");
|
||||||
// Slug-Regel von github-slugger (das rehype-slug verwendet) nachgebildet.
|
// Slug-Regel von github-slugger (das rehype-slug verwendet) nachgebildet.
|
||||||
const slug = (s: string) =>
|
const slug = (s: string) =>
|
||||||
|
|||||||
+2
-2
@@ -1,5 +1,5 @@
|
|||||||
// Einheitliches Format fuer Geldbetraege im ganzen Tool: Tausendertrennzeichen mit
|
// Einheitliches Format für Geldbeträge im ganzen Tool: Tausendertrennzeichen mit
|
||||||
// geradem Apostroph, keine Nachkommastellen (z. B. 1'780'000). Bewusst nicht ueber
|
// geradem Apostroph, keine Nachkommastellen (z. B. 1'780'000). Bewusst nicht über
|
||||||
// Intl/toLocaleString("de-CH"), da dessen Gruppentrennzeichen (’ U+2019) nicht dem
|
// Intl/toLocaleString("de-CH"), da dessen Gruppentrennzeichen (’ U+2019) nicht dem
|
||||||
// geraden Apostroph entspricht.
|
// geraden Apostroph entspricht.
|
||||||
export function formatChf(value: number): string {
|
export function formatChf(value: number): string {
|
||||||
|
|||||||
+11
-11
@@ -1,6 +1,6 @@
|
|||||||
// Spielt ALLE Migrationen gegen ein echtes PostgreSQL (PGlite, in-process) ein und prueft das
|
// Spielt ALLE Migrationen gegen ein echtes PostgreSQL (PGlite, in-process) ein und prüft das
|
||||||
// Ergebnis. Faengt kaputte oder nicht-idempotente Migrations-SQL ab, bevor sie beim Deploy
|
// Ergebnis. Fängt kaputte oder nicht-idempotente Migrations-SQL ab, bevor sie beim Deploy
|
||||||
// gegen die Live-Datenbank laufen -- lokal steht sonst keine Datenbank zur Verfuegung.
|
// gegen die Live-Datenbank laufen -- lokal steht sonst keine Datenbank zur Verfügung.
|
||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import { PGlite } from "@electric-sql/pglite";
|
import { PGlite } from "@electric-sql/pglite";
|
||||||
import { readFileSync, readdirSync } from "node:fs";
|
import { readFileSync, readdirSync } from "node:fs";
|
||||||
@@ -9,7 +9,7 @@ import path from "node:path";
|
|||||||
const MIG = path.join(process.cwd(), "prisma", "migrations");
|
const MIG = path.join(process.cwd(), "prisma", "migrations");
|
||||||
|
|
||||||
describe("Datenbank-Migrationen", () => {
|
describe("Datenbank-Migrationen", () => {
|
||||||
it("laufen vollstaendig durch und ergeben das erwartete Schema", async () => {
|
it("laufen vollständig durch und ergeben das erwartete Schema", async () => {
|
||||||
const db = await PGlite.create();
|
const db = await PGlite.create();
|
||||||
const dirs = readdirSync(MIG).filter((d) => !d.endsWith(".toml")).sort();
|
const dirs = readdirSync(MIG).filter((d) => !d.endsWith(".toml")).sort();
|
||||||
expect(dirs.length).toBeGreaterThan(0);
|
expect(dirs.length).toBeGreaterThan(0);
|
||||||
@@ -24,7 +24,7 @@ describe("Datenbank-Migrationen", () => {
|
|||||||
)
|
)
|
||||||
).rows.map((r) => r.table_name);
|
).rows.map((r) => r.table_name);
|
||||||
|
|
||||||
// V6-Struktur: Behaelter Plan + berechenbares Scenario.
|
// V6-Struktur: Behälter Plan + berechenbares Scenario.
|
||||||
for (const t of ["User", "Plan", "Scenario", "Person", "Phase", "FinancialElement"]) {
|
for (const t of ["User", "Plan", "Scenario", "Person", "Phase", "FinancialElement"]) {
|
||||||
expect(tables, `Tabelle ${t} fehlt`).toContain(t);
|
expect(tables, `Tabelle ${t} fehlt`).toContain(t);
|
||||||
}
|
}
|
||||||
@@ -37,27 +37,27 @@ describe("Datenbank-Migrationen", () => {
|
|||||||
)
|
)
|
||||||
).rows.map((r) => r.column_name);
|
).rows.map((r) => r.column_name);
|
||||||
|
|
||||||
// Kind-Tabellen haengen am Szenario, nicht mehr am Plan.
|
// Kind-Tabellen hängen am Szenario, nicht mehr am Plan.
|
||||||
for (const t of ["Person", "Phase", "FinancialElement"]) {
|
for (const t of ["Person", "Phase", "FinancialElement"]) {
|
||||||
const c = await cols(t);
|
const c = await cols(t);
|
||||||
expect(c, `${t}.scenarioId fehlt`).toContain("scenarioId");
|
expect(c, `${t}.scenarioId fehlt`).toContain("scenarioId");
|
||||||
expect(c, `${t}.planId haette entfernt werden muessen`).not.toContain("planId");
|
expect(c, `${t}.planId hätte entfernt werden müssen`).not.toContain("planId");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Herkunfts-Verweise fuer den Diff.
|
// Herkunfts-Verweise für den Diff.
|
||||||
expect(await cols("Phase")).toContain("sourcePhaseId");
|
expect(await cols("Phase")).toContain("sourcePhaseId");
|
||||||
expect(await cols("FinancialElement")).toContain("sourceElementId");
|
expect(await cols("FinancialElement")).toContain("sourceElementId");
|
||||||
|
|
||||||
// Der Plan traegt keine Finanzdaten mehr.
|
// Der Plan trägt keine Finanzdaten mehr.
|
||||||
const planCols = await cols("Plan");
|
const planCols = await cols("Plan");
|
||||||
expect(planCols).not.toContain("householdType");
|
expect(planCols).not.toContain("householdType");
|
||||||
expect(planCols).toContain("userId");
|
expect(planCols).toContain("userId");
|
||||||
|
|
||||||
// Das Szenario traegt sie.
|
// Das Szenario trägt sie.
|
||||||
const scenCols = await cols("Scenario");
|
const scenCols = await cols("Scenario");
|
||||||
for (const c of ["planId", "isBase", "parentScenarioId", "householdType", "initialCash"]) {
|
for (const c of ["planId", "isBase", "parentScenarioId", "householdType", "initialCash"]) {
|
||||||
expect(scenCols, `Scenario.${c} fehlt`).toContain(c);
|
expect(scenCols, `Scenario.${c} fehlt`).toContain(c);
|
||||||
}
|
}
|
||||||
expect(scenCols).not.toContain("userId"); // Eigentuemer haengt am Plan
|
expect(scenCols).not.toContain("userId"); // Eigentümer hängt am Plan
|
||||||
}, 60000);
|
}, 60000);
|
||||||
});
|
});
|
||||||
|
|||||||
+21
-21
@@ -12,8 +12,8 @@ import {
|
|||||||
} from "@/lib/montecarlo";
|
} from "@/lib/montecarlo";
|
||||||
import type { PlanInput } from "@/lib/types";
|
import type { PlanInput } from "@/lib/types";
|
||||||
|
|
||||||
// Plan: 40-jaehrig, 1 Phase 10 Jahre, ein Sonstiges Vermoegen 100'000 @ 5 %, 2 % Inflation.
|
// Plan: 40-jährig, 1 Phase 10 Jahre, ein Sonstiges Vermögen 100'000 @ 5 %, 2 % Inflation.
|
||||||
// Deterministisch: 100'000 x 1.05^10 = 162'889 Endvermoegen, kein Ruin.
|
// Deterministisch: 100'000 x 1.05^10 = 162'889 Endvermögen, kein Ruin.
|
||||||
function basePlan(): PlanInput {
|
function basePlan(): PlanInput {
|
||||||
return {
|
return {
|
||||||
id: "p", name: "T", householdType: "SINGLE", inflationRateDefault: 2, initialCash: 0,
|
id: "p", name: "T", householdType: "SINGLE", inflationRateDefault: 2, initialCash: 0,
|
||||||
@@ -49,15 +49,15 @@ describe("Monte Carlo", () => {
|
|||||||
const det = detEnd();
|
const det = detEnd();
|
||||||
expect(r.finalWealthMedian).toBe(det);
|
expect(r.finalWealthMedian).toBe(det);
|
||||||
expect(r.finalWealthP10).toBe(det);
|
expect(r.finalWealthP10).toBe(det);
|
||||||
expect(r.finalWealthP90).toBe(det); // Baender kollabieren auf die deterministische Linie
|
expect(r.finalWealthP90).toBe(det); // Bänder kollabieren auf die deterministische Linie
|
||||||
expect(r.ruinProbability).toBe(0);
|
expect(r.ruinProbability).toBe(0);
|
||||||
expect(r.bands[r.bands.length - 1].p50).toBe(det);
|
expect(r.bands[r.bands.length - 1].p50).toBe(det);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("Volatilitaet spreizt den Faecher (p90 > p10)", async () => {
|
it("Volatilität spreizt den Fächer (p90 > p10)", async () => {
|
||||||
const r = await runMonteCarlo(basePlan(), params({ elements: { asset: { mean: 5, sigma: 20, floor: -100 } } }));
|
const r = await runMonteCarlo(basePlan(), params({ elements: { asset: { mean: 5, sigma: 20, floor: -100 } } }));
|
||||||
expect(r.finalWealthP90).toBeGreaterThan(r.finalWealthP10);
|
expect(r.finalWealthP90).toBeGreaterThan(r.finalWealthP10);
|
||||||
// Median bleibt in der Naehe des deterministischen Werts (leicht darunter wegen Vol-Drag).
|
// Median bleibt in der Nähe des deterministischen Werts (leicht darunter wegen Vol-Drag).
|
||||||
expect(r.finalWealthMedian).toBeLessThan(r.finalWealthP90);
|
expect(r.finalWealthMedian).toBeLessThan(r.finalWealthP90);
|
||||||
expect(r.finalWealthMedian).toBeGreaterThan(r.finalWealthP10);
|
expect(r.finalWealthMedian).toBeGreaterThan(r.finalWealthP10);
|
||||||
});
|
});
|
||||||
@@ -79,8 +79,8 @@ describe("Monte Carlo", () => {
|
|||||||
expect(a.ruinProbability).toBe(b.ruinProbability);
|
expect(a.ruinProbability).toBe(b.ruinProbability);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("Boden 0 % (PK/3a): Rendite nie negativ -> Endvermoegen nie unter dem Startwert", async () => {
|
it("Boden 0 % (PK/3a): Rendite nie negativ -> Endvermögen nie unter dem Startwert", async () => {
|
||||||
// Ohne Beitraege kann ein bei 0 % gebodetes Asset nur wachsen oder gleich bleiben.
|
// Ohne Beiträge kann ein bei 0 % gebodetes Asset nur wachsen oder gleich bleiben.
|
||||||
const r = await runMonteCarlo(
|
const r = await runMonteCarlo(
|
||||||
basePlan(),
|
basePlan(),
|
||||||
params({ elements: { asset: { mean: 0, sigma: 80, floor: 0 } } })
|
params({ elements: { asset: { mean: 0, sigma: 80, floor: 0 } } })
|
||||||
@@ -88,8 +88,8 @@ describe("Monte Carlo", () => {
|
|||||||
expect(r.finalWealthP10).toBeGreaterThanOrEqual(100000);
|
expect(r.finalWealthP10).toBeGreaterThanOrEqual(100000);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("Ruinwahrscheinlichkeit: sicherer Verzehr fuehrt immer in den Ruin", async () => {
|
it("Ruinwahrscheinlichkeit: sicherer Verzehr führt immer in den Ruin", async () => {
|
||||||
// Rente 20k, Ausgaben 60k, kleines Vermoegen -> deterministisch Ruin, auch ohne Streuung.
|
// Rente 20k, Ausgaben 60k, kleines Vermögen -> deterministisch Ruin, auch ohne Streuung.
|
||||||
const plan: PlanInput = {
|
const plan: PlanInput = {
|
||||||
id: "p", name: "T", householdType: "SINGLE", inflationRateDefault: 0, initialCash: 0,
|
id: "p", name: "T", householdType: "SINGLE", inflationRateDefault: 0, initialCash: 0,
|
||||||
persons: [{ id: "A", role: "PERSON_A", name: null, age: 65, retirementAge: 65 }],
|
persons: [{ id: "A", role: "PERSON_A", name: null, age: 65, retirementAge: 65 }],
|
||||||
@@ -104,7 +104,7 @@ describe("Monte Carlo", () => {
|
|||||||
runs: 1000, inflationMean: 0, inflationSigma: 0,
|
runs: 1000, inflationMean: 0, inflationSigma: 0,
|
||||||
elements: { asset: { mean: 0, sigma: 10, floor: -100 } }, target: 0, seed: 1,
|
elements: { asset: { mean: 0, sigma: 10, floor: -100 } }, target: 0, seed: 1,
|
||||||
});
|
});
|
||||||
expect(r.ruinProbability).toBe(1); // Verzehr uebersteigt Rente + Vermoegen in jedem Pfad
|
expect(r.ruinProbability).toBe(1); // Verzehr übersteigt Rente + Vermögen in jedem Pfad
|
||||||
});
|
});
|
||||||
|
|
||||||
it("Hilfsfunktionen: Default-Stufen und σ-Tabelle", () => {
|
it("Hilfsfunktionen: Default-Stufen und σ-Tabelle", () => {
|
||||||
@@ -118,7 +118,7 @@ describe("Monte Carlo", () => {
|
|||||||
// --- Mehrere Szenarien im selben Lauf ----------------------------------------------------
|
// --- Mehrere Szenarien im selben Lauf ----------------------------------------------------
|
||||||
|
|
||||||
// Kopie des Basisplans mit neuen Ids; das Element verweist per sourceElementId auf sein
|
// Kopie des Basisplans mit neuen Ids; das Element verweist per sourceElementId auf sein
|
||||||
// Gegenstueck -- genau wie es die Kopier-Route beim Anlegen eines Szenarios setzt.
|
// Gegenstück -- genau wie es die Kopier-Route beim Anlegen eines Szenarios setzt.
|
||||||
function copyPlan(source: PlanInput, suffix: string, expectedReturn: number): PlanInput {
|
function copyPlan(source: PlanInput, suffix: string, expectedReturn: number): PlanInput {
|
||||||
return {
|
return {
|
||||||
...source,
|
...source,
|
||||||
@@ -133,7 +133,7 @@ function copyPlan(source: PlanInput, suffix: string, expectedReturn: number): Pl
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("Monte Carlo: mehrere Szenarien", () => {
|
describe("Monte Carlo: mehrere Szenarien", () => {
|
||||||
it("loest die Herkunfts-Kette bis zum Ursprung auf", () => {
|
it("löst die Herkunfts-Kette bis zum Ursprung auf", () => {
|
||||||
const chain = new Map<string, string | null>([
|
const chain = new Map<string, string | null>([
|
||||||
["a", null],
|
["a", null],
|
||||||
["b", "a"],
|
["b", "a"],
|
||||||
@@ -141,13 +141,13 @@ describe("Monte Carlo: mehrere Szenarien", () => {
|
|||||||
]);
|
]);
|
||||||
expect(resolveRootElementId("c", chain)).toBe("a");
|
expect(resolveRootElementId("c", chain)).toBe("a");
|
||||||
expect(resolveRootElementId("a", chain)).toBe("a");
|
expect(resolveRootElementId("a", chain)).toBe("a");
|
||||||
// Verweis ins Leere (Vorlage geloescht): das Element ist selbst die Wurzel.
|
// Verweis ins Leere (Vorlage gelöscht): das Element ist selbst die Wurzel.
|
||||||
expect(resolveRootElementId("x", new Map([["x", "weg"]]))).toBe("x");
|
expect(resolveRootElementId("x", new Map([["x", "weg"]]))).toBe("x");
|
||||||
// Defekte Kette darf nicht zur Endlosschleife fuehren.
|
// Defekte Kette darf nicht zur Endlosschleife führen.
|
||||||
expect(resolveRootElementId("p", new Map([["p", "q"], ["q", "p"]]))).toBeDefined();
|
expect(resolveRootElementId("p", new Map([["p", "q"], ["q", "p"]]))).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("fasst dasselbe Element ueber Szenarien zu EINER Gruppe zusammen", () => {
|
it("fasst dasselbe Element über Szenarien zu EINER Gruppe zusammen", () => {
|
||||||
const base = basePlan();
|
const base = basePlan();
|
||||||
const s2 = copyPlan(base, "s2", 6);
|
const s2 = copyPlan(base, "s2", 6);
|
||||||
const groups = buildElementGroups(
|
const groups = buildElementGroups(
|
||||||
@@ -160,13 +160,13 @@ describe("Monte Carlo: mehrere Szenarien", () => {
|
|||||||
expect(groups[0].scenarioIds.sort()).toEqual(["base", "s2"]);
|
expect(groups[0].scenarioIds.sort()).toEqual(["base", "s2"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("loest die Kette auch ueber ein NICHT ausgewaehltes Zwischen-Szenario auf", () => {
|
it("löst die Kette auch über ein NICHT ausgewähltes Zwischen-Szenario auf", () => {
|
||||||
const base = basePlan();
|
const base = basePlan();
|
||||||
const s1 = copyPlan(base, "s1", 6);
|
const s1 = copyPlan(base, "s1", 6);
|
||||||
const s2 = copyPlan(s1, "s2", 7); // zeigt auf s1, nicht auf base
|
const s2 = copyPlan(s1, "s2", 7); // zeigt auf s1, nicht auf base
|
||||||
const groups = buildElementGroups(
|
const groups = buildElementGroups(
|
||||||
[{ id: "base", plan: base }, { id: "s1", plan: s1 }, { id: "s2", plan: s2 }],
|
[{ id: "base", plan: base }, { id: "s1", plan: s1 }, { id: "s2", plan: s2 }],
|
||||||
["base", "s2"] // s1 ist nur zur Aufloesung geladen
|
["base", "s2"] // s1 ist nur zur Auflösung geladen
|
||||||
);
|
);
|
||||||
expect(groups).toHaveLength(1);
|
expect(groups).toHaveLength(1);
|
||||||
expect(groups[0].rootId).toBe("asset");
|
expect(groups[0].rootId).toBe("asset");
|
||||||
@@ -191,7 +191,7 @@ describe("Monte Carlo: mehrere Szenarien", () => {
|
|||||||
expect(neu.scenarioIds).toEqual(["s2"]); // im Basisszenario ohne Wirkung
|
expect(neu.scenarioIds).toEqual(["s2"]); // im Basisszenario ohne Wirkung
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uebersetzt die Gruppen-Parameter auf die Element-Ids des jeweiligen Szenarios", () => {
|
it("übersetzt die Gruppen-Parameter auf die Element-Ids des jeweiligen Szenarios", () => {
|
||||||
const base = basePlan();
|
const base = basePlan();
|
||||||
const s2 = copyPlan(base, "s2", 6);
|
const s2 = copyPlan(base, "s2", 6);
|
||||||
const groups = buildElementGroups([{ id: "base", plan: base }, { id: "s2", plan: s2 }], ["base", "s2"]);
|
const groups = buildElementGroups([{ id: "base", plan: base }, { id: "s2", plan: s2 }], ["base", "s2"]);
|
||||||
@@ -203,9 +203,9 @@ describe("Monte Carlo: mehrere Szenarien", () => {
|
|||||||
it("gleicher Seed: nur die GEPLANTE Rendite unterscheidet sich -> identische Verteilung, andere Erfolgsquote", async () => {
|
it("gleicher Seed: nur die GEPLANTE Rendite unterscheidet sich -> identische Verteilung, andere Erfolgsquote", async () => {
|
||||||
// Der Kern-Anwendungsfall: dasselbe Portfolio, einmal pessimistisch (5 %) und einmal
|
// Der Kern-Anwendungsfall: dasselbe Portfolio, einmal pessimistisch (5 %) und einmal
|
||||||
// optimistisch (6 %) geplant. In der Simulation wird die geplante Rendite ersetzt, also
|
// optimistisch (6 %) geplant. In der Simulation wird die geplante Rendite ersetzt, also
|
||||||
// sind beide Verlaeufe identisch -- der Unterschied liegt allein im Zielbetrag, der aus
|
// sind beide Verläufe identisch -- der Unterschied liegt allein im Zielbetrag, der aus
|
||||||
// der jeweiligen Planung stammt. Das pessimistische Szenario haelt sein (tieferes)
|
// der jeweiligen Planung stammt. Das pessimistische Szenario hält sein (tieferes)
|
||||||
// Versprechen oefter.
|
// Versprechen öfter.
|
||||||
const base = basePlan(); // expectedReturn 5
|
const base = basePlan(); // expectedReturn 5
|
||||||
const optimistisch = copyPlan(base, "opt", 6);
|
const optimistisch = copyPlan(base, "opt", 6);
|
||||||
|
|
||||||
|
|||||||
+32
-32
@@ -1,11 +1,11 @@
|
|||||||
// Monte-Carlo-Simulation (Roadmap Nr. 19, Stufe A). Laeuft vollstaendig im Browser, weil
|
// Monte-Carlo-Simulation (Roadmap Nr. 19, Stufe A). Läuft vollständig im Browser, weil
|
||||||
// computePlan eine reine Funktion ohne Server-Abhaengigkeiten ist.
|
// computePlan eine reine Funktion ohne Server-Abhängigkeiten ist.
|
||||||
//
|
//
|
||||||
// Modell: Pro Jahr wird EIN gemeinsamer Marktschock gezogen (damit riskante Anlagen zusammen
|
// Modell: Pro Jahr wird EIN gemeinsamer Marktschock gezogen (damit riskante Anlagen zusammen
|
||||||
// fallen, nicht gegeneinander). Jede Rendite = historischer Mittelwert + Standardabweichung x
|
// fallen, nicht gegeneinander). Jede Rendite = historischer Mittelwert + Standardabweichung x
|
||||||
// (Marktanteil x Marktschock + Eigenanteil x Eigenrauschen). Beide Schocks sind fettschwaenzig
|
// (Marktanteil x Marktschock + Eigenanteil x Eigenrauschen). Beide Schocks sind fettschwänzig
|
||||||
// (standardisierte Student-t), damit Extremcrashs realistisch haeufig auftreten -- eine
|
// (standardisierte Student-t), damit Extremcrashs realistisch häufig auftreten -- eine
|
||||||
// Normalverteilung wuerde sie stark unterschaetzen. Boeden: 0 % fuer PK/3a, -100 % sonst.
|
// Normalverteilung würde sie stark unterschätzen. Böden: 0 % für PK/3a, -100 % sonst.
|
||||||
|
|
||||||
import { computePlan, type PlanSample } from "@/lib/calculations";
|
import { computePlan, type PlanSample } from "@/lib/calculations";
|
||||||
import type { ElementCategory } from "@/lib/elements";
|
import type { ElementCategory } from "@/lib/elements";
|
||||||
@@ -23,7 +23,7 @@ export const RETURN_VOLATILITY_LEVELS = {
|
|||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
// Inflation ist in der Schweiz historisch stabil (Standardabweichung der letzten 20 Jahre
|
// Inflation ist in der Schweiz historisch stabil (Standardabweichung der letzten 20 Jahre
|
||||||
// ~1 %). Hoehere Stufen ergaeben Hyperinflations-Annahmen -- deshalb nur zwei Stufen.
|
// ~1 %). Höhere Stufen ergäben Hyperinflations-Annahmen -- deshalb nur zwei Stufen.
|
||||||
export const INFLATION_VOLATILITY_LEVELS = {
|
export const INFLATION_VOLATILITY_LEVELS = {
|
||||||
sehr_niedrig: 1,
|
sehr_niedrig: 1,
|
||||||
niedrig: 2,
|
niedrig: 2,
|
||||||
@@ -56,7 +56,7 @@ export function floorFor(category: ElementCategory): number {
|
|||||||
export interface ElementMcParams {
|
export interface ElementMcParams {
|
||||||
mean: number; // historische Durchschnittsrendite (%/Jahr)
|
mean: number; // historische Durchschnittsrendite (%/Jahr)
|
||||||
sigma: number; // Standardabweichung (%/Jahr)
|
sigma: number; // Standardabweichung (%/Jahr)
|
||||||
floor: number; // 0 fuer PK/3a, -100 sonst
|
floor: number; // 0 für PK/3a, -100 sonst
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MonteCarloParams {
|
export interface MonteCarloParams {
|
||||||
@@ -64,42 +64,42 @@ export interface MonteCarloParams {
|
|||||||
inflationMean: number;
|
inflationMean: number;
|
||||||
inflationSigma: number;
|
inflationSigma: number;
|
||||||
elements: Record<string, ElementMcParams>; // key = elementId
|
elements: Record<string, ElementMcParams>; // key = elementId
|
||||||
target: number; // Zielbetrag fuer die Erfolgswahrscheinlichkeit (nominal)
|
target: number; // Zielbetrag für die Erfolgswahrscheinlichkeit (nominal)
|
||||||
seed?: number;
|
seed?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MonteCarloResult {
|
export interface MonteCarloResult {
|
||||||
runs: number;
|
runs: number;
|
||||||
ruinProbability: number; // P(Vermoegen faellt vor Planende unter 0)
|
ruinProbability: number; // P(Vermögen fällt vor Planende unter 0)
|
||||||
successProbability: number; // P(Endvermoegen >= Zielbetrag)
|
successProbability: number; // P(Endvermögen >= Zielbetrag)
|
||||||
finalWealthP10: number;
|
finalWealthP10: number;
|
||||||
finalWealthMedian: number;
|
finalWealthMedian: number;
|
||||||
finalWealthP90: number;
|
finalWealthP90: number;
|
||||||
// Faecher ueber das Alter: je Alterspunkt der pessimistische/mittlere/optimistische Wert.
|
// Fächer über das Alter: je Alterspunkt der pessimistische/mittlere/optimistische Wert.
|
||||||
bands: { age: number; p10: number; p50: number; p90: number }[];
|
bands: { age: number; p10: number; p50: number; p90: number }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Mehrere Szenarien im selben Lauf vergleichen -------------------------------------
|
// --- Mehrere Szenarien im selben Lauf vergleichen -------------------------------------
|
||||||
//
|
//
|
||||||
// Ein "logisches" Element ueber Szenariogrenzen hinweg: Beim Kopieren eines Szenarios
|
// Ein "logisches" Element über Szenariogrenzen hinweg: Beim Kopieren eines Szenarios
|
||||||
// erhaelt jedes Element eine NEUE Id plus einen Verweis auf sein Gegenstueck in der Vorlage
|
// erhält jedes Element eine NEUE Id plus einen Verweis auf sein Gegenstück in der Vorlage
|
||||||
// (sourceElementId -- dieselbe Kette, auf der auch der Diff beruht). Ueber diese Kette wird
|
// (sourceElementId -- dieselbe Kette, auf der auch der Diff beruht). Über diese Kette wird
|
||||||
// dieselbe Anlage in mehreren Szenarien wiedergefunden. Das ist die Voraussetzung dafuer,
|
// dieselbe Anlage in mehreren Szenarien wiedergefunden. Das ist die Voraussetzung dafür,
|
||||||
// die historischen Annahmen nur EINMAL zu erfassen: Ein Vergleich ist nur dann aussagekraeftig,
|
// die historischen Annahmen nur EINMAL zu erfassen: Ein Vergleich ist nur dann aussagekräftig,
|
||||||
// wenn alle Szenarien mit denselben Marktannahmen gewuerfelt werden -- sonst vergleicht man
|
// wenn alle Szenarien mit denselben Marktannahmen gewürfelt werden -- sonst vergleicht man
|
||||||
// die Eingaben statt der Szenarien.
|
// die Eingaben statt der Szenarien.
|
||||||
export interface ElementGroup {
|
export interface ElementGroup {
|
||||||
rootId: string; // Id des Ursprungs-Elements (Anker der Parametereingabe)
|
rootId: string; // Id des Ursprungs-Elements (Anker der Parametereingabe)
|
||||||
name: string;
|
name: string;
|
||||||
category: ElementCategory;
|
category: ElementCategory;
|
||||||
memberIds: string[]; // Element-Ids ueber alle ausgewaehlten Szenarien
|
memberIds: string[]; // Element-Ids über alle ausgewählten Szenarien
|
||||||
scenarioIds: string[]; // Szenarien, in denen dieses Element vorkommt
|
scenarioIds: string[]; // Szenarien, in denen dieses Element vorkommt
|
||||||
}
|
}
|
||||||
|
|
||||||
// Folgt sourceElementId bis zum Ursprung. Bricht ab, sobald der Verweis ins Leere zeigt
|
// Folgt sourceElementId bis zum Ursprung. Bricht ab, sobald der Verweis ins Leere zeigt
|
||||||
// (Vorlage geloescht oder Szenario nicht geladen) -- dann ist dieses Element selbst die
|
// (Vorlage gelöscht oder Szenario nicht geladen) -- dann ist dieses Element selbst die
|
||||||
// Wurzel und bildet eine eigene Gruppe. Der Zyklusschutz ist reine Vorsicht: die Verweise
|
// Wurzel und bildet eine eigene Gruppe. Der Zyklusschutz ist reine Vorsicht: die Verweise
|
||||||
// sind lose (kein FK), eine defekte Kette darf nicht zur Endlosschleife fuehren.
|
// sind lose (kein FK), eine defekte Kette darf nicht zur Endlosschleife führen.
|
||||||
export function resolveRootElementId(elementId: string, sourceById: Map<string, string | null>): string {
|
export function resolveRootElementId(elementId: string, sourceById: Map<string, string | null>): string {
|
||||||
let current = elementId;
|
let current = elementId;
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
@@ -112,9 +112,9 @@ export function resolveRootElementId(elementId: string, sourceById: Map<string,
|
|||||||
return current;
|
return current;
|
||||||
}
|
}
|
||||||
|
|
||||||
// `all` enthaelt ALLE Szenarien des Plans (auch nicht ausgewaehlte) -- nur so laesst sich die
|
// `all` enthält ALLE Szenarien des Plans (auch nicht ausgewählte) -- nur so lässt sich die
|
||||||
// Kette ueber ein uebersprungenes Zwischen-Szenario hinweg aufloesen (Basis -> S1 -> S2, wenn
|
// Kette über ein übersprungenes Zwischen-Szenario hinweg auflösen (Basis -> S1 -> S2, wenn
|
||||||
// nur Basis und S2 ausgewaehlt sind). Gruppen entstehen nur fuer die ausgewaehlten Szenarien.
|
// nur Basis und S2 ausgewählt sind). Gruppen entstehen nur für die ausgewählten Szenarien.
|
||||||
export function buildElementGroups(
|
export function buildElementGroups(
|
||||||
all: { id: string; plan: PlanInput }[],
|
all: { id: string; plan: PlanInput }[],
|
||||||
selectedIds: string[]
|
selectedIds: string[]
|
||||||
@@ -154,7 +154,7 @@ export function buildElementGroups(
|
|||||||
return [...groups.values()];
|
return [...groups.values()];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Uebersetzt die je Gruppe erfassten Parameter auf die Element-Ids EINES Szenarios.
|
// Übersetzt die je Gruppe erfassten Parameter auf die Element-Ids EINES Szenarios.
|
||||||
export function paramsForScenario(
|
export function paramsForScenario(
|
||||||
plan: PlanInput,
|
plan: PlanInput,
|
||||||
groups: ElementGroup[],
|
groups: ElementGroup[],
|
||||||
@@ -186,9 +186,9 @@ export interface ScenarioMcResult extends MonteCarloResult {
|
|||||||
target: number;
|
target: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fuehrt dieselbe Simulation fuer mehrere Szenarien aus -- mit DEMSELBEN Seed. Ohne das
|
// Führt dieselbe Simulation für mehrere Szenarien aus -- mit DEMSELBEN Seed. Ohne das
|
||||||
// waeren kleine Unterschiede blosses Rauschen (bei 1'000 Laeufen betraegt der Standardfehler
|
// wären kleine Unterschiede blosses Rauschen (bei 1'000 Läufen beträgt der Standardfehler
|
||||||
// der Erfolgswahrscheinlichkeit rund 1.5 Prozentpunkte, zwei identische Szenarien koennten
|
// der Erfolgswahrscheinlichkeit rund 1.5 Prozentpunkte, zwei identische Szenarien könnten
|
||||||
// also 87 % und 90 % zeigen). Mit gemeinsamem Seed teilen strukturgleiche Szenarien dieselben
|
// also 87 % und 90 % zeigen). Mit gemeinsamem Seed teilen strukturgleiche Szenarien dieselben
|
||||||
// Marktpfade, und die Unterschiede sind rein strukturell (Common Random Numbers).
|
// Marktpfade, und die Unterschiede sind rein strukturell (Common Random Numbers).
|
||||||
export async function runMonteCarloMulti(
|
export async function runMonteCarloMulti(
|
||||||
@@ -242,7 +242,7 @@ function normal(rng: () => number): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Standardisierte Student-t mit nu Freiheitsgraden (Einheitsvarianz -> die eingegebene
|
// Standardisierte Student-t mit nu Freiheitsgraden (Einheitsvarianz -> die eingegebene
|
||||||
// Standardabweichung bleibt die tatsaechliche). Fette Raender: nu = 5.
|
// Standardabweichung bleibt die tatsächliche). Fette Ränder: nu = 5.
|
||||||
const NU = 5;
|
const NU = 5;
|
||||||
function studentT(rng: () => number): number {
|
function studentT(rng: () => number): number {
|
||||||
let chi2 = 0;
|
let chi2 = 0;
|
||||||
@@ -265,7 +265,7 @@ function percentile(sortedAsc: number[], p: number): number {
|
|||||||
return sortedAsc[idx];
|
return sortedAsc[idx];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ein Alterspunkt je Phasenanfang plus das Planende (wie im Vermoegensverlauf-Chart).
|
// Ein Alterspunkt je Phasenanfang plus das Planende (wie im Vermögensverlauf-Chart).
|
||||||
function agePointsOf(plan: PlanInput): number[] {
|
function agePointsOf(plan: PlanInput): number[] {
|
||||||
const phases = [...plan.phases].sort((a, b) => a.sequenceNumber - b.sequenceNumber);
|
const phases = [...plan.phases].sort((a, b) => a.sequenceNumber - b.sequenceNumber);
|
||||||
const startAge = plan.persons.find((p) => p.role === "PERSON_A")?.age ?? plan.persons[0]?.age ?? 0;
|
const startAge = plan.persons.find((p) => p.role === "PERSON_A")?.age ?? plan.persons[0]?.age ?? 0;
|
||||||
@@ -278,7 +278,7 @@ function agePointsOf(plan: PlanInput): number[] {
|
|||||||
return points;
|
return points;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wert je Alterspunkt fuer EINEN Durchlauf (Start-/Endvermoegen der Phasen, nominal).
|
// Wert je Alterspunkt für EINEN Durchlauf (Start-/Endvermögen der Phasen, nominal).
|
||||||
function wealthTrajectory(computed: ReturnType<typeof computePlan>): number[] {
|
function wealthTrajectory(computed: ReturnType<typeof computePlan>): number[] {
|
||||||
const phases = computed.phases;
|
const phases = computed.phases;
|
||||||
if (phases.length === 0) return [];
|
if (phases.length === 0) return [];
|
||||||
@@ -306,7 +306,7 @@ export async function runMonteCarlo(
|
|||||||
const elementIds = Object.keys(params.elements);
|
const elementIds = Object.keys(params.elements);
|
||||||
|
|
||||||
for (let run = 0; run < params.runs; run++) {
|
for (let run = 0; run < params.runs; run++) {
|
||||||
// Pro Jahr ein Marktschock; pro Element/Jahr eine Rendite (fette Raender, gebodet).
|
// Pro Jahr ein Marktschock; pro Element/Jahr eine Rendite (fette Ränder, gebodet).
|
||||||
const inflation: number[] = new Array(totalYears);
|
const inflation: number[] = new Array(totalYears);
|
||||||
for (let y = 0; y < totalYears; y++) {
|
for (let y = 0; y < totalYears; y++) {
|
||||||
inflation[y] = params.inflationMean + params.inflationSigma * studentT(rng);
|
inflation[y] = params.inflationMean + params.inflationSigma * studentT(rng);
|
||||||
@@ -338,7 +338,7 @@ export async function runMonteCarlo(
|
|||||||
finalWealth.push(fw);
|
finalWealth.push(fw);
|
||||||
if (fw >= params.target) successCount++;
|
if (fw >= params.target) successCount++;
|
||||||
|
|
||||||
// Alle ~500 Laeufe die Kontrolle abgeben, damit die Oberflaeche nicht einfriert.
|
// Alle ~500 Läufe die Kontrolle abgeben, damit die Oberfläche nicht einfriert.
|
||||||
if (run % 500 === 499) {
|
if (run % 500 === 499) {
|
||||||
onProgress?.(run + 1, params.runs);
|
onProgress?.(run + 1, params.runs);
|
||||||
await new Promise((r) => setTimeout(r, 0));
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
|
|||||||
+7
-7
@@ -73,10 +73,10 @@ export function toPlanInput(plan: PlanWithRelations): PlanInput {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Ownership-Abfragen. Der Eigentuemer haengt neu am PLAN; Szenario/Phase/Element
|
// --- Ownership-Abfragen. Der Eigentümer hängt neu am PLAN; Szenario/Phase/Element
|
||||||
// erben ihn ueber die Kette Scenario -> Plan -> User. ---
|
// erben ihn über die Kette Scenario -> Plan -> User. ---
|
||||||
|
|
||||||
// Laedt ein Szenario inkl. Profil + Phasen + Elemente, nur wenn es dem Benutzer gehoert.
|
// Lädt ein Szenario inkl. Profil + Phasen + Elemente, nur wenn es dem Benutzer gehört.
|
||||||
export async function getOwnedScenario(scenarioId: string, userId: string) {
|
export async function getOwnedScenario(scenarioId: string, userId: string) {
|
||||||
return prisma.scenario.findFirst({
|
return prisma.scenario.findFirst({
|
||||||
where: { id: scenarioId, plan: { userId } },
|
where: { id: scenarioId, plan: { userId } },
|
||||||
@@ -84,7 +84,7 @@ export async function getOwnedScenario(scenarioId: string, userId: string) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wie oben, zusaetzlich mit den Kopfdaten (Plan, Basis-Flag, Elternteil).
|
// Wie oben, zusätzlich mit den Kopfdaten (Plan, Basis-Flag, Elternteil).
|
||||||
export async function getOwnedScenarioWithMeta(scenarioId: string, userId: string) {
|
export async function getOwnedScenarioWithMeta(scenarioId: string, userId: string) {
|
||||||
return prisma.scenario.findFirst({
|
return prisma.scenario.findFirst({
|
||||||
where: { id: scenarioId, plan: { userId } },
|
where: { id: scenarioId, plan: { userId } },
|
||||||
@@ -92,7 +92,7 @@ export async function getOwnedScenarioWithMeta(scenarioId: string, userId: strin
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Laedt einen Plan (Behaelter) inkl. Szenario-Kopfdaten.
|
// Lädt einen Plan (Behälter) inkl. Szenario-Kopfdaten.
|
||||||
export async function getOwnedPlan(planId: string, userId: string) {
|
export async function getOwnedPlan(planId: string, userId: string) {
|
||||||
return prisma.plan.findFirst({
|
return prisma.plan.findFirst({
|
||||||
where: { id: planId, userId },
|
where: { id: planId, userId },
|
||||||
@@ -100,14 +100,14 @@ export async function getOwnedPlan(planId: string, userId: string) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Laedt eine Phase (Basisdaten), aber nur wenn sie dem Benutzer gehoert.
|
// Lädt eine Phase (Basisdaten), aber nur wenn sie dem Benutzer gehört.
|
||||||
export async function getOwnedPhase(phaseId: string, userId: string) {
|
export async function getOwnedPhase(phaseId: string, userId: string) {
|
||||||
return prisma.phase.findFirst({
|
return prisma.phase.findFirst({
|
||||||
where: { id: phaseId, scenario: { plan: { userId } } },
|
where: { id: phaseId, scenario: { plan: { userId } } },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Laedt ein Element (Basisdaten), aber nur wenn es dem Benutzer gehoert.
|
// Lädt ein Element (Basisdaten), aber nur wenn es dem Benutzer gehört.
|
||||||
export async function getOwnedElement(elementId: string, userId: string) {
|
export async function getOwnedElement(elementId: string, userId: string) {
|
||||||
return prisma.financialElement.findFirst({
|
return prisma.financialElement.findFirst({
|
||||||
where: { id: elementId, scenario: { plan: { userId } } },
|
where: { id: elementId, scenario: { plan: { userId } } },
|
||||||
|
|||||||
+19
-19
@@ -9,8 +9,8 @@ import {
|
|||||||
} from "@/lib/sensitivity";
|
} from "@/lib/sensitivity";
|
||||||
import type { PlanInput } from "@/lib/types";
|
import type { PlanInput } from "@/lib/types";
|
||||||
|
|
||||||
// Plan: 45-jaehrig, zwei Phasen (20 J. Erwerb + 20 J. Pension), Einkommen 100'000 netto,
|
// Plan: 45-jährig, zwei Phasen (20 J. Erwerb + 20 J. Pension), Einkommen 100'000 netto,
|
||||||
// Ausgaben 70'000 real, ein Sonstiges Vermoegen 200'000 @ 4 %, Inflation 1.5 %.
|
// Ausgaben 70'000 real, ein Sonstiges Vermögen 200'000 @ 4 %, Inflation 1.5 %.
|
||||||
function basePlan(): PlanInput {
|
function basePlan(): PlanInput {
|
||||||
return {
|
return {
|
||||||
id: "p",
|
id: "p",
|
||||||
@@ -58,7 +58,7 @@ function basePlan(): PlanInput {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Plan mit Immobilie -- wahlweise gehalten oder am Uebergang verkauft.
|
// Plan mit Immobilie -- wahlweise gehalten oder am Übergang verkauft.
|
||||||
function planMitImmobilie(verkaufen: boolean): PlanInput {
|
function planMitImmobilie(verkaufen: boolean): PlanInput {
|
||||||
const p = basePlan();
|
const p = basePlan();
|
||||||
return {
|
return {
|
||||||
@@ -81,8 +81,8 @@ function planMitImmobilie(verkaufen: boolean): PlanInput {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("Sensitivitaet: applyDriver", () => {
|
describe("Sensitivität: applyDriver", () => {
|
||||||
it("laesst den Ausgangsplan unberuehrt (rein)", () => {
|
it("lässt den Ausgangsplan unberührt (rein)", () => {
|
||||||
const p = basePlan();
|
const p = basePlan();
|
||||||
const snapshot = JSON.stringify(p);
|
const snapshot = JSON.stringify(p);
|
||||||
applyDriver(p, "inflation", 3);
|
applyDriver(p, "inflation", 3);
|
||||||
@@ -101,7 +101,7 @@ describe("Sensitivitaet: applyDriver", () => {
|
|||||||
const asset = p.elements.find((e) => e.id === "asset")!;
|
const asset = p.elements.find((e) => e.id === "asset")!;
|
||||||
expect(asset.phaseValues.p1.expectedReturn).toBe(6);
|
expect(asset.phaseValues.p1.expectedReturn).toBe(6);
|
||||||
expect(asset.phaseValues.p2.expectedReturn).toBe(6);
|
expect(asset.phaseValues.p2.expectedReturn).toBe(6);
|
||||||
// Einkommen/Ausgaben bleiben unberuehrt.
|
// Einkommen/Ausgaben bleiben unberührt.
|
||||||
expect(p.elements.find((e) => e.id === "inc")!.phaseValues.p1.amount).toBe(100000);
|
expect(p.elements.find((e) => e.id === "inc")!.phaseValues.p1.amount).toBe(100000);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -126,8 +126,8 @@ describe("Sensitivitaet: applyDriver", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Sensitivitaet: Treiber-Verfuegbarkeit", () => {
|
describe("Sensitivität: Treiber-Verfügbarkeit", () => {
|
||||||
it("blendet Treiber aus, fuer die es keine passenden Elemente gibt", () => {
|
it("blendet Treiber aus, für die es keine passenden Elemente gibt", () => {
|
||||||
const p = basePlan();
|
const p = basePlan();
|
||||||
expect(driverById("propertyGrowth").applies(p)).toBe(false); // keine Immobilie
|
expect(driverById("propertyGrowth").applies(p)).toBe(false); // keine Immobilie
|
||||||
expect(driverById("returns").applies(p)).toBe(true);
|
expect(driverById("returns").applies(p)).toBe(true);
|
||||||
@@ -143,8 +143,8 @@ describe("Sensitivitaet: Treiber-Verfuegbarkeit", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Sensitivitaet: Tornado", () => {
|
describe("Sensitivität: Tornado", () => {
|
||||||
it("Basiswert entspricht dem unveraenderten Plan", () => {
|
it("Basiswert entspricht dem unveränderten Plan", () => {
|
||||||
const p = basePlan();
|
const p = basePlan();
|
||||||
const result = computeTornado(p, "real", [{ id: "inflation", low: 0.5, high: 3.5 }]);
|
const result = computeTornado(p, "real", [{ id: "inflation", low: 0.5, high: 3.5 }]);
|
||||||
const last = computePlan(p).phases.at(-1)!;
|
const last = computePlan(p).phases.at(-1)!;
|
||||||
@@ -163,19 +163,19 @@ describe("Sensitivitaet: Tornado", () => {
|
|||||||
// Ein Treiber ohne Bandbreite kann nichts bewegen und landet zuunterst.
|
// Ein Treiber ohne Bandbreite kann nichts bewegen und landet zuunterst.
|
||||||
expect(result.bars.at(-1)!.id).toBe("salaryGrowth");
|
expect(result.bars.at(-1)!.id).toBe("salaryGrowth");
|
||||||
expect(result.bars.at(-1)!.swing).toBe(0);
|
expect(result.bars.at(-1)!.swing).toBe(0);
|
||||||
// Welcher Treiber oben steht, haengt vom konkreten Plan ab -- genau das ist die Aussage
|
// Welcher Treiber oben steht, hängt vom konkreten Plan ab -- genau das ist die Aussage
|
||||||
// des Tornados und deshalb bewusst nicht fix getestet.
|
// des Tornados und deshalb bewusst nicht fix getestet.
|
||||||
});
|
});
|
||||||
|
|
||||||
it("kehrt die Richtung korrekt ab: hoehere Ausgaben -> tieferes Endvermoegen", () => {
|
it("kehrt die Richtung korrekt ab: höhere Ausgaben -> tieferes Endvermögen", () => {
|
||||||
const [bar] = computeTornado(basePlan(), "real", [{ id: "expenses", low: -15, high: 15 }]).bars;
|
const [bar] = computeTornado(basePlan(), "real", [{ id: "expenses", low: -15, high: 15 }]).bars;
|
||||||
expect(bar.lowResult).toBeGreaterThan(bar.highResult); // tiefe Ausgaben = mehr Vermoegen
|
expect(bar.lowResult).toBeGreaterThan(bar.highResult); // tiefe Ausgaben = mehr Vermögen
|
||||||
expect(bar.min).toBe(bar.highResult);
|
expect(bar.min).toBe(bar.highResult);
|
||||||
expect(bar.max).toBe(bar.lowResult);
|
expect(bar.max).toBe(bar.lowResult);
|
||||||
expect(bar.swing).toBe(bar.max - bar.min);
|
expect(bar.swing).toBe(bar.max - bar.min);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("hoehere Rendite -> hoeheres Endvermoegen", () => {
|
it("höhere Rendite -> höheres Endvermögen", () => {
|
||||||
const [bar] = computeTornado(basePlan(), "real", [{ id: "returns", low: -2, high: 2 }]).bars;
|
const [bar] = computeTornado(basePlan(), "real", [{ id: "returns", low: -2, high: 2 }]).bars;
|
||||||
expect(bar.highResult).toBeGreaterThan(bar.lowResult);
|
expect(bar.highResult).toBeGreaterThan(bar.lowResult);
|
||||||
});
|
});
|
||||||
@@ -183,13 +183,13 @@ describe("Sensitivitaet: Tornado", () => {
|
|||||||
it("identische Bandbreite ergibt Spannweite 0", () => {
|
it("identische Bandbreite ergibt Spannweite 0", () => {
|
||||||
const [bar] = computeTornado(basePlan(), "real", [{ id: "inflation", low: 2, high: 2 }]).bars;
|
const [bar] = computeTornado(basePlan(), "real", [{ id: "inflation", low: 2, high: 2 }]).bars;
|
||||||
expect(bar.swing).toBe(0);
|
expect(bar.swing).toBe(0);
|
||||||
expect(bar.note).toBeTruthy(); // Nullbalken bekommt immer eine Erklaerung
|
expect(bar.note).toBeTruthy(); // Nullbalken bekommt immer eine Erklärung
|
||||||
});
|
});
|
||||||
|
|
||||||
it("verkaufte Immobilie: Wertsteigerung ist nachweislich wirkungslos und wird erklaert", () => {
|
it("verkaufte Immobilie: Wertsteigerung ist nachweislich wirkungslos und wird erklärt", () => {
|
||||||
// Beim Verkauf ist der Erloes `Verkaufspreis - Hypothek - Steuer` und haengt am ERFASSTEN
|
// Beim Verkauf ist der Erlös `Verkaufspreis - Hypothek - Steuer` und hängt am ERFASSTEN
|
||||||
// Preis, nicht am modellierten Verkehrswert. Die aufgelaufene Wertsteigerung wird damit
|
// Preis, nicht am modellierten Verkehrswert. Die aufgelaufene Wertsteigerung wird damit
|
||||||
// verworfen -- der Treiber kann das Endvermoegen nicht mehr bewegen.
|
// verworfen -- der Treiber kann das Endvermögen nicht mehr bewegen.
|
||||||
const gehalten = computeTornado(planMitImmobilie(false), "real", [{ id: "propertyGrowth", low: 0.5, high: 2.5 }]);
|
const gehalten = computeTornado(planMitImmobilie(false), "real", [{ id: "propertyGrowth", low: 0.5, high: 2.5 }]);
|
||||||
const verkauft = computeTornado(planMitImmobilie(true), "real", [{ id: "propertyGrowth", low: 0.5, high: 2.5 }]);
|
const verkauft = computeTornado(planMitImmobilie(true), "real", [{ id: "propertyGrowth", low: 0.5, high: 2.5 }]);
|
||||||
|
|
||||||
@@ -206,7 +206,7 @@ describe("Sensitivitaet: Tornado", () => {
|
|||||||
const real = planMetric(p, "real");
|
const real = planMetric(p, "real");
|
||||||
const nominal = planMetric(p, "nominal");
|
const nominal = planMetric(p, "nominal");
|
||||||
const last = computePlan(p).phases.at(-1)!;
|
const last = computePlan(p).phases.at(-1)!;
|
||||||
expect(nominal).toBeGreaterThan(real); // 1.5 % Inflation ueber 40 Jahre
|
expect(nominal).toBeGreaterThan(real); // 1.5 % Inflation über 40 Jahre
|
||||||
expect(real).toBe(Math.round(nominal / last.cumulativeInflationEnd));
|
expect(real).toBe(Math.round(nominal / last.cumulativeInflationEnd));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+31
-31
@@ -1,17 +1,17 @@
|
|||||||
// Sensitivitaetsanalyse / Tornado (Roadmap Nr. 20). Beantwortet nicht "wie viel Geld habe
|
// Sensitivitätsanalyse / Tornado (Roadmap Nr. 20). Beantwortet nicht "wie viel Geld habe
|
||||||
// ich am Schluss", sondern "welche meiner Annahmen entscheidet ueberhaupt ueber das Ergebnis".
|
// ich am Schluss", sondern "welche meiner Annahmen entscheidet überhaupt über das Ergebnis".
|
||||||
//
|
//
|
||||||
// Verfahren: One-at-a-time (OAT). Je Treiber wird EIN Parameter auf seinen tiefen und seinen
|
// Verfahren: One-at-a-time (OAT). Je Treiber wird EIN Parameter auf seinen tiefen und seinen
|
||||||
// hohen Wert gesetzt, alle uebrigen bleiben auf dem Planwert; die Differenz der beiden
|
// hohen Wert gesetzt, alle übrigen bleiben auf dem Planwert; die Differenz der beiden
|
||||||
// Ergebnisse ist die Spannweite. Nach Spannweite sortiert ergibt sich die Trichterform.
|
// Ergebnisse ist die Spannweite. Nach Spannweite sortiert ergibt sich die Trichterform.
|
||||||
//
|
//
|
||||||
// Zwei bewusste Grenzen (im Dialog ausgewiesen, siehe SPEZIFIKATION 9.18):
|
// Zwei bewusste Grenzen (im Dialog ausgewiesen, siehe SPEZIFIKATION 9.18):
|
||||||
// 1. Die Balkenlaenge haengt von den eingegebenen Bandbreiten ab -- deshalb gibt es hier
|
// 1. Die Balkenlänge hängt von den eingegebenen Bandbreiten ab -- deshalb gibt es hier
|
||||||
// KEINE Defaults, die Bandbreite ist je Treiber Pflichteingabe.
|
// KEINE Defaults, die Bandbreite ist je Treiber Pflichteingabe.
|
||||||
// 2. OAT sieht keine Wechselwirkungen (tiefe Rendite UND hohe Ausgaben treffen haerter als
|
// 2. OAT sieht keine Wechselwirkungen (tiefe Rendite UND hohe Ausgaben treffen härter als
|
||||||
// die Summe der Einzelbalken). Dafuer ist die Monte-Carlo-Simulation zustaendig.
|
// die Summe der Einzelbalken). Dafür ist die Monte-Carlo-Simulation zuständig.
|
||||||
//
|
//
|
||||||
// Laeuft wie die Monte-Carlo-Simulation vollstaendig im Browser: computePlan ist rein, und
|
// Läuft wie die Monte-Carlo-Simulation vollständig im Browser: computePlan ist rein, und
|
||||||
// ein Tornado braucht nur 2 Aufrufe je Treiber (Millisekunden).
|
// ein Tornado braucht nur 2 Aufrufe je Treiber (Millisekunden).
|
||||||
|
|
||||||
import { computePlan } from "@/lib/calculations";
|
import { computePlan } from "@/lib/calculations";
|
||||||
@@ -29,18 +29,18 @@ export type DriverId =
|
|||||||
| "propertyGrowth";
|
| "propertyGrowth";
|
||||||
|
|
||||||
// Die Einheit bestimmt, WAS der eingegebene Wert bedeutet -- das ist je Treiber verschieden
|
// Die Einheit bestimmt, WAS der eingegebene Wert bedeutet -- das ist je Treiber verschieden
|
||||||
// und laesst sich nicht vereinheitlichen, ohne fachlich falsch zu werden:
|
// und lässt sich nicht vereinheitlichen, ohne fachlich falsch zu werden:
|
||||||
// abs_pct absoluter Prozentsatz (es gibt genau einen plan-weiten Wert)
|
// abs_pct absoluter Prozentsatz (es gibt genau einen plan-weiten Wert)
|
||||||
// delta_pp Verschiebung in Prozentpunkten (die Elemente haben je eigene Saetze -- ein
|
// delta_pp Verschiebung in Prozentpunkten (die Elemente haben je eigene Sätze -- ein
|
||||||
// absoluter Wert wuerde die PK auf ETF-Rendite plaetten)
|
// absoluter Wert würde die PK auf ETF-Rendite plätten)
|
||||||
// rel_pct relative Abweichung in Prozent (die Elemente haben je eigene Betraege)
|
// rel_pct relative Abweichung in Prozent (die Elemente haben je eigene Beträge)
|
||||||
// delta_years Verschiebung in Jahren
|
// delta_years Verschiebung in Jahren
|
||||||
export type DriverUnit = "abs_pct" | "delta_pp" | "rel_pct" | "delta_years";
|
export type DriverUnit = "abs_pct" | "delta_pp" | "rel_pct" | "delta_years";
|
||||||
|
|
||||||
export interface DriverDef {
|
export interface DriverDef {
|
||||||
id: DriverId;
|
id: DriverId;
|
||||||
label: string;
|
label: string;
|
||||||
shortLabel: string; // Achsenbeschriftung im Tornado (kurz genug fuer die y-Achse)
|
shortLabel: string; // Achsenbeschriftung im Tornado (kurz genug für die y-Achse)
|
||||||
unit: DriverUnit;
|
unit: DriverUnit;
|
||||||
help: string;
|
help: string;
|
||||||
applies: (plan: PlanInput) => boolean;
|
applies: (plan: PlanInput) => boolean;
|
||||||
@@ -118,7 +118,7 @@ export const DRIVERS: DriverDef[] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
// Einheiten-Suffix fuer die Eingabefelder und die Ergebnistabelle.
|
// Einheiten-Suffix für die Eingabefelder und die Ergebnistabelle.
|
||||||
export const UNIT_SUFFIX: Record<DriverUnit, string> = {
|
export const UNIT_SUFFIX: Record<DriverUnit, string> = {
|
||||||
abs_pct: "%",
|
abs_pct: "%",
|
||||||
delta_pp: "pp",
|
delta_pp: "pp",
|
||||||
@@ -133,7 +133,7 @@ export function driverById(id: DriverId): DriverDef {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- Anwenden eines Treiber-Wertes auf einen Plan -------------------------------------
|
// --- Anwenden eines Treiber-Wertes auf einen Plan -------------------------------------
|
||||||
// Alle Transformationen sind rein: sie liefern eine Kopie und lassen das Original unberuehrt.
|
// Alle Transformationen sind rein: sie liefern eine Kopie und lassen das Original unberührt.
|
||||||
|
|
||||||
function mapPhaseData(element: ElementInput, f: (pd: PhaseData) => PhaseData): ElementInput {
|
function mapPhaseData(element: ElementInput, f: (pd: PhaseData) => PhaseData): ElementInput {
|
||||||
const phaseValues: Record<string, PhaseData> = {};
|
const phaseValues: Record<string, PhaseData> = {};
|
||||||
@@ -141,7 +141,7 @@ function mapPhaseData(element: ElementInput, f: (pd: PhaseData) => PhaseData): E
|
|||||||
return { ...element, phaseValues };
|
return { ...element, phaseValues };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bildet die Elemente der gegebenen Kategorien ab; alle uebrigen bleiben unveraendert.
|
// Bildet die Elemente der gegebenen Kategorien ab; alle übrigen bleiben unverändert.
|
||||||
function mapElements(
|
function mapElements(
|
||||||
plan: PlanInput,
|
plan: PlanInput,
|
||||||
categories: ElementCategory[],
|
categories: ElementCategory[],
|
||||||
@@ -159,7 +159,7 @@ export function applyDriver(plan: PlanInput, id: DriverId, value: number): PlanI
|
|||||||
return { ...plan, inflationRateDefault: value };
|
return { ...plan, inflationRateDefault: value };
|
||||||
|
|
||||||
case "returns":
|
case "returns":
|
||||||
// Verschiebung in Prozentpunkten auf die geplante Rendite. Nur dort, wo ueberhaupt ein
|
// Verschiebung in Prozentpunkten auf die geplante Rendite. Nur dort, wo überhaupt ein
|
||||||
// Werte-Datensatz existiert -- fehlt er, rechnet computePlan ohnehin mit 0 %.
|
// Werte-Datensatz existiert -- fehlt er, rechnet computePlan ohnehin mit 0 %.
|
||||||
return mapElements(plan, RETURN_CATEGORIES, (pd) => ({
|
return mapElements(plan, RETURN_CATEGORIES, (pd) => ({
|
||||||
...pd,
|
...pd,
|
||||||
@@ -177,7 +177,7 @@ export function applyDriver(plan: PlanInput, id: DriverId, value: number): PlanI
|
|||||||
// Relative Skalierung des Basisbetrags. Bewusst nur dort, wo `amount` gesetzt ist:
|
// Relative Skalierung des Basisbetrags. Bewusst nur dort, wo `amount` gesetzt ist:
|
||||||
// ab Phase 2 ist der Wert in der Regel live vererbt (kein gespeicherter Betrag), und
|
// ab Phase 2 ist der Wert in der Regel live vererbt (kein gespeicherter Betrag), und
|
||||||
// die Fortschreibung leitet ihn aus dem skalierten Basiswert ab -- dadurch wirkt die
|
// die Fortschreibung leitet ihn aus dem skalierten Basiswert ab -- dadurch wirkt die
|
||||||
// Skalierung automatisch ueber alle Folgephasen.
|
// Skalierung automatisch über alle Folgephasen.
|
||||||
const factor = 1 + value / 100;
|
const factor = 1 + value / 100;
|
||||||
return mapElements(plan, [id === "expenses" ? "EXPENSE" : "INCOME"], (pd) =>
|
return mapElements(plan, [id === "expenses" ? "EXPENSE" : "INCOME"], (pd) =>
|
||||||
typeof pd.amount === "number" ? { ...pd, amount: Math.max(0, pd.amount * factor) } : pd
|
typeof pd.amount === "number" ? { ...pd, amount: Math.max(0, pd.amount * factor) } : pd
|
||||||
@@ -191,7 +191,7 @@ export function applyDriver(plan: PlanInput, id: DriverId, value: number): PlanI
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
case "lifespan": {
|
case "lifespan": {
|
||||||
// Verlaengert/verkuerzt die LETZTE Phase. Bewusst nicht das Pensionsalter: das laesst
|
// Verlängert/verkürzt die LETZTE Phase. Bewusst nicht das Pensionsalter: das lässt
|
||||||
// sich ohne Mitverschieben der Phasengrenzen nicht sinnvoll variieren (siehe 9.18).
|
// sich ohne Mitverschieben der Phasengrenzen nicht sinnvoll variieren (siehe 9.18).
|
||||||
if (plan.phases.length === 0) return plan;
|
if (plan.phases.length === 0) return plan;
|
||||||
const lastSeq = Math.max(...plan.phases.map((p) => p.sequenceNumber));
|
const lastSeq = Math.max(...plan.phases.map((p) => p.sequenceNumber));
|
||||||
@@ -221,13 +221,13 @@ export interface TornadoBar {
|
|||||||
id: DriverId;
|
id: DriverId;
|
||||||
label: string;
|
label: string;
|
||||||
shortLabel: string;
|
shortLabel: string;
|
||||||
note?: string; // Erklaerung, wenn der Treiber das Ergebnis nachweislich nicht bewegt
|
note?: string; // Erklärung, wenn der Treiber das Ergebnis nachweislich nicht bewegt
|
||||||
unit: DriverUnit;
|
unit: DriverUnit;
|
||||||
low: number; // eingegebene Bandbreite
|
low: number; // eingegebene Bandbreite
|
||||||
high: number;
|
high: number;
|
||||||
lowResult: number; // Zielgroesse beim tiefen Wert
|
lowResult: number; // Zielgrösse beim tiefen Wert
|
||||||
highResult: number; // Zielgroesse beim hohen Wert
|
highResult: number; // Zielgrösse beim hohen Wert
|
||||||
min: number; // fuer den Balken: kleinerer der beiden Ergebniswerte
|
min: number; // für den Balken: kleinerer der beiden Ergebniswerte
|
||||||
max: number;
|
max: number;
|
||||||
swing: number; // max - min
|
swing: number; // max - min
|
||||||
}
|
}
|
||||||
@@ -237,13 +237,13 @@ export interface TornadoResult {
|
|||||||
bars: TornadoBar[];
|
bars: TornadoBar[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Warum bewegt ein Treiber gar nichts? Ein stummer Nullbalken ohne Erklaerung ist die
|
// Warum bewegt ein Treiber gar nichts? Ein stummer Nullbalken ohne Erklärung ist die
|
||||||
// schlechteste Antwort -- der Nutzer haelt ihn fuer einen Fehler.
|
// schlechteste Antwort -- der Nutzer hält ihn für einen Fehler.
|
||||||
//
|
//
|
||||||
// Der wichtigste Fall ist die Immobilien-Wertsteigerung bei einem Verkauf: Der Verkaufserloes
|
// Der wichtigste Fall ist die Immobilien-Wertsteigerung bei einem Verkauf: Der Verkaufserlös
|
||||||
// ist `Verkaufspreis - Hypothek - Grundstueckgewinnsteuer` und haengt damit am ERFASSTEN
|
// ist `Verkaufspreis - Hypothek - Grundstückgewinnsteuer` und hängt damit am ERFASSTEN
|
||||||
// Verkaufspreis, nicht am modellierten Verkehrswert. Die aufgelaufene Wertsteigerung wird beim
|
// Verkaufspreis, nicht am modellierten Verkehrswert. Die aufgelaufene Wertsteigerung wird beim
|
||||||
// Verkauf also verworfen -- der Treiber kann das Endvermoegen nicht mehr beeinflussen.
|
// Verkauf also verworfen -- der Treiber kann das Endvermögen nicht mehr beeinflussen.
|
||||||
export function ineffectiveReason(plan: PlanInput, id: DriverId): string {
|
export function ineffectiveReason(plan: PlanInput, id: DriverId): string {
|
||||||
if (id === "propertyGrowth") {
|
if (id === "propertyGrowth") {
|
||||||
const computed = computePlan(plan);
|
const computed = computePlan(plan);
|
||||||
@@ -256,7 +256,7 @@ export function ineffectiveReason(plan: PlanInput, id: DriverId): string {
|
|||||||
return "Dieser Parameter bewegt das Endvermögen in diesem Plan nicht.";
|
return "Dieser Parameter bewegt das Endvermögen in diesem Plan nicht.";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Zielgroesse: Endvermoegen der letzten Phase, real (kaufkraftbereinigt) oder nominal.
|
// Zielgrösse: Endvermögen der letzten Phase, real (kaufkraftbereinigt) oder nominal.
|
||||||
export function planMetric(plan: PlanInput, metric: TornadoMetric): number {
|
export function planMetric(plan: PlanInput, metric: TornadoMetric): number {
|
||||||
const computed = computePlan(plan);
|
const computed = computePlan(plan);
|
||||||
const last = computed.phases[computed.phases.length - 1];
|
const last = computed.phases[computed.phases.length - 1];
|
||||||
@@ -275,8 +275,8 @@ export function computeTornado(
|
|||||||
const def = driverById(input.id);
|
const def = driverById(input.id);
|
||||||
const lowResult = planMetric(applyDriver(plan, input.id, input.low), metric);
|
const lowResult = planMetric(applyDriver(plan, input.id, input.low), metric);
|
||||||
const highResult = planMetric(applyDriver(plan, input.id, input.high), metric);
|
const highResult = planMetric(applyDriver(plan, input.id, input.high), metric);
|
||||||
// Die Richtung kann sich umkehren (tiefe Ausgaben -> hohes Vermoegen). Der Balken spannt
|
// Die Richtung kann sich umkehren (tiefe Ausgaben -> hohes Vermögen). Der Balken spannt
|
||||||
// deshalb ueber min..max; welche Eingabe zu welchem Ende gehoert, zeigt die Tabelle.
|
// deshalb über min..max; welche Eingabe zu welchem Ende gehört, zeigt die Tabelle.
|
||||||
const swing = Math.abs(highResult - lowResult);
|
const swing = Math.abs(highResult - lowResult);
|
||||||
return {
|
return {
|
||||||
id: input.id,
|
id: input.id,
|
||||||
@@ -294,7 +294,7 @@ export function computeTornado(
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// Trichterform: groesste Spannweite zuoberst.
|
// Trichterform: grösste Spannweite zuoberst.
|
||||||
bars.sort((a, b) => b.swing - a.swing);
|
bars.sort((a, b) => b.swing - a.swing);
|
||||||
return { base, bars };
|
return { base, bars };
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -2,8 +2,8 @@ import { cookies } from "next/headers";
|
|||||||
import { SESSION_COOKIE_NAME, verifySessionToken } from "@/lib/auth";
|
import { SESSION_COOKIE_NAME, verifySessionToken } from "@/lib/auth";
|
||||||
|
|
||||||
// Liest die User-ID des eingeloggten Benutzers aus dem Session-Cookie.
|
// Liest die User-ID des eingeloggten Benutzers aus dem Session-Cookie.
|
||||||
// Fuer API-Routen (Node.js-Runtime); die Middleware schuetzt die Routen bereits,
|
// Für API-Routen (Node.js-Runtime); die Middleware schützt die Routen bereits,
|
||||||
// dies ist die zweite Verteidigungslinie und liefert die ID fuer Ownership-Checks.
|
// dies ist die zweite Verteidigungslinie und liefert die ID für Ownership-Checks.
|
||||||
export async function getCurrentUserId(): Promise<string | null> {
|
export async function getCurrentUserId(): Promise<string | null> {
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
const token = cookieStore.get(SESSION_COOKIE_NAME)?.value;
|
const token = cookieStore.get(SESSION_COOKIE_NAME)?.value;
|
||||||
|
|||||||
+2
-2
@@ -1,5 +1,5 @@
|
|||||||
// Theme-Verwaltung: drei waehlbare Schemata, persistiert in localStorage und als
|
// Theme-Verwaltung: drei wählbare Schemata, persistiert in localStorage und als
|
||||||
// data-theme am <html> gesetzt. Ohne gespeicherte Wahl folgt die Oberflaeche der
|
// data-theme am <html> gesetzt. Ohne gespeicherte Wahl folgt die Oberfläche der
|
||||||
// OS-Einstellung (siehe globals.css, prefers-color-scheme).
|
// OS-Einstellung (siehe globals.css, prefers-color-scheme).
|
||||||
|
|
||||||
export type Theme = "light" | "dark" | "warm";
|
export type Theme = "light" | "dark" | "warm";
|
||||||
|
|||||||
+11
-11
@@ -1,5 +1,5 @@
|
|||||||
// Domain-Typen fuer Berechnungslogik und API-Payloads. Entkoppelt von den generierten
|
// Domain-Typen für Berechnungslogik und API-Payloads. Entkoppelt von den generierten
|
||||||
// Prisma-Typen, damit die Berechnung unabhaengig testbar bleibt.
|
// Prisma-Typen, damit die Berechnung unabhängig testbar bleibt.
|
||||||
//
|
//
|
||||||
// V3-Rework: Das Grundprofil (Haushaltsform, Personen, Inflation) liegt neu direkt am Plan.
|
// V3-Rework: Das Grundprofil (Haushaltsform, Personen, Inflation) liegt neu direkt am Plan.
|
||||||
|
|
||||||
@@ -21,9 +21,9 @@ export interface PhaseInput {
|
|||||||
sequenceNumber: number;
|
sequenceNumber: number;
|
||||||
name: string;
|
name: string;
|
||||||
durationYears: number;
|
durationYears: number;
|
||||||
// Cash-Entscheid beim Uebergang NACH dieser Phase (einmalige Sonderein-/ausgaben).
|
// Cash-Entscheid beim Übergang NACH dieser Phase (einmalige Sonderein-/ausgaben).
|
||||||
cashTransition: CashTransitionData;
|
cashTransition: CashTransitionData;
|
||||||
// Gegenstueck im Eltern-Szenario (Diff-Grundlage); null im Basisszenario.
|
// Gegenstück im Eltern-Szenario (Diff-Grundlage); null im Basisszenario.
|
||||||
sourcePhaseId?: string | null;
|
sourcePhaseId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,14 +33,14 @@ export interface ElementInput {
|
|||||||
name: string;
|
name: string;
|
||||||
ownerRole: OwnerRole | null;
|
ownerRole: OwnerRole | null;
|
||||||
orderIndex: number;
|
orderIndex: number;
|
||||||
// Werte je Phase (Key = phaseId) bzw. je Uebergang (Key = fromPhaseId).
|
// Werte je Phase (Key = phaseId) bzw. je Übergang (Key = fromPhaseId).
|
||||||
phaseValues: Record<string, PhaseData>;
|
phaseValues: Record<string, PhaseData>;
|
||||||
transitionValues: Record<string, TransitionData>;
|
transitionValues: Record<string, TransitionData>;
|
||||||
// Gegenstueck im Eltern-Szenario (Diff-Grundlage); null im Basisszenario.
|
// Gegenstück im Eltern-Szenario (Diff-Grundlage); null im Basisszenario.
|
||||||
sourceElementId?: string | null;
|
sourceElementId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Kopf-Daten eines Szenarios (fuer Baum und Auswahl in der Seitenleiste).
|
// Kopf-Daten eines Szenarios (für Baum und Auswahl in der Seitenleiste).
|
||||||
export interface ScenarioMeta {
|
export interface ScenarioMeta {
|
||||||
id: string;
|
id: string;
|
||||||
planId: string;
|
planId: string;
|
||||||
@@ -49,7 +49,7 @@ export interface ScenarioMeta {
|
|||||||
parentScenarioId: string | null;
|
parentScenarioId: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ein Plan ist der Behaelter; er traegt nur den Namen und seine Szenarien.
|
// Ein Plan ist der Behälter; er trägt nur den Namen und seine Szenarien.
|
||||||
export interface PlanListItem {
|
export interface PlanListItem {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -57,7 +57,7 @@ export interface PlanListItem {
|
|||||||
scenarios: ScenarioMeta[];
|
scenarios: ScenarioMeta[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Die berechenbare Einheit (fachlich: ein SZENARIO). Sie ist selbsttragend und traegt ihr
|
// Die berechenbare Einheit (fachlich: ein SZENARIO). Sie ist selbsttragend und trägt ihr
|
||||||
// eigenes Grundprofil (Haushaltsform, Personen, Inflation, Cash) plus Phasen und Elemente.
|
// eigenes Grundprofil (Haushaltsform, Personen, Inflation, Cash) plus Phasen und Elemente.
|
||||||
// Der Name `PlanInput` ist historisch und bleibt, weil die ganze Berechnungsschicht darauf
|
// Der Name `PlanInput` ist historisch und bleibt, weil die ganze Berechnungsschicht darauf
|
||||||
// aufsetzt (computePlan, Monte Carlo, Tests).
|
// aufsetzt (computePlan, Monte Carlo, Tests).
|
||||||
@@ -67,8 +67,8 @@ export interface PlanInput {
|
|||||||
householdType: HouseholdType;
|
householdType: HouseholdType;
|
||||||
inflationRateDefault: number;
|
inflationRateDefault: number;
|
||||||
initialCash: number; // Anfangswert des Cash-Kontos in der ersten Lebensphase
|
initialCash: number; // Anfangswert des Cash-Kontos in der ersten Lebensphase
|
||||||
// Kalenderjahr des Planbeginns (Jahr 1) -- nur fuer die Darstellung, nicht fuer die
|
// Kalenderjahr des Planbeginns (Jahr 1) -- nur für die Darstellung, nicht für die
|
||||||
// Berechnung. Optional, damit Berechnungs-Fixtures es nicht setzen muessen.
|
// Berechnung. Optional, damit Berechnungs-Fixtures es nicht setzen müssen.
|
||||||
startYear?: number | null;
|
startYear?: number | null;
|
||||||
persons: PersonInput[];
|
persons: PersonInput[];
|
||||||
phases: PhaseInput[];
|
phases: PhaseInput[];
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@ import bcrypt from "bcryptjs";
|
|||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
|
|
||||||
// Nur von API-Routen (Node.js-Runtime) verwendet -- niemals von middleware.ts
|
// Nur von API-Routen (Node.js-Runtime) verwendet -- niemals von middleware.ts
|
||||||
// importieren, da dort (Edge-Runtime) kein Datenbankzugriff moeglich ist.
|
// importieren, da dort (Edge-Runtime) kein Datenbankzugriff möglich ist.
|
||||||
|
|
||||||
const USERNAME_PATTERN = /^[a-zA-Z0-9._-]{3,32}$/;
|
const USERNAME_PATTERN = /^[a-zA-Z0-9._-]{3,32}$/;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user