Modul-Review 2 Feinschliff: Assistent, Tour, 3-Spalten-Ansicht
Deploy App / deploy (push) Successful in 1m8s

Sechs Punkte aus der Testrunde:

Assistent:
- Willkommens-Screen (Gesamtbild der 5 Schritte) + persistente Schritt-Leiste
- Rendite-Feld bei PK/3a/Wertschriften ergaenzt (war fest verdrahtet)
- Schritt 5: Hypothekarzinsen (falls nicht in Ausgaben) korrekt bei den
  Ausgaben dazugerechnet; "noch zu verteilen" prominent nach oben;
  Verteilung nach Gemeinsam/Person A/Person B gruppiert

Tour:
- Spotlight: Rest abgedunkelt (9999px box-shadow), staerkerer Puls
- Karte groesser + springt auf die gegenueberliegende Bildschirmhaelfte
- "Ueberspringen"-Knopf

Ansicht:
- Grundprofil + Zeitachse + Kennzahlen (Endvermoegen nom/real, Ruin) als
  ein kompakter 3-Spalten-Block (25/50/25%) statt zweier breiter Zeilen
- neue Modal-Groesse xwide

Kein Eingriff in den Rechenkern; 267 Tests unveraendert. SPEZIFIKATION 0.29.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-24 22:42:09 +02:00
parent 52c48a9956
commit efcc04c645
6 changed files with 381 additions and 178 deletions
+42 -17
View File
@@ -4,10 +4,10 @@
| | | | | |
|---|---| |---|---|
| **Dokument** | Funktionale und Technische Spezifikation FPT | | **Dokument** | Funktionale und Technische Spezifikation FPT |
| **Version** | 0.28 | | **Version** | 0.29 |
| **Datum** | 2026-07-24 | | **Datum** | 2026-07-24 |
| **Status** | Lebendes Dokument | | **Status** | Lebendes Dokument |
| **Codestand** | Arbeitsstand nach `2bb243b` inkl. Modul-Review 2 (Onboarding) (Branch `main`) | | **Codestand** | Arbeitsstand nach `52c48a9` inkl. Modul-Review 2 (Feinschliff) (Branch `main`) |
| **Ersetzt** | `FDD_TDD_FPT.docx` (v1v5) im Ordner `Info Dateien` diese sind ab Version 0.1 dieses Dokuments obsolet | | **Ersetzt** | `FDD_TDD_FPT.docx` (v1v5) im Ordner `Info Dateien` diese sind ab Version 0.1 dieses Dokuments obsolet |
| **Geltungsbereich** | Gesamter Code im Verzeichnis `FPT` | | **Geltungsbereich** | Gesamter Code im Verzeichnis `FPT` |
@@ -17,6 +17,7 @@
| Version | Datum | Autor | Änderung | | Version | Datum | Autor | Änderung |
|---|---|---|---| |---|---|---|---|
| 0.29 | 2026-07-24 | Claude (Opus 4.8) | **Modul-Review 2, Feinschliff (Assistent, Tour, Ansicht).** (1) **Assistent:** neuer **Willkommens-Screen** vor Schritt 1 mit dem Gesamtbild der fünf Schritte, danach eine **persistente Schritt-Leiste** (links im breiten Modal, mobil als Fortschrittsbalken) der aktuelle Schritt hervorgehoben, erledigte mit Haken, kommende gedämpft. Schritt «Vorsorge & Vermögen» fragt neu auch die **erwartete Rendite** bei PK, 3a und Wertschriften ab (vorher fest verdrahtet). Im Schritt «Sparen & Verteilen» rechnet die Sparquote-Vorschau **Hypothekarzinsen**, die als «noch nicht in den Ausgaben» markiert sind, korrekt zu den Ausgaben dazu (wie der Rechenkern bei `interestHandling: ADD`); der **noch nicht verteilte Rest** steht neu **prominent oben** (zwischen PK und Verteilung), und die Verteilung ist nach **Gemeinsam / Person A / Person B** gruppiert. (2) **Tour** (Kap. 9.24): statt der bisher bewusst schlichten Hervorhebung jetzt ein **Spotlight** der Rest der Ansicht wird abgedunkelt (ein 9999-px-Kastenschatten, kein separates Overlay), der pulsierende Rahmen ist deutlich stärker, die Karte ist grösser und **springt** auf die dem Ziel gegenüberliegende Bildschirmhälfte (verdeckt es nie); neu mit **«Überspringen»**-Knopf. (3) **Szenario-Ansicht** (Kap. 3.7.7): Grundprofil, Zeitachse und die Kennzahlen (Endvermögen nominal + real, Ruinalter) bilden neu **einen kompakten Block** aus drei Spalten (25 / 50 / 25 %) statt zweier über die ganze Breite gezogener Zeilen. Neue Modal-Grösse `xwide`. Kein Eingriff in den Rechenkern; 267 Tests unverändert grün. |
| 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 7258 auf ca. 36288 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.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 7258 auf ca. 36288 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). |
@@ -456,8 +457,19 @@ der die Beitrags-Obergrenze anhebt (siehe 4.11 / Feld `selfEmployed3a`).
**Schritt 5 (Sparen & Verteilen)** bringt das Kernmodell des Tools zum Anfassen: Aus **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, `Nettoeinkommen Ausgaben` entsteht die **Sparquote**; der Nutzer verteilt sie auf Säule 3a,
Wertschriften, Amortisation und Schuldtilgung, und der **Rest bleibt sichtbar auf dem Wertschriften, Amortisation und Schuldtilgung, und der **noch nicht verteilte Rest** steht
Cash-Konto** (live gerechnet, negativer Rest wird gewarnt). Die **PK-Einzahlung** steht bewusst **prominent zwischen PK-Block und Verteilung** und bleibt auf dem Cash-Konto (live gerechnet,
negativer Rest wird gewarnt). Die Verteilung ist nach **Gemeinsam / Person A / Person B**
gruppiert. Hypothekarzinsen, die im vorigen Schritt als «noch nicht in den Ausgaben» markiert
sind, rechnet die Sparquote-Vorschau zu den Ausgaben dazu so wie der Rechenkern bei
`interestHandling: ADD`.
**Vor Schritt 1** steht ein **Willkommens-Screen** mit dem Gesamtbild der fünf Schritte (Icon,
Titel, ein Satz je Schritt); danach begleitet eine **persistente Schritt-Leiste** den ganzen
Ablauf (links im breiten Modal, auf schmalen Screens als Fortschrittsbalken): aktueller Schritt
hervorgehoben mit Kurzbeschreibung, erledigte mit Haken, kommende gedämpft. Der Nutzer weiss so
jederzeit, wo er steht und was noch folgt. Die **Zusammenfassung** ist der Abschluss und trägt
keine eigene Schritt-Nummer. Die **PK-Einzahlung** steht bewusst
in einem **eigenen** Block mit dem Hinweis, dass sie vom **Bruttolohn** bezahlt wird also 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 **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 mit dem Rechenkern, wo der PK-Beitrag nicht zur Quote zählt ([4.6.3](#463-pension_fund)). Der
@@ -1291,11 +1303,15 @@ 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 **Aufbau der Szenario-Ansicht (seit 0.28/0.29):** von oben nach unten eine schlanke
**Funktions-Leiste** (Änderungshistorie · Tour · Neues Szenario aus diesem · Rechenwege · **Funktions-Leiste** (Änderungshistorie · Tour · Neues Szenario aus diesem · Rechenwege ·
CSV-Export · Szenario löschen · Abweichungs-Badge), dann **Nächste Schritte**, das CSV-Export · Szenario löschen · Abweichungs-Badge), dann **Nächste Schritte**, danach ein
**Grundprofil**, die **Zeitachse**, der **Anzeige-Umschalter** (Nominal/Beide/Real, Plan/Ist) **kompakter Block aus drei Spalten** **Grundprofil** (25 %, Angaben untereinander,
und zuunterst die **Matrix** mit ihren «Element»-/«Lebensphase»-Knöpfen. Die Analyse-Werkzeuge «Bearbeiten» oben rechts), **Zeitachse** (50 %) und **Kennzahlen** (25 %: Endvermögen nominal
und real, bei Ruin das Ruinalter) , dann der **Anzeige-Umschalter** (Nominal/Beide/Real,
Plan/Ist) und zuunterst die **Matrix** mit ihren «Element»-/«Lebensphase»-Knöpfen. Der
Drei-Spalten-Block ersetzt das früher über die ganze Breite gezogene Grundprofil, das auf
breiten Bildschirmen unnötig lang wirkte. Auf schmalen Screens stapeln die drei Spalten. Die Analyse-Werkzeuge
(Grafiken, Live-Simulation, Monte-Carlo, Einflussfaktoren) und die Effektiven Werte sind hier (Grafiken, Live-Simulation, Monte-Carlo, Einflussfaktoren) und die Effektiven Werte sind hier
bewusst **nicht** mehr verlinkt sie laufen ausschliesslich über ihre eigenen Menüpunkte 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 ([3.10](#310-navigation-auf-plan-ebene-und-gespeicherte-analysen)), damit es je Funktion genau
@@ -1304,9 +1320,11 @@ einen Ort gibt.
### 3.7.8 Tour und «Nächste Schritte» ### 3.7.8 Tour und «Nächste Schritte»
Die **Tour** (`Tour.tsx`) führt in bis zu sechs Schritten über Profil, Zeitachse, Matrix, Die **Tour** (`Tour.tsx`) 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 Übergänge, Cash und Analysen als **Spotlight**: das jeweilige Ziel bleibt hell, der Rest wird
(`data-tour`-Attribute). Schritte ohne vorhandenes Ziel werden übersprungen; der «Tour»-Knopf abgedunkelt, dazu ein pulsierender Rahmen (`data-tour`-Attribute); die Karte springt auf die
in der oberen Funktions-Leiste startet sie jederzeit neu. Grenze: 9.24. gegenüberliegende Bildschirmhälfte und lässt sich überspringen (Umsetzung und Grenze: 9.24).
Schritte ohne vorhandenes Ziel werden übersprungen; der «Tour»-Knopf in der oberen
Funktions-Leiste startet sie jederzeit neu.
Sie liegt seit 0.28 in `AppShell` (nicht mehr in `PlanView`), weil dort die Plan-Erstellung 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, zusammenläuft, und hat **zwei Auslöser**: Nach **jeder** Plan-Erstellung (Assistent,
@@ -4210,13 +4228,20 @@ auch aber es gibt kein automatisches Rollback. Das wäre nur mit Backend-Unt
(Transaktion über mehrere Requests oder Batch-Endpunkt) sauber lösbar und ist bewusst nicht (Transaktion über mehrere Requests oder Batch-Endpunkt) sauber lösbar und ist bewusst nicht
gebaut: Der seltene Fehlerfall rechtfertigt keinen neuen Endpunkt. gebaut: Der seltene Fehlerfall rechtfertigt keinen neuen Endpunkt.
## 9.24 Tour ohne Spotlight-Engine ## 9.24 Tour: Spotlight ohne Engine
Die Tour hebt ihr Ziel per Rahmen-Puls und `scrollIntoView` hervor bewusst ohne Seit 0.29 hebt die Tour ihr Ziel als **Spotlight** hervor: Der Rest der Ansicht wird
Spotlight-Overlay und Positionierungs-Engine (die Karte sitzt fix unten). Bei stark abgedunkelt, das Ziel bleibt hell und trägt einen kräftig pulsierenden Rahmen. Der Trick ist
verschachtelten Scroll-Situationen kann das Ziel teilweise verdeckt sein. Der Einfachheit bewusst **ohne Positionierungs-Engine und ohne separates Overlay** umgesetzt ein einziger
halber in Kauf genommen; eine echte Coach-Mark-Bibliothek wäre der nächste Schritt, wenn die CSS-Kastenschatten mit riesigem Radius (`box-shadow: 0 0 0 9999px …`) dunkelt alles **um** das
Tour sich bewährt. Element herum ab, ohne es selbst zu berühren (keine Transparenz-Probleme). Die Karte springt auf
die dem Ziel **gegenüberliegende** Bildschirmhälfte (oben/unten), damit sie es nie verdeckt, und
lässt sich jederzeit **überspringen**.
Grenze: Liegt das Ziel in einem Container mit `overflow: hidden` (z. B. der Matrix-Scrollbereich),
wird der Abdunkel-Schatten an dessen Rand geklippt der Spotlight wirkt dann nur innerhalb dieses
Bereichs. Für die heutigen Ziele ist das akzeptabel; eine echte Coach-Mark-Bibliothek mit
Viewport-genauem Cutout wäre der nächste Schritt, wenn die Tour sich bewährt.
## 9.25 Die Quote ist kein fester Betrag ## 9.25 Die Quote ist kein fester Betrag
+12 -4
View File
@@ -207,15 +207,23 @@ body {
} }
/* Hervorhebung des aktuellen Tour-Ziels. */ /* Hervorhebung des aktuellen Tour-Ziels. */
/* Spotlight: Der riesige (9999px) erste Schatten dunkelt ALLES um das Element herum ab, das
Element selbst bleibt hell (der Schatten liegt ausserhalb). Der zweite Schatten ist der
pulsierende Akzent-Rahmen. So braucht es kein separates Overlay, und es gibt keine
Transparenz-Probleme. */
@keyframes tour-pulse-kf { @keyframes tour-pulse-kf {
0%, 100% { box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 45%, transparent); } 0%, 100% {
50% { box-shadow: 0 0 0 7px color-mix(in srgb, var(--accent) 20%, transparent); } box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.55), 0 0 0 3px color-mix(in srgb, var(--accent) 85%, transparent);
}
50% {
box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.55), 0 0 0 9px color-mix(in srgb, var(--accent) 45%, transparent);
}
} }
.tour-highlight { .tour-highlight {
position: relative; position: relative;
z-index: 35; z-index: 40;
border-radius: 0.75rem; border-radius: 0.75rem;
animation: tour-pulse-kf 1.6s ease-in-out infinite; animation: tour-pulse-kf 1.4s ease-in-out infinite;
} }
/* ------------------------------------------------------------------------- /* -------------------------------------------------------------------------
+63 -31
View File
@@ -343,6 +343,7 @@ export function PlanView({
const hasPhases = computed.phases.length > 0; const hasPhases = computed.phases.length > 0;
const firstPhase = computed.phases[0] ?? null; const firstPhase = computed.phases[0] ?? null;
const lastPhase = computed.phases[computed.phases.length - 1] ?? null;
return ( return (
<div className="flex flex-col gap-5"> <div className="flex flex-col gap-5">
@@ -360,39 +361,70 @@ export function PlanView({
onOpenSensitivity={onOpenSensitivity} onOpenSensitivity={onOpenSensitivity}
/> />
{/* Szenario-Profil */} {/* Kompakter Klotz: Grundprofil (25 %) · Zeitachse (50 %) · Kennzahlen (25 %). Auf
<div schmalen Screens stapeln die drei. */}
data-tour="profil" <div className="grid grid-cols-1 gap-4 lg:grid-cols-4">
className={`flex flex-wrap items-center gap-3 rounded-xl border px-4 py-3 text-sm shadow-sm ${ {/* Grundprofil -- Daten untereinander, «Bearbeiten» oben rechts. */}
diff?.profileChanged ? "border-diff bg-diff-soft" : "border-border bg-surface" <div
}`} data-tour="profil"
> className={`flex flex-col gap-2 rounded-xl border px-4 py-3 shadow-sm lg:col-span-1 ${
<span className="text-xs font-semibold uppercase tracking-wide text-faint"> diff?.profileChanged ? "border-diff bg-diff-soft" : "border-border bg-surface"
Grundprofil (Szenario){diff?.profileChanged ? " · abweichend" : ""} }`}
</span>
{plan.persons.map((p) => (
<span key={p.role} className="text-xs text-muted">
{personLabel(p.role)}: {p.age} J., Pension {p.retirementAge}
</span>
))}
<span className="text-xs text-muted">Inflation {plan.inflationRateDefault}%</span>
<button
type="button"
onClick={() => setPanel({ kind: "profile" })}
className="ml-auto flex items-center gap-1.5 rounded-lg border border-border px-2.5 py-1 text-xs font-medium text-muted hover:bg-surface-2"
> >
<Settings2 className="h-3.5 w-3.5" /> Profil bearbeiten <div className="flex items-start justify-between gap-2">
</button> <span className="text-xs font-semibold uppercase tracking-wide text-faint">
</div> Grundprofil{diff?.profileChanged ? " · abweichend" : ""}
</span>
<button
type="button"
onClick={() => setPanel({ kind: "profile" })}
className="flex shrink-0 items-center gap-1 rounded-lg border border-border px-2 py-0.5 text-xs font-medium text-muted hover:bg-surface-2"
>
<Settings2 className="h-3.5 w-3.5" /> Bearbeiten
</button>
</div>
<div className="flex flex-col gap-1">
{plan.persons.map((p) => (
<span key={p.role} className="text-xs text-muted">
{personLabel(p.role)}: {p.age} J., Pension {p.retirementAge}
</span>
))}
<span className="text-xs text-muted">Inflation {plan.inflationRateDefault}%</span>
</div>
</div>
<div data-tour="timeline"> {/* Zeitachse */}
<Timeline <div data-tour="timeline" className="lg:col-span-2">
phases={computed.phases} <Timeline
persons={personAxes} phases={computed.phases}
ruinAge={computed.ruinAge} persons={personAxes}
actualYears={actualYears} ruinAge={computed.ruinAge}
startYear={plan.startYear} actualYears={actualYears}
/> startYear={plan.startYear}
/>
</div>
{/* Kennzahlen: Endvermögen nominal + real (+ Ruin, falls vorhanden). */}
<div className="flex flex-col justify-center gap-2 rounded-xl border border-border bg-surface px-4 py-3 shadow-sm lg:col-span-1">
<span className="text-xs font-semibold uppercase tracking-wide text-faint">Endvermögen</span>
<div>
<div className="text-[11px] text-muted">Nominal</div>
<div className="text-lg font-bold text-fg">
{lastPhase ? formatChf(Math.round(lastPhase.endWealthNominal)) : "—"}
</div>
</div>
<div>
<div className="text-[11px] text-muted">Real (heutige Kaufkraft)</div>
<div className="text-lg font-bold text-fg">
{lastPhase ? formatChf(Math.round(lastPhase.endWealthReal)) : "—"}
</div>
</div>
{computed.ruinAge !== null && (
<div className="mt-0.5 rounded-lg bg-danger-soft px-2 py-1 text-xs font-medium text-danger">
Kapital reicht nur bis Alter {computed.ruinAge}
</div>
)}
</div>
</div> </div>
{/* Anzeige-Umschalter (nominal/real, Plan/Ist) -- direkt über der Matrix, weil er nur {/* Anzeige-Umschalter (nominal/real, Plan/Ist) -- direkt über der Matrix, weil er nur
+216 -94
View File
@@ -16,8 +16,8 @@
// 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 { Fragment, useMemo, useRef, useState } from "react"; import { useMemo, useRef, useState } from "react";
import { ArrowLeft, ArrowRight, Flag, Plus, Sparkles, Trash2 } from "lucide-react"; import { ArrowLeft, ArrowRight, CalendarRange, Check, Coins, Flag, PiggyBank, Plus, Sparkles, Trash2, User, Wallet } 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";
import { InfoBubble } from "@/components/InfoBubble"; import { InfoBubble } from "@/components/InfoBubble";
@@ -41,6 +41,16 @@ const STEP_TITLES = [
"Fertig", "Fertig",
]; ];
// Die fünf «Tu-was»-Schritte für den Willkommens-Screen und die persistente Schritt-Leiste.
// «Fertig» (Zusammenfassung) ist der Abschluss und trägt keine eigene Nummer.
const WIZARD_OVERVIEW: { icon: React.ComponentType<{ className?: string }>; title: string; desc: string }[] = [
{ icon: User, title: "Über dich", desc: "Haushalt, Alter und Pensionsalter." },
{ icon: CalendarRange, title: "Lebensphasen", desc: "Die Abschnitte deiner Planung bis zur Pension und danach." },
{ icon: Wallet, title: "Einkommen & Ausgaben", desc: "Was reinkommt, was rausgeht, was auf dem Konto liegt." },
{ icon: PiggyBank, title: "Vorsorge & Vermögen", desc: "Was du heute besitzt PK, 3a, Wertschriften, Wohneigentum." },
{ icon: Coins, title: "Sparen & Verteilen", desc: "Deine Sparquote auf die Sparziele aufteilen." },
];
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: {
label: "Erwerb", label: "Erwerb",
@@ -86,10 +96,10 @@ type Scope = "SELF" | "HOUSEHOLD" | "PERSON_A" | "PERSON_B";
type Category = "pk" | "p3a" | "etf" | "re" | "debt"; 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; ret: number };
// selfEmployed: «grosse Säule 3a» ohne PK -> höhere Obergrenze (Roadmap-Feedback C7c). // selfEmployed: «grosse Säule 3a» ohne PK -> höhere Obergrenze (Roadmap-Feedback C7c).
p3a: { on: boolean; value: number; contribution: number; selfEmployed: boolean }; p3a: { on: boolean; value: number; contribution: number; selfEmployed: boolean; ret: number };
etf: { on: boolean; value: number; contribution: number }; etf: { on: boolean; value: number; contribution: number; ret: number };
// growth = Wertsteigerung %/Jahr; interestIncluded = Zinsen bereits in den Ausgaben. // growth = Wertsteigerung %/Jahr; interestIncluded = Zinsen bereits in den Ausgaben.
re: { on: boolean; price: number; mortgage: number; amortization: number; interest: number; growth: number; interestIncluded: boolean }; 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 };
@@ -97,9 +107,9 @@ interface ScopeAssets {
function emptyScope(): ScopeAssets { function emptyScope(): ScopeAssets {
return { return {
pk: { on: false, value: 0, contribution: 0 }, pk: { on: false, value: 0, contribution: 0, ret: 1.5 },
p3a: { on: false, value: 0, contribution: 0, selfEmployed: false }, p3a: { on: false, value: 0, contribution: 0, selfEmployed: false, ret: 2 },
etf: { on: false, value: 0, contribution: 0 }, etf: { on: false, value: 0, contribution: 0, ret: 5 },
re: { on: false, price: 0, mortgage: 0, amortization: 0, interest: 1.5, growth: 1, interestIncluded: true }, 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 },
}; };
@@ -112,6 +122,7 @@ export function PlanWizard({
onCreated: (scenarioId: string) => void; onCreated: (scenarioId: string) => void;
onClose: () => void; onClose: () => void;
}) { }) {
const [started, setStarted] = useState(false);
const [step, setStep] = useState(0); const [step, setStep] = useState(0);
const [planName, setPlanName] = useState("Meine Planung"); const [planName, setPlanName] = useState("Meine Planung");
const [profile, setProfile] = useState<ProfileDraft>(emptyProfileDraft); const [profile, setProfile] = useState<ProfileDraft>(emptyProfileDraft);
@@ -245,14 +256,14 @@ export function PlanWizard({
await addEl("PENSION_FUND", `Pensionskasse${suffix}`, scope.vorsorgeRole!, { await addEl("PENSION_FUND", `Pensionskasse${suffix}`, scope.vorsorgeRole!, {
currentValue: a.pk.value, currentValue: a.pk.value,
annualContribution: a.pk.contribution, annualContribution: a.pk.contribution,
expectedReturn: 1.5, expectedReturn: a.pk.ret,
}); });
} }
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, max3aFor(scope.id)), annualContribution: Math.min(a.p3a.contribution, max3aFor(scope.id)),
expectedReturn: 2, expectedReturn: a.p3a.ret,
selfEmployed3a: a.p3a.selfEmployed, selfEmployed3a: a.p3a.selfEmployed,
}); });
} }
@@ -261,7 +272,7 @@ export function PlanWizard({
await addEl("OTHER_ASSET", `Wertschriften${suffix}`, scope.otherRole, { await addEl("OTHER_ASSET", `Wertschriften${suffix}`, scope.otherRole, {
startValue: a.etf.value, startValue: a.etf.value,
annualContribution: a.etf.contribution, annualContribution: a.etf.contribution,
expectedReturn: 5, expectedReturn: a.etf.ret,
}); });
} }
if (a.re.on) { if (a.re.on) {
@@ -324,7 +335,15 @@ export function PlanWizard({
// Die Sparquote entsteht aus Nettoeinkommen minus Ausgaben. PK-Beiträge kommen VOM // 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. // 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 totalIncome = profile.persons.reduce((s, p) => s + (incomes[p.role] ?? 0), 0);
const sparBase = totalIncome - expenses; // Hypothekarzinsen, die NICHT bereits in den Ausgaben stecken, werden hier den Ausgaben
// zugerechnet -- so wie sie der Rechenkern bei "interestHandling: ADD" behandelt. Sonst
// zeigte die Vorschau eine zu hohe Sparquote.
const addedInterest = renderedScopes.reduce((sum, sc) => {
const re = assets[sc.id].re;
return sum + (re.on && !re.interestIncluded ? Math.round((re.mortgage * re.interest) / 100) : 0);
}, 0);
const effectiveExpenses = expenses + addedInterest;
const sparBase = totalIncome - effectiveExpenses;
const allocatedFromQuote = renderedScopes.reduce((sum, sc) => { const allocatedFromQuote = renderedScopes.reduce((sum, sc) => {
const a = assets[sc.id]; const a = assets[sc.id];
return ( return (
@@ -344,20 +363,102 @@ export function PlanWizard({
const scopeTag = (sc: { label: string }) => (isCouple ? ` · ${sc.label}` : ""); 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); const max3aFor = (sc: Scope) => (assets[sc].p3a.selfEmployed ? PILLAR_3A_MAX_SELF_EMPLOYED : PILLAR_3A_MAX_ANNUAL);
// Willkommens-Screen vor dem ersten Schritt: das Gesamtbild der fünf Schritte, damit der
// Nutzer von Anfang an weiss, was auf ihn zukommt (Roadmap-Feedback).
if (!started) {
return (
<Modal title="Plan erstellen geführt" subtitle="In 5 Schritten zu deinem Finanzplan" onClose={onClose} xwide>
<div className="flex flex-col gap-4">
<p className="text-sm text-muted">
Erstelle deinen Finanzplan in fünf einfachen Schritten das dauert nur ein paar Minuten, und alles lässt
sich später jederzeit ändern.
</p>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
{WIZARD_OVERVIEW.map((s, i) => (
<div key={i} className="flex items-start gap-3 rounded-xl border border-border bg-surface-2 p-3">
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-accent-soft text-accent-soft-fg">
<s.icon className="h-4 w-4" />
</span>
<div className="min-w-0">
<div className="text-sm font-semibold text-fg">
{i + 1}. {s.title}
</div>
<p className="text-xs text-muted">{s.desc}</p>
</div>
</div>
))}
</div>
<div className="flex items-center justify-between border-t border-border pt-3">
<Button variant="ghost" size="sm" onClick={onClose}>
Abbrechen
</Button>
<Button onClick={() => setStarted(true)}>
Los gehts <ArrowRight className="h-4 w-4" />
</Button>
</div>
</div>
</Modal>
);
}
return ( return (
<Modal <Modal
title="Plan erstellen geführt" title="Plan erstellen geführt"
subtitle={`Schritt ${step + 1} von ${STEP_TITLES.length}: ${STEP_TITLES[step]}`} subtitle={step < 5 ? `Schritt ${step + 1} von 5: ${STEP_TITLES[step]}` : "Zusammenfassung"}
onClose={onClose} onClose={onClose}
wide xwide
> >
<div className="flex gap-1"> <div className="flex gap-5">
{STEP_TITLES.map((_, i) => ( {/* Persistente Schritt-Leiste (Desktop): der Nutzer sieht immer, wo er steht und was folgt. */}
<div key={i} className={`h-1 flex-1 rounded-full ${i <= step ? "bg-accent" : "bg-surface-2"}`} /> <nav className="hidden w-48 shrink-0 flex-col gap-1 sm:flex">
))} {WIZARD_OVERVIEW.map((s, i) => {
</div> const state = step > i ? "done" : step === i ? "current" : "todo";
return (
<div
key={i}
className={`flex items-start gap-2 rounded-lg px-2 py-1.5 text-xs ${
state === "current" ? "bg-accent-soft text-accent-soft-fg" : "text-muted"
}`}
>
<span
className={`mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-[10px] font-bold ${
state === "done"
? "bg-accent text-accent-fg"
: state === "current"
? "border border-accent-soft-fg text-accent-soft-fg"
: "border border-border text-faint"
}`}
>
{state === "done" ? <Check className="h-3 w-3" /> : i + 1}
</span>
<div className="min-w-0">
<div className={`font-semibold ${state === "todo" ? "text-faint" : ""}`}>{s.title}</div>
{state === "current" && <p className="mt-0.5 leading-snug">{s.desc}</p>}
</div>
</div>
);
})}
<div className="mt-1 flex items-center gap-2 rounded-lg px-2 py-1.5 text-xs">
<span
className={`flex h-5 w-5 shrink-0 items-center justify-center rounded-full ${
step === 5 ? "bg-accent text-accent-fg" : "border border-border text-faint"
}`}
>
<Flag className="h-3 w-3" />
</span>
<span className={step === 5 ? "font-semibold text-fg" : "text-faint"}>Fertig</span>
</div>
</nav>
{step === 0 && ( <div className="flex min-w-0 flex-1 flex-col gap-4">
{/* Fortschritt (Mobile, wo die Leiste ausgeblendet ist). */}
<div className="flex gap-1 sm:hidden">
{WIZARD_OVERVIEW.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"> <div className="flex flex-col gap-4">
<p className="text-sm text-muted"> <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. Ein paar Angaben zu dir daraus baut das Tool dein Grundgerüst. Alles lässt sich später ändern.
@@ -507,6 +608,7 @@ export function PlanWizard({
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 })} />
<NumberField label="Erwartete Rendite (%/Jahr)" step={0.1} value={assets[sc.id].pk.ret} onChange={(v) => setScope(sc.id, "pk", { ret: v })} />
</WizardToggle> </WizardToggle>
)} )}
{sc.cats.includes("p3a") && ( {sc.cats.includes("p3a") && (
@@ -517,6 +619,7 @@ export function PlanWizard({
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 })} />
<NumberField label="Erwartete Rendite (%/Jahr)" step={0.1} value={assets[sc.id].p3a.ret} onChange={(v) => setScope(sc.id, "p3a", { ret: v })} />
<label className="col-span-full flex cursor-pointer items-center gap-2 text-xs text-muted"> <label className="col-span-full flex cursor-pointer items-center gap-2 text-xs text-muted">
<input <input
type="checkbox" type="checkbox"
@@ -536,6 +639,7 @@ export function PlanWizard({
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 })} />
<NumberField label="Erwartete Rendite (%/Jahr)" step={0.1} value={assets[sc.id].etf.ret} onChange={(v) => setScope(sc.id, "etf", { ret: v })} />
</WizardToggle> </WizardToggle>
)} )}
{sc.cats.includes("re") && ( {sc.cats.includes("re") && (
@@ -588,12 +692,18 @@ export function PlanWizard({
<div className="mt-1 flex flex-wrap items-baseline gap-x-2 gap-y-1 text-sm"> <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-muted">{totalIncome.toLocaleString("de-CH")} CHF Einkommen</span>
<span className="text-faint"></span> <span className="text-faint"></span>
<span className="text-muted">{expenses.toLocaleString("de-CH")} CHF Ausgaben</span> <span className="text-muted">{effectiveExpenses.toLocaleString("de-CH")} CHF Ausgaben</span>
<span className="text-faint">=</span> <span className="text-faint">=</span>
<span className={`text-lg font-bold ${sparBase >= 0 ? "text-fg" : "text-danger"}`}> <span className={`text-lg font-bold ${sparBase >= 0 ? "text-fg" : "text-danger"}`}>
{sparBase.toLocaleString("de-CH")} CHF / Jahr {sparBase.toLocaleString("de-CH")} CHF / Jahr
</span> </span>
</div> </div>
{addedInterest > 0 && (
<p className="mt-1 text-[11px] text-faint">
Enthält {addedInterest.toLocaleString("de-CH")} CHF Hypothekarzinsen, die du im vorigen Schritt als «noch
nicht in den Ausgaben» markiert hast.
</p>
)}
<p className="mt-1 text-xs text-muted"> <p className="mt-1 text-xs text-muted">
{sparBase >= 0 {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." ? "Dieser Betrag bleibt dir jährlich zum Sparen, Amortisieren oder Anlegen. Verteile ihn unten was übrig bleibt, wächst auf dem Cash-Konto."
@@ -625,62 +735,73 @@ export function PlanWizard({
</div> </div>
)} )}
{/* Verteilung der Sparquote auf 3a / Wertschriften / Amortisation / Schuldtilgung. */} {/* "Noch zu verteilen" prominent -- direkt unter der PK, ueber der Verteilung. */}
{anyQuoteTarget ? ( {anyQuoteTarget && (
<div className="rounded-xl border border-border p-3"> <div
<div className="mb-2 text-xs font-semibold uppercase tracking-wide text-faint"> className={`flex items-center justify-between rounded-xl border px-4 py-2.5 text-sm ${
Wohin fliesst deine Sparquote? restToCash < 0 ? "border-danger bg-danger-soft" : "border-border bg-surface-2"
</div> }`}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2"> >
{renderedScopes.map((sc) => ( <span className="font-medium text-fg">Noch nicht verteilt (bleibt auf dem Cash-Konto)</span>
<Fragment key={sc.id}> <span className={`text-base font-bold ${restToCash < 0 ? "text-danger" : "text-fg"}`}>
{assets[sc.id].p3a.on && ( {restToCash.toLocaleString("de-CH")} CHF / Jahr
<MoneyField </span>
label={`Säule 3a: Einzahlung / Jahr (CHF)${scopeTag(sc)}`} </div>
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} {anyQuoteTarget && restToCash < 0 && (
max={max3aFor(sc.id)} <p className="-mt-2 text-[11px] text-danger">
onChange={(v) => setScope(sc.id, "p3a", { contribution: v })} Du verteilst mehr, als deine Sparquote hergibt die Differenz muss aus dem Cash-Bestand kommen und kann ihn
/> ins Minus ziehen.
)} </p>
{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. */} {/* Verteilung der Sparquote -- gruppiert nach Eigentuemer-Bereich (Gemeinsam/A/B). */}
<div className="mt-3 flex items-center justify-between border-t border-border pt-2 text-sm"> {anyQuoteTarget ? (
<span className="text-muted">Rest bleibt auf dem Cash-Konto</span> <div className="flex flex-col gap-3">
<span className={`font-semibold ${restToCash < 0 ? "text-danger" : "text-fg"}`}> {renderedScopes
{restToCash.toLocaleString("de-CH")} CHF / Jahr .filter((sc) => {
</span> const a = assets[sc.id];
</div> return a.p3a.on || a.etf.on || a.re.on || a.debt.on;
{restToCash < 0 && ( })
<p className="mt-1 text-[11px] text-danger"> .map((sc) => (
Du verteilst mehr, als deine Sparquote hergibt die Differenz muss aus dem Cash-Bestand kommen und kann <div key={sc.id} className="rounded-xl border border-border p-3">
ihn ins Minus ziehen. <div className="mb-2 text-xs font-semibold uppercase tracking-wide text-faint">
</p> {isCouple ? sc.label : "Wohin fliesst deine Sparquote?"}
)} </div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{assets[sc.id].p3a.on && (
<MoneyField
label="Säule 3a: Einzahlung / Jahr (CHF)"
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)"
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)"
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)"
value={assets[sc.id].debt.repayment}
onChange={(v) => setScope(sc.id, "debt", { repayment: v })}
/>
)}
</div>
</div>
))}
</div> </div>
) : ( ) : (
<p className="text-sm text-muted"> <p className="text-sm text-muted">
@@ -714,25 +835,26 @@ export function PlanWizard({
{error && <p className="text-sm text-danger">{error}</p>} {error && <p className="text-sm text-danger">{error}</p>}
<div className="flex items-center justify-between border-t border-border pt-3"> <div className="flex items-center justify-between border-t border-border pt-3">
<Button variant="ghost" size="sm" onClick={onClose}> <Button variant="ghost" size="sm" onClick={onClose}>
Abbrechen 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> </Button>
)} <div className="flex gap-2">
{step < STEP_TITLES.length - 1 ? ( {/* Zurück führt in Schritt 0 auf den Willkommens-Screen. */}
<Button onClick={goNext} disabled={step === 1 && !step1Valid}> <Button variant="secondary" onClick={() => (step > 0 ? setStep(step - 1) : setStarted(false))} disabled={saving}>
Weiter <ArrowRight className="h-4 w-4" /> <ArrowLeft className="h-4 w-4" /> Zurück
</Button> </Button>
) : ( {step < STEP_TITLES.length - 1 ? (
<Button onClick={create} disabled={saving}> <Button onClick={goNext} disabled={step === 1 && !step1Valid}>
<Sparkles className="h-4 w-4" /> {saving ? "Wird angelegt…" : "Plan erstellen"} Weiter <ArrowRight className="h-4 w-4" />
</Button> </Button>
)} ) : (
<Button onClick={create} disabled={saving}>
<Sparkles className="h-4 w-4" /> {saving ? "Wird angelegt…" : "Plan erstellen"}
</Button>
)}
</div>
</div>
</div> </div>
</div> </div>
</Modal> </Modal>
+44 -31
View File
@@ -58,6 +58,9 @@ export function Tour({ onClose }: { onClose: () => void }) {
[] []
); );
const [index, setIndex] = useState(0); const [index, setIndex] = useState(0);
// Die Karte springt auf die dem Ziel GEGENÜBERliegende Bildschirmhälfte, damit sie das
// hervorgehobene Element nie verdeckt.
const [cardAtBottom, setCardAtBottom] = useState(true);
const step = steps[index]; const step = steps[index];
useEffect(() => { useEffect(() => {
@@ -66,6 +69,9 @@ export function Tour({ onClose }: { onClose: () => void }) {
if (!el) return; if (!el) return;
el.classList.add("tour-highlight"); el.classList.add("tour-highlight");
el.scrollIntoView({ block: "center", behavior: "smooth" }); el.scrollIntoView({ block: "center", behavior: "smooth" });
const rect = el.getBoundingClientRect();
// eslint-disable-next-line react-hooks/set-state-in-effect -- Position aus der DOM-Lage des Ziels
setCardAtBottom(rect.top + rect.height / 2 < window.innerHeight * 0.5);
return () => el.classList.remove("tour-highlight"); return () => el.classList.remove("tour-highlight");
}, [step]); }, [step]);
@@ -81,15 +87,20 @@ export function Tour({ onClose }: { onClose: () => void }) {
} }
return ( 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
key={index}
className={`ui-pop fixed left-1/2 z-50 w-[32rem] max-w-[calc(100vw-2rem)] -translate-x-1/2 rounded-2xl border-2 border-accent bg-surface p-5 shadow-2xl ${
cardAtBottom ? "bottom-8" : "top-8"
}`}
>
<div className="flex items-start justify-between gap-2"> <div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2.5">
<span className="flex h-7 w-7 items-center justify-center rounded-lg bg-accent-soft text-accent-soft-fg"> <span className="flex h-9 w-9 items-center justify-center rounded-xl bg-accent text-accent-fg shadow-sm">
<Lightbulb className="h-4 w-4" /> <Lightbulb className="h-5 w-5" />
</span> </span>
<div> <div>
<div className="text-sm font-semibold text-fg">{step.title}</div> <div className="text-base font-semibold text-fg">{step.title}</div>
<div className="text-[11px] text-faint"> <div className="text-[11px] font-medium uppercase tracking-wide text-accent">
Schritt {index + 1} von {steps.length} Schritt {index + 1} von {steps.length}
</div> </div>
</div> </div>
@@ -103,31 +114,33 @@ export function Tour({ onClose }: { onClose: () => void }) {
<X className="h-4 w-4" /> <X className="h-4 w-4" />
</button> </button>
</div> </div>
<p className="mt-2 text-sm leading-relaxed text-muted">{step.text}</p> <p className="mt-3 text-sm leading-relaxed text-muted">{step.text}</p>
<div className="mt-3 flex items-center justify-between"> <div className="mt-4 flex items-center justify-between gap-2">
<div className="flex gap-1"> <button type="button" onClick={finish} className="text-xs font-medium text-faint hover:text-muted">
{steps.map((_, i) => ( Überspringen
<span </button>
key={i} <div className="flex items-center gap-3">
className={`h-1.5 w-1.5 rounded-full ${i === index ? "bg-accent" : "bg-border-strong"}`} <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"> </div>
{index > 0 && ( <div className="flex gap-2">
<Button variant="secondary" size="sm" onClick={() => setIndex(index - 1)}> {index > 0 && (
Zurück <Button variant="secondary" size="sm" onClick={() => setIndex(index - 1)}>
</Button> Zurück
)} </Button>
{index < steps.length - 1 ? ( )}
<Button size="sm" onClick={() => setIndex(index + 1)}> {index < steps.length - 1 ? (
Weiter <Button size="sm" onClick={() => setIndex(index + 1)}>
</Button> Weiter
) : ( </Button>
<Button size="sm" onClick={finish}> ) : (
Fertig <Button size="sm" onClick={finish}>
</Button> Fertig
)} </Button>
)}
</div>
</div> </div>
</div> </div>
</div> </div>
+4 -1
View File
@@ -128,16 +128,19 @@ export function Modal({
onClose, onClose,
children, children,
wide, wide,
xwide,
}: { }: {
title: string; title: string;
subtitle?: string; subtitle?: string;
onClose: () => void; onClose: () => void;
children: React.ReactNode; children: React.ReactNode;
wide?: boolean; wide?: boolean;
xwide?: boolean;
}) { }) {
const panelRef = useRef<HTMLDivElement | null>(null); const panelRef = useRef<HTMLDivElement | null>(null);
useEscClose(onClose); useEscClose(onClose);
useFocusTrap(panelRef); useFocusTrap(panelRef);
const maxWidth = xwide ? "max-w-4xl" : wide ? "max-w-2xl" : "max-w-md";
return ( return (
<div <div
className="ui-fade fixed inset-0 z-40 flex items-start justify-center overflow-y-auto bg-black/40 px-4 py-8" className="ui-fade fixed inset-0 z-40 flex items-start justify-center overflow-y-auto bg-black/40 px-4 py-8"
@@ -149,7 +152,7 @@ export function Modal({
aria-modal="true" aria-modal="true"
aria-label={title} aria-label={title}
onClick={(e) => e.stopPropagation()} 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`} className={`ui-pop flex w-full ${maxWidth} 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="flex items-start justify-between gap-3">
<div className="min-w-0"> <div className="min-w-0">