Modul-Review 2: Onboarding-Umbau, Tour, Assistenten-Redesign
Deploy App / deploy (push) Successful in 1m10s
Deploy App / deploy (push) Successful in 1m10s
Ergebnis der zweiten Test- und Review-Runde (Modul "Onboarding & Plan-Erstellung"): Layout: - Szenario-Ansicht neu geordnet: schlanke Funktions-Leiste oben (Historie, Tour, Neues Szenario, Rechenwege, CSV-Export, Loeschen, Diff-Badge), dann Naechste Schritte -> Grundprofil -> Zeitachse -> Anzeige -> Matrix - Grafiken/Effektive Werte/Live-Sim/MC/Einflussfaktoren aus der Leiste entfernt (laufen ueber die eigenen Menuepunkte) - Tour nach AppShell gehoben; startet neu bei JEDER Plan-Erstellung (F14) Assistent: - durchgehend Du-Form im Einzelmodus - Schritt "Vorsorge & Vermoegen" nur noch Bestandswerte - neuer Schritt "Sparen & Verteilen": Sparquote (Netto - Ausgaben) auf 3a/Wertschriften/Amortisation/Tilgung verteilen, Rest bleibt Cash; PK separat mit Hinweis "vor Netto, reduziert Sparquote nicht" - Immobilie: Wertsteigerung + Zins-in-Ausgaben-Schalter; Lohn 1% Default Grosse Saeule 3a (Selbststaendige ohne PK): - Schalter am 3a-Element + im Assistenten hebt Obergrenze 7258 -> 36288 - neue Konstante PILLAR_3A_MAX_SELF_EMPLOYED (2026-Wert zu verifizieren) - neues Feld selfEmployed3a Nebenbei: POST /plans liefert lesbare Fehlermeldung statt zod-Objekt. Kein Eingriff in den Rechenkern; 267 Tests unveraendert. SPEZIFIKATION 0.28. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+48
-10
@@ -4,10 +4,10 @@
|
|||||||
| | |
|
| | |
|
||||||
|---|---|
|
|---|---|
|
||||||
| **Dokument** | Funktionale und Technische Spezifikation FPT |
|
| **Dokument** | Funktionale und Technische Spezifikation FPT |
|
||||||
| **Version** | 0.27 |
|
| **Version** | 0.28 |
|
||||||
| **Datum** | 2026-07-24 |
|
| **Datum** | 2026-07-24 |
|
||||||
| **Status** | Lebendes Dokument |
|
| **Status** | Lebendes Dokument |
|
||||||
| **Codestand** | Arbeitsstand nach `d6855ef` inkl. Sicherheits-Nachbesserungen Auth (Branch `main`) |
|
| **Codestand** | Arbeitsstand nach `2bb243b` inkl. Modul-Review 2 (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.28 | 2026-07-24 | Claude (Opus 4.8) | **Modul-Review 2 (Onboarding & Ansicht): Layout-Umbau, Tour und Assistenten-Redesign.** (1) **Szenario-Ansicht neu geordnet** (Kap. 3.7.7): oben eine schlanke Funktions-Leiste (Änderungshistorie · Tour · Neues Szenario · Rechenwege · CSV-Export · Löschen · Abweichungs-Badge), darunter Nächste Schritte → Grundprofil → Zeitachse → Anzeige-Umschalter → Matrix. Grafiken, Effektive Werte, Live-Simulation, Monte-Carlo und Einflussfaktoren sind aus der Leiste **entfernt** – sie laufen über die eigenen Menüpunkte (Analysen / Effektive Werte). (2) **Tour** liegt neu in `AppShell` (statt `PlanView`) und startet nach **jeder** Plan-Erstellung – Assistent, Beispielplan **und** leerer Plan – unabhängig davon, ob sie schon einmal beendet wurde (Kap. 3.7.8); die Erfolgsmeldung hält damit ihr Versprechen. (3) **Assistent durchgehend in Du-Form** im Einzelmodus (Paarmodus weiter «ihr» / je Person). (4) **Schritt-Redesign** (Kap. 3.2.8): Schritt «Vorsorge & Vermögen» erfasst nur noch **Bestandswerte**; ein **neuer Schritt «Sparen & Verteilen»** zeigt die Sparquote (Nettoeinkommen − Ausgaben) und lässt sie auf 3a, Wertschriften, Amortisation und Schuldtilgung verteilen – der Rest bleibt sichtbar auf dem Cash-Konto. Die **PK-Einzahlung** steht dort bewusst separat, mit dem Hinweis, dass sie vom Bruttolohn kommt und die Sparquote **nicht** schmälert (deckt sich mit dem Rechenkern). Immobilien fragen im Assistenten neu **Wertsteigerung** und den **Zins-in-Ausgaben-Schalter** ab; der Lohn bekommt 1 % Default-Erhöhung. (5) **«Grosse Säule 3a» für Selbstständige** (Roadmap-Feedback): ein Schalter am 3a-Element (und im Assistenten) hebt die Beitrags-Obergrenze von 7’258 auf ca. 36’288 CHF an (neue Konstante `PILLAR_3A_MAX_SELF_EMPLOYED`, **2026-Wert zu verifizieren**; neues Feld `selfEmployed3a`). (6) Nebenbei: `POST /plans` liefert bei Validierungsfehlern eine lesbare Meldung statt eines rohen zod-Objekts. Keine Änderung am Rechenkern; 267 Tests unverändert grün. |
|
||||||
| 0.27 | 2026-07-24 | Claude (Opus 4.8) | **Modul-Review 1 (Zugang & App-Rahmen): fünf Nachbesserungen an der Authentifizierung.** Ergebnis der ersten gemeinsamen Test- und Review-Runde. (1) **Timing-Ausgleich beim Login:** Ein unbekannter Benutzer wird neu gegen einen Dummy-bcrypt-Hash geprüft, damit die Antwortzeit dieselbe ist wie bei einem bekannten -- vorher liess sich aus der Dauer ablesen, ob ein Benutzername existiert (Kap. 3.1.2). (2) **Zurück-Knopf nach Logout:** Die aus dem Browser-Cache (bfcache) zurückgeholte, eingefrorene Ansicht prüft neu beim `pageshow` die Session und leitet ohne Anmeldung sofort auf `/login` -- vorher blieb die alte Ansicht sichtbar (Kap. 3.1.3). (3) **Rate-Limiting:** Login (10/15 min je IP), Registrierung (5/h je IP) und Passwortänderung (10/15 min je Benutzer) sind gegen Durchprobieren gebremst; neues In-Memory-Modul `rate-limit.ts`, 429 mit `Retry-After` (Kap. 3.1.6). Der Login-Zähler läuft je IP **und Benutzername** -- ein Konto sperrt nicht die anderen Konten derselben IP. (4) **Passwort-Dialog** läuft neu über die zentrale `Modal`-Komponente und schliesst damit auf Esc (Fokus-Falle, aria-modal inklusive) -- war zuvor von Hand gebaut (Kap. 3.1.4). (5) **Registrierungs-Fehler** werden sauber getrennt: nur der belegte Benutzername ist ein 409 mit freundlicher Meldung, jeder andere Fehler ein 500 statt einer rohen Prisma-Meldung als Konflikt. 6 Tests ergänzt (261 → 267). Offen für Roadmap #34: kein Passwort-Längen-Maximum (bcrypt-72-Byte-Grenze), keine ARIA-Labels auf Login/Palette. |
|
| 0.27 | 2026-07-24 | Claude (Opus 4.8) | **Modul-Review 1 (Zugang & App-Rahmen): fünf Nachbesserungen an der Authentifizierung.** Ergebnis der ersten gemeinsamen Test- und Review-Runde. (1) **Timing-Ausgleich beim Login:** Ein unbekannter Benutzer wird neu gegen einen Dummy-bcrypt-Hash geprüft, damit die Antwortzeit dieselbe ist wie bei einem bekannten -- vorher liess sich aus der Dauer ablesen, ob ein Benutzername existiert (Kap. 3.1.2). (2) **Zurück-Knopf nach Logout:** Die aus dem Browser-Cache (bfcache) zurückgeholte, eingefrorene Ansicht prüft neu beim `pageshow` die Session und leitet ohne Anmeldung sofort auf `/login` -- vorher blieb die alte Ansicht sichtbar (Kap. 3.1.3). (3) **Rate-Limiting:** Login (10/15 min je IP), Registrierung (5/h je IP) und Passwortänderung (10/15 min je Benutzer) sind gegen Durchprobieren gebremst; neues In-Memory-Modul `rate-limit.ts`, 429 mit `Retry-After` (Kap. 3.1.6). Der Login-Zähler läuft je IP **und Benutzername** -- ein Konto sperrt nicht die anderen Konten derselben IP. (4) **Passwort-Dialog** läuft neu über die zentrale `Modal`-Komponente und schliesst damit auf Esc (Fokus-Falle, aria-modal inklusive) -- war zuvor von Hand gebaut (Kap. 3.1.4). (5) **Registrierungs-Fehler** werden sauber getrennt: nur der belegte Benutzername ist ein 409 mit freundlicher Meldung, jeder andere Fehler ein 500 statt einer rohen Prisma-Meldung als Konflikt. 6 Tests ergänzt (261 → 267). Offen für Roadmap #34: kein Passwort-Längen-Maximum (bcrypt-72-Byte-Grenze), keine ARIA-Labels auf Login/Palette. |
|
||||||
| 0.26 | 2026-07-21 | Claude (Opus 4.8) | **Pensionsalter anpassen** (Roadmap Nr. 44, neue Kapitel 3.12, 4.4.7 und 4.16). Bisher war das Pensionsalter faktisch unantastbar: Es bestimmt, wo eine Lebensphase endet – ein frei geändertes Alter hätte die Phasengrenze zerrissen. Neu wird nicht das Alter geändert, sondern **die Grenze verschoben**: Die Phase davor wird länger, die danach kürzer, die Gesamtdauer bleibt gleich. Der Spielraum endet dort, wo eine angrenzende Phase unter ein Jahr fiele; ein Schritt weiter **entfällt sie ganz**, was vorher bestätigt wird, weil dabei zwei Übergänge **zusammengelegt** werden (bereits getroffene Entscheide bleiben, leere Felder werden aus dem entfallenden Übergang ergänzt, einmalige Cash-Beträge werden addiert – der Steuersatz betragsgewichtet). Das Pensionsalter ist damit auch **Treiber im Tornado** und **Regler in der Live-Simulation**. **AHV-Referenzalter (4.4.7):** Die Rente beginnt neu **immer mit 65**, unabhängig vom Pensionsalter – wer länger arbeitet, erhält sie zusätzlich zum Lohn; wer früher aufhört, zahlt bis 65 einen **Beitrag als Nichterwerbstätige(r)**, der als laufende Ausgabe auf die Verzehrquote schlägt und mit 65 wegfällt (neues Feld `ahvContribution`, ohne Default, Hilfetext nennt die reale Bandbreite von rund 530 bis 26'500 CHF pro Jahr). Beide Wechsel können **innerhalb** einer Phase liegen, die AHV wird deshalb **jahresweise** statt phasenweise gerechnet. **Punkt B:** Ein Einkommen, das einer **Person** zugeordnet ist, fällt bei deren Pensionierung auf 0 – bisher lief der Lohn stillschweigend in die Pension weiter. Gemeinsame Einkommen (Mieterträge o. Ä.) bleiben; ein ausdrücklich erfasster Betrag gewinnt, damit ein Teilzeitpensum modellierbar bleibt. **Punkt A:** Wiederkehr-Parameter (Raten, Beiträge, Amortisation, Wertsteigerung, Zinssatz) werden neu **live aus der Vorphase geerbt** statt beim Anlegen der Phase kopiert – sichtbar als angehaktes **«Aus Vorphase übernehmen»** je Feld. Vorher blieb die Kopie stehen, wenn man die Vorphase später änderte. **Punkt C:** Der Kapitalzufluss am Pensions-Übergang (PK, 3a, Verkaufserlös) lässt sich in **Prozent** auf Amortisation, Anlage und Cash aufteilen – bewusst nicht in Franken, weil sich der Betrag mit dem Pensionsalter ändert und eine Quote mitskaliert. **Nebenbei ein echter Fehler behoben:** Der fortgeschriebene Basiswert für Einkommen und Ausgaben wurde **vor** der Jahresschleife berechnet – effektive Werte kamen dadurch nie in der Folgephase an. Neues Modul `retirement.ts`, neuer Endpunkt `POST /api/scenarios/<id>/retirement`; 40 Tests ergänzt (221 → 261). |
|
| 0.26 | 2026-07-21 | Claude (Opus 4.8) | **Pensionsalter anpassen** (Roadmap Nr. 44, neue Kapitel 3.12, 4.4.7 und 4.16). Bisher war das Pensionsalter faktisch unantastbar: Es bestimmt, wo eine Lebensphase endet – ein frei geändertes Alter hätte die Phasengrenze zerrissen. Neu wird nicht das Alter geändert, sondern **die Grenze verschoben**: Die Phase davor wird länger, die danach kürzer, die Gesamtdauer bleibt gleich. Der Spielraum endet dort, wo eine angrenzende Phase unter ein Jahr fiele; ein Schritt weiter **entfällt sie ganz**, was vorher bestätigt wird, weil dabei zwei Übergänge **zusammengelegt** werden (bereits getroffene Entscheide bleiben, leere Felder werden aus dem entfallenden Übergang ergänzt, einmalige Cash-Beträge werden addiert – der Steuersatz betragsgewichtet). Das Pensionsalter ist damit auch **Treiber im Tornado** und **Regler in der Live-Simulation**. **AHV-Referenzalter (4.4.7):** Die Rente beginnt neu **immer mit 65**, unabhängig vom Pensionsalter – wer länger arbeitet, erhält sie zusätzlich zum Lohn; wer früher aufhört, zahlt bis 65 einen **Beitrag als Nichterwerbstätige(r)**, der als laufende Ausgabe auf die Verzehrquote schlägt und mit 65 wegfällt (neues Feld `ahvContribution`, ohne Default, Hilfetext nennt die reale Bandbreite von rund 530 bis 26'500 CHF pro Jahr). Beide Wechsel können **innerhalb** einer Phase liegen, die AHV wird deshalb **jahresweise** statt phasenweise gerechnet. **Punkt B:** Ein Einkommen, das einer **Person** zugeordnet ist, fällt bei deren Pensionierung auf 0 – bisher lief der Lohn stillschweigend in die Pension weiter. Gemeinsame Einkommen (Mieterträge o. Ä.) bleiben; ein ausdrücklich erfasster Betrag gewinnt, damit ein Teilzeitpensum modellierbar bleibt. **Punkt A:** Wiederkehr-Parameter (Raten, Beiträge, Amortisation, Wertsteigerung, Zinssatz) werden neu **live aus der Vorphase geerbt** statt beim Anlegen der Phase kopiert – sichtbar als angehaktes **«Aus Vorphase übernehmen»** je Feld. Vorher blieb die Kopie stehen, wenn man die Vorphase später änderte. **Punkt C:** Der Kapitalzufluss am Pensions-Übergang (PK, 3a, Verkaufserlös) lässt sich in **Prozent** auf Amortisation, Anlage und Cash aufteilen – bewusst nicht in Franken, weil sich der Betrag mit dem Pensionsalter ändert und eine Quote mitskaliert. **Nebenbei ein echter Fehler behoben:** Der fortgeschriebene Basiswert für Einkommen und Ausgaben wurde **vor** der Jahresschleife berechnet – effektive Werte kamen dadurch nie in der Folgephase an. Neues Modul `retirement.ts`, neuer Endpunkt `POST /api/scenarios/<id>/retirement`; 40 Tests ergänzt (221 → 261). |
|
||||||
| 0.25 | 2026-07-21 | Claude (Opus 4.8) | **PDF-Berichte** (Roadmap Nr. 11, neues Kapitel 3.11). Neuer Unterpunkt **«Berichte»** auf Plan-Ebene: Liste der erzeugten Berichte plus Assistent zum Anlegen (Titel, Notiz, nominal **oder** real, Plan- oder effektive Daten, bis zu **drei** Szenarien, beliebige gespeicherte Analysen). Das **Layout ist immer gleich**; die Auswahl bestimmt nur, welche Bausteine erscheinen: Deckblatt mit Zusammenfassung und drei Kernaussagen, dann je Szenario Kennzahlen, Vermögensverlauf, Lebensphasen und Annahmen, danach Vergleich, Plan/Ist, Analysen und die Hinweise. **Die PDF-Datei wird als Datei abgelegt** (BYTEA in Postgres, nicht im Container-Dateisystem, das jeder Deploy neu baut): Ein Bericht muss in drei Jahren byte-identisch wieder herunterladbar sein – eine Neuerzeugung könnte das nach Änderungen an Plan, Rechenkern oder Layout nicht garantieren. **Kennzahlen je Szenario:** Endvermögen, Kapitalreichweite, Vermögen und Vorsorgekapital bei Pensionierung, AHV- und PK-Rente sowie die **offenen Entscheide** – die einzige unmittelbar handlungsleitende Zahl. Damit Bericht und Matrix nie verschiedene Zahlen nennen, liegt deren Zählung neu als reine Funktion in `decisions.ts`, die beide benutzen. **Zu jeder Kennzahl steht ihre Grundlage** als kurzer Verweis; die vollständigen Annahmen (Startwerte, Renditen, Raten je Element) stehen **einmal** je Szenario, statt bei jeder Kennzahl wiederholt zu werden. Ein **Haftungsausschluss** ist verpflichtend und durch einen Test gesichert – ein formal gesetztes PDF wird sonst als Beratung gelesen. **Technik:** `pdfkit` in der Node-Runtime statt Headless-Browser (kein Chromium im Image); `@react-pdf/renderer` schied aus, weil es mit React 19 / Next 16 bricht. Diagramme entstehen als **echte Vektoren** aus den gespeicherten Zahlen – genau dafür wurden die Analysen in 0.24 als Zahlen und nicht als Bilder abgelegt. `pdfkit` ist als externes Paket deklariert, weil es Font-Metriken über Dateipfade lädt und gebündelt erst in der Produktion bräche. Neue Tabelle `Report`, Endpunkte unter `/api/plans/<id>/reports`, neue Module `report.ts`, `report-pdf.ts`, `decisions.ts`; 9 Tests ergänzt (212 → 221). |
|
| 0.25 | 2026-07-21 | Claude (Opus 4.8) | **PDF-Berichte** (Roadmap Nr. 11, neues Kapitel 3.11). Neuer Unterpunkt **«Berichte»** auf Plan-Ebene: Liste der erzeugten Berichte plus Assistent zum Anlegen (Titel, Notiz, nominal **oder** real, Plan- oder effektive Daten, bis zu **drei** Szenarien, beliebige gespeicherte Analysen). Das **Layout ist immer gleich**; die Auswahl bestimmt nur, welche Bausteine erscheinen: Deckblatt mit Zusammenfassung und drei Kernaussagen, dann je Szenario Kennzahlen, Vermögensverlauf, Lebensphasen und Annahmen, danach Vergleich, Plan/Ist, Analysen und die Hinweise. **Die PDF-Datei wird als Datei abgelegt** (BYTEA in Postgres, nicht im Container-Dateisystem, das jeder Deploy neu baut): Ein Bericht muss in drei Jahren byte-identisch wieder herunterladbar sein – eine Neuerzeugung könnte das nach Änderungen an Plan, Rechenkern oder Layout nicht garantieren. **Kennzahlen je Szenario:** Endvermögen, Kapitalreichweite, Vermögen und Vorsorgekapital bei Pensionierung, AHV- und PK-Rente sowie die **offenen Entscheide** – die einzige unmittelbar handlungsleitende Zahl. Damit Bericht und Matrix nie verschiedene Zahlen nennen, liegt deren Zählung neu als reine Funktion in `decisions.ts`, die beide benutzen. **Zu jeder Kennzahl steht ihre Grundlage** als kurzer Verweis; die vollständigen Annahmen (Startwerte, Renditen, Raten je Element) stehen **einmal** je Szenario, statt bei jeder Kennzahl wiederholt zu werden. Ein **Haftungsausschluss** ist verpflichtend und durch einen Test gesichert – ein formal gesetztes PDF wird sonst als Beratung gelesen. **Technik:** `pdfkit` in der Node-Runtime statt Headless-Browser (kein Chromium im Image); `@react-pdf/renderer` schied aus, weil es mit React 19 / Next 16 bricht. Diagramme entstehen als **echte Vektoren** aus den gespeicherten Zahlen – genau dafür wurden die Analysen in 0.24 als Zahlen und nicht als Bilder abgelegt. `pdfkit` ist als externes Paket deklariert, weil es Font-Metriken über Dateipfade lädt und gebündelt erst in der Produktion bräche. Neue Tabelle `Report`, Endpunkte unter `/api/plans/<id>/reports`, neue Module `report.ts`, `report-pdf.ts`, `decisions.ts`; 9 Tests ergänzt (212 → 221). |
|
||||||
@@ -411,8 +412,10 @@ gesetzt. Kalenderjahr eines Planjahrs: `startYear + (Jahr − 1)`.
|
|||||||
### 3.2.8 Geführter Assistent und Beispielplan
|
### 3.2.8 Geführter Assistent und Beispielplan
|
||||||
|
|
||||||
**Der Plan-Assistent** (Roadmap Nr. 10: «Schritt für Schritt statt leerer Matrix») fragt in
|
**Der Plan-Assistent** (Roadmap Nr. 10: «Schritt für Schritt statt leerer Matrix») fragt in
|
||||||
fünf Schritten in Alltagssprache: (1) Grundprofil, (2) Lebensphasen, (3) Einkommen und Ausgaben
|
**sechs** Schritten in Alltagssprache: (1) Grundprofil, (2) Lebensphasen, (3) Einkommen und
|
||||||
plus Kontostand, (4) Vorsorge und Vermögen, (5) Zusammenfassung.
|
Ausgaben plus Kontostand, (4) Vorsorge und Vermögen (**nur Bestandswerte**), (5) Sparen und
|
||||||
|
Verteilen, (6) Zusammenfassung. Im **Einzelmodus** durchgehend in **Du-Form** («Was verdienst
|
||||||
|
du?»); im Paarmodus je Person bzw. «ihr».
|
||||||
|
|
||||||
**Schritt 2 ist an den fixen Pensionierungszeitpunkten ausgerichtet.** Das Pensionsalter jeder
|
**Schritt 2 ist an den fixen Pensionierungszeitpunkten ausgerichtet.** Das Pensionsalter jeder
|
||||||
Person ist ein Fixpunkt auf der Lebenslinie; dazwischen entstehen Abschnitte mit konstantem
|
Person ist ein Fixpunkt auf der Lebenslinie; dazwischen entstehen Abschnitte mit konstantem
|
||||||
@@ -444,7 +447,22 @@ Phasen lesbar bleiben.
|
|||||||
|
|
||||||
**Schritt 4 (Vorsorge & Vermögen)** ist bei Paaren in **Gemeinsam / Person A / Person B**
|
**Schritt 4 (Vorsorge & Vermögen)** ist bei Paaren in **Gemeinsam / Person A / Person B**
|
||||||
aufgeteilt. Pensionskasse und Säule 3a sind immer persönlich (personengebundene Kategorien);
|
aufgeteilt. Pensionskasse und Säule 3a sind immer persönlich (personengebundene Kategorien);
|
||||||
Wertschriften, Wohneigentum und Schulden lassen sich gemeinsam **oder** je Person erfassen.
|
Wertschriften, Wohneigentum und Schulden lassen sich gemeinsam **oder** je Person erfassen. Hier
|
||||||
|
werden nur die **heutigen Bestandswerte** erfasst (Guthaben, Kaufpreis, Hypothek, Restschuld) –
|
||||||
|
die laufenden Jahresbeträge folgen in Schritt 5. Zusätzlich fragt das Wohneigentum die
|
||||||
|
**Wertsteigerung** und den **Zins-in-Ausgaben-Schalter** ab (damit Hypothekarzinsen nicht
|
||||||
|
doppelt zählen), und die Säule 3a einen Schalter **«Selbstständig ohne PK (grosse Säule 3a)»**,
|
||||||
|
der die Beitrags-Obergrenze anhebt (siehe 4.11 / Feld `selfEmployed3a`).
|
||||||
|
|
||||||
|
**Schritt 5 (Sparen & Verteilen)** bringt das Kernmodell des Tools zum Anfassen: Aus
|
||||||
|
`Nettoeinkommen − Ausgaben` entsteht die **Sparquote**; der Nutzer verteilt sie auf Säule 3a,
|
||||||
|
Wertschriften, Amortisation und Schuldtilgung, und der **Rest bleibt sichtbar auf dem
|
||||||
|
Cash-Konto** (live gerechnet, negativer Rest wird gewarnt). Die **PK-Einzahlung** steht bewusst
|
||||||
|
in einem **eigenen** Block mit dem Hinweis, dass sie vom **Bruttolohn** bezahlt wird – also
|
||||||
|
**vor** dem Nettoeinkommen – und die Sparquote deshalb **nicht** schmälert. Das deckt sich exakt
|
||||||
|
mit dem Rechenkern, wo der PK-Beitrag nicht zur Quote zählt ([4.6.3](#463-pension_fund)). Der
|
||||||
|
Schritt entfällt fachlich nie, aber wenn keine Spar-/Vorsorgeposten angehakt sind, weist er nur
|
||||||
|
darauf hin, dass der ganze Betrag auf dem Cash-Konto wächst.
|
||||||
|
|
||||||
Zwei bewusste Entscheide bleiben:
|
Zwei bewusste Entscheide bleiben:
|
||||||
- **Einkommen wird pro Person erfasst**, nie als «Gemeinsam» – in Paar-Plänen zählt
|
- **Einkommen wird pro Person erfasst**, nie als «Gemeinsam» – in Paar-Plänen zählt
|
||||||
@@ -1273,13 +1291,31 @@ Neuaufbau des Formulars wie zuvor bei den Dialogen.
|
|||||||
Der frühere Dialog «Plan-Einstellungen» heisst im Panel korrekt **«Szenario-Profil»** – er
|
Der frühere Dialog «Plan-Einstellungen» heisst im Panel korrekt **«Szenario-Profil»** – er
|
||||||
bearbeitet seit V6 das Szenario, nicht den Plan.
|
bearbeitet seit V6 das Szenario, nicht den Plan.
|
||||||
|
|
||||||
|
**Aufbau der Szenario-Ansicht (seit 0.28):** von oben nach unten eine schlanke
|
||||||
|
**Funktions-Leiste** (Änderungshistorie · Tour · Neues Szenario aus diesem · Rechenwege ·
|
||||||
|
CSV-Export · Szenario löschen · Abweichungs-Badge), dann **Nächste Schritte**, das
|
||||||
|
**Grundprofil**, die **Zeitachse**, der **Anzeige-Umschalter** (Nominal/Beide/Real, Plan/Ist)
|
||||||
|
und zuunterst die **Matrix** mit ihren «Element»-/«Lebensphase»-Knöpfen. Die Analyse-Werkzeuge
|
||||||
|
(Grafiken, Live-Simulation, Monte-Carlo, Einflussfaktoren) und die Effektiven Werte sind hier
|
||||||
|
bewusst **nicht** mehr verlinkt – sie laufen ausschliesslich über ihre eigenen Menüpunkte
|
||||||
|
([3.10](#310-navigation-auf-plan-ebene-und-gespeicherte-analysen)), damit es je Funktion genau
|
||||||
|
einen Ort gibt.
|
||||||
|
|
||||||
### 3.7.8 Tour und «Nächste Schritte»
|
### 3.7.8 Tour und «Nächste Schritte»
|
||||||
|
|
||||||
Die **Tour** (`Tour.tsx`) startet einmalig beim ersten Öffnen eines Plans mit Phasen
|
Die **Tour** (`Tour.tsx`) führt in bis zu sechs Schritten über Profil, Zeitachse, Matrix,
|
||||||
(localStorage `fpt-tour-done`) und führt in bis zu sechs Schritten über Profil, Zeitachse,
|
Übergänge, Cash und Analysen – als Karte am unteren Rand plus pulsierender Rahmen um das Ziel
|
||||||
Matrix, Übergänge, Cash und Analysen – als Karte am unteren Rand plus pulsierender Rahmen um
|
(`data-tour`-Attribute). Schritte ohne vorhandenes Ziel werden übersprungen; der «Tour»-Knopf
|
||||||
das Ziel (`data-tour`-Attribute). Schritte ohne vorhandenes Ziel werden übersprungen; der
|
in der oberen Funktions-Leiste startet sie jederzeit neu. Grenze: 9.24.
|
||||||
«Tour»-Knopf in der Werkzeugleiste startet sie jederzeit neu. Grenze: 9.24.
|
|
||||||
|
Sie liegt seit 0.28 in `AppShell` (nicht mehr in `PlanView`), weil dort die Plan-Erstellung
|
||||||
|
zusammenläuft, und hat **zwei Auslöser**: Nach **jeder** Plan-Erstellung (Assistent,
|
||||||
|
Beispielplan, leerer Plan) startet sie **einmal** – unabhängig davon, ob sie schon beendet
|
||||||
|
wurde (die Erfolgsmeldung «die Tour zeigt dir gleich …» hält damit ihr Versprechen). Zusätzlich
|
||||||
|
startet sie beim ersten Öffnen eines bestehenden Plans mit Phasen, solange sie noch nie beendet
|
||||||
|
wurde (localStorage `fpt-tour-done`). Ein aus dem DOM gelesenes Ziel setzt voraus, dass die
|
||||||
|
Szenario-Ansicht bereits gerendert ist; der Auslöser feuert deshalb erst, wenn das Szenario
|
||||||
|
geladen ist.
|
||||||
|
|
||||||
Die Karte **«Nächste Schritte»** über der Matrix leitet aus den vorhandenen Daten ab, was
|
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
|
sinnvollerweise als Nächstes ansteht – offene Übergangs-Entscheide (mit Direktsprung in den
|
||||||
@@ -2455,6 +2491,7 @@ Zentral in `src/lib/constants.ts` geführt, weil sie sich periodisch durch Bunde
|
|||||||
| `AHV_FULL_CONTRIBUTION_YEARS` | 44 | Volle Beitragsdauer (Rentenskala 44) |
|
| `AHV_FULL_CONTRIBUTION_YEARS` | 44 | Volle Beitragsdauer (Rentenskala 44) |
|
||||||
| `AHV_REFERENCE_AGE` | 65 | Referenzalter – ab hier fliesst die Rente, **unabhängig vom Pensionsalter** ([4.4.7](#447-referenzalter-die-rente-beginnt-mit-65-nicht-mit-der-pensionierung)) |
|
| `AHV_REFERENCE_AGE` | 65 | Referenzalter – ab hier fliesst die Rente, **unabhängig vom Pensionsalter** ([4.4.7](#447-referenzalter-die-rente-beginnt-mit-65-nicht-mit-der-pensionierung)) |
|
||||||
| `PILLAR_3A_MAX_ANNUAL` | 7'258 | Max. 3a-Beitrag/Jahr für PK-Versicherte (2026) |
|
| `PILLAR_3A_MAX_ANNUAL` | 7'258 | Max. 3a-Beitrag/Jahr für PK-Versicherte (2026) |
|
||||||
|
| `PILLAR_3A_MAX_SELF_EMPLOYED` | 36'288 | Max. 3a-Beitrag/Jahr «grosse Säule 3a» (Selbstständige ohne PK). **2026-Wert zu verifizieren** (2025: 36'288) |
|
||||||
| `DEFAULT_PK_CONVERSION_RATE` | 6 % | Umwandlungssatz |
|
| `DEFAULT_PK_CONVERSION_RATE` | 6 % | Umwandlungssatz |
|
||||||
| `DEFAULT_CAPITAL_TAX_RATE` | 8 % | Kapitalbezugssteuer |
|
| `DEFAULT_CAPITAL_TAX_RATE` | 8 % | Kapitalbezugssteuer |
|
||||||
| `DEFAULT_PROPERTY_GAINS_TAX_RATE` | 20 % | Grundstückgewinnsteuer |
|
| `DEFAULT_PROPERTY_GAINS_TAX_RATE` | 20 % | Grundstückgewinnsteuer |
|
||||||
@@ -3338,6 +3375,7 @@ Referenz: `prisma/schema.prisma` Zeilen 4–6, `src/lib/elements.ts` Zeilen 48
|
|||||||
| `startValue` | OTHER_ASSET, OTHER_DEBT | ≥ 0 |
|
| `startValue` | OTHER_ASSET, OTHER_DEBT | ≥ 0 |
|
||||||
| `expectedReturn` | PK, 3a, OTHER_ASSET | −50 bis 100 |
|
| `expectedReturn` | PK, 3a, OTHER_ASSET | −50 bis 100 |
|
||||||
| `annualContribution` | PK, 3a, OTHER_ASSET | ≥ 0 |
|
| `annualContribution` | PK, 3a, OTHER_ASSET | ≥ 0 |
|
||||||
|
| `selfEmployed3a` | PILLAR_3A – «grosse Säule 3a» (Selbstständige ohne PK) → höhere Obergrenze | Boolean |
|
||||||
| `annualWithdrawal` | OTHER_ASSET | ≥ 0 |
|
| `annualWithdrawal` | OTHER_ASSET | ≥ 0 |
|
||||||
| `additionalInvestment` | PK, 3a, OTHER_ASSET (ab Phase 2) | ≥ 0 |
|
| `additionalInvestment` | PK, 3a, OTHER_ASSET (ab Phase 2) | ≥ 0 |
|
||||||
| `purchasePrice` | REAL_ESTATE | ≥ 0 |
|
| `purchasePrice` | REAL_ESTATE | ≥ 0 |
|
||||||
|
|||||||
@@ -65,7 +65,14 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const parsed = createPlanSchema.safeParse(body);
|
const parsed = createPlanSchema.safeParse(body);
|
||||||
if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
|
if (!parsed.success) {
|
||||||
|
// Lesbare Meldung statt des rohen zod-Objekts (sonst sieht der Nutzer einen JSON-Blob).
|
||||||
|
const first = parsed.error.issues[0];
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: first ? `${first.path.join(".") || "Eingabe"}: ${first.message}` : "Ungültige Eingabe." },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
const error = validatePersonsForType(parsed.data);
|
const error = validatePersonsForType(parsed.data);
|
||||||
if (error) return NextResponse.json({ error }, { status: 400 });
|
if (error) return NextResponse.json({ error }, { status: 400 });
|
||||||
|
|
||||||
|
|||||||
+73
-41
@@ -1,19 +1,20 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
BarChart3,
|
BarChart3,
|
||||||
BookOpen,
|
BookOpen,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Copy,
|
Copy,
|
||||||
Dices,
|
|
||||||
FileText,
|
FileText,
|
||||||
FileSpreadsheet,
|
FileSpreadsheet,
|
||||||
FolderKanban,
|
FolderKanban,
|
||||||
CalendarClock,
|
CalendarClock,
|
||||||
GitBranch,
|
GitBranch,
|
||||||
|
HelpCircle,
|
||||||
History,
|
History,
|
||||||
|
Download,
|
||||||
Layers,
|
Layers,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
Menu,
|
Menu,
|
||||||
@@ -22,7 +23,6 @@ import {
|
|||||||
Search,
|
Search,
|
||||||
SlidersHorizontal,
|
SlidersHorizontal,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
Tornado,
|
|
||||||
Trash2,
|
Trash2,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
@@ -44,6 +44,7 @@ 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 { PlanWizard } from "@/components/PlanWizard";
|
||||||
|
import { Tour, TOUR_DONE_KEY } from "@/components/Tour";
|
||||||
import { CommandPalette, type PaletteAction } from "@/components/CommandPalette";
|
import { CommandPalette, type PaletteAction } from "@/components/CommandPalette";
|
||||||
import { PlanProfileFields, emptyProfileDraft, type ProfileDraft } from "@/components/PlanProfileFields";
|
import { PlanProfileFields, emptyProfileDraft, type ProfileDraft } from "@/components/PlanProfileFields";
|
||||||
import {
|
import {
|
||||||
@@ -117,6 +118,33 @@ function AppShellInner({ username }: { username: string }) {
|
|||||||
// 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);
|
||||||
|
|
||||||
|
// Tour (seit dem Layout-Umbau hier statt in PlanView -- sie liest die data-tour-Ziele im
|
||||||
|
// DOM der Szenario-Ansicht). Zwei Auslöser:
|
||||||
|
// * pendingTourRef: nach JEDER Plan-Erstellung genau einmal erzwingen (Roadmap-Feedback F14).
|
||||||
|
// * Erst-Öffnen eines Plans mit Phasen, solange die Tour noch nie beendet wurde.
|
||||||
|
const [showTour, setShowTour] = useState(false);
|
||||||
|
const pendingTourRef = useRef(false);
|
||||||
|
const autoTourCheckedRef = useRef<Set<string>>(new Set());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!detail || detail.plan.phases.length === 0) return;
|
||||||
|
const sid = detail.meta.id;
|
||||||
|
if (pendingTourRef.current) {
|
||||||
|
pendingTourRef.current = false;
|
||||||
|
autoTourCheckedRef.current.add(sid);
|
||||||
|
setShowTour(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (autoTourCheckedRef.current.has(sid)) return; // pro Szenario nur einmal, nicht bei Refresh
|
||||||
|
autoTourCheckedRef.current.add(sid);
|
||||||
|
try {
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect -- einmalige Initialisierung aus localStorage
|
||||||
|
if (!localStorage.getItem(TOUR_DONE_KEY)) setShowTour(true);
|
||||||
|
} catch {
|
||||||
|
/* localStorage nicht verfügbar */
|
||||||
|
}
|
||||||
|
}, [detail]);
|
||||||
|
|
||||||
function openSpecAt(anchor: string) {
|
function openSpecAt(anchor: string) {
|
||||||
setSpecAnchor(anchor);
|
setSpecAnchor(anchor);
|
||||||
setShowSpec(true);
|
setShowSpec(true);
|
||||||
@@ -257,8 +285,9 @@ function AppShellInner({ username }: { username: string }) {
|
|||||||
const sid = await createDemoPlan();
|
const sid = await createDemoPlan();
|
||||||
await loadPlans();
|
await loadPlans();
|
||||||
setShowNewPlanChooser(false);
|
setShowNewPlanChooser(false);
|
||||||
|
pendingTourRef.current = true; // Tour bei jeder Plan-Erstellung (F14)
|
||||||
openScenario(sid);
|
openScenario(sid);
|
||||||
toast("success", "Beispielplan angelegt – schau dich um!");
|
toast("success", "Beispielplan angelegt – die Tour zeigt dir gleich die wichtigsten Stellen.");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast("error", e instanceof Error ? e.message : "Beispielplan konnte nicht angelegt werden.");
|
toast("error", e instanceof Error ? e.message : "Beispielplan konnte nicht angelegt werden.");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -597,53 +626,52 @@ function AppShellInner({ username }: { username: string }) {
|
|||||||
|
|
||||||
{!showSpec && !showSystemParams && !loading && detail && selectedScenarioId && (
|
{!showSpec && !showSystemParams && !loading && detail && selectedScenarioId && (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
|
{/* Funktions-Buttons der Szenario-Ansicht. Grafiken/Effektive Werte/Live-Sim/
|
||||||
|
Monte-Carlo/Einflussfaktoren sind hier bewusst NICHT mehr -- sie laufen über
|
||||||
|
die eigenen Menüpunkte (Analysen / Effektive Werte). Hier bleiben die reinen
|
||||||
|
Szenario-Aktionen plus Tour und CSV-Export. */}
|
||||||
<div className="flex flex-wrap items-center gap-2" data-tour="analysen">
|
<div className="flex flex-wrap items-center gap-2" data-tour="analysen">
|
||||||
|
{detail.plan.phases.length > 0 && (
|
||||||
|
<Button variant="secondary" onClick={() => setShowHistory(true)}>
|
||||||
|
<History className="h-4 w-4" />
|
||||||
|
Änderungshistorie
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{detail.plan.phases.length > 0 && (
|
||||||
|
<Button variant="secondary" onClick={() => setShowTour(true)} title="Kurze Tour durch die Ansicht">
|
||||||
|
<HelpCircle className="h-4 w-4" />
|
||||||
|
Tour
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button variant="secondary" onClick={() => setCopyFrom(detail.meta)}>
|
<Button variant="secondary" onClick={() => setCopyFrom(detail.meta)}>
|
||||||
<Copy className="h-4 w-4" />
|
<Copy className="h-4 w-4" />
|
||||||
Neues Szenario aus diesem
|
Neues Szenario aus diesem
|
||||||
</Button>
|
</Button>
|
||||||
|
{detail.plan.phases.length > 0 && (
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => setShowPlanTraces(true)}
|
||||||
|
title="Wie wird gerechnet? Plan-weite Grössen wie Deflatoren, AHV-Karriere und Ruinalter"
|
||||||
|
>
|
||||||
|
<BookOpen className="h-4 w-4" />
|
||||||
|
Rechenwege
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{detail.plan.phases.length > 0 && (
|
||||||
|
<a
|
||||||
|
href={`/api/scenarios/${detail.meta.id}/export`}
|
||||||
|
className="inline-flex items-center justify-center gap-1.5 rounded-lg border border-border bg-surface px-4 py-2 text-sm font-medium text-muted transition-all duration-150 hover:bg-surface-2 hover:text-fg"
|
||||||
|
>
|
||||||
|
<Download className="h-4 w-4" />
|
||||||
|
CSV-Export
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
{!detail.meta.isBase && (
|
{!detail.meta.isBase && (
|
||||||
<Button variant="danger" onClick={() => handleDeleteScenario(detail.meta)}>
|
<Button variant="danger" onClick={() => handleDeleteScenario(detail.meta)}>
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
Szenario löschen
|
Szenario löschen
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{detail.plan.phases.length > 0 && (
|
|
||||||
<>
|
|
||||||
<Button variant="secondary" onClick={() => setShowCharts(true)}>
|
|
||||||
<BarChart3 className="h-4 w-4" />
|
|
||||||
Grafiken
|
|
||||||
</Button>
|
|
||||||
<Button variant="secondary" onClick={() => setShowActuals(true)}>
|
|
||||||
<CalendarClock className="h-4 w-4" />
|
|
||||||
Effektive Werte
|
|
||||||
</Button>
|
|
||||||
<Button variant="secondary" onClick={() => setShowHistory(true)}>
|
|
||||||
<History className="h-4 w-4" />
|
|
||||||
Änderungshistorie
|
|
||||||
</Button>
|
|
||||||
<Button variant="secondary" onClick={() => setShowLiveSim(true)}>
|
|
||||||
<SlidersHorizontal className="h-4 w-4" />
|
|
||||||
Live-Simulation
|
|
||||||
</Button>
|
|
||||||
<Button variant="secondary" onClick={() => setShowMonteCarlo(true)}>
|
|
||||||
<Dices className="h-4 w-4" />
|
|
||||||
Monte-Carlo-Simulation
|
|
||||||
</Button>
|
|
||||||
<Button variant="secondary" onClick={() => setShowSensitivity(true)}>
|
|
||||||
<Tornado className="h-4 w-4" />
|
|
||||||
Einflussfaktoren
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
onClick={() => setShowPlanTraces(true)}
|
|
||||||
title="Wie wird gerechnet? Plan-weite Grössen wie Deflatoren, AHV-Karriere und Ruinalter"
|
|
||||||
>
|
|
||||||
<BookOpen className="h-4 w-4" />
|
|
||||||
Rechenwege
|
|
||||||
</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" />
|
||||||
@@ -690,6 +718,7 @@ function AppShellInner({ username }: { username: string }) {
|
|||||||
onCreated={async (sid) => {
|
onCreated={async (sid) => {
|
||||||
setShowWizard(false);
|
setShowWizard(false);
|
||||||
await loadPlans();
|
await loadPlans();
|
||||||
|
pendingTourRef.current = true; // Tour bei jeder Plan-Erstellung (F14)
|
||||||
openScenario(sid);
|
openScenario(sid);
|
||||||
toast("success", "Plan erstellt – die Tour zeigt dir gleich die wichtigsten Stellen.");
|
toast("success", "Plan erstellt – die Tour zeigt dir gleich die wichtigsten Stellen.");
|
||||||
}}
|
}}
|
||||||
@@ -706,8 +735,9 @@ function AppShellInner({ username }: { username: string }) {
|
|||||||
);
|
);
|
||||||
setShowEmptyPlanDialog(false);
|
setShowEmptyPlanDialog(false);
|
||||||
await loadPlans();
|
await loadPlans();
|
||||||
|
pendingTourRef.current = true; // Tour bei jeder Plan-Erstellung (F14)
|
||||||
openScenario(scenario.id);
|
openScenario(scenario.id);
|
||||||
toast("success", "Plan erstellt.");
|
toast("success", "Plan erstellt – die Tour zeigt dir gleich die wichtigsten Stellen.");
|
||||||
}}
|
}}
|
||||||
onClose={() => setShowEmptyPlanDialog(false)}
|
onClose={() => setShowEmptyPlanDialog(false)}
|
||||||
/>
|
/>
|
||||||
@@ -817,6 +847,8 @@ function AppShellInner({ username }: { username: string }) {
|
|||||||
actions={paletteActions}
|
actions={paletteActions}
|
||||||
onOpenScenario={openScenario}
|
onOpenScenario={openScenario}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{showTour && <Tour onClose={() => setShowTour(false)} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useState } from "react";
|
|||||||
import { Trash2 } from "lucide-react";
|
import { Trash2 } from "lucide-react";
|
||||||
import { AlertTriangle } from "lucide-react";
|
import { AlertTriangle } from "lucide-react";
|
||||||
import { FieldLabel, InheritableField, MoneyField, NumberField, SelectField, TextField } from "@/components/FormField";
|
import { FieldLabel, InheritableField, MoneyField, NumberField, SelectField, TextField } from "@/components/FormField";
|
||||||
|
import { InfoBubble } from "@/components/InfoBubble";
|
||||||
import { formatChf } from "@/lib/format";
|
import { formatChf } from "@/lib/format";
|
||||||
import { api } from "@/lib/api-client";
|
import { api } from "@/lib/api-client";
|
||||||
// Die reinen Uebergangs-Regeln liegen in lib/transitions.ts (auch serverseitig nutzbar) und
|
// Die reinen Uebergangs-Regeln liegen in lib/transitions.ts (auch serverseitig nutzbar) und
|
||||||
@@ -31,6 +32,7 @@ import {
|
|||||||
DEFAULT_PK_CONVERSION_RATE,
|
DEFAULT_PK_CONVERSION_RATE,
|
||||||
DEFAULT_PROPERTY_GAINS_TAX_RATE,
|
DEFAULT_PROPERTY_GAINS_TAX_RATE,
|
||||||
PILLAR_3A_MAX_ANNUAL,
|
PILLAR_3A_MAX_ANNUAL,
|
||||||
|
PILLAR_3A_MAX_SELF_EMPLOYED,
|
||||||
} from "@/lib/constants";
|
} from "@/lib/constants";
|
||||||
import type {
|
import type {
|
||||||
CashTransitionData,
|
CashTransitionData,
|
||||||
@@ -664,10 +666,12 @@ 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 Säule 3a wird beim Pensions-Übergang vollständig bezogen.</p>;
|
return <p className="col-span-2 text-sm text-muted">Die Säule 3a wird beim Pensions-Übergang vollständig bezogen.</p>;
|
||||||
}
|
}
|
||||||
|
// «Grosse Säule 3a» für Selbstständige ohne PK: höhere Obergrenze (Roadmap-Feedback C7c).
|
||||||
|
const max3a = pd.selfEmployed3a ? PILLAR_3A_MAX_SELF_EMPLOYED : PILLAR_3A_MAX_ANNUAL;
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{carried ? (
|
{carried ? (
|
||||||
@@ -684,14 +688,23 @@ 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 })} />
|
||||||
)}
|
)}
|
||||||
|
<label className="col-span-2 flex cursor-pointer items-center gap-2 text-xs text-muted">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={!!pd.selfEmployed3a}
|
||||||
|
onChange={(e) => setP({ selfEmployed3a: e.target.checked })}
|
||||||
|
/>
|
||||||
|
Selbstständig ohne Pensionskasse («grosse Säule 3a»)
|
||||||
|
<InfoBubble text={`Selbstständigerwerbende ohne PK dürfen bis 20 % des Nettoerwerbseinkommens einzahlen, gedeckelt auf ca. ${PILLAR_3A_MAX_SELF_EMPLOYED.toLocaleString("de-CH")} CHF – statt der ${PILLAR_3A_MAX_ANNUAL.toLocaleString("de-CH")} CHF mit PK.`} />
|
||||||
|
</label>
|
||||||
{inh(
|
{inh(
|
||||||
"annualContribution",
|
"annualContribution",
|
||||||
"Jährliche Einzahlung (CHF)",
|
"Jährliche Einzahlung (CHF)",
|
||||||
(v, set) => (
|
(v, set) => (
|
||||||
<MoneyField label="Jährliche Einzahlung (CHF)" value={v} max={PILLAR_3A_MAX_ANNUAL} onChange={set} />
|
<MoneyField label="Jährliche Einzahlung (CHF)" value={v} max={max3a} onChange={set} />
|
||||||
),
|
),
|
||||||
asChf,
|
asChf,
|
||||||
`Maximal CHF ${PILLAR_3A_MAX_ANNUAL.toLocaleString("de-CH")} (2026, mit PK) und höchstens die Sparquote. Wird von der Sparquote abgezogen.`
|
`Maximal CHF ${max3a.toLocaleString("de-CH")} (2026, ${pd.selfEmployed3a ? "Selbstständig ohne PK" : "mit PK"}) und höchstens die Sparquote. Wird von der Sparquote abgezogen.`
|
||||||
)}
|
)}
|
||||||
{inh(
|
{inh(
|
||||||
"expectedReturn",
|
"expectedReturn",
|
||||||
@@ -701,6 +714,7 @@ export function ElementPhaseFields({
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
}
|
||||||
case "REAL_ESTATE": {
|
case "REAL_ESTATE": {
|
||||||
// Zinsbetrag zu Phasenbeginn und -ende: die Restschuld sinkt mit der Amortisation,
|
// Zinsbetrag zu Phasenbeginn und -ende: die Restschuld sinkt mit der Amortisation,
|
||||||
// der Zinsbetrag also mit. Am Nullpunkt gekappt (analog zur Berechnung).
|
// der Zinsbetrag also mit. Am Nullpunkt gekappt (analog zur Berechnung).
|
||||||
|
|||||||
+73
-101
@@ -9,8 +9,6 @@ import {
|
|||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
CreditCard,
|
CreditCard,
|
||||||
Download,
|
|
||||||
HelpCircle,
|
|
||||||
Home,
|
Home,
|
||||||
Landmark,
|
Landmark,
|
||||||
ListTodo,
|
ListTodo,
|
||||||
@@ -33,7 +31,6 @@ import {
|
|||||||
transitionInactive as inactiveAtTransition,
|
transitionInactive as inactiveAtTransition,
|
||||||
TRANSITION_CATEGORIES,
|
TRANSITION_CATEGORIES,
|
||||||
} from "@/lib/decisions";
|
} from "@/lib/decisions";
|
||||||
import { Tour, TOUR_DONE_KEY } from "@/components/Tour";
|
|
||||||
import { Button, EmptyState, InspectorShell, Modal, useConfirm, useToast } from "@/components/ui";
|
import { Button, EmptyState, InspectorShell, Modal, useConfirm, useToast } from "@/components/ui";
|
||||||
import { ElementDetailDialog, PhaseDetailDialog } from "@/components/DetailView";
|
import { ElementDetailDialog, PhaseDetailDialog } from "@/components/DetailView";
|
||||||
import {
|
import {
|
||||||
@@ -165,24 +162,12 @@ export function PlanView({
|
|||||||
const [addCategory, setAddCategory] = useState<ElementCategory | null>(null);
|
const [addCategory, setAddCategory] = useState<ElementCategory | null>(null);
|
||||||
const [showAddPhase, setShowAddPhase] = useState(false);
|
const [showAddPhase, setShowAddPhase] = useState(false);
|
||||||
const [reviewFromPhaseId, setReviewFromPhaseId] = useState<string | null>(null);
|
const [reviewFromPhaseId, setReviewFromPhaseId] = useState<string | null>(null);
|
||||||
const [showTour, setShowTour] = useState(false);
|
|
||||||
// Offener Verteil-Dialog (Kapital bzw. Spar-/Verzehrquote) -- bewusst ein eigenes Popup,
|
// Offener Verteil-Dialog (Kapital bzw. Spar-/Verzehrquote) -- bewusst ein eigenes Popup,
|
||||||
// weil beide mehrere Elemente auf einmal bearbeiten.
|
// weil beide mehrere Elemente auf einmal bearbeiten.
|
||||||
const [distribute, setDistribute] = useState<{ kind: "capital" | "rates"; phaseId: string } | null>(null);
|
const [distribute, setDistribute] = useState<{ kind: "capital" | "rates"; phaseId: string } | null>(null);
|
||||||
const [valueMode, setValueMode] = useState<ValueMode>("nominal");
|
const [valueMode, setValueMode] = useState<ValueMode>("nominal");
|
||||||
|
// Die Tour (Start-Knopf, Auto-Start bei Plan-Erstellung, Rendering) liegt seit dem
|
||||||
// Tour beim ersten Besuch eines Plans mit Phasen automatisch starten.
|
// Layout-Umbau in AppShell -- sie liest die data-tour-Ziele im DOM dieser Ansicht.
|
||||||
useEffect(() => {
|
|
||||||
try {
|
|
||||||
if (computed.phases.length > 0 && !localStorage.getItem(TOUR_DONE_KEY)) {
|
|
||||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- einmalige Initialisierung aus localStorage
|
|
||||||
setShowTour(true);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
/* localStorage nicht verfügbar */
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, []);
|
|
||||||
// Nur-Lese-Detailansicht (Roadmap Nr. 43). Der Rechenweg wird erst beim Öffnen erzeugt.
|
// Nur-Lese-Detailansicht (Roadmap Nr. 43). Der Rechenweg wird erst beim Öffnen erzeugt.
|
||||||
const [detailFor, setDetailFor] = useState<{ kind: "element"; id: string } | { kind: "phase"; id: string } | null>(null);
|
const [detailFor, setDetailFor] = useState<{ kind: "element"; id: string } | { kind: "phase"; id: string } | null>(null);
|
||||||
|
|
||||||
@@ -361,60 +346,19 @@ export function PlanView({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-5">
|
<div className="flex flex-col gap-5">
|
||||||
<div className="flex items-center gap-2">
|
<NextSteps
|
||||||
<span className="text-xs text-muted">Anzeige</span>
|
plan={plan}
|
||||||
<div className="inline-flex rounded-lg border border-border bg-surface p-0.5 text-xs">
|
computed={computed}
|
||||||
{(["nominal", "both", "real"] as ValueMode[]).map((m) => (
|
columns={columns}
|
||||||
<button
|
openCountFor={transitionOpenCount}
|
||||||
key={m}
|
onReview={(fromPhaseId) => setReviewFromPhaseId(fromPhaseId)}
|
||||||
type="button"
|
onAddElement={(cat) => {
|
||||||
onClick={() => changeValueMode(m)}
|
setAddCategory(cat);
|
||||||
className={`rounded-md px-2.5 py-1 font-medium ${
|
setShowAdd(true);
|
||||||
valueMode === m ? "bg-accent text-accent-fg" : "text-muted hover:bg-surface-2"
|
}}
|
||||||
}`}
|
onAddPhase={() => setShowAddPhase(true)}
|
||||||
>
|
onOpenSensitivity={onOpenSensitivity}
|
||||||
{m === "nominal" ? "Nominal" : m === "both" ? "Beide" : "Real"}
|
/>
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<span className="text-[11px] text-faint">real = kaufkraftbereinigt (Planbeginn)</span>
|
|
||||||
|
|
||||||
{/* Zweite Achse: Datenquelle. Erscheint nur, wenn es überhaupt Ist-Werte gibt --
|
|
||||||
sonst wäre es ein Umschalter ohne Gegenstück. */}
|
|
||||||
{hasActuals && (
|
|
||||||
<>
|
|
||||||
<span className="ml-2 text-xs text-muted">Zahlen</span>
|
|
||||||
<div className="inline-flex rounded-lg border border-border bg-surface p-0.5 text-xs">
|
|
||||||
{DATA_SOURCE_OPTIONS.map((o) => (
|
|
||||||
<button
|
|
||||||
key={o.value}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setDataSource(o.value)}
|
|
||||||
title={o.label}
|
|
||||||
className={`rounded-md px-2.5 py-1 font-medium ${
|
|
||||||
dataSource === o.value ? "bg-accent text-accent-fg" : "text-muted hover:bg-surface-2"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{o.short}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
{dataSource === "ACTUAL" && (
|
|
||||||
<span className="text-[11px] text-faint">Abweichung gegenüber Plan farbig</span>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div data-tour="timeline">
|
|
||||||
<Timeline
|
|
||||||
phases={computed.phases}
|
|
||||||
persons={personAxes}
|
|
||||||
ruinAge={computed.ruinAge}
|
|
||||||
actualYears={actualYears}
|
|
||||||
startYear={plan.startYear}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Szenario-Profil */}
|
{/* Szenario-Profil */}
|
||||||
<div
|
<div
|
||||||
@@ -441,19 +385,64 @@ export function PlanView({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<NextSteps
|
<div data-tour="timeline">
|
||||||
plan={plan}
|
<Timeline
|
||||||
computed={computed}
|
phases={computed.phases}
|
||||||
columns={columns}
|
persons={personAxes}
|
||||||
openCountFor={transitionOpenCount}
|
ruinAge={computed.ruinAge}
|
||||||
onReview={(fromPhaseId) => setReviewFromPhaseId(fromPhaseId)}
|
actualYears={actualYears}
|
||||||
onAddElement={(cat) => {
|
startYear={plan.startYear}
|
||||||
setAddCategory(cat);
|
/>
|
||||||
setShowAdd(true);
|
</div>
|
||||||
}}
|
|
||||||
onAddPhase={() => setShowAddPhase(true)}
|
{/* Anzeige-Umschalter (nominal/real, Plan/Ist) -- direkt über der Matrix, weil er nur
|
||||||
onOpenSensitivity={onOpenSensitivity}
|
deren Zahlen steuert. */}
|
||||||
/>
|
{hasPhases && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs text-muted">Anzeige</span>
|
||||||
|
<div className="inline-flex rounded-lg border border-border bg-surface p-0.5 text-xs">
|
||||||
|
{(["nominal", "both", "real"] as ValueMode[]).map((m) => (
|
||||||
|
<button
|
||||||
|
key={m}
|
||||||
|
type="button"
|
||||||
|
onClick={() => changeValueMode(m)}
|
||||||
|
className={`rounded-md px-2.5 py-1 font-medium ${
|
||||||
|
valueMode === m ? "bg-accent text-accent-fg" : "text-muted hover:bg-surface-2"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{m === "nominal" ? "Nominal" : m === "both" ? "Beide" : "Real"}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<span className="text-[11px] text-faint">real = kaufkraftbereinigt (Planbeginn)</span>
|
||||||
|
|
||||||
|
{/* Zweite Achse: Datenquelle. Erscheint nur, wenn es überhaupt Ist-Werte gibt --
|
||||||
|
sonst wäre es ein Umschalter ohne Gegenstück. */}
|
||||||
|
{hasActuals && (
|
||||||
|
<>
|
||||||
|
<span className="ml-2 text-xs text-muted">Zahlen</span>
|
||||||
|
<div className="inline-flex rounded-lg border border-border bg-surface p-0.5 text-xs">
|
||||||
|
{DATA_SOURCE_OPTIONS.map((o) => (
|
||||||
|
<button
|
||||||
|
key={o.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setDataSource(o.value)}
|
||||||
|
title={o.label}
|
||||||
|
className={`rounded-md px-2.5 py-1 font-medium ${
|
||||||
|
dataSource === o.value ? "bg-accent text-accent-fg" : "text-muted hover:bg-surface-2"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{o.short}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{dataSource === "ACTUAL" && (
|
||||||
|
<span className="text-[11px] text-faint">Abweichung gegenüber Plan farbig</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{!hasPhases && (
|
{!hasPhases && (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
@@ -479,21 +468,6 @@ export function PlanView({
|
|||||||
>
|
>
|
||||||
<Plus className="h-4 w-4" /> Lebensphase
|
<Plus className="h-4 w-4" /> Lebensphase
|
||||||
</button>
|
</button>
|
||||||
{/* CSV-Export der Matrix. Liest immer den aktuellen Stand aus der Datenbank. */}
|
|
||||||
<a
|
|
||||||
href={`/api/scenarios/${plan.id}/export`}
|
|
||||||
className="ml-auto flex items-center gap-1.5 rounded-lg border border-border px-2.5 py-1.5 text-xs font-medium text-muted transition-colors hover:bg-surface-2 hover:text-fg"
|
|
||||||
>
|
|
||||||
<Download className="h-3.5 w-3.5" /> CSV-Export
|
|
||||||
</a>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowTour(true)}
|
|
||||||
title="Kurze Tour durch die Ansicht"
|
|
||||||
className="flex items-center gap-1.5 rounded-lg border border-border px-2.5 py-1.5 text-xs font-medium text-muted transition-colors hover:bg-surface-2 hover:text-fg"
|
|
||||||
>
|
|
||||||
<HelpCircle className="h-3.5 w-3.5" /> Tour
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -852,8 +826,6 @@ export function PlanView({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showTour && <Tour onClose={() => setShowTour(false)} />}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
+206
-34
@@ -16,7 +16,7 @@
|
|||||||
// Ehrliche Grenze: Bricht die Sequenz mittendrin ab (Netzfehler), existiert ein Teil-Plan
|
// Ehrliche Grenze: Bricht die Sequenz mittendrin ab (Netzfehler), existiert ein Teil-Plan
|
||||||
// (siehe SPEZIFIKATION 9.23).
|
// (siehe SPEZIFIKATION 9.23).
|
||||||
|
|
||||||
import { useMemo, useRef, useState } from "react";
|
import { Fragment, useMemo, useRef, useState } from "react";
|
||||||
import { ArrowLeft, ArrowRight, Flag, Plus, Sparkles, Trash2 } from "lucide-react";
|
import { ArrowLeft, ArrowRight, Flag, Plus, Sparkles, Trash2 } from "lucide-react";
|
||||||
import { Modal, Button } from "@/components/ui";
|
import { Modal, Button } from "@/components/ui";
|
||||||
import { MoneyField, NumberField, TextField } from "@/components/FormField";
|
import { MoneyField, NumberField, TextField } from "@/components/FormField";
|
||||||
@@ -24,7 +24,7 @@ import { InfoBubble } from "@/components/InfoBubble";
|
|||||||
import { PlanProfileFields, emptyProfileDraft, type ProfileDraft } from "@/components/PlanProfileFields";
|
import { PlanProfileFields, emptyProfileDraft, type ProfileDraft } from "@/components/PlanProfileFields";
|
||||||
import { api } from "@/lib/api-client";
|
import { api } from "@/lib/api-client";
|
||||||
import { planSegments, defaultOpenDuration, type PlanSegment, type SegmentType } from "@/lib/phaseplan";
|
import { planSegments, defaultOpenDuration, type PlanSegment, type SegmentType } from "@/lib/phaseplan";
|
||||||
import { PILLAR_3A_MAX_ANNUAL } from "@/lib/constants";
|
import { PILLAR_3A_MAX_ANNUAL, PILLAR_3A_MAX_SELF_EMPLOYED } from "@/lib/constants";
|
||||||
import type { PhaseData } from "@/lib/elements";
|
import type { PhaseData } from "@/lib/elements";
|
||||||
|
|
||||||
interface PhaseDraft {
|
interface PhaseDraft {
|
||||||
@@ -32,7 +32,14 @@ interface PhaseDraft {
|
|||||||
durationYears: number;
|
durationYears: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const STEP_TITLES = ["Über dich", "Deine Lebensphasen", "Einkommen & Ausgaben", "Vorsorge & Vermögen", "Fertig"];
|
const STEP_TITLES = [
|
||||||
|
"Über dich",
|
||||||
|
"Deine Lebensphasen",
|
||||||
|
"Einkommen & Ausgaben",
|
||||||
|
"Vorsorge & Vermögen",
|
||||||
|
"Sparen & Verteilen",
|
||||||
|
"Fertig",
|
||||||
|
];
|
||||||
|
|
||||||
const SEGMENT_META: Record<SegmentType, { label: string; phase: string; def: string; color: string; soft: string }> = {
|
const SEGMENT_META: Record<SegmentType, { label: string; phase: string; def: string; color: string; soft: string }> = {
|
||||||
ERWERB: {
|
ERWERB: {
|
||||||
@@ -80,18 +87,20 @@ type Category = "pk" | "p3a" | "etf" | "re" | "debt";
|
|||||||
|
|
||||||
interface ScopeAssets {
|
interface ScopeAssets {
|
||||||
pk: { on: boolean; value: number; contribution: number };
|
pk: { on: boolean; value: number; contribution: number };
|
||||||
p3a: { on: boolean; value: number; contribution: number };
|
// selfEmployed: «grosse Säule 3a» ohne PK -> höhere Obergrenze (Roadmap-Feedback C7c).
|
||||||
|
p3a: { on: boolean; value: number; contribution: number; selfEmployed: boolean };
|
||||||
etf: { on: boolean; value: number; contribution: number };
|
etf: { on: boolean; value: number; contribution: number };
|
||||||
re: { on: boolean; price: number; mortgage: number; amortization: number; interest: number };
|
// growth = Wertsteigerung %/Jahr; interestIncluded = Zinsen bereits in den Ausgaben.
|
||||||
|
re: { on: boolean; price: number; mortgage: number; amortization: number; interest: number; growth: number; interestIncluded: boolean };
|
||||||
debt: { on: boolean; value: number; repayment: number };
|
debt: { on: boolean; value: number; repayment: number };
|
||||||
}
|
}
|
||||||
|
|
||||||
function emptyScope(): ScopeAssets {
|
function emptyScope(): ScopeAssets {
|
||||||
return {
|
return {
|
||||||
pk: { on: false, value: 0, contribution: 0 },
|
pk: { on: false, value: 0, contribution: 0 },
|
||||||
p3a: { on: false, value: 0, contribution: 0 },
|
p3a: { on: false, value: 0, contribution: 0, selfEmployed: false },
|
||||||
etf: { on: false, value: 0, contribution: 0 },
|
etf: { on: false, value: 0, contribution: 0 },
|
||||||
re: { on: false, price: 0, mortgage: 0, amortization: 0, interest: 1.5 },
|
re: { on: false, price: 0, mortgage: 0, amortization: 0, interest: 1.5, growth: 1, interestIncluded: true },
|
||||||
debt: { on: false, value: 0, repayment: 0 },
|
debt: { on: false, value: 0, repayment: 0 },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -219,7 +228,8 @@ export function PlanWizard({
|
|||||||
|
|
||||||
for (const p of profile.persons) {
|
for (const p of profile.persons) {
|
||||||
const amount = incomes[p.role] ?? 0;
|
const amount = incomes[p.role] ?? 0;
|
||||||
if (amount > 0) await addEl("INCOME", `Lohn ${personLabel(p.role)}`, p.role, { amount, teuerungsausgleich: 0 });
|
// 1 % nominale Lohnerhöhung als dezenter Default -- 0 % liesse den Lohn real verlieren.
|
||||||
|
if (amount > 0) await addEl("INCOME", `Lohn ${personLabel(p.role)}`, p.role, { amount, teuerungsausgleich: 1 });
|
||||||
}
|
}
|
||||||
if (expenses > 0) await addEl("EXPENSE", "Lebenshaltung", "HOUSEHOLD", { amount: expenses, teuerungsausgleich: 0 });
|
if (expenses > 0) await addEl("EXPENSE", "Lebenshaltung", "HOUSEHOLD", { amount: expenses, teuerungsausgleich: 0 });
|
||||||
for (const p of profile.persons) {
|
for (const p of profile.persons) {
|
||||||
@@ -241,8 +251,9 @@ export function PlanWizard({
|
|||||||
if (a.p3a.on) {
|
if (a.p3a.on) {
|
||||||
await addEl("PILLAR_3A", `Säule 3a${suffix}`, scope.vorsorgeRole!, {
|
await addEl("PILLAR_3A", `Säule 3a${suffix}`, scope.vorsorgeRole!, {
|
||||||
currentValue: a.p3a.value,
|
currentValue: a.p3a.value,
|
||||||
annualContribution: Math.min(a.p3a.contribution, PILLAR_3A_MAX_ANNUAL),
|
annualContribution: Math.min(a.p3a.contribution, max3aFor(scope.id)),
|
||||||
expectedReturn: 2,
|
expectedReturn: 2,
|
||||||
|
selfEmployed3a: a.p3a.selfEmployed,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -259,6 +270,8 @@ export function PlanWizard({
|
|||||||
mortgage: a.re.mortgage,
|
mortgage: a.re.mortgage,
|
||||||
amortization: a.re.amortization,
|
amortization: a.re.amortization,
|
||||||
interestRate: a.re.interest,
|
interestRate: a.re.interest,
|
||||||
|
valueGrowth: a.re.growth,
|
||||||
|
interestHandling: a.re.interestIncluded ? "INCLUDED" : "ADD",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (a.debt.on) {
|
if (a.debt.on) {
|
||||||
@@ -307,6 +320,30 @@ export function PlanWizard({
|
|||||||
return n + sc.cats.filter((c) => a[c].on).length;
|
return n + sc.cats.filter((c) => a[c].on).length;
|
||||||
}, 0);
|
}, 0);
|
||||||
|
|
||||||
|
// --- Sparquote (Schritt "Sparen & Verteilen") ---
|
||||||
|
// Die Sparquote entsteht aus Nettoeinkommen minus Ausgaben. PK-Beiträge kommen VOM
|
||||||
|
// Bruttolohn (vor dem Netto) und reduzieren sie deshalb NICHT -- genau wie im Rechenkern.
|
||||||
|
const totalIncome = profile.persons.reduce((s, p) => s + (incomes[p.role] ?? 0), 0);
|
||||||
|
const sparBase = totalIncome - expenses;
|
||||||
|
const allocatedFromQuote = renderedScopes.reduce((sum, sc) => {
|
||||||
|
const a = assets[sc.id];
|
||||||
|
return (
|
||||||
|
sum +
|
||||||
|
(a.p3a.on ? a.p3a.contribution : 0) +
|
||||||
|
(a.etf.on ? a.etf.contribution : 0) +
|
||||||
|
(a.re.on ? a.re.amortization : 0) +
|
||||||
|
(a.debt.on ? a.debt.repayment : 0)
|
||||||
|
);
|
||||||
|
}, 0);
|
||||||
|
const restToCash = sparBase - allocatedFromQuote;
|
||||||
|
const anyPk = renderedScopes.some((sc) => assets[sc.id].pk.on);
|
||||||
|
const anyQuoteTarget = renderedScopes.some((sc) => {
|
||||||
|
const a = assets[sc.id];
|
||||||
|
return a.p3a.on || a.etf.on || a.re.on || a.debt.on;
|
||||||
|
});
|
||||||
|
const scopeTag = (sc: { label: string }) => (isCouple ? ` · ${sc.label}` : "");
|
||||||
|
const max3aFor = (sc: Scope) => (assets[sc].p3a.selfEmployed ? PILLAR_3A_MAX_SELF_EMPLOYED : PILLAR_3A_MAX_ANNUAL);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
title="Plan erstellen – geführt"
|
title="Plan erstellen – geführt"
|
||||||
@@ -416,25 +453,32 @@ export function PlanWizard({
|
|||||||
|
|
||||||
{step === 2 && (
|
{step === 2 && (
|
||||||
<div className="flex flex-col gap-3">
|
<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>
|
<p className="text-sm text-muted">Die wichtigsten Zahlen deiner Planung – grob geschätzt reicht für den Anfang.</p>
|
||||||
{profile.persons.map((p) => (
|
{profile.persons.map((p) => {
|
||||||
<MoneyField
|
const nm = profile.persons.find((x) => x.role === p.role)?.name?.trim();
|
||||||
key={p.role}
|
return (
|
||||||
label={`Was verdient ${personLabel(p.role)} netto pro Jahr? (CHF)`}
|
<MoneyField
|
||||||
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."
|
key={p.role}
|
||||||
value={incomes[p.role] ?? 0}
|
label={
|
||||||
onChange={(v) => setIncomes((prev) => ({ ...prev, [p.role]: v }))}
|
isCouple
|
||||||
/>
|
? `Was verdient ${personLabel(p.role)} netto pro Jahr? (CHF)`
|
||||||
))}
|
: `Was verdienst du${nm ? ` (${nm})` : ""} 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
|
<MoneyField
|
||||||
label="Was gebt ihr heute pro Jahr aus? (CHF)"
|
label={isCouple ? "Was gebt ihr heute pro Jahr aus? (CHF)" : "Was gibst du 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."
|
help="Alle Lebenshaltungskosten in heutiger Kaufkraft – Miete/Wohnen, Essen, Mobilität, Versicherungen, Steuern, Freizeit. Die Inflation rechnet das Tool automatisch dazu."
|
||||||
value={expenses}
|
value={expenses}
|
||||||
onChange={setExpenses}
|
onChange={setExpenses}
|
||||||
/>
|
/>
|
||||||
<MoneyField
|
<MoneyField
|
||||||
label="Wie viel liegt auf dem Konto? (CHF)"
|
label={isCouple ? "Wie viel liegt auf euren Konten? (CHF)" : "Wie viel liegt auf deinem Konto? (CHF)"}
|
||||||
help="Dein heutiger Kontostand (Cash). Er ist der Startwert des Ausgleichskontos."
|
help="Euer heutiger Kontostand (Cash). Er ist der Startwert des Ausgleichskontos."
|
||||||
value={initialCash}
|
value={initialCash}
|
||||||
onChange={setInitialCash}
|
onChange={setInitialCash}
|
||||||
/>
|
/>
|
||||||
@@ -444,8 +488,10 @@ export function PlanWizard({
|
|||||||
{step === 3 && (
|
{step === 3 && (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<p className="text-sm text-muted">
|
<p className="text-sm text-muted">
|
||||||
Was besitzt ihr? Hake an, was zutrifft – nur das Angehakte fragt nach Details.
|
{isCouple ? "Was besitzt ihr?" : "Was besitzt du?"} Hake an, was zutrifft – nur das Angehakte fragt nach Details.
|
||||||
{isCouple && " Pensionskasse und Säule 3a sind immer persönlich; Wertschriften, Wohneigentum und Schulden kannst du gemeinsam oder je Person erfassen."}
|
Hier geht es nur um die <strong className="text-fg">heutigen Werte</strong>; die jährlichen Einzahlungen und
|
||||||
|
Amortisationen verteilst du im nächsten Schritt aus deiner Sparquote.
|
||||||
|
{isCouple && " Pensionskasse und Säule 3a sind immer persönlich; Wertschriften, Wohneigentum und Schulden könnt ihr gemeinsam oder je Person erfassen."}
|
||||||
</p>
|
</p>
|
||||||
{renderedScopes.map((sc) => (
|
{renderedScopes.map((sc) => (
|
||||||
<div key={sc.id} className="rounded-xl border border-border p-3">
|
<div key={sc.id} className="rounded-xl border border-border p-3">
|
||||||
@@ -456,58 +502,72 @@ export function PlanWizard({
|
|||||||
{sc.cats.includes("pk") && (
|
{sc.cats.includes("pk") && (
|
||||||
<WizardToggle
|
<WizardToggle
|
||||||
label="Pensionskasse"
|
label="Pensionskasse"
|
||||||
help="PK-Guthaben laut Vorsorgeausweis und die jährlichen Einzahlungen (Arbeitnehmer + Arbeitgeber)."
|
help="PK-Guthaben laut Vorsorgeausweis. Die jährliche Einzahlung erfasst du im nächsten Schritt."
|
||||||
checked={assets[sc.id].pk.on}
|
checked={assets[sc.id].pk.on}
|
||||||
onChange={(v) => setScope(sc.id, "pk", { on: v })}
|
onChange={(v) => setScope(sc.id, "pk", { on: v })}
|
||||||
>
|
>
|
||||||
<MoneyField label="Aktuelles Guthaben (CHF)" value={assets[sc.id].pk.value} onChange={(v) => setScope(sc.id, "pk", { value: v })} />
|
<MoneyField label="Aktuelles Guthaben (CHF)" value={assets[sc.id].pk.value} onChange={(v) => setScope(sc.id, "pk", { value: v })} />
|
||||||
<MoneyField label="Einzahlung pro Jahr (CHF)" value={assets[sc.id].pk.contribution} onChange={(v) => setScope(sc.id, "pk", { contribution: v })} />
|
|
||||||
</WizardToggle>
|
</WizardToggle>
|
||||||
)}
|
)}
|
||||||
{sc.cats.includes("p3a") && (
|
{sc.cats.includes("p3a") && (
|
||||||
<WizardToggle
|
<WizardToggle
|
||||||
label="Säule 3a"
|
label="Säule 3a"
|
||||||
help={`3a-Guthaben und die jährliche Einzahlung (max. ${PILLAR_3A_MAX_ANNUAL.toLocaleString("de-CH")} CHF).`}
|
help="3a-Guthaben laut Bescheinigung. Die jährliche Einzahlung erfasst du im nächsten Schritt."
|
||||||
checked={assets[sc.id].p3a.on}
|
checked={assets[sc.id].p3a.on}
|
||||||
onChange={(v) => setScope(sc.id, "p3a", { on: v })}
|
onChange={(v) => setScope(sc.id, "p3a", { on: v })}
|
||||||
>
|
>
|
||||||
<MoneyField label="Aktuelles Guthaben (CHF)" value={assets[sc.id].p3a.value} onChange={(v) => setScope(sc.id, "p3a", { value: v })} />
|
<MoneyField label="Aktuelles Guthaben (CHF)" value={assets[sc.id].p3a.value} onChange={(v) => setScope(sc.id, "p3a", { value: v })} />
|
||||||
<MoneyField label="Einzahlung pro Jahr (CHF)" value={assets[sc.id].p3a.contribution} max={PILLAR_3A_MAX_ANNUAL} onChange={(v) => setScope(sc.id, "p3a", { contribution: v })} />
|
<label className="col-span-full flex cursor-pointer items-center gap-2 text-xs text-muted">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={assets[sc.id].p3a.selfEmployed}
|
||||||
|
onChange={(e) => setScope(sc.id, "p3a", { selfEmployed: e.target.checked })}
|
||||||
|
/>
|
||||||
|
Selbstständig ohne Pensionskasse («grosse Säule 3a»)
|
||||||
|
<InfoBubble text={`Selbstständigerwerbende ohne PK dürfen bis 20 % des Nettoerwerbseinkommens einzahlen, gedeckelt auf ca. ${PILLAR_3A_MAX_SELF_EMPLOYED.toLocaleString("de-CH")} CHF – statt der ${PILLAR_3A_MAX_ANNUAL.toLocaleString("de-CH")} CHF mit PK.`} />
|
||||||
|
</label>
|
||||||
</WizardToggle>
|
</WizardToggle>
|
||||||
)}
|
)}
|
||||||
{sc.cats.includes("etf") && (
|
{sc.cats.includes("etf") && (
|
||||||
<WizardToggle
|
<WizardToggle
|
||||||
label="Wertschriften / ETF / Sparen"
|
label="Wertschriften / ETF / Sparen"
|
||||||
help="Depots, Fonds, Sparkonten – alles, was Rendite bringt und nicht Vorsorge ist."
|
help="Depots, Fonds, Sparkonten – alles, was Rendite bringt und nicht Vorsorge ist. Den jährlichen Sparbeitrag erfasst du im nächsten Schritt."
|
||||||
checked={assets[sc.id].etf.on}
|
checked={assets[sc.id].etf.on}
|
||||||
onChange={(v) => setScope(sc.id, "etf", { on: v })}
|
onChange={(v) => setScope(sc.id, "etf", { on: v })}
|
||||||
>
|
>
|
||||||
<MoneyField label="Aktueller Wert (CHF)" value={assets[sc.id].etf.value} onChange={(v) => setScope(sc.id, "etf", { value: v })} />
|
<MoneyField label="Aktueller Wert (CHF)" value={assets[sc.id].etf.value} onChange={(v) => setScope(sc.id, "etf", { value: v })} />
|
||||||
<MoneyField label="Sparbeitrag pro Jahr (CHF)" value={assets[sc.id].etf.contribution} onChange={(v) => setScope(sc.id, "etf", { contribution: v })} />
|
|
||||||
</WizardToggle>
|
</WizardToggle>
|
||||||
)}
|
)}
|
||||||
{sc.cats.includes("re") && (
|
{sc.cats.includes("re") && (
|
||||||
<WizardToggle
|
<WizardToggle
|
||||||
label="Wohneigentum"
|
label="Wohneigentum"
|
||||||
help="Kaufpreis, Hypothek und jährliche Amortisation gemäss Hypothekarvertrag."
|
help="Kaufpreis und Hypothek laut Vertrag. Die jährliche Amortisation erfasst du im nächsten Schritt."
|
||||||
checked={assets[sc.id].re.on}
|
checked={assets[sc.id].re.on}
|
||||||
onChange={(v) => setScope(sc.id, "re", { on: v })}
|
onChange={(v) => setScope(sc.id, "re", { on: v })}
|
||||||
>
|
>
|
||||||
<MoneyField label="Kaufpreis (CHF)" value={assets[sc.id].re.price} onChange={(v) => setScope(sc.id, "re", { price: v })} />
|
<MoneyField label="Kaufpreis (CHF)" value={assets[sc.id].re.price} onChange={(v) => setScope(sc.id, "re", { price: v })} />
|
||||||
<MoneyField label="Hypothek (CHF)" value={assets[sc.id].re.mortgage} onChange={(v) => setScope(sc.id, "re", { mortgage: v })} />
|
<MoneyField label="Hypothek (CHF)" value={assets[sc.id].re.mortgage} onChange={(v) => setScope(sc.id, "re", { mortgage: v })} />
|
||||||
<MoneyField label="Amortisation pro Jahr (CHF)" value={assets[sc.id].re.amortization} onChange={(v) => setScope(sc.id, "re", { amortization: v })} />
|
|
||||||
<NumberField label="Hypothekarzins (%)" step={0.1} value={assets[sc.id].re.interest} onChange={(v) => setScope(sc.id, "re", { interest: v })} />
|
<NumberField label="Hypothekarzins (%)" step={0.1} value={assets[sc.id].re.interest} onChange={(v) => setScope(sc.id, "re", { interest: v })} />
|
||||||
|
<NumberField label="Wertsteigerung (%/Jahr)" step={0.1} value={assets[sc.id].re.growth} onChange={(v) => setScope(sc.id, "re", { growth: v })} />
|
||||||
|
<label className="col-span-full flex cursor-pointer items-center gap-2 text-xs text-muted">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={assets[sc.id].re.interestIncluded}
|
||||||
|
onChange={(e) => setScope(sc.id, "re", { interestIncluded: e.target.checked })}
|
||||||
|
/>
|
||||||
|
Hypothekarzinsen sind bereits in den Ausgaben enthalten
|
||||||
|
<InfoBubble text="Damit die Zinsen nicht doppelt zählen. Angehakt (Standard): Du hast sie oben schon in den Jahresausgaben berücksichtigt. Nicht angehakt: Das Tool rechnet sie zusätzlich dazu." />
|
||||||
|
</label>
|
||||||
</WizardToggle>
|
</WizardToggle>
|
||||||
)}
|
)}
|
||||||
{sc.cats.includes("debt") && (
|
{sc.cats.includes("debt") && (
|
||||||
<WizardToggle
|
<WizardToggle
|
||||||
label="Schulden / Kredite"
|
label="Schulden / Kredite"
|
||||||
help="Privatkredite, Autoleasing-Restwert und Ähnliches – ohne die Hypothek (die gehört zum Wohneigentum)."
|
help="Privatkredite, Autoleasing-Restwert und Ähnliches – ohne die Hypothek (die gehört zum Wohneigentum). Die jährliche Rückzahlung erfasst du im nächsten Schritt."
|
||||||
checked={assets[sc.id].debt.on}
|
checked={assets[sc.id].debt.on}
|
||||||
onChange={(v) => setScope(sc.id, "debt", { on: v })}
|
onChange={(v) => setScope(sc.id, "debt", { on: v })}
|
||||||
>
|
>
|
||||||
<MoneyField label="Restschuld (CHF)" value={assets[sc.id].debt.value} onChange={(v) => setScope(sc.id, "debt", { value: v })} />
|
<MoneyField label="Restschuld (CHF)" value={assets[sc.id].debt.value} onChange={(v) => setScope(sc.id, "debt", { value: v })} />
|
||||||
<MoneyField label="Rückzahlung pro Jahr (CHF)" value={assets[sc.id].debt.repayment} onChange={(v) => setScope(sc.id, "debt", { repayment: v })} />
|
|
||||||
</WizardToggle>
|
</WizardToggle>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -521,6 +581,118 @@ export function PlanWizard({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{step === 4 && (
|
{step === 4 && (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{/* Sparquote-Karte: das Kernmodell des Tools zum Anfassen. */}
|
||||||
|
<div className={`rounded-xl border p-4 ${sparBase >= 0 ? "border-accent bg-accent-soft/30" : "border-danger bg-danger-soft"}`}>
|
||||||
|
<div className="text-xs font-semibold uppercase tracking-wide text-faint">Deine jährliche Sparquote</div>
|
||||||
|
<div className="mt-1 flex flex-wrap items-baseline gap-x-2 gap-y-1 text-sm">
|
||||||
|
<span className="text-muted">{totalIncome.toLocaleString("de-CH")} CHF Einkommen</span>
|
||||||
|
<span className="text-faint">−</span>
|
||||||
|
<span className="text-muted">{expenses.toLocaleString("de-CH")} CHF Ausgaben</span>
|
||||||
|
<span className="text-faint">=</span>
|
||||||
|
<span className={`text-lg font-bold ${sparBase >= 0 ? "text-fg" : "text-danger"}`}>
|
||||||
|
{sparBase.toLocaleString("de-CH")} CHF / Jahr
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-xs text-muted">
|
||||||
|
{sparBase >= 0
|
||||||
|
? "Dieser Betrag bleibt dir jährlich zum Sparen, Amortisieren oder Anlegen. Verteile ihn unten – was übrig bleibt, wächst auf dem Cash-Konto."
|
||||||
|
: "Du gibst mehr aus, als du einnimmst – die Differenz wird laufend aus deinem Vermögen bzw. Cash gedeckt. Einzahlungen unten sind dann nur sinnvoll, wenn genügend Cash vorhanden ist."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* PK-Einzahlung: bewusst SEPARAT, weil sie vom Bruttolohn kommt und die Sparquote
|
||||||
|
nicht schmälert -- exakt wie der Rechenkern es behandelt. */}
|
||||||
|
{anyPk && (
|
||||||
|
<div className="rounded-xl border border-border p-3">
|
||||||
|
<div className="mb-1 text-xs font-semibold uppercase tracking-wide text-faint">
|
||||||
|
Pensionskassen-Einzahlung
|
||||||
|
</div>
|
||||||
|
<p className="mb-2 text-xs text-muted">
|
||||||
|
Wird direkt vom <strong className="text-fg">Bruttolohn</strong> bezahlt – also <strong className="text-fg">bevor</strong> dein
|
||||||
|
Nettoeinkommen entsteht. Sie schmälert deine Sparquote deshalb <strong className="text-fg">nicht</strong>.
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
|
{renderedScopes.filter((sc) => assets[sc.id].pk.on).map((sc) => (
|
||||||
|
<MoneyField
|
||||||
|
key={sc.id}
|
||||||
|
label={`PK-Einzahlung pro Jahr (CHF)${scopeTag(sc)}`}
|
||||||
|
value={assets[sc.id].pk.contribution}
|
||||||
|
onChange={(v) => setScope(sc.id, "pk", { contribution: v })}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Verteilung der Sparquote auf 3a / Wertschriften / Amortisation / Schuldtilgung. */}
|
||||||
|
{anyQuoteTarget ? (
|
||||||
|
<div className="rounded-xl border border-border p-3">
|
||||||
|
<div className="mb-2 text-xs font-semibold uppercase tracking-wide text-faint">
|
||||||
|
Wohin fliesst deine Sparquote?
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
|
{renderedScopes.map((sc) => (
|
||||||
|
<Fragment key={sc.id}>
|
||||||
|
{assets[sc.id].p3a.on && (
|
||||||
|
<MoneyField
|
||||||
|
label={`Säule 3a: Einzahlung / Jahr (CHF)${scopeTag(sc)}`}
|
||||||
|
help={`Maximal ${max3aFor(sc.id).toLocaleString("de-CH")} CHF${assets[sc.id].p3a.selfEmployed ? " (Selbstständig ohne PK)" : " (mit PK)"}.`}
|
||||||
|
value={assets[sc.id].p3a.contribution}
|
||||||
|
max={max3aFor(sc.id)}
|
||||||
|
onChange={(v) => setScope(sc.id, "p3a", { contribution: v })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{assets[sc.id].etf.on && (
|
||||||
|
<MoneyField
|
||||||
|
label={`Wertschriften: Sparbeitrag / Jahr (CHF)${scopeTag(sc)}`}
|
||||||
|
value={assets[sc.id].etf.contribution}
|
||||||
|
onChange={(v) => setScope(sc.id, "etf", { contribution: v })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{assets[sc.id].re.on && (
|
||||||
|
<MoneyField
|
||||||
|
label={`Wohneigentum: Amortisation / Jahr (CHF)${scopeTag(sc)}`}
|
||||||
|
value={assets[sc.id].re.amortization}
|
||||||
|
onChange={(v) => setScope(sc.id, "re", { amortization: v })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{assets[sc.id].debt.on && (
|
||||||
|
<MoneyField
|
||||||
|
label={`Schulden: Rückzahlung / Jahr (CHF)${scopeTag(sc)}`}
|
||||||
|
value={assets[sc.id].debt.repayment}
|
||||||
|
onChange={(v) => setScope(sc.id, "debt", { repayment: v })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Live-Rest: was nach der Verteilung aufs Cash geht. */}
|
||||||
|
<div className="mt-3 flex items-center justify-between border-t border-border pt-2 text-sm">
|
||||||
|
<span className="text-muted">Rest bleibt auf dem Cash-Konto</span>
|
||||||
|
<span className={`font-semibold ${restToCash < 0 ? "text-danger" : "text-fg"}`}>
|
||||||
|
{restToCash.toLocaleString("de-CH")} CHF / Jahr
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{restToCash < 0 && (
|
||||||
|
<p className="mt-1 text-[11px] text-danger">
|
||||||
|
Du verteilst mehr, als deine Sparquote hergibt – die Differenz muss aus dem Cash-Bestand kommen und kann
|
||||||
|
ihn ins Minus ziehen.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted">
|
||||||
|
Du hast im vorigen Schritt keine Spar-/Vorsorgeposten angehakt, in die deine Sparquote fliessen könnte. Der
|
||||||
|
ganze Betrag wächst dann auf dem Cash-Konto. Zurück im vorigen Schritt kannst du z. B. Wertschriften oder die
|
||||||
|
Säule 3a ergänzen.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 5 && (
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<p className="text-sm text-muted">Das wird angelegt:</p>
|
<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">
|
<ul className="flex flex-col gap-1.5 rounded-xl border border-border bg-surface-2 p-4 text-sm text-fg">
|
||||||
|
|||||||
+19
-1
@@ -53,6 +53,13 @@ export const AHV_FULL_CONTRIBUTION_YEARS = 44;
|
|||||||
// Maximaler jährlicher Säule-3a-Beitrag für 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;
|
||||||
|
|
||||||
|
// Maximaler jährlicher Säule-3a-Beitrag für Selbstständigerwerbende OHNE Pensionskasse
|
||||||
|
// («grosse Säule 3a»): 20 % des Nettoerwerbseinkommens, gedeckelt auf diesen Betrag.
|
||||||
|
// TODO(Verifikation): Wert gegen die aktuellen BSV-Angaben für 2026 prüfen -- 2025 galt
|
||||||
|
// CHF 36'288. Das Tool klammert das Eingabefeld hart auf diesen Deckel; die 20-%-Regel
|
||||||
|
// selbst wird (noch) nicht geprüft.
|
||||||
|
export const PILLAR_3A_MAX_SELF_EMPLOYED = 36288;
|
||||||
|
|
||||||
// Default-Annahmen für Übergangs-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 fälschlich als 0 gerechnet wird.
|
// angetupfter Wert nicht fälschlich als 0 gerechnet wird.
|
||||||
@@ -175,7 +182,7 @@ export const SYSTEM_PARAMETERS: SystemParameter[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "PILLAR_3A_MAX_ANNUAL",
|
key: "PILLAR_3A_MAX_ANNUAL",
|
||||||
label: "Maximaler Säule-3a-Beitrag pro Jahr",
|
label: "Maximaler Säule-3a-Beitrag pro Jahr (mit PK)",
|
||||||
value: PILLAR_3A_MAX_ANNUAL,
|
value: PILLAR_3A_MAX_ANNUAL,
|
||||||
unit: "CHF",
|
unit: "CHF",
|
||||||
meaning: "Höchstbetrag für Personen mit Pensionskasse. Im Eingabefeld hart geklammert.",
|
meaning: "Höchstbetrag für Personen mit Pensionskasse. Im Eingabefeld hart geklammert.",
|
||||||
@@ -183,6 +190,17 @@ export const SYSTEM_PARAMETERS: SystemParameter[] = [
|
|||||||
validFrom: "1.1.2026",
|
validFrom: "1.1.2026",
|
||||||
group: "Vorsorge",
|
group: "Vorsorge",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "PILLAR_3A_MAX_SELF_EMPLOYED",
|
||||||
|
label: "Maximaler Säule-3a-Beitrag pro Jahr (Selbstständige ohne PK)",
|
||||||
|
value: PILLAR_3A_MAX_SELF_EMPLOYED,
|
||||||
|
unit: "CHF",
|
||||||
|
meaning:
|
||||||
|
"«Grosse Säule 3a»: 20 % des Nettoerwerbseinkommens, gedeckelt auf diesen Betrag. Gilt für Selbstständige ohne Pensionskasse. Wert für 2026 noch zu verifizieren (2025: 36'288).",
|
||||||
|
source: 'BSV «Beträge gültig ab 1.1.2026» (zu verifizieren)',
|
||||||
|
validFrom: "1.1.2026",
|
||||||
|
group: "Vorsorge",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: "DEFAULT_PK_CONVERSION_RATE",
|
key: "DEFAULT_PK_CONVERSION_RATE",
|
||||||
label: "Umwandlungssatz Pensionskasse (Vorschlag)",
|
label: "Umwandlungssatz Pensionskasse (Vorschlag)",
|
||||||
|
|||||||
@@ -71,6 +71,8 @@ export interface PhaseData {
|
|||||||
startValue?: number;
|
startValue?: number;
|
||||||
expectedReturn?: number;
|
expectedReturn?: number;
|
||||||
annualContribution?: number;
|
annualContribution?: number;
|
||||||
|
// PILLAR_3A: «grosse Säule 3a» für Selbstständige ohne PK -> höhere Beitrags-Obergrenze.
|
||||||
|
selfEmployed3a?: boolean;
|
||||||
// OTHER_ASSET: jährliche Bezugsrate (Entnahme). Mindert das Vermögen 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): zusätzliche Einlage aus dem
|
// PENSION_FUND / PILLAR_3A / OTHER_ASSET (ab Phase 2): zusätzliche Einlage aus dem
|
||||||
@@ -178,6 +180,7 @@ export const phaseDataSchema = z
|
|||||||
startValue: nonNeg.optional(),
|
startValue: nonNeg.optional(),
|
||||||
expectedReturn: z.number().min(-50).max(100).optional(),
|
expectedReturn: z.number().min(-50).max(100).optional(),
|
||||||
annualContribution: nonNeg.optional(),
|
annualContribution: nonNeg.optional(),
|
||||||
|
selfEmployed3a: z.boolean().optional(),
|
||||||
annualWithdrawal: nonNeg.optional(),
|
annualWithdrawal: nonNeg.optional(),
|
||||||
additionalInvestment: nonNeg.optional(),
|
additionalInvestment: nonNeg.optional(),
|
||||||
purchasePrice: nonNeg.optional(),
|
purchasePrice: nonNeg.optional(),
|
||||||
|
|||||||
Reference in New Issue
Block a user