Modul-Review 2 Feinschliff: Assistent, Tour, 3-Spalten-Ansicht
Deploy App / deploy (push) Successful in 1m8s
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:
+42
-17
@@ -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` (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.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 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.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). |
|
||||||
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* -------------------------------------------------------------------------
|
/* -------------------------------------------------------------------------
|
||||||
|
|||||||
+43
-11
@@ -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,32 +361,40 @@ export function PlanView({
|
|||||||
onOpenSensitivity={onOpenSensitivity}
|
onOpenSensitivity={onOpenSensitivity}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Szenario-Profil */}
|
{/* Kompakter Klotz: Grundprofil (25 %) · Zeitachse (50 %) · Kennzahlen (25 %). Auf
|
||||||
|
schmalen Screens stapeln die drei. */}
|
||||||
|
<div className="grid grid-cols-1 gap-4 lg:grid-cols-4">
|
||||||
|
{/* Grundprofil -- Daten untereinander, «Bearbeiten» oben rechts. */}
|
||||||
<div
|
<div
|
||||||
data-tour="profil"
|
data-tour="profil"
|
||||||
className={`flex flex-wrap items-center gap-3 rounded-xl border px-4 py-3 text-sm shadow-sm ${
|
className={`flex flex-col gap-2 rounded-xl border px-4 py-3 shadow-sm lg:col-span-1 ${
|
||||||
diff?.profileChanged ? "border-diff bg-diff-soft" : "border-border bg-surface"
|
diff?.profileChanged ? "border-diff bg-diff-soft" : "border-border bg-surface"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
<span className="text-xs font-semibold uppercase tracking-wide text-faint">
|
<span className="text-xs font-semibold uppercase tracking-wide text-faint">
|
||||||
Grundprofil (Szenario){diff?.profileChanged ? " · abweichend" : ""}
|
Grundprofil{diff?.profileChanged ? " · abweichend" : ""}
|
||||||
</span>
|
</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) => (
|
{plan.persons.map((p) => (
|
||||||
<span key={p.role} className="text-xs text-muted">
|
<span key={p.role} className="text-xs text-muted">
|
||||||
{personLabel(p.role)}: {p.age} J., Pension {p.retirementAge}
|
{personLabel(p.role)}: {p.age} J., Pension {p.retirementAge}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
<span className="text-xs text-muted">Inflation {plan.inflationRateDefault}%</span>
|
<span className="text-xs text-muted">Inflation {plan.inflationRateDefault}%</span>
|
||||||
<button
|
</div>
|
||||||
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
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div data-tour="timeline">
|
{/* Zeitachse */}
|
||||||
|
<div data-tour="timeline" className="lg:col-span-2">
|
||||||
<Timeline
|
<Timeline
|
||||||
phases={computed.phases}
|
phases={computed.phases}
|
||||||
persons={personAxes}
|
persons={personAxes}
|
||||||
@@ -395,6 +404,29 @@ export function PlanView({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
{/* 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
|
||||||
deren Zahlen steuert. */}
|
deren Zahlen steuert. */}
|
||||||
{hasPhases && (
|
{hasPhases && (
|
||||||
|
|||||||
+167
-45
@@ -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,15 +363,97 @@ 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 geht’s <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. */}
|
||||||
|
<nav className="hidden w-48 shrink-0 flex-col gap-1 sm:flex">
|
||||||
|
{WIZARD_OVERVIEW.map((s, i) => {
|
||||||
|
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>
|
||||||
|
|
||||||
|
<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 key={i} className={`h-1 flex-1 rounded-full ${i <= step ? "bg-accent" : "bg-surface-2"}`} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -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,18 +735,43 @@ 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 && (
|
||||||
|
<div
|
||||||
|
className={`flex items-center justify-between rounded-xl border px-4 py-2.5 text-sm ${
|
||||||
|
restToCash < 0 ? "border-danger bg-danger-soft" : "border-border bg-surface-2"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="font-medium text-fg">Noch nicht verteilt (bleibt auf dem Cash-Konto)</span>
|
||||||
|
<span className={`text-base font-bold ${restToCash < 0 ? "text-danger" : "text-fg"}`}>
|
||||||
|
{restToCash.toLocaleString("de-CH")} CHF / Jahr
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{anyQuoteTarget && restToCash < 0 && (
|
||||||
|
<p className="-mt-2 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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Verteilung der Sparquote -- gruppiert nach Eigentuemer-Bereich (Gemeinsam/A/B). */}
|
||||||
{anyQuoteTarget ? (
|
{anyQuoteTarget ? (
|
||||||
<div className="rounded-xl border border-border p-3">
|
<div className="flex flex-col gap-3">
|
||||||
|
{renderedScopes
|
||||||
|
.filter((sc) => {
|
||||||
|
const a = assets[sc.id];
|
||||||
|
return a.p3a.on || a.etf.on || a.re.on || a.debt.on;
|
||||||
|
})
|
||||||
|
.map((sc) => (
|
||||||
|
<div key={sc.id} className="rounded-xl border border-border p-3">
|
||||||
<div className="mb-2 text-xs font-semibold uppercase tracking-wide text-faint">
|
<div className="mb-2 text-xs font-semibold uppercase tracking-wide text-faint">
|
||||||
Wohin fliesst deine Sparquote?
|
{isCouple ? sc.label : "Wohin fliesst deine Sparquote?"}
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
{renderedScopes.map((sc) => (
|
|
||||||
<Fragment key={sc.id}>
|
|
||||||
{assets[sc.id].p3a.on && (
|
{assets[sc.id].p3a.on && (
|
||||||
<MoneyField
|
<MoneyField
|
||||||
label={`Säule 3a: Einzahlung / Jahr (CHF)${scopeTag(sc)}`}
|
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)"}.`}
|
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}
|
value={assets[sc.id].p3a.contribution}
|
||||||
max={max3aFor(sc.id)}
|
max={max3aFor(sc.id)}
|
||||||
@@ -645,43 +780,29 @@ export function PlanWizard({
|
|||||||
)}
|
)}
|
||||||
{assets[sc.id].etf.on && (
|
{assets[sc.id].etf.on && (
|
||||||
<MoneyField
|
<MoneyField
|
||||||
label={`Wertschriften: Sparbeitrag / Jahr (CHF)${scopeTag(sc)}`}
|
label="Wertschriften: Sparbeitrag / Jahr (CHF)"
|
||||||
value={assets[sc.id].etf.contribution}
|
value={assets[sc.id].etf.contribution}
|
||||||
onChange={(v) => setScope(sc.id, "etf", { contribution: v })}
|
onChange={(v) => setScope(sc.id, "etf", { contribution: v })}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{assets[sc.id].re.on && (
|
{assets[sc.id].re.on && (
|
||||||
<MoneyField
|
<MoneyField
|
||||||
label={`Wohneigentum: Amortisation / Jahr (CHF)${scopeTag(sc)}`}
|
label="Wohneigentum: Amortisation / Jahr (CHF)"
|
||||||
value={assets[sc.id].re.amortization}
|
value={assets[sc.id].re.amortization}
|
||||||
onChange={(v) => setScope(sc.id, "re", { amortization: v })}
|
onChange={(v) => setScope(sc.id, "re", { amortization: v })}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{assets[sc.id].debt.on && (
|
{assets[sc.id].debt.on && (
|
||||||
<MoneyField
|
<MoneyField
|
||||||
label={`Schulden: Rückzahlung / Jahr (CHF)${scopeTag(sc)}`}
|
label="Schulden: Rückzahlung / Jahr (CHF)"
|
||||||
value={assets[sc.id].debt.repayment}
|
value={assets[sc.id].debt.repayment}
|
||||||
onChange={(v) => setScope(sc.id, "debt", { repayment: v })}
|
onChange={(v) => setScope(sc.id, "debt", { repayment: v })}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Fragment>
|
</div>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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">
|
<p className="text-sm text-muted">
|
||||||
Du hast im vorigen Schritt keine Spar-/Vorsorgeposten angehakt, in die deine Sparquote fliessen könnte. Der
|
Du hast im vorigen Schritt keine Spar-/Vorsorgeposten angehakt, in die deine Sparquote fliessen könnte. Der
|
||||||
@@ -719,11 +840,10 @@ export function PlanWizard({
|
|||||||
Abbrechen
|
Abbrechen
|
||||||
</Button>
|
</Button>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
{step > 0 && (
|
{/* Zurück führt in Schritt 0 auf den Willkommens-Screen. */}
|
||||||
<Button variant="secondary" onClick={() => setStep(step - 1)} disabled={saving}>
|
<Button variant="secondary" onClick={() => (step > 0 ? setStep(step - 1) : setStarted(false))} disabled={saving}>
|
||||||
<ArrowLeft className="h-4 w-4" /> Zurück
|
<ArrowLeft className="h-4 w-4" /> Zurück
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
|
||||||
{step < STEP_TITLES.length - 1 ? (
|
{step < STEP_TITLES.length - 1 ? (
|
||||||
<Button onClick={goNext} disabled={step === 1 && !step1Valid}>
|
<Button onClick={goNext} disabled={step === 1 && !step1Valid}>
|
||||||
Weiter <ArrowRight className="h-4 w-4" />
|
Weiter <ArrowRight className="h-4 w-4" />
|
||||||
@@ -735,6 +855,8 @@ export function PlanWizard({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+25
-12
@@ -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,14 +114,15 @@ 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">
|
||||||
|
<button type="button" onClick={finish} className="text-xs font-medium text-faint hover:text-muted">
|
||||||
|
Überspringen
|
||||||
|
</button>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
{steps.map((_, i) => (
|
{steps.map((_, i) => (
|
||||||
<span
|
<span key={i} className={`h-1.5 w-1.5 rounded-full ${i === index ? "bg-accent" : "bg-border-strong"}`} />
|
||||||
key={i}
|
|
||||||
className={`h-1.5 w-1.5 rounded-full ${i === index ? "bg-accent" : "bg-border-strong"}`}
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
@@ -131,5 +143,6 @@ export function Tour({ onClose }: { onClose: () => void }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
Reference in New Issue
Block a user