Compare commits

..

11 Commits

Author SHA1 Message Date
admGitAICDS a3c86fc559 Zeitachse als Zeitstrahl: Beschriftung alternierend, Startjahr markant, aktive Phase hervorgehoben
Deploy App / deploy (push) Successful in 1m11s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 22:20:12 +02:00
admGitAICDS 5842a84b20 Menue schiebt statt zu ueberlappen, Karte schmaler mit Seitenpfeilen, Zeitachse neu, Cash inline
Deploy App / deploy (push) Successful in 1m9s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 22:07:44 +02:00
admGitAICDS ff862ed030 Menueleiste als eine Komponente in zwei Breiten, Kartenansicht wieder erreichbar
Deploy App / deploy (push) Successful in 1m15s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 21:47:28 +02:00
admGitAICDS f41c3614b5 Kartenansicht je Lebensphase, Icon-Schiene links, Steuerung rechts
Deploy App / deploy (push) Successful in 1m10s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 21:29:42 +02:00
admGitAICDS 113616cc1f Memo 2026081701: Kapitalverwendung mit waehlbarem Tilgungsziel, AHV automatisch, Farb- und Textkorrekturen
Deploy App / deploy (push) Successful in 1m18s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 20:04:45 +02:00
admGitAICDS f9d7ca07fd Stammdaten gewinnen in Phase 1, Bestaetigung je Zeile statt pauschal
Deploy App / deploy (push) Successful in 1m10s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 21:41:07 +02:00
admGitAICDS ce987b9978 Ein Wert, ein Ort: Startwerte und Raten read-only, Verteil-Dialoge als einziger Eingabeort
Deploy App / deploy (push) Successful in 1m13s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 21:17:39 +02:00
admGitAICDS 5ff9bc0709 Bestandsaufnahme: Anlegen schliesst den Dialog nicht mehr
Deploy App / deploy (push) Successful in 1m12s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 20:17:34 +02:00
admGitAICDS f22b0a3f27 Uebersicht der offenen Punkte statt Assistent, Bestaetigung je Phasenzelle
Deploy App / deploy (push) Successful in 1m55s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 15:02:15 +02:00
admGitAICDS 6ff144d7e1 Assistent auf zwei Schritte, Pensionsalter fix 65, Horizont abgeleitet
Deploy App / deploy (push) Successful in 1m57s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 12:18:56 +02:00
admGitAICDS 2f6b788bd4 Bestandsaufnahme: Kategorienamen, Sammel-Speichern, Matrix-Spalte Start
Deploy App / deploy (push) Successful in 1m39s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 11:37:16 +02:00
37 changed files with 4537 additions and 2773 deletions
+551 -94
View File
@@ -4,10 +4,10 @@
| | | | | |
|---|---| |---|---|
| **Dokument** | Funktionale und Technische Spezifikation FPT | | **Dokument** | Funktionale und Technische Spezifikation FPT |
| **Version** | 0.36 | | **Version** | 0.42.3 |
| **Datum** | 2026-07-25 | | **Datum** | 2026-07-25 |
| **Status** | Lebendes Dokument | | **Status** | Lebendes Dokument |
| **Codestand** | Arbeitsstand nach `c440063` inkl. Assistent als Herzstück (Branch `main`) | | **Codestand** | Arbeitsstand nach `5842a84` inkl. Zeitstrahl (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,17 @@
| Version | Datum | Autor | Änderung | | Version | Datum | Autor | Änderung |
|---|---|---|---| |---|---|---|---|
| 0.42.3 | 2026-08-17 | Claude (Opus 5) | **Die Zeitachse als klassischer Zeitstrahl.** Die Beschriftungen stehen neu **abwechselnd ober- und unterhalb** des Balkens, verbunden über ein Dreieck und eine dünne Linie. Das ist nicht Zierde, sondern löst ein Platzproblem: Nebeneinander gesetzte Beschriftungen kollidieren, sobald eine Phase kurz ist abwechselnd liegen Nachbarn auf derselben Seite zwei Phasen auseinander und haben den doppelten Raum. Jede Beschriftung nennt Jahresspanne, Phasenname und Dauer. Der Balken ist durchgehend und dunkel, seine Segmente teilen sich die Breite **streng nach Dauer**; die aktive Phase ist im Akzent gefüllt, samt Dreieck, Verbindungslinie und hervorgehobener Beschriftung. Das **Startjahr** steht als grosse Zahl am linken Ende es ist der Nullpunkt, auf den sich die reale Kaufkraft und die ganze Achse beziehen , das Planende kleiner rechts. Die Marker (Pensionierung je Person, Ruinalter, erfasste Ist-Jahre) liegen neu **auf** dem Balken statt darüber zu schweben; oberhalb kollidierten sie mit den Beschriftungen. |
| 0.42.2 | 2026-08-17 | Claude (Opus 5) | **Memo 2026081703** Feinschliff der neuen Oberfläche, sechs Punkte. (1) **Die Menüleiste schiebt, statt zu überlappen.** Beim Ausfahren legte sie sich über den Inhalt; ein Streifen verschwand jedes Mal, und das las sich wie ein Fehler. Neu ist der ausgefahrene Zustand optisch identisch mit dem angepinnten das Anpinnen entscheidet nur noch, ob es so BLEIBT. (2) **Suchen und Anpinnen stehen direkt unter den Systemparametern** statt am Fuss der Leiste. Die Leiste ist so hoch wie die Seite, und mit einer aufgeklappten Karte wird die sehr lang am Fuss waren die beiden ausserhalb des Blickfelds. Zusätzlich klebt der Inhalt beider Leisten neu am oberen Bildschirmrand (`sticky`), sonst scrollt die Steuerung beim Arbeiten weg. (3) **Die Karte nutzt nicht mehr die volle Breite**, sondern höchstens 46 rem. Eine Zeile aus 1300 px liest sich schlecht: Das Auge verliert beim Zeilenwechsel den Anschluss, und Beschriftung und Wert driften so weit auseinander, dass man sie nicht mehr zusammenbringt. Die frei werdende Luft links und rechts trägt zwei **grosse Pfeile** zur Nachbarphase ein Bedienelement, das keinen zusätzlichen Platz kostet. (4) **Die drei Akte sind sichtbar getrennt.** Vorher schied sie nur eine Haarlinie, und man las die Karte als eine lange Liste statt als drei Schritte. Neu trägt jeder Akt einen getönten Kopfstreifen über die volle Breite mit einer **Nummer im Kreis**; «Ergebnis» ist durchgehend getönt und trägt die Nummer im Akzent es ist der Schlusspunkt, nicht ein weiterer Abschnitt. (5) **Die Zeitachse neu gezeichnet**: kein Kasten mit Überschrift, keine Legende, keine Fusszeile. Stattdessen abgesetzte Kacheln, deren Abstand die Phasengrenze zeigt, dazwischen ein Pfeil für den Übergang, und die Marker (Pensionierung, Ruinalter, erfasste Ist-Jahre) sitzen als Pillen direkt an der Leiste statt darüber zu schweben. (6) **Cash verhält sich wie jede andere Zeile.** Es war der einzige Eintritts-Punkt, der das rechte Panel öffnete statt aufzuklappen auf einer Karte, die genau davon wegwollte, ein Fremdkörper. Neu klappt es inline auf, mit denselben Feldern (`CashTransitionFields`) und eigenem Speichern. |
| 0.42.1 | 2026-08-17 | Claude (Opus 5) | **Memo 2026081702** vier Befunde aus der ersten Testrunde der neuen Oberfläche. (1) **Die Kartenansicht war überhaupt nicht erreichbar** der Umschalter dauerhaft ausgegraut. Ursache: Die Bedingung lautete `forceMatrix = !!diff`, aber `computeScenarioDiff` liefert **immer** ein Objekt (bei Gleichheit ein leeres). Auch `diff.total > 0` wäre falsch gewesen: Ein Kind-Szenario weicht nach der ersten Änderung immer von seiner Vorlage ab, Karten wären dort für immer gesperrt. Die Diff-Markierungen sind eine **Verzierung** der Matrix, kein Modus. Neu erzwingt nur die **Ist-Ansicht** die Matrix (das ist ein Modus, in den man bewusst wechselt); bei Abweichungen zur Vorlage steht in der Kartenansicht ein Hinweis mit dem Weg dorthin. (2) **Die Menüleiste wechselte beim Überfahren die Darstellung** eingeklappt Icons, ausgefahren ein Plan-Baum. Das war kein Übergang, sondern ein Sprung. Neu ist es **eine Komponente in zwei Breiten**: Die Symbole sitzen in einer festen 3.5-rem-Spalte und bleiben exakt an ihrem Platz, rechts daneben fährt nur die Beschriftung aus. Damit entfällt der Baum aller Pläne; er wird nicht vermisst, weil der Plan über den Wähler oben gewechselt wird und die vier Bereiche sich immer auf den **aktiven** Plan beziehen. Plan anlegen und löschen sind in den Wähler gewandert, der Szenario-Baum hängt aufklappbar unter «Szenarien». (3) **Die Matrix schob sich vor die ausgefahrene Leiste.** Ihre sticky Köpfe tragen `z-40` und konkurrierten damit global; die Ecke oben links gewann. Die Matrix ist neu ein eigener **Stapel-Kontext** (`relative z-0`) ihre Köpfe stapeln sich nur noch untereinander. (4) **Die Kachel «Offene Punkte» zog sich über den ganzen Bildschirm**, auch mit einem einzigen Eintrag: ein `h-full` aus der Zeit, als die vier Kacheln in einem Raster gleich hoch sein mussten. In der rechten Leiste ist das falsch sie ist jetzt so hoch wie ihr Inhalt. |
| 0.42 | 2026-08-17 | Claude (Opus 5) | **Kartenansicht und Drei-Spalten-Shell** (neues Kapitel 3.15). Der grösste Eingriff in die Oberfläche seit 0.36 und er behebt eine Ursache statt Symptome. Ausgemessen war die Lage eindeutig: Der Phasenkopf trug 220 px an Kennzahlen, dauerhaft fixiert; von 675 px Matrixhöhe blieben 430 px zum Arbeiten. Fünf Phasen brauchten 1760 px Breite, sichtbar waren mit offenem Panel 1024 px. Und das Bearbeitungs-Panel (`fixed right-0`, 416 px) legte sich ausgerechnet über die Spalte, zu der man gerade gescrollt hatte. (1) **Eine Karte je Lebensphase** ersetzt die Matrix als Arbeitsansicht. Sie erzählt die Phase als Zeitablauf: **Eintritt** (der Übergang aus der Vorphase; bei Phase 1 der Bestand), **Laufend** (die Elemente mit ihren Annahmen und die Quote), **Ergebnis** (Vermögen und Cash am Phasenende). Horizontales Scrollen entfällt vollständig. Der Übergang gehört bewusst zur FOLGEphase dort wird das frei werdende Kapital verteilt, konsistent mit 0.41. (2) **Bearbeitet wird IN der Karte.** Ein Klick klappt die Zeile auf, die Felder stehen darunter. Damit kann nichts mehr verdecken, woran man arbeitet das Panel bleibt nur für die Matrix. Ein Knopf im Kartenkopf klappt **alle** Zeilen auf oder zu; bei acht Elementen ist das der Unterschied zwischen einmal durchsehen und acht Klicks. (3) **Die Zeitachse wird zum Navigator**: Das Segment der offenen Karte ist hervorgehoben, ein Klick springt hin. Dazu Pfeilknöpfe unten und die **Pfeiltasten** (ausser in Eingabefeldern sonst springt die Karte beim Tippen weg). (4) **Die Matrix bleibt als Vergleichsansicht.** Umschalter oben rechts; die Wahl gehört zum Benutzer und überlebt die Sitzung. Ein Szenario-Diff oder eine Ist-Ansicht **erzwingt** die Matrix: Vergleich über alle Phasen kann eine einzelne Karte prinzipiell nicht leisten, und stillschweigend die falsche Darstellung anzubieten wäre schlechter als der Wechsel. (5) **Drei-Spalten-Shell.** Die Menüleiste links ist eingeklappt eine **Icon-Schiene** (56 statt 256 px); beim Überfahren fährt sie als Overlay aus, ein **Pin** hält sie breit. Die vier Kacheln wandern von oben in eine **rechte Leiste** mit aufklappbaren Abschnitten. Das ist der entscheidende Tausch: Sie kosteten oben **vertikalen** Platz, rechts kosten sie horizontalen und Höhe ist auf einem Breitbildschirm die knappe Grösse. Eingeklappt bleibt die Zahl der offenen Punkte am Knopf sichtbar. Zusammen: statt 1184 × 574 rund **1328 × 804** für den Inhalt, also +12 % Breite und **+40 % Höhe**. (6) Der Anzeige-Umschalter nominal/beide/real steht neu auch in der Werkzeugleiste (`ValueModeSwitch`, eine Implementierung für beide Orte). Neue Komponente `PhaseCards`; `ElementDetail` bekommt eine `compact`-Variante, `Timeline` die Navigator-Rolle, `Tile` eine rahmenlose Variante. Kein Eingriff in den Rechenkern; 337 Tests unverändert grün. |
| 0.41 | 2026-08-17 | Claude (Opus 5) | **Memo 2026081701** zehn Punkte aus einer Testrunde, mehrheitlich Feinschliff, zwei davon inhaltlich. (1) **Die Kapitalverwendung heisst neu Schuldentilgung · Investition · Cash, und die Tilgung hat ein wählbares Ziel** (`capitalUseDebtTargetElementId`): eine Immobilie **oder** eine sonstige Schuld. Bisher floss die Quote stur in die erstbeste Immobilie wer neben einer Hypothek zu 1,5 % einen Konsumkredit zu 6 % trägt, konnte den Kredit gar nicht zuerst tilgen. Ohne Wahl bleibt es beim alten Verhalten, damit bestehende Pläne unverändert rechnen. **Cash ist neu eine gleichrangige Zeile** statt einer blassen Fussnote: «ich lasse es liegen» ist ein legitimer Entscheid. Rechnerisch bleibt es der Rest alle drei frei eintippbar zu machen hiesse, dass beim Tippen still eine andere Zahl wandert. (2) **Die Maske gibt es neu auch bei der Säule 3a.** Dort stellt sich die Frage sogar zwingender als bei der PK: Ein 3a-Konto wird IMMER vollständig bezogen, es fliesst also in jedem Fall ein grosser Betrag der bis 0.40 stumm auf dem Cash landete. (3) **Je Person entsteht beim Abschluss der Bestandsaufnahme automatisch ein AHV-Element**, idempotent (wer die Bestandsaufnahme später korrigiert, bekommt kein zweites). Die AHV ist die einzige Kategorie ohne Bestand ein Plus-Knopf mit leerer Karte wäre sinnlos, das Element wegzulassen aber auch: Ohne es rechnet der Plan ab der Pensionierung mit einer AHV-Rente von 0. Die Karte in der Bestandsaufnahme sagt neu, warum dort nichts zu erfassen ist. (4) **Farben folgen der Bedeutung, nicht dem Zustand.** Ein einmaliger Cash-Zufluss am Übergang trug die Akzentfarbe im warmen Schema ein Orangerot, das eine Erbschaft wie einen Fehler aussehen liess; neu ist er grün, ein Abfluss neutral. Die Knöpfe «Kapital verteilen» und «Sparquote verteilen» sind **grün mit Haken, sobald erledigt** vorher sah man einer vollständig verteilten Phase nicht an, dass sie fertig war. (5) **Sechs Stellen mit unlesbarer Schrift behoben:** `text-attention-fg` ist **weiss** und für vollflächigen Attention-Grund gedacht; auf hellem oder 10-%-Grund war der Text im warmen und im hellen Schema praktisch unsichtbar (PK-Einkaufswarnung, 3a-Kollision, fehlendes Anlageziel, zwei Stellen in der Kachel «Offene Punkte»). (6) **Der Kapitalanteil-Regler hatte keine sichtbare Schiene**: `appearance-none` schaltet die native Darstellung ab, womit auch `accent-color` nicht mehr greift übrig blieb der blosse Knopf. (7) **Der Umwandlungssatz verschwindet bei 100 % Kapital**, spiegelbildlich zur Kapitalbezugssteuer bei 0 %. Bei vollem Kapitalbezug wird nichts verrentet. (8) **Der Dialog «Neue Lebensphase» erklärt die Dauer-Kappung** welches Ereignis sie setzt und warum (die Rechnung leitet Erwerbsstatus und Bezüge am Phasen**beginn** ab). Vorher stand dort nur «max. N». (9) **Zustand statt Regel nach der Pensionierung:** Die Zellen von PK und 3a beschrieben in der Pensionsphase die Regel im Futur («wird berechnet», «wird bezogen»), obwohl der Bezug längst geschehen war. Neu steht dort der gerechnete Stand bei der PK die Rente in Franken oder «Vollständig bezogen», bei der 3a «Vollständig bezogen». (10) **Eine Vokabel für einen Gedanken:** Die Bestätigung der Pensionsentscheide heisst neu «angeschaut» wie die der Phasenzellen, und der Erklärtext sagt ausdrücklich, dass der Haken **keine Zahl ändert**. Zwei Wörter für dieselbe Sache hatten mehr verwirrt als geholfen. Die Fixpunkt-Kappung um AHV-, PK- und 3a-Bezugsalter zu erweitern wurde **bewusst zurückgestellt** im Basisszenario ist das Pensionsalter fest 65, das Thema gehört zur Szenario-Art «Frühpensionierung» (Roadmap Nr. 48). 3 Tests ergänzt (334 → 337). |
| 0.40.1 | 2026-08-16 | Claude (Opus 5) | **Zwei Fehler aus der Testrunde.** (1) **«Ein Wert, ein Ort» gilt jetzt im Rechenkern, nicht nur in der Oberfläche.** Eine Immobilie zeigte in der ersten Lebensphase Startwert 0 statt Kaufpreis minus Hypothek. Ursache: Der Rechenkern legte die Phasenwerte über die Stammdaten (`{...baseData, ...phaseValues}`) eine in 0.39 versehentlich gespeicherte 0 gewann damit gegen die Bestandsaufnahme. Seit 0.40 ist das Feld read-only, wodurch dieser Altwert **unerreichbar** war und den Kaufpreis dauerhaft verdeckt hätte. Neu gewinnen bei den fünf Bestandsfeldern (`amount`, `currentValue`, `startValue`, `purchasePrice`, `mortgage`) **immer die Stammdaten**, wo sie einen Wert tragen (`firstPhaseValues`); der Rückfall auf den Phasenwert bleibt nur, solange die Stammdaten leer sind sonst fielen Pläne aus der Zeit vor 0.36 schlagartig auf 0. Die Annahmen (Rendite, Zins, Wertsteigerung, Teuerung) bleiben in Phase 1 überschreibbar: Sie gelten für einen Zeitraum, nicht für den Anfangsbestand. Damit heilen bestehende Daten von selbst. (2) **«Alle bestätigen» war zu grob.** Der Knopf in der Annahmen-Maske schrieb `confirmed: true` auf jede Zeile man konnte ihn drücken, ohne je gescrollt zu haben, also genau das Durchwinken, das der Mechanismus verhindern soll. Neu trägt **jede Zeile ein Häkchen «Angeschaut»**, anfangs leer; wer ein Feld ändert, hakt es automatisch an (bearbeiten IST anschauen); oben markiert ein Klick alle auf einmal, aber als sichtbarer Akt. Gespeichert werden die Werte aller Zeilen, bestätigt nur die angehakten der Knopf nennt die Zahl. 4 Tests ergänzt (330 → 334). |
| 0.40 | 2026-08-16 | Claude (Opus 5) | **Ein Wert, ein Ort** (neues Kap. 3.14.3, aus einer Testrunde des Nutzers). Der rote Faden aller acht Punkte: Für dieselbe Zahl gab es an mehreren Stellen ein Eingabefeld, und der Rechenkern entschied still, welches gewinnt. (1) **Startwerte sind in der ersten Lebensphase read-only.** Sie standen dort als Eingabefeld, das `phaseValues[phase1]` schrieb der Rechenkern legt die Phase über die Stammdaten (`{...baseData, ...phaseValues}`), also überschattete jede Eingabe still die Bestandsaufnahme. Neu wird der Stammdatenwert angezeigt, mit Absprung in die Spalte «Start». Ab Phase 2 bleibt der Betrag bei Einkommen und Ausgaben **änderbar** Teilzeit, Beförderung, Jobwechsel sind echte Entscheide dieser Phase; die Bestände sind dort ohnehin schon fortgeschrieben und read-only. (2) **Jährliche Raten entstehen nur noch im Verteil-Dialog**, einmalige Kapitalverwendungen nur noch im Kapital-Dialog. In der Zelle stehen sie weiterhin, aber read-only mit Absprung. Nur im Dialog sieht man, wie viel überhaupt zu verteilen ist und ob die Summe aufgeht. (3) **Der PK-Beitrag wandert in den Verteil-Dialog** in einen **eigenen Block** «Aus dem Bruttolohn (ausserhalb der Quote)». Er ist bewusst nicht Teil der Quote ([4.6.3](#463-pension_fund)), aber der Dialog ist neu der einzige Ort für jährliche Beträge; ohne ihn wäre er unerreichbar und fiele still auf 0. (4) **Deckel und Schalter «grosse Säule 3a» stehen im Verteil-Dialog**, direkt über der Einzahlung, deren Obergrenze sie bestimmen getrennt davon war der Schalter eine Einstellung ohne sichtbare Wirkung. (5) **Sonderamortisation und Sofort-Tilgung verlassen die Übergangszelle.** Sie zehren vom Kapital der FOLGEphase und werden dort entschieden; angezeigt werden sie in der Phasenzelle, wo auch die Zusatzeinlage steht. (6) **Der «Kapital verteilen»-Knopf ist immer sichtbar.** Er hing an `pot.total > 0` wer alles verteilt hätte, käme an seine eigene Zuteilung nie mehr heran, sobald die Elementfelder read-only sind. (7) **Cash in der Bestandsaufnahme.** Es fehlte ganz: Die Summe «Vermögen heute» rechnete es bereits mit, erfassen konnte man es dort nirgends. Kein Plus-Knopf, sondern ein festes Feld Cash ist kein Element, sondern `Scenario.initialCash`. (8) **Die Tour startet direkt nach dem Anlegen des Plans.** Sie hing an `phases.length > 0`, einem Überbleibsel der Spotlight-Tour, die echte DOM-Ziele brauchte; die Attrappe braucht nichts und die Tour gehört genau dorthin, wo man noch nicht weiss, wie der Plan aufgebaut ist. (9) **Das Abzeichen im Phasenkopf öffnet eine Maske mit allen offenen Annahmen** (`PhaseReviewDialog`), analog zum Übergangs-Review. Bis 0.39 fragte es nur «N Annahmen bestätigen?» eine Zustimmung zu etwas Ungesehenem, also genau die Bewegung, die der Mechanismus verhindern soll. (10) **Unbestätigte Zellen sind deutlicher markiert**: getönter Grund, kräftiger linker Balken, Warnzeichen. Der dünne Ring aus 0.39 ging in einer vollen Matrix unter. (11) **Cash-Zeile mit `ValuePair`**: der Realwert steht im Modus «Beide» darunter statt daneben, wie in jeder anderen Zeile. (12) Zwei Folgen daraus: Der Dialog **«Element anlegen» schreibt neu in die Stammdaten** statt in Phase 1 und **braucht keine Lebensphase mehr**; und `needsConfirmation` verlangt keine Bestätigung mehr für Zellen **ohne jede Annahme** eine «Sonstige Schuld» trägt seit (2) nichts mehr in der Phasenzelle. 1 Test ergänzt (329 → 330). |
| 0.39.1 | 2026-08-16 | Claude (Opus 5) | **Fehlerbehebung: Die Bestandsaufnahme schloss sich beim ersten Plus-Knopf.** `InventoryDialog` kannte nur einen Rückkanal nach oben (`onSaved`) und benutzte ihn für zwei verschiedene Ereignisse: «Element angelegt, bitte Plan neu laden» und «Dialog fertig». In `PlanView` hängt an `onSaved` aber das Schliessen -- ein Klick auf «Ausgaben» legte das Element korrekt an und beendete den Dialog sofort, womit sich genau der eine Bildschirm nicht bedienen liess, der alles erfassen soll. Neu trägt der Dialog beide Rückkanäle getrennt: `onChanged` lädt nur nach (der Entwurf im Dialog überlebt das, weil `loadDetail(id, true)` still nachlädt und die `PlanView` montiert bleibt), `onSaved` schliesst. Betraf auch das Löschen einer Position aus dem Dialog heraus. |
| 0.39 | 2026-08-16 | Claude (Opus 5) | **Aus dem Assistenten wird eine Übersicht der offenen Punkte** (Kap. 3.14 neu geschrieben). Der Unterschied ist grundsätzlich: Ein Assistent ist ein **Ablauf** («tu dies, dann das») und trägt nur beim ersten Aufsetzen -- wer einen bestehenden Plan öffnete, bekam Schritte angeboten, die längst erledigt waren, und die Kachel wusste nichts davon. Die Übersicht ist ein **Zustand** («das ist noch offen»); sie wird aus dem Plan abgeleitet und trägt bei jedem Plan, in jeder Reihenfolge, auch beim zwanzigsten Szenario. (1) Der Assistent samt Schritten, gespeichertem Fortschritt (`Scenario.assistantProgress`) und Endpunkt entfällt. Oben rechts steht neu die Übersicht: je Lebensphase und je Übergang, was dort fehlt, jeweils mit Sprung dorthin. Ist nichts offen, meldet sie das ausdrücklich -- **grün und in Worten**, nicht als «0». (2) **Die Bestätigung gilt neu auch für Phasenwerte** (`PhaseData.confirmed`). Eine neue Lebensphase übernimmt die Werte der Vorphase, aber sie gelten als unbestätigt, bis jemand hingeschaut hat: vorbelegen ja, stillschweigend übernehmen nein. Betroffen sind die phasenspezifischen Annahmen -- Renditen, Lohnentwicklung, Teuerung, Hypothekarzins, Wertsteigerung. Damit gilt in der ganzen Matrix derselbe Mechanismus, den die Übergänge seit 0.35 haben. **Bestätigen heisst «ich habe hingeschaut», nicht «festnageln»**: Der Haken steht NEBEN den Werten und kopiert nichts -- die Feld-Vererbung ([3.12.4](#3124-punkt-a-aus-vorphase-übernehmen)) bleibt unberührt, sonst wäre jede bestätigte Phase eingefroren und der ganze Punkt-A-Mechanismus hinfällig. Ein Test sichert genau das. (3) **Die Spar-/Verzehrquote zählt eigens** (`Phase.ratesConfirmed`). Man kann jede Zelle angeschaut und die Verteilung trotzdem nie getroffen haben -- dann bliebe der ganze Überschuss still auf dem Cash-Konto liegen, und die Übersicht meldete «alles erledigt». (4) **Sammel-Bestätigung je Phase:** Bei sechs Elementen und fünf Phasen wären es dreissig Klicks; der Phasenkopf trägt deshalb ein Abzeichen mit der Anzahl offener Annahmen, das alle auf einmal bestätigt. Unbestätigte Zellen tragen dieselbe Attention-Markierung wie offene Übergänge. (5) **Die Bestandsaufnahme bleibt als eigener Dialog** (`InventoryDialog`, vormals `AssistantSteps`) -- als grosser Knopf im leeren Plan und dauerhaft unter den Schnellaktionen. Sie ist der einzige Sammel-Dialog, der geblieben ist, weil sie als einzige etwas leistet, das die Matrix nicht kann: sieben Kategorien in einem Zug erfassen, bevor man weiss, wie das Tool aufgebaut ist. (6) Zwei **Startzustände** statt einer Zahl: Ohne Elemente und ohne Lebensphasen gibt es naturgemäss nichts Offenes, obwohl der Plan leer ist -- eine «0» wäre dort eine Lüge. Die Kachel fordert stattdessen zum nächsten Handgriff auf. Neues Modul `review.ts`, neue Komponenten `ReviewTile` und `InventoryDialog`; entfallen sind `assistant.ts`, `Assistant` und `AssistantStepDialog`. **Keine Datenmigration** (Pläne wurden vorgängig gelöscht). 7 Tests ergänzt (322 → 329). |
| 0.38 | 2026-08-16 | Claude (Opus 5) | **Der Assistent führt bis zur ersten Lebensphase und nicht weiter.** Sieben Schritte waren ein Versprechen, das die letzten fünf nicht einlösten: Sie zeigten Stationen, statt zu führen. Neu sind es **zwei**, die tragen; wie es danach weitergeht, wird eigens entworfen. (1) **Trennung von Tatsache und Annahme.** Schritt 1 erfasst nur noch, was man nachschlagen kann Kontostand, Guthaben, Kaufpreis, Restschuld. Renditen, Lohnentwicklung, Teuerung, Hypothekarzins und Wertsteigerung wandern in Schritt 2, wo sie hingehören: **Annahmen gelten immer nur für einen Zeitraum.** Bis 0.37 standen beide zusammen in der Bestandsaufnahme, wodurch eine Annahme wie eine Tatsache aussah. Neu trägt jede Kategorie `baseFields` und `phaseFields`. (2) **Neuer Schritt 2 «Erste Lebensphase»**: Name → Dauer → jährliche Annahmen je Element → **Sparquote verteilen**. Erst danach ist der Schritt fertig; endete er nach den Annahmen, bliebe der ganze Überschuss stumm auf dem Cash-Konto liegen. Die Dauer ist an der Pensionierung **gekappt** und wird im Feld begrenzt, statt hinterher als Fehler zu erscheinen. Der **PK-Beitrag** steht hier er stammt aus dem Bruttolohn und lässt sich aus der Sparquote gar nicht verteilen, wäre also sonst durch alle Maschen gefallen. (3) **Pensionsalter im Basisszenario fest auf 65**, nicht änderbar. Vorbezug, Aufschub und gestaffelte Kapitalbezüge bleiben im Rechenkern vollständig erhalten (samt Tests), werden hier aber nicht angeboten sie gehören zu einer eigenen Szenario-Art (Roadmap Nr. 48). Der Knopf «Pensionsplanung» und der zugehörige Bildschirm entfallen; die Grundeinstellungen nennen das Pensionsalter je Person und weisen auf die spätere Szenario-Art hin. (4) **Der Planungshorizont wird abgeleitet** er ist die Summe der Lebensphasen. Dritter und letzter Anlauf: 0.35 führte ihn als Endalter je Person, 0.36 als Jahreszahl am Szenario, beide Male eine zweite Wahrheit über dieselbe Sache. Wer die Phasen einzeln plant, hat den Horizont bereits bestimmt. `Scenario.planningHorizonYears`, der Endpunkt `/horizon` und `planHorizonChange` entfallen. (5) Entfallen: die Schritte 3 bis 7 samt ihren Werkzeugen und `RetirementPanel`. |
| 0.37 | 2026-08-16 | Claude (Opus 5) | **Nachbesserungen zur Bestandsaufnahme** (aus einer Testrunde des Nutzers). (1) **Die Knöpfe tragen die Kategorienamen** (Einkommen · Ausgaben · Pensionskasse · Säule 3a · Sonstiges Vermögen · Immobilie · Sonstige Schulden), die Alltagssprache steht als Erklärzeile darunter. Vorher hatte der Assistent eigene Vokabeln erfunden am deutlichsten «Lebenshaltung», hinter der sich ein Feld «Ausgaben pro Jahr» verbarg. Wer hier eine andere Sprache lernt als die, die Matrix, Rechenwege und Bericht sprechen, sucht sie später vergeblich. Der Elementname darf konkret bleiben: Kategorie «Ausgaben», Zeile «Lebenshaltung». (2) **Kein Speichern-Knopf je Element mehr.** Der Entwurf liegt neu auf Ebene des ganzen Schritts statt in der einzelnen Karte nur so überlebt er den Wechsel des Reiters, denn dabei verschwinden die Karten des vorigen Bereichs samt ihrem Zustand. «Schritt abschliessen» schreibt alles in einem Zug. Weil damit alle Zahlen an einem Ort liegen, zeigt der Dialog neu die Summe **«Vermögen heute» live beim Tippen** die Rückmeldung, die sonst mit dem Speichern-Knopf verloren gegangen wäre. Wer den Dialog mit ungespeicherten Eingaben schliesst, wird gefragt. (3) **«Zurück/Weiter» entfällt** in Schritt 1 und 2: Die Reiter oben sind der Weg durch die Bereiche, zwei Navigationen für dieselbe Bewegung sind eine zu viel. (4) Der 3a-Hinweis zum Höchstbetrag ist gestrichen er beantwortete an dieser Stelle eine Frage, die niemand stellt. (5) **Neue Matrix-Spalte «Start»** und: **die Matrix erscheint, sobald es Elemente gibt** Lebensphasen sind dafür nicht mehr nötig. Das war ein Fehler in 0.36: Die Stammdaten wurden eigens dafür eingeführt, dass eine Bestandsaufnahme ohne Zeitachse möglich ist, und dann rendete die Matrix nichts, weil sie ganz an den Phasen hing. Die Spezifikation behauptete das Richtige, der Code hielt es nicht. Die Spalte löst zugleich ein zweites Problem: Sie ist der Ort, an dem ein Startwert **änderbar** ist. Ohne sie hätte man ihn in Phase 1 bearbeitet und dabei still einen Phasenwert geschrieben, der die Stammdaten überdeckt dieselbe Zahl an zwei Orten. (6) Der Text des leeren Zustands ist korrigiert: Seit dem Umbau kommen die Elemente zuerst und die Phasen danach. Neue gemeinsame Komponente `BaseFields` (Assistent und Matrix nutzen dieselben Felder), neues Panel für die Stammdaten. 2 Tests ergänzt (334 → 336). |
| 0.36 | 2026-07-26 | Claude (Opus 5) | **Der Assistent wird das Herzstück** (neues Kapitel 3.14). Ein Umbau von Onboarding, Bildschirmaufbau und Führung. (1) **Ein Weg hinein.** Die Übersicht zeigt im leeren Zustand nur noch «Meinen ersten Finanzplan anlegen»; der geführte Start und der Beispielplan entfallen. Drei Knöpfe waren eine Wahl, die niemand treffen kann, der das Tool noch nicht kennt. Der Plan-Dialog fragt nur noch sechs Dinge: Name, Haushaltsform, Personennamen, Startjahr, Alter, Inflation. **Das Pensionsalter wird nicht mehr abgefragt** es ist kein Stammdatum, sondern der erste Entscheid der Pensionsplanung, und es erzeugt eine Phasengrenze. Bis dahin gilt das Referenzalter. (2) **Element-Stammdaten** (`FinancialElement.baseData`): Bestand bei Planbeginn und Ausgangs-Annahmen hängen neu am ELEMENT statt in Phase 1. Zwei Gründe ein Startwert ist nicht «phase-1-spezifisch», sondern schlicht der Stand am Anfang; und Elemente lassen sich damit erfassen, **bevor es Lebensphasen gibt**. Genau das braucht die Bestandsaufnahme als erster Schritt. Zugleich sind die Stammdaten die **Wurzel der Feld-Vererbung**: Phase 1 hatte bisher nichts, von dem sie hätte erben können, und fiel auf 0. (3) **Aus einem Fixpunkt werden bis zu vier je Person.** `phaseplan.ts` kannte nur das Erwerbsende. Da AHV, Pensionskasse und jedes 3a-Konto eigene Bezugsalter haben (`pkWithdrawalAge` neu), erzwingt jeder Bezugsbeginn eine Phasengrenze sonst fiele er mitten in eine Phase und rutschte auf die nächste Grenze, unter Umständen Jahre später. Die Phasendauer-Kappung zählt sie mit; Ereignisse im selben Jahr teilen sich eine Grenze. (4) **Planungshorizont in JAHREN** am Szenario (`planningHorizonYears`) statt als Endalter je Person: eine Zahl statt zweier, die bei einem Paar auseinanderlaufen könnten; die Endalter sind die Ableitung. Ersetzt `Person.planningHorizonAge` aus 0.35. (5) **Neuer Szenario-Bildschirm.** Zwei farblich getrennte Hälften: oben die Steuerung in vier Kacheln (Grundeinstellungen mit «Pensionsplanung» je Person · Kennzahlen inkl. neuem **«Vermögen heute»** · Schnellaktionen · Assistent) plus die Zeitachse über die volle Breite; unten die Matrix. Die Aktionsleiste über der Matrix ist verschwunden: **«+ Element», «+ Phase», der Nominal/Real-Umschalter und «Alle auf-/zuklappen» sitzen jetzt in der Ecke oben links der Matrix** sie steuern die Matrix und lagen vorher lose darüber wie Aktionen der ganzen Seite. (6) **Der FPT-Assistent** ersetzt die Karte «Nächste Schritte». Sieben Schritte von der Bestandsaufnahme bis zum Feinschliff. Jeder öffnet ein Popup, das **zuerst erklärt** (welche Fragen der Schritt beantwortet, was man wissen sollte) und **danach das Werkzeug** anbietet; «Selbst erledigen» überspringt beides. Der Haken ist **manuell** das Tool masst sich nicht an zu wissen, wann jemand fertig ist , aber daneben steht der **abgeleitete Stand** («0 Lebensphasen»), damit ein abgehakter Schritt ohne Substanz auffällt. Erledigte rutschen nach unten und werden blass; die Kachel ist gelb, bis alle sieben stehen, dann grün. (7) **Neue Tour**: ein grosses Popup mit einem **nachgebauten** Bildschirm und erfundenen Zahlen, schrittweise erklärt. Das frühere Spotlight lag über der echten Ansicht und hatte auf einem frisch angelegten, leeren Plan nichts hervorzuheben, also gerade dann nicht, wenn es am nötigsten war. Der Preis ist, dass die Attrappe bei UI-Änderungen nachzuführen ist. (8) Entfallen: `PlanWizard` (der Assistent führt jetzt IM Plan statt davor) und `demoplan.ts`. Neue Module `assistant.ts`, neue Komponenten `Assistant`, `AssistantStepDialog`, `AssistantSteps`; neue Endpunkte `PUT /api/elements/<id>/base` und `POST /api/scenarios/<id>/assistant`. **Keine Datenmigration** (Pläne wurden vorgängig gelöscht). 12 Tests ergänzt (322 → 334). | | 0.36 | 2026-07-26 | Claude (Opus 5) | **Der Assistent wird das Herzstück** (neues Kapitel 3.14). Ein Umbau von Onboarding, Bildschirmaufbau und Führung. (1) **Ein Weg hinein.** Die Übersicht zeigt im leeren Zustand nur noch «Meinen ersten Finanzplan anlegen»; der geführte Start und der Beispielplan entfallen. Drei Knöpfe waren eine Wahl, die niemand treffen kann, der das Tool noch nicht kennt. Der Plan-Dialog fragt nur noch sechs Dinge: Name, Haushaltsform, Personennamen, Startjahr, Alter, Inflation. **Das Pensionsalter wird nicht mehr abgefragt** es ist kein Stammdatum, sondern der erste Entscheid der Pensionsplanung, und es erzeugt eine Phasengrenze. Bis dahin gilt das Referenzalter. (2) **Element-Stammdaten** (`FinancialElement.baseData`): Bestand bei Planbeginn und Ausgangs-Annahmen hängen neu am ELEMENT statt in Phase 1. Zwei Gründe ein Startwert ist nicht «phase-1-spezifisch», sondern schlicht der Stand am Anfang; und Elemente lassen sich damit erfassen, **bevor es Lebensphasen gibt**. Genau das braucht die Bestandsaufnahme als erster Schritt. Zugleich sind die Stammdaten die **Wurzel der Feld-Vererbung**: Phase 1 hatte bisher nichts, von dem sie hätte erben können, und fiel auf 0. (3) **Aus einem Fixpunkt werden bis zu vier je Person.** `phaseplan.ts` kannte nur das Erwerbsende. Da AHV, Pensionskasse und jedes 3a-Konto eigene Bezugsalter haben (`pkWithdrawalAge` neu), erzwingt jeder Bezugsbeginn eine Phasengrenze sonst fiele er mitten in eine Phase und rutschte auf die nächste Grenze, unter Umständen Jahre später. Die Phasendauer-Kappung zählt sie mit; Ereignisse im selben Jahr teilen sich eine Grenze. (4) **Planungshorizont in JAHREN** am Szenario (`planningHorizonYears`) statt als Endalter je Person: eine Zahl statt zweier, die bei einem Paar auseinanderlaufen könnten; die Endalter sind die Ableitung. Ersetzt `Person.planningHorizonAge` aus 0.35. (5) **Neuer Szenario-Bildschirm.** Zwei farblich getrennte Hälften: oben die Steuerung in vier Kacheln (Grundeinstellungen mit «Pensionsplanung» je Person · Kennzahlen inkl. neuem **«Vermögen heute»** · Schnellaktionen · Assistent) plus die Zeitachse über die volle Breite; unten die Matrix. Die Aktionsleiste über der Matrix ist verschwunden: **«+ Element», «+ Phase», der Nominal/Real-Umschalter und «Alle auf-/zuklappen» sitzen jetzt in der Ecke oben links der Matrix** sie steuern die Matrix und lagen vorher lose darüber wie Aktionen der ganzen Seite. (6) **Der FPT-Assistent** ersetzt die Karte «Nächste Schritte». Sieben Schritte von der Bestandsaufnahme bis zum Feinschliff. Jeder öffnet ein Popup, das **zuerst erklärt** (welche Fragen der Schritt beantwortet, was man wissen sollte) und **danach das Werkzeug** anbietet; «Selbst erledigen» überspringt beides. Der Haken ist **manuell** das Tool masst sich nicht an zu wissen, wann jemand fertig ist , aber daneben steht der **abgeleitete Stand** («0 Lebensphasen»), damit ein abgehakter Schritt ohne Substanz auffällt. Erledigte rutschen nach unten und werden blass; die Kachel ist gelb, bis alle sieben stehen, dann grün. (7) **Neue Tour**: ein grosses Popup mit einem **nachgebauten** Bildschirm und erfundenen Zahlen, schrittweise erklärt. Das frühere Spotlight lag über der echten Ansicht und hatte auf einem frisch angelegten, leeren Plan nichts hervorzuheben, also gerade dann nicht, wenn es am nötigsten war. Der Preis ist, dass die Attrappe bei UI-Änderungen nachzuführen ist. (8) Entfallen: `PlanWizard` (der Assistent führt jetzt IM Plan statt davor) und `demoplan.ts`. Neue Module `assistant.ts`, neue Komponenten `Assistant`, `AssistantStepDialog`, `AssistantSteps`; neue Endpunkte `PUT /api/elements/<id>/base` und `POST /api/scenarios/<id>/assistant`. **Keine Datenmigration** (Pläne wurden vorgängig gelöscht). 12 Tests ergänzt (322 → 334). |
| 0.35 | 2026-07-26 | Claude (Opus 5) | **Die Pensionierung ist eine Eigenschaft der PERSON, nicht der Zeitachse** (neues Kapitel 3.13). Der grösste Eingriff seit V7. Bisher hing jeder Bezugs-Entscheid an `transitionValues[phaseId]` am Schlüssel Element × Phasen-ID. Daraus folgte fast alles, was an der Pensionsplanung störte: Entscheide, die inhaltlich **eine** Frage sind, lagen in drei weit auseinander liegenden Matrix-Zellen; das Alter zu ändern war ein struktureller Eingriff, bei dem Entscheide über `mergeTransition` verlustbehaftet von Grenze zu Grenze gerettet werden mussten; ein Szenario nur für ein anderes Pensionsalter hiess, alles neu zu entscheiden; und der Ziel-Solver (Roadmap Nr. 21) hätte nichts zum Anfassen gehabt. **Neu liegt der Entscheid am ELEMENT** (`FinancialElement.retirementDecision`, ohne Phasenbezug) und überlebt damit jede Verschiebung der Zeitachse. (1) **Neuer Pensionierungs-Bildschirm** gleichrangig neben der Matrix, mit der **Rentenlücke** als Leitzahl keine neue Rechnung, sondern die Verzehrquote im ersten voll pensionierten Jahr; sie fehlte bisher nur als Begriff. Gerechnet im Rechenkern (`PlanComputed.retirement`), damit Bildschirm und PDF-Bericht nicht auseinanderlaufen. Die Matrix-Zellen am Pensions-Übergang bleiben bedienbar und nutzen **dieselbe Komponente** (`RetirementFields`) zwei Ansichten auf ein Objekt, kein Duplikat. (2) **AHV-Vorbezug und -Aufschub** werden gerechnet (Kap. 4.4.7 neu geschrieben): Kürzung 6,8 %/Jahr, Zuschlag +5,2/10,8/17,1/24,0/31,5 % nach 15 Jahren, Teilbezug 2080 %. Bis 0.34 startete die Rente **immer** mit 65 wer mit 62 aufhörte, bekam die ungekürzte Rente drei Jahre später, wer bis 68 arbeitete, verschenkte den Zuschlag. Dabei wurde eine fachliche Trennung eingeführt, die es vorher gar nicht gab: **Rentenbeginn und Beitragspflicht sind zwei verschiedene Alter.** Wer mit 62 aufhört und ab 63 vorbezieht, bezieht ab 63 **und** zahlt bis 65 weiter als Nichterwerbstätige(r). (3) **Pensionskasse: ein Regler statt eines Modus.** `payoutMode` (`PENSION`/`CAPITAL`/`COMBI`) und der absolute `capitalAmount` entfallen zugunsten von `capitalSharePct` (0100 %). Als Quote, weil sich das Guthaben mit dem Pensionsalter ändert ein fixer Betrag bedeutete beim Verschieben still ein anderes Verhältnis. (4) **Säule 3a: wählbares Bezugsalter** (6070) statt starr am Pensions-Übergang. Ein Konto lässt sich nur ganz auflösen, und alle Bezüge desselben Jahres werden steuerlich zusammengezählt gestaffelt wird deshalb über Konten und Jahre. Gezogen wird an der ersten Phasengrenze bei oder nach dem Wunschalter. (5) **Planungshorizont** (`Person.planningHorizonAge`): Bisher ergab sich das Planende stillschweigend aus der Summe der Phasendauern zwei Szenarien konnten unbemerkt verschieden weit rechnen und waren nicht vergleichbar. Neue Funktion `planHorizonChange`, neuer Endpunkt `POST /api/scenarios/<id>/horizon`. (6) **Ampel mit drei Zuständen** (Kap. 3.5.3 neu): `unbeantwortet` · `auf Vorgabe` · `bestätigt`. Mit durchgängigen Vorgaben bewusst, damit niemand am Anfang Fragen beantworten muss, die er erst am Ende beantworten kann entstand ein Zustand, den das Modell nicht kannte: Das System **hat** eine Antwort, nur nicht die des Benutzers. Eine Vorgabe wie «volle Rente statt Kapitalbezug» als beantwortet zu zählen hiesse, sie unbemerkt durchgehen zu lassen. Sie zählt deshalb mit, aber getrennt benannt: «2 offene Entscheide · 3 Vorgaben ungeprüft», bestätigt wird je Säule. (7) **Drei neue Treiber** in Tornado und Live-Simulation: PK-Kapitalanteil, AHV-Vorbezug/Aufschub (in Monaten, neue Einheit `delta_months`) und das bestehende Pensionsalter wird endlich **korrekt**, weil der AHV-Beginn jetzt mitzieht. (8) Nebenbei zwei Vereinfachungen: Die Beitragskarriere vor Planbeginn lag an **zwei** Orten (Übergangszelle bzw. Phasenzelle für bereits Pensionierte) mit zwei Codepfaden jetzt an einem. Und Szenario-Kopie, Versionierung und Diff tragen den Entscheid mit; ohne das wäre die Kopie genau für den Zweck unbrauchbar, für den man sie am häufigsten anlegt. **Keine Datenmigration** (Testdaten wurden vorgängig gelöscht); alte Werte in `transitionValues` werden ignoriert, betroffene Elemente erscheinen als «Vorgabe ungeprüft». (9) **Assistent:** neuer Überblicksschritt «Deine Pensionierung» (Kap. 3.2.8) er ZEIGT Rentenlücke und Reichweite, statt Fragen zu stellen, die zu diesem Zeitpunkt niemand beantworten kann. Die Vorschau wird gerechnet, bevor der Plan existiert; Vorschau und Anlage speisen sich aus EINER Element-Liste, damit sie nicht auseinanderlaufen. Neues Modul `retirement-decision.ts`, neue Komponenten `RetirementPanel` und `RetirementFields`. 23 Tests ergänzt (292 → 315). | | 0.35 | 2026-07-26 | Claude (Opus 5) | **Die Pensionierung ist eine Eigenschaft der PERSON, nicht der Zeitachse** (neues Kapitel 3.13). Der grösste Eingriff seit V7. Bisher hing jeder Bezugs-Entscheid an `transitionValues[phaseId]` am Schlüssel Element × Phasen-ID. Daraus folgte fast alles, was an der Pensionsplanung störte: Entscheide, die inhaltlich **eine** Frage sind, lagen in drei weit auseinander liegenden Matrix-Zellen; das Alter zu ändern war ein struktureller Eingriff, bei dem Entscheide über `mergeTransition` verlustbehaftet von Grenze zu Grenze gerettet werden mussten; ein Szenario nur für ein anderes Pensionsalter hiess, alles neu zu entscheiden; und der Ziel-Solver (Roadmap Nr. 21) hätte nichts zum Anfassen gehabt. **Neu liegt der Entscheid am ELEMENT** (`FinancialElement.retirementDecision`, ohne Phasenbezug) und überlebt damit jede Verschiebung der Zeitachse. (1) **Neuer Pensionierungs-Bildschirm** gleichrangig neben der Matrix, mit der **Rentenlücke** als Leitzahl keine neue Rechnung, sondern die Verzehrquote im ersten voll pensionierten Jahr; sie fehlte bisher nur als Begriff. Gerechnet im Rechenkern (`PlanComputed.retirement`), damit Bildschirm und PDF-Bericht nicht auseinanderlaufen. Die Matrix-Zellen am Pensions-Übergang bleiben bedienbar und nutzen **dieselbe Komponente** (`RetirementFields`) zwei Ansichten auf ein Objekt, kein Duplikat. (2) **AHV-Vorbezug und -Aufschub** werden gerechnet (Kap. 4.4.7 neu geschrieben): Kürzung 6,8 %/Jahr, Zuschlag +5,2/10,8/17,1/24,0/31,5 % nach 15 Jahren, Teilbezug 2080 %. Bis 0.34 startete die Rente **immer** mit 65 wer mit 62 aufhörte, bekam die ungekürzte Rente drei Jahre später, wer bis 68 arbeitete, verschenkte den Zuschlag. Dabei wurde eine fachliche Trennung eingeführt, die es vorher gar nicht gab: **Rentenbeginn und Beitragspflicht sind zwei verschiedene Alter.** Wer mit 62 aufhört und ab 63 vorbezieht, bezieht ab 63 **und** zahlt bis 65 weiter als Nichterwerbstätige(r). (3) **Pensionskasse: ein Regler statt eines Modus.** `payoutMode` (`PENSION`/`CAPITAL`/`COMBI`) und der absolute `capitalAmount` entfallen zugunsten von `capitalSharePct` (0100 %). Als Quote, weil sich das Guthaben mit dem Pensionsalter ändert ein fixer Betrag bedeutete beim Verschieben still ein anderes Verhältnis. (4) **Säule 3a: wählbares Bezugsalter** (6070) statt starr am Pensions-Übergang. Ein Konto lässt sich nur ganz auflösen, und alle Bezüge desselben Jahres werden steuerlich zusammengezählt gestaffelt wird deshalb über Konten und Jahre. Gezogen wird an der ersten Phasengrenze bei oder nach dem Wunschalter. (5) **Planungshorizont** (`Person.planningHorizonAge`): Bisher ergab sich das Planende stillschweigend aus der Summe der Phasendauern zwei Szenarien konnten unbemerkt verschieden weit rechnen und waren nicht vergleichbar. Neue Funktion `planHorizonChange`, neuer Endpunkt `POST /api/scenarios/<id>/horizon`. (6) **Ampel mit drei Zuständen** (Kap. 3.5.3 neu): `unbeantwortet` · `auf Vorgabe` · `bestätigt`. Mit durchgängigen Vorgaben bewusst, damit niemand am Anfang Fragen beantworten muss, die er erst am Ende beantworten kann entstand ein Zustand, den das Modell nicht kannte: Das System **hat** eine Antwort, nur nicht die des Benutzers. Eine Vorgabe wie «volle Rente statt Kapitalbezug» als beantwortet zu zählen hiesse, sie unbemerkt durchgehen zu lassen. Sie zählt deshalb mit, aber getrennt benannt: «2 offene Entscheide · 3 Vorgaben ungeprüft», bestätigt wird je Säule. (7) **Drei neue Treiber** in Tornado und Live-Simulation: PK-Kapitalanteil, AHV-Vorbezug/Aufschub (in Monaten, neue Einheit `delta_months`) und das bestehende Pensionsalter wird endlich **korrekt**, weil der AHV-Beginn jetzt mitzieht. (8) Nebenbei zwei Vereinfachungen: Die Beitragskarriere vor Planbeginn lag an **zwei** Orten (Übergangszelle bzw. Phasenzelle für bereits Pensionierte) mit zwei Codepfaden jetzt an einem. Und Szenario-Kopie, Versionierung und Diff tragen den Entscheid mit; ohne das wäre die Kopie genau für den Zweck unbrauchbar, für den man sie am häufigsten anlegt. **Keine Datenmigration** (Testdaten wurden vorgängig gelöscht); alte Werte in `transitionValues` werden ignoriert, betroffene Elemente erscheinen als «Vorgabe ungeprüft». (9) **Assistent:** neuer Überblicksschritt «Deine Pensionierung» (Kap. 3.2.8) er ZEIGT Rentenlücke und Reichweite, statt Fragen zu stellen, die zu diesem Zeitpunkt niemand beantworten kann. Die Vorschau wird gerechnet, bevor der Plan existiert; Vorschau und Anlage speisen sich aus EINER Element-Liste, damit sie nicht auseinanderlaufen. Neues Modul `retirement-decision.ts`, neue Komponenten `RetirementPanel` und `RetirementFields`. 23 Tests ergänzt (292 → 315). |
| 0.34 | 2026-07-25 | Claude (Opus 5) | **Modul-Review 4, Nachbesserungen: die Übergangs-Entscheide bis ans Ende durchgezogen.** (1) **Zuordnung überall dort, wo Elemente über ihren Namen angeboten werden.** Zwei Personen nennen ihre Guthaben typischerweise gleich («Säule 3a», «ETF»); ohne die Person wählt man im Dropdown blind. Betroffen waren das **Ziel der Anlage-Quote** beim Kapitalbezug (dort mit hoher Folgewirkung: Ein Fehlgriff leitet das Alterskapital in das Depot der falschen Person) und die Zeilen im Dialog **«Kapital verteilen»**. Die Klartext-Zuordnung liegt neu als `ownerLabel` in `src/lib/elements.ts` und wird von allen drei Stellen genutzt. (2) **Herkunft des umgeleiteten Alterskapitals wird ausgewiesen.** Fliessen PK **und** 3a in dasselbe Vermögens-Element, stand dort bisher nur eine Summe ob wirklich beide angekommen sind, liess sich nicht prüfen. `Carry` und `ElementPhaseComputed` führen neu `capitalInSources` bzw. `capitalFromTransferSources` mit: Betrag **je Quelle**, benannt mit Element **und** Person. Sichtbar am Ziel-Element und im Dialog «Kapital verteilen». Das Feld heisst neu **«Zusatzinvestition aus Kapitalbezug»** (vorher «Davon aus Kapitalbezug (PK/3a)» irreführend, weil es kein Anteil an der manuell erfassten Zusatzinvestition ist, sondern ein zweiter, davon unabhängiger Betrag). (3) **Der Dialog «Kapital verteilen» zeigt das bereits Zugeteilte.** Vorher stand dort eine **0**, obwohl die Quote geflossen war das Feld führt nur den manuell erfassten Teil. Neu erscheint darüber eine read-only Zeile mit dem aus dem Bezugs-Entscheid stammenden Betrag samt Aufschlüsselung, darunter das editierbare Feld und die Summe beider. (4) **Bezogene Vorsorge-Guthaben werden in beiden Verteil-Dialogen nicht mehr angeboten.** Nach der Pensionierung ignoriert die Rechnung Beiträge und Zusatzeinlagen in PK und Säule 3a die Dialoge boten sie trotzdem an, inklusive eines aus der Vorphase geerbten 3a-Beitrags, der dort als aktive Rate erschien. Der Filter prüfte nur den `status` (`ACTIVE`), und der bleibt nach dem Bezug bestehen. Neu setzt die Rechnung selbst das Kennzeichen `acceptsCapital: false`; die Dialoge lesen es, statt die Regel ein zweites Mal nachzubauen. 4 Tests ergänzt (288 → 292). | | 0.34 | 2026-07-25 | Claude (Opus 5) | **Modul-Review 4, Nachbesserungen: die Übergangs-Entscheide bis ans Ende durchgezogen.** (1) **Zuordnung überall dort, wo Elemente über ihren Namen angeboten werden.** Zwei Personen nennen ihre Guthaben typischerweise gleich («Säule 3a», «ETF»); ohne die Person wählt man im Dropdown blind. Betroffen waren das **Ziel der Anlage-Quote** beim Kapitalbezug (dort mit hoher Folgewirkung: Ein Fehlgriff leitet das Alterskapital in das Depot der falschen Person) und die Zeilen im Dialog **«Kapital verteilen»**. Die Klartext-Zuordnung liegt neu als `ownerLabel` in `src/lib/elements.ts` und wird von allen drei Stellen genutzt. (2) **Herkunft des umgeleiteten Alterskapitals wird ausgewiesen.** Fliessen PK **und** 3a in dasselbe Vermögens-Element, stand dort bisher nur eine Summe ob wirklich beide angekommen sind, liess sich nicht prüfen. `Carry` und `ElementPhaseComputed` führen neu `capitalInSources` bzw. `capitalFromTransferSources` mit: Betrag **je Quelle**, benannt mit Element **und** Person. Sichtbar am Ziel-Element und im Dialog «Kapital verteilen». Das Feld heisst neu **«Zusatzinvestition aus Kapitalbezug»** (vorher «Davon aus Kapitalbezug (PK/3a)» irreführend, weil es kein Anteil an der manuell erfassten Zusatzinvestition ist, sondern ein zweiter, davon unabhängiger Betrag). (3) **Der Dialog «Kapital verteilen» zeigt das bereits Zugeteilte.** Vorher stand dort eine **0**, obwohl die Quote geflossen war das Feld führt nur den manuell erfassten Teil. Neu erscheint darüber eine read-only Zeile mit dem aus dem Bezugs-Entscheid stammenden Betrag samt Aufschlüsselung, darunter das editierbare Feld und die Summe beider. (4) **Bezogene Vorsorge-Guthaben werden in beiden Verteil-Dialogen nicht mehr angeboten.** Nach der Pensionierung ignoriert die Rechnung Beiträge und Zusatzeinlagen in PK und Säule 3a die Dialoge boten sie trotzdem an, inklusive eines aus der Vorphase geerbten 3a-Beitrags, der dort als aktive Rate erschien. Der Filter prüfte nur den `status` (`ACTIVE`), und der bleibt nach dem Bezug bestehen. Neu setzt die Rechnung selbst das Kennzeichen `acceptsCapital: false`; die Dialoge lesen es, statt die Regel ein zweites Mal nachzubauen. 4 Tests ergänzt (288 → 292). |
@@ -306,7 +317,7 @@ die ersten 72 Byte), keine ARIA-Labels auf der Login-Maske und der Befehls-Palet
Es gibt genau **einen** Weg: den Knopf «Meinen ersten Finanzplan anlegen» in der Übersicht Es gibt genau **einen** Weg: den Knopf «Meinen ersten Finanzplan anlegen» in der Übersicht
bzw. das «+» in der Seitenleiste. Beide öffnen denselben Dialog. Zur Begründung, warum die bzw. das «+» in der Seitenleiste. Beide öffnen denselben Dialog. Zur Begründung, warum die
frühere Auswahl aus drei Wegen entfallen ist, siehe frühere Auswahl aus drei Wegen entfallen ist, siehe
[3.2.8](#328-der-einstieg-ein-weg-eine-tour-ein-assistent). [3.2.8](#328-der-einstieg-ein-weg-eine-tour-eine-bestandsaufnahme).
Der Dialog fragt Name plus Grundprofil -- **ohne Pensionsalter**, das gehört in die Der Dialog fragt Name plus Grundprofil -- **ohne Pensionsalter**, das gehört in die
Pensionsplanung: Pensionsplanung:
@@ -427,7 +438,7 @@ und Grafiken beschriften damit Jahre statt nur Alter. Die Berechnung rechnet unv
Beim Anlegen wird das laufende Jahr vorbelegt; bestehende Pläne wurden per Migration darauf Beim Anlegen wird das laufende Jahr vorbelegt; bestehende Pläne wurden per Migration darauf
gesetzt. Kalenderjahr eines Planjahrs: `startYear + (Jahr 1)`. gesetzt. Kalenderjahr eines Planjahrs: `startYear + (Jahr 1)`.
### 3.2.8 Der Einstieg: ein Weg, eine Tour, ein Assistent ### 3.2.8 Der Einstieg: ein Weg, eine Tour, eine Bestandsaufnahme
Bis 0.35 standen im leeren Zustand **drei** Knöpfe: geführt starten, Beispielplan ansehen, leer Bis 0.35 standen im leeren Zustand **drei** Knöpfe: geführt starten, Beispielplan ansehen, leer
starten. Das ist eine Wahl, die niemand treffen kann, der das Tool noch nicht kennt -- und sie starten. Das ist eine Wahl, die niemand treffen kann, der das Tool noch nicht kennt -- und sie
@@ -442,7 +453,7 @@ Seit 0.36 gibt es genau einen Weg:
Plan-Dialog (sechs Felder) Plan-Dialog (sechs Felder)
| |
v v
Basisszenario, leer -> Tour (Demo-Popup) -> FPT-Assistent Basisszenario, leer -> Tour (Demo-Popup) -> Bestandsaufnahme
``` ```
**Der Plan-Dialog** fragt nur noch: Name des Plans, Haushaltsform, Namen der Personen **Der Plan-Dialog** fragt nur noch: Name des Plans, Haushaltsform, Namen der Personen
@@ -451,14 +462,14 @@ Seitenleiste.
**Das Pensionsalter wird bewusst NICHT gefragt.** Es ist kein Stammdatum, sondern der erste **Das Pensionsalter wird bewusst NICHT gefragt.** Es ist kein Stammdatum, sondern der erste
Entscheid der Pensionsplanung -- und es erzeugt eine Phasengrenze Entscheid der Pensionsplanung -- und es erzeugt eine Phasengrenze
([3.14.4](#3144-fixpunkte-jeder-bezugsbeginn-erzwingt-eine-phasengrenze)). Bis Schritt 2 des ([3.14.5](#3145-fixpunkte-jeder-bezugsbeginn-erzwingt-eine-phasengrenze)). Im Basisszenario
Assistenten gilt das Referenzalter. gilt durchgehend das Referenzalter 65.
**Der frühere Plan-Assistent (`PlanWizard`) und der Beispielplan sind entfallen.** Der Wizard **Der frühere Plan-Assistent (`PlanWizard`) und der Beispielplan sind entfallen.** Der Wizard
führte VOR dem Plan durch ein Formular und liess einen danach mit der Matrix allein; der führte VOR dem Plan durch ein Formular und liess einen danach mit der Matrix allein. Was der
Assistent führt jetzt IM Plan und bleibt dort, solange man ihn braucht Beispielplan leistete -- einmal sehen, wie ein gefüllter Plan aussieht --, übernimmt die Tour;
([3.14](#314-der-fpt-assistent)). Was der Beispielplan leistete -- einmal sehen, wie ein was danach zu tun ist, sagt die Übersicht der offenen Punkte
gefüllter Plan aussieht --, übernimmt die Tour. ([3.14](#314-bestandsaufnahme-und-offene-punkte)).
### 3.3.1 Phase anlegen ### 3.3.1 Phase anlegen
@@ -468,12 +479,28 @@ Neue Phasen werden **immer am Ende der Kette** angehängt (`sequenceNumber = Anz
**Automatische Dauer-Kappung:** Die Dauer wird ans nächste Pensionsereignis gekappt. Formel **Automatische Dauer-Kappung:** Die Dauer wird ans nächste Pensionsereignis gekappt. Formel
(`maxPhaseDuration`): für jede Person, die zu Phasenbeginn noch erwerbstätig ist, gilt (`maxPhaseDuration`): für jede Person, die zu Phasenbeginn noch erwerbstätig ist, gilt
`retirementAge (age + yearsBefore)`; das Minimum dieser Werte ist die Obergrenze. Ist keine `retirementAge (age + yearsBefore)`; das Minimum dieser Werte ist die Obergrenze. Ist keine
Person mehr erwerbstätig, gibt es keine Obergrenze (`null`). Diese Kappung ist im Dialog Person mehr erwerbstätig, gibt es keine Obergrenze (`null`). Diese Kappung wird
sichtbar („max. N") **und** wird serverseitig erzwungen. serverseitig erzwungen.
Fachliche Begründung: Eine Phase darf keine Pensionierung überspannen, weil der Phasentyp Fachliche Begründung: Eine Phase darf keine Pensionierung überspannen, weil der Phasentyp
und die AHV-/PK-Renten am Phasenbeginn ausgewertet werden. und die AHV-/PK-Renten am Phasenbeginn ausgewertet werden.
**Der Dialog nennt seit 0.41 den Grund, nicht nur die Zahl.** Vorher stand dort «max. 12»
eine Sperre ohne Erklärung wirkt willkürlich, und man vermutet einen Fehler. Neu steht im
Klartext, welches Ereignis die Grenze setzt («Anna geht mit 65 in Pension»), warum das so ist
(die Rechnung wertet Erwerbsstatus und Renten am Phasen**beginn** aus) und dass es weitergeht
(«danach legst du einfach die nächste Phase an»). Sind alle Personen bereits pensioniert, sagt
der Dialog stattdessen, dass die Dauer frei ist.
> **Bekannte Lücke.** `maxPhaseDuration` kann zusätzliche Fixpunkte entgegennehmen (AHV-Beginn,
> PK-Bezug, jedes 3a-Konto, siehe [3.14.5](#3145-fixpunkte-jeder-bezugsbeginn-erzwingt-eine-phasengrenze)),
> aber weder der Dialog noch der Endpunkt übergeben sie gekappt wird nur am Erwerbsende. Im
> Basisszenario fällt das nicht ins Gewicht, weil das Pensionsalter dort fest **65** ist. Wer
> im Übergangs-Dialog ein abweichendes 3a-Bezugsalter setzt, kann die Grenze jedoch
> überspannen; der Bezug rutscht dann auf die nächste Phasengrenze. Bewusst zurückgestellt bis
> zur Szenario-Art «Frühpensionierung» (Roadmap Nr. 48), wo abweichende Bezugsalter zum
> eigentlichen Thema werden.
**Default-Dauer:** die Kappung, sonst 10 Jahre. **Default-Dauer:** die Kappung, sonst 10 Jahre.
**Default-Name:** Phase 1 → „Erste Lebensphase"; sonst „Pensionsphase" wenn zu Phasenbeginn **Default-Name:** Phase 1 → „Erste Lebensphase"; sonst „Pensionsphase" wenn zu Phasenbeginn
mindestens eine Person pensioniert ist, sonst „Erwerbsphase". mindestens eine Person pensioniert ist, sonst „Erwerbsphase".
@@ -554,10 +581,16 @@ Referenz: `src/lib/elements.ts` (`PERSON_ONLY_CATEGORIES`), `src/app/api/scenari
### 3.4.2 Element anlegen ### 3.4.2 Element anlegen
Der Dialog „Finanzielles Element" fragt Kategorie, Zuordnung, Bezeichnung **und direkt die Der Dialog „Finanzielles Element" fragt Kategorie, Zuordnung, Bezeichnung **und den Bestand
Werte der ersten Lebensphase** ab. Nach dem Anlegen (`POST .../elements`) werden die Werte per von heute** ab dieselben Felder wie die Bestandsaufnahme (`BaseFields`). Nach dem Anlegen
`PUT .../phase/<firstPhaseId>` gespeichert, sofern mindestens ein Feld gesetzt wurde. Der (`POST .../elements`) landen sie per `PUT .../elements/<id>/base` in den **Stammdaten**, sofern
Bezeichnungs-Default ist das Kategorie-Label. mindestens ein Feld gesetzt wurde. Der Bezeichnungs-Default ist das Kategorie-Label.
Bis 0.39 schrieb der Dialog stattdessen in `phase/<firstPhaseId>` und legte damit einen zweiten
Ort für denselben Betrag an ([3.14.3](#3143-ein-wert-ein-ort)). Aus derselben Änderung folgt:
Der Dialog **braucht keine Lebensphase mehr**. Stammdaten hängen am Element, ein Bestand lässt
sich damit vor jeder Zeitplanung aufnehmen. Die Annahmen (Renditen, Teuerung, Zins) folgen in
der Phasenzelle, wo sie hingehören.
`orderIndex` = bisheriges Maximum + 1; bestimmt die Reihenfolge innerhalb der Kategoriegruppe. `orderIndex` = bisheriges Maximum + 1; bestimmt die Reihenfolge innerhalb der Kategoriegruppe.
@@ -744,7 +777,7 @@ Seit 0.35 gibt es **drei** Zustände statt zwei. Der dritte ist der interessante
|---|---| |---|---|
| **unbeantwortet** | Das System weiss nichts. Verkauf/Halten, Tilgung, Cash-Übergang. | | **unbeantwortet** | Das System weiss nichts. Verkauf/Halten, Tilgung, Cash-Übergang. |
| **auf Vorgabe** | Das System **hat** eine Antwort nur nicht die des Benutzers. Gilt für die Pensionierungs-Entscheide (AHV/PK/3a), die seit 0.35 durchgängige Vorgaben haben. | | **auf Vorgabe** | Das System **hat** eine Antwort nur nicht die des Benutzers. Gilt für die Pensionierungs-Entscheide (AHV/PK/3a), die seit 0.35 durchgängige Vorgaben haben. |
| **bestätigt** | Der Benutzer hat hingeschaut (`retirementDecision.confirmed`), je Säule. | | **bestätigt** | Der Benutzer hat hingeschaut (`retirementDecision.confirmed`), je Säule. Seit 0.41 heisst das im UI durchgehend «angeschaut» dieselbe Vokabel wie bei den Phasenzellen ([3.14.2](#3142-bestätigen-heisst-ich-habe-hingeschaut)), weil es derselbe Gedanke ist: dort auf den Werten der Vorphase, hier auf einer Vorgabe des Tools. Zwei Wörter für eine Sache hatten mehr verwirrt als geholfen. Der Erklärtext sagt neu ausdrücklich, dass der Haken **keine Zahl ändert**. |
Warum der dritte Zustand nötig wurde: Die Vorgaben sind Absicht ohne sie müsste man am Warum der dritte Zustand nötig wurde: Die Vorgaben sind Absicht ohne sie müsste man am
Anfang Fragen beantworten, die man erst am Ende beantworten kann, und der Plan wäre bis dahin Anfang Fragen beantworten, die man erst am Ende beantworten kann, und der Plan wäre bis dahin
@@ -770,6 +803,12 @@ Der Übergangs-Spaltenkopf zeigt „N offen" (Akzentfarbe), „N Vorgaben" (gede
Handlungsdefizit ist) oder „geprüft" (grün, Häkchen). Offene Zellen sind hervorgehoben und Handlungsdefizit ist) oder „geprüft" (grün, Häkchen). Offene Zellen sind hervorgehoben und
zeigen „?". zeigen „?".
**Seit 0.39 gilt derselbe Mechanismus auch für Phasenzellen** (`PhaseData.confirmed`,
[3.14.2](#3142-bestätigen-heisst-ich-habe-hingeschaut)). Eine neue Phase übernimmt die Werte
der Vorphase; sie gelten als unbestätigt, bis jemand hingeschaut hat. Damit ist die Ampel
nicht mehr auf die Übergänge beschränkt, sondern deckt die ganze Matrix ab -- die Zählung
liegt in `review.ts`, die Anzeige teilt sich die Attention-Farbe mit den Übergängen.
**Auswirkung auf den PDF-Bericht:** Die Kennzahl «Offene Entscheide» weist beide Töpfe **Auswirkung auf den PDF-Bericht:** Die Kennzahl «Offene Entscheide» weist beide Töpfe
zusammen aus. Berichte von vor 0.35 sind deshalb nicht direkt vergleichbar. zusammen aus. Berichte von vor 0.35 sind deshalb nicht direkt vergleichbar.
@@ -1161,6 +1200,11 @@ bestehende Felder** über die bestehenden Endpunkte an der Berechnung änder
| Immobilie | `extraAmortization` | dem **Übergang davor** | | Immobilie | `extraAmortization` | dem **Übergang davor** |
| Sonstige Schulden | `immediateRepayment` | dem **Übergang davor** | | Sonstige Schulden | `immediateRepayment` | dem **Übergang davor** |
Die beiden letzten waren bis 0.39 **zusätzlich** in der Übergangszelle erfassbar. Das war die
falsche Stelle: Sie zehren vom Kapital der FOLGEphase, und der zuständige Dialog hängt an deren
Phasenkopf. Seit 0.40 stehen sie nur noch hier; angezeigt werden sie in der **Phasenzelle**,
zusammen mit der Zusatzeinlage aus demselben Topf.
Jede Zeile nennt neben dem Namen die **Zuordnung** (Person A / Person B / Gemeinsam) ohne sie Jede Zeile nennt neben dem Namen die **Zuordnung** (Person A / Person B / Gemeinsam) ohne sie
sind zwei gleichnamige Guthaben nicht unterscheidbar. **Bezogene** PK- und 3a-Guthaben erscheinen sind zwei gleichnamige Guthaben nicht unterscheidbar. **Bezogene** PK- und 3a-Guthaben erscheinen
gar nicht mehr (Kennzeichen `acceptsCapital`, siehe 3.12.5): Die Rechnung ignoriert dort jede gar nicht mehr (Kennzeichen `acceptsCapital`, siehe 3.12.5): Die Rechnung ignoriert dort jede
@@ -1176,6 +1220,18 @@ Was nicht verteilt wird, **bleibt automatisch auf dem Cash** dafür braucht
ist das Verhalten des Modells. Wird mehr verteilt als vorhanden, startet die Folgephase mit ist das Verhalten des Modells. Wird mehr verteilt als vorhanden, startet die Folgephase mit
negativem Cash; der Dialog weist das als Liquiditätslücke aus. negativem Cash; der Dialog weist das als Liquiditätslücke aus.
**Der Knopf ist grün mit Haken, sobald etwas zugeteilt ist**, und trägt sonst die
Attention-Farbe. Vorher sah man einer fertig verteilten Phase nicht an, dass sie fertig war
der Knopf sah immer gleich aus. «Erledigt» heisst hier: Es ist Kapital da und **etwas davon**
ist zugeteilt; wer bewusst nur einen Teil verteilt und den Rest liegen lässt, hat entschieden.
Anders als bei der Quote gibt es dafür kein gespeichertes Kennzeichen das Kapital ist keine
Frage, die man beantworten MUSS, sondern eine Gelegenheit.
**Der Knopf ist immer sichtbar**, auch wenn der Topf leer ist. Bis 0.39 hing er an
`pot.total > 0` also am **Rest**. Wer alles verteilt hatte, sah ihn nicht mehr. Solange die
Beträge im Element noch änderbar waren, ging das; seit 0.40 sind sie read-only, und der Knopf
ist der einzige Weg dorthin. Ohne ihn wäre man in der eigenen Zuteilung eingesperrt.
**Bereits zugeteiltes Alterskapital steht read-only darüber.** Das Eingabefeld führt nur den **Bereits zugeteiltes Alterskapital steht read-only darüber.** Das Eingabefeld führt nur den
hier erfassten Teil; was aus der Prozent-Quote des Bezugs-Entscheids stammt hier erfassten Teil; was aus der Prozent-Quote des Bezugs-Entscheids stammt
([3.12.5](#3125-punkt-c-verwendung-des-bezogenen-alterskapitals)), liegt daneben und ist nach ([3.12.5](#3125-punkt-c-verwendung-des-bezogenen-alterskapitals)), liegt daneben und ist nach
@@ -1186,9 +1242,19 @@ wirkungslos.
jährliche Raten: `annualContribution` (3a, Sonstiges Vermögen), `annualWithdrawal` (Sonstiges jährliche Raten: `annualContribution` (3a, Sonstiges Vermögen), `annualWithdrawal` (Sonstiges
Vermögen), `amortization` (Immobilie), `annualRepayment` (Schulden). Vermögen), `amortization` (Immobilie), `annualRepayment` (Schulden).
> **Die Pensionskasse fehlt hier bewusst.** Ihr Beitrag stammt aus dem Bruttolohn und belastet Seit 0.40 sind diese Felder **ausschliesslich** hier erfassbar; in der Phasenzelle stehen sie
> das Cash-Konto nicht ([4.6.3](#463-pension_fund)) er lässt sich also gar nicht aus der Quote read-only mit Absprung hierher ([3.14.3](#3143-ein-wert-ein-ort)).
> verteilen.
> **Die Pensionskasse ist dabei aber nicht in der Quote.** Ihr Beitrag stammt aus dem
> Bruttolohn und belastet das Cash-Konto nicht ([4.6.3](#463-pension_fund)); er lässt sich also
> gar nicht aus der Quote verteilen. Er steht seit 0.40 trotzdem in diesem Dialog, in einem
> **eigenen Block** «Aus dem Bruttolohn (ausserhalb der Quote)» und ausserhalb der Bilanz
> darunter. Der Grund ist der Umbau selbst: Dies ist der einzige Ort für jährliche Beträge
> ohne ihn wäre der PK-Beitrag unerreichbar und fiele still auf 0.
Bei der **Säule 3a** stehen hier auch die **Obergrenze** und der Schalter «grosse Säule 3a»
(Selbstständige ohne PK). Der Schalter steht **über** dem Betrag, dessen Grenze er bestimmt
in der Phasenzelle, getrennt vom Feld, war er eine Einstellung ohne sichtbare Wirkung.
Auch hier gilt der Filter über `acceptsCapital`: Eine **bezogene Säule 3a** nimmt keine Einzahlung Auch hier gilt der Filter über `acceptsCapital`: Eine **bezogene Säule 3a** nimmt keine Einzahlung
mehr auf und erscheint deshalb nicht. Bis 0.33 stand sie in der Liste mitsamt dem aus der mehr auf und erscheint deshalb nicht. Bis 0.33 stand sie in der Liste mitsamt dem aus der
@@ -1280,6 +1346,19 @@ Referenz: `src/lib/ratefields.ts`, `src/components/ElementDetail.tsx`.
### 3.7.2 Farbschemata ### 3.7.2 Farbschemata
> **Der Akzent ist keine Bedeutung** (Lehre aus 0.41). Im warmen Schema ist er ein Orangerot,
> im dunklen ein Indigo. Wer ihn benutzt, um «erledigt» oder «hier ist Geld hereingekommen» zu
> sagen, sagt im einen Schema etwas anderes als im anderen: Ein einmaliger Erbschafts-Zufluss
> sah warm aus wie ein Fehler. Bedeutung trägt deshalb **Grün** (positiv, erledigt),
> **Attention** (hier fehlt etwas) und **Danger** (hier ist etwas falsch) der Akzent nur
> «hier kannst du klicken».
>
> Ebenso hat jede Farbe **zwei** Vordergrund-Töne, und sie sind nicht austauschbar:
> `--attention-fg` ist weiss und gehört auf **vollflächigen** Attention-Grund;
> `--attention-soft-fg` ist dunkel und gehört auf den weichen. Verwechselt man sie, ist die
> Schrift im warmen und im hellen Schema praktisch unsichtbar in 0.41 an sechs Stellen
> behoben.
Drei Themes: **Hell**, **Dunkel**, **Warm** (cremefarben, Koralle-Akzent). Wahl im Profilmenü, Drei Themes: **Hell**, **Dunkel**, **Warm** (cremefarben, Koralle-Akzent). Wahl im Profilmenü,
persistiert in `localStorage` (`fpt-theme`), gesetzt als `data-theme` am `<html>`. Ohne persistiert in `localStorage` (`fpt-theme`), gesetzt als `data-theme` am `<html>`. Ohne
gespeicherte Wahl folgt die Oberfläche `prefers-color-scheme`. Ein Inline-Script im `<head>` gespeicherte Wahl folgt die Oberfläche `prefers-color-scheme`. Ein Inline-Script im `<head>`
@@ -1352,6 +1431,10 @@ dem Schema heraus und sahen im Dunkelmodus falsch aus.
### 3.7.7 Inspector-Panel statt Modals ### 3.7.7 Inspector-Panel statt Modals
> **Seit 0.42 gilt das nur noch für die Matrix.** In der Kartenansicht wird in der Karte selbst
> bearbeitet ([3.15.2](#3152-eine-karte-je-lebensphase)) das Panel legte sich als
> `fixed right-0` ausgerechnet über die Spalte, zu der man gerade gescrollt hatte.
Alle **Einzel-Bearbeitungen** Phasenzelle, Übergangszelle, Cash-Übergang, Cash-Anfangswert, Alle **Einzel-Bearbeitungen** Phasenzelle, Übergangszelle, Cash-Übergang, Cash-Anfangswert,
Phase (Name/Dauer), Szenario-Profil öffnen sich als rechtes Seitenpanel (`InspectorShell`, Phase (Name/Dauer), Szenario-Profil öffnen sich als rechtes Seitenpanel (`InspectorShell`,
26 rem, mobil volle Breite) statt als Modal. Bewusst **ohne Backdrop**: Die Matrix bleibt 26 rem, mobil volle Breite) statt als Modal. Bewusst **ohne Backdrop**: Die Matrix bleibt
@@ -1360,7 +1443,7 @@ der Kontext, den Modals nehmen, bleibt erhalten. Genau ein Panel kann offen sein
`Panel`-Union ersetzt die früheren Einzel-Zustände); der `key` erzwingt beim Wechsel den `Panel`-Union ersetzt die früheren Einzel-Zustände); der `key` erzwingt beim Wechsel den
Neuaufbau des Formulars wie zuvor bei den Dialogen. Neuaufbau des Formulars wie zuvor bei den Dialogen.
**Modals bleiben** für Erstell-Flows (Element, Phase, Plan, Assistent), den geführten **Modals bleiben** für Erstell-Flows (Element, Phase, Plan, Bestandsaufnahme), den geführten
Übergang (mehrere Objekte auf einmal), die Analysen und die Detailansichten. Übergang (mehrere Objekte auf einmal), die Analysen und die Detailansichten.
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
@@ -1390,8 +1473,8 @@ Schritte ohne vorhandenes Ziel werden übersprungen; der «Tour»-Knopf in der o
Funktions-Leiste startet sie jederzeit neu. 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 startet sie
Beispielplan, leerer Plan) startet sie **einmal** unabhängig davon, ob sie schon beendet **einmal**, und zwar **sofort** unabhängig davon, ob sie schon beendet
wurde (die Erfolgsmeldung «die Tour zeigt dir gleich …» hält damit ihr Versprechen). Zusätzlich wurde (die Erfolgsmeldung «die Tour zeigt dir gleich …» hält damit ihr Versprechen). Zusätzlich
startet sie beim ersten Öffnen eines bestehenden Plans mit Phasen, solange sie noch nie beendet startet sie beim ersten Öffnen eines bestehenden Plans mit Phasen, solange sie noch nie beendet
wurde (localStorage `fpt-tour-done`). Ein aus dem DOM gelesenes Ziel setzt voraus, dass die wurde (localStorage `fpt-tour-done`). Ein aus dem DOM gelesenes Ziel setzt voraus, dass die
@@ -1430,8 +1513,8 @@ Jedes **Szenario** trägt eine Version **A.B** und eine vollständige Änderungs
FPT hat **keinen Speichern-Knopf**: Jede Änderung schreibt sofort. Eine Version je FPT hat **keinen Speichern-Knopf**: Jede Änderung schreibt sofort. Eine Version je
Schreibvorgang wäre deshalb ein Tastenprotokoll und keine Historie ein Durchlauf des Schreibvorgang wäre deshalb ein Tastenprotokoll und keine Historie ein Durchlauf des
Plan-Assistenten macht rund **14** Schreibvorgänge, ein Klick im Verteil-Dialog einen **je Bestandsaufnahme-Dialogs macht rund **14** Schreibvorgänge, ein Klick im Verteil-Dialog einen
Zielelement**. **je Zielelement**.
Stattdessen werden alle Schreibvorgänge innerhalb eines **Zeitfensters von 10 Minuten** zu Stattdessen werden alle Schreibvorgänge innerhalb eines **Zeitfensters von 10 Minuten** zu
**einer** Nebenversion zusammengefasst: Der erste legt sie an, alle weiteren aktualisieren **einer** Nebenversion zusammengefasst: Der erste legt sie an, alle weiteren aktualisieren
@@ -1939,11 +2022,31 @@ gegen die Vererbung, bis man das Häkchen aktiv setzt.
Am Pensions-Übergang kommt oft ein grosser Betrag auf einmal herein (PK-Kapital, Säule 3a). Am Pensions-Übergang kommt oft ein grosser Betrag auf einmal herein (PK-Kapital, Säule 3a).
Ihn vollständig als Cash liegen zu lassen ist selten die Absicht. Beim **Bezugs-Entscheid des Ihn vollständig als Cash liegen zu lassen ist selten die Absicht. Beim **Bezugs-Entscheid des
jeweiligen Guthabens** lässt sich deshalb erfassen, wie viel **in Prozent** in die Amortisation jeweiligen Guthabens** lässt sich deshalb erfassen, wie viel **in Prozent** wohin fliesst seit
der Hypothek und in eine Anlage fliesst; der Rest bleibt Cash. 0.41 in drei gleichrangigen Zeilen:
| Zeile | Ziel | wirkt als |
|---|---|---|
| **Schuldentilgung** | wählbar: eine Immobilie **oder** eine sonstige Schuld | Sonderamortisation bzw. Sofort-Tilgung |
| **Investition** | wählbar: ein Element «Sonstiges Vermögen» | Zusatzeinlage in der Folgephase |
| **Cash** | | bleibt liegen |
**Das Tilgungs-Ziel ist wählbar, seit es Schulden ausserhalb der Hypothek gibt.** Bis 0.40 floss
die Quote stur in die erstbeste Immobilie; wer neben einer Hypothek zu 1,5 % einen Konsumkredit
zu 6 % trägt, konnte den Kredit gar nicht zuerst tilgen. Ohne getroffene Wahl bleibt es beim
alten Verhalten (Immobilien der Reihe nach) ein Bestandsschutz, damit bestehende Pläne
unverändert rechnen; ein Schuld-Element wird nur getilgt, wenn es **ausdrücklich** gewählt ist.
**Cash ist eine Zeile, kein Rest.** Rechnerisch ist es beides die Zahl ergibt sich aus
100 % minus den beiden anderen und ist deshalb nicht eintippbar. Dargestellt wird sie trotzdem
gleichrangig: «ich lasse es liegen und entscheide später» ist ein Entscheid, keine Unterlassung.
Alle drei frei eintippbar zu machen hiesse, dass beim Tippen still eine der anderen Zahlen
wandert.
Die Frage steht seit 0.33 dort, wo der Bezug entschieden wird bei der **Pensionskasse** (nur Die Frage steht seit 0.33 dort, wo der Bezug entschieden wird bei der **Pensionskasse** (nur
wenn Kapital bezogen wird) und bei der **Säule 3a**. Bis 0.32 hing sie am **Cash-Übergang**: wenn Kapital bezogen wird) und bei der **Säule 3a**. Bei der 3a fehlte sie bis 0.40 allerdings
in der Oberfläche, obwohl sie sich dort **zwingender** stellt: Ein 3a-Konto lässt sich nur ganz
auflösen, es fliesst also in jedem Fall ein grosser Betrag und der landete stumm auf dem Cash. Bis 0.32 hing sie am **Cash-Übergang**:
Das war der falsche Ort, weil sich mit zwei Guthaben nicht getrennt festlegen liess, welches Das war der falsche Ort, weil sich mit zwei Guthaben nicht getrennt festlegen liess, welches
wohin fliesst. Beide Dialoge führen deshalb neu in der Reihenfolge, in der man tatsächlich wohin fliesst. Beide Dialoge führen deshalb neu in der Reihenfolge, in der man tatsächlich
entscheidet: **Bezugsart → Betrag brutto → Steuersatz → Betrag netto → Verteilung**. entscheidet: **Bezugsart → Betrag brutto → Steuersatz → Betrag netto → Verteilung**.
@@ -1952,8 +2055,7 @@ entscheidet: **Bezugsart → Betrag brutto → Steuersatz → Betrag netto → V
Kapital. Ein Frankenbetrag müsste von Hand nachgezogen werden und würde bis dahin still eine Kapital. Ein Frankenbetrag müsste von Hand nachgezogen werden und würde bis dahin still eine
falsche Aufteilung rechnen. Eine Quote skaliert mit. falsche Aufteilung rechnen. Eine Quote skaliert mit.
Die Amortisations-Quote ist am Restsaldo der Hypothek gekappt; ist sie grösser, bleibt der Rest Die Tilgungs-Quote ist am Restsaldo gekappt; ist sie grösser, bleibt der Rest Cash. Die Anlage-Quote fliesst in ein wählbares Vermögens-Element (Vorgabe: das erste aktive).
Cash. Die Anlage-Quote fliesst in ein wählbares Vermögens-Element (Vorgabe: das erste aktive).
Beide sind mechanisch nichts Neues die eine wirkt wie eine Sonderamortisation, die andere wie Beide sind mechanisch nichts Neues die eine wirkt wie eine Sonderamortisation, die andere wie
eine Zusatzeinlage, und beide laufen dadurch korrekt durch die zwei Wasserfall-Brücken. eine Zusatzeinlage, und beide laufen dadurch korrekt durch die zwei Wasserfall-Brücken.
@@ -2072,18 +2174,19 @@ Codepfaden für dieselbe Frage. Jetzt an einem.
### 3.13.5 Planungshorizont ### 3.13.5 Planungshorizont
`Person.planningHorizonAge` macht das Planende **explizit**. Bisher ergab es sich stillschweigend Der Planungshorizont ist die **Summe der Lebensphasen** -- eine abgeleitete Grösse, kein Feld.
aus der Summe der Phasendauern: Zwei Szenarien konnten dadurch unbemerkt verschieden weit Er steht in den Grundeinstellungen, zusammen mit dem Endalter je Person; ohne Phasen ist er
rechnen und waren nicht vergleichbar obwohl das ihr Zweck ist. leer.
Die Mechanik ist dieselbe wie beim Pensionsalter: **Zahl stellen, Struktur folgt.** Die letzte Das war der dritte Anlauf. In 0.35 stand er als **Endalter je Person**, in 0.36 als
Lebensphase wird so angepasst, dass der Plan bis zum Horizont läuft (`planHorizonChange`, **Jahreszahl am Szenario**, der die letzte Phase folgte. Beide Male war es eine zweite
`POST /api/scenarios/<id>/horizon`). Fiele sie dabei unter ein Jahr, wird blockiert. Bei zwei Wahrheit über dieselbe Sache: Wer seine Lebensphasen einzeln plant, hat den Horizont damit
Personen läuft der Plan bis zum **spätesten** Horizont. bereits bestimmt. Ihn zusätzlich zu erfassen hiess nur, zwei Zahlen synchron halten zu müssen
-- und beim Verschieben einer Phase zu entscheiden, welche der beiden gewinnt.
Bewusst neutral formuliert («Planungshorizont», nicht «Sterbealter»). Der Default liegt über der Der ursprüngliche Anlass bleibt gültig: Zwei Szenarien dürfen nicht unbemerkt verschieden weit
Lebenserwartung: Eine zu kurze Planung sieht tragfähig aus, obwohl das Geld nur nicht lange rechnen. Sichtbar wird das jetzt über die Anzeige in den Grundeinstellungen statt über ein
genug reichen muss. eigenes Feld.
### 3.13.6 Was bewusst nicht abgebildet wird ### 3.13.6 Was bewusst nicht abgebildet wird
@@ -2105,50 +2208,186 @@ genug reichen muss.
Referenz: `src/lib/retirement-decision.ts`, `src/components/RetirementPanel.tsx`, Referenz: `src/lib/retirement-decision.ts`, `src/components/RetirementPanel.tsx`,
`src/components/RetirementFields.tsx`, `src/lib/retirement.ts` (`planHorizonChange`). `src/components/RetirementFields.tsx`, `src/lib/retirement.ts` (`planHorizonChange`).
## 3.14 Der FPT-Assistent ## 3.14 Bestandsaufnahme und offene Punkte
### 3.14.1 Warum er die «Nächsten Schritte» ersetzt ### 3.14.1 Warum aus dem Assistenten eine Übersicht wurde
Die frühere Karte leitete AB, was zu tun wäre -- und liess einen damit allein. Sie konnte Ein Assistent ist ein **Ablauf**: tu dies, dann das, dann bist du fertig. Das trägt genau
sagen «4 Übergangs-Entscheide offen», aber nicht, was ein Übergangs-Entscheid überhaupt ist einmal -- beim ersten Aufsetzen. Wer einen bestehenden Plan öffnete, bekam Schritte
oder in welcher Reihenfolge man vorgeht. angeboten, die längst erledigt waren, und ein Häkchen, das nichts wusste. Vier Anläufe
(sieben Schritte, dann zwei) haben dasselbe Grundproblem nur kleiner gemacht.
Der Assistent führt stattdessen. Sieben Schritte, jeder mit einem eigenen Werkzeug und einer Eine Übersicht ist ein **Zustand**: das ist noch offen. Sie wird bei jedem Rendern aus dem
Seite davor, die erklärt, worum es geht: Plan abgeleitet (`reviewPlan`), speichert nichts und kann deshalb nie veralten. Sie trägt bei
jedem Plan, in jeder Reihenfolge, auch beim zwanzigsten Szenario.
| # | Schritt | Was dabei entsteht | Sie steht oben rechts, wo vorher der Assistent stand, und nennt **je Lebensphase und je
Übergang**, was dort fehlt -- jede Zeile ist ein Sprung an die Stelle:
| Zustand | Was die Kachel zeigt |
|---|---|
| `NO_ELEMENTS` | «Noch nichts erfasst» + grosser Knopf **Bestandsaufnahme** |
| `NO_PHASES` | «Noch keine Zeitachse» + Knopf **Erste Lebensphase anlegen** |
| `OPEN` | die Liste der Gruppen mit Anzahl und Grund |
| `DONE` | grün: «Alles angeschaut» |
**Warum zwei Startzustände statt einer Zahl.** Ohne Elemente und ohne Phasen gibt es
naturgemäss nichts Offenes -- der Plan ist trotzdem leer. Eine «0 offene Punkte» wäre dort
eine Lüge, und ein grünes Häkchen die falscheste Auskunft überhaupt. Deshalb zählt die Kachel
in diesen beiden Fällen nicht, sondern fordert zum nächsten Handgriff auf.
### Tatsache und Annahme gehören getrennt
Dieser Schnitt ist geblieben; er ist der Grund, warum die Bestandsaufnahme vor der Zeitachse
kommt:
| Tatsachen (`baseData`) | Annahmen (`phaseValues`) |
|---|---|
| Nettolohn, Ausgaben pro Jahr | Lohnerhöhung, Teuerung |
| PK-Guthaben, 3a-Guthaben, Wert der Wertschriften | Verzinsung, erwartete Renditen, **PK-Beitrag** |
| Kaufpreis, Hypothek, Restschuld | Hypothekarzins, Wertsteigerung, Zins-in-Ausgaben |
Links steht, was man nachschlagen kann. Rechts steht, was man vermutet -- und eine Vermutung
gilt immer nur für einen Zeitraum. Bis 0.37 standen beide zusammen; dadurch sah eine Annahme
aus wie eine Tatsache, und man traf sie, bevor überhaupt feststand, für welche Jahre sie
gelten sollte.
Der **PK-Beitrag** steht bewusst bei den Annahmen und nicht in der Sparquoten-Verteilung: Er
stammt aus dem Bruttolohn und belastet das Cash-Konto nicht
([4.6.3](#463-pension_fund)) -- aus der Quote liesse er sich gar nicht verteilen.
**Beim Abschliessen entsteht je Person ein AHV-Element** (seit 0.41), idempotent wer die
Bestandsaufnahme später korrigiert und erneut abschliesst, bekommt kein zweites. Die AHV ist
die einzige Kategorie **ohne Bestand**: Es gibt kein Guthaben zum Nachschlagen, nur eine
Beitragskarriere, die FPT aus den Einkommen dieses Plans ableitet. Ein Plus-Knopf mit einer
leeren Karte darunter wäre deshalb sinnlos gewesen sie wegzulassen aber auch: In der Schweiz
hat jede Person eine AHV, und ohne das Element rechnet der Plan ab der Pensionierung mit einer
Rente von 0. Erscheint die Karte später in der Liste, sagt sie, warum dort nichts zu erfassen
ist. Wer die Bestandsaufnahme nie öffnet, legt das Element selbst an.
**Die Bestandsaufnahme bleibt als eigener Dialog** (`InventoryDialog`): als grosser Knopf im
leeren Plan und dauerhaft unter den Schnellaktionen. Sie ist der einzige Sammel-Dialog, der
geblieben ist, weil sie als einzige etwas leistet, das die Matrix nicht kann -- sieben
Kategorien in einem Zug erfassen, bevor man weiss, wie das Tool aufgebaut ist. Der Entwurf
liegt auf Ebene des ganzen Dialogs, nicht in der einzelnen Karte; nur so überlebt er den
Wechsel des Reiters. «Fertig» schreibt alles in einem Zug, und die Summe **«Vermögen heute»**
läuft beim Tippen mit.
### 3.14.2 Bestätigen heisst «ich habe hingeschaut»
Eine neue Lebensphase übernimmt alle Werte der Vorphase. Das ist richtig -- ohne Vorbelegung
müsste man in jeder Phase alles neu eintippen. Es ist aber auch gefährlich: Eine Rendite von
5 %, die aus dem Erwerbsleben stillschweigend in die Pension weiterläuft, ist keine
Entscheidung, sondern ein Versehen.
Deshalb gilt seit 0.39 in der ganzen Matrix derselbe Mechanismus, den die Übergänge seit 0.35
haben: **vorbelegen ja, stillschweigend übernehmen nein.** Jede Phasenzelle trägt ein
`confirmed`-Flag; solange es fehlt, ist die Zelle mit der Attention-Farbe markiert und zählt
in der Übersicht.
> **Der Haken steht NEBEN den Werten, nicht statt ihrer.** Bestätigen kopiert nichts und
> friert nichts ein -- eine bestätigte Phase erbt weiterhin live aus der Vorphase
> ([3.12.4](#3124-punkt-a-aus-vorphase-übernehmen)). Würde die Bestätigung die geerbten Werte
> materialisieren, wäre der ganze Punkt-A-Mechanismus hinfällig: Eine Änderung in Phase 1
> erreichte Phase 3 nicht mehr. Ein Test sichert genau das.
Was gilt überhaupt als offen? Nur Zellen, die in dieser Phase noch etwas beitragen
(`needsConfirmation` prüft `status === "ACTIVE"`). Ein verkauftes Haus trägt in der Folgephase
keine Annahmen mehr und verlangt auch keine Bestätigung.
**Die Spar-/Verzehrquote zählt eigens** (`Phase.ratesConfirmed`). Sie ist keine Eigenschaft
eines Elements, sondern der Phase, und sie lässt sich nicht aus den Zellen ableiten: Man kann
jede einzelne angeschaut und die Verteilung trotzdem nie getroffen haben. Dann bliebe der
ganze Überschuss still auf dem Cash-Konto liegen -- und die Übersicht meldete «alles
erledigt», während das Geld unverzinst herumliegt. Das Flag wird gesetzt, sobald der
Verteil-Dialog einmal gespeichert hat.
**Das Abzeichen im Phasenkopf öffnet eine Maske mit allen offenen Annahmen** (seit 0.40,
`PhaseReviewDialog`) -- das Gegenstück zum geführten Übergang ([3.5.4](#354-geführter-übergang-review-dialog)).
Sie listet jede unbestätigte Zelle mit ihren Feldern, bearbeitbar, und schliesst mit «Alle
bestätigen».
Bis 0.39 fragte das Abzeichen nur «N Annahmen bestätigen?». Das war eine Zustimmung zu etwas,
das man gar nicht sah -- also genau die Bewegung, die der Mechanismus verhindern soll.
**Bestätigt wird zeilenweise.** Jede Zeile trägt ein Häkchen «Angeschaut», anfangs leer;
gespeichert werden die Werte aller Zeilen, `confirmed` aber nur bei den angehakten. Ein Knopf,
der pauschal alles bestätigt, liesse sich drücken, ohne je gescrollt zu haben -- der erste
Anlauf in 0.40 tat genau das. Zwei Dinge halten den Aufwand trotzdem klein:
* **Wer ein Feld ändert, hakt es automatisch an.** Bearbeiten IST anschauen.
* Oben markiert **ein** Klick alle Zeilen -- aber als sichtbarer Akt, nicht als Nebenwirkung
des Speicherns.
Das ist nötig, weil es sonst bei sechs Elementen und fünf Phasen dreissig einzelne Klicks
wären, und das erzieht wieder zum Durchklicken. Wer eine einzelne Zelle in der Matrix öffnet
und speichert, bestätigt sie dabei ohnehin.
Ist zusätzlich die Quote dieser Phase noch nicht verteilt, steht das oben in der Maske mit
einem Absprung in den Verteil-Dialog -- die beiden zählen getrennt, gehören aber zusammen.
Unbestätigte Zellen tragen in der Matrix getönten Grund, einen kräftigen linken Balken und ein
Warnzeichen. Der dünne Ring aus 0.39 ging in einer gefüllten Matrix schlicht unter.
**Eine Zelle ohne Annahmen verlangt keine Bestätigung.** Seit 0.40 entstehen Bestände in den
Stammdaten und Raten im Verteil-Dialog ([3.14.3](#3143-ein-wert-ein-ort)); in der Phasenzelle
bleiben nur die Annahmen. Eine «Sonstige Schuld» trägt damit gar keine mehr -- eine Frage ohne
Gegenstand zu stellen, wäre schlimmer als keine.
### 3.14.3 Ein Wert, ein Ort
Die Regel, die seit 0.40 durchgehend gilt: **Für jede Zahl gibt es genau EIN Eingabefeld.**
Überall sonst steht sie read-only, mit einem Absprung dorthin, wo sie entsteht.
| Was | Entsteht in | Angezeigt (read-only) in |
|---|---|---| |---|---|---|
| 1 | **Bestandsaufnahme** | alle Elemente mit ihrem heutigen Stand | | Bestand bei Planbeginn | Stammdaten Spalte «Start» bzw. Bestandsaufnahme | Phasenzelle der **ersten** Phase |
| 2 | **Eckdaten und Pensionsplanung** | Planungshorizont; je Person Erwerbsende und Bezugszeitpunkte | | Jährliche Rate (Sparbeitrag, Bezugsrate, Amortisation, Tilgung, 3a- und PK-Einzahlung) | «Quote verteilen» | Phasenzelle jeder Phase |
| 3 | **Lebensphasen** | die Zeitachse, an den Fixpunkten aus Schritt 2 ausgerichtet | | Einmalige Kapitalverwendung (Zusatzeinlage, Sonderamortisation, Sofort-Tilgung) | «Kapital verteilen» | Phasenzelle der Phase, in der das Kapital anfällt |
| 4 | **Erwerbsjahre planen** | Sparquoten und Übergänge bis zur Pensionierung | | Annahme (Rendite, Lohnentwicklung, Teuerung, Hypothekarzins, Wertsteigerung) | Phasenzelle | |
| 5 | **Pensionierung planen** | Rente oder Kapital, Verwendung, die Übergänge dorthin | | Cash bei Planbeginn | Bestandsaufnahme bzw. Spalte «Start» der Cash-Zeile | |
| 6 | **Ruhestand planen** | Bezüge aus dem Vermögen, die restlichen Übergänge |
| 7 | **Feinschliff** | reine Information: was jetzt noch möglich ist |
Die Reihenfolge ist nicht beliebig. Sie beginnt mit dem, was **feststeht** (was habe ich?), **Warum das kein Kosmetikpunkt ist.** Der Rechenkern legte in der ersten Phase die Phasenwerte
geht dann zu dem, was man **entscheidet** (wann höre ich auf?), und erst danach zu dem, was über die Stammdaten (`{...baseData, ...phaseValues}`). Ein zweites Eingabefeld für den
sich daraus **ergibt** (wie teile ich ein?). Genau deshalb steht die Bestandsaufnahme vor der Startwert bedeutete also: Man tippt in Phase 1 eine Zahl, sie **gewinnt still** gegen die
Zeitachse -- und genau deshalb brauchte es die Element-Stammdaten (3.14.3). Bestandsaufnahme, und in der Spalte «Start» steht weiterhin die alte. Niemand sieht den
Konflikt man sieht nur, dass eine Korrektur wirkungslos bleibt.
### 3.14.2 Der Haken ist manuell -- der Stand daneben nicht **Die Regel steht deshalb seit 0.40.1 im Rechenkern** (`firstPhaseValues`), nicht nur in der
Oberfläche: Bei den fünf Bestandsfeldern (`amount`, `currentValue`, `startValue`,
`purchasePrice`, `mortgage`) gewinnen in der ersten Phase **immer die Stammdaten**, wo sie
einen Wert tragen. Der Anlass war ein handfester Fehler: Eine in 0.39 versehentlich
gespeicherte 0 verdeckte den Kaufpreis einer Immobilie und weil das Feld seit 0.40 read-only
ist, war sie **nicht mehr zu entfernen**. Eine Sperre in der Oberfläche schützt eben nur vor
neuen Eingaben, nicht vor alten.
Zwei Gestaltungsentscheide, die zusammengehören: Der Rückfall auf den Phasenwert bleibt, solange die Stammdaten leer sind; sonst fielen Pläne
aus der Zeit vor 0.36 schlagartig auf 0. Die **Annahmen** sind ausdrücklich nicht betroffen:
Eine Rendite gilt für einen Zeitraum, nicht für den Anfangsbestand, und darf in Phase 1
abweichen.
1. **Abgehakt wird von Hand.** Wann jemand mit einem Schritt fertig ist, ist eine Einschätzung Bei den Raten ist der Grund ein anderer, aber nicht kleiner: **Nur der Verteil-Dialog kennt
und keine Messgrösse. «Genug geplant» kann das Tool nicht wissen. die Quote.** Er zeigt, wie viel überhaupt zu verteilen ist, ob die Summe aufgeht und ob das
2. **Daneben steht der abgeleitete Stand** (`stepStatus`): «0 Lebensphasen», «2 Elemente», Cash-Konto dabei ins Minus fällt. Ein Eingabefeld am Element liess sich beliebig darüber
«Planungshorizont fehlt». Ein abgehakter Schritt ohne Substanz fällt damit auf, ohne dass hinaus füllen, ohne dass es irgendwo auffiel.
das Tool den Haken verweigert.
Gesperrt wird nur das **Werkzeug**, nie die Selbstauskunft (`stepBlockedReason`): Die **Der PK-Beitrag ist die Ausnahme, die die Regel trägt.** Er ist kein Teil der Quote er
Phasenplanung ohne Planungshorizont wäre gegenstandslos, die Übergangs-Schritte ohne Phasen stammt aus dem Bruttolohn und belastet das Cash-Konto nicht ([4.6.3](#463-pension_fund)).
ebenso. Der Haken bleibt trotzdem jederzeit setzbar. Trotzdem steht er im Verteil-Dialog, weil der seit 0.40 der einzige Ort für jährliche Beträge
ist; ohne ihn wäre er unerreichbar und fiele still auf 0, obwohl er das Vorsorgekapital um
sechsstellige Beträge bewegt. Er steht dort in einem **eigenen Block** «Aus dem Bruttolohn
(ausserhalb der Quote)», damit ihn niemand für einen Teil der Quote hält.
Erledigte Schritte rutschen nach unten und werden blass -- oben steht immer das Nächste. Die **Was die Vererbung angeht, bleibt es beim Verhalten von 0.32:** Der Verteil-Dialog schreibt
Kachel ist **gelb**, solange etwas offen ist, und **grün**, wenn alle sieben stehen. beim Speichern jede Zeile explizit weg, auch die, die man nicht angefasst hat er belegt sie
mit dem effektiven, also geerbten Wert vor. Damit ist eine verteilte Phase für diese Felder
nicht mehr an die Vorphase gekoppelt. Das ist bewusst so: **Verteilt wird je Phase**, und eine
Zahl, die man in der Verteilung gesehen und stehen gelassen hat, ist ein Entscheid. Die
Annahmen (Renditen, Teuerung) erben unverändert weiter ([3.12.4](#3124-punkt-a-aus-vorphase-übernehmen)).
### 3.14.3 Element-Stammdaten: Bestand vor Zeitachse Referenz: `LinkedField` in `src/components/ElementDetail.tsx`.
### 3.14.4 Element-Stammdaten: Bestand vor Zeitachse
Bis 0.35 lagen alle Elementwerte unter `phaseValues[phaseId]`. Ohne Phase gab es keinen Bis 0.35 lagen alle Elementwerte unter `phaseValues[phaseId]`. Ohne Phase gab es keinen
Schlüssel -- eine Bestandsaufnahme als erster Schritt war damit unmöglich. Schlüssel -- eine Bestandsaufnahme als erster Schritt war damit unmöglich.
@@ -2170,10 +2409,21 @@ erben können, und fiel auf 0. Die Stammdaten sind jetzt die **Wurzel** dieser K
eigener Phasenwert -> aus der Vorphase geerbt -> Stammdaten -> 0 eigener Phasenwert -> aus der Vorphase geerbt -> Stammdaten -> 0
``` ```
In der Matrix erscheinen Elemente ohne Lebensphasen mit ihren Stammdaten; Endwerte gibt es **Die Matrix erscheint, sobald es Elemente gibt** -- Lebensphasen sind dafür nicht nötig. Ihre
erst, wenn eine Phase eine Dauer vorgibt. erste Spalte heisst **«Start»** und zeigt den Bestand bei Planbeginn; Endwerte gibt es erst,
wenn eine Phase eine Dauer vorgibt. Solange keine Phasen bestehen, steht rechts davon der
Hinweis auf den nächsten Schritt.
### 3.14.4 Fixpunkte: jeder Bezugsbeginn erzwingt eine Phasengrenze Die Spalte ist auch mit Phasen dauerhaft sichtbar, und zwar aus einem zweiten Grund: Sie ist
der **einzige Ort, an dem ein Startwert änderbar ist**. Bearbeitete man ihn stattdessen in
Phase 1, entstünde dort ein Phasenwert, der die Stammdaten überdeckt -- dieselbe Zahl an zwei
Orten, genau das Muster, das dieser Umbau beseitigt hat.
> **0.36 hielt das nicht.** Die Stammdaten waren eingeführt, die Matrix hing aber weiterhin
> vollständig an den Phasen -- die Bestandsaufnahme verschwand nach dem Speichern im Nichts.
> Behoben in 0.37.
### 3.14.5 Fixpunkte: jeder Bezugsbeginn erzwingt eine Phasengrenze
`phaseplan.ts` kannte bis 0.35 genau einen Fixpunkt je Person -- das Erwerbsende. Seit AHV, `phaseplan.ts` kannte bis 0.35 genau einen Fixpunkt je Person -- das Erwerbsende. Seit AHV,
Pensionskasse und jedes 3a-Konto ein eigenes Bezugsalter haben, sind es bis zu vier: Pensionskasse und jedes 3a-Konto ein eigenes Bezugsalter haben, sind es bis zu vier:
@@ -2194,7 +2444,7 @@ dort mehrfach beschriftet.
Die Folge ist ehrlich, aber spürbar: Ein Paar mit gestaffelten Bezügen kommt schnell auf acht Die Folge ist ehrlich, aber spürbar: Ein Paar mit gestaffelten Bezügen kommt schnell auf acht
bis zehn Pflichtphasen. bis zehn Pflichtphasen.
### 3.14.5 Der Bildschirm ### 3.14.6 Der Bildschirm
Zwei farblich getrennte Hälften, damit sichtbar ist: **oben stellst du ein, unten siehst du Zwei farblich getrennte Hälften, damit sichtbar ist: **oben stellst du ein, unten siehst du
das Ergebnis.** das Ergebnis.**
@@ -2206,20 +2456,28 @@ Oben vier gleichrangige Kacheln plus die Zeitachse über die volle Breite:
| **Grundeinstellungen** | plan-weit (Personen, Startjahr, Inflation) und szenario-eigen (Horizont, Endjahr, Endalter, Pensionsalter). Stift zum Bearbeiten; je Person ein Knopf **«Pensionsplanung»** | | **Grundeinstellungen** | plan-weit (Personen, Startjahr, Inflation) und szenario-eigen (Horizont, Endjahr, Endalter, Pensionsalter). Stift zum Bearbeiten; je Person ein Knopf **«Pensionsplanung»** |
| **Kennzahlen** | **Vermögen heute** (Summe der Stammdaten -- die einzige Zahl, die schon vor jeder Zeitplanung etwas aussagt), Endvermögen nominal und real, Reichweite | | **Kennzahlen** | **Vermögen heute** (Summe der Stammdaten -- die einzige Zahl, die schon vor jeder Zeitplanung etwas aussagt), Endvermögen nominal und real, Reichweite |
| **Schnellaktionen** | Neues Szenario · Tour · Änderungshistorie · Rechenwege · CSV-Export | | **Schnellaktionen** | Neues Szenario · Tour · Änderungshistorie · Rechenwege · CSV-Export |
| **Assistent** | siehe 3.14.1 | | **Offene Punkte** | siehe 3.14.1 |
Das **Pensionsalter lässt sich in den Grundeinstellungen nicht ändern** -- es erzeugt eine Das **Pensionsalter ist im Basisszenario auf 65 festgelegt** und nicht änderbar. Vorbezug,
Phasengrenze und gehört deshalb in die Pensionsplanung. Aufschub, eigene Bezugsalter für Pensionskasse und Säule 3a sowie die daraus folgenden
Fixpunkte sind im Rechenkern vollständig vorhanden und getestet -- sie werden hier bewusst
**nicht angeboten**.
Der Grund ist nicht technisch, sondern didaktisch: Wer einen Plan zum ersten Mal aufsetzt,
soll nicht zwischen fünf Pensionsmodellen wählen müssen, bevor er weiss, wie sein Plan
überhaupt aussieht. Abweichende Modelle gehören zu einer eigenen **Szenario-Art** -- man legt
ein zweites Szenario an und wählt dort «Frühpensionierung». Bis dahin weisen die
Grundeinstellungen darauf hin.
Unten die Matrix. Die frühere Aktionsleiste darüber ist verschwunden: **«+ Element», Unten die Matrix. Die frühere Aktionsleiste darüber ist verschwunden: **«+ Element»,
«+ Phase», der Nominal/Real-Umschalter, die Plan/Ist-Umschaltung und «Alle auf-/zuklappen» «+ Phase», der Nominal/Real-Umschalter, die Plan/Ist-Umschaltung und «Alle auf-/zuklappen»
sitzen in der Ecke oben links der Matrix.** Sie steuern die Matrix und lagen vorher lose sitzen in der Ecke oben links der Matrix.** Sie steuern die Matrix und lagen vorher lose
darüber wie Aktionen der ganzen Seite. darüber wie Aktionen der ganzen Seite.
### 3.14.6 Die Tour ### 3.14.7 Die Tour
Ein grosses Popup mit einem **nachgebauten** Bildschirm und erfundenen Zahlen, in neun Ein grosses Popup mit einem **nachgebauten** Bildschirm und erfundenen Zahlen, in neun
Schritten erklärt. Der letzte führt zum Assistenten. Schritten erklärt. Der letzte führt zur Bestandsaufnahme.
Das frühere Spotlight legte sich über die echte Ansicht. Zwei Nachteile liessen sich nicht Das frühere Spotlight legte sich über die echte Ansicht. Zwei Nachteile liessen sich nicht
beheben: Auf einem frisch angelegten, leeren Plan gab es kaum etwas hervorzuheben -- also beheben: Auf einem frisch angelegten, leeren Plan gab es kaum etwas hervorzuheben -- also
@@ -2230,9 +2488,202 @@ Der Preis ist bekannt und bewusst in Kauf genommen: **Die Attrappe muss bei UI-
nachgeführt werden.** Dafür funktioniert die Tour ab der ersten Sekunde und unabhängig davon, nachgeführt werden.** Dafür funktioniert die Tour ab der ersten Sekunde und unabhängig davon,
was im Plan schon steht. was im Plan schon steht.
Referenz: `src/lib/assistant.ts`, `src/components/Assistant.tsx`, Referenz: `src/lib/review.ts`, `src/components/ReviewTile.tsx`,
`src/components/AssistantStepDialog.tsx`, `src/components/AssistantSteps.tsx`, `src/components/InventoryDialog.tsx`, `src/components/Tour.tsx`.
`src/components/Tour.tsx`.
---
## 3.15 Der Bildschirm: drei Spalten, zwei Sichten
### 3.15.1 Was gemessen wurde
Die Matrix war ab der vierten Lebensphase nicht mehr zu bedienen. Der Befund in Zahlen, bevor
irgendetwas entworfen wurde:
| Grösse | Wert | Folge |
|---|---|---|
| Phasenkopf | 220 px, dauerhaft fixiert | von 675 px Matrixhöhe blieben **430 px** |
| Fünf Phasen | 224 + 112 + 5×208 + 4×96 = **1760 px** | passt auf keinen Laptop |
| Bearbeitungs-Panel | `fixed right-0`, 416 px | legte sich über die Spalte, zu der man gescrollt hatte |
| Kacheln und Zeitachse | `230 px über der Matrix | ein zweiter Kopf über dem ersten |
Drei davon haben dieselbe Wurzel: **Der Phasenkopf war ein Kennzahlen-Dashboard.** Die 208 px
Spaltenbreite waren nicht die Breite, die eine Zelle braucht, sondern die, die der
Kennzahlenblock brauchte.
### 3.15.2 Eine Karte je Lebensphase
Die Matrix stellt Phasen **nebeneinander** gut zum Vergleichen, schlecht zum Arbeiten. Eine
Karte zeigt genau eine Phase über die ganze Breite. Damit wird horizontales Scrollen, das eine
Web-App am zuverlässigsten unbenutzbar macht, gegen vertikales getauscht, das jeder beherrscht.
Die Karte erzählt die Phase als **Zeitablauf**:
| Abschnitt | Inhalt |
|---|---|
| **Eintritt** | der Übergang aus der Vorphase: Verkauf oder Halten, Kapitalbezug, einmalige Cash-Ereignisse, die Kapitalverteilung. Bei Phase 1 stattdessen der **Start** aus der Bestandsaufnahme |
| **Laufend** | die Elemente mit ihren Annahmen, dazu die Spar- bzw. Verzehrquote und ihre Verteilung |
| **Ergebnis** | Vermögen und Cash am Phasenende |
**Der Übergang gehört zur Folgephase, nicht ans Ende der Vorphase.** Das dabei frei werdende
Kapital wird in der Folgephase verteilt dieselbe Zuordnung, die 0.41 schon für
Sonderamortisation und Sofort-Tilgung eingeführt hat ([3.12.5](#3125-punkt-c-verwendung-des-bezogenen-alterskapitals)).
Der Preis ist bekannt: Wer den Hausverkauf gedanklich am Ende von Phase N trifft, findet ihn
auf Karte N+1. Bewusst in Kauf genommen, weil dort die **Folgen** stehen.
**Die Karte ist auf 46 rem begrenzt** und steht zentriert. Die volle Breite auszunutzen war
verlockend und falsch: Eine Zeile aus 1300 px liest sich schlecht, weil das Auge beim
Zeilenwechsel den Anschluss verliert und Beschriftung und Wert so weit auseinanderdriften, dass
man sie nicht mehr zusammenbringt. Die Luft links und rechts ist kein Verlust sie trägt die
beiden **grossen Pfeile** zur Nachbarphase, ein Bedienelement ohne eigenen Platzbedarf.
**Die drei Akte sind sichtbar getrennt.** Jeder trägt einen getönten Kopfstreifen über die
volle Breite mit einer Nummer im Kreis; «Ergebnis» ist durchgehend getönt und trägt die Nummer
im Akzent es ist der Schlusspunkt, nicht ein weiterer Abschnitt. Der erste Anlauf trennte sie
nur mit einer Haarlinie, und man las die Karte als eine lange Liste statt als drei Schritte.
**Bearbeitet wird in der Karte.** Ein Klick klappt die Zeile auf, die Felder stehen darunter
dieselben wie im Panel (`ElementDetail` mit `compact`), also keine zweite Implementierung.
Damit kann nichts mehr verdecken, woran man arbeitet; das Panel bleibt der Matrix vorbehalten.
Ein Knopf im Kartenkopf klappt **alle** Zeilen auf oder zu: Bei acht Elementen ist das der
Unterschied zwischen einmal durchsehen und acht Klicks.
Das gilt **ausnahmslos**, auch für den Cash-Übergang. Er öffnete bis 0.42.1 als einziger das
rechte Panel auf einer Karte, die genau davon wegwollte, ein Fremdkörper. Die Felder sind
dieselben wie im Panel (`CashTransitionFields`); zwei Implementierungen wären garantiert
auseinandergelaufen.
### 3.15.3 Die Zeitachse ist der Navigator
Sie war bis 0.41 Dekoration. Neu ist das Segment der offenen Karte hervorgehoben, und ein
Klick springt hin. Das ist der Punkt, an dem sich Aussehen und Verständlichkeit treffen: Zeit
läuft von links nach rechts, das braucht keine Erklärung.
**Gezeichnet ist sie als klassischer Zeitstrahl** (0.42.3): ein durchgehender dunkler Balken,
dessen Segmente sich die Breite **streng nach Dauer** teilen, und Beschriftungen **abwechselnd
ober- und unterhalb**, verbunden über ein Dreieck und eine dünne Linie.
Das Abwechseln ist kein Stilmittel, sondern löst ein Platzproblem: Nebeneinander gesetzte
Beschriftungen kollidieren, sobald eine Phase kurz ist. Abwechselnd liegen Nachbarn auf
derselben Seite **zwei** Phasen auseinander und haben damit den doppelten Raum. Jede
Beschriftung nennt Jahresspanne, Phasenname und Dauer.
| Element | Gestaltung |
|---|---|
| Aktive Phase | Segment, Dreieck, Linie und Beschriftung im **Akzent** |
| Startjahr | grosse Zahl am linken Ende der Nullpunkt, auf den sich die reale Kaufkraft bezieht |
| Planende | kleiner am rechten Ende |
| Pensionierung, Ruinalter, Ist-Jahre | Marker **auf** dem Balken (oberhalb kollidierten sie mit den Beschriftungen) |
Entfallen sind die Überschrift (eine Zeitachse erkennt man), der Rahmen und die Legende.
Drei Wege durch die Phasen, weil verschiedene Leute verschieden arbeiten:
* **Klick auf die Zeitachse** gezielt zu einer bestimmten Phase.
* **Pfeilknöpfe unter der Karte**, beschriftet mit dem Namen der Nachbarphase. Auf der
**letzten** Karte steht dort stattdessen «+ Lebensphase» am Ende der Kette baut man weiter.
* **Pfeiltasten links/rechts**, ausser der Fokus steht in einem Eingabefeld. Ohne diese
Ausnahme spränge die Karte beim Bearbeiten eines Betrags weg.
### 3.15.4 Zwei Sichten auf dasselbe Material
Die Matrix wird **nicht** ersetzt. Ihr Wert ist der Vergleich über Phasen hinweg, und den kann
eine Karte prinzipiell nicht leisten. Konkret hängen drei Dinge daran: der **Szenario-Diff**
(was ist anders als in der Vorlage), der **Plan/Ist-Vergleich** und die schlichte Frage, wie
sich eine Grösse über alle Phasen entwickelt.
Der Umschalter steht oben rechts; die Wahl gehört zum **Benutzer** und überlebt die Sitzung
(`localStorage`) sie ist eine Arbeitsgewohnheit, keine Eigenschaft des Plans.
**Was die Matrix erzwingt und was nicht** (korrigiert in 0.42.1):
| Fall | Verhalten | Warum |
|---|---|---|
| **Ist-Ansicht** aktiv | Matrix erzwungen | Plan gegen Ist ist eine Frage über alle Phasen und es ist ein Modus, in den man bewusst wechselt |
| **Keine Lebensphase** | Matrix erzwungen | Eine Karte ohne Phase gibt es nicht |
| **Abweichungen zur Vorlage** | Karten bleiben, mit Hinweis | Die Diff-Markierungen sind eine Verzierung der Matrix, kein Modus |
> **Der erste Anlauf sperrte die Karten vollständig.** Die Bedingung lautete `!!diff`, und
> `computeScenarioDiff` liefert immer ein Objekt bei Gleichheit ein leeres. Der Umschalter
> war damit dauerhaft ausgegraut. Aber auch `diff.total > 0` wäre falsch gewesen: Ein
> Kind-Szenario weicht nach der ersten Änderung **immer** ab, und Karten wären dort für immer
> gesperrt. Eine Verzierung darf keinen Modus erzwingen.
### 3.15.5 Drei Spalten
```
[Icon-Schiene 56px] [ Karten oder Matrix ] [ Steuerung 304px ]
^ Pin haelt sie breit ^ einklappbar
```
**Links** die Menüleiste: Haus, Plan-Wähler und die vier Bereiche des aktiven Plans, darunter
das Wissen sowie Suchen und Anpinnen. Beim Überfahren fährt sie aus und **schiebt** den Inhalt;
der **Pin** entscheidet, ob sie so bleibt.
> **Ausfahren heisst schieben, nicht überlagern** (korrigiert in 0.42.2). Der erste Anlauf
> liess sie als Overlay ausfahren, damit sie im Ruhezustand keinen Platz kostet. Dabei
> verschwand aber jedes Mal ein Streifen des Inhalts, und das las sich wie ein Fehler.
> Aufgeklappt sieht es jetzt genau so aus wie angepinnt der einzige Unterschied ist, ob es
> so bleibt.
**Suchen und Anpinnen stehen direkt unter den Systemparametern**, nicht am Fuss der Leiste. Die
Leiste ist so hoch wie die Seite, und mit einer aufgeklappten Karte wird die sehr lang am
Fuss wären die beiden ausserhalb des Blickfelds. Der Inhalt beider Leisten klebt zudem am
oberen Bildschirmrand (`sticky`): Steuerung, die beim Arbeiten wegscrollt, ist keine.
> **Eine Komponente in zwei Breiten, nicht zwei Darstellungen** (korrigiert in 0.42.1). Die
> Symbole sitzen in einer **festen 3.5-rem-Spalte** und bleiben beim Ausfahren exakt an ihrem
> Platz; rechts daneben kommt nur die Beschriftung dazu. Der erste Anlauf zeigte eingeklappt
> Icons und ausgefahren einen Plan-Baum das war kein Übergang, sondern ein Sprung, und man
> verlor jedes Mal die Orientierung.
Damit entfällt der **Baum aller Pläne**. Er wird nicht vermisst: Der Plan wird über den Wähler
gewechselt, und die vier Bereiche beziehen sich immer auf den **aktiven** Plan. Statt einer
Liste von Listen bleiben vier Knöpfe. Was daran hing, hat einen neuen Ort:
| Vorher im Baum | Neu |
|---|---|
| Plan wechseln | Plan-Wähler (Klapp-Menü) |
| Plan anlegen, Plan löschen | ebenfalls im Plan-Wähler |
| Szenario-Baum mit Kopieren/Löschen | aufklappbar unter «Szenarien» (nur ausgefahren er braucht Text) |
Der Plan-Wähler zeigt eingeklappt die **Initialen** des aktiven Plans. Ohne diesen Bezug wüsste
man nach dem Umschalten nicht mehr, worin man ist und die vier Knöpfe darunter wären
gegenstandslos.
**Die Matrix ist ein eigener Stapel-Kontext** (`relative z-0`). Ihre sticky Köpfe tragen `z-40`
und konkurrierten sonst global mit der ausgefahrenen Leiste die Ecke oben links schob sich
vor das Menü. Innerhalb der Matrix stapeln sich die Köpfe unverändert; nach aussen tritt sie
als eine Ebene auf.
**Rechts** die vier bisherigen Kacheln als aufklappbare Abschnitte. Das ist der entscheidende
Tausch: Oben kosteten sie **vertikalen** Platz, rechts kosten sie horizontalen und auf einem
Breitbildschirm ist Höhe die knappe Grösse. Eingeklappt bleibt die **Zahl der offenen Punkte**
am Knopf sichtbar; sonst klappt man die Leiste zu und vergisst den halben Plan.
Unter 1280 px verschwindet die rechte Leiste, unter 1024 px die linke Schiene (dort greift der
bestehende Overlay-Drawer). Ergebnis auf 1440 × 900: statt 1184 × 574 rund **1328 × 804** für
den Inhalt 12 % mehr Breite und **40 % mehr Höhe**.
### 3.15.6 Grundsätze, die daraus folgen
Aus dem Umbau sind drei Regeln geworden, die für neue Oberfläche gelten:
1. **Höhe ist knapper als Breite.** Was dauerhaft sichtbar sein muss, gehört an den Rand, nicht
nach oben.
2. **Was fixiert ist, muss sich rechtfertigen.** Ein sticky Element kostet seine Höhe auf jedem
Bildschirm, in jeder Sitzung, für jeden Benutzer.
3. **Nichts legt sich über das, was man bearbeitet.** Overlays sind für Dinge, die den
Arbeitsfluss unterbrechen sollen nicht für die Arbeit selbst.
4. **Ein Übergang verschiebt, er ersetzt nicht.** Wenn ein Element beim Aus- oder Aufklappen
seinen Platz behält und nur Beiwerk dazukommt, folgt das Auge mit. Wechselt stattdessen die
ganze Darstellung, ist es ein Sprung und man sucht sich neu zurecht.
5. **Wer `z-index` vergibt, baut einen Stapel-Kontext.** Sonst konkurrieren Zahlen aus
verschiedenen Bauteilen miteinander, und wer gewinnt, ist Zufall der Reihenfolge im DOM.
Referenz: `src/components/PhaseCards.tsx`, `src/components/Timeline.tsx`,
`IconRail` in `src/components/AppShell.tsx`, `RailSection` und `ValueModeSwitch` in
`src/components/PlanView.tsx`.
--- ---
@@ -3699,7 +4150,8 @@ PlanComputed ← an den Client geliefert
| `distribution.ts` | Kapitaltopf und Quoten-Zerlegung, Anwenden von Entwurfswerten für die Verteil-Werkzeuge (Kap. 3.6.9/3.6.10). Rein. | | `distribution.ts` | Kapitaltopf und Quoten-Zerlegung, Anwenden von Entwurfswerten für die Verteil-Werkzeuge (Kap. 3.6.9/3.6.10). Rein. |
| `retirement.ts` | Pensionsalter verschieben: Spielraum je Person, Verschiebung der Phasengrenze, Zusammenlegung zweier Übergänge (Kap. 4.16). Rein, ohne I/O. | | `retirement.ts` | Pensionsalter verschieben: Spielraum je Person, Verschiebung der Phasengrenze, Zusammenlegung zweier Übergänge (Kap. 4.16). Rein, ohne I/O. |
| `transitions.ts` | Reine Übergangs-Regeln (Vorbelegung, «beantwortet?», Cash-Zusammenfassung). Liegt hier und nicht in einer Komponente, weil auch der Server sie braucht -- ein Import aus `src/components` bricht erst in der Produktion. | | `transitions.ts` | Reine Übergangs-Regeln (Vorbelegung, «beantwortet?», Cash-Zusammenfassung). Liegt hier und nicht in einer Komponente, weil auch der Server sie braucht -- ein Import aus `src/components` bricht erst in der Produktion. |
| `phaseplan.ts` | Ableitung der Lebensabschnitte (Erwerb/Misch/Pension) aus den fixen Pensionierungszeitpunkten für den Assistenten (Kap. 3.2.8). Rein. | | `phaseplan.ts` | Ableitung der Lebensabschnitte (Erwerb/Misch/Pension) aus den fixen Pensionierungszeitpunkten, Fixpunkte und Dauer-Kappung (Kap. 3.14.5). Rein. |
| `review.ts` | Leitet die offenen Punkte eines Plans ab: unbestätigte Phasenzellen, nicht verteilte Quoten, offene Übergänge (Kap. 3.14). Rein. |
| `constants.ts` (erweitert) | zusätzlich `SYSTEM_PARAMETERS`: dieselben Werte maschinenlesbar mit Bedeutung, Herleitung, Quelle und Stand Grundlage der Systemparameter-Ansicht | | `constants.ts` (erweitert) | zusätzlich `SYSTEM_PARAMETERS`: dieselben Werte maschinenlesbar mit Bedeutung, Herleitung, Quelle und Stand Grundlage der Systemparameter-Ansicht |
| `diff.ts` | Abweichungs-Erkennung eines Szenarios gegen sein Eltern-Szenario (Kap. 3.2.6) | | `diff.ts` | Abweichungs-Erkennung eines Szenarios gegen sein Eltern-Szenario (Kap. 3.2.6) |
| `queries.ts` | Prisma-Includes, `toPlanInput()`, Ownership-Abfragen | | `queries.ts` | Prisma-Includes, `toPlanInput()`, Ownership-Abfragen |
@@ -3770,6 +4222,7 @@ PlanComputed ← an den Client geliefert
| `name` | String | | | `name` | String | |
| `durationYears` | Int | 180 | | `durationYears` | Int | 180 |
| `cashTransition` | Json? | Cash-Entscheid beim Übergang **nach** dieser Phase (siehe 5.4.5) | | `cashTransition` | Json? | Cash-Entscheid beim Übergang **nach** dieser Phase (siehe 5.4.5) |
| `ratesConfirmed` | Boolean | Default `false` wurde die Spar-/Verzehrquote dieser Phase je verteilt? ([3.14.2](#3142-bestätigen-heisst-ich-habe-hingeschaut)) |
| `sourcePhaseId` | String? | Gegenstück in der Vorlage (**lose** Referenz, kein FK) Diff-Grundlage | | `sourcePhaseId` | String? | Gegenstück in der Vorlage (**lose** Referenz, kein FK) Diff-Grundlage |
| `createdAt` / `updatedAt` | DateTime | | | `createdAt` / `updatedAt` | DateTime | |
| | | `@@unique([scenarioId, sequenceNumber])` | | | | `@@unique([scenarioId, sequenceNumber])` |
@@ -3852,6 +4305,7 @@ Referenz: `prisma/schema.prisma` Zeilen 46, `src/lib/elements.ts` Zeilen 48
| `interestHandling` | REAL_ESTATE Doppelzählungs-Schalter | `INCLUDED` (Default) \| `ADD` | | `interestHandling` | REAL_ESTATE Doppelzählungs-Schalter | `INCLUDED` (Default) \| `ADD` |
| `valueGrowth` | REAL_ESTATE Wertsteigerung %/Jahr auf die Liegenschaft | 20 bis 20 | | `valueGrowth` | REAL_ESTATE Wertsteigerung %/Jahr auf die Liegenschaft | 20 bis 20 |
| `annualRepayment` | OTHER_DEBT | ≥ 0 | | `annualRepayment` | OTHER_DEBT | ≥ 0 |
| `confirmed` | alle «ich habe hingeschaut» ([3.14.2](#3142-bestätigen-heisst-ich-habe-hingeschaut)) | Boolean, optional |
**Vererbbare Felder (Punkt A, Kap. 3.12.4):** `teuerungsausgleich`, `expectedReturn`, **Vererbbare Felder (Punkt A, Kap. 3.12.4):** `teuerungsausgleich`, `expectedReturn`,
`annualContribution`, `annualWithdrawal`, `amortization`, `valueGrowth`, `interestRate` und `annualContribution`, `annualWithdrawal`, `amortization`, `valueGrowth`, `interestRate` und
@@ -3877,9 +4331,10 @@ den Wert der Vorphase. Alle übrigen Felder bedeuten «nicht gesetzt = 0» wie b
| `extraAmortization` | REAL_ESTATE (Sonderamortisation) | ≥ 0 | | `extraAmortization` | REAL_ESTATE (Sonderamortisation) | ≥ 0 |
| `saleTaxRate` | REAL_ESTATE | 0100 | | `saleTaxRate` | REAL_ESTATE | 0100 |
| `immediateRepayment` | OTHER_DEBT | ≥ 0 | | `immediateRepayment` | OTHER_DEBT | ≥ 0 |
| `capitalUseAmortizationPct` | PENSION_FUND (Kapitalbezug), PILLAR_3A Anteil des bezogenen Kapitals in die Amortisation | 0100 | | `capitalUseAmortizationPct` | PENSION_FUND (Kapitalbezug), PILLAR_3A Anteil des bezogenen Kapitals in die **Schuldentilgung** | 0100 |
| `capitalUseInvestPct` | dito Anteil in eine Anlage | 0100 | | `capitalUseDebtTargetElementId` | Ziel der Tilgungs-Quote: eine Immobilie **oder** eine sonstige Schuld. Ohne Angabe die Immobilien der Reihe nach (Bestandsschutz); ein Schuld-Element wird nur getilgt, wenn es ausdrücklich gewählt ist | ≤ 60 Zeichen |
| `capitalUseTargetElementId` | Ziel der Anlage-Quote; ohne Angabe das erste aktive Sonstige Vermögen | ≤ 60 Zeichen | | `capitalUseInvestPct` | dito Anteil in eine **Investition** | 0100 |
| `capitalUseTargetElementId` | Ziel der Investitions-Quote; ohne Angabe das erste aktive Sonstige Vermögen | ≤ 60 Zeichen |
### 5.4.5 JSON-Payload `CashTransitionData` ### 5.4.5 JSON-Payload `CashTransitionData`
@@ -3924,6 +4379,7 @@ sondern zu leeren Werten.
| `20260721090000_reports` | Tabelle `Report`: gewählte Parameter, eingefrorenes Modell und die PDF-Datei als `BYTEA` | | `20260721090000_reports` | Tabelle `Report`: gewählte Parameter, eingefrorenes Modell und die PDF-Datei als `BYTEA` |
| `20260724120000_version_zero_start` | `Scenario.currentMajor` startet bei **0** statt 1 die Versionierung beginnt bei 0.1 (Kap. 3.8). Nur der Default; bestehende Zeilen bleiben | | `20260724120000_version_zero_start` | `Scenario.currentMajor` startet bei **0** statt 1 die Versionierung beginnt bei 0.1 (Kap. 3.8). Nur der Default; bestehende Zeilen bleiben |
| `20260720160000_saved_analyses` | Tabelle `SavedAnalysis` (Eingaben + Ergebnis als JSONB, denormalisierte Kerndaten) | | `20260720160000_saved_analyses` | Tabelle `SavedAnalysis` (Eingaben + Ergebnis als JSONB, denormalisierte Kerndaten) |
| `20260816150000_review_state` | `Phase.ratesConfirmed` (Default `false`); `Scenario.assistantProgress` entfällt mit dem Assistenten (Kap. 3.14) |
**Zur V6-Migration:** Sie benennt die bisherige `Plan`-Tabelle in `Scenario` um dadurch **Zur V6-Migration:** Sie benennt die bisherige `Plan`-Tabelle in `Scenario` um dadurch
bleiben alle IDs und damit sämtliche Kind-Fremdschlüssel gültig. Für jedes bisherige bleiben alle IDs und damit sämtliche Kind-Fremdschlüssel gültig. Für jedes bisherige
@@ -3985,7 +4441,8 @@ wird der Plan neu geladen; die Berechnung kommt immer vom Server.
| `SpecView` | 65 | Rendert `SPEZIFIKATION.md` (via `/api/spec`) als lesbares Dokument, inkl. Sprungmarken aus den Rechenwegen | | `SpecView` | 65 | Rendert `SPEZIFIKATION.md` (via `/api/spec`) als lesbares Dokument, inkl. Sprungmarken aus den Rechenwegen |
| `InfoBubble` | 28 | Hilfe-Tooltip | | `InfoBubble` | 28 | Hilfe-Tooltip |
| `ui` | ~370 | UI-Primitiven: Button, Modal, InspectorShell, Confirm, Toast, Skeleton, EmptyState ([3.7.6](#376-sprache-und-ui-primitiven)/[3.7.7](#377-inspector-panel-statt-modals)) | | `ui` | ~370 | UI-Primitiven: Button, Modal, InspectorShell, Confirm, Toast, Skeleton, EmptyState ([3.7.6](#376-sprache-und-ui-primitiven)/[3.7.7](#377-inspector-panel-statt-modals)) |
| `Assistant` · `AssistantStepDialog` · `AssistantSteps` | ~1200 | Der FPT-Assistent: Fortschrittskachel, Erklärseiten und die Werkzeuge der sieben Schritte ([3.14](#314-der-fpt-assistent)) | | `ReviewTile` · `InventoryDialog` | ~900 | Übersicht der offenen Punkte und der Sammel-Dialog der Bestandsaufnahme ([3.14](#314-bestandsaufnahme-und-offene-punkte)) |
| `PhaseCards` | ~560 | Kartenansicht: eine Karte je Lebensphase, Bearbeitung in der Karte ([3.15](#315-der-bildschirm-drei-spalten-zwei-sichten)) |
| `Tour` | ~140 | Interaktive Kurz-Tour über die Planansicht ([3.7.8](#378-tour-und-nächste-schritte)) | | `Tour` | ~140 | Interaktive Kurz-Tour über die Planansicht ([3.7.8](#378-tour-und-nächste-schritte)) |
| `CommandPalette` | ~130 | Befehls-Palette Ctrl/Cmd+K ([3.7.9](#379-befehls-palette-und-sparklines)) | | `CommandPalette` | ~130 | Befehls-Palette Ctrl/Cmd+K ([3.7.9](#379-befehls-palette-und-sparklines)) |
| `DistributionDialogs` | ~460 | Verteil-Werkzeuge für Kapital und Spar-/Verzehrquote ([3.6.10](#3610-verteil-werkzeuge)) | | `DistributionDialogs` | ~460 | Verteil-Werkzeuge für Kapital und Spar-/Verzehrquote ([3.6.10](#3610-verteil-werkzeuge)) |
@@ -4306,7 +4763,7 @@ Include `src/**/*.test.ts`). Es gibt **keine** Komponenten-, API- oder E2E-Tests
| `migrations.test.ts` | 3 | spielt alle Migrationen gegen echtes PostgreSQL (PGlite) ein; prüft zusätzlich die V7-**Datenübernahme** (Basisszenario gewinnt, Pensionsalter bleiben szenario-eigen) | | `migrations.test.ts` | 3 | spielt alle Migrationen gegen echtes PostgreSQL (PGlite) ein; prüft zusätzlich die V7-**Datenübernahme** (Basisszenario gewinnt, Pensionsalter bleiben szenario-eigen) |
| `rate-limit.test.ts` | 6 | Fixed-Window: erlaubt bis Limit, blockt danach, startet nach Fensterablauf neu, trennt je Schlüssel; Client-IP aus X-Forwarded-For / X-Real-IP | | `rate-limit.test.ts` | 6 | Fixed-Window: erlaubt bis Limit, blockt danach, startet nach Fensterablauf neu, trennt je Schlüssel; Client-IP aus X-Forwarded-For / X-Real-IP |
| `csv.test.ts` | 7 | BOM, alle vier Blöcke, jedes Element als Zeile, Beginn-/Ende-/Übergangsspalten, Entscheid im Klartext, ein Eintrag je Planjahr, Maskierung von `;` und `"` | | `csv.test.ts` | 7 | BOM, alle vier Blöcke, jedes Element als Zeile, Beginn-/Ende-/Übergangsspalten, Entscheid im Klartext, ein Eintrag je Planjahr, Maskierung von `;` und `"` |
| **Total** | **334** | | | **Total** | **337** | |
## 8.2 Testfälle ## 8.2 Testfälle
@@ -4670,10 +5127,10 @@ seit 0.12 erklärt wird ([4.13.5](#4135-wirkungslose-treiber-werden-erklärt));
Vermögensbrücke erscheint stattdessen die Differenz `Verkaufspreis Verkehrswert` als eigener Vermögensbrücke erscheint stattdessen die Differenz `Verkaufspreis Verkehrswert` als eigener
Posten. Posten.
## 9.23 Assistent: Teilzustand bei Abbruch ## 9.23 Sammel-Dialoge: Teilzustand bei Abbruch
Der Plan-Assistent und der Beispielplan senden am Ende eine **Sequenz** bestehender API-Aufrufe Die Bestandsaufnahme schreibt am Ende eine **Sequenz** bestehender API-Aufrufe
(Plan → Phase 1 → Elemente → Folgephasen). Bricht die Sequenz mittendrin ab (Netzfehler), (Element anlegen → Stammdaten → Phasenwerte, je Element). Bricht die Sequenz mittendrin ab (Netzfehler),
existiert ein **Teil-Plan**. Der ist normal weiterbearbeitbar und der Fehlerhinweis sagt das existiert ein **Teil-Plan**. Der ist normal weiterbearbeitbar und der Fehlerhinweis sagt das
auch aber es gibt kein automatisches Rollback. Das wäre nur mit Backend-Unterstützung auch aber es gibt kein automatisches Rollback. Das wäre nur mit Backend-Unterstützung
(Transaktion über mehrere Requests oder Batch-Endpunkt) sauber lösbar und ist bewusst nicht (Transaktion über mehrere Requests oder Batch-Endpunkt) sauber lösbar und ist bewusst nicht
@@ -0,0 +1,8 @@
-- Der Planungshorizont wird ABGELEITET statt erfasst (SPEZIFIKATION 3.14.5).
--
-- Dritter und letzter Anlauf: 0.35 fuehrte ihn als Endalter je Person, 0.36 als Jahreszahl am
-- Szenario -- beide Male eine zweite Wahrheit ueber dieselbe Sache. Wer die Lebensphasen
-- einzeln plant, hat den Horizont damit bereits bestimmt; ihn zusaetzlich zu speichern hiess
-- nur, zwei Zahlen synchron halten zu muessen. Er ist jetzt schlicht die Summe der
-- Phasendauern.
ALTER TABLE "Scenario" DROP COLUMN IF EXISTS "planningHorizonYears";
@@ -0,0 +1,18 @@
-- Der FPT-Assistent wird durch eine Uebersicht der OFFENEN PUNKTE ersetzt (SPEZIFIKATION 3.14).
--
-- Der Unterschied ist grundsaetzlich: Der Assistent war ein ABLAUF ("tu dies, dann das") und
-- funktionierte nur beim ersten Aufsetzen. Die Uebersicht ist ein ZUSTAND ("das ist noch
-- offen") und traegt bei jedem Plan, in jeder Reihenfolge, auch beim zwanzigsten Szenario.
--
-- (1) `Phase.ratesConfirmed` haelt fest, ob die Spar-/Verzehrquote dieser Phase einmal bewusst
-- verteilt wurde. Was man nicht verteilt, sammelt sich still als Cash an -- rechnerisch
-- richtig, aber selten die Absicht. Der Haken unterscheidet "bewusst so gelassen" von
-- "noch nie angeschaut".
--
-- (2) `Scenario.assistantProgress` entfaellt mit dem Assistenten. Der Fortschritt wird nicht
-- mehr erklaert, sondern ABGELEITET -- aus dem, was tatsaechlich noch offen ist.
--
-- Die Bestaetigung der Phasenwerte je Element liegt als `confirmed` im vorhandenen JSON von
-- `ElementPhaseValue` und braucht deshalb keine Spalte.
ALTER TABLE "Phase" ADD COLUMN "ratesConfirmed" BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE "Scenario" DROP COLUMN IF EXISTS "assistantProgress";
+5 -6
View File
@@ -222,13 +222,7 @@ model Scenario {
inflationRateDefault Float inflationRateDefault Float
initialCash Float @default(0) initialCash Float @default(0)
// Wie viele Jahre die Planung umfasst. Bewusst in JAHREN und nicht als Endalter je Person:
// eine Zahl statt zweier, die bei einem Paar auseinanderlaufen koennten. Die Endalter
// werden daraus abgeleitet. NULL = wie bisher aus der Summe der Phasendauern.
planningHorizonYears Int?
// Fortschritt des FPT-Assistenten (sieben Schritte, vom Benutzer abgehakt).
assistantProgress Json?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -288,6 +282,11 @@ model Phase {
// und nicht in ElementTransitionValue, weil Cash kein FinancialElement ist. // und nicht in ElementTransitionValue, weil Cash kein FinancialElement ist.
cashTransition Json? cashTransition Json?
// Wurde die Spar-/Verzehrquote dieser Phase einmal bewusst verteilt? Was man nicht verteilt,
// sammelt sich still als Cash an -- rechnerisch richtig, aber selten die Absicht. Der Haken
// unterscheidet "bewusst so gelassen" von "noch nie angeschaut".
ratesConfirmed Boolean @default(false)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
+3
View File
@@ -10,6 +10,8 @@ import { planDurationChange } from "@/lib/phaseplan";
const updatePhaseSchema = z.object({ const updatePhaseSchema = z.object({
name: z.string().min(1).max(120).optional(), name: z.string().min(1).max(120).optional(),
durationYears: z.number().int().min(1).max(80).optional(), durationYears: z.number().int().min(1).max(80).optional(),
// Die Spar-/Verzehrquote dieser Phase wurde bewusst verteilt (SPEZIFIKATION 3.14).
ratesConfirmed: z.boolean().optional(),
}); });
export async function PUT( export async function PUT(
@@ -58,6 +60,7 @@ export async function PUT(
data: { data: {
name: parsed.data.name ?? undefined, name: parsed.data.name ?? undefined,
durationYears: duration ?? undefined, durationYears: duration ?? undefined,
ratesConfirmed: parsed.data.ratesConfirmed ?? undefined,
}, },
}); });
if (neighbour) { if (neighbour) {
@@ -1,34 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { prisma } from "@/lib/db";
import { getOwnedScenario } from "@/lib/queries";
import { getCurrentUserId } from "@/lib/session";
import { ASSISTANT_STEP_COUNT, normalizeProgress } from "@/lib/assistant";
// Fortschritt des FPT-Assistenten setzen (ein Haken je Schritt).
//
// Bewusst OHNE `touchScenario`: Das Abhaken ist eine Notiz des Benutzers über sich selbst,
// keine Änderung am Plan. Eine Version dafür anzulegen würde die Historie mit Einträgen
// fluten, die inhaltlich nichts unterscheiden.
const bodySchema = z.object({
step: z.number().int().min(0).max(ASSISTANT_STEP_COUNT - 1),
done: z.boolean(),
});
export async function POST(request: NextRequest, { params }: { params: Promise<{ scenarioId: string }> }) {
const userId = await getCurrentUserId();
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
const { scenarioId } = await params;
const scenario = await getOwnedScenario(scenarioId, userId);
if (!scenario) return NextResponse.json({ error: "Szenario nicht gefunden." }, { status: 404 });
const parsed = bodySchema.safeParse(await request.json().catch(() => ({})));
if (!parsed.success) return NextResponse.json({ error: "Ungültige Eingabe." }, { status: 400 });
const progress = normalizeProgress(scenario.assistantProgress);
progress[parsed.data.step] = parsed.data.done;
await prisma.scenario.update({ where: { id: scenarioId }, data: { assistantProgress: progress } });
return NextResponse.json({ ok: true, progress });
}
@@ -33,8 +33,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
// kopiert. Szenario-eigen sind nur Inflation, Cash-Anfangswert und Pensionsalter. // kopiert. Szenario-eigen sind nur Inflation, Cash-Anfangswert und Pensionsalter.
inflationRateDefault: source.inflationRateDefault, inflationRateDefault: source.inflationRateDefault,
initialCash: source.initialCash, initialCash: source.initialCash,
planningHorizonYears: source.planningHorizonYears,
assistantProgress: source.assistantProgress ?? undefined,
persons: { persons: {
create: source.persons.map((p) => ({ role: p.role, retirementAge: p.retirementAge })), create: source.persons.map((p) => ({ role: p.role, retirementAge: p.retirementAge })),
}, },
@@ -1,46 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { prisma } from "@/lib/db";
import { getOwnedScenario, toPlanInput } from "@/lib/queries";
import { getCurrentUserId } from "@/lib/session";
import { touchScenario } from "@/lib/versioning-db";
import { planHorizonChange } from "@/lib/retirement";
import { MAX_PLANNING_HORIZON_YEARS, MIN_PLANNING_HORIZON_YEARS } from "@/lib/constants";
// Planungshorizont setzen: wie viele JAHRE die Planung umfasst.
//
// Wie beim Pensionsalter gilt: Zahl stellen, Struktur folgt. Die LETZTE Lebensphase wird so
// verlängert oder gekürzt, dass der Plan genau bis zum Horizont läuft. Gibt es noch keine
// Phasen, wird nur die Zahl gespeichert -- sie ist dann die Grundlage, auf der der Assistent
// die Zeitachse aufspannt.
const bodySchema = z.object({
horizonYears: z.number().int().min(MIN_PLANNING_HORIZON_YEARS).max(MAX_PLANNING_HORIZON_YEARS),
});
export async function POST(request: NextRequest, { params }: { params: Promise<{ scenarioId: string }> }) {
const userId = await getCurrentUserId();
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
const { scenarioId } = await params;
const scenario = await getOwnedScenario(scenarioId, userId);
if (!scenario) return NextResponse.json({ error: "Szenario nicht gefunden." }, { status: 404 });
const parsed = bodySchema.safeParse(await request.json().catch(() => ({})));
if (!parsed.success) return NextResponse.json({ error: "Ungültige Eingabe." }, { status: 400 });
const { horizonYears } = parsed.data;
const planInput = toPlanInput(scenario);
const change = planInput.phases.length > 0 ? planHorizonChange(planInput, horizonYears) : null;
if (change?.blocked) return NextResponse.json({ error: change.blocked }, { status: 400 });
await prisma.$transaction([
prisma.scenario.update({ where: { id: scenarioId }, data: { planningHorizonYears: horizonYears } }),
...(change
? [prisma.phase.update({ where: { id: change.lastPhaseId }, data: { durationYears: change.newDuration } })]
: []),
]);
await touchScenario(scenario.id, userId);
return NextResponse.json({ ok: true, lastPhaseDuration: change?.newDuration ?? null });
}
+456 -171
View File
@@ -14,6 +14,8 @@ import {
Layers, Layers,
LayoutDashboard, LayoutDashboard,
Menu, Menu,
Pin,
PinOff,
PiggyBank, PiggyBank,
Plus, Plus,
Search, Search,
@@ -39,8 +41,7 @@ import { SystemParametersView } from "@/components/SystemParametersView";
import { PlanTraceDialog } from "@/components/DetailView"; import { PlanTraceDialog } from "@/components/DetailView";
import { computePlan } from "@/lib/calculations"; import { computePlan } from "@/lib/calculations";
import { ProfileMenu } from "@/components/ProfileMenu"; import { ProfileMenu } from "@/components/ProfileMenu";
import { Tour, TOUR_DONE_KEY } from "@/components/Tour"; import { Tour, TOUR_DONE_KEY } from "@/components/Tour";import { CommandPalette, type PaletteAction } from "@/components/CommandPalette";
import { CommandPalette, type PaletteAction } from "@/components/CommandPalette";
import { PlanProfileFields, emptyProfileDraft, type ProfileDraft } from "@/components/PlanProfileFields"; import { PlanProfileFields, emptyProfileDraft, type ProfileDraft } from "@/components/PlanProfileFields";
import { import {
Button, Button,
@@ -52,6 +53,9 @@ import {
useConfirm, useConfirm,
useToast, useToast,
} from "@/components/ui"; } from "@/components/ui";
// Angepinnte Menueleiste: eine Arbeitsgewohnheit des Benutzers, keine Eigenschaft des Plans.
const RAIL_PINNED_KEY = "fpt.rail.pinned";
import { api } from "@/lib/api-client"; import { api } from "@/lib/api-client";
import { computeScenarioDiff } from "@/lib/diff"; import { computeScenarioDiff } from "@/lib/diff";
import type { PlanInput, PlanListItem, ScenarioMeta } from "@/lib/types"; import type { PlanInput, PlanListItem, ScenarioMeta } from "@/lib/types";
@@ -100,8 +104,6 @@ function AppShellInner({ username }: { username: string }) {
const [showActuals, setShowActuals] = useState(false); const [showActuals, setShowActuals] = useState(false);
// Plan-Ebene: Dashboard / Szenarien-Liste / Analysen. Null = kein Plan-View aktiv. // Plan-Ebene: Dashboard / Szenarien-Liste / Analysen. Null = kein Plan-View aktiv.
const [planNav, setPlanNav] = useState<{ planId: string; tab: "dashboard" | "scenarios" | "actuals" | "analyses" | "reports" } | null>(null); const [planNav, setPlanNav] = useState<{ planId: string; tab: "dashboard" | "scenarios" | "actuals" | "analyses" | "reports" } | null>(null);
// Welche Szenario-Bäume in der Seitenleiste aufgeklappt sind. Standard: eingeklappt.
const [expandedTrees, setExpandedTrees] = useState<Record<string, boolean>>({});
const [savedAnalysisId, setSavedAnalysisId] = useState<string | null>(null); const [savedAnalysisId, setSavedAnalysisId] = useState<string | null>(null);
const [showSystemParams, setShowSystemParams] = useState(false); const [showSystemParams, setShowSystemParams] = useState(false);
const [showPlanTraces, setShowPlanTraces] = useState(false); const [showPlanTraces, setShowPlanTraces] = useState(false);
@@ -111,6 +113,37 @@ function AppShellInner({ username }: { username: string }) {
// Erhoehen erzwingt ein Neuladen der Szenario-Liste (bleibt bei einer Loeschung montiert). // Erhoehen erzwingt ein Neuladen der Szenario-Liste (bleibt bei einer Loeschung montiert).
const [scenarioListKey, setScenarioListKey] = useState(0); const [scenarioListKey, setScenarioListKey] = useState(0);
// Menueleiste links: eingeklappt auf eine Icon-Schiene (56 px) statt 240 px.
//
// Beim Ueberfahren faehrt sie aus und SCHIEBT den Inhalt -- sie legt sich nicht darueber.
// Der erste Anlauf (0.42) liess sie als Overlay ausfahren, um im Ruhezustand keinen Platz
// zu kosten; dabei verschwand aber jedes Mal ein Streifen des Inhalts, und das las sich wie
// ein Fehler. Aufgeklappt sieht es damit genau so aus wie angepinnt, und das Anpinnen
// entscheidet nur noch, ob es so BLEIBT. Die Wahl gehoert zum Benutzer und ueberlebt die
// Sitzung.
const [railPinned, setRailPinned] = useState(false);
const [railHover, setRailHover] = useState(false);
const [planMenuOpen, setPlanMenuOpen] = useState(false);
useEffect(() => {
try {
// eslint-disable-next-line react-hooks/set-state-in-effect -- einmalige Initialisierung
if (localStorage.getItem(RAIL_PINNED_KEY) === "1") setRailPinned(true);
} catch {
/* localStorage nicht verfuegbar */
}
}, []);
function toggleRailPin() {
setRailPinned((v) => {
try {
localStorage.setItem(RAIL_PINNED_KEY, v ? "0" : "1");
} catch {
/* ignorieren */
}
return !v;
});
}
const railWide = railPinned || railHover;
// Tour (seit dem Layout-Umbau hier statt in PlanView -- sie liest die data-tour-Ziele im // Tour (seit dem Layout-Umbau hier statt in PlanView -- sie liest die data-tour-Ziele im
// DOM der Szenario-Ansicht). Zwei Auslöser: // DOM der Szenario-Ansicht). Zwei Auslöser:
// * pendingTourRef: nach JEDER Plan-Erstellung genau einmal erzwingen (Roadmap-Feedback F14). // * pendingTourRef: nach JEDER Plan-Erstellung genau einmal erzwingen (Roadmap-Feedback F14).
@@ -120,7 +153,11 @@ function AppShellInner({ username }: { username: string }) {
const autoTourCheckedRef = useRef<Set<string>>(new Set()); const autoTourCheckedRef = useRef<Set<string>>(new Set());
useEffect(() => { useEffect(() => {
if (!detail || detail.plan.phases.length === 0) return; // Bis 0.39 stand hier zusätzlich `plan.phases.length === 0`: Die alte Spotlight-Tour
// beleuchtete echte DOM-Ziele in der Matrix und hatte ohne Phasen nichts zu zeigen. Die
// Tour ist seit 0.36 eine Attrappe mit erfundenen Zahlen -- sie braucht nichts, und sie
// gehört genau an den Anfang, wo man noch gar nicht weiss, wie der Plan aufgebaut ist.
if (!detail) return;
const sid = detail.meta.id; const sid = detail.meta.id;
if (pendingTourRef.current) { if (pendingTourRef.current) {
pendingTourRef.current = false; pendingTourRef.current = false;
@@ -307,189 +344,74 @@ function AppShellInner({ username }: { username: string }) {
return base; return base;
}, [detail, selectedScenarioId]); }, [detail, selectedScenarioId]);
const sidebar = ( const diff = detail ? computeScenarioDiff(detail.plan, detail.base) : null;
<div className="flex h-full flex-col">
<div className="flex items-center gap-2 px-4 py-4">
<div className="flex h-8 w-8 items-center justify-center rounded-xl bg-accent">
<PiggyBank className="h-5 w-5 text-accent-fg" />
</div>
<span className="text-sm font-semibold text-fg">FPT</span>
<button
type="button"
onClick={() => setShowPalette(true)}
title="Suchen und springen (Ctrl+K)"
className="ml-auto flex items-center gap-1 rounded-lg border border-border px-2 py-1 text-[10px] text-faint transition-colors hover:bg-surface-2 hover:text-fg"
>
<Search className="h-3 w-3" />
Ctrl K
</button>
</div>
<nav className="flex flex-1 flex-col gap-1 overflow-y-auto px-3 pb-4"> return (
<button <div className="flex min-h-screen w-full">
type="button" {/* Linke Leiste. Eingeklappt eine Icon-Schiene; beim Ueberfahren faehrt sie als Overlay
onClick={() => { aus (kostet keinen Platz), angepinnt bleibt sie stehen und schiebt den Inhalt.
Icon-only braucht zwingend Tooltip UND aria-label -- sonst ist die Schiene fuer
Screenreader und fuer Neulinge gleichermassen stumm. */}
{/* Linke Leiste. EINE Komponente in zwei Breiten: Die Symbole bleiben an Ort und
Stelle, rechts daneben faehrt die Beschriftung aus. Ein Wechsel der Darstellung
(Icons hier, Baum dort) waere kein Uebergang, sondern ein Sprung -- und genau das
hat sich falsch angefuehlt. */}
<div
className="relative hidden shrink-0 lg:block"
style={{ width: railWide ? "15rem" : "3.5rem", transition: "width 200ms" }}
onMouseEnter={() => setRailHover(true)}
onMouseLeave={() => {
setRailHover(false);
setPlanMenuOpen(false);
}}
>
<Rail
wide={railWide}
pinned={railPinned}
onTogglePin={toggleRailPin}
plans={plans}
activeScenarioId={selectedScenarioId}
planNav={planNav}
atHome={selectedScenarioId === null && !planNav && !showSpec && !showSystemParams}
showSpec={showSpec}
showSystemParams={showSystemParams}
planMenuOpen={planMenuOpen}
onTogglePlanMenu={() => setPlanMenuOpen((o) => !o)}
onNewPlan={() => {
setPlanMenuOpen(false);
setShowEmptyPlanDialog(true);
}}
onDeletePlan={handleDeletePlan}
onHome={() => {
setSelectedScenarioId(null); setSelectedScenarioId(null);
setPlanNav(null); setPlanNav(null);
setShowSpec(false); setShowSpec(false);
setShowSystemParams(false); setShowSystemParams(false);
setSidebarOpen(false);
}} }}
className={`flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium transition-colors ${ onOpenTab={(planId, tab) => {
selectedScenarioId === null && !planNav && !showSpec && !showSystemParams ? "bg-accent-soft text-accent-soft-fg" : "text-muted hover:bg-surface-2" setPlanMenuOpen(false);
}`} if (tab === "actuals") void openActualsTab(planId);
> else openPlanTab(planId, tab);
<LayoutDashboard className="h-4 w-4" /> }}
Übersicht onOpenScenario={openScenario}
</button> onCopyScenario={setCopyFrom}
onDeleteScenario={handleDeleteScenario}
<div className="mt-4 flex items-center justify-between px-3"> onSpec={() => {
<span className="text-[11px] font-semibold uppercase tracking-wide text-faint">Meine Pläne</span>
<button
type="button"
onClick={() => setShowEmptyPlanDialog(true)}
aria-label="Neuen Plan erstellen"
className="rounded-md p-1 text-accent transition-colors hover:bg-accent-soft"
>
<Plus className="h-4 w-4" />
</button>
</div>
{plans.length === 0 && <p className="px-3 py-2 text-xs text-faint">Noch keine Pläne.</p>}
{plans.map((p) => {
const navHere = planNav?.planId === p.id;
// Tour-Ziel «menu»: die Menü-Gruppe des gerade offenen Plans.
const isActivePlan = p.scenarios.some((s) => s.id === selectedScenarioId);
const subItem = (tab: "dashboard" | "scenarios" | "actuals" | "analyses" | "reports", label: string, Icon: typeof FolderKanban) => (
<button
type="button"
onClick={() => openPlanTab(p.id, tab)}
className={`flex w-full items-center gap-2 rounded-lg py-1.5 pl-[1.625rem] pr-3 text-left text-xs font-medium transition-colors ${
navHere && planNav?.tab === tab ? "bg-accent-soft text-accent-soft-fg" : "text-muted hover:bg-surface-2"
}`}
>
<Icon className="h-3.5 w-3.5 shrink-0" />
{label}
</button>
);
return (
<div key={p.id} className="mb-1" data-tour={isActivePlan ? "menu" : undefined}>
{/* Plan-Name: Klick öffnet das Plan-Dashboard. */}
<div className="group flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-semibold text-fg">
<button
type="button"
onClick={() => openPlanTab(p.id, "dashboard")}
className="flex min-w-0 flex-1 items-center gap-1.5 text-left"
title={p.name}
>
<FolderKanban className={`h-3.5 w-3.5 shrink-0 ${navHere && planNav?.tab === "dashboard" ? "text-accent" : "text-faint"}`} />
<span className="min-w-0 flex-1 truncate">{p.name}</span>
</button>
<button
type="button"
aria-label="Plan löschen"
onClick={() => handleDeletePlan(p.id)}
className="rounded p-0.5 text-faint opacity-60 transition-opacity hover:bg-danger-soft hover:text-danger group-hover:opacity-100"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
{/* «Szenarien»: Chevron klappt den Baum auf/zu (Standard: zu), das Label führt
in die Szenario-Liste. */}
<div
className={`group flex w-full items-center gap-1 rounded-lg py-1.5 pr-3 pl-1 text-xs font-medium transition-colors ${
navHere && planNav?.tab === "scenarios" ? "bg-accent-soft text-accent-soft-fg" : "text-muted hover:bg-surface-2"
}`}
>
<button
type="button"
aria-label={expandedTrees[p.id] ? "Szenarien einklappen" : "Szenarien aufklappen"}
onClick={() => setExpandedTrees((prev) => ({ ...prev, [p.id]: !prev[p.id] }))}
className="rounded p-0.5 text-faint hover:text-fg"
>
{expandedTrees[p.id] ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
</button>
<button
type="button"
onClick={() => openPlanTab(p.id, "scenarios")}
className="flex min-w-0 flex-1 items-center gap-2 text-left"
>
<Layers className="h-3.5 w-3.5 shrink-0" />
Szenarien
</button>
</div>
{/* Ebene 3: nur unter "Szenarien", verschachtelt nach Herkunft. */}
{expandedTrees[p.id] && (
<ScenarioTree
scenarios={p.scenarios}
selectedId={selectedScenarioId}
onSelect={openScenario}
onCopy={setCopyFrom}
onDelete={handleDeleteScenario}
/>
)}
<button
type="button"
onClick={() => void openActualsTab(p.id)}
className={`flex w-full items-center gap-2 rounded-lg py-1.5 pl-[1.625rem] pr-3 text-left text-xs font-medium transition-colors ${
navHere && planNav?.tab === "actuals" ? "bg-accent-soft text-accent-soft-fg" : "text-muted hover:bg-surface-2"
}`}
>
<CalendarClock className="h-3.5 w-3.5 shrink-0" />
Effektive Werte
</button>
{subItem("analyses", "Analysen", BarChart3)}
{subItem("reports", "Berichte", FileSpreadsheet)}
</div>
);
})}
<div className="mt-4 flex flex-col gap-1 border-t border-border pt-3">
<span className="px-3 pb-1 text-[11px] font-semibold uppercase tracking-wide text-faint">Wissen</span>
<button
type="button"
onClick={() => {
setShowSpec(true); setShowSpec(true);
setSpecAnchor(null); setSpecAnchor(null);
setShowSystemParams(false); setShowSystemParams(false);
setSelectedScenarioId(null); setSelectedScenarioId(null);
setPlanNav(null); setPlanNav(null);
setSidebarOpen(false);
}} }}
className={`flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm font-medium transition-colors ${ onParams={() => {
showSpec ? "bg-accent-soft text-accent-soft-fg" : "text-muted hover:bg-surface-2"
}`}
>
<FileText className="h-4 w-4 shrink-0" />
So rechnet FPT
</button>
<button
type="button"
onClick={() => {
setShowSystemParams(true); setShowSystemParams(true);
setShowSpec(false); setShowSpec(false);
setSelectedScenarioId(null); setSelectedScenarioId(null);
setPlanNav(null); setPlanNav(null);
setSidebarOpen(false);
}} }}
className={`flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm font-medium transition-colors ${ onSearch={() => setShowPalette(true)}
showSystemParams ? "bg-accent-soft text-accent-soft-fg" : "text-muted hover:bg-surface-2" />
}`}
>
<SlidersHorizontal className="h-4 w-4 shrink-0" />
Systemparameter
</button>
</div> </div>
</nav>
</div>
);
const diff = detail ? computeScenarioDiff(detail.plan, detail.base) : null;
return (
<div className="flex min-h-screen w-full">
<aside className="hidden w-64 shrink-0 border-r border-border bg-surface lg:block">{sidebar}</aside>
{sidebarOpen && ( {sidebarOpen && (
<div className="fixed inset-0 z-40 lg:hidden"> <div className="fixed inset-0 z-40 lg:hidden">
@@ -503,7 +425,60 @@ function AppShellInner({ username }: { username: string }) {
> >
<X className="h-4 w-4" /> <X className="h-4 w-4" />
</button> </button>
{sidebar} <Rail
wide
pinned
mobile
onTogglePin={() => setSidebarOpen(false)}
plans={plans}
activeScenarioId={selectedScenarioId}
planNav={planNav}
atHome={selectedScenarioId === null && !planNav && !showSpec && !showSystemParams}
showSpec={showSpec}
showSystemParams={showSystemParams}
planMenuOpen={planMenuOpen}
onTogglePlanMenu={() => setPlanMenuOpen((o) => !o)}
onNewPlan={() => {
setPlanMenuOpen(false);
setShowEmptyPlanDialog(true);
}}
onDeletePlan={handleDeletePlan}
onHome={() => {
setSelectedScenarioId(null);
setPlanNav(null);
setShowSpec(false);
setShowSystemParams(false);
setSidebarOpen(false);
}}
onOpenTab={(planId, tab) => {
setPlanMenuOpen(false);
setSidebarOpen(false);
if (tab === "actuals") void openActualsTab(planId);
else openPlanTab(planId, tab);
}}
onOpenScenario={(id) => {
openScenario(id);
setSidebarOpen(false);
}}
onCopyScenario={setCopyFrom}
onDeleteScenario={handleDeleteScenario}
onSpec={() => {
setShowSpec(true);
setSpecAnchor(null);
setShowSystemParams(false);
setSelectedScenarioId(null);
setPlanNav(null);
setSidebarOpen(false);
}}
onParams={() => {
setShowSystemParams(true);
setShowSpec(false);
setSelectedScenarioId(null);
setPlanNav(null);
setSidebarOpen(false);
}}
onSearch={() => setShowPalette(true)}
/>
</aside> </aside>
</div> </div>
)} )}
@@ -1087,3 +1062,313 @@ function CopyScenarioDialog({
</Modal> </Modal>
); );
} }
// --- Die linke Leiste ------------------------------------------------------------------------
//
// EINE Komponente in zwei Breiten. Die Symbole stehen immer an derselben Stelle -- in einer
// festen 3.5-rem-Spalte --, rechts daneben faehrt die Beschriftung aus. Bis 0.42 wechselte die
// Leiste beim Ueberfahren die Darstellung: eingeklappt Icons, ausgefahren ein Plan-Baum. Das
// war kein Uebergang, sondern ein Sprung, und man verlor jedes Mal die Orientierung.
//
// Der Preis ist der Verlust des Baums ALLER Plaene. Er wird nicht vermisst: Der Plan wird ueber
// den Waehler oben gewechselt, und die vier Bereiche darunter beziehen sich immer auf den
// AKTIVEN Plan. Statt einer Liste von Listen bleiben vier Knoepfe.
function Rail({
wide,
pinned,
mobile,
onTogglePin,
plans,
activeScenarioId,
planNav,
atHome,
showSpec,
showSystemParams,
planMenuOpen,
onTogglePlanMenu,
onNewPlan,
onDeletePlan,
onHome,
onOpenTab,
onOpenScenario,
onCopyScenario,
onDeleteScenario,
onSpec,
onParams,
onSearch,
}: {
wide: boolean;
pinned: boolean;
mobile?: boolean;
onTogglePin: () => void;
plans: PlanListItem[];
activeScenarioId: string | null;
planNav: { planId: string; tab: string } | null;
atHome: boolean;
showSpec: boolean;
showSystemParams: boolean;
planMenuOpen: boolean;
onTogglePlanMenu: () => void;
onNewPlan: () => void;
onDeletePlan: (planId: string) => void;
onHome: () => void;
onOpenTab: (planId: string, tab: "dashboard" | "scenarios" | "actuals" | "analyses" | "reports") => void;
onOpenScenario: (scenarioId: string) => void;
onCopyScenario: (meta: ScenarioMeta) => void;
onDeleteScenario: (s: ScenarioMeta) => void;
onSpec: () => void;
onParams: () => void;
onSearch: () => void;
}) {
// Der Szenario-Baum haengt unter «Szenarien» und braucht Text -- eingeklappt gibt es ihn
// nicht. Standard: zu, sonst waere die Leiste beim Ueberfahren sofort lang.
const [treeOpen, setTreeOpen] = useState(false);
// Welcher Plan ist im Zugriff? Erst der offene, sonst der zuletzt gewaehlte Bereich, sonst
// der einzige. Ohne diesen Bezug waeren die vier Knoepfe darunter gegenstandslos.
const active =
plans.find((p) => p.scenarios.some((sc) => sc.id === activeScenarioId)) ??
plans.find((p) => p.id === planNav?.planId) ??
(plans.length === 1 ? plans[0] : undefined);
const initials = active
? active.name.split(/\s+/).slice(0, 2).map((w) => w[0]).join("").toUpperCase()
: "";
return (
<aside
className={`${
mobile ? "relative h-full w-full" : "absolute inset-y-0 left-0 z-40"
} flex flex-col overflow-hidden border-r border-border bg-surface transition-[width] duration-200 ${
mobile ? "" : wide ? "w-60" : "w-14"
}`}
>
{/* Klebt am oberen Bildschirmrand: Die Leiste ist so hoch wie die SEITE, und mit einer
aufgeklappten Karte wird die sehr lang -- ohne sticky waeren die Knoepfe nach ein
wenig Scrollen ausserhalb des Blickfelds. */}
<div className="sticky top-0 flex max-h-screen flex-col gap-0.5 overflow-y-auto py-3">
<RailRow wide={wide} icon={<PiggyBank className="h-[18px] w-[18px] text-accent-fg" />} iconBox="bg-accent" label="FPT" />
<div className="my-1 h-px w-6 self-center bg-border" />
<RailRow wide={wide} active={atHome} label="Übersicht" onClick={onHome} icon={<LayoutDashboard className="h-[18px] w-[18px]" />} />
{/* Plan-Wähler. Eingeklappt die Initialen -- ohne diesen Bezug wüsste man nach dem
Umschalten nicht mehr, worin man gerade ist. */}
<div className="relative">
<RailRow
wide={wide}
label={active?.name ?? "Plan wählen"}
onClick={onTogglePlanMenu}
trailing={<ChevronDown className="h-3.5 w-3.5 text-faint" />}
icon={
<span
className={`flex h-[26px] w-[26px] items-center justify-center rounded-md border text-[10px] font-semibold ${
active ? "border-border text-fg" : "border-dashed border-border text-faint"
}`}
>
{initials}
</span>
}
/>
{planMenuOpen && (
<div
className={`ui-fade absolute z-50 w-56 rounded-xl border border-border bg-surface p-1 shadow-xl ${
wide ? "left-3 top-full mt-1" : "left-12 top-0"
}`}
>
{plans.length === 0 && <p className="px-2 py-1.5 text-xs text-faint">Noch keine Pläne.</p>}
{plans.map((p) => (
<div key={p.id} className="group flex items-center gap-1">
<button
type="button"
onClick={() => onOpenTab(p.id, "dashboard")}
className={`flex min-w-0 flex-1 items-center gap-2 rounded-lg px-2 py-1.5 text-left text-xs font-medium transition-colors ${
p.id === active?.id ? "bg-accent-soft text-accent-soft-fg" : "text-muted hover:bg-surface-2"
}`}
>
<FolderKanban className="h-3.5 w-3.5 shrink-0" />
<span className="min-w-0 truncate">{p.name}</span>
</button>
<button
type="button"
aria-label={`${p.name} löschen`}
onClick={() => onDeletePlan(p.id)}
className="rounded p-1 text-faint opacity-0 transition-opacity hover:bg-danger-soft hover:text-danger group-hover:opacity-100"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
))}
<button
type="button"
onClick={onNewPlan}
className="mt-1 flex w-full items-center gap-2 rounded-lg border-t border-border px-2 py-1.5 pt-2 text-left text-xs font-medium text-accent transition-colors hover:bg-accent-soft"
>
<Plus className="h-3.5 w-3.5 shrink-0" /> Neuer Plan
</button>
</div>
)}
</div>
{/* Die vier Bereiche des aktiven Plans. Mehr braucht es nicht. */}
<RailRow
wide={wide}
disabled={!active}
active={planNav?.tab === "scenarios" || !!activeScenarioId}
label="Szenarien"
onClick={() => active && onOpenTab(active.id, "scenarios")}
icon={<Layers className="h-[18px] w-[18px]" />}
trailing={
wide && active ? (
<span
role="button"
tabIndex={0}
aria-label={treeOpen ? "Szenarien einklappen" : "Szenarien aufklappen"}
onClick={(e) => {
e.stopPropagation();
setTreeOpen((o) => !o);
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.stopPropagation();
setTreeOpen((o) => !o);
}
}}
className="rounded p-0.5 text-faint hover:text-fg"
>
{treeOpen ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
</span>
) : undefined
}
/>
{wide && treeOpen && active && (
<div className="pl-3 pr-2">
<ScenarioTree
scenarios={active.scenarios}
selectedId={activeScenarioId}
onSelect={onOpenScenario}
onCopy={onCopyScenario}
onDelete={onDeleteScenario}
/>
</div>
)}
<RailRow
wide={wide}
disabled={!active}
active={planNav?.tab === "actuals"}
label="Effektive Werte"
onClick={() => active && onOpenTab(active.id, "actuals")}
icon={<CalendarClock className="h-[18px] w-[18px]" />}
/>
<RailRow
wide={wide}
disabled={!active}
active={planNav?.tab === "analyses"}
label="Analysen"
onClick={() => active && onOpenTab(active.id, "analyses")}
icon={<BarChart3 className="h-[18px] w-[18px]" />}
/>
<RailRow
wide={wide}
disabled={!active}
active={planNav?.tab === "reports"}
label="Berichte"
onClick={() => active && onOpenTab(active.id, "reports")}
icon={<FileSpreadsheet className="h-[18px] w-[18px]" />}
/>
<div className="my-1 h-px w-6 self-center bg-border" />
<RailRow wide={wide} active={showSpec} label="So rechnet FPT" onClick={onSpec} icon={<FileText className="h-[18px] w-[18px]" />} />
<RailRow
wide={wide}
active={showSystemParams}
label="Systemparameter"
onClick={onParams}
icon={<SlidersHorizontal className="h-[18px] w-[18px]" />}
/>
{/* Suchen und Anpinnen stehen DIREKT hier, nicht am unteren Rand. Die Leiste ist so
hoch wie die Seite -- und mit einer aufgeklappten Karte wird die sehr lang. Am Fuss
waeren die beiden dann ausserhalb des Blickfelds, obwohl sie zu den am haeufigsten
gebrauchten Knoepfen gehoeren. */}
<div className="my-1 h-px w-6 self-center bg-border" />
<RailRow
wide={wide}
label="Suchen und springen (Ctrl+K)"
shortLabel="Suchen"
onClick={onSearch}
icon={<Search className="h-[18px] w-[18px]" />}
/>
{!mobile && (
<RailRow
wide={wide}
active={pinned}
label={pinned ? "Menü loslösen" : "Menü anpinnen"}
onClick={onTogglePin}
icon={pinned ? <PinOff className="h-[18px] w-[18px]" /> : <Pin className="h-[18px] w-[18px]" />}
/>
)}
</div>
</aside>
);
}
// Eine Zeile der Leiste. Das Symbol sitzt in einer FESTEN Spalte von 3.5 rem -- dadurch bleibt
// es beim Ausfahren exakt an seinem Platz, und es kommt nur die Beschriftung dazu.
function RailRow({
wide,
icon,
iconBox,
label,
shortLabel,
onClick,
active,
disabled,
trailing,
}: {
wide: boolean;
icon: React.ReactNode;
iconBox?: string;
label: string;
shortLabel?: string;
onClick?: () => void;
active?: boolean;
disabled?: boolean;
trailing?: React.ReactNode;
}) {
const cls = `flex w-full items-center rounded-lg pr-2 text-left transition-colors ${
disabled ? "opacity-30" : ""
} ${active ? "bg-accent-soft text-accent-soft-fg" : onClick ? "text-muted hover:bg-surface-2 hover:text-fg" : "text-fg"}`;
const inner = (
<>
<span className="flex h-9 w-14 shrink-0 items-center justify-center">
<span className={iconBox ? `flex h-7 w-7 items-center justify-center rounded-lg ${iconBox}` : ""}>{icon}</span>
</span>
{wide && (
<>
<span className="min-w-0 flex-1 truncate text-sm font-medium">{shortLabel ?? label}</span>
{trailing}
</>
)}
</>
);
if (!onClick) return <div className={cls}>{inner}</div>;
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
aria-label={label}
title={wide ? undefined : label}
className={cls}
>
{inner}
</button>
);
}
-131
View File
@@ -1,131 +0,0 @@
"use client";
// Der FPT-Assistent -- das Herzstück der Anwendung.
//
// Er ersetzt die frühere Karte «Nächste Schritte». Der Unterschied ist nicht kosmetisch: Die
// alte Karte leitete AB, was zu tun wäre, und liess einen damit allein. Hier führt jeder
// Schritt sein eigenes Werkzeug mit sich -- und davor eine Seite, die erklärt, worum es
// überhaupt geht.
//
// Zwei Gestaltungsentscheide, die zusammengehören:
//
// 1. Der Haken ist MANUELL. Das Tool masst sich nicht an zu wissen, wann jemand mit einem
// Schritt fertig ist -- «genug geplant» ist eine Einschätzung, keine Messgrösse.
// 2. Daneben steht der ABGELEITETE Stand («0 Lebensphasen»). Ein abgehakter Schritt ohne
// Substanz fällt so auf, ohne dass das Tool den Haken verweigert.
//
// Erledigte Schritte rutschen nach unten und werden blass -- oben steht immer das, was als
// Nächstes ansteht.
import { useState } from "react";
import { Check, ChevronRight, Lock, Sparkles } from "lucide-react";
import { api } from "@/lib/api-client";
import { ASSISTANT_STEPS, stepBlockedReason, stepStatus, type AssistantProgress } from "@/lib/assistant";
import type { PlanInput } from "@/lib/types";
export function Assistant({
plan,
progress,
onOpenStep,
onChanged,
}: {
plan: PlanInput;
progress: AssistantProgress;
onOpenStep: (index: number) => void;
onChanged: () => void;
}) {
const [busy, setBusy] = useState<number | null>(null);
const done = progress.filter(Boolean).length;
const allDone = done === ASSISTANT_STEPS.length;
async function toggle(index: number, next: boolean) {
setBusy(index);
try {
await api.post(`/api/scenarios/${plan.id}/assistant`, { step: index, done: next });
onChanged();
} finally {
setBusy(null);
}
}
// Offene zuerst, erledigte darunter -- die Reihenfolge innerhalb der Gruppen bleibt.
const ordered = [...ASSISTANT_STEPS].sort((a, b) => {
const da = progress[a.index] ? 1 : 0;
const db = progress[b.index] ? 1 : 0;
return da - db || a.index - b.index;
});
return (
<div
data-tour="assistant"
className={`flex h-full flex-col rounded-xl border px-3 py-2.5 shadow-sm transition-colors ${
allDone ? "border-success bg-success-soft" : "border-attention bg-attention-soft"
}`}
>
<div className="mb-2 flex items-center gap-2">
<Sparkles className={`h-4 w-4 ${allDone ? "text-success" : "text-attention-fg"}`} />
<span className="text-xs font-semibold uppercase tracking-wide text-fg">FPT-Assistent</span>
<span className={`ml-auto text-[11px] font-semibold ${allDone ? "text-success" : "text-muted"}`}>
{done} / {ASSISTANT_STEPS.length}
</span>
</div>
<div className="flex min-h-0 flex-1 flex-col gap-1 overflow-auto">
{ordered.map((step) => {
const isDone = progress[step.index] === true;
const blocked = stepBlockedReason(plan, step.index);
const status = stepStatus(plan, step.index);
return (
<div
key={step.index}
className={`flex items-start gap-2 rounded-lg px-1.5 py-1 transition-colors ${
isDone ? "opacity-50" : "hover:bg-surface/60"
}`}
>
<button
type="button"
aria-label={isDone ? "Als offen markieren" : "Als erledigt markieren"}
disabled={busy === step.index}
onClick={() => toggle(step.index, !isDone)}
className={`mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded border transition-colors ${
isDone ? "border-success bg-success text-white" : "border-border bg-surface hover:border-accent"
}`}
>
{isDone && <Check className="h-3 w-3" />}
</button>
<button
type="button"
onClick={() => onOpenStep(step.index)}
className="min-w-0 flex-1 text-left"
>
<span
className={`flex items-center gap-1 text-xs font-semibold ${
isDone ? "text-muted line-through" : "text-fg"
}`}
>
<span className="truncate">
{step.index + 1}. {step.title}
</span>
{blocked && !isDone && <Lock className="h-3 w-3 shrink-0 text-faint" />}
<ChevronRight className="h-3 w-3 shrink-0 text-faint" />
</span>
{!isDone && (
<span className="mt-0.5 block truncate text-[11px] text-muted">
{status || step.short}
</span>
)}
</button>
</div>
);
})}
</div>
{allDone && (
<p className="mt-2 border-t border-success/30 pt-2 text-[11px] text-success">
Alle Schritte erledigt. Dein Plan steht verfeinere ihn jederzeit über die Matrix.
</p>
)}
</div>
);
}
-297
View File
@@ -1,297 +0,0 @@
"use client";
// Das Popup eines Assistenten-Schritts.
//
// Immer gleich aufgebaut: SEITE 1 erklärt, worum es geht -- welche Fragen man sich stellt,
// welche Möglichkeiten das Tool bietet, worauf es ankommt. Erst danach folgt das Werkzeug.
//
// Warum die Erklärseite nicht übersprungen wird: Finanzplanung scheitert selten an der
// Bedienung und fast immer daran, dass unklar ist, was der Schritt eigentlich bezweckt. Wer
// es schon weiss, klickt unten links auf «Selbst erledigen» und ist in zwei Sekunden draussen.
import { useState } from "react";
import { ArrowLeft, ArrowRight, Check, Lock } from "lucide-react";
import { Button, Modal } from "@/components/ui";
import { ASSISTANT_STEPS, stepBlockedReason } from "@/lib/assistant";
import type { PlanInput } from "@/lib/types";
export interface StepExplainer {
// Kurze Einordnung ganz oben.
lead: string;
// Die Fragen, die dieser Schritt beantwortet.
questions: string[];
// Was man wissen sollte, bevor man loslegt.
notes: { title: string; text: string }[];
}
export const STEP_EXPLAINERS: StepExplainer[] = [
{
lead:
"Bevor du planst, hältst du fest, was heute da ist. Alles Weitere baut darauf auf ohne Bestandsaufnahme rechnet das Tool ins Leere.",
questions: [
"Was verdienst du, was gibst du aus?",
"Welche Guthaben hast du Pensionskasse, Säule 3a, Wertschriften, Konto?",
"Besitzt du Wohneigentum? Wie hoch ist die Hypothek?",
"Hast du Schulden ausserhalb der Hypothek?",
],
notes: [
{
title: "Nur Bestände, noch keine Raten",
text: "Wie viel du jährlich sparst oder amortisierst, kommt später das hängt von deinen Lebensphasen ab. Hier zählt nur der Stand von heute.",
},
{
title: "Lieber grob als gar nicht",
text: "Ein geschätzter Wert ist besser als ein leeres Feld. Du kannst jede Zahl später überall im Tool korrigieren.",
},
{
title: "Getrennt nach Person",
text: "Pensionskasse und Säule 3a gehören immer einer Person. Wertschriften, Immobilien und Schulden können gemeinsam sein.",
},
],
},
{
lead:
"Jetzt legst du die Zeitachse fest: wie weit du planst und wann welche Rente oder welches Kapital zu fliessen beginnt. Das bestimmt, wo deine Lebensphasen zwingend beginnen müssen.",
questions: [
"Über wie viele Jahre soll die Planung laufen?",
"Wann hörst du auf zu arbeiten?",
"Wann beziehst du die AHV früher, mit 65, oder später?",
"Wann beziehst du Pensionskasse und Säule 3a?",
],
notes: [
{
title: "Noch keine Beträge",
text: "Hier geht es nur um Zeitpunkte. Ob Rente oder Kapital, und wohin das Geld fliesst, entscheidest du in Schritt 5.",
},
{
title: "Jeder Bezug erzeugt eine Phasengrenze",
text: "Beziehst du die AHV mit 63 und die Säule 3a mit 65, sind das zwei Fixpunkte. FPT rechnet phasenweise deshalb muss dort jeweils eine neue Lebensphase beginnen.",
},
{
title: "Früher kostet, später bringt",
text: "Ein AHV-Vorbezug kürzt die Rente lebenslang um 6,8 % pro Jahr, ein Aufschub erhöht sie um bis zu 31,5 %. Das Tool rechnet beides für dich aus.",
},
],
},
{
lead:
"Deine Planung zerfällt in Abschnitte mit gleichbleibenden Verhältnissen. Die Fixpunkte aus Schritt 2 stehen fest dazwischen teilst du frei ein.",
questions: [
"Gibt es Jahre, die sich deutlich voneinander unterscheiden?",
"Planst du einen Hauskauf, eine Weiterbildung, ein Sabbatical?",
"Wie fein willst du planen wenige grosse Phasen oder viele kleine?",
],
notes: [
{
title: "Eine Phase = gleichbleibende Verhältnisse",
text: "Innerhalb einer Lebensphase laufen Einkommen, Ausgaben und Sparraten gleichmässig weiter. Ändert sich etwas grundlegend, beginnt eine neue Phase.",
},
{
title: "Weniger ist oft mehr",
text: "Drei bis sechs Phasen reichen für die meisten Planungen. Jede zusätzliche Phase bedeutet mehr Entscheide, die du treffen musst.",
},
],
},
{
lead:
"Für die Jahre, in denen du noch arbeitest: Wohin fliesst das Geld, das übrig bleibt und was passiert an den Übergängen dazwischen?",
questions: [
"Wie viel sparst du pro Jahr, und wohin?",
"Zahlst du in die Säule 3a ein? Amortisierst du die Hypothek?",
"Verkaufst du unterwegs etwas, oder kommt Geld herein?",
],
notes: [
{
title: "Der Rest bleibt auf dem Konto",
text: "Was du nicht zuteilst, sammelt sich als Cash an. Das ist kein Fehler aber meist nicht die Absicht.",
},
{
title: "Übergänge sind Entscheidungspunkte",
text: "Zwischen zwei Lebensphasen entscheidest du, was mit Immobilien, Schulden und einmaligen Ereignissen geschieht.",
},
],
},
{
lead:
"Der wichtigste Schritt: Was passiert mit deinem Altersguthaben? Hier entscheidest du über Rente oder Kapital und darüber, wovon du in der Pension lebst.",
questions: [
"Rente oder Kapital oder eine Mischung?",
"Wenn Kapital: wohin damit? Hypothek amortisieren, anlegen, liegen lassen?",
"Reicht das Renteneinkommen für deine Ausgaben?",
],
notes: [
{
title: "Rente ist sicher, Kapital ist flexibel",
text: "Eine Rente fliesst lebenslang, unabhängig von den Märkten. Kapital bleibt verfügbar und vererbbar, trägt aber das Anlagerisiko und muss reichen.",
},
{
title: "Die Rentenlücke ist normal",
text: "Fast niemand deckt seine Ausgaben allein aus AHV und Pensionskasse. Entscheidend ist nicht, ob eine Lücke besteht, sondern wie lange dein Vermögen sie trägt.",
},
{
title: "Steuern beim Kapitalbezug",
text: "Ein Kapitalbezug wird einmalig und getrennt vom Einkommen besteuert. Alle Bezüge desselben Jahres werden zusammengezählt gestaffelt über mehrere Jahre zahlst du weniger.",
},
],
},
{
lead:
"Die Jahre nach der Pensionierung: Kein Lohn mehr, dafür Renten und das Vermögen, das du aufgebaut hast. Jetzt legst du fest, wovon du lebst.",
questions: [
"Wie viel entnimmst du jährlich aus deinem Vermögen?",
"Ändern sich deine Ausgaben im Alter Reisen zuerst, Pflege später?",
"Verkaufst du irgendwann die Immobilie?",
],
notes: [
{
title: "Verzehr statt Sparen",
text: "In diesen Phasen ist die Quote meist negativ: Du gibst mehr aus, als hereinkommt. Die Differenz kommt aus dem Vermögen das ist der Zweck des Sparens.",
},
{
title: "Achte auf das Ruinalter",
text: "Reicht das Vermögen nicht bis zum Planungshorizont, meldet FPT das. Dann helfen: später aufhören, weniger ausgeben, oder anders anlegen.",
},
],
},
{
lead:
"Dein Plan steht. Was jetzt noch möglich ist und wo du es findest.",
questions: [],
notes: [
{
title: "Werte je Lebensphase anpassen",
text: "Willst du für eine einzelne Phase eine andere Rendite oder Sparrate? Klicke die Zelle in der Matrix an. Leere Felder erben automatisch von der Vorphase so gilt eine Änderung ab dort weiter.",
},
{
title: "Pensionsplanung zentral ändern",
text: "Alle Entscheide zu AHV, Pensionskasse und Säule 3a stehen gebündelt unter «Pensionsplanung» in den Grundeinstellungen für jede Person einzeln.",
},
{
title: "Varianten vergleichen",
text: "Lege ein zweites Szenario an (Schnellaktionen), ändere dort etwa das Pensionsalter, und vergleiche beide. Deine Entscheide werden mitkopiert.",
},
{
title: "Unsicherheiten prüfen",
text: "Unter «Analysen» findest du die Einflussfaktoren (welcher Hebel wirkt am stärksten), die Live-Simulation (an Reglern drehen) und die Monte-Carlo-Simulation (wie robust ist der Plan).",
},
{
title: "Nachvollziehen, was gerechnet wird",
text: "«Rechenwege» in den Schnellaktionen zeigt zu jeder Zahl den vollständigen Weg inklusive der Vereinfachungen, die FPT bewusst macht.",
},
],
},
];
export function AssistantStepDialog({
plan,
stepIndex,
isDone,
onToggleDone,
onClose,
children,
}: {
plan: PlanInput;
stepIndex: number;
isDone: boolean;
onToggleDone: (done: boolean) => void;
onClose: () => void;
// Das Werkzeug des Schritts. Fehlt es, ist der Schritt reine Information.
children?: React.ReactNode;
}) {
const step = ASSISTANT_STEPS[stepIndex];
const explainer = STEP_EXPLAINERS[stepIndex];
const blocked = stepBlockedReason(plan, stepIndex);
const [showTool, setShowTool] = useState(false);
const canGuide = step.guided && !blocked && !!children;
return (
<Modal
title={`Schritt ${stepIndex + 1}: ${step.title}`}
subtitle={showTool ? undefined : step.lead}
onClose={onClose}
xwide
>
{showTool ? (
<div className="flex flex-col gap-4">
{children}
<div className="flex items-center justify-between border-t border-border pt-3">
<Button variant="ghost" onClick={() => setShowTool(false)}>
<ArrowLeft className="h-4 w-4" /> Zurück zur Erklärung
</Button>
<Button
onClick={() => {
onToggleDone(true);
onClose();
}}
>
<Check className="h-4 w-4" /> Schritt abschliessen
</Button>
</div>
</div>
) : (
<div className="flex flex-col gap-4">
<p className="text-sm text-fg">{explainer.lead}</p>
{explainer.questions.length > 0 && (
<div className="rounded-xl border border-border bg-surface-2 p-4">
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-faint">
Diese Fragen beantwortest du hier
</p>
<ul className="flex flex-col gap-1.5">
{explainer.questions.map((q) => (
<li key={q} className="flex items-start gap-2 text-sm text-fg">
<ArrowRight className="mt-0.5 h-3.5 w-3.5 shrink-0 text-accent" />
{q}
</li>
))}
</ul>
</div>
)}
<div className="grid gap-3 sm:grid-cols-2">
{explainer.notes.map((n) => (
<div key={n.title} className="rounded-xl border border-border p-3">
<p className="text-sm font-semibold text-fg">{n.title}</p>
<p className="mt-1 text-xs leading-relaxed text-muted">{n.text}</p>
</div>
))}
</div>
{blocked && (
<p className="flex items-start gap-2 rounded-lg border border-attention bg-attention-soft px-3 py-2 text-xs text-attention-soft-fg">
<Lock className="mt-0.5 h-3.5 w-3.5 shrink-0" />
{blocked}
</p>
)}
<div className="flex items-center justify-between border-t border-border pt-3">
<Button
variant="ghost"
onClick={() => {
onToggleDone(!isDone);
onClose();
}}
>
{isDone ? "Wieder als offen markieren" : "Selbst erledigen"}
</Button>
{canGuide ? (
<Button onClick={() => setShowTool(true)}>
Schritt für Schritt <ArrowRight className="h-4 w-4" />
</Button>
) : (
<Button
onClick={() => {
onToggleDone(true);
onClose();
}}
>
<Check className="h-4 w-4" /> Verstanden
</Button>
)}
</div>
</div>
)}
</Modal>
);
}
-917
View File
@@ -1,917 +0,0 @@
"use client";
// Die Werkzeuge der Assistenten-Schritte.
//
// Alle arbeiten ausschliesslich über die bestehenden Endpunkte -- der Assistent ist
// Orchestrierung, kein zweiter Datenpfad. Was er schreibt, hätte man auch von Hand über die
// Matrix schreiben können; er nimmt einem nur das Suchen ab.
import { useState } from "react";
import { AlertTriangle, ArrowLeft, ArrowRight, Check, Plus, Trash2 } from "lucide-react";
import { api } from "@/lib/api-client";
import { Button } from "@/components/ui";
import { MoneyField, NumberField, SelectField, TextField } from "@/components/FormField";
import { RetirementFields } from "@/components/RetirementFields";
import { CapitalDistributionDialog, RateDistributionDialog } from "@/components/DistributionDialogs";
import { formatChf } from "@/lib/format";
import { CATEGORY_LABELS, ownerLabel, num, type PhaseData } from "@/lib/elements";
import {
DEFAULT_PLANNING_HORIZON_YEARS,
MAX_PLANNING_HORIZON_YEARS,
MIN_PLANNING_HORIZON_YEARS,
PILLAR_3A_MAX_ANNUAL,
} from "@/lib/constants";
import { planSegments, defaultOpenDuration, type PlanSegment } from "@/lib/phaseplan";
import { fixpointPersonsOf, type PlanComputed } from "@/lib/calculations";
import type { PlanInput, PersonRole } from "@/lib/types";
// ============================================================================================
// Schritt 1: Bestandsaufnahme
// ============================================================================================
//
// Legt Elemente an und schreibt ihre STAMMDATEN -- ohne dass Lebensphasen existieren müssen.
// Das ist der Grund, warum es `baseData` gibt: Eine Bestandsaufnahme ist keine Aussage über
// eine Phase, sondern über den Stand von heute.
type Scope = "HOUSEHOLD" | "PERSON_A" | "PERSON_B";
interface Kind {
key: string;
category: string;
label: string;
hint: string;
personOnly?: boolean;
fields: (v: PhaseData, set: (p: Partial<PhaseData>) => void, plan: PlanInput) => React.ReactNode;
}
const KINDS: Kind[] = [
{
key: "income",
category: "INCOME",
label: "Erwerbseinkommen",
hint: "Dein Nettolohn also das, was auf dem Konto ankommt.",
personOnly: true,
fields: (v, set) => (
<>
<MoneyField
label="Nettolohn pro Jahr"
help="Was tatsächlich aufs Konto kommt, nach allen Abzügen. FPT rechnet für die AHV intern auf den Bruttolohn hoch."
value={num(v.amount)}
onChange={(x) => set({ amount: x })}
/>
<NumberField
label="Jährliche Lohnerhöhung (%)"
help="Nominal, also inklusive Teuerung. 1 % ist ein vorsichtiger Ausgangswert."
step={0.5}
value={num(v.teuerungsausgleich, 1)}
onChange={(x) => set({ teuerungsausgleich: x })}
/>
</>
),
},
{
key: "expense",
category: "EXPENSE",
label: "Lebenshaltung",
hint: "Alles, was du im Jahr ausgibst Wohnen, Essen, Versicherungen, Freizeit.",
fields: (v, set) => (
<MoneyField
label="Ausgaben pro Jahr"
help="In heutiger Kaufkraft. Die Teuerung rechnet FPT selbst dazu."
value={num(v.amount)}
onChange={(x) => set({ amount: x })}
/>
),
},
{
key: "pk",
category: "PENSION_FUND",
label: "Pensionskasse",
hint: "Dein Altersguthaben laut PK-Ausweis.",
personOnly: true,
fields: (v, set) => (
<>
<MoneyField
label="Altersguthaben heute"
help="Steht auf deinem Vorsorgeausweis."
value={num(v.currentValue)}
onChange={(x) => set({ currentValue: x })}
/>
<NumberField
label="Verzinsung (%/Jahr)"
help="Der BVG-Mindestzins liegt aktuell tief; viele Kassen verzinsen etwas darüber."
step={0.25}
value={num(v.expectedReturn, 1.25)}
onChange={(x) => set({ expectedReturn: x })}
/>
</>
),
},
{
key: "p3a",
category: "PILLAR_3A",
label: "Säule 3a",
hint: "Ein Konto oder Depot der gebundenen Vorsorge. Mehrere Konten legst du einzeln an.",
personOnly: true,
fields: (v, set) => (
<>
<MoneyField
label="Guthaben heute"
value={num(v.currentValue)}
onChange={(x) => set({ currentValue: x })}
/>
<NumberField
label="Erwartete Rendite (%/Jahr)"
help="Auf einem Konto praktisch 0, in einer Wertschriftenlösung deutlich mehr."
step={0.5}
value={num(v.expectedReturn, 2)}
onChange={(x) => set({ expectedReturn: x })}
/>
</>
),
},
{
key: "etf",
category: "OTHER_ASSET",
label: "Wertschriften und Sparguthaben",
hint: "Depot, ETF, Sparkonto alles freie Vermögen ausser dem Cash-Konto.",
fields: (v, set) => (
<>
<MoneyField label="Wert heute" value={num(v.startValue)} onChange={(x) => set({ startValue: x })} />
<NumberField
label="Erwartete Rendite (%/Jahr)"
help="Langfristiger Durchschnitt. Für ein gemischtes Portfolio sind 3 bis 5 % ein üblicher Ansatz."
step={0.5}
value={num(v.expectedReturn, 4)}
onChange={(x) => set({ expectedReturn: x })}
/>
</>
),
},
{
key: "re",
category: "REAL_ESTATE",
label: "Wohneigentum",
hint: "Haus oder Wohnung samt Hypothek.",
fields: (v, set) => (
<>
<MoneyField
label="Kaufpreis"
help="Der ursprüngliche Kaufpreis Grundlage der Grundstückgewinnsteuer bei einem späteren Verkauf."
value={num(v.purchasePrice)}
onChange={(x) => set({ purchasePrice: x })}
/>
<MoneyField
label="Hypothek heute"
value={num(v.mortgage)}
onChange={(x) => set({ mortgage: x })}
/>
<NumberField
label="Hypothekarzins (%)"
step={0.25}
value={num(v.interestRate, 2)}
onChange={(x) => set({ interestRate: x })}
/>
<NumberField
label="Wertsteigerung (%/Jahr)"
help="Auf den Wert der Liegenschaft, nicht auf dein Eigenkapital dort wirkt sie gehebelt."
step={0.25}
value={num(v.valueGrowth, 1)}
onChange={(x) => set({ valueGrowth: x })}
/>
<div className="sm:col-span-2">
<SelectField
label="Sind die Hypothekarzinsen in deinen Ausgaben schon enthalten?"
help="Wenn ja, rechnet FPT sie nicht noch einmal dazu sonst zählten sie doppelt."
value={v.interestHandling ?? "INCLUDED"}
onChange={(x: "INCLUDED" | "ADD") => set({ interestHandling: x })}
options={[
{ value: "INCLUDED", label: "Ja, sind in der Lebenshaltung enthalten" },
{ value: "ADD", label: "Nein, bitte zusätzlich rechnen" },
]}
/>
</div>
</>
),
},
{
key: "debt",
category: "OTHER_DEBT",
label: "Schulden",
hint: "Kredite ausserhalb der Hypothek.",
fields: (v, set) => (
<MoneyField label="Restschuld heute" value={num(v.startValue)} onChange={(x) => set({ startValue: x })} />
),
},
];
export function Step1Elements({ plan, onChanged }: { plan: PlanInput; onChanged: () => void }) {
const scopes: Scope[] =
plan.householdType === "COUPLE" ? ["HOUSEHOLD", "PERSON_A", "PERSON_B"] : ["PERSON_A"];
const [page, setPage] = useState(0);
const scope = scopes[page];
return (
<div className="flex flex-col gap-4">
{scopes.length > 1 && (
<div className="flex gap-1 rounded-lg border border-border bg-surface-2 p-1 text-xs">
{scopes.map((sc, i) => (
<button
key={sc}
type="button"
onClick={() => setPage(i)}
className={`flex-1 rounded-md px-2 py-1 font-medium ${
page === i ? "bg-accent text-accent-fg" : "text-muted hover:bg-surface"
}`}
>
{sc === "HOUSEHOLD" ? "Gemeinsam" : ownerLabel(plan.persons, sc)}
</button>
))}
</div>
)}
<ScopeElements plan={plan} scope={scope} onChanged={onChanged} />
{scopes.length > 1 && (
<div className="flex justify-between">
<Button variant="ghost" disabled={page === 0} onClick={() => setPage((p) => p - 1)}>
<ArrowLeft className="h-4 w-4" /> Zurück
</Button>
<Button
variant="secondary"
disabled={page === scopes.length - 1}
onClick={() => setPage((p) => p + 1)}
>
Weiter <ArrowRight className="h-4 w-4" />
</Button>
</div>
)}
</div>
);
}
function ScopeElements({
plan,
scope,
onChanged,
}: {
plan: PlanInput;
scope: Scope;
onChanged: () => void;
}) {
const [busy, setBusy] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const kinds = KINDS.filter((k) => (scope === "HOUSEHOLD" ? !k.personOnly : true));
const mine = plan.elements.filter((e) => (e.ownerRole ?? "HOUSEHOLD") === scope);
async function add(kind: Kind) {
setBusy(kind.key);
setError(null);
try {
const label = plan.householdType === "COUPLE" && scope !== "HOUSEHOLD" ? ` ${ownerLabel(plan.persons, scope)}` : "";
await api.post(`/api/scenarios/${plan.id}/elements`, {
category: kind.category,
name: `${kind.label}${label}`,
ownerRole: scope,
});
onChanged();
} catch (e) {
setError(e instanceof Error ? e.message : "Anlegen fehlgeschlagen.");
} finally {
setBusy(null);
}
}
return (
<div className="flex flex-col gap-3">
<div className="flex flex-wrap gap-2">
{kinds.map((k) => (
<button
key={k.key}
type="button"
disabled={busy === k.key}
onClick={() => add(k)}
className="flex items-center gap-1.5 rounded-lg border border-dashed border-border px-2.5 py-1.5 text-xs font-medium text-muted transition-colors hover:border-accent hover:text-accent-soft-fg"
>
<Plus className="h-3.5 w-3.5" /> {k.label}
</button>
))}
</div>
{mine.length === 0 ? (
<p className="rounded-lg border border-dashed border-border bg-surface-2 p-3 text-sm text-muted">
Noch nichts erfasst. Wähle oben, was zutrifft du kannst jederzeit weitere Positionen ergänzen.
</p>
) : (
<div className="flex flex-col gap-2">
{mine.map((el) => (
<BaseDataCard key={el.id} plan={plan} elementId={el.id} onChanged={onChanged} />
))}
</div>
)}
{error && <p className="text-sm text-danger">{error}</p>}
</div>
);
}
function BaseDataCard({
plan,
elementId,
onChanged,
}: {
plan: PlanInput;
elementId: string;
onChanged: () => void;
}) {
const el = plan.elements.find((x) => x.id === elementId)!;
const kind = KINDS.find((k) => k.category === el.category);
const [draft, setDraft] = useState<PhaseData>(el.baseData ?? {});
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
const [name, setName] = useState(el.name);
async function save() {
setSaving(true);
try {
if (name.trim() && name.trim() !== el.name) {
await api.patch(`/api/elements/${el.id}`, { name: name.trim() });
}
await api.put(`/api/elements/${el.id}/base`, draft);
setSaved(true);
onChanged();
} finally {
setSaving(false);
}
}
async function remove() {
await api.delete(`/api/elements/${el.id}`);
onChanged();
}
const set = (p: Partial<PhaseData>) => {
setDraft((d) => ({ ...d, ...p }));
setSaved(false);
};
return (
<div className="rounded-xl border border-border p-3">
<div className="mb-2 flex items-center gap-2">
<span className="text-xs font-semibold uppercase tracking-wide text-faint">
{CATEGORY_LABELS[el.category]}
</span>
<button
type="button"
onClick={remove}
className="ml-auto rounded p-1 text-faint hover:bg-danger-soft hover:text-danger"
aria-label="Entfernen"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div className="sm:col-span-2">
<TextField label="Bezeichnung" value={name} onChange={setName} />
</div>
{kind?.fields(draft, set, plan)}
{el.category === "PILLAR_3A" && (
<p className="sm:col-span-2 text-xs text-faint">
Hinweis: Der jährliche Höchstbetrag liegt bei {formatChf(PILLAR_3A_MAX_ANNUAL)}. Wie viel du einzahlst,
legst du in Schritt 4 fest.
</p>
)}
</div>
<div className="mt-3 flex items-center gap-2">
<Button size="sm" disabled={saving} onClick={save}>
{saving ? "…" : saved ? "Gespeichert" : "Speichern"}
</Button>
{saved && <Check className="h-4 w-4 text-success" />}
</div>
</div>
);
}
// ============================================================================================
// Schritt 2: Eckdaten und Pensionsplanung
// ============================================================================================
export function Step2Timeline({
plan,
computed,
onChanged,
}: {
plan: PlanInput;
computed: PlanComputed;
onChanged: () => void;
}) {
const [page, setPage] = useState(0);
const pages = ["HORIZON", ...plan.persons.map((p) => p.role)];
return (
<div className="flex flex-col gap-4">
<div className="flex gap-1 rounded-lg border border-border bg-surface-2 p-1 text-xs">
{pages.map((pg, i) => (
<button
key={pg}
type="button"
onClick={() => setPage(i)}
className={`flex-1 rounded-md px-2 py-1 font-medium ${
page === i ? "bg-accent text-accent-fg" : "text-muted hover:bg-surface"
}`}
>
{pg === "HORIZON" ? "Planungshorizont" : `Pensionierung ${ownerLabel(plan.persons, pg)}`}
</button>
))}
</div>
{page === 0 ? (
<HorizonStep plan={plan} onChanged={onChanged} />
) : (
<PersonTimingStep
plan={plan}
computed={computed}
role={pages[page] as PersonRole}
onChanged={onChanged}
/>
)}
<div className="flex justify-between">
<Button variant="ghost" disabled={page === 0} onClick={() => setPage((p) => p - 1)}>
<ArrowLeft className="h-4 w-4" /> Zurück
</Button>
<Button variant="secondary" disabled={page === pages.length - 1} onClick={() => setPage((p) => p + 1)}>
Weiter <ArrowRight className="h-4 w-4" />
</Button>
</div>
</div>
);
}
function HorizonStep({ plan, onChanged }: { plan: PlanInput; onChanged: () => void }) {
const [years, setYears] = useState(plan.planningHorizonYears ?? DEFAULT_PLANNING_HORIZON_YEARS);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
async function save() {
setBusy(true);
setError(null);
try {
await api.post(`/api/scenarios/${plan.id}/horizon`, { horizonYears: Math.round(years) });
onChanged();
} catch (e) {
setError(e instanceof Error ? e.message : "Nicht möglich.");
} finally {
setBusy(false);
}
}
return (
<div className="flex flex-col gap-3">
<p className="text-sm text-muted">
Über wie viele Jahre soll gerechnet werden? Der Horizont sollte deutlich über der Lebenserwartung liegen
eine zu kurze Planung sieht tragfähig aus, obwohl das Geld nur nicht lange genug reichen muss.
</p>
<div className="flex items-end gap-3">
<div className="w-40">
<NumberField
label="Planungshorizont (Jahre)"
value={years}
min={MIN_PLANNING_HORIZON_YEARS}
max={MAX_PLANNING_HORIZON_YEARS}
step={1}
onChange={setYears}
/>
</div>
<Button disabled={busy} onClick={save}>
{busy ? "…" : "Übernehmen"}
</Button>
</div>
<div className="rounded-xl border border-border bg-surface-2 p-3">
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-faint">Daraus ergibt sich</p>
<div className="flex flex-col gap-1 text-sm">
{plan.persons.map((p) => (
<div key={p.role} className="flex justify-between gap-3">
<span className="text-muted">{ownerLabel(plan.persons, p.role)} am Planende</span>
<span className="tabular-nums text-fg">{p.age + Math.round(years)} Jahre</span>
</div>
))}
{plan.startYear && (
<div className="flex justify-between gap-3 border-t border-border pt-1">
<span className="text-muted">Letztes Planjahr</span>
<span className="tabular-nums text-fg">{plan.startYear + Math.round(years) - 1}</span>
</div>
)}
</div>
</div>
{error && <p className="text-sm text-danger">{error}</p>}
</div>
);
}
function PersonTimingStep({
plan,
computed,
role,
onChanged,
}: {
plan: PlanInput;
computed: PlanComputed;
role: PersonRole;
onChanged: () => void;
}) {
const person = plan.persons.find((p) => p.role === role)!;
const own = plan.elements.filter(
(e) => e.ownerRole === role && ["AHV", "PENSION_FUND", "PILLAR_3A"].includes(e.category)
);
const summary = computed.retirement.perPerson.find((x) => x.role === role);
const [age, setAge] = useState(person.retirementAge);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [draft, setDraft] = useState<Record<string, Record<string, unknown>>>({});
async function saveAge() {
setBusy(true);
setError(null);
try {
await api.patch(`/api/scenarios/${plan.id}`, {
persons: plan.persons.map((p) => ({
role: p.role,
retirementAge: p.role === role ? Math.round(age) : p.retirementAge,
})),
});
onChanged();
} catch (e) {
setError(e instanceof Error ? e.message : "Nicht möglich.");
} finally {
setBusy(false);
}
}
async function saveDecisions() {
setBusy(true);
try {
for (const [id, patch] of Object.entries(draft)) {
const el = plan.elements.find((x) => x.id === id);
await api.put(`/api/elements/${id}/retirement`, { ...(el?.retirementDecision ?? {}), ...patch });
}
setDraft({});
onChanged();
} finally {
setBusy(false);
}
}
return (
<div className="flex flex-col gap-4">
<div className="rounded-xl border border-border p-3">
<div className="flex items-end gap-3">
<div className="w-44">
<NumberField
label="Erwerbstätigkeit endet mit"
help="Ab diesem Alter fliesst kein Lohn mehr. Das ist nicht zwingend dasselbe Alter, in dem deine Renten beginnen."
value={age}
min={40}
max={75}
step={1}
onChange={setAge}
/>
</div>
<Button disabled={busy || Math.round(age) === person.retirementAge} onClick={saveAge}>
Übernehmen
</Button>
</div>
{plan.startYear && (
<p className="mt-1 text-xs text-faint">
Das ist das Jahr {plan.startYear + (Math.round(age) - person.age)}.
</p>
)}
</div>
{own.length === 0 ? (
<p className="rounded-lg border border-dashed border-border bg-surface-2 p-3 text-sm text-muted">
Für {ownerLabel(plan.persons, role)} sind noch keine Vorsorge-Elemente erfasst. Gehe zurück zu Schritt 1
und lege AHV, Pensionskasse und Säule 3a an dann kannst du hier die Bezugszeitpunkte festlegen.
</p>
) : (
<>
<p className="text-sm text-muted">
Wann soll welches Guthaben zu fliessen beginnen? Jeder dieser Zeitpunkte erzeugt in Schritt 3 eine
Phasengrenze FPT rechnet phasenweise, deshalb muss ein Bezug immer am Anfang einer Lebensphase stehen.
</p>
<div className="flex flex-col gap-2">
{own.map((el) => (
<RetirementFields
key={el.id}
plan={plan}
el={el}
defaultOpen
patch={(id, patch) =>
setDraft((d) => ({ ...d, [id]: { ...(d[id] ?? {}), ...patch } }))
}
summary={summary}
/>
))}
</div>
{Object.keys(draft).length > 0 && (
<Button disabled={busy} onClick={saveDecisions}>
{busy ? "…" : "Bezugszeitpunkte speichern"}
</Button>
)}
</>
)}
{error && <p className="text-sm text-danger">{error}</p>}
</div>
);
}
// ============================================================================================
// Schritt 3: Lebensphasen
// ============================================================================================
export function Step3Phases({ plan, onChanged }: { plan: PlanInput; onChanged: () => void }) {
const segments = planSegments(fixpointPersonsOf(plan));
const horizon = plan.planningHorizonYears ?? DEFAULT_PLANNING_HORIZON_YEARS;
const [draft, setDraft] = useState<{ name: string; years: number }[][]>(() =>
segments.map((seg, i) => [
{
name: defaultName(seg, i),
years: seg.fixedYears ?? defaultOpenDuration(plan.persons, seg.fromYear, horizon),
},
])
);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const sums = draft.map((phs) => phs.reduce((s, p) => s + Math.max(0, p.years), 0));
const valid = segments.every((seg, i) => seg.fixedYears === null || sums[i] === seg.fixedYears);
async function create() {
setBusy(true);
setError(null);
try {
for (const phs of draft) {
for (const ph of phs) {
await api.post(`/api/scenarios/${plan.id}/phases`, {
name: ph.name.trim() || "Lebensphase",
durationYears: Math.max(1, Math.round(ph.years)),
});
}
}
onChanged();
} catch (e) {
setError(e instanceof Error ? e.message : "Anlegen fehlgeschlagen.");
} finally {
setBusy(false);
}
}
if (plan.phases.length > 0) {
return (
<p className="rounded-lg border border-dashed border-border bg-surface-2 p-3 text-sm text-muted">
Dieses Szenario hat bereits {plan.phases.length} Lebensphasen. Weitere legst du direkt in der Matrix an
(«+ Lebensphase» in der Ecke oben links) dort siehst du auch gleich, was sich dadurch ändert.
</p>
);
}
return (
<div className="flex flex-col gap-4">
<p className="text-sm text-muted">
Die dick umrandeten Abschnitte sind gesetzt sie ergeben sich aus deinen Bezugszeitpunkten. Innerhalb
eines Abschnitts kannst du beliebig fein unterteilen, die Summe muss aber genau aufgehen.
</p>
{segments.map((seg, i) => (
<div key={i} className="rounded-xl border border-border p-3">
<div className="mb-2 flex flex-wrap items-baseline gap-2">
<span className="text-sm font-semibold text-fg">
{seg.type === "ERWERB" ? "Erwerbsphase" : seg.type === "MIXED" ? "Übergangszeit" : "Ruhestand"}
</span>
<span className="text-xs text-muted">
ab Jahr {seg.fromYear + 1}
{seg.fixedYears !== null ? ` · ${seg.fixedYears} Jahre fest` : " · Dauer frei"}
</span>
{seg.endedBy.length > 0 && (
<span className="text-xs text-faint">endet mit: {seg.endedBy.map((f) => f.label).join(", ")}</span>
)}
{seg.fixedYears !== null && (
<span
className={`ml-auto text-xs font-semibold ${
sums[i] === seg.fixedYears ? "text-success" : "text-danger"
}`}
>
{sums[i]} / {seg.fixedYears} Jahre
</span>
)}
</div>
<div className="flex flex-col gap-2">
{draft[i].map((ph, j) => (
<div key={j} className="flex items-end gap-2">
<div className="flex-1">
<TextField
label="Bezeichnung"
value={ph.name}
onChange={(v) =>
setDraft((d) => d.map((x, xi) => (xi === i ? x.map((y, yj) => (yj === j ? { ...y, name: v } : y)) : x)))
}
/>
</div>
<div className="w-28">
<NumberField
label="Jahre"
value={ph.years}
min={1}
step={1}
onChange={(v) =>
setDraft((d) => d.map((x, xi) => (xi === i ? x.map((y, yj) => (yj === j ? { ...y, years: Math.round(v) } : y)) : x)))
}
/>
</div>
{draft[i].length > 1 && (
<button
type="button"
onClick={() => setDraft((d) => d.map((x, xi) => (xi === i ? x.filter((_, yj) => yj !== j) : x)))}
className="mb-1 rounded p-1.5 text-faint hover:bg-danger-soft hover:text-danger"
aria-label="Phase entfernen"
>
<Trash2 className="h-4 w-4" />
</button>
)}
</div>
))}
<button
type="button"
onClick={() =>
setDraft((d) =>
d.map((x, xi) => (xi === i ? [...x, { name: `Abschnitt ${x.length + 1}`, years: 1 }] : x))
)
}
className="flex w-fit items-center gap-1 rounded-lg border border-dashed border-border px-2 py-1 text-xs text-muted hover:border-accent"
>
<Plus className="h-3.5 w-3.5" /> Weiter unterteilen
</button>
</div>
</div>
))}
{!valid && (
<p className="flex items-start gap-2 text-xs text-danger">
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
In mindestens einem festen Abschnitt geht die Summe der Jahre nicht auf.
</p>
)}
{error && <p className="text-sm text-danger">{error}</p>}
<Button disabled={busy || !valid} onClick={create}>
{busy ? "…" : "Lebensphasen anlegen"}
</Button>
</div>
);
}
function defaultName(seg: PlanSegment, i: number): string {
if (seg.type === "ERWERB") return i === 0 ? "Erwerbsjahre" : "Erwerbsjahre II";
if (seg.type === "MIXED") return "Übergangszeit";
return "Ruhestand";
}
// ============================================================================================
// Schritte 4 bis 6: durch die Phasen und Übergänge führen
// ============================================================================================
//
// Alle drei arbeiten gleich -- sie unterscheiden sich nur im Ausschnitt der Zeitachse. Statt
// drei fast gleicher Komponenten gibt es eine mit einem Filter.
export type WalkScope = "WORK" | "RETIRE" | "PENSION";
export function StepWalkthrough({
plan,
computed,
scope,
onChanged,
}: {
plan: PlanInput;
computed: PlanComputed;
scope: WalkScope;
onChanged: () => void;
}) {
const [pos, setPos] = useState(0);
const [open, setOpen] = useState<"capital" | "rates" | null>(null);
// Die Stationen: Phasen und die Übergänge dazwischen, gefiltert auf den Ausschnitt.
const stations = computed.phases.flatMap((ph, i) => {
const next = computed.phases[i + 1];
const kind = phaseKind(ph.type, next?.type);
const out: { phaseId: string; label: string; type: "phase" | "transition"; toName?: string }[] = [];
if (inScope(scope, ph.type === "ERWERB" ? "WORK" : ph.type === "MIXED" ? "RETIRE" : "PENSION"))
out.push({ phaseId: ph.id, label: ph.name, type: "phase" });
if (next && inScope(scope, kind))
out.push({ phaseId: ph.id, label: `Übergang nach ${next.name}`, type: "transition", toName: next.name });
return out;
});
if (stations.length === 0) {
return (
<p className="rounded-lg border border-dashed border-border bg-surface-2 p-3 text-sm text-muted">
In diesem Abschnitt deiner Planung gibt es nichts zu entscheiden.
</p>
);
}
const st = stations[Math.min(pos, stations.length - 1)];
const phase = computed.phases.find((p) => p.id === st.phaseId)!;
return (
<div className="flex flex-col gap-4">
<div className="flex items-center gap-2">
<span className="text-xs text-muted">
Station {pos + 1} von {stations.length}
</span>
<div className="flex flex-1 gap-1">
{stations.map((_, i) => (
<div key={i} className={`h-1 flex-1 rounded-full ${i <= pos ? "bg-accent" : "bg-surface-2"}`} />
))}
</div>
</div>
<div className="rounded-xl border border-border p-4">
<h4 className="text-base font-semibold text-fg">{st.label}</h4>
<p className="mt-1 text-xs text-muted">
{st.type === "phase"
? `${phase.durationYears} Jahre · ${phase.isConsumption ? "Verzehrquote" : "Sparquote"} ${formatChf(phase.quotaStart)} im ersten Jahr`
: "Was geschieht beim Wechsel in die nächste Lebensphase?"}
</p>
<div className="mt-3 flex flex-wrap gap-2">
{st.type === "phase" ? (
<>
<Button size="sm" onClick={() => setOpen("rates")}>
{phase.isConsumption ? "Bezüge verteilen" : "Sparquote verteilen"}
</Button>
<Button size="sm" variant="secondary" onClick={() => setOpen("capital")}>
Kapital verteilen
</Button>
</>
) : (
<p className="text-sm text-muted">
Die Entscheide dieses Übergangs triffst du in der Matrix klicke dort auf die Übergangsspalte
nach «{phase.name}».
</p>
)}
</div>
</div>
<div className="flex justify-between">
<Button variant="ghost" disabled={pos === 0} onClick={() => setPos((p) => p - 1)}>
<ArrowLeft className="h-4 w-4" /> Vorherige
</Button>
<Button
variant="secondary"
disabled={pos >= stations.length - 1}
onClick={() => setPos((p) => p + 1)}
>
Nächste <ArrowRight className="h-4 w-4" />
</Button>
</div>
{open === "capital" && (
<CapitalDistributionDialog
plan={plan}
computed={computed}
phaseId={st.phaseId}
onClose={() => setOpen(null)}
onSaved={() => {
setOpen(null);
onChanged();
}}
/>
)}
{open === "rates" && (
<RateDistributionDialog
plan={plan}
computed={computed}
phaseId={st.phaseId}
onClose={() => setOpen(null)}
onSaved={() => {
setOpen(null);
onChanged();
}}
/>
)}
</div>
);
}
// Zu welchem Abschnitt gehört ein Übergang? Massgeblich ist, WOHIN er führt.
function phaseKind(from: string, to?: string): WalkScope {
if (!to) return from === "ERWERB" ? "WORK" : from === "MIXED" ? "RETIRE" : "PENSION";
if (to === "ERWERB") return "WORK";
if (to === "MIXED") return "RETIRE";
return from === "PENSION" ? "PENSION" : "RETIRE";
}
function inScope(scope: WalkScope, kind: WalkScope): boolean {
return scope === kind;
}
+67 -8
View File
@@ -21,6 +21,7 @@ import { CarryWarning } from "@/components/ElementDetail";
import { api } from "@/lib/api-client"; import { api } from "@/lib/api-client";
import { formatChf } from "@/lib/format"; import { formatChf } from "@/lib/format";
import { computePlan } from "@/lib/calculations"; import { computePlan } from "@/lib/calculations";
import { PILLAR_3A_MAX_ANNUAL, PILLAR_3A_MAX_SELF_EMPLOYED } from "@/lib/constants";
import { import {
CATEGORY_LABELS, CATEGORY_LABELS,
inheritedPhaseValues, inheritedPhaseValues,
@@ -368,8 +369,14 @@ interface RateTarget {
owner: string; owner: string;
field: "annualContribution" | "annualWithdrawal" | "amortization" | "annualRepayment"; field: "annualContribution" | "annualWithdrawal" | "amortization" | "annualRepayment";
label: string; label: string;
direction: "out" | "in"; // "out"/"in" = fliesst aus der bzw. in die Quote. "gross" = weder noch: der PK-Beitrag
// stammt aus dem BRUTTOLOHN und berührt das Cash-Konto nicht. Er steht seit 0.40 trotzdem
// hier, weil er sonst nirgends mehr erfassbar wäre -- aber in einem eigenen Block, damit
// niemand ihn für einen Teil der Quote hält.
direction: "out" | "in" | "gross";
hint: string; hint: string;
// Obergrenze (Säule 3a). Ohne sie liesse sich ein steuerlich unzulässiger Betrag erfassen.
max?: number;
} }
export function RateDistributionDialog({ export function RateDistributionDialog({
@@ -387,6 +394,13 @@ export function RateDistributionDialog({
}) { }) {
const phase = computed.phases.find((p) => p.id === phaseId)!; const phase = computed.phases.find((p) => p.id === phaseId)!;
// «Grosse Säule 3a» je Element: bestimmt die Obergrenze der Einzahlung. Der Schalter steht
// seit 0.40 hier statt in der Phasenzelle -- getrennt von dem Feld, dessen Grenze er
// festlegt, war er eine Einstellung ohne sichtbare Wirkung.
const [flags, setFlags] = useState<Record<string, boolean>>(() =>
Object.fromEntries(plan.elements.map((e) => [e.id, !!e.phaseValues[phaseId]?.selfEmployed3a]))
);
const targets = useMemo<RateTarget[]>(() => { const targets = useMemo<RateTarget[]>(() => {
const out: RateTarget[] = []; const out: RateTarget[] = [];
for (const e of plan.elements) { for (const e of plan.elements) {
@@ -396,13 +410,23 @@ export function RateDistributionDialog({
// Die Zeile hier trotzdem anzubieten hiess, den Entscheid aus dem Übergang zu // Die Zeile hier trotzdem anzubieten hiess, den Entscheid aus dem Übergang zu
// widersprechen -- inklusive eines geerbten Betrags, der wirkungslos weiterläuft. // widersprechen -- inklusive eines geerbten Betrags, der wirkungslos weiterläuft.
if (ce.acceptsCapital === false) continue; if (ce.acceptsCapital === false) continue;
// Die PK ist bewusst NICHT dabei: Ihr Beitrag stammt aus dem Bruttolohn und belastet if (e.category === "PENSION_FUND") {
// das Cash-Konto nicht -- er lässt sich also gar nicht aus der Quote verteilen. // Der PK-Beitrag ist NICHT Teil der Quote (er stammt aus dem Bruttolohn). Er steht in
if (e.category === "PILLAR_3A") { // einem eigenen Block -- aber er steht hier, weil dies seit 0.40 der einzige Ort ist,
// an dem jährliche Beträge entstehen. Ohne ihn wäre er unerreichbar und fiele still
// auf 0, obwohl er das Endvermögen um sechsstellige Beträge bewegt. Eine bereits
// bezogene PK ist oben über `acceptsCapital` schon ausgeschieden.
out.push({
elementId: e.id, name: e.name, category: e.category, owner: ownerLabel(plan.persons, e.ownerRole), field: "annualContribution",
label: "Jährliche Einzahlung", direction: "gross",
hint: "Arbeitnehmer- und Arbeitgeberbeiträge aus dem Bruttolohn. Sie sind im Nettolohn bereits abgezogen und schmälern die Sparquote deshalb NICHT.",
});
} else if (e.category === "PILLAR_3A") {
out.push({ out.push({
elementId: e.id, name: e.name, category: e.category, owner: ownerLabel(plan.persons, e.ownerRole), field: "annualContribution", elementId: e.id, name: e.name, category: e.category, owner: ownerLabel(plan.persons, e.ownerRole), field: "annualContribution",
label: "Jährliche Einzahlung", direction: "out", label: "Jährliche Einzahlung", direction: "out",
hint: "Fliesst jährlich vom Cash in die Säule 3a.", hint: "Fliesst jährlich vom Cash in die Säule 3a.",
max: flags[e.id] ? PILLAR_3A_MAX_SELF_EMPLOYED : PILLAR_3A_MAX_ANNUAL,
}); });
} else if (e.category === "OTHER_ASSET") { } else if (e.category === "OTHER_ASSET") {
out.push({ out.push({
@@ -431,7 +455,7 @@ export function RateDistributionDialog({
} }
return out; return out;
// eslint-disable-next-line react-hooks/exhaustive-deps -- phase folgt phaseId // eslint-disable-next-line react-hooks/exhaustive-deps -- phase folgt phaseId
}, [plan.elements, phaseId]); }, [plan.elements, phaseId, flags]);
const key = (t: RateTarget) => `${t.elementId}:${t.field}`; const key = (t: RateTarget) => `${t.elementId}:${t.field}`;
@@ -479,11 +503,15 @@ export function RateDistributionDialog({
const e = plan.elements.find((x) => x.id === t.elementId)!; const e = plan.elements.find((x) => x.id === t.elementId)!;
const merged = byElement.get(t.elementId) ?? { ...(e.phaseValues[phaseId] ?? {}) }; const merged = byElement.get(t.elementId) ?? { ...(e.phaseValues[phaseId] ?? {}) };
(merged as Record<string, number>)[t.field] = draft[key(t)] ?? 0; (merged as Record<string, number>)[t.field] = draft[key(t)] ?? 0;
if (t.category === "PILLAR_3A") merged.selfEmployed3a = !!flags[t.elementId];
byElement.set(t.elementId, merged); byElement.set(t.elementId, merged);
} }
for (const [elementId, data] of byElement) { for (const [elementId, data] of byElement) {
await api.put(`/api/elements/${elementId}/phase/${phaseId}`, data); await api.put(`/api/elements/${elementId}/phase/${phaseId}`, data);
} }
// Die Verteilung zählt eigens als offener Punkt: Man kann jede Zelle angeschaut und die
// Quote trotzdem nie verteilt haben -- dann bliebe alles still auf dem Cash-Konto.
await api.put(`/api/phases/${phaseId}`, { ratesConfirmed: true });
onSaved(); onSaved();
} catch (e) { } catch (e) {
setError(e instanceof Error ? e.message : "Speichern fehlgeschlagen."); setError(e instanceof Error ? e.message : "Speichern fehlgeschlagen.");
@@ -532,9 +560,9 @@ export function RateDistributionDialog({
</p> </p>
) : ( ) : (
// Bewusst nach RICHTUNG gruppiert: erst alles, was das Cash verlässt, dann alles, was // Bewusst nach RICHTUNG gruppiert: erst alles, was das Cash verlässt, dann alles, was
// hineinfliesst. Gemischt war die Liste schwer zu lesen. // hineinfliesst, zuletzt der Bruttolohn-Block. Gemischt war die Liste schwer zu lesen.
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
{(["out", "in"] as const).map((dir) => { {(["out", "in", "gross"] as const).map((dir) => {
const rows = targets.filter((t) => t.direction === dir); const rows = targets.filter((t) => t.direction === dir);
if (rows.length === 0) return null; if (rows.length === 0) return null;
return ( return (
@@ -544,8 +572,19 @@ export function RateDistributionDialog({
dir === "in" ? "text-success" : "text-muted" dir === "in" ? "text-success" : "text-muted"
}`} }`}
> >
{dir === "in" ? "Ins Cash (Entnahmen)" : "Vom Cash (Einzahlungen und Tilgungen)"} {dir === "in"
? "Ins Cash (Entnahmen)"
: dir === "gross"
? "Aus dem Bruttolohn (ausserhalb der Quote)"
: "Vom Cash (Einzahlungen und Tilgungen)"}
</div> </div>
{dir === "gross" && (
<p className="rounded-lg bg-surface-2 px-3 py-2 text-[11px] leading-relaxed text-muted">
Diese Beiträge sind im Nettolohn <strong className="text-fg">bereits abgezogen</strong>. Sie
schmälern die Sparquote nicht und tauchen in der Bilanz unten deshalb nicht auf sie stehen
hier, weil sie das Vorsorgekapital gleichwohl jährlich erhöhen.
</p>
)}
{rows.map((t) => ( {rows.map((t) => (
<div key={key(t)} className="rounded-xl border border-border p-3"> <div key={key(t)} className="rounded-xl border border-border p-3">
<div className="mb-2 flex flex-wrap items-center gap-2 text-sm"> <div className="mb-2 flex flex-wrap items-center gap-2 text-sm">
@@ -556,9 +595,29 @@ export function RateDistributionDialog({
<span className="rounded bg-surface-2 px-1.5 py-0.5 text-[10px] text-muted">{t.owner}</span> <span className="rounded bg-surface-2 px-1.5 py-0.5 text-[10px] text-muted">{t.owner}</span>
<InfoBubble text={t.hint} /> <InfoBubble text={t.hint} />
</div> </div>
{/* Das Häkchen steht ÜBER dem Betrag, weil es dessen Obergrenze bestimmt. */}
{t.category === "PILLAR_3A" && (
<label className="mb-2 flex cursor-pointer items-center gap-2 text-xs text-muted">
<input
type="checkbox"
checked={!!flags[t.elementId]}
onChange={(e) => setFlags((prev) => ({ ...prev, [t.elementId]: e.target.checked }))}
/>
Selbstständig ohne Pensionskasse («grosse Säule 3a»)
<InfoBubble
text={`Selbstständigerwerbende ohne PK dürfen bis 20 % des Nettoerwerbseinkommens einzahlen, gedeckelt auf ca. ${PILLAR_3A_MAX_SELF_EMPLOYED.toLocaleString("de-CH")} CHF statt der ${PILLAR_3A_MAX_ANNUAL.toLocaleString("de-CH")} CHF mit PK.`}
/>
</label>
)}
<MoneyField <MoneyField
label={`${t.label} (CHF/Jahr)`} label={`${t.label} (CHF/Jahr)`}
help={
t.max !== undefined
? `Höchstens CHF ${t.max.toLocaleString("de-CH")} (2026) und höchstens die Sparquote.`
: undefined
}
value={draft[key(t)] ?? 0} value={draft[key(t)] ?? 0}
max={t.max}
onChange={(v) => setDraft((prev) => ({ ...prev, [key(t)]: v }))} onChange={(v) => setDraft((prev) => ({ ...prev, [key(t)]: v }))}
/> />
</div> </div>
+227 -83
View File
@@ -4,7 +4,6 @@ import { useState } from "react";
import { Trash2 } from "lucide-react"; import { Trash2 } from "lucide-react";
import { AlertTriangle } from "lucide-react"; import { AlertTriangle } from "lucide-react";
import { FieldLabel, InheritableField, MoneyField, NumberField, SelectField, TextField } from "@/components/FormField"; import { FieldLabel, InheritableField, MoneyField, NumberField, SelectField, TextField } from "@/components/FormField";
import { InfoBubble } from "@/components/InfoBubble";
import { formatChf } from "@/lib/format"; import { formatChf } from "@/lib/format";
import { api } from "@/lib/api-client"; import { api } from "@/lib/api-client";
// Die reinen Uebergangs-Regeln liegen in lib/transitions.ts (auch serverseitig nutzbar) und // Die reinen Uebergangs-Regeln liegen in lib/transitions.ts (auch serverseitig nutzbar) und
@@ -73,6 +72,25 @@ export interface CellContext {
// Betrag, der aus einem Kapitalbezug in DIESES Element umgeleitet wurde (Punkt C). // Betrag, der aus einem Kapitalbezug in DIESES Element umgeleitet wurde (Punkt C).
capitalFromTransfer: number; capitalFromTransfer: number;
capitalFromTransferSources: { name: string; amount: number }[]; capitalFromTransferSources: { name: string; amount: number }[];
// Stammdaten des Elements. In der ERSTEN Phase ist der Startwert genau das -- er wird dort
// nur noch ANGEZEIGT, denn sonst entstünde derselbe Betrag an zwei Orten (Kap. 3.14.3).
baseData: PhaseData;
// Der TATSÄCHLICH gerechnete Stand dieser Zelle. In einer Phase, in der die Person schon
// pensioniert ist, stand hier bis 0.40 eine Regelbeschreibung im Futur («wird berechnet»,
// «wird bezogen»), obwohl der Bezug längst geschehen war. Was die Zelle braucht, ist der
// Zustand, nicht die Regel.
computedSummary: string;
computedNote: string | null;
computedValue: number;
// Einmalige Kapitalverwendungen, die am Übergang IN diese Phase beschlossen wurden. Sie
// liegen technisch an der Vorphase, gehören aber sichtbar hierher -- zusammen mit der
// Zusatzeinlage, die aus demselben Topf stammt.
oneOffAmortization: number;
oneOffRepayment: number;
// Absprünge. Ein Wert entsteht an genau EINEM Ort; angezeigt wird er überall, wo er wirkt.
onEditBase?: () => void;
onDistributeRates?: () => void;
onDistributeCapital?: () => void;
} }
interface Props { interface Props {
@@ -86,6 +104,9 @@ interface Props {
phaseDataByPhase?: Record<string, PhaseData>; phaseDataByPhase?: Record<string, PhaseData>;
onSaved: () => void; onSaved: () => void;
onDeleteElement: () => void; onDeleteElement: () => void;
// In der Kartenansicht steht Name und Kategorie schon im Zeilenkopf -- dann waere die
// eigene Ueberschrift eine Wiederholung, und der Loeschen-Knopf sitzt dort im Menue.
compact?: boolean;
} }
// Warnhinweis beim Bearbeiten einer Phase, der noch Phasen folgen. Seit V3 werden Werte live // Warnhinweis beim Bearbeiten einer Phase, der noch Phasen folgen. Seit V3 werden Werte live
@@ -118,6 +139,51 @@ function DerivedField({ label, value, help }: { label: string; value: number; he
); );
} }
// Read-only Anzeige mit Absprung an den Ort, an dem der Wert ENTSTEHT.
//
// Seit 0.40 hat jede Zahl genau einen Entstehungsort: Bestände in den Stammdaten, jährliche
// Raten im Verteil-Dialog der Quote, einmalige Kapitalverwendungen im Kapital-Dialog. In der
// Zelle stehen sie trotzdem -- nur eben unveränderlich. Zwei Eingabefelder für dieselbe Zahl
// sind der Fehler, den dieser Umbau beseitigt: Man tippt in das eine und wundert sich, warum
// das andere gewinnt.
function LinkedField({
label,
value,
help,
actionLabel,
onAction,
suffix,
}: {
label: string;
value: number;
help?: string;
actionLabel: string;
onAction?: () => void;
// Zusatz hinter dem Betrag, z. B. "/Jahr".
suffix?: string;
}) {
return (
<div>
<FieldLabel label={label} help={help} />
<div className="flex w-full items-center justify-between gap-2 rounded-lg border border-dashed border-border bg-surface-2 px-2.5 py-1.5 text-sm text-muted">
<span className="tabular-nums">
{formatChf(value)}
{suffix ? <span className="text-faint"> {suffix}</span> : null}
</span>
{onAction && (
<button
type="button"
onClick={onAction}
className="shrink-0 whitespace-nowrap rounded border border-accent px-1.5 py-0.5 text-[11px] font-semibold text-accent transition-colors hover:bg-accent hover:text-accent-fg"
>
{actionLabel}
</button>
)}
</div>
</div>
);
}
// PK/3a-Bezugs-Entscheid im normalen Übergang (Vorbezug): Kein Bezug / Bezug (+ Betrag + // PK/3a-Bezugs-Entscheid im normalen Übergang (Vorbezug): Kein Bezug / Bezug (+ Betrag +
// Kapitalbezugssteuer). Der Bezugsbetrag wird brutto dem Kapital entnommen; ins Cash fliesst // Kapitalbezugssteuer). Der Bezugsbetrag wird brutto dem Kapital entnommen; ins Cash fliesst
// der Betrag nach Abzug der Steuer. // der Betrag nach Abzug der Steuer.
@@ -484,7 +550,51 @@ export function ElementPhaseFields({
); );
}; };
const asPct = (v: number) => `${v} %`; const asPct = (v: number) => `${v} %`;
const asChf = (v: number) => formatChf(v);
// Der Startwert der ERSTEN Phase ist der Stammdatenwert -- hier nur angezeigt, geändert
// wird er in der Spalte «Start». Bis 0.39 stand hier ein Eingabefeld, das
// `phaseValues[phase1]` schrieb und die Stammdaten still überschattete (der Rechenkern
// legt sie als `{...baseData, ...phaseValues}` übereinander): dieselbe Zahl an zwei Orten.
// Dieselbe Vorrangregel wie im Rechenkern (`firstPhaseValues`): Wo die Stammdaten einen Wert
// tragen, gewinnen SIE -- ein Phasenwert darf sie nicht überschatten. Der Rückfall auf den
// Phasenwert bleibt nur, solange die Stammdaten leer sind.
const bd = context.baseData;
const start = (key: "amount" | "currentValue" | "startValue" | "purchasePrice" | "mortgage", label: string, help?: string) => (
<LinkedField
label={label}
help={help}
value={Math.round(typeof bd[key] === "number" ? (bd[key] as number) : num(pd[key]))}
actionLabel="Startwert bearbeiten"
onAction={context.onEditBase}
/>
);
// Jährliche Raten. Sie entstehen ausschliesslich im Verteil-Dialog der Spar- bzw.
// Verzehrquote: Nur dort sieht man, wie viel überhaupt zu verteilen ist, und dass die
// Summe aufgeht. Ein zweites Eingabefeld je Element hätte man auch über die Quote hinaus
// füllen können, ohne dass es jemand merkt.
const rate = (key: InheritableKey, label: string, help?: string) => (
<LinkedField
label={label}
help={help}
value={Math.round(num(pd[key], num(context.inheritedValues[key])))}
suffix="/Jahr"
actionLabel="Quote verteilen"
onAction={context.onDistributeRates}
/>
);
// Einmalige Verwendung des in dieser Phase verfügbaren Kapitals -- alle aus demselben Topf,
// deshalb auch alle im selben Dialog.
const capital = (label: string, value: number, help?: string) => (
<LinkedField
label={label}
help={help}
value={Math.round(value)}
actionLabel="Kapital verteilen"
onAction={context.onDistributeCapital}
/>
);
switch (element.category) { switch (element.category) {
case "INCOME": case "INCOME":
@@ -492,7 +602,13 @@ export function ElementPhaseFields({
const isIncome = element.category === "INCOME"; const isIncome = element.category === "INCOME";
// Basiswert (erstes Jahr). Ab Phase 2 mit dem fortgeschriebenen Wert der Vorphase // Basiswert (erstes Jahr). Ab Phase 2 mit dem fortgeschriebenen Wert der Vorphase
// vorbelegt, aber bewusst änderbar (Teilzeit, Beförderung, Jobwechsel …). // vorbelegt, aber bewusst änderbar (Teilzeit, Beförderung, Jobwechsel …).
const baseValue = typeof pd.amount === "number" ? pd.amount : carried ? context.derivedStart : 0; const baseValue = carried
? typeof pd.amount === "number"
? pd.amount
: context.derivedStart
: typeof bd.amount === "number"
? bd.amount
: num(pd.amount);
const d = context.deflatorStart || 1; const d = context.deflatorStart || 1;
// Info-Gegenwert im ersten Jahr: Einkommen -> real; Ausgaben -> nominal. // Info-Gegenwert im ersten Jahr: Einkommen -> real; Ausgaben -> nominal.
const otherValue = isIncome ? Math.round(baseValue / d) : Math.round(baseValue * d); const otherValue = isIncome ? Math.round(baseValue / d) : Math.round(baseValue * d);
@@ -503,12 +619,22 @@ export function ElementPhaseFields({
? "Bitte das NETTO-Einkommen erfassen: der Betrag, der nach allen Lohnabzügen (AHV/ALV, Pensionskasse, NBU) tatsächlich aufs Konto kommt nicht der Bruttolohn. Erfasst wird NOMINAL; der reale Wert (heutige Kaufkraft) erscheint nur zur Info. Für die AHV-Rente rechnet das Tool intern auf den Bruttolohn zurück." ? "Bitte das NETTO-Einkommen erfassen: der Betrag, der nach allen Lohnabzügen (AHV/ALV, Pensionskasse, NBU) tatsächlich aufs Konto kommt nicht der Bruttolohn. Erfasst wird NOMINAL; der reale Wert (heutige Kaufkraft) erscheint nur zur Info. Für die AHV-Rente rechnet das Tool intern auf den Bruttolohn zurück."
: "Ausgaben werden REAL erfasst (in heutiger Kaufkraft). Die Inflation (plan-weit) rechnet daraus automatisch die nominalen Ausgaben nur zur Info."} : "Ausgaben werden REAL erfasst (in heutiger Kaufkraft). Die Inflation (plan-weit) rechnet daraus automatisch die nominalen Ausgaben nur zur Info."}
</p> </p>
{carried ? (
// Ab Phase 2 bewusst änderbar: Teilzeit, Beförderung, Jobwechsel, anderer
// Lebensstandard -- das sind echte Entscheide dieser Phase.
<MoneyField <MoneyField
label={isIncome ? "Jahreseinkommen NETTO, nominal (erstes Jahr)" : "Jahresausgaben REAL (erstes Jahr, heutige Kaufkraft)"} label={isIncome ? "Jahreseinkommen NETTO, nominal (erstes Jahr)" : "Jahresausgaben REAL (erstes Jahr, heutige Kaufkraft)"}
help={carried ? "Vorbelegt mit dem fortgeschriebenen Wert der Vorphase; hier bewusst änderbar." : undefined} help="Vorbelegt mit dem fortgeschriebenen Wert der Vorphase; hier bewusst änderbar."
value={baseValue} value={baseValue}
onChange={(v) => setP({ amount: v })} onChange={(v) => setP({ amount: v })}
/> />
) : (
start(
"amount",
isIncome ? "Jahreseinkommen NETTO, nominal (erstes Jahr)" : "Jahresausgaben REAL (erstes Jahr, heutige Kaufkraft)",
"In der ersten Lebensphase ist das der Wert aus der Bestandsaufnahme geändert wird er in der Spalte «Start»."
)
)}
<DerivedField <DerivedField
label={isIncome ? "≈ real (heutige Kaufkraft)" : "≈ nominal (in diesem Jahr)"} label={isIncome ? "≈ real (heutige Kaufkraft)" : "≈ nominal (in diesem Jahr)"}
value={otherValue} value={otherValue}
@@ -634,11 +760,23 @@ export function ElementPhaseFields({
} }
case "PENSION_FUND": case "PENSION_FUND":
if (!context.ownerWorking) { if (!context.ownerWorking) {
// Zustand statt Regel: entweder läuft eine Rente, oder das Guthaben ist bezogen.
const hasPension = context.computedValue > 0;
return ( return (
<p className="col-span-2 text-sm text-muted"> <div className="col-span-2 rounded-lg bg-surface-2 px-3 py-2 text-sm text-muted">
Die PK-Rente wird aus dem beim Pensions-Übergang gewählten Umwandlungssatz berechnet (siehe {hasPension ? (
Kennzahl). Bei reinem Kapitalbezug erscheint hier &quot;Vollständig bezogen&quot;. <>
</p> <strong className="text-fg">Rente: {formatChf(context.computedValue)} pro Jahr.</strong> Das
Guthaben wurde beim Übergang in die Pension verrentet hier gibt es nichts mehr einzustellen.
Den Umwandlungssatz und den Kapitalanteil änderst du in der Übergangsspalte davor.
</>
) : (
<>
<strong className="text-fg">Vollständig bezogen.</strong> Das Guthaben wurde beim Übergang in die
Pension als Kapital ausbezahlt; wohin es geflossen ist, steht dort im Bezugs-Entscheid.
</>
)}
</div>
); );
} }
return ( return (
@@ -646,23 +784,19 @@ export function ElementPhaseFields({
{carried ? ( {carried ? (
<> <>
<DerivedField label="Startwert (fortgeschrieben)" value={context.derivedStart} help="Endwert der Vorphase, fortgeschrieben." /> <DerivedField label="Startwert (fortgeschrieben)" value={context.derivedStart} help="Endwert der Vorphase, fortgeschrieben." />
<MoneyField {capital(
label="Zusatzeinlage aus Kapital (CHF)" "Zusatzeinlage aus Kapital (CHF)",
help="Aufstockung aus dem verfügbaren Kapital dieser Phase." num(pd.additionalInvestment),
value={num(pd.additionalInvestment)} "Aufstockung aus dem verfügbaren Kapital dieser Phase."
)}
onChange={(v) => setP({ additionalInvestment: v })}
/>
</> </>
) : ( ) : (
<MoneyField label="Aktueller PK-Wert (CHF)" value={num(pd.currentValue)} onChange={(v) => setP({ currentValue: v })} /> start("currentValue", "Aktueller PK-Wert (CHF)")
)} )}
{inh( {rate(
"annualContribution", "annualContribution",
"Jährliche Einzahlung (CHF)", "Jährliche Einzahlung (CHF)",
(v, set) => <MoneyField label="Jährliche Einzahlung (CHF)" value={v} onChange={set} />, "Arbeitnehmer- und Arbeitgeberbeiträge. Stammen aus dem BRUTTOLOHN und schmälern die Sparquote nicht deshalb stehen sie im Verteil-Dialog in einem eigenen Block."
asChf,
"Arbeitnehmer- und Arbeitgeberbeiträge. Fliesst NICHT in die Sparquote ein (bereits in den Ausgaben berücksichtigt)."
)} )}
{inh( {inh(
"expectedReturn", "expectedReturn",
@@ -674,43 +808,36 @@ export function ElementPhaseFields({
); );
case "PILLAR_3A": { case "PILLAR_3A": {
if (!context.ownerWorking) { if (!context.ownerWorking) {
return <p className="col-span-2 text-sm text-muted">Die Säule 3a wird beim Pensions-Übergang vollständig bezogen.</p>; return (
<div className="col-span-2 rounded-lg bg-surface-2 px-3 py-2 text-sm text-muted">
<strong className="text-fg">Vollständig bezogen.</strong> Ein 3a-Konto lässt sich nur ganz auflösen
das ist beim Übergang geschehen. Bezugsalter, Steuersatz und die Verwendung des Kapitals stehen in der
Übergangsspalte davor.
</div>
);
} }
// «Grosse Säule 3a» für Selbstständige ohne PK: höhere Obergrenze (Roadmap-Feedback C7c). // «Grosse Säule 3a» für Selbstständige ohne PK: höhere Obergrenze (Roadmap-Feedback C7c).
// Der Schalter steht seit 0.40 im Verteil-Dialog, weil er dort die Obergrenze der
// Einzahlung bestimmt -- getrennt von ihr wäre er eine Einstellung ohne sichtbare Wirkung.
const max3a = pd.selfEmployed3a ? PILLAR_3A_MAX_SELF_EMPLOYED : PILLAR_3A_MAX_ANNUAL; const max3a = pd.selfEmployed3a ? PILLAR_3A_MAX_SELF_EMPLOYED : PILLAR_3A_MAX_ANNUAL;
return ( return (
<> <>
{carried ? ( {carried ? (
<> <>
<DerivedField label="Startwert (fortgeschrieben)" value={context.derivedStart} help="Endwert der Vorphase, fortgeschrieben." /> <DerivedField label="Startwert (fortgeschrieben)" value={context.derivedStart} help="Endwert der Vorphase, fortgeschrieben." />
<MoneyField {capital(
label="Zusatzeinlage aus Kapital (CHF)" "Zusatzeinlage aus Kapital (CHF)",
help="Aufstockung aus dem verfügbaren Kapital dieser Phase." num(pd.additionalInvestment),
value={num(pd.additionalInvestment)} "Aufstockung aus dem verfügbaren Kapital dieser Phase."
)}
onChange={(v) => setP({ additionalInvestment: v })}
/>
</> </>
) : ( ) : (
<MoneyField label="Aktueller 3a-Wert (CHF)" value={num(pd.currentValue)} onChange={(v) => setP({ currentValue: v })} /> start("currentValue", "Aktueller 3a-Wert (CHF)")
)} )}
<label className="col-span-2 flex cursor-pointer items-center gap-2 text-xs text-muted"> {rate(
<input
type="checkbox"
checked={!!pd.selfEmployed3a}
onChange={(e) => setP({ selfEmployed3a: e.target.checked })}
/>
Selbstständig ohne Pensionskasse («grosse Säule 3a»)
<InfoBubble text={`Selbstständigerwerbende ohne PK dürfen bis 20 % des Nettoerwerbseinkommens einzahlen, gedeckelt auf ca. ${PILLAR_3A_MAX_SELF_EMPLOYED.toLocaleString("de-CH")} CHF statt der ${PILLAR_3A_MAX_ANNUAL.toLocaleString("de-CH")} CHF mit PK.`} />
</label>
{inh(
"annualContribution", "annualContribution",
"Jährliche Einzahlung (CHF)", "Jährliche Einzahlung (CHF)",
(v, set) => ( `Maximal CHF ${max3a.toLocaleString("de-CH")} (2026, ${pd.selfEmployed3a ? "Selbstständig ohne PK" : "mit PK"}) und höchstens die Sparquote.`
<MoneyField label="Jährliche Einzahlung (CHF)" value={v} max={max3a} onChange={set} />
),
asChf,
`Maximal CHF ${max3a.toLocaleString("de-CH")} (2026, ${pd.selfEmployed3a ? "Selbstständig ohne PK" : "mit PK"}) und höchstens die Sparquote. Wird von der Sparquote abgezogen.`
)} )}
{inh( {inh(
"expectedReturn", "expectedReturn",
@@ -724,8 +851,13 @@ export function ElementPhaseFields({
case "REAL_ESTATE": { case "REAL_ESTATE": {
// Zinsbetrag zu Phasenbeginn und -ende: die Restschuld sinkt mit der Amortisation, // Zinsbetrag zu Phasenbeginn und -ende: die Restschuld sinkt mit der Amortisation,
// der Zinsbetrag also mit. Am Nullpunkt gekappt (analog zur Berechnung). // der Zinsbetrag also mit. Am Nullpunkt gekappt (analog zur Berechnung).
const hypStart = carried ? context.derivedMortgage : num(pd.mortgage); const hypStart = carried
const hypEnde = Math.max(0, hypStart - num(pd.amortization) * context.durationYears); ? context.derivedMortgage
: typeof bd.mortgage === "number"
? bd.mortgage
: num(pd.mortgage);
const amortEff = num(pd.amortization, num(context.inheritedValues.amortization));
const hypEnde = Math.max(0, hypStart - amortEff * context.durationYears);
const zinsStart = Math.round((hypStart * num(pd.interestRate)) / 100); const zinsStart = Math.round((hypStart * num(pd.interestRate)) / 100);
const zinsEnde = Math.round((hypEnde * num(pd.interestRate)) / 100); const zinsEnde = Math.round((hypEnde * num(pd.interestRate)) / 100);
const handling = pd.interestHandling ?? "INCLUDED"; const handling = pd.interestHandling ?? "INCLUDED";
@@ -742,17 +874,21 @@ export function ElementPhaseFields({
</> </>
) : ( ) : (
<> <>
<MoneyField label="Kaufpreis (CHF)" value={num(pd.purchasePrice)} onChange={(v) => setP({ purchasePrice: v })} /> {start("purchasePrice", "Kaufpreis (CHF)")}
<MoneyField label="Hypothek (CHF)" value={num(pd.mortgage)} onChange={(v) => setP({ mortgage: v })} /> {start("mortgage", "Hypothek (CHF)")}
</> </>
)} )}
{inh( {rate(
"amortization", "amortization",
"Amortisation (CHF/Jahr)", "Amortisation (CHF/Jahr)",
(v, set) => <MoneyField label="Amortisation (CHF/Jahr)" value={v} onChange={set} />,
asChf,
"Jährliche Reduktion der Hypothek. Zählt gegen die Sparquote und endet, sobald die Hypothek abbezahlt ist." "Jährliche Reduktion der Hypothek. Zählt gegen die Sparquote und endet, sobald die Hypothek abbezahlt ist."
)} )}
{context.oneOffAmortization > 0 &&
capital(
"Sonderamortisation aus Kapital (CHF)",
context.oneOffAmortization,
"Einmaltilgung am Übergang in diese Phase, aus dem verfügbaren Kapital."
)}
{inh( {inh(
"interestRate", "interestRate",
"Hypothekarzins (%/Jahr)", "Hypothekarzins (%/Jahr)",
@@ -797,12 +933,11 @@ export function ElementPhaseFields({
{carried ? ( {carried ? (
<> <>
<DerivedField label="Startwert (fortgeschrieben)" value={context.derivedStart} help="Endwert der Vorphase, fortgeschrieben." /> <DerivedField label="Startwert (fortgeschrieben)" value={context.derivedStart} help="Endwert der Vorphase, fortgeschrieben." />
<MoneyField {capital(
label="Zusatzinvestition aus Kapital (CHF)" "Zusatzinvestition aus Kapital (CHF)",
help="Neuinvestition aus dem verfügbaren Kapital dieser Phase." num(pd.additionalInvestment),
value={num(pd.additionalInvestment)} "Neuinvestition aus dem verfügbaren Kapital dieser Phase."
onChange={(v) => setP({ additionalInvestment: v })} )}
/>
{/* Aus einem Kapitalbezug (PK/3a) am letzten Übergang umgeleitet -- nicht hier {/* Aus einem Kapitalbezug (PK/3a) am letzten Übergang umgeleitet -- nicht hier
erfasst, sondern dort als Quote entschieden (Punkt C). Kommt der Betrag aus erfasst, sondern dort als Quote entschieden (Punkt C). Kommt der Betrag aus
mehreren Bezügen (PK und 3a, ggf. beider Personen), wird er einzeln mehreren Bezügen (PK und 3a, ggf. beider Personen), wird er einzeln
@@ -831,7 +966,7 @@ export function ElementPhaseFields({
)} )}
</> </>
) : ( ) : (
<MoneyField label="Startwert (CHF)" value={num(pd.startValue)} onChange={(v) => setP({ startValue: v })} /> start("startValue", "Startwert (CHF)")
)} )}
{inh( {inh(
"expectedReturn", "expectedReturn",
@@ -839,18 +974,14 @@ export function ElementPhaseFields({
(v, set) => <NumberField label="Erwartete Rendite (%/Jahr)" step={0.1} value={v} onChange={set} />, (v, set) => <NumberField label="Erwartete Rendite (%/Jahr)" step={0.1} value={v} onChange={set} />,
asPct asPct
)} )}
{inh( {rate(
"annualContribution", "annualContribution",
"Jährlicher Sparbeitrag (CHF)", "Jährlicher Sparbeitrag (CHF)",
(v, set) => <MoneyField label="Jährlicher Sparbeitrag (CHF)" value={v} onChange={set} />,
asChf,
"Flacher Jahresbetrag, der ins Vermögen fliesst und vom Cash abgezogen wird (geplante Sparrate)." "Flacher Jahresbetrag, der ins Vermögen fliesst und vom Cash abgezogen wird (geplante Sparrate)."
)} )}
{inh( {rate(
"annualWithdrawal", "annualWithdrawal",
"Jährliche Bezugsrate (CHF)", "Jährliche Bezugsrate (CHF)",
(v, set) => <MoneyField label="Jährliche Bezugsrate (CHF)" value={v} onChange={set} />,
asChf,
"Entnahme aus dem Vermögen (z. B. laufende Renten-Entnahme im Alter). Mindert das Vermögen und fliesst jährlich ins Cash (geplante Verzehrrate)." "Entnahme aus dem Vermögen (z. B. laufende Renten-Entnahme im Alter). Mindert das Vermögen und fliesst jährlich ins Cash (geplante Verzehrrate)."
)} )}
</> </>
@@ -861,14 +992,14 @@ export function ElementPhaseFields({
{carried ? ( {carried ? (
<DerivedField label="Restschuld (fortgeschrieben)" value={Math.abs(context.derivedStart)} help="Fortgeschriebene Restschuld aus der Vorphase." /> <DerivedField label="Restschuld (fortgeschrieben)" value={Math.abs(context.derivedStart)} help="Fortgeschriebene Restschuld aus der Vorphase." />
) : ( ) : (
<MoneyField label="Restschuld (CHF)" value={num(pd.startValue)} onChange={(v) => setP({ startValue: v })} /> start("startValue", "Restschuld (CHF)")
)} )}
{inh( {rate("annualRepayment", "Jährliche Tilgung (CHF)", "Zählt gegen die Sparquote (max. die Sparquote).")}
"annualRepayment", {context.oneOffRepayment > 0 &&
"Jährliche Tilgung (CHF)", capital(
(v, set) => <MoneyField label="Jährliche Tilgung (CHF)" value={v} onChange={set} />, "Sofort-Tilgung aus Kapital (CHF)",
asChf, context.oneOffRepayment,
"Zählt gegen die Sparquote (max. die Sparquote)." "Einmalige Tilgung am Übergang in diese Phase, aus dem verfügbaren Kapital."
)} )}
</> </>
); );
@@ -988,13 +1119,15 @@ export function ElementTransitionFields({
<NumberField label="Grundstückgewinnsteuer (%)" step={1} value={num(td.saleTaxRate, DEFAULT_PROPERTY_GAINS_TAX_RATE)} onChange={(v) => setT({ saleTaxRate: v })} /> <NumberField label="Grundstückgewinnsteuer (%)" step={1} value={num(td.saleTaxRate, DEFAULT_PROPERTY_GAINS_TAX_RATE)} onChange={(v) => setT({ saleTaxRate: v })} />
</> </>
)} )}
{decision !== "SELL" && context.mortgageEnd > 0 && ( {/* Die Sonderamortisation stand bis 0.39 hier. Sie stammt aber aus dem Kapital, das
<MoneyField in der FOLGEphase verfügbar ist, und wird dort im Kapital-Dialog zusammen mit
label="Sonderamortisation (CHF)" allen anderen Verwendungen desselben Topfs entschieden. Zwei Eingaben für
help={`Einmaltilgung der Hypothek aus dem Cash der nächsten Phase (z. B. aus einem 3a-Bezug). Senkt Restschuld und künftige Zinsen. Maximal ${formatChf(context.mortgageEnd)} (Resthypothek am Phasenende).`} denselben Franken -- hier und dort -- gab es damit nicht mehr. */}
{decision !== "SELL" && context.mortgageEnd > 0 && num(td.extraAmortization) > 0 && (
<DerivedField
label="Sonderamortisation aus Kapital (CHF)"
value={num(td.extraAmortization)} value={num(td.extraAmortization)}
max={context.mortgageEnd} help="Einmaltilgung aus dem Kapital der nächsten Phase. Geändert wird sie dort unter «Kapital verteilen»."
onChange={(v) => setT({ extraAmortization: v })}
/> />
)} )}
</> </>
@@ -1027,13 +1160,18 @@ export function ElementTransitionFields({
); );
} }
case "OTHER_DEBT": case "OTHER_DEBT":
return ( // Dito: Die Sofort-Tilgung zehrt vom Kapital der Folgephase und wird dort entschieden.
<MoneyField return num(td.immediateRepayment) > 0 ? (
label="Sofortige Tilgung (CHF)" <DerivedField
help="Wird sofort getilgt und vom verfügbaren Kapital der nächsten Phase abgezogen." label="Sofort-Tilgung aus Kapital (CHF)"
value={num(td.immediateRepayment)} value={num(td.immediateRepayment)}
onChange={(v) => setT({ immediateRepayment: v })} help="Wird am Übergang getilgt, aus dem Kapital der nächsten Phase. Geändert wird sie dort unter «Kapital verteilen»."
/> />
) : (
<p className="col-span-2 text-sm text-muted">
Am Übergang ist hier nichts zu entscheiden. Eine einmalige Tilgung aus dem verfügbaren Kapital
nimmst du in der nächsten Lebensphase unter «Kapital verteilen» vor.
</p>
); );
} }
} }
@@ -1047,6 +1185,7 @@ export function ElementDetail({
phaseDataByPhase = {}, phaseDataByPhase = {},
onSaved, onSaved,
onDeleteElement, onDeleteElement,
compact,
}: Props) { }: Props) {
const [pd, setPd] = useState<PhaseData>({ ...phaseData }); const [pd, setPd] = useState<PhaseData>({ ...phaseData });
const [td, setTd] = useState<TransitionData>({ ...transitionData }); const [td, setTd] = useState<TransitionData>({ ...transitionData });
@@ -1068,7 +1207,10 @@ export function ElementDetail({
if (isTransition) { if (isTransition) {
await api.put(`/api/elements/${element.id}/transition/${context.phaseId}`, td); await api.put(`/api/elements/${element.id}/transition/${context.phaseId}`, td);
} else { } else {
const payload: PhaseData = { ...pd }; // Speichern heisst zugleich BESTAETIGEN: Wer die Zelle geoeffnet und gespeichert hat,
// hat hingeschaut. Der Haken steht neben den Werten und friert nichts ein -- leere
// Felder erben weiterhin live aus der Vorphase (SPEZIFIKATION 3.12.4).
const payload: PhaseData = { ...pd, confirmed: true };
// Einkommen/Ausgaben ab Phase 2: entspricht der Basiswert dem fortgeschriebenen Wert // Einkommen/Ausgaben ab Phase 2: entspricht der Basiswert dem fortgeschriebenen Wert
// der Vorphase, KEINEN Override speichern -> Wert bleibt live vererbt (Änderungen in // der Vorphase, KEINEN Override speichern -> Wert bleibt live vererbt (Änderungen in
// früheren Phasen wirken sich weiter aus). Nur ein bewusst abweichender Wert wird fix. // früheren Phasen wirken sich weiter aus). Nur ein bewusst abweichender Wert wird fix.
@@ -1114,6 +1256,7 @@ export function ElementDetail({
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
{!compact && (
<div className="flex items-start justify-between gap-2"> <div className="flex items-start justify-between gap-2">
<div> <div>
<div className="text-xs font-semibold uppercase tracking-wide text-accent-soft-fg"> <div className="text-xs font-semibold uppercase tracking-wide text-accent-soft-fg">
@@ -1130,6 +1273,7 @@ export function ElementDetail({
<Trash2 className="h-3.5 w-3.5" /> Element löschen <Trash2 className="h-3.5 w-3.5" /> Element löschen
</button> </button>
</div> </div>
)}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{/* Punkt 4: Warnung, dass Änderungen sich in die Folgephasen fortschreiben. */} {/* Punkt 4: Warnung, dass Änderungen sich in die Folgephasen fortschreiben. */}
+627
View File
@@ -0,0 +1,627 @@
"use client";
// Die Bestandsaufnahme: alles erfassen, was heute da ist.
//
// Der einzige Sammel-Dialog, der geblieben ist. Er arbeitet ausschliesslich über die
// bestehenden Endpunkte -- was er schreibt, hätte man auch einzeln über die Matrix erfassen
// können; er nimmt einem nur das Suchen ab.
//
// Erfasst werden NUR Tatsachen: Kontostand, Guthaben, Kaufpreis, Restschuld. Renditen,
// Lohnentwicklung und Teuerung stehen bewusst nicht hier -- sie sind Annahmen, und eine
// Annahme gilt immer nur für einen Zeitraum. Sie werden in den Phasenzellen der Matrix
// gesetzt und dort bestätigt (SPEZIFIKATION 3.14).
import { useState } from "react";
import { Check, Plus, Trash2 } from "lucide-react";
import { api } from "@/lib/api-client";
import { Button, Modal, useConfirm } from "@/components/ui";
import { MoneyField, NumberField, SelectField, TextField } from "@/components/FormField";
import { formatChf } from "@/lib/format";
import { CATEGORY_LABELS, ownerLabel, num, type PhaseData } from "@/lib/elements";
import type { PlanInput } from "@/lib/types";
type Scope = "HOUSEHOLD" | "PERSON_A" | "PERSON_B";
interface Kind {
key: string;
category: string;
// Beschriftung des Knopfs. Bewusst der KATEGORIENAME und keine erfundene Umgangssprache:
// Die Matrix, die Rechenwege und der PDF-Bericht sprechen dieselbe Sprache -- wer hier eine
// andere Vokabel lernt, sucht sie spaeter vergeblich. Die Alltagssprache steht in `hint`.
label: string;
// Vorschlag fuer den Namen des angelegten Elements. Hier darf es konkret werden: Die
// Kategorie ist "Ausgaben", die Zeile heisst "Lebenshaltung".
defaultName: string;
hint: string;
personOnly?: boolean;
// SCHRITT 1 -- der Bestand von heute. Nur Zahlen, die man nachschlagen kann: Kontostand,
// Guthaben, Kaufpreis, Restschuld.
baseFields: (v: PhaseData, set: (p: Partial<PhaseData>) => void) => React.ReactNode;
// SCHRITT 2 -- die Annahmen fuer EINE Lebensphase. Alles, was pro Jahr geschieht und sich
// von Phase zu Phase aendern darf: Renditen, Lohnentwicklung, Wertsteigerung, Zins.
//
// Die Trennung ist der Kern: Ein Guthaben ist eine Tatsache, eine Rendite eine Annahme --
// und Annahmen gelten immer nur fuer einen Zeitraum. Bis 0.37 standen beide zusammen in der
// Bestandsaufnahme, wodurch eine Annahme wie eine Tatsache aussah.
phaseFields?: (v: PhaseData, set: (p: Partial<PhaseData>) => void) => React.ReactNode;
}
const KINDS: Kind[] = [
{
key: "income",
category: "INCOME",
label: "Einkommen",
defaultName: "Lohn",
hint: "Dein Nettolohn das, was auf dem Konto ankommt.",
personOnly: true,
baseFields: (v, set) => (
<MoneyField
label="Nettolohn pro Jahr"
help="Was tatsächlich aufs Konto kommt, nach allen Abzügen. FPT rechnet für die AHV intern auf den Bruttolohn hoch."
value={num(v.amount)}
onChange={(x) => set({ amount: x })}
/>
),
phaseFields: (v, set) => (
<NumberField
label="Jährliche Lohnerhöhung (%)"
help="Nominal, also inklusive Teuerung. 1 % ist ein vorsichtiger Ausgangswert."
step={0.5}
value={num(v.teuerungsausgleich, 1)}
onChange={(x) => set({ teuerungsausgleich: x })}
/>
),
},
{
key: "expense",
category: "EXPENSE",
label: "Ausgaben",
defaultName: "Lebenshaltung",
hint: "Alles, was du im Jahr ausgibst Wohnen, Essen, Versicherungen, Freizeit.",
baseFields: (v, set) => (
<MoneyField
label="Ausgaben pro Jahr"
help="In heutiger Kaufkraft. Die Teuerung rechnet FPT selbst dazu."
value={num(v.amount)}
onChange={(x) => set({ amount: x })}
/>
),
phaseFields: (v, set) => (
<NumberField
label="Jährliche Teuerung (%)"
help="Leer lassen heisst: die Inflationsannahme des Szenarios. Eine eigene Zahl nur, wenn du für diese Ausgaben etwas anderes erwartest."
step={0.25}
value={num(v.teuerungsausgleich)}
onChange={(x) => set({ teuerungsausgleich: x })}
/>
),
},
{
key: "pk",
category: "PENSION_FUND",
label: "Pensionskasse",
defaultName: "Pensionskasse",
hint: "Dein Altersguthaben laut PK-Ausweis.",
personOnly: true,
baseFields: (v, set) => (
<MoneyField
label="Altersguthaben heute"
help="Steht auf deinem Vorsorgeausweis."
value={num(v.currentValue)}
onChange={(x) => set({ currentValue: x })}
/>
),
phaseFields: (v, set) => (
<>
<NumberField
label="Verzinsung (%/Jahr)"
help="Der BVG-Mindestzins liegt aktuell tief; viele Kassen verzinsen etwas darüber."
step={0.25}
value={num(v.expectedReturn, 1.25)}
onChange={(x) => set({ expectedReturn: x })}
/>
{/* Der PK-Beitrag steht hier und nicht bei der Sparquote: Er stammt aus dem
Bruttolohn und belastet das Cash-Konto nicht -- aus der Quote liesse er sich
gar nicht verteilen (SPEZIFIKATION 4.6.3). */}
<MoneyField
label="Jährlicher Beitrag (Arbeitnehmer + Arbeitgeber)"
help="Die Altersgutschrift laut Vorsorgeausweis. Sie kommt aus dem Bruttolohn und schmälert deine Sparquote deshalb nicht."
value={num(v.annualContribution)}
onChange={(x) => set({ annualContribution: x })}
/>
</>
),
},
{
key: "p3a",
category: "PILLAR_3A",
label: "Säule 3a",
defaultName: "Säule 3a",
hint: "Ein Konto oder Depot der gebundenen Vorsorge. Mehrere Konten legst du einzeln an.",
personOnly: true,
baseFields: (v, set) => (
<MoneyField label="Guthaben heute" value={num(v.currentValue)} onChange={(x) => set({ currentValue: x })} />
),
phaseFields: (v, set) => (
<NumberField
label="Erwartete Rendite (%/Jahr)"
help="Auf einem Konto praktisch 0, in einer Wertschriftenlösung deutlich mehr."
step={0.5}
value={num(v.expectedReturn, 2)}
onChange={(x) => set({ expectedReturn: x })}
/>
),
},
{
key: "etf",
category: "OTHER_ASSET",
label: "Sonstiges Vermögen",
defaultName: "Wertschriften",
hint: "Depot, ETF, Sparkonto alles freie Vermögen ausser dem Cash-Konto.",
baseFields: (v, set) => (
<MoneyField label="Wert heute" value={num(v.startValue)} onChange={(x) => set({ startValue: x })} />
),
phaseFields: (v, set) => (
<NumberField
label="Erwartete Rendite (%/Jahr)"
help="Langfristiger Durchschnitt. Für ein gemischtes Portfolio sind 3 bis 5 % ein üblicher Ansatz."
step={0.5}
value={num(v.expectedReturn, 4)}
onChange={(x) => set({ expectedReturn: x })}
/>
),
},
{
key: "re",
category: "REAL_ESTATE",
label: "Immobilie",
defaultName: "Wohneigentum",
hint: "Haus oder Wohnung samt Hypothek.",
baseFields: (v, set) => (
<>
<MoneyField
label="Kaufpreis"
help="Der ursprüngliche Kaufpreis Grundlage der Grundstückgewinnsteuer bei einem späteren Verkauf."
value={num(v.purchasePrice)}
onChange={(x) => set({ purchasePrice: x })}
/>
<MoneyField label="Hypothek heute" value={num(v.mortgage)} onChange={(x) => set({ mortgage: x })} />
</>
),
phaseFields: (v, set) => (
<>
<NumberField
label="Hypothekarzins (%)"
step={0.25}
value={num(v.interestRate, 2)}
onChange={(x) => set({ interestRate: x })}
/>
<NumberField
label="Wertsteigerung (%/Jahr)"
help="Auf den Wert der Liegenschaft, nicht auf dein Eigenkapital dort wirkt sie gehebelt."
step={0.25}
value={num(v.valueGrowth, 1)}
onChange={(x) => set({ valueGrowth: x })}
/>
<div className="sm:col-span-2">
<SelectField
label="Sind die Hypothekarzinsen in deinen Ausgaben schon enthalten?"
help="Wenn ja, rechnet FPT sie nicht noch einmal dazu sonst zählten sie doppelt."
value={v.interestHandling ?? "INCLUDED"}
onChange={(x: "INCLUDED" | "ADD") => set({ interestHandling: x })}
options={[
{ value: "INCLUDED", label: "Ja, sind in der Lebenshaltung enthalten" },
{ value: "ADD", label: "Nein, bitte zusätzlich rechnen" },
]}
/>
</div>
</>
),
},
{
key: "debt",
category: "OTHER_DEBT",
label: "Sonstige Schulden",
defaultName: "Schulden",
hint: "Kredite ausserhalb der Hypothek.",
baseFields: (v, set) => (
<MoneyField label="Restschuld heute" value={num(v.startValue)} onChange={(x) => set({ startValue: x })} />
),
},
];
// Der Entwurf des GANZEN Schritts, nicht der einzelnen Karte.
//
// Zwei Gruende dafuer, dass er hier oben liegt und nicht in der Karte: Beim Wechsel des
// Reiters verschwinden die Karten des vorigen Bereichs vom Bildschirm -- mit ihrem eigenen
// Zustand waeren alle getippten Zahlen still weg. Und die Summe "Vermoegen heute" laesst sich
// nur berechnen, wenn alle Entwuerfe an EINEM Ort liegen.
export interface InventoryDraft {
base: Record<string, PhaseData>;
names: Record<string, string>;
// Cash ist kein Element, sondern eine Eigenschaft des Szenarios (`Scenario.initialCash`) --
// deshalb kein Plus-Knopf, sondern ein festes Feld. Es fehlte bis 0.39 ganz: Die Summe
// «Vermögen heute» rechnete es bereits mit, erfassen konnte man es hier aber nirgends.
// `null` = unberührt (dann wird beim Speichern auch nichts geschrieben).
cash: number | null;
}
export function emptyInventoryDraft(): InventoryDraft {
return { base: {}, names: {}, cash: null };
}
// Bestand eines Elements aus Stammdaten -- dieselbe Rechnung wie in der Kachel «Kennzahlen»,
// hier nur auf dem Entwurf statt auf dem gespeicherten Stand.
function wealthOf(category: string, d: PhaseData): number {
if (category === "OTHER_ASSET") return num(d.startValue);
if (category === "PENSION_FUND" || category === "PILLAR_3A") return num(d.currentValue);
if (category === "REAL_ESTATE") return num(d.purchasePrice) - num(d.mortgage);
if (category === "OTHER_DEBT") return -num(d.startValue);
return 0;
}
// Die Stammdaten-Felder einer Kategorie. Eigene Komponente, weil sie an ZWEI Orten
// erscheinen: im Assistenten-Schritt 1 (als Liste) und in der Matrix-Spalte «Start» (einzeln).
// Zwei Implementierungen derselben Felder liefen garantiert auseinander.
export function BaseFields({
category,
values,
set,
}: {
category: string;
values: PhaseData;
set: (patch: Partial<PhaseData>) => void;
}) {
const kind = KINDS.find((k) => k.category === category);
return <>{kind?.baseFields(values, set)}</>;
}
export function InventoryFields({
plan,
draft,
setDraft,
onChanged,
}: {
plan: PlanInput;
draft: InventoryDraft;
setDraft: (fn: (d: InventoryDraft) => InventoryDraft) => void;
onChanged: () => void;
}) {
const scopes: Scope[] =
plan.householdType === "COUPLE" ? ["HOUSEHOLD", "PERSON_A", "PERSON_B"] : ["PERSON_A"];
const [page, setPage] = useState(0);
const scope = scopes[page];
const cash = draft.cash ?? Math.round(plan.initialCash || 0);
// Live-Summe aus dem ENTWURF: Sie soll sich beim Tippen bewegen, nicht erst beim Speichern.
const today = plan.elements.reduce(
(sum, e) => sum + wealthOf(e.category, draft.base[e.id] ?? e.baseData ?? {}),
cash
);
return (
<div className="flex flex-col gap-4">
{/* Cash steht ausserhalb der Reiter: Es ist kein Element und gehört keiner Person --
es ist der Topf, aus dem alles fliesst und in den alles zurückfliesst. */}
<div className="rounded-xl border border-border bg-surface-2 p-3">
<MoneyField
label="Cash heute (Konto- und Sparguthaben)"
help="Alles, was sofort verfügbar ist. Aus diesem Topf zahlt FPT die Ausgaben; was du sparst und nicht zuteilst, bleibt hier liegen."
value={cash}
onChange={(x) => setDraft((d) => ({ ...d, cash: x }))}
/>
</div>
{scopes.length > 1 && (
<div className="flex gap-1 rounded-lg border border-border bg-surface-2 p-1 text-xs">
{scopes.map((sc, i) => (
<button
key={sc}
type="button"
onClick={() => setPage(i)}
className={`flex-1 rounded-md px-2 py-1 font-medium ${
page === i ? "bg-accent text-accent-fg" : "text-muted hover:bg-surface"
}`}
>
{sc === "HOUSEHOLD" ? "Gemeinsam" : ownerLabel(plan.persons, sc)}
</button>
))}
</div>
)}
<ScopeElements plan={plan} scope={scope} draft={draft} setDraft={setDraft} onChanged={onChanged} />
{/* Die einzige Zahl, die schon vor jeder Zeitplanung etwas aussagt -- und die erste
Rueckmeldung auf die Bestandsaufnahme. */}
<div className="sticky bottom-0 flex items-baseline justify-between gap-3 border-t border-border bg-surface px-1 pt-3">
<span className="text-xs font-semibold uppercase tracking-wide text-faint">Vermögen heute</span>
<span className="text-lg font-semibold tabular-nums text-fg">{formatChf(today)}</span>
</div>
</div>
);
}
function ScopeElements({
plan,
scope,
draft,
setDraft,
onChanged,
}: {
plan: PlanInput;
scope: Scope;
draft: InventoryDraft;
setDraft: (fn: (d: InventoryDraft) => InventoryDraft) => void;
onChanged: () => void;
}) {
const [busy, setBusy] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const kinds = KINDS.filter((k) => (scope === "HOUSEHOLD" ? !k.personOnly : true));
const mine = plan.elements.filter((e) => (e.ownerRole ?? "HOUSEHOLD") === scope);
// Das Element entsteht SOFORT -- es braucht eine Id, an der die Werte haengen. Nur die
// Zahlen werden bis zum Abschluss des Schritts zurueckgehalten.
async function add(kind: Kind) {
setBusy(kind.key);
setError(null);
try {
const suffix =
plan.householdType === "COUPLE" && scope !== "HOUSEHOLD" ? ` ${ownerLabel(plan.persons, scope)}` : "";
await api.post(`/api/scenarios/${plan.id}/elements`, {
category: kind.category,
name: `${kind.defaultName}${suffix}`,
ownerRole: scope,
});
onChanged();
} catch (e) {
setError(e instanceof Error ? e.message : "Anlegen fehlgeschlagen.");
} finally {
setBusy(null);
}
}
return (
<div className="flex flex-col gap-3">
{/* Der Knopf traegt den Kategorienamen, die Erklaerung steht darunter -- so lernt man
beim ersten Kontakt die Vokabel, die auch die Matrix verwendet. */}
<div className="grid gap-1.5 sm:grid-cols-2 lg:grid-cols-3">
{kinds.map((k) => (
<button
key={k.key}
type="button"
disabled={busy === k.key}
onClick={() => add(k)}
className="flex items-start gap-1.5 rounded-lg border border-dashed border-border px-2.5 py-1.5 text-left transition-colors hover:border-accent hover:bg-accent-soft/20"
>
<Plus className="mt-0.5 h-3.5 w-3.5 shrink-0 text-accent" />
<span className="min-w-0">
<span className="block text-xs font-semibold text-fg">{k.label}</span>
<span className="block text-[11px] leading-snug text-muted">{k.hint}</span>
</span>
</button>
))}
</div>
{mine.length === 0 ? (
<p className="rounded-lg border border-dashed border-border bg-surface-2 p-3 text-sm text-muted">
Noch nichts erfasst. Wähle oben, was zutrifft du kannst jederzeit weitere Positionen ergänzen.
</p>
) : (
<div className="flex flex-col gap-2">
{mine.map((el) => (
<BaseDataCard
key={el.id}
plan={plan}
elementId={el.id}
draft={draft}
setDraft={setDraft}
onChanged={onChanged}
/>
))}
</div>
)}
{error && <p className="text-sm text-danger">{error}</p>}
</div>
);
}
function BaseDataCard({
plan,
elementId,
draft,
setDraft,
onChanged,
}: {
plan: PlanInput;
elementId: string;
draft: InventoryDraft;
setDraft: (fn: (d: InventoryDraft) => InventoryDraft) => void;
onChanged: () => void;
}) {
const el = plan.elements.find((x) => x.id === elementId)!;
const kind = KINDS.find((k) => k.category === el.category);
const values = draft.base[el.id] ?? el.baseData ?? {};
const name = draft.names[el.id] ?? el.name;
const set = (patch: Partial<PhaseData>) =>
setDraft((d) => ({ ...d, base: { ...d.base, [el.id]: { ...(d.base[el.id] ?? el.baseData ?? {}), ...patch } } }));
async function remove() {
await api.delete(`/api/elements/${el.id}`);
setDraft((d) => {
const base = { ...d.base };
const names = { ...d.names };
delete base[el.id];
delete names[el.id];
return { ...d, base, names };
});
onChanged();
}
return (
<div className="rounded-xl border border-border p-3">
<div className="mb-2 flex items-center gap-2">
<span className="text-xs font-semibold uppercase tracking-wide text-faint">
{CATEGORY_LABELS[el.category]}
</span>
<button
type="button"
onClick={remove}
className="ml-auto rounded p-1 text-faint hover:bg-danger-soft hover:text-danger"
aria-label="Entfernen"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div className="sm:col-span-2">
<TextField
label="Bezeichnung"
value={name}
onChange={(v) => setDraft((d) => ({ ...d, names: { ...d.names, [el.id]: v } }))}
/>
</div>
{kind ? (
kind.baseFields(values, set)
) : (
// Die AHV ist die einzige Kategorie ohne Bestand: Es gibt kein Guthaben zum
// Nachschlagen, nur eine Beitragskarriere. Eine leere Karte sähe nach einem Fehler
// aus -- deshalb steht hier, warum nichts zu tun ist.
<p className="sm:col-span-2 rounded-lg bg-surface-2 px-3 py-2 text-xs text-muted">
Hier gibt es nichts zu erfassen. Die AHV-Rente entsteht aus deiner Beitragskarriere FPT leitet sie
aus den Einkommen dieses Plans ab. Beim Übergang in die Pension kannst du sie prüfen und um
Beitragslücken vor Planbeginn ergänzen.
</p>
)}
</div>
</div>
);
}
// Schreibt den gesammelten Entwurf. Wird vom Schritt-Dialog beim Abschliessen aufgerufen --
// es gibt bewusst keinen Speichern-Knopf je Karte mehr.
export async function saveInventoryDraft(plan: PlanInput, draft: InventoryDraft): Promise<void> {
for (const el of plan.elements) {
const nextName = draft.names[el.id]?.trim();
if (nextName && nextName !== el.name) {
await api.patch(`/api/elements/${el.id}`, { name: nextName });
}
const base = draft.base[el.id];
if (base) await api.put(`/api/elements/${el.id}/base`, base);
}
// Cash hängt am Szenario, nicht an einem Element -- eigener Endpunkt.
if (draft.cash !== null && draft.cash !== plan.initialCash) {
await api.patch(`/api/scenarios/${plan.id}`, { initialCash: draft.cash });
}
// Die AHV entsteht am Schluss von selbst -- je Person eine.
//
// Sie hat als einzige Kategorie KEINEN Bestand: Es gibt kein Guthaben, das man nachschlagen
// könnte, nur eine Beitragskarriere. Ein Plus-Knopf mit einer leeren Karte darunter wäre
// deshalb sinnlos gewesen. Weglassen ist aber keine Option: In der Schweiz hat jede Person
// eine AHV, und ohne das Element rechnet der Plan ab der Pensionierung mit einer Rente von 0.
//
// IDEMPOTENT: Wer die Bestandsaufnahme später korrigiert und erneut abschliesst, bekommt
// kein zweites Element -- geprüft wird je Person, nicht global.
for (const person of plan.persons) {
const exists = plan.elements.some((e) => e.category === "AHV" && e.ownerRole === person.role);
if (exists) continue;
const name = person.name?.trim() ? `AHV ${person.name.trim()}` : "AHV";
await api.post(`/api/scenarios/${plan.id}/elements`, {
category: "AHV",
name,
ownerRole: person.role,
});
}
}
// --- Der Dialog drumherum -------------------------------------------------------------------
//
// Gespeichert wird EINMAL, beim Abschliessen. Ein Speichern-Knopf je Element hätte bedeutet,
// dass ein Reiter-Wechsel die getippten Zahlen verwirft -- deshalb liegt der Entwurf hier
// oben und nicht in den einzelnen Karten.
export function InventoryDialog({
plan,
onClose,
onChanged,
onSaved,
}: {
plan: PlanInput;
onClose: () => void;
// Der Plan hat sich geaendert (Element angelegt oder geloescht) -- neu laden, aber den
// Dialog OFFEN lassen. Getrennt von onSaved, denn das schliesst ihn.
onChanged: () => void;
onSaved: () => void;
}) {
const [draft, setDraft] = useState<InventoryDraft>(emptyInventoryDraft);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const confirm = useConfirm();
const dirty =
Object.keys(draft.base).length > 0 ||
Object.keys(draft.names).length > 0 ||
(draft.cash !== null && draft.cash !== plan.initialCash);
async function closeGuarded() {
if (!dirty) return onClose();
const ok = await confirm({
title: "Eingaben verwerfen?",
message:
"Du hast Angaben erfasst, die noch nicht gespeichert sind. Schliesst du jetzt, gehen sie verloren. " +
"Mit «Bestandsaufnahme abschliessen» werden sie übernommen.",
confirmLabel: "Verwerfen",
danger: true,
});
if (ok) onClose();
}
async function save() {
setSaving(true);
setError(null);
try {
await saveInventoryDraft(plan, draft);
setDraft(emptyInventoryDraft());
onSaved();
} catch (e) {
setError(e instanceof Error ? e.message : "Speichern fehlgeschlagen.");
setSaving(false);
}
}
return (
<Modal
title="Bestandsaufnahme"
subtitle="Alles, was du heute hast Einkommen, Ausgaben, Guthaben, Immobilien, Schulden"
onClose={closeGuarded}
xwide
>
<div className="flex flex-col gap-4">
<div className="rounded-xl border border-border bg-surface-2 p-3 text-xs leading-relaxed text-muted">
Hier stehen nur Zahlen, die du <strong className="text-fg">nachschlagen</strong> kannst. Renditen,
Lohnentwicklung und Teuerung kommen später sie sind Annahmen, und eine Annahme gilt immer nur für
einen bestimmten Zeitraum. Du triffst sie in den Lebensphasen.
</div>
<InventoryFields
plan={plan}
draft={draft}
setDraft={(fn) => setDraft((d) => fn(d))}
onChanged={onChanged}
/>
{error && <p className="text-sm text-danger">{error}</p>}
<div className="flex items-center justify-between border-t border-border pt-3">
<Button variant="ghost" onClick={closeGuarded}>
Abbrechen
</Button>
<Button disabled={saving} onClick={save}>
<Check className="h-4 w-4" /> {saving ? "Wird gespeichert…" : "Bestandsaufnahme abschliessen"}
</Button>
</div>
</div>
</Modal>
);
}
+732
View File
@@ -0,0 +1,732 @@
"use client";
// Die Kartenansicht: eine Karte je Lebensphase (SPEZIFIKATION 3.15).
//
// Sie loest das Grundproblem der Matrix, nicht dessen Symptome. Die Matrix stellt Phasen
// NEBENEINANDER -- gut zum Vergleichen, schlecht zum Arbeiten: Ab der vierten Phase scrollt
// man horizontal, und das rechte Bearbeitungs-Panel legte sich ausgerechnet ueber die Spalte,
// zu der man gerade gescrollt hatte. Eine Karte zeigt genau eine Phase, ueber die ganze
// Breite, und getippt wird in der Karte selbst. Horizontales Scrollen gibt es nicht mehr.
//
// Die Karte erzaehlt die Phase als ZEITABLAUF in drei Abschnitten:
// 1. Eintritt -- was beim Wechsel in diese Phase geschah (Uebergang aus der Vorphase).
// Bei der ersten Phase stattdessen der Bestand aus der Bestandsaufnahme.
// 2. Laufend -- was in diesen Jahren gilt: die Elemente mit ihren Annahmen.
// 3. Ergebnis -- was am Ende dasteht.
//
// Der Uebergang gehoert bewusst zur FOLGEphase und nicht ans Ende der Vorphase: Das dabei
// frei werdende Kapital wird in der Folgephase verteilt (seit 0.41 auch in der Anzeige, Kap.
// 3.12.5). Der Preis ist, dass man den Hausverkauf gedanklich am Ende von Phase N trifft, ihn
// aber auf Karte N+1 findet -- bewusst in Kauf genommen, weil dort die Folgen stehen.
//
// Die Matrix bleibt als VERGLEICHSANSICHT bestehen (Diff, Plan/Ist, Verlauf ueber alle
// Phasen) -- dasselbe Datenmaterial, zwei Sichten.
import { useEffect, useState } from "react";
import {
AlertTriangle,
ChevronDown,
ChevronLeft,
ChevronRight,
Coins,
Maximize2,
Pencil,
PiggyBank,
Plus,
Trash2,
Wallet,
} from "lucide-react";
import { Button } from "@/components/ui";
import {
CashTransitionFields,
ElementDetail,
cashTransitionSummary,
isCashTransitionAnswered,
withCashTransitionDefaults,
type CellContext,
} from "@/components/ElementDetail";
import { api } from "@/lib/api-client";
import { CATEGORY_LABELS, ownerLabel, type CashTransitionData, type PhaseData } from "@/lib/elements";
import { capitalPot } from "@/lib/distribution";
import { formatChf } from "@/lib/format";
import { unconfirmedCells } from "@/lib/review";
import type { PhaseComputed, PlanComputed, ElementPhaseComputed } from "@/lib/calculations";
import type { ElementInput, PlanInput } from "@/lib/types";
import type { PhaseRef } from "@/lib/ratefields";
export type ValueMode = "nominal" | "both" | "real";
// Alles, was die Karte an ihre Umgebung zurueckmeldet. Bewusst ein flaches Buendel statt
// vieler Einzel-Props: Die Karte ist eine Ansicht, keine Datenhaltung.
export interface CardActions {
onEditPhase: (phaseId: string) => void;
onDeletePhase: (phaseId: string, name: string) => void;
onAddPhase: () => void;
onAddElement: () => void;
onDeleteElement: (elementId: string, name: string) => void;
onDistributeRates: (phaseId: string) => void;
onDistributeCapital: (phaseId: string) => void;
onCashTransition: (fromPhaseId: string) => void;
onOpenPhaseDetail: (phaseId: string) => void;
onChanged: () => void;
}
interface Props {
plan: PlanInput;
computed: PlanComputed;
mode: ValueMode;
activePhaseId: string | null;
onSelectPhase: (phaseId: string) => void;
phaseContext: (phase: PhaseComputed, el: ElementInput) => CellContext;
transitionContext: (fromPhase: PhaseComputed, toPhase: PhaseComputed | undefined, el: ElementInput) => CellContext;
// Elemente, die am Uebergang NACH einer Phase ueberhaupt etwas zu entscheiden haben.
transitionElements: (fromPhase: PhaseComputed, toPhase: PhaseComputed | undefined) => ElementInput[];
cashTransitionFor: (phaseId: string) => CashTransitionData;
actions: CardActions;
}
const real = (v: number, d: number) => Math.round(v / (d || 1));
function val(v: number, deflator: number, mode: ValueMode): string {
if (mode === "real") return formatChf(real(v, deflator));
if (mode === "both") return `${formatChf(v)} (${formatChf(real(v, deflator))})`;
return formatChf(v);
}
export function PhaseCards({
plan,
computed,
mode,
activePhaseId,
onSelectPhase,
phaseContext,
transitionContext,
transitionElements,
cashTransitionFor,
actions,
}: Props) {
const phases = computed.phases;
const index = Math.max(0, phases.findIndex((p) => p.id === activePhaseId));
const phase = phases[index];
// Pfeiltasten. Der dritte Weg neben Zeitachse und den Knoepfen unten -- wer eine Phase nach
// der anderen durchgeht, will die Hand nicht an der Maus haben. Eingabefelder duerfen davon
// nicht betroffen sein, sonst springt die Karte beim Bearbeiten eines Betrags weg.
useEffect(() => {
function onKey(e: KeyboardEvent) {
if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;
const t = e.target as HTMLElement | null;
if (t && (t.tagName === "INPUT" || t.tagName === "SELECT" || t.tagName === "TEXTAREA" || t.isContentEditable)) return;
if (e.metaKey || e.ctrlKey || e.altKey) return;
const next = e.key === "ArrowLeft" ? index - 1 : index + 1;
if (next < 0 || next >= phases.length) return;
e.preventDefault();
onSelectPhase(phases[next].id);
}
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [index, phases, onSelectPhase]);
if (!phase) return null;
return (
<div className="flex flex-col gap-3">
{/* Die Karte laeuft NICHT ueber die volle Breite. Eine Zeile aus 1300 px liest sich
schlecht -- das Auge verliert beim Zeilenwechsel den Anschluss, und Beschriftung und
Wert driften so weit auseinander, dass man sie nicht mehr zusammenbringt. Links und
rechts bleibt Luft, und die traegt die beiden grossen Pfeile. */}
<div className="flex items-stretch justify-center gap-3">
<SideArrow
dir="left"
label={phases[index - 1]?.name}
disabled={index === 0}
onClick={() => index > 0 && onSelectPhase(phases[index - 1].id)}
/>
<div className="min-w-0 flex-1" style={{ maxWidth: "46rem" }}>
<PhaseCard
key={phase.id}
plan={plan}
computed={computed}
phase={phase}
previous={phases[index - 1]}
mode={mode}
phaseContext={phaseContext}
transitionContext={transitionContext}
transitionElements={transitionElements}
cashTransitionFor={cashTransitionFor}
actions={actions}
/>
</div>
<SideArrow
dir="right"
label={phases[index + 1]?.name}
disabled={index === phases.length - 1}
onClick={() => index < phases.length - 1 && onSelectPhase(phases[index + 1].id)}
/>
</div>
<div className="flex items-center justify-center gap-2 text-xs text-muted">
<button
type="button"
disabled={index === 0}
onClick={() => onSelectPhase(phases[index - 1].id)}
className="flex items-center gap-1 rounded-lg border border-border px-2.5 py-1.5 transition-colors hover:bg-surface-2 disabled:opacity-40"
>
<ChevronLeft className="h-4 w-4" />
{phases[index - 1]?.name ?? "Anfang"}
</button>
<span className="px-2 tabular-nums">
Phase {index + 1} von {phases.length}
</span>
{index === phases.length - 1 ? (
<button
type="button"
onClick={actions.onAddPhase}
className="flex items-center gap-1 rounded-lg border border-dashed border-accent px-2.5 py-1.5 font-medium text-accent transition-colors hover:bg-accent hover:text-accent-fg"
>
<Plus className="h-4 w-4" /> Lebensphase
</button>
) : (
<button
type="button"
onClick={() => onSelectPhase(phases[index + 1].id)}
className="flex items-center gap-1 rounded-lg border border-border px-2.5 py-1.5 transition-colors hover:bg-surface-2"
>
{phases[index + 1].name}
<ChevronRight className="h-4 w-4" />
</button>
)}
</div>
</div>
);
}
// --- Die Karte -----------------------------------------------------------------------------
function PhaseCard({
plan,
computed,
phase,
previous,
mode,
phaseContext,
transitionContext,
transitionElements,
cashTransitionFor,
actions,
}: {
plan: PlanInput;
computed: PlanComputed;
phase: PhaseComputed;
previous: PhaseComputed | undefined;
mode: ValueMode;
phaseContext: (phase: PhaseComputed, el: ElementInput) => CellContext;
transitionContext: (fromPhase: PhaseComputed, toPhase: PhaseComputed | undefined, el: ElementInput) => CellContext;
transitionElements: (fromPhase: PhaseComputed, toPhase: PhaseComputed | undefined) => ElementInput[];
cashTransitionFor: (phaseId: string) => CashTransitionData;
actions: CardActions;
}) {
// Welche Zeilen sind aufgeklappt? Beim Wechsel der Karte faengt es wieder bei zu an --
// die Karte wird ueber `key` neu montiert.
const [open, setOpen] = useState<Record<string, boolean>>({});
const rows = phase.elements.filter((e) => e.status !== "SOLD" || e.startValue !== 0);
const openCount = unconfirmedCells(plan, computed, phase.id).length;
const ratesOpen = plan.phases.find((p) => p.id === phase.id)?.ratesConfirmed !== true;
const pot = capitalPot(phase);
const capitalOpen = pot.total > 0 && pot.allocatedInvestments + pot.allocatedRepayments === 0;
const transEls = previous ? transitionElements(previous, phase) : [];
const ct = previous ? cashTransitionFor(previous.id) : null;
const cashOpen = ct ? !isCashTransitionAnswered(ct) : false;
// Ein Knopf fuer alle Zeilen der Karte. Bei acht Elementen ist das der Unterschied zwischen
// «einmal durchsehen» und acht Klicks.
const allKeys = [...transEls.map((e) => `t:${e.id}`), ...rows.map((e) => `p:${e.elementId}`)];
const anyOpen = allKeys.some((k) => open[k]);
const toggleAll = () =>
setOpen(anyOpen ? {} : Object.fromEntries(allKeys.map((k) => [k, true])));
const dS = phase.cumulativeInflationStart;
const dE = phase.cumulativeInflationEnd;
const years =
plan.startYear != null
? (() => {
const before = computed.phases
.slice(0, computed.phases.findIndex((p) => p.id === phase.id))
.reduce((s, p) => s + p.durationYears, 0);
return `${plan.startYear + before}${plan.startYear + before + phase.durationYears - 1}`;
})()
: null;
return (
<article className="ui-fade overflow-hidden rounded-2xl border border-border bg-surface shadow-sm">
{/* Kopf: ruhig und gross. Er beantwortet «wo bin ich» in einem Blick. */}
<header className="flex flex-wrap items-start gap-3 px-5 py-4">
<div className="min-w-0">
<h2 className="truncate text-lg font-semibold text-fg">{phase.name}</h2>
<p className="mt-0.5 text-xs text-muted">
{years ? `${years} · ` : ""}
{phase.durationYears} {phase.durationYears === 1 ? "Jahr" : "Jahre"}
{phase.persons.length > 0 ? " · " : ""}
{phase.persons
.map((p) => `${ownerLabel(plan.persons, p.role)} ${p.startAge}${p.endAge}`)
.join(", ")}
</p>
</div>
<div className="ml-auto flex flex-wrap items-center gap-1.5">
{openCount + (ratesOpen ? 1 : 0) + (cashOpen ? 1 : 0) > 0 && (
<span className="flex items-center gap-1 rounded-full bg-attention-soft px-2 py-0.5 text-[11px] font-semibold text-attention-soft-fg">
<AlertTriangle className="h-3 w-3" />
{openCount + (ratesOpen ? 1 : 0) + (cashOpen ? 1 : 0)} offen
</span>
)}
{allKeys.length > 0 && (
<button
type="button"
onClick={toggleAll}
className="rounded-lg border border-border px-2 py-1 text-[11px] font-medium text-muted transition-colors hover:bg-surface-2 hover:text-fg"
>
{anyOpen ? "Alle zuklappen" : "Alle aufklappen"}
</button>
)}
<IconBtn label="Bezeichnung und Dauer ändern" onClick={() => actions.onEditPhase(phase.id)}>
<Pencil className="h-4 w-4" />
</IconBtn>
<IconBtn label="Detailansicht" onClick={() => actions.onOpenPhaseDetail(phase.id)}>
<Maximize2 className="h-4 w-4" />
</IconBtn>
{phase.sequenceNumber === computed.phases.length && (
<IconBtn label="Lebensphase löschen" danger onClick={() => actions.onDeletePhase(phase.id, phase.name)}>
<Trash2 className="h-4 w-4" />
</IconBtn>
)}
</div>
</header>
{/* 1 · EINTRITT */}
<Section
step={1}
title={previous ? "Eintritt" : "Start"}
hint={
previous
? `Was beim Wechsel aus «${previous.name}» geschah`
: "Der Bestand bei Planbeginn erfasst in der Bestandsaufnahme"
}
>
{!previous ? (
<p className="px-1 py-2 text-xs text-muted">
Diese Phase beginnt mit dem heutigen Stand. Startwerte änderst du in der
Bestandsaufnahme oder über die Zeile selbst.
</p>
) : (
<>
{ct && (
// Cash verhielt sich bis 0.42.1 als einziger Eintritts-Punkt anders: Ein Klick
// oeffnete das rechte Panel statt aufzuklappen. Auf einer Karte, die genau davon
// wegwollte, war das ein Fremdkoerper.
<Row
open={!!open["cash"]}
onToggle={() => setOpen((pr) => ({ ...pr, cash: !pr.cash }))}
icon={<Wallet className="h-4 w-4" />}
name="Cash"
meta="einmalige Sonderein-/ausgaben"
attention={cashOpen}
right={<span className="tabular-nums">{cashTransitionSummary(ct)}</span>}
>
<CashTransitionEditor
fromPhaseId={previous.id}
initial={ct}
deflatorEnd={previous.cumulativeInflationEnd}
onSaved={actions.onChanged}
/>
</Row>
)}
{transEls.length === 0 && !cashOpen && (
<p className="px-1 py-2 text-xs text-muted">An diesem Übergang gibt es nichts zu entscheiden.</p>
)}
{transEls.map((el) => {
const key = `t:${el.id}`;
const ctx = transitionContext(previous, phase, el);
return (
<Row
key={key}
open={!!open[key]}
onToggle={() => setOpen((p) => ({ ...p, [key]: !p[key] }))}
icon={<Coins className="h-4 w-4" />}
name={el.name}
meta={`${CATEGORY_LABELS[el.category]} · ${ownerLabel(plan.persons, el.ownerRole)}`}
right={null}
>
<ElementDetail
compact
element={el}
context={ctx}
phaseData={el.phaseValues[phase.id] ?? {}}
transitionData={el.transitionValues[previous.id] ?? {}}
onSaved={actions.onChanged}
onDeleteElement={() => actions.onDeleteElement(el.id, el.name)}
/>
</Row>
);
})}
<div className="pt-1">
<QuotaButton
label="Kapital verteilen"
open={capitalOpen}
hidden={!pot.total && !pot.allocatedInvestments && !pot.allocatedRepayments}
sub={`${formatChf(pot.total)} verfügbar · ${formatChf(pot.rest)} bleibt Cash`}
onClick={() => actions.onDistributeCapital(phase.id)}
/>
</div>
</>
)}
</Section>
{/* 2 · LAUFEND */}
<Section
step={2}
title="Laufend"
hint={`Was in diesen ${phase.durationYears} Jahren gilt`}
action={
<button
type="button"
onClick={actions.onAddElement}
className="flex items-center gap-1 rounded-lg border border-border px-2 py-1 text-[11px] font-medium text-muted transition-colors hover:bg-surface-2 hover:text-fg"
>
<Plus className="h-3.5 w-3.5" /> Element
</button>
}
>
{rows.length === 0 && (
<p className="px-1 py-2 text-xs text-muted">Noch keine Elemente erfasst.</p>
)}
{rows.map((ce) => {
const el = plan.elements.find((x) => x.id === ce.elementId);
if (!el) return null;
const key = `p:${el.id}`;
const isOpen = unconfirmedCells(plan, computed, phase.id).includes(el.id);
return (
<Row
key={key}
open={!!open[key]}
onToggle={() => setOpen((p) => ({ ...p, [key]: !p[key] }))}
icon={<PiggyBank className="h-4 w-4" />}
name={el.name}
meta={`${CATEGORY_LABELS[el.category]} · ${ownerLabel(plan.persons, el.ownerRole)}`}
attention={isOpen}
right={<CellValue ce={ce} dS={dS} dE={dE} mode={mode} />}
>
<ElementDetail
compact
element={el}
context={phaseContext(phase, el)}
phaseData={el.phaseValues[phase.id] ?? {}}
transitionData={{}}
allPhases={plan.phases.map<PhaseRef>((p) => ({ id: p.id, name: p.name, sequenceNumber: p.sequenceNumber }))}
phaseDataByPhase={Object.fromEntries(
plan.phases.map((p) => [p.id, (el.phaseValues[p.id] ?? {}) as PhaseData])
)}
onSaved={actions.onChanged}
onDeleteElement={() => actions.onDeleteElement(el.id, el.name)}
/>
</Row>
);
})}
<div className="pt-1">
<QuotaButton
label={phase.isConsumption ? "Bezug verteilen" : "Sparquote verteilen"}
open={ratesOpen}
sub={`${phase.isConsumption ? "Verzehrquote" : "Sparquote"} ${val(phase.quotaStart, dS, mode)}${val(
phase.quotaEnd,
phase.flowDeflatorEnd,
mode
)}`}
onClick={() => actions.onDistributeRates(phase.id)}
/>
</div>
</Section>
{/* 3 · ERGEBNIS */}
<Section step={3} title="Ergebnis" hint="Stand am Ende dieser Phase" tone="result">
<div className="grid gap-x-6 gap-y-1.5 sm:grid-cols-2">
<Figure label="Vermögen" from={val(phase.startWealthNominal, dS, mode)} to={val(phase.endWealthNominal, dE, mode)} strong />
<Figure
label="Cash"
from={val(phase.cashStart, dS, mode)}
to={val(phase.cashEnd, dE, mode)}
danger={phase.cashNegative}
/>
</div>
{phase.cashNegative && (
<p className="mt-2 flex items-start gap-2 text-xs text-danger">
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
Das Cash-Konto fällt in dieser Phase unter 0 eine Liquiditätslücke.
</p>
)}
</Section>
</article>
);
}
// --- Bausteine -----------------------------------------------------------------------------
function IconBtn({
label,
onClick,
danger,
children,
}: {
label: string;
onClick: () => void;
danger?: boolean;
children: React.ReactNode;
}) {
return (
<button
type="button"
aria-label={label}
title={label}
onClick={onClick}
className={`rounded-lg p-1.5 text-faint transition-colors ${
danger ? "hover:bg-danger-soft hover:text-danger" : "hover:bg-surface-2 hover:text-fg"
}`}
>
{children}
</button>
);
}
// Ein Akt der Karte. Die drei sind bewusst deutlich voneinander abgesetzt: ein getoenter
// Kopfstreifen ueber die volle Breite, eine Nummer im Kreis, darunter der Inhalt. Beim ersten
// Anlauf trennte sie nur eine Haarlinie -- man las die Karte als eine lange Liste statt als
// drei Schritte.
function Section({
step,
title,
hint,
action,
tone = "plain",
children,
}: {
step: number;
title: string;
hint: string;
action?: React.ReactNode;
// "plain" = weisser Inhalt, "result" = durchgehend getoent (der Schlusspunkt).
tone?: "plain" | "result";
children: React.ReactNode;
}) {
return (
<section className={`border-t-2 border-border ${tone === "result" ? "bg-surface-2" : ""}`}>
<div className={`flex items-center gap-2 px-5 py-2 ${tone === "result" ? "" : "bg-surface-2/70"}`}>
<span
className={`flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-[10px] font-semibold ${
tone === "result" ? "bg-accent text-accent-fg" : "bg-surface text-muted ring-1 ring-border"
}`}
>
{step}
</span>
<span className="text-xs font-semibold uppercase tracking-wide text-fg">{title}</span>
<span className="min-w-0 truncate text-[11px] text-faint">{hint}</span>
{action && <span className="ml-auto shrink-0">{action}</span>}
</div>
<div className="px-5 py-2.5">{children}</div>
</section>
);
}
// Der grosse Pfeil neben der Karte. Er sitzt in der Luft, die durch die begrenzte Kartenbreite
// ohnehin entsteht -- ein Bedienelement, das keinen zusaetzlichen Platz kostet.
function SideArrow({
dir,
label,
disabled,
onClick,
}: {
dir: "left" | "right";
label?: string;
disabled: boolean;
onClick: () => void;
}) {
return (
<button
type="button"
disabled={disabled}
onClick={onClick}
aria-label={dir === "left" ? `Zurück zu ${label ?? "vorheriger Phase"}` : `Weiter zu ${label ?? "nächster Phase"}`}
title={label}
className="hidden w-12 shrink-0 items-center justify-center rounded-2xl border border-transparent text-faint transition-colors hover:border-border hover:bg-surface hover:text-accent disabled:pointer-events-none disabled:opacity-0 lg:flex"
>
{dir === "left" ? <ChevronLeft className="h-7 w-7" /> : <ChevronRight className="h-7 w-7" />}
</button>
);
}
// Eine aufklappbare Zeile. Zugeklappt ist sie eine Zusammenfassung, aufgeklappt der ganze
// Editor -- und zwar IN der Karte. Kein Panel legt sich mehr ueber das, was man bearbeitet.
function Row({
open,
onToggle,
icon,
name,
meta,
right,
attention,
children,
}: {
open: boolean;
onToggle: () => void;
icon: React.ReactNode;
name: string;
meta: string;
right: React.ReactNode;
attention?: boolean;
children: React.ReactNode;
}) {
return (
<div className={`border-b border-border last:border-b-0 ${open ? "bg-surface-2/40" : ""}`}>
<button
type="button"
onClick={onToggle}
aria-expanded={open}
className="flex w-full items-center gap-2.5 py-2 text-left transition-colors hover:bg-surface-2/60"
>
<span className={`h-1.5 w-1.5 shrink-0 rounded-full ${attention ? "bg-attention" : "bg-success"}`} />
<span className={attention ? "text-attention" : "text-faint"}>{icon}</span>
<span className="min-w-0">
<span className="block truncate text-sm text-fg">{name}</span>
<span className="block truncate text-[11px] text-faint">{meta}</span>
</span>
<span className="ml-auto shrink-0 text-xs text-muted">{right}</span>
<ChevronDown className={`h-4 w-4 shrink-0 text-faint transition-transform ${open ? "rotate-180" : ""}`} />
</button>
{open && <div className="pb-4 pl-6 pr-1">{children}</div>}
</div>
);
}
// Der Cash-Uebergang in der Karte. Dieselben Felder wie im Panel (`CashTransitionFields`),
// nur mit eigenem Speichern -- eine zweite Implementierung der Felder waere garantiert
// auseinandergelaufen.
function CashTransitionEditor({
fromPhaseId,
initial,
deflatorEnd,
onSaved,
}: {
fromPhaseId: string;
initial: CashTransitionData;
deflatorEnd: number;
onSaved: () => void;
}) {
// Vorbelegen, damit ein blosses Speichern den sichtbaren Default (1:1) festhaelt.
const [ct, setCt] = useState<CashTransitionData>(() => withCashTransitionDefaults(initial));
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
async function save() {
setSaving(true);
setError(null);
try {
await api.put(`/api/phases/${fromPhaseId}/cash-transition`, ct);
onSaved();
} catch (e) {
setError(e instanceof Error ? e.message : "Speichern fehlgeschlagen.");
} finally {
setSaving(false);
}
}
return (
<div className="flex flex-col gap-3">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<CashTransitionFields ct={ct} setC={(patch) => setCt((prev) => ({ ...prev, ...patch }))} deflatorEnd={deflatorEnd} />
</div>
{error && <p className="text-sm text-danger">{error}</p>}
<div>
<Button disabled={saving} onClick={save}>
{saving ? "Speichern…" : "Speichern"}
</Button>
</div>
</div>
);
}
function CellValue({
ce,
dS,
dE,
mode,
}: {
ce: ElementPhaseComputed;
dS: number;
dE: number;
mode: ValueMode;
}) {
if (ce.summary) return <span className="tabular-nums">{ce.summary}</span>;
if (ce.note) return <span>{ce.note}</span>;
return (
<span className="whitespace-nowrap tabular-nums">
{val(ce.startValue, dS, mode)} <span className="text-faint"></span> {val(ce.endValue, dE, mode)}
</span>
);
}
function Figure({
label,
from,
to,
strong,
danger,
}: {
label: string;
from: string;
to: string;
strong?: boolean;
danger?: boolean;
}) {
return (
<div className="flex items-baseline justify-between gap-3 border-b border-border/60 py-1 last:border-b-0">
<span className="text-xs text-muted">{label}</span>
<span
className={`whitespace-nowrap tabular-nums ${strong ? "text-sm font-semibold" : "text-xs"} ${
danger ? "text-danger" : "text-fg"
}`}
>
{from} <span className="text-faint"></span> {to}
</span>
</div>
);
}
// Der Weg in einen Verteil-Dialog. Gruen mit Haken, sobald erledigt -- dieselbe Sprache wie
// im Phasenkopf der Matrix (SPEZIFIKATION 3.6.9).
function QuotaButton({
label,
sub,
open,
hidden,
onClick,
}: {
label: string;
sub: string;
open: boolean;
hidden?: boolean;
onClick: () => void;
}) {
if (hidden) return null;
return (
<Button variant={open ? "primary" : "ghost"} onClick={onClick} className="w-full justify-between">
<span className="flex flex-col items-start">
<span>
{label}
{open ? "" : " ✓"}
</span>
<span className="text-[11px] font-normal opacity-80">{sub}</span>
</span>
<ChevronRight className="h-4 w-4" />
</Button>
);
}
File diff suppressed because it is too large Load Diff
+121 -49
View File
@@ -60,6 +60,7 @@ export function RetirementFields({
if (el.category === "PILLAR_3A") if (el.category === "PILLAR_3A")
return ( return (
<Pillar3aBlock <Pillar3aBlock
plan={plan}
el={el} el={el}
rd={rd} rd={rd}
patch={patch} patch={patch}
@@ -99,9 +100,12 @@ export function Pillar({
{open ? <ChevronDown className="h-4 w-4 text-faint" /> : <ChevronRight className="h-4 w-4 text-faint" />} {open ? <ChevronDown className="h-4 w-4 text-faint" /> : <ChevronRight className="h-4 w-4 text-faint" />}
<span className="text-sm font-semibold text-fg">{title}</span> <span className="text-sm font-semibold text-fg">{title}</span>
<span className="flex-1 truncate text-xs text-muted">{subtitle}</span> <span className="flex-1 truncate text-xs text-muted">{subtitle}</span>
{/* Dieselbe Vokabel wie bei den Phasenzellen (SPEZIFIKATION 3.14.2): «angeschaut».
Es ist derselbe Gedanke -- dort auf den Werten der Vorphase, hier auf einer Vorgabe
des Tools -- und zwei Wörter für eine Sache haben nur verwirrt. */}
{confirmed ? ( {confirmed ? (
<span className="flex items-center gap-1 whitespace-nowrap text-[11px] font-semibold text-success"> <span className="flex items-center gap-1 whitespace-nowrap text-[11px] font-semibold text-success">
<Check className="h-3.5 w-3.5" /> bestätigt <Check className="h-3.5 w-3.5" /> angeschaut
</span> </span>
) : ( ) : (
<span className="flex items-center gap-1 whitespace-nowrap rounded-full border border-border px-2 py-0.5 text-[11px] text-muted"> <span className="flex items-center gap-1 whitespace-nowrap rounded-full border border-border px-2 py-0.5 text-[11px] text-muted">
@@ -112,7 +116,7 @@ export function Pillar({
{open && ( {open && (
<div className="border-t border-border px-3 py-3"> <div className="border-t border-border px-3 py-3">
<div className="grid gap-3 sm:grid-cols-2">{children}</div> <div className="grid gap-3 sm:grid-cols-2">{children}</div>
<label className="mt-3 flex cursor-pointer items-start gap-2 text-xs text-muted"> <label className="mt-3 flex cursor-pointer items-start gap-2 rounded-lg bg-surface-2 px-3 py-2 text-xs text-muted">
<input <input
type="checkbox" type="checkbox"
checked={confirmed} checked={confirmed}
@@ -120,8 +124,11 @@ export function Pillar({
className="mt-0.5" className="mt-0.5"
/> />
<span> <span>
Ich habe das angeschaut und bestätige es. Ohne Häkchen rechnet das Tool mit der Vorgabe sie wird als <strong className="text-fg">Angeschaut.</strong> FPT muss hier etwas rechnen, auch bevor du entschieden
«ungeprüft» ausgewiesen, damit sie nicht unbemerkt durchgeht. hast deshalb gibt es eine Vorgabe (volle Rente, Bezug im Pensionsjahr). Der Haken hält fest, dass du
sie gesehen und für richtig befunden hast. <strong className="text-fg">Er ändert keine Zahl.</strong>{" "}
Ohne ihn rechnet FPT genauso weiter, weist die Vorgabe aber als «ungeprüft» aus, damit sie nicht
unbemerkt durchgeht.
</span> </span>
</label> </label>
</div> </div>
@@ -232,7 +239,6 @@ export function PkBlock({
defaultOpen?: boolean; defaultOpen?: boolean;
}) { }) {
const share = rd.capitalSharePct ?? 0; const share = rd.capitalSharePct ?? 0;
const targets = plan.elements.filter((e) => e.category === "OTHER_ASSET");
return ( return (
<Pillar <Pillar
@@ -261,7 +267,12 @@ export function PkBlock({
step={5} step={5}
value={share} value={share}
onChange={(e) => patch(el.id, { capitalSharePct: Number(e.target.value) })} onChange={(e) => patch(el.id, { capitalSharePct: Number(e.target.value) })}
className="h-2 flex-1 cursor-pointer appearance-none rounded-full bg-surface-2 accent-[var(--accent)]" // KEIN appearance-none: Damit zeichnet der Browser die Schiene nicht mehr selbst,
// und accent-color greift nicht -- uebrig blieb der blosse Knopf. Im warmen Schema
// war die Schiene dadurch unsichtbar.
// Gleiche Schreibweise wie der Regler der Live-Simulation, der immer funktioniert
// hat: keine erzwungene Hoehe, kein appearance-none.
className="flex-1 cursor-pointer accent-[var(--accent)]"
/> />
<span className="w-28 whitespace-nowrap text-right text-sm tabular-nums text-fg"> <span className="w-28 whitespace-nowrap text-right text-sm tabular-nums text-fg">
{share} % Kapital {share} % Kapital
@@ -273,6 +284,9 @@ export function PkBlock({
</div> </div>
</div> </div>
{/* Bei 100 % Kapital wird nichts verrentet -- dann ist der Satz gegenstandslos, genau wie
die Kapitalbezugssteuer bei 0 % Kapital. */}
{share < 100 && (
<NumberField <NumberField
label="Umwandlungssatz (%)" label="Umwandlungssatz (%)"
help="Aus deinem PK-Ausweis. Der gesetzliche Mindestsatz von 6,8 % gilt nur für den obligatorischen Teil umhüllende Kassen liegen effektiv oft bei 5 bis 5,6 %, bei einer Frühpensionierung tiefer." help="Aus deinem PK-Ausweis. Der gesetzliche Mindestsatz von 6,8 % gilt nur für den obligatorischen Teil umhüllende Kassen liegen effektiv oft bei 5 bis 5,6 %, bei einer Frühpensionierung tiefer."
@@ -282,6 +296,7 @@ export function PkBlock({
max={20} max={20}
onChange={(v) => patch(el.id, { conversionRate: v })} onChange={(v) => patch(el.id, { conversionRate: v })}
/> />
)}
{share > 0 && ( {share > 0 && (
<NumberField <NumberField
label="Kapitalbezugssteuer (%)" label="Kapitalbezugssteuer (%)"
@@ -309,7 +324,9 @@ export function PkBlock({
</span> </span>
</label> </label>
{rd.recentBuyIn && ( {rd.recentBuyIn && (
<p className="mt-1 flex items-start gap-2 rounded-lg border border-attention bg-attention/10 px-3 py-2 text-xs text-attention-fg"> // attention-fg ist WEISS -- gedacht fuer vollflaechigen Attention-Grund. Auf
// 10 % Deckkraft war der Text im warmen und im hellen Schema unlesbar.
<p className="mt-1 flex items-start gap-2 rounded-lg border border-attention bg-attention-soft px-3 py-2 text-xs text-attention-soft-fg">
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" /> <AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
Ein Kapitalbezug innerhalb von {PK_BUYIN_BLOCKING_YEARS} Jahren nach einem Einkauf lässt den Ein Kapitalbezug innerhalb von {PK_BUYIN_BLOCKING_YEARS} Jahren nach einem Einkauf lässt den
Steuerabzug für diesen Einkauf nachträglich entfallen (Art. 79b Abs. 3 BVG). Das Tool rechnet diesen Steuerabzug für diesen Einkauf nachträglich entfallen (Art. 79b Abs. 3 BVG). Das Tool rechnet diesen
@@ -317,7 +334,7 @@ export function PkBlock({
</p> </p>
)} )}
</div> </div>
<CapitalUse rd={rd} elementId={el.id} patch={patch} targets={targets} plan={plan} /> <CapitalUse rd={rd} elementId={el.id} patch={patch} plan={plan} />
</> </>
)} )}
</Pillar> </Pillar>
@@ -325,6 +342,7 @@ export function PkBlock({
} }
export function Pillar3aBlock({ export function Pillar3aBlock({
plan,
el, el,
rd, rd,
patch, patch,
@@ -332,6 +350,7 @@ export function Pillar3aBlock({
siblings, siblings,
defaultOpen, defaultOpen,
}: { }: {
plan: PlanInput;
el: ElementInput; el: ElementInput;
rd: RetirementDecision; rd: RetirementDecision;
patch: (elementId: string, p: Partial<RetirementDecision>) => void; patch: (elementId: string, p: Partial<RetirementDecision>) => void;
@@ -379,90 +398,143 @@ export function Pillar3aBlock({
)} )}
</div> </div>
{clash && ( {clash && (
<p className="sm:col-span-2 flex items-start gap-2 text-xs text-attention-fg"> <p className="sm:col-span-2 flex items-start gap-2 text-xs text-attention-soft-fg">
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" /> <AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
Ein weiteres 3a-Konto wird im selben Jahr bezogen. Die Beträge werden steuerlich zusammengezählt ein Ein weiteres 3a-Konto wird im selben Jahr bezogen. Die Beträge werden steuerlich zusammengezählt ein
anderes Bezugsjahr senkt die Progression. anderes Bezugsjahr senkt die Progression.
</p> </p>
)} )}
{/* Die Frage stellt sich hier sogar zwingender als bei der PK: Ein 3a-Konto wird IMMER
vollständig bezogen, es fliesst also in jedem Fall ein grosser Betrag. Bis 0.40 gab
es die Maske nur bei der Pensionskasse -- das 3a-Kapital landete stumm auf dem Cash. */}
<CapitalUse rd={rd} elementId={el.id} patch={patch} plan={plan} />
</Pillar> </Pillar>
); );
} }
// Eine Zeile der Kapitalverwendung: Quote plus, sobald sie > 0 ist, das Ziel. Eigene Komponente
// auf Modulebene -- innerhalb von CapitalUse deklariert wuerde sie bei jedem Tastendruck neu
// erzeugt und verloere ihren Zustand (der React Compiler weist das zu Recht zurueck).
function CapitalUseRow({
title,
help,
value,
max,
onChange,
targets,
targetValue,
onTarget,
targetLabel,
missing,
label,
}: {
title: string;
help: string;
value: number;
max: number;
onChange: (v: number) => void;
targets: ElementInput[];
targetValue?: string;
onTarget: (id: string) => void;
targetLabel: string;
missing: string;
label: (e: ElementInput) => string;
}) {
return (
<div className="rounded-lg border border-border p-3">
<div className="grid gap-3 sm:grid-cols-2">
<NumberField label={title} help={help} value={value} step={5} min={0} max={max} onChange={onChange} />
{value > 0 && targets.length > 0 && (
<SelectField
label={targetLabel}
value={targetValue ?? targets[0].id}
onChange={(v: string) => onTarget(v)}
options={targets.map((t) => ({ value: t.id, label: label(t) }))}
/>
)}
{value > 0 && targets.length === 0 && (
<p className="flex items-start gap-2 self-end text-xs text-attention-soft-fg">
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
{missing}
</p>
)}
</div>
</div>
);
}
// Verwendung des bezogenen Kapitals (Punkt C). Dieselbe Frage wie in der Matrix-Zelle -- hier // Verwendung des bezogenen Kapitals (Punkt C). Dieselbe Frage wie in der Matrix-Zelle -- hier
// nur an dem Ort, an dem man ohnehin über den Bezug nachdenkt. // nur an dem Ort, an dem man ohnehin über den Bezug nachdenkt.
//
// Drei gleichrangige Zeilen: Schuldentilgung, Investition, Cash. Cash ist rechnerisch der
// REST, wird aber wie eine Wahl dargestellt und nicht wie eine Fussnote -- «ich lasse es
// liegen und entscheide später» ist ein legitimer Entscheid. Alle drei frei eintippbar zu
// machen hiesse, dass beim Tippen still eine der anderen Zahlen wandert; das überrascht mehr,
// als es hilft.
function CapitalUse({ function CapitalUse({
rd, rd,
elementId, elementId,
patch, patch,
targets,
plan, plan,
}: { }: {
rd: RetirementDecision; rd: RetirementDecision;
elementId: string; elementId: string;
patch: (elementId: string, p: Partial<RetirementDecision>) => void; patch: (elementId: string, p: Partial<RetirementDecision>) => void;
targets: ElementInput[];
plan: PlanInput; plan: PlanInput;
}) { }) {
const amort = Math.max(0, Math.min(100, rd.capitalUseAmortizationPct ?? 0)); const amort = Math.max(0, Math.min(100, rd.capitalUseAmortizationPct ?? 0));
const invest = Math.max(0, Math.min(100 - amort, rd.capitalUseInvestPct ?? 0)); const invest = Math.max(0, Math.min(100 - amort, rd.capitalUseInvestPct ?? 0));
const cash = Math.max(0, 100 - amort - invest); const cash = Math.max(0, 100 - amort - invest);
const hasMortgage = plan.elements.some((e) => e.category === "REAL_ESTATE");
// Tilgungs-Ziele: Hypotheken UND sonstige Schulden. Wer einen Konsumkredit zu 6 % neben
// einer Hypothek zu 1,5 % trägt, tilgt zuerst den Kredit.
const debtTargets = plan.elements.filter((e) => e.category === "REAL_ESTATE" || e.category === "OTHER_DEBT");
const investTargets = plan.elements.filter((e) => e.category === "OTHER_ASSET");
const label = (e: ElementInput) => `${e.name} · ${ownerLabel(plan.persons, e.ownerRole)}`;
return ( return (
<>
<div className="sm:col-span-2 mt-1 border-t border-border pt-3"> <div className="sm:col-span-2 mt-1 border-t border-border pt-3">
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-faint"> <p className="mb-2 text-xs font-semibold uppercase tracking-wide text-faint">
Wohin fliesst das bezogene Kapital? Wohin fliesst das bezogene Kapital?
</p> </p>
<div className="grid gap-3 sm:grid-cols-2"> <div className="flex flex-col gap-2">
<NumberField <CapitalUseRow
label="… in die Hypothek (%)" label={label}
help={ title="Schuldentilgung (%)"
hasMortgage help="Einmalige Tilgung. Am Restsaldo gekappt ist die Schuld kleiner als der Anteil, bleibt der Rest Cash."
? "Einmalige Amortisation. Am Restsaldo gekappt ist die Hypothek kleiner, bleibt der Rest Cash."
: "Es gibt keine Immobilie in diesem Szenario dieser Anteil bliebe wirkungslos auf dem Cash."
}
value={amort} value={amort}
step={5}
min={0}
max={100} max={100}
onChange={(v) => patch(elementId, { capitalUseAmortizationPct: Math.max(0, Math.min(100, v)) })} onChange={(v) => patch(elementId, { capitalUseAmortizationPct: Math.max(0, Math.min(100, v)) })}
targets={debtTargets}
targetValue={rd.capitalUseDebtTargetElementId}
onTarget={(v) => patch(elementId, { capitalUseDebtTargetElementId: v })}
targetLabel="Welche Schuld?"
missing="Es gibt weder Immobilie noch Schuld in diesem Szenario der Anteil bliebe auf dem Cash-Konto."
/> />
<NumberField <CapitalUseRow
label="… in die Anlage (%)" label={label}
title="Investition (%)"
help="Fliesst als Zusatzeinlage in das gewählte Vermögens-Element und wächst dort weiter." help="Fliesst als Zusatzeinlage in das gewählte Vermögens-Element und wächst dort weiter."
value={invest} value={invest}
step={5}
min={0}
max={100 - amort} max={100 - amort}
onChange={(v) => patch(elementId, { capitalUseInvestPct: Math.max(0, Math.min(100 - amort, v)) })} onChange={(v) => patch(elementId, { capitalUseInvestPct: Math.max(0, Math.min(100 - amort, v)) })}
targets={investTargets}
targetValue={rd.capitalUseTargetElementId}
onTarget={(v) => patch(elementId, { capitalUseTargetElementId: v })}
targetLabel="Wohin investieren?"
missing="Es gibt kein Element «Sonstiges Vermögen» der Anteil bliebe auf dem Cash-Konto."
/> />
{invest > 0 && targets.length > 0 && ( {/* Cash ist der Rest, aber als vollwertige Zeile dargestellt. */}
<div className="sm:col-span-2"> <div className="rounded-lg border border-dashed border-border bg-surface-2 p-3">
<SelectField <div className="flex items-baseline justify-between gap-3">
label="Ziel der Anlage-Quote" <span className="text-xs font-medium text-muted">Cash (%)</span>
value={rd.capitalUseTargetElementId ?? targets[0].id} <span className="text-sm font-semibold tabular-nums text-fg">{cash} %</span>
onChange={(v: string) => patch(elementId, { capitalUseTargetElementId: v })}
options={targets.map((t) => ({
value: t.id,
label: `${t.name} · ${ownerLabel(plan.persons, t.ownerRole)}`,
}))}
/>
</div> </div>
)} <p className="mt-1 text-[11px] text-faint">
{invest > 0 && targets.length === 0 && ( Was du nicht zuteilst, bleibt auf dem Cash-Konto unverzinst, aber jederzeit verfügbar.
<p className="sm:col-span-2 flex items-start gap-2 text-xs text-attention-fg">
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
Es gibt kein Element «Sonstiges Vermögen», in das die Anlage-Quote fliessen könnte. Der Betrag bliebe auf
dem Cash-Konto liegen.
</p>
)}
<p className="sm:col-span-2 text-xs text-faint">
Nicht zugeteilt: <strong className="text-fg">{cash} %</strong> bleibt auf dem Cash-Konto.
</p> </p>
</div> </div>
</div> </div>
</> </div>
); );
} }
-266
View File
@@ -1,266 +0,0 @@
"use client";
// Der Pensionierungs-Bildschirm.
//
// Bis 0.33 lagen die Entscheide, die inhaltlich EINE Frage sind, in drei weit auseinander
// liegenden Matrix-Zellen (AHV, PK, 3a) am Pensions-Übergang. Hier stehen sie beieinander --
// in der Reihenfolge, in der man tatsächlich darüber nachdenkt:
//
// Wann höre ich auf? → Was kommt dann rein? → Was habe ich auf einen Schlag?
// → Reicht das? → Was mache ich mit dem Haufen?
//
// Die Leitzahl ganz oben ist die RENTENLÜCKE. Sie ist keine neue Rechnung, sondern die
// Verzehrquote im ersten voll pensionierten Jahr -- es fehlte bisher nur der Name dafür.
// Gerechnet wird sie im Rechenkern (`computed.retirement`), damit Bildschirm und PDF-Bericht
// nicht auseinanderlaufen können.
//
// Gestaltungsprinzip: KEIN leeres Formular, sondern ein vollständiger Vorschlag, den man
// korrigiert. Sonst müsste man am Anfang Fragen beantworten, die man erst am Ende beantworten
// kann -- und ohne Vorgaben wäre der Plan bis dahin gar nicht rechenbar.
import { useState } from "react";
import { AlertTriangle } from "lucide-react";
import { api } from "@/lib/api-client";
import { Button, useToast } from "@/components/ui";
import { InfoBubble } from "@/components/InfoBubble";
import { RetirementAdjuster } from "@/components/RetirementAdjuster";
import { RetirementFields } from "@/components/RetirementFields";
import { formatChf } from "@/lib/format";
import { ownerLabel } from "@/lib/elements";
import { AHV_REFERENCE_AGE, PK_MIN_RETIREMENT_AGE } from "@/lib/constants";
import { withRetirementDefaults, type RetirementDecision } from "@/lib/retirement-decision";
import type { PlanComputed, RetirementPersonSummary } from "@/lib/calculations";
import type { ElementInput, PersonRole, PlanInput } from "@/lib/types";
type Draft = Record<string, RetirementDecision>;
export function RetirementPanel({
plan,
computed,
role,
onSaved,
}: {
plan: PlanInput;
computed: PlanComputed;
// Seit 0.36 immer GENAU eine Person: Der Bildschirm wird über den Knopf «Pensionsplanung»
// in den Grundeinstellungen geöffnet, und der steht je Person.
role: PersonRole;
onSaved: () => void;
}) {
const toast = useToast();
const [draft, setDraft] = useState<Draft>({});
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
// Effektiver Entscheid je Element: gespeicherter Wert, überlagert vom Entwurf, aufgefüllt
// mit den Vorgaben. Genau das rechnet auch der Rechenkern.
const effective = (el: ElementInput): RetirementDecision => {
const person = plan.persons.find((p) => p.role === el.ownerRole);
return withRetirementDefaults(el.category, person?.retirementAge ?? AHV_REFERENCE_AGE, {
...(el.retirementDecision ?? {}),
...(draft[el.id] ?? {}),
});
};
const patch = (elementId: string, p: Partial<RetirementDecision>) =>
setDraft((d) => ({ ...d, [elementId]: { ...(d[elementId] ?? {}), ...p } }));
const dirty = Object.keys(draft).length > 0;
async function save() {
setSaving(true);
setError(null);
try {
for (const [elementId, changes] of Object.entries(draft)) {
const el = plan.elements.find((x) => x.id === elementId);
if (!el) continue;
await api.put(`/api/elements/${elementId}/retirement`, {
...(el.retirementDecision ?? {}),
...changes,
});
}
setDraft({});
toast("success", "Pensionierung gespeichert.");
onSaved();
} catch (e) {
setError(e instanceof Error ? e.message : "Speichern fehlgeschlagen.");
} finally {
setSaving(false);
}
}
return (
<div className="flex flex-col gap-5">
<p className="text-sm text-muted">
Was du hier festlegst, steht auch in der Matrix am Pensions-Übergang es ist derselbe Entscheid, nur
anders angeschaut.
</p>
<PersonBlock
plan={plan}
computed={computed}
role={role}
summary={computed.retirement.perPerson.find((s) => s.role === role)}
effective={effective}
patch={patch}
onSaved={onSaved}
/>
{error && <p className="text-sm text-danger">{error}</p>}
{dirty && (
<div className="sticky bottom-0 flex items-center gap-3 border-t border-border bg-surface px-1 py-3">
<Button disabled={saving} onClick={save}>
{saving ? "…" : "Änderungen speichern"}
</Button>
<Button variant="ghost" disabled={saving} onClick={() => setDraft({})}>
Verwerfen
</Button>
</div>
)}
</div>
);
}
// --- ein Block je Person -------------------------------------------------------------------
function PersonBlock({
plan,
computed,
role,
summary,
effective,
patch,
onSaved,
}: {
plan: PlanInput;
computed: PlanComputed;
role: PersonRole;
summary: RetirementPersonSummary | undefined;
effective: (el: ElementInput) => RetirementDecision;
patch: (elementId: string, p: Partial<RetirementDecision>) => void;
onSaved: () => void;
}) {
const person = plan.persons.find((p) => p.role === role)!;
const label = ownerLabel(plan.persons, role);
const own = (cat: string) => plan.elements.filter((e) => e.category === cat && e.ownerRole === role);
const ahvEl = own("AHV")[0];
const pkEls = own("PENSION_FUND");
const a3Els = own("PILLAR_3A");
return (
<section className="rounded-2xl border border-border">
<header className="border-b border-border px-4 py-3">
<div className="flex flex-wrap items-baseline justify-between gap-2">
<h3 className="text-base font-semibold text-fg">
{label} · Pensionierung mit {person.retirementAge}
</h3>
{person.retirementAge < PK_MIN_RETIREMENT_AGE && (
<span className="flex items-center gap-1 text-xs text-attention-fg">
<AlertTriangle className="h-3.5 w-3.5" />
Vor {PK_MIN_RETIREMENT_AGE} lässt kaum eine Pensionskasse eine Pensionierung zu.
</span>
)}
</div>
</header>
<div className="px-4 py-4">
<RetirementAdjuster plan={plan} onSaved={onSaved} />
</div>
<GapBox plan={plan} computed={computed} summary={summary} />
<div className="flex flex-col gap-2 px-4 pb-4">
{[ahvEl, ...pkEls, ...a3Els].filter(Boolean).map((el) => (
<RetirementFields key={el!.id} plan={plan} el={el!} rd={effective(el!)} patch={patch} summary={summary} />
))}
{!ahvEl && pkEls.length === 0 && a3Els.length === 0 && (
<p className="rounded-lg border border-dashed border-border bg-surface-2 p-3 text-sm text-muted">
Für {label} sind noch keine Vorsorge-Elemente erfasst. Lege in der Matrix AHV, Pensionskasse oder Säule 3a
an die Entscheide dazu erscheinen dann hier.
</p>
)}
</div>
</section>
);
}
// --- Leitzahlen ----------------------------------------------------------------------------
function GapBox({
plan,
computed,
summary,
}: {
plan: PlanInput;
computed: PlanComputed;
summary: RetirementPersonSummary | undefined;
}) {
const r = computed.retirement;
const startYear = plan.startYear ?? null;
const horizon = summary?.planningHorizonAge ?? null;
const reachesEnd = computed.ruinAge === null;
return (
<div className="mx-4 mb-4 rounded-xl border border-border bg-surface-2 p-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="flex flex-col gap-1 text-sm">
<Line label="AHV-Rente" value={summary?.ahvAnnual ?? 0} note={summary?.ahvDraw} />
<Line label="PK-Rente" value={summary?.pkPensionAnnual ?? 0} />
<div className="my-1 border-t border-border" />
<Line label="Renteneinkommen (Haushalt)" value={r.pensionIncome ?? 0} strong />
<Line label="Ausgaben (Haushalt)" value={r.expenses ?? 0} />
</div>
<div className="flex flex-col justify-center gap-3">
<div>
<div className="flex items-center text-xs uppercase tracking-wide text-faint">
Rentenlücke
<InfoBubble text="Renteneinkommen minus Ausgaben im ersten Jahr, in dem niemand mehr arbeitet. Ein negativer Wert ist normal er wird aus dem Vermögen gedeckt. Entscheidend ist, wie lange das trägt." />
</div>
<div
className={`text-2xl font-semibold tabular-nums ${
(r.gapAnnual ?? 0) < 0 ? "text-danger" : "text-success"
}`}
>
{r.gapAnnual === null ? "" : `${formatChf(r.gapAnnual)} / Jahr`}
</div>
{r.firstRetirementYear !== null && startYear && (
<div className="text-xs text-faint">
gerechnet für {startYear + r.firstRetirementYear - 1}, das erste voll pensionierte Jahr
</div>
)}
</div>
<div>
<div className="text-xs uppercase tracking-wide text-faint">Vermögen reicht</div>
<div className={`text-lg font-semibold ${reachesEnd ? "text-success" : "text-danger"}`}>
{reachesEnd
? horizon
? `bis zum Horizont (Alter ${horizon})`
: "über die ganze Planung"
: `bis Alter ${computed.ruinAge}`}
</div>
</div>
{summary && summary.capitalAtRetirement > 0 && (
<div className="text-xs text-muted">
Einmalig verfügbar: <strong className="text-fg">{formatChf(summary.capitalAtRetirement)}</strong> netto
aus PK und Säule 3a
</div>
)}
</div>
</div>
</div>
);
}
function Line({ label, value, note, strong }: { label: string; value: number; note?: string; strong?: boolean }) {
return (
<div className="flex items-baseline justify-between gap-3">
<span className={strong ? "font-semibold text-fg" : "text-muted"}>
{label}
{note && <span className="ml-1 text-xs text-faint">({note})</span>}
</span>
<span className={`tabular-nums ${strong ? "font-semibold text-fg" : "text-fg"}`}>{formatChf(value)}</span>
</div>
);
}
+114
View File
@@ -0,0 +1,114 @@
"use client";
// Die Übersicht der offenen Punkte -- oben rechts, wo bis 0.38 der Assistent stand.
//
// Sie listet keine Schritte auf, sondern zeigt einen ZUSTAND: Was ist in diesem Plan noch
// nicht entschieden oder nicht bestätigt? Der Unterschied fällt auf, sobald man einen
// bestehenden Plan öffnet -- der Assistent bot dort Schritte an, die längst erledigt waren.
//
// Zwei Sonderzustände ganz am Anfang: Ohne Elemente und ohne Lebensphasen gibt es
// naturgemäss nichts Offenes, obwohl der Plan leer ist. Eine "0" wäre dort eine Lüge; deshalb
// steht dort eine Aufforderung statt einer Zahl.
import { CheckCircle2, ClipboardList, Sparkles } from "lucide-react";
import type { PlanReview } from "@/lib/review";
export function ReviewTile({
review,
onOpenInventory,
onAddPhase,
onJump,
}: {
review: PlanReview;
onOpenInventory: () => void;
onAddPhase: () => void;
onJump: (group: { id: string; kind: "phase" | "transition" }) => void;
}) {
const done = review.stage === "DONE";
const starting = review.stage === "NO_ELEMENTS" || review.stage === "NO_PHASES";
return (
<div
data-tour="assistant"
// KEIN h-full: In der rechten Leiste ist die Kachel das einzige Element mit Inhalt --
// mit h-full zog sie sich ueber den ganzen Bildschirm, obwohl ein einziger Eintrag
// darin stand.
className={`flex flex-col rounded-xl border px-3 py-2.5 shadow-sm transition-colors ${
done ? "border-success bg-success-soft" : "border-attention bg-attention-soft"
}`}
>
<div className="mb-2 flex items-center gap-2">
{done ? (
<CheckCircle2 className="h-4 w-4 text-success" />
) : (
<ClipboardList className="h-4 w-4 text-attention" />
)}
<span className="text-xs font-semibold uppercase tracking-wide text-fg">Offene Punkte</span>
{!starting && (
<span className={`ml-auto text-[11px] font-semibold ${done ? "text-success" : "text-muted"}`}>
{done ? "keine" : review.total}
</span>
)}
</div>
{review.stage === "NO_ELEMENTS" && (
<div className="flex min-h-0 flex-1 flex-col gap-2">
<p className="text-[11px] leading-snug text-muted">
Dein Plan ist noch leer. Erfasse zuerst, was du heute hast danach planst du deine erste Lebensphase.
</p>
<button
type="button"
onClick={onOpenInventory}
className="mt-auto flex items-center justify-center gap-1.5 rounded-lg bg-accent px-2 py-1.5 text-[11px] font-semibold text-accent-fg hover:opacity-90"
>
<Sparkles className="h-3.5 w-3.5" /> Bestandsaufnahme
</button>
</div>
)}
{review.stage === "NO_PHASES" && (
<div className="flex min-h-0 flex-1 flex-col gap-2">
<p className="text-[11px] leading-snug text-muted">
Deine Bestandsaufnahme steht. Jetzt fehlt die erste Lebensphase erst dann gibt es etwas zu
entscheiden.
</p>
<button
type="button"
onClick={onAddPhase}
className="mt-auto flex items-center justify-center gap-1.5 rounded-lg bg-accent px-2 py-1.5 text-[11px] font-semibold text-accent-fg hover:opacity-90"
>
Erste Lebensphase anlegen
</button>
</div>
)}
{review.stage === "DONE" && (
<div className="flex min-h-0 flex-1 flex-col justify-center gap-1">
<p className="text-sm font-semibold text-success">Alles entschieden</p>
<p className="text-[11px] leading-snug text-muted">
In allen Lebensphasen und Übergängen ist nichts mehr offen. Änderst du etwas, erscheint es hier wieder.
</p>
</div>
)}
{review.stage === "OPEN" && (
<div className="flex min-h-0 flex-1 flex-col gap-1 overflow-auto">
{review.groups.map((g) => (
<button
key={`${g.kind}-${g.id}`}
type="button"
onClick={() => onJump(g)}
className="rounded-lg px-1.5 py-1 text-left transition-colors hover:bg-surface/60"
>
<span className="flex items-baseline gap-1.5">
<span className="min-w-0 flex-1 truncate text-xs font-semibold text-fg">{g.title}</span>
<span className="shrink-0 text-[11px] font-semibold text-attention">{g.open}</span>
</span>
<span className="block truncate text-[11px] text-muted">{g.reasons.join(" · ")}</span>
</button>
))}
</div>
)}
</div>
);
}
+159 -104
View File
@@ -11,15 +11,24 @@ interface PersonAxis {
color: string; color: string;
} }
// Horizontale Zeitachse: die Lebensphasen als aneinandergereihte Segmente (Breite = Dauer), // Die Zeitachse: ein durchgehender Balken, die Lebensphasen als Segmente, Breite nach Dauer.
// darunter Alter und -- sofern ein Planstart gesetzt ist -- die Kalenderjahre. Pensionsalter //
// je Person und ein allfälliges Ruinalter sind als Marker eingezeichnet. // Seit 0.42 ist sie zugleich der NAVIGATOR der Kartenansicht -- ein Klick springt zur Phase.
// Zeit laeuft von links nach rechts; das versteht man ohne Erklaerung.
//
// 0.42.3 gibt ihr die Form, die man von Zeitstrahlen kennt: Die Beschriftungen stehen
// ABWECHSELND ober- und unterhalb des Balkens, mit Dreieck und Verbindungslinie. Das ist nicht
// Zierde, sondern loest ein Platzproblem: Nebeneinander gesetzte Beschriftungen kollidieren,
// sobald eine Phase kurz ist. Abwechselnd hat jede den doppelten Raum -- Nachbarn auf
// derselben Seite liegen zwei Phasen auseinander.
export function Timeline({ export function Timeline({
phases, phases,
persons, persons,
ruinAge, ruinAge,
startYear, startYear,
actualYears = [], actualYears = [],
activePhaseId,
onSelectPhase,
}: { }: {
phases: PhaseComputed[]; phases: PhaseComputed[];
persons: PersonAxis[]; persons: PersonAxis[];
@@ -28,143 +37,189 @@ export function Timeline({
// Kalenderjahre, für die effektive Werte erfasst sind (aufsteigend). Der jüngste Satz // Kalenderjahre, für die effektive Werte erfasst sind (aufsteigend). Der jüngste Satz
// wird hervorgehoben, ältere bleiben blass -- sie sind überholt, aber nicht bedeutungslos. // wird hervorgehoben, ältere bleiben blass -- sie sind überholt, aber nicht bedeutungslos.
actualYears?: number[]; actualYears?: number[];
activePhaseId?: string | null;
onSelectPhase?: (phaseId: string) => void;
}) { }) {
if (phases.length === 0 || persons.length === 0) return null; if (phases.length === 0 || persons.length === 0) return null;
const totalYears = phases.reduce((s, p) => s + p.durationYears, 0); const totalYears = phases.reduce((s, p) => s + p.durationYears, 0);
const minAge = Math.min(...persons.map((p) => p.currentAge)); const minAge = Math.min(...persons.map((p) => p.currentAge));
const maxAge = minAge + totalYears; const maxAge = minAge + totalYears;
const span = Math.max(1, maxAge - minAge); const span = Math.max(1, totalYears);
const pct = (age: number) => `${(Math.max(0, Math.min(span, age - minAge)) / span) * 100}%`; // Position eines Alters auf dem Balken, in Prozent seiner Länge.
const pctOfAge = (age: number) => (Math.max(0, Math.min(span, age - minAge)) / span) * 100;
const yearOf = (yearsFromStart: number) => (startYear ? startYear + yearsFromStart : null); const yearOf = (yearsFromStart: number) => (startYear ? startYear + yearsFromStart : null);
// Segmente: Start-/Endjahr (relativ zum Planbeginn) je Phase -- ohne Mutation während
// des Renderns, deshalb die kumulierte Summe der vorangehenden Dauern.
const segments = phases.map((p, i) => { const segments = phases.map((p, i) => {
const from = phases.slice(0, i).reduce((s, x) => s + x.durationYears, 0); const from = phases.slice(0, i).reduce((s, x) => s + x.durationYears, 0);
return { phase: p, from, to: from + p.durationYears }; return { phase: p, from, to: from + p.durationYears, index: i };
}); });
return ( const clickable = !!onSelectPhase;
<div className="rounded-xl border border-border bg-surface p-4 shadow-sm"> const BAR = "var(--fg)";
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<h3 className="text-sm font-semibold text-fg">Zeitachse</h3>
<div className="flex gap-3 text-xs text-muted">
{persons.map((p) => (
<span key={p.role} className="flex items-center gap-1">
<span className="inline-block h-2 w-2 rounded-full" style={{ backgroundColor: p.color }} />
{p.label} (heute {p.currentAge})
</span>
))}
</div>
</div>
<div className="relative pt-6"> // Ein Beschriftungsblock. Oben steht er auf dem Kopf (Karte, Linie, Dreieck), unten in
{/* Pensionsmarker je Person */} // umgekehrter Reihenfolge -- so zeigt das Dreieck immer zum Balken hin.
{persons.map((p, i) => const labelFor = (s: (typeof segments)[number], above: boolean) => {
p.retirementAge > minAge && p.retirementAge < maxAge ? ( const active = activePhaseId === s.phase.id;
<div const from = yearOf(s.from);
key={p.role} const to = yearOf(s.to);
className="absolute top-0 flex -translate-x-1/2 flex-col items-center" const tri = (
style={{ left: pct(p.retirementAge) }} <span
title={`${p.label}: Pensionierung mit ${p.retirementAge}${ aria-hidden="true"
yearOf(p.retirementAge - minAge) ? ` (${yearOf(p.retirementAge - minAge)})` : "" className="h-0 w-0 border-x-[7px] border-x-transparent"
style={
above
? { borderBottomWidth: 9, borderBottomStyle: "solid", borderBottomColor: active ? "var(--accent)" : BAR }
: { borderTopWidth: 9, borderTopStyle: "solid", borderTopColor: active ? "var(--accent)" : BAR }
}
/>
);
const line = <span className="w-px flex-1" style={{ backgroundColor: active ? "var(--accent)" : "var(--border-strong)" }} />;
const card = (
<span
className={`max-w-[9.5rem] rounded-xl border px-2.5 py-1 text-center transition-colors ${
active
? "border-accent bg-accent-soft text-accent-soft-fg shadow-sm"
: "border-border bg-surface text-muted group-hover/tl:border-accent group-hover/tl:text-accent-soft-fg"
}`} }`}
> >
<Flag className="h-3.5 w-3.5" style={{ color: p.color }} fill={p.color} /> <span className={`block whitespace-nowrap text-[13px] font-semibold ${active ? "" : "text-fg"}`}>
<span className="whitespace-nowrap text-[10px] font-medium" style={{ color: p.color }}> {from ? (s.phase.durationYears === 1 ? `${from}` : `${from}${to! - 1}`) : `Phase ${s.index + 1}`}
{p.retirementAge} </span>
<span className="block truncate text-[11px]">{s.phase.name}</span>
<span className="block text-[10px] text-faint">
{s.phase.durationYears} {s.phase.durationYears === 1 ? "Jahr" : "Jahre"}
</span>
</span>
);
return (
<button
key={s.phase.id}
type="button"
disabled={!clickable}
onClick={() => onSelectPhase?.(s.phase.id)}
aria-current={active ? "true" : undefined}
title={`${s.phase.name} · ${s.phase.durationYears} Jahre`}
className={`group/tl absolute bottom-0 top-0 flex -translate-x-1/2 flex-col items-center ${
clickable ? "cursor-pointer" : "cursor-default"
}`}
style={{ left: `${((s.from + s.phase.durationYears / 2) / span) * 100}%` }}
>
{above ? (
<>
{card}
{line}
{tri}
</>
) : (
<>
{tri}
{line}
{card}
</>
)}
</button>
);
};
return (
<div className="flex items-stretch gap-3">
{/* Das Startjahr steht markant am linken Ende -- es ist der Nullpunkt, auf den sich alle
realen Werte und die ganze Achse beziehen. */}
<div className="flex shrink-0 flex-col justify-center pb-1 text-right">
<span className="text-xl font-semibold leading-none text-fg">{startYear ?? minAge}</span>
<span className="mt-0.5 text-[10px] uppercase tracking-wide text-faint">
{startYear ? "Start" : "Jahre"}
</span> </span>
<div className="mt-0.5 h-3 w-px" style={{ backgroundColor: p.color, marginTop: i * 2 }} />
</div> </div>
<div className="relative min-w-0 flex-1">
{/* Beschriftungen oberhalb (Phase 1, 3, 5 …) */}
<div className="relative h-[68px]">{segments.filter((s) => s.index % 2 === 0).map((s) => labelFor(s, true))}</div>
{/* Der Balken. Die Segmente teilen sich die Breite streng nach Dauer -- eine Phase von
fünf Jahren ist halb so breit wie eine von zehn. */}
<div className="relative flex h-9 gap-[3px] overflow-hidden rounded-full">
{segments.map((s) => {
const active = activePhaseId === s.phase.id;
return (
<button
key={s.phase.id}
type="button"
disabled={!clickable}
onClick={() => onSelectPhase?.(s.phase.id)}
aria-label={`${s.phase.name}, ${s.phase.durationYears} Jahre`}
title={`${s.phase.name} · ${s.phase.durationYears} Jahre`}
className={`min-w-0 transition-all ${clickable ? "cursor-pointer hover:brightness-110" : ""}`}
style={{
width: `${(s.phase.durationYears / span) * 100}%`,
backgroundColor: active ? "var(--accent)" : BAR,
opacity: active ? 1 : 0.85,
}}
/>
);
})}
{/* Marker auf dem Balken: Pensionierung je Person, Ruinalter, erfasste Ist-Jahre.
Sie liegen AUF dem Balken statt darüber -- oberhalb kollidierten sie mit den
Beschriftungen. */}
{persons.map((p) =>
p.retirementAge > minAge && p.retirementAge < maxAge ? (
<span
key={p.role}
className="pointer-events-none absolute inset-y-1 w-0.5 -translate-x-1/2 rounded-full"
style={{ left: `${pctOfAge(p.retirementAge)}%`, backgroundColor: p.color }}
title={`${p.label}: Pensionierung mit ${p.retirementAge}`}
/>
) : null ) : null
)} )}
{/* Ruin-Marker (Person A) */}
{ruinAge != null && ruinAge > minAge && ruinAge <= maxAge && ( {ruinAge != null && ruinAge > minAge && ruinAge <= maxAge && (
<div <span
className="absolute top-0 flex -translate-x-1/2 flex-col items-center" className="pointer-events-none absolute inset-y-0 w-1 -translate-x-1/2"
style={{ left: pct(ruinAge) }} style={{ left: `${pctOfAge(ruinAge)}%`, backgroundColor: "var(--danger)" }}
title={`Kapital aufgebraucht mit Alter ${ruinAge}`} title={`Kapital aufgebraucht mit Alter ${ruinAge}`}
> />
<span className="text-[10px] font-semibold" style={{ color: "var(--danger)" }}>
Ruin {ruinAge}
</span>
<div className="mt-0.5 h-4 w-px" style={{ backgroundColor: "var(--danger)" }} />
</div>
)} )}
{/* Marker für erfasste effektive Werte. Nur mit bekanntem Planstartjahr platzierbar --
ohne Kalenderbezug gäbe es keine Position auf der Achse. */}
{startYear && {startYear &&
actualYears.map((y) => { actualYears.map((y) => {
const age = minAge + (y - startYear); const age = minAge + (y - startYear);
if (age < minAge || age > maxAge) return null; if (age < minAge || age > maxAge) return null;
const isLatest = y === actualYears[actualYears.length - 1]; const isLatest = y === actualYears[actualYears.length - 1];
return ( return (
<div
key={y}
className="absolute top-0 flex -translate-x-1/2 flex-col items-center"
style={{ left: pct(age), opacity: isLatest ? 1 : 0.35 }}
title={
isLatest
? `Effektive Werte erfasst für ${y} (aktuellster Stand)`
: `Effektive Werte erfasst für ${y} (überholt)`
}
>
<CalendarCheck
className="h-3.5 w-3.5"
style={{ color: isLatest ? "var(--attention)" : "var(--muted)" }}
/>
<span <span
className="whitespace-nowrap text-[10px] font-medium" key={y}
style={{ color: isLatest ? "var(--attention)" : "var(--muted)" }} className="pointer-events-none absolute top-1/2 h-2 w-2 -translate-x-1/2 -translate-y-1/2 rounded-full ring-2"
> style={{
{y} left: `${pctOfAge(age)}%`,
</span> backgroundColor: isLatest ? "var(--attention)" : "var(--faint)",
<div // eslint-disable-next-line @typescript-eslint/no-explicit-any
className="mt-0.5 h-3 w-px" ["--tw-ring-color" as any]: "var(--surface)",
style={{ backgroundColor: isLatest ? "var(--attention)" : "var(--muted)" }} }}
title={isLatest ? `Effektive Werte für ${y} (aktuell)` : `Effektive Werte für ${y} (überholt)`}
/> />
</div>
);
})}
{/* Phasen-Segmente: Breite proportional zur Dauer, Einfärbung nach Phasentyp. */}
<div className="flex w-full overflow-hidden rounded-lg border border-border">
{segments.map((s, i) => {
const soft = s.phase.type === "PENSION" || s.phase.type === "MIXED";
return (
<div
key={s.phase.id}
style={{ width: `${(s.phase.durationYears / span) * 100}%` }}
title={`${s.phase.name} · ${s.phase.durationYears} Jahre`}
className={`flex min-w-0 flex-col justify-center px-2 py-1.5 ${
i > 0 ? "border-l border-border" : ""
} ${s.phase.type === "PENSION" ? "bg-accent-soft" : s.phase.type === "MIXED" ? "bg-accent/25" : "bg-accent/70"}`}
>
<span className={`truncate text-[10px] font-semibold ${soft ? "text-accent-soft-fg" : "text-accent-fg"}`}>
{s.phase.name}
</span>
<span className={`truncate text-[9px] ${soft ? "text-accent-soft-fg" : "text-accent-fg"} opacity-80`}>
{s.phase.durationYears} J.
{yearOf(s.from) ? ` · ${yearOf(s.from)}${yearOf(s.to)}` : ""}
</span>
</div>
); );
})} })}
</div> </div>
{/* Alters- und Jahres-Beschriftung */} {/* Beschriftungen unterhalb (Phase 2, 4, 6 …) */}
<div className="mt-1 flex justify-between text-[11px] text-muted"> <div className="relative h-[68px]">{segments.filter((s) => s.index % 2 === 1).map((s) => labelFor(s, false))}</div>
<span>
{minAge} J.{yearOf(0) ? ` · ${yearOf(0)}` : ""}
</span>
<span>
{maxAge} J.{yearOf(totalYears) ? ` · ${yearOf(totalYears)}` : ""}
</span>
</div> </div>
{/* Rechts das Planende und, falls vorhanden, die Marker-Erklärung in Kurzform. */}
<div className="flex shrink-0 flex-col justify-center pb-1">
<span className="text-sm font-semibold leading-none text-muted">
{startYear ? startYear + totalYears - 1 : maxAge}
</span>
<span className="mt-0.5 flex items-center gap-1 text-[10px] uppercase tracking-wide text-faint">
Ende
{persons.some((p) => p.retirementAge > minAge && p.retirementAge < maxAge) && (
<Flag className="h-2.5 w-2.5" aria-label="Pensionierung markiert" />
)}
{actualYears.length > 0 && <CalendarCheck className="h-2.5 w-2.5" aria-label="Effektive Werte markiert" />}
</span>
</div> </div>
</div> </div>
); );
-145
View File
@@ -1,145 +0,0 @@
// Der FPT-Assistent und die Element-Stammdaten (0.36).
//
// Der Assistent selbst ist UI; getestet wird hier die reine Logik dahinter -- der Fortschritt,
// der abgeleitete Stand und die Voraussetzungen je Schritt. Dazu die Eigenschaft, wegen der
// `baseData` überhaupt existiert: Ein Element muss sich erfassen lassen, BEVOR es Phasen gibt.
import { describe, it, expect } from "vitest";
import {
ASSISTANT_STEP_COUNT,
ASSISTANT_STEPS,
emptyProgress,
normalizeProgress,
stepBlockedReason,
stepStatus,
} from "@/lib/assistant";
import { computePlan } from "@/lib/calculations";
import type { PhaseData } from "@/lib/elements";
import type { PlanInput } from "@/lib/types";
function plan(over: Partial<PlanInput> = {}): PlanInput {
return {
id: "plan",
name: "T",
householdType: "SINGLE",
inflationRateDefault: 0,
initialCash: 0,
startYear: 2026,
planningHorizonYears: null,
persons: [{ id: "A", role: "PERSON_A", name: null, age: 40, retirementAge: 65 }],
phases: [],
elements: [],
...over,
};
}
function el(category: string, baseData: PhaseData = {}, phaseValues: Record<string, PhaseData> = {}) {
return {
id: `e-${category}-${Math.random().toString(36).slice(2, 7)}`,
category: category as never,
name: category,
ownerRole: "PERSON_A" as never,
orderIndex: 0,
phaseValues,
transitionValues: {},
baseData,
};
}
describe("Assistenten-Fortschritt", () => {
it("startet mit sieben offenen Schritten", () => {
expect(emptyProgress()).toHaveLength(ASSISTANT_STEP_COUNT);
expect(emptyProgress().some(Boolean)).toBe(false);
expect(ASSISTANT_STEPS).toHaveLength(ASSISTANT_STEP_COUNT);
});
it("verwirft kaputte Daten, statt daran zu scheitern", () => {
// Der Fortschritt liegt als JSON in der Datenbank -- ein alter oder manipulierter Stand
// darf die Ansicht nicht zerlegen.
expect(normalizeProgress(null)).toEqual(emptyProgress());
expect(normalizeProgress([true, false])).toEqual(emptyProgress()); // falsche Länge
expect(normalizeProgress("kaputt")).toEqual(emptyProgress());
const gut = [true, false, true, false, true, false, true];
expect(normalizeProgress(gut)).toEqual(gut);
});
});
describe("Abgeleiteter Stand neben dem Haken", () => {
// Der Haken ist bewusst manuell -- aber der Assistent soll nichts Falsches behaupten.
// Deshalb steht daneben, was tatsächlich da ist.
it("zählt die erfassten Elemente", () => {
expect(stepStatus(plan(), 0)).toBe("noch nichts erfasst");
expect(stepStatus(plan({ elements: [el("INCOME")] }), 0)).toBe("1 Element");
expect(stepStatus(plan({ elements: [el("INCOME"), el("EXPENSE")] }), 0)).toBe("2 Elemente");
});
it("meldet den fehlenden Planungshorizont", () => {
expect(stepStatus(plan(), 1)).toBe("Planungshorizont fehlt");
expect(stepStatus(plan({ planningHorizonYears: 40 }), 1)).toContain("40 Jahre");
});
it("zählt die Lebensphasen -- auch wenn es keine gibt", () => {
expect(stepStatus(plan(), 2)).toBe("0 Lebensphasen");
expect(
stepStatus(
plan({ phases: [{ id: "p1", sequenceNumber: 1, name: "p1", durationYears: 5, cashTransition: {} }] }),
2
)
).toBe("1 Lebensphase");
});
});
describe("Voraussetzungen je Schritt", () => {
it("sperrt die Phasenplanung, solange der Horizont fehlt", () => {
expect(stepBlockedReason(plan(), 2)).toContain("Planungshorizont");
expect(stepBlockedReason(plan({ planningHorizonYears: 40 }), 2)).toBeNull();
});
it("sperrt die Übergangs-Schritte, solange es keine Phasen gibt", () => {
const p = plan({ planningHorizonYears: 40 });
for (const step of [3, 4, 5]) expect(stepBlockedReason(p, step)).toContain("Lebensphasen");
});
it("lässt die Bestandsaufnahme immer zu -- sie braucht keine Zeitachse", () => {
expect(stepBlockedReason(plan(), 0)).toBeNull();
expect(stepBlockedReason(plan(), 1)).toBeNull();
});
});
describe("Element-Stammdaten (baseData)", () => {
// Der Grund für den ganzen Umbau: Eine Bestandsaufnahme ist keine Aussage über eine Phase.
it("erlaubt Elemente ohne jede Lebensphase", () => {
const p = plan({ elements: [el("OTHER_ASSET", { startValue: 100000, expectedReturn: 3 })] });
const c = computePlan(p);
expect(c.phases).toHaveLength(0);
expect(c.ruinAge).toBeNull();
});
it("dient der ersten Phase als Startwert", () => {
const p = plan({
phases: [{ id: "p1", sequenceNumber: 1, name: "p1", durationYears: 10, cashTransition: {} }],
elements: [el("OTHER_ASSET", { startValue: 100000, expectedReturn: 0 }, { p1: {} })],
});
const asset = computePlan(p).phases[0].elements.find((e) => e.category === "OTHER_ASSET")!;
expect(asset.startValue).toBe(100000);
});
it("ist die Wurzel der Vererbung -- Phase 1 erbt die Rendite von dort", () => {
// Vor 0.36 hatte Phase 1 nichts, von dem sie hätte erben können, und fiel auf 0.
const p = plan({
phases: [{ id: "p1", sequenceNumber: 1, name: "p1", durationYears: 10, cashTransition: {} }],
elements: [el("OTHER_ASSET", { startValue: 100000, expectedReturn: 5 }, { p1: {} })],
});
const asset = computePlan(p).phases[0].elements.find((e) => e.category === "OTHER_ASSET")!;
expect(asset.endValue).toBe(Math.round(100000 * Math.pow(1.05, 10)));
});
it("weicht einem erfassten Phasenwert -- der gewinnt", () => {
const p = plan({
phases: [{ id: "p1", sequenceNumber: 1, name: "p1", durationYears: 10, cashTransition: {} }],
elements: [el("OTHER_ASSET", { startValue: 100000, expectedReturn: 5 }, { p1: { expectedReturn: 0 } })],
});
const asset = computePlan(p).phases[0].elements.find((e) => e.category === "OTHER_ASSET")!;
expect(asset.endValue).toBe(100000);
});
});
-132
View File
@@ -1,132 +0,0 @@
// Der FPT-Assistent: sieben Schritte von der leeren Matrix zum fertigen Plan.
//
// Er ersetzt die frueheren "Naechsten Schritte". Der Unterschied ist nicht kosmetisch: Die
// alte Karte leitete AB, was zu tun waere; hier fuehrt jeder Schritt sein eigenes Werkzeug
// mit sich. Der Nutzer haelt den Fortschritt selbst fest (Haken) -- das Tool masst sich nicht
// an zu wissen, wann jemand mit einem Schritt fertig IST.
//
// Damit der Assistent trotzdem nichts Falsches behauptet, steht neben jedem Haken der
// ABGELEITETE Stand ("0 Lebensphasen"). Ein abgehakter Schritt ohne Substanz faellt so auf,
// ohne dass das Tool den Haken verweigert.
import { z } from "zod";
import type { PlanInput } from "@/lib/types";
export const ASSISTANT_STEP_COUNT = 7;
export type AssistantProgress = boolean[];
export const assistantProgressSchema = z.array(z.boolean()).length(ASSISTANT_STEP_COUNT);
export function emptyProgress(): AssistantProgress {
return Array.from({ length: ASSISTANT_STEP_COUNT }, () => false);
}
export function normalizeProgress(raw: unknown): AssistantProgress {
const parsed = assistantProgressSchema.safeParse(raw);
return parsed.success ? parsed.data : emptyProgress();
}
export interface AssistantStep {
index: number;
title: string;
short: string;
// Ein Satz, der sagt, worum es geht -- steht in der Liste unter dem Titel.
lead: string;
// Hat dieser Schritt ein Werkzeug, oder ist er reine Information?
guided: boolean;
}
export const ASSISTANT_STEPS: AssistantStep[] = [
{
index: 0,
title: "Bestandsaufnahme",
short: "Was du hast",
lead: "Alle Konten, Guthaben, Immobilien und Schulden erfassen mit ihrem heutigen Stand.",
guided: true,
},
{
index: 1,
title: "Eckdaten und Pensionsplanung",
short: "Wann was passiert",
lead: "Wie weit du planst und wann du aufhörst zu arbeiten und deine Renten beziehst.",
guided: true,
},
{
index: 2,
title: "Lebensphasen",
short: "Die Zeitachse",
lead: "Deine Planung in Abschnitte teilen. Die Fixpunkte aus Schritt 2 sind gesetzt.",
guided: true,
},
{
index: 3,
title: "Erwerbsjahre planen",
short: "Sparen bis zur Pension",
lead: "Wohin dein Sparbetrag fliesst und was an den Übergängen dazwischen passiert.",
guided: true,
},
{
index: 4,
title: "Pensionierung planen",
short: "Der Übergang",
lead: "Rente oder Kapital, wohin das Geld fliesst und wie es in den Jahren danach weitergeht.",
guided: true,
},
{
index: 5,
title: "Ruhestand planen",
short: "Die Jahre danach",
lead: "Wovon du lebst, wenn kein Lohn mehr kommt Bezüge aus dem Vermögen und die restlichen Übergänge.",
guided: true,
},
{
index: 6,
title: "Feinschliff",
short: "Was noch geht",
lead: "Was du jetzt noch verfeinern kannst und wo du es findest.",
guided: false,
},
];
// --- Abgeleiteter Stand je Schritt -----------------------------------------------------------
//
// Bewusst NICHT zum Erzwingen des Hakens, sondern als Gegenprobe daneben. Wer "Lebensphasen"
// abhakt, ohne eine anzulegen, sieht "0 Lebensphasen" -- das reicht.
export function stepStatus(plan: PlanInput, index: number): string {
const phases = plan.phases.length;
const elements = plan.elements.length;
switch (index) {
case 0:
return elements === 0
? "noch nichts erfasst"
: `${elements} ${elements === 1 ? "Element" : "Elemente"}`;
case 1: {
const geplant = plan.elements.filter(
(e) => e.category === "AHV" && e.retirementDecision?.confirmed === true
).length;
const horizon = plan.planningHorizonYears;
if (!horizon) return "Planungshorizont fehlt";
return geplant === plan.persons.length
? `${horizon} Jahre · Pensionsplanung steht`
: `${horizon} Jahre · Pensionsplanung offen`;
}
case 2:
return phases === 0 ? "0 Lebensphasen" : `${phases} ${phases === 1 ? "Lebensphase" : "Lebensphasen"}`;
default:
return "";
}
}
// Ein Schritt lässt sich erst sinnvoll öffnen, wenn seine Voraussetzung erfüllt ist. Der Haken
// bleibt trotzdem jederzeit setzbar -- gesperrt wird nur das Werkzeug, nicht die Selbstauskunft.
export function stepBlockedReason(plan: PlanInput, index: number): string | null {
const hasHorizon = typeof plan.planningHorizonYears === "number" && plan.planningHorizonYears > 0;
if (index === 2 && !hasHorizon) {
return "Lege zuerst den Planungshorizont und die Pensionsplanung fest daraus ergeben sich die Fixpunkte der Zeitachse.";
}
if (index >= 3 && plan.phases.length === 0) {
return "Lege zuerst deine Lebensphasen an ohne sie gibt es keine Übergänge zu entscheiden.";
}
return null;
}
+71
View File
@@ -0,0 +1,71 @@
// «Ein Wert, ein Ort» -- durchgesetzt im Rechenkern, nicht nur in der Oberfläche
// (SPEZIFIKATION 3.14.3).
//
// Der Bestand bei Planbeginn gehört in die Stammdaten. In der ersten Phase stand dafür bis
// 0.39 zusätzlich ein Eingabefeld; eine dort gespeicherte Zahl überschattete die Stammdaten
// still. Seit 0.40 ist das Feld read-only -- womit so ein Altwert unerreichbar WÄRE, wenn er
// weiterhin gewönne. Deshalb entscheidet die Regel der Rechenkern.
import { describe, it, expect } from "vitest";
import { computePlan, firstPhaseValues } from "@/lib/calculations";
import type { PlanInput } from "@/lib/types";
function plan(elements: PlanInput["elements"]): PlanInput {
return {
id: "plan",
name: "T",
householdType: "SINGLE",
inflationRateDefault: 0,
initialCash: 0,
startYear: 2026,
persons: [{ id: "A", role: "PERSON_A", name: null, age: 40, retirementAge: 65 }],
phases: [{ id: "p1", sequenceNumber: 1, name: "P1", durationYears: 10, cashTransition: { mode: "NONE" } }],
elements,
};
}
function haus(phaseValues: Record<string, Record<string, unknown>>) {
return [
{
id: "e1",
category: "REAL_ESTATE" as never,
name: "Haus",
ownerRole: "HOUSEHOLD" as never,
orderIndex: 1,
baseData: { purchasePrice: 800000, mortgage: 500000 } as Record<string, number>,
phaseValues: phaseValues as never,
transitionValues: {},
},
];
}
describe("Bestandsfelder in der ersten Phase", () => {
it("nimmt den Bestand aus den Stammdaten", () => {
const c = computePlan(plan(haus({})));
expect(c.phases[0].elements[0].startValue).toBe(300000);
});
it("lässt sich von einem Phasenwert NICHT überschatten", () => {
// Genau der Fall aus der Testrunde: In 0.39 schrieb das Speichern der Zelle eine 0 --
// seither stand das Haus mit Startwert 0 da, ohne dass man es noch korrigieren konnte.
const c = computePlan(plan(haus({ p1: { confirmed: true, purchasePrice: 0, mortgage: 0 } })));
expect(c.phases[0].elements[0].startValue).toBe(300000);
});
it("fällt auf den Phasenwert zurück, solange die Stammdaten leer sind", () => {
// Ohne diesen Rückfall wären Pläne aus der Zeit vor den Stammdaten schlagartig auf 0.
const els = haus({ p1: { purchasePrice: 600000, mortgage: 400000 } });
els[0].baseData = {};
expect(computePlan(plan(els)).phases[0].elements[0].startValue).toBe(200000);
});
it("lässt die ANNAHMEN in der ersten Phase überschreibbar", () => {
// Eine Rendite gilt für einen Zeitraum, kein Anfangsbestand -- sie darf je Phase abweichen.
const merged = firstPhaseValues(
{ startValue: 100000, expectedReturn: 3 },
{ expectedReturn: 7, startValue: 0 }
);
expect(merged.expectedReturn).toBe(7);
expect(merged.startValue).toBe(100000);
});
});
+49 -6
View File
@@ -455,6 +455,32 @@ function st(
): TraceStep { ): TraceStep {
return { label, formula, substituted, result, unit, note }; return { label, formula, substituted, result, unit, note };
} }
// Bestandsfelder: der Stand bei PLANBEGINN. Sie gehoeren ausschliesslich in die Stammdaten
// (SPEZIFIKATION 3.14.3, «Ein Wert, ein Ort») -- ein Startwert ist nicht «phase-1-spezifisch»,
// sondern schlicht der Stand am Anfang.
const BASE_ONLY_KEYS = ["amount", "currentValue", "startValue", "purchasePrice", "mortgage"] as const;
// Die wirksamen Werte der ERSTEN Phase: Stammdaten als Wurzel, Phasenwerte darueber.
//
// Mit einer Ausnahme, und die ist der Grund, warum es diese Funktion gibt: Bei den
// Bestandsfeldern gewinnen die STAMMDATEN, wo sie einen Wert tragen. Ein blosses
// `{...bd, ...raw}` liess einen Phasenwert die Stammdaten ueberschatten -- und weil das Feld
// in Phase 1 seit 0.40 read-only ist, waere so ein Wert danach unerreichbar: Eine in einer
// aelteren Version versehentlich gespeicherte 0 haette den Kaufpreis fuer immer verdeckt.
// Die Regel «ein Wert, ein Ort» steht damit im Rechenkern und nicht nur in der Oberflaeche.
//
// Der Rueckfall auf den Phasenwert bleibt fuer den Fall, dass die Stammdaten (noch) leer sind.
// Die ANNAHMEN (Rendite, Zins, Wertsteigerung, Teuerung) bleiben in Phase 1 unveraendert
// ueberschreibbar -- sie gelten fuer einen Zeitraum, nicht fuer den Anfangsbestand.
export function firstPhaseValues(bd: PhaseData, raw: PhaseData): PhaseData {
const merged: PhaseData = { ...bd, ...raw };
for (const key of BASE_ONLY_KEYS) {
const root = bd[key];
if (typeof root === "number") (merged as Record<string, unknown>)[key] = root;
}
return merged;
}
function pct(v: number): string { function pct(v: number): string {
return `${Math.round(v * 1000) / 1000} %`; return `${Math.round(v * 1000) / 1000} %`;
} }
@@ -666,7 +692,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
// Phase 1 nichts Besonderes mehr, sondern erbt schlicht von der Wurzel. // Phase 1 nichts Besonderes mehr, sondern erbt schlicht von der Wurzel.
const bd = e.baseData ?? {}; const bd = e.baseData ?? {};
const raw = e.phaseValues[phase.id] ?? {}; const raw = e.phaseValues[phase.id] ?? {};
const pd: PhaseData = isFirstPhase ? { ...bd, ...raw } : raw; const pd: PhaseData = isFirstPhase ? firstPhaseValues(bd, raw) : raw;
const owner = e.ownerRole && e.ownerRole !== "HOUSEHOLD" ? personByRole(persons, e.ownerRole) : null; const owner = e.ownerRole && e.ownerRole !== "HOUSEHOLD" ? personByRole(persons, e.ownerRole) : null;
const ownerWorking = owner ? workingByPerson.get(owner.id) ?? false : anyWorking; const ownerWorking = owner ? workingByPerson.get(owner.id) ?? false : anyWorking;
@@ -1892,16 +1918,33 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
const investPct = Math.max(0, Math.min(100 - amortPct, num(use.td.capitalUseInvestPct))); const investPct = Math.max(0, Math.min(100 - amortPct, num(use.td.capitalUseInvestPct)));
if (use.net <= 0 || (amortPct === 0 && investPct === 0)) continue; if (use.net <= 0 || (amortPct === 0 && investPct === 0)) continue;
// Schuldentilgung. Das Ziel ist seit 0.41 waehlbar und darf eine Immobilie ODER eine
// sonstige Schuld sein: Wer einen Konsumkredit zu 6 % neben einer Hypothek zu 1,5 %
// traegt, tilgt zuerst den Kredit -- vorher floss der Betrag stur in die erstbeste
// Immobilie. Ohne Wahl bleibt es beim bisherigen Verhalten (alle Immobilien der Reihe
// nach), damit bestehende Plaene unveraendert rechnen.
let amortBudget = Math.round((use.net * amortPct) / 100); let amortBudget = Math.round((use.net * amortPct) / 100);
for (const e of orderedElements) { const chosenDebt = orderedElements.find((e) => e.id === use.td.capitalUseDebtTargetElementId);
const debtOrder = chosenDebt ? [chosenDebt, ...orderedElements.filter((e) => e !== chosenDebt)] : orderedElements;
for (const e of debtOrder) {
if (amortBudget <= 0) break; if (amortBudget <= 0) break;
if (e.category !== "REAL_ESTATE") continue;
const c = carries.get(e.id)!; const c = carries.get(e.id)!;
if (c.status !== "ACTIVE" || c.mortgage <= 0) continue; if (c.status !== "ACTIVE") continue;
if (e.category === "REAL_ESTATE") {
if (c.mortgage <= 0) continue;
const pay = Math.min(amortBudget, c.mortgage); const pay = Math.min(amortBudget, c.mortgage);
c.mortgage -= pay; c.mortgage -= pay;
amortBudget -= pay; amortBudget -= pay;
txImmediateRepay += pay; txImmediateRepay += pay;
} else if (e.category === "OTHER_DEBT") {
// Nur das AUSDRUECKLICH gewaehlte Schuld-Element -- sonst wuerden bestehende Plaene,
// die bisher nur Hypotheken kannten, ploetzlich anders rechnen.
if (e !== chosenDebt || c.owed <= 0) continue;
const pay = Math.min(amortBudget, c.owed);
c.owed -= pay;
amortBudget -= pay;
txImmediateRepay += pay;
}
} }
const investBudget = Math.round((use.net * investPct) / 100); const investBudget = Math.round((use.net * investPct) / 100);
@@ -1989,8 +2032,8 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
personId: p.id, personId: p.id,
role: p.role, role: p.role,
retirementAge: retirementAge.get(p.id) ?? AHV_REFERENCE_AGE, retirementAge: retirementAge.get(p.id) ?? AHV_REFERENCE_AGE,
// Alter am Planende -- abgeleitet aus dem Horizont, nicht erfasst. // Alter am Planende -- abgeleitet aus der Summe der Lebensphasen.
planningHorizonAge: typeof plan.planningHorizonYears === "number" ? p.age + plan.planningHorizonYears : null, planningHorizonAge: totalYears > 0 ? p.age + totalYears : null,
ahvAnnual: Math.round(ahvFinalByPerson.get(p.id) ?? 0), ahvAnnual: Math.round(ahvFinalByPerson.get(p.id) ?? 0),
ahvFromAge: Math.round(ahvStartAge(rd)), ahvFromAge: Math.round(ahvStartAge(rd)),
ahvDraw: ahvDrawLabel(rd), ahvDraw: ahvDrawLabel(rd),
+92
View File
@@ -0,0 +1,92 @@
// Verwendung des bezogenen Alterskapitals: das Tilgungs-Ziel ist wählbar (0.41).
//
// Bis 0.40 floss die Tilgungs-Quote stur in die erstbeste Immobilie. Wer neben einer Hypothek
// zu 1,5 % einen Konsumkredit zu 6 % trägt, will aber zuerst den Kredit los -- und hatte keine
// Möglichkeit, das zu sagen.
import { describe, it, expect } from "vitest";
import { computePlan } from "@/lib/calculations";
import type { PlanInput } from "@/lib/types";
function plan(debtTarget?: string): PlanInput {
return {
id: "plan",
name: "T",
householdType: "SINGLE",
inflationRateDefault: 0,
initialCash: 0,
startYear: 2026,
persons: [{ id: "A", role: "PERSON_A", name: null, age: 64, retirementAge: 65 }],
phases: [
{ id: "p1", sequenceNumber: 1, name: "Erwerb", durationYears: 1, cashTransition: { mode: "NONE" } },
{ id: "p2", sequenceNumber: 2, name: "Pension", durationYears: 5, cashTransition: { mode: "NONE" } },
],
elements: [
{
id: "pk",
category: "PENSION_FUND" as never,
name: "PK",
ownerRole: "PERSON_A" as never,
orderIndex: 1,
baseData: { currentValue: 400000, expectedReturn: 0 },
phaseValues: { p1: {}, p2: {} },
transitionValues: {},
// Volles Kapital, keine Steuer, alles in die Tilgung: 400'000 stehen zur Verfügung.
retirementDecision: {
capitalSharePct: 100,
capitalTaxRate: 0,
conversionRate: 6,
capitalUseAmortizationPct: 100,
...(debtTarget ? { capitalUseDebtTargetElementId: debtTarget } : {}),
} as never,
},
{
id: "haus",
category: "REAL_ESTATE" as never,
name: "Haus",
ownerRole: "HOUSEHOLD" as never,
orderIndex: 2,
baseData: { purchasePrice: 900000, mortgage: 300000, interestRate: 1.5, valueGrowth: 0 },
phaseValues: { p1: {}, p2: {} },
transitionValues: { p1: { decision: "HOLD" as never } },
},
{
id: "kredit",
category: "OTHER_DEBT" as never,
name: "Konsumkredit",
ownerRole: "HOUSEHOLD" as never,
orderIndex: 3,
baseData: { startValue: 100000 },
phaseValues: { p1: {}, p2: {} },
transitionValues: {},
},
],
};
}
const restOf = (p: PlanInput, id: string) => {
const c = computePlan(p);
const ph = c.phases[1];
const ce = ph.elements.find((e) => e.elementId === id)!;
return id === "haus" ? ce.mortgageStart : Math.abs(ce.startValue);
};
describe("Schuldentilgung aus dem Alterskapital", () => {
it("tilgt ohne Wahl weiterhin die Hypothek", () => {
// Bestandsschutz: Pläne aus der Zeit vor der Zielwahl müssen unverändert rechnen.
const p = plan();
expect(restOf(p, "haus")).toBe(0);
expect(restOf(p, "kredit")).toBe(100000);
});
it("tilgt den gewählten Kredit zuerst", () => {
const p = plan("kredit");
expect(restOf(p, "kredit")).toBe(0);
});
it("lässt eine nicht gewählte Schuld unberührt, wenn die Hypothek gewählt ist", () => {
const p = plan("haus");
expect(restOf(p, "haus")).toBe(0);
expect(restOf(p, "kredit")).toBe(100000);
});
});
-1
View File
@@ -144,7 +144,6 @@ export function computeScenarioDiff(scenario: PlanInput, base: PlanInput | null)
d.profileChanged = d.profileChanged =
scenario.householdType !== base.householdType || scenario.householdType !== base.householdType ||
scenario.inflationRateDefault !== base.inflationRateDefault || scenario.inflationRateDefault !== base.inflationRateDefault ||
(scenario.planningHorizonYears ?? null) !== (base.planningHorizonYears ?? null) ||
scenario.persons.map(personKey).sort().join(";") !== base.persons.map(personKey).sort().join(";"); scenario.persons.map(personKey).sort().join(";") !== base.persons.map(personKey).sort().join(";");
d.total = d.total =
+4
View File
@@ -50,6 +50,9 @@ export const CATEGORY_ORDER: ElementCategory[] = [
// Defaults, das UI zeigt je nach Kontext nur die relevanten Felder. // Defaults, das UI zeigt je nach Kontext nur die relevanten Felder.
export interface PhaseData { export interface PhaseData {
// Hat jemand die Annahmen dieser Zelle einmal angeschaut? Steht NEBEN den Werten und
// kopiert nichts -- eine bestaetigte Phase erbt weiterhin live von der Vorphase.
confirmed?: boolean;
// INCOME / EXPENSE // INCOME / EXPENSE
amount?: number; amount?: number;
// INCOME / EXPENSE: jährlicher Teuerungsausgleich (%). Indexiert den Flow über die // INCOME / EXPENSE: jährlicher Teuerungsausgleich (%). Indexiert den Flow über die
@@ -173,6 +176,7 @@ export const cashTransitionSchema = z
export const phaseDataSchema = z export const phaseDataSchema = z
.object({ .object({
confirmed: z.boolean().optional(),
amount: nonNeg.optional(), amount: nonNeg.optional(),
teuerungsausgleich: z.number().min(-20).max(50).optional(), teuerungsausgleich: z.number().min(-20).max(50).optional(),
gapYears: z.number().int().min(0).optional(), gapYears: z.number().int().min(0).optional(),
+5 -3
View File
@@ -76,9 +76,11 @@ describe("Datenbank-Migrationen", () => {
expect(personCols).not.toContain("name"); expect(personCols).not.toContain("name");
expect(personCols).not.toContain("age"); expect(personCols).not.toContain("age");
const scenCols2 = await cols("Scenario"); // Der Assistenten-Fortschritt ist mit dem Assistenten entfallen (0.39): Was offen ist,
expect(scenCols2).toContain("planningHorizonYears"); // wird abgeleitet und nicht gespeichert.
expect(scenCols2).toContain("assistantProgress"); expect(await cols("Scenario")).not.toContain("assistantProgress");
// Die Spar-/Verzehrquote je Phase zaehlt eigens als offener Punkt.
expect(await cols("Phase")).toContain("ratesConfirmed");
// Der Pensionierungs-Entscheid hängt am ELEMENT und bewusst an keiner Phase (0.34) -- // Der Pensionierungs-Entscheid hängt am ELEMENT und bewusst an keiner Phase (0.34) --
// nur so überlebt er eine Verschiebung der Zeitachse. Die Stammdaten (0.36) ebenso: Sie // nur so überlebt er eine Verschiebung der Zeitachse. Die Stammdaten (0.36) ebenso: Sie
+1 -3
View File
@@ -2,7 +2,6 @@ import { Prisma } from "@/generated/prisma/client";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { cashTransitionSchema, phaseDataSchema, transitionDataSchema } from "@/lib/elements"; import { cashTransitionSchema, phaseDataSchema, transitionDataSchema } from "@/lib/elements";
import { retirementDecisionSchema } from "@/lib/retirement-decision"; import { retirementDecisionSchema } from "@/lib/retirement-decision";
import { normalizeProgress } from "@/lib/assistant";
import type { CashTransitionData, PhaseData, TransitionData } from "@/lib/elements"; import type { CashTransitionData, PhaseData, TransitionData } from "@/lib/elements";
import type { RetirementDecision } from "@/lib/retirement-decision"; import type { RetirementDecision } from "@/lib/retirement-decision";
import type { PlanInput } from "@/lib/types"; import type { PlanInput } from "@/lib/types";
@@ -51,8 +50,6 @@ export function toPlanInput(plan: PlanWithRelations): PlanInput {
inflationRateDefault: plan.inflationRateDefault, inflationRateDefault: plan.inflationRateDefault,
initialCash: plan.initialCash, initialCash: plan.initialCash,
startYear: plan.plan.startYear, startYear: plan.plan.startYear,
planningHorizonYears: plan.planningHorizonYears,
assistantProgress: normalizeProgress(plan.assistantProgress),
// Name und Alter vom Plan, Pensionsalter vom Szenario. Fehlt zu einer Rolle die // Name und Alter vom Plan, Pensionsalter vom Szenario. Fehlt zu einer Rolle die
// Plan-Person, greift ein Notbehelf -- die Berechnung darf daran nicht scheitern. // Plan-Person, greift ein Notbehelf -- die Berechnung darf daran nicht scheitern.
persons: plan.persons.map((p) => { persons: plan.persons.map((p) => {
@@ -71,6 +68,7 @@ export function toPlanInput(plan: PlanWithRelations): PlanInput {
name: phase.name, name: phase.name,
durationYears: phase.durationYears, durationYears: phase.durationYears,
cashTransition: parseCashTransition(phase.cashTransition), cashTransition: parseCashTransition(phase.cashTransition),
ratesConfirmed: phase.ratesConfirmed,
sourcePhaseId: phase.sourcePhaseId, sourcePhaseId: phase.sourcePhaseId,
})), })),
elements: plan.elements.map((e) => { elements: plan.elements.map((e) => {
-1
View File
@@ -64,7 +64,6 @@ function plan(opts: {
durationYears: p.durationYears, durationYears: p.durationYears,
cashTransition: p.cashTransition ?? {}, cashTransition: p.cashTransition ?? {},
})), })),
planningHorizonYears: opts.horizon ?? null,
elements: opts.elements, elements: opts.elements,
}; };
} }
+5
View File
@@ -79,7 +79,11 @@ export interface RetirementDecision {
// --- gemeinsam: Kapitalbezug und seine Verwendung --------------------------------------- // --- gemeinsam: Kapitalbezug und seine Verwendung ---------------------------------------
capitalTaxRate?: number; capitalTaxRate?: number;
// Quote in die SCHULDENTILGUNG. Ziel ist seit 0.41 waehlbar: eine Hypothek (Immobilie)
// ODER eine sonstige Schuld -- vorher floss der Betrag stets in die erstbeste Immobilie,
// was bei einem teuren Konsumkredit daneben die falsche Wahl war.
capitalUseAmortizationPct?: number; capitalUseAmortizationPct?: number;
capitalUseDebtTargetElementId?: string;
capitalUseInvestPct?: number; capitalUseInvestPct?: number;
capitalUseTargetElementId?: string; capitalUseTargetElementId?: string;
} }
@@ -110,6 +114,7 @@ export const retirementDecisionSchema = z
capitalTaxRate: z.number().min(0).max(100).optional(), capitalTaxRate: z.number().min(0).max(100).optional(),
capitalUseAmortizationPct: pct.optional(), capitalUseAmortizationPct: pct.optional(),
capitalUseDebtTargetElementId: z.string().max(60).optional(),
capitalUseInvestPct: pct.optional(), capitalUseInvestPct: pct.optional(),
capitalUseTargetElementId: z.string().max(60).optional(), capitalUseTargetElementId: z.string().max(60).optional(),
}) })
+7 -35
View File
@@ -53,13 +53,14 @@ export function sortedPhases(plan: PlanInput) {
// konnten dadurch unbemerkt verschieden weit rechnen -- und waren dann nicht vergleichbar, // konnten dadurch unbemerkt verschieden weit rechnen -- und waren dann nicht vergleichbar,
// obwohl genau das ihr Zweck ist. // obwohl genau das ihr Zweck ist.
// //
// Seit 0.36 ist der Horizont eine Zahl in JAHREN am Szenario. Vorher stand er als Endalter je // Seit 0.38 wird der Horizont ABGELEITET: Er ist die Summe der Lebensphasen. Zwei Anlaeufe
// PERSON -- bei einem Paar zwei Zahlen, die auseinanderlaufen konnten und aus denen sich das // davor -- Endalter je Person (0.35), dann eine eigene Jahreszahl am Szenario (0.36) -- waren
// Planende erst per Maximum ergab. Eine Laufzeit ist eine Eigenschaft der PLANUNG, nicht der // beide eine zweite Wahrheit ueber dieselbe Sache. Wer die Phasen einzeln plant, hat den
// Person; die Endalter sind die Ableitung davon, nicht umgekehrt. // Horizont damit bereits bestimmt; ihn zusaetzlich zu erfassen hiess nur, zwei Zahlen
// synchron halten zu muessen.
export function planEndYear(plan: PlanInput): number | null { export function planEndYear(plan: PlanInput): number | null {
const y = plan.planningHorizonYears; const total = plan.phases.reduce((n, p) => n + Math.max(0, p.durationYears), 0);
return typeof y === "number" && y > 0 ? y : null; return total > 0 ? total : null;
} }
// Alter, das eine Person am Ende der Planung erreicht. Read-only-Anzeige neben dem Horizont. // Alter, das eine Person am Ende der Planung erreicht. Read-only-Anzeige neben dem Horizont.
@@ -69,35 +70,6 @@ export function endAgeOf(plan: PlanInput, role: PersonRole): number | null {
return years !== null && person ? person.age + years : null; return years !== null && person ? person.age + years : null;
} }
export interface HorizonChange {
lastPhaseId: string;
oldDuration: number;
newDuration: number;
blocked: string | null;
}
// Was muesste an der letzten Phase geschehen, damit der Plan genau bis zum Horizont laeuft?
// Dieselbe Richtung wie beim Pensionsalter: Zahl stellen, Struktur folgt.
export function planHorizonChange(plan: PlanInput, horizonYears: number): HorizonChange | null {
const phases = sortedPhases(plan);
const last = phases[phases.length - 1];
if (!last) return null;
const before = phases.slice(0, -1).reduce((s, p) => s + p.durationYears, 0);
const newDuration = horizonYears - before;
const change: HorizonChange = {
lastPhaseId: last.id,
oldDuration: last.durationYears,
newDuration,
blocked: null,
};
if (newDuration < 1) {
change.blocked =
"Der Horizont liegt vor dem Ende der zweitletzten Lebensphase. Die letzte Phase muss mindestens ein Jahr dauern kürze zuerst eine frühere Phase oder verlängere den Horizont.";
}
return change;
}
// Analysiert für JEDE Person, ob und wie weit sich ihr Pensionsalter verschieben lässt. // Analysiert für JEDE Person, ob und wie weit sich ihr Pensionsalter verschieben lässt.
export function retirementBoundaries(plan: PlanInput): RetirementBoundary[] { export function retirementBoundaries(plan: PlanInput): RetirementBoundary[] {
const phases = sortedPhases(plan); const phases = sortedPhases(plan);
+154
View File
@@ -0,0 +1,154 @@
// Die Übersicht der offenen Punkte (0.39).
//
// Sie ersetzt den FPT-Assistenten. Getestet wird die Regel dahinter: Wann gilt etwas als
// offen, wann als erledigt -- und die beiden Sonderzustände am Anfang, in denen eine Zahl
// eine Lüge wäre.
import { describe, it, expect } from "vitest";
import { computePlan } from "@/lib/calculations";
import { reviewPlan, unconfirmedCells, isCellConfirmed } from "@/lib/review";
import type { PhaseData } from "@/lib/elements";
import type { PlanInput } from "@/lib/types";
function plan(over: Partial<PlanInput> = {}): PlanInput {
return {
id: "plan",
name: "T",
householdType: "SINGLE",
inflationRateDefault: 0,
initialCash: 0,
startYear: 2026,
persons: [{ id: "A", role: "PERSON_A", name: null, age: 40, retirementAge: 65 }],
phases: [],
elements: [],
...over,
};
}
function phase(id: string, seq: number, years: number, ratesConfirmed = true) {
return { id, sequenceNumber: seq, name: id, durationYears: years, cashTransition: { mode: "NONE" as const }, ratesConfirmed };
}
let n = 0;
function el(category: string, phaseValues: Record<string, PhaseData> = {}) {
return {
id: `e${n++}`,
category: category as never,
name: category,
ownerRole: "HOUSEHOLD" as never,
orderIndex: n,
phaseValues,
transitionValues: {},
baseData: {},
};
}
const ok = { confirmed: true };
describe("Die beiden Startzustände", () => {
// Ohne Elemente und ohne Phasen gibt es naturgemäss nichts Offenes -- der Plan ist trotzdem
// leer. Eine «0» wäre dort eine Lüge, deshalb eigene Zustände.
it("meldet einen Plan ohne Elemente als NO_ELEMENTS, nicht als fertig", () => {
const p = plan();
const r = reviewPlan(p, computePlan(p));
expect(r.stage).toBe("NO_ELEMENTS");
expect(r.total).toBe(0);
});
it("meldet Elemente ohne Lebensphasen als NO_PHASES", () => {
const p = plan({ elements: [el("OTHER_ASSET")] });
expect(reviewPlan(p, computePlan(p)).stage).toBe("NO_PHASES");
});
});
describe("Offene Punkte je Phase", () => {
it("zählt jede unbestätigte Zelle", () => {
const p = plan({
phases: [phase("p1", 1, 10)],
elements: [el("INCOME", { p1: {} }), el("EXPENSE", { p1: {} }), el("OTHER_ASSET", { p1: ok })],
});
const c = computePlan(p);
expect(unconfirmedCells(p, c, "p1")).toHaveLength(2);
const r = reviewPlan(p, c);
expect(r.stage).toBe("OPEN");
expect(r.total).toBe(2);
expect(r.groups[0].reasons[0]).toContain("2 Annahmen");
});
it("zählt die nicht verteilte Sparquote eigens", () => {
// Man kann jede Zelle angeschaut und die Quote trotzdem nie verteilt haben -- dann bliebe
// alles still auf dem Cash-Konto.
const p = plan({
phases: [phase("p1", 1, 10, false)],
elements: [el("INCOME", { p1: ok })],
});
const r = reviewPlan(p, computePlan(p));
expect(r.total).toBe(1);
expect(r.groups[0].reasons[0]).toContain("Sparquote nicht verteilt");
});
it("ist fertig, wenn alles bestätigt und verteilt ist", () => {
const p = plan({
phases: [phase("p1", 1, 10)],
elements: [el("INCOME", { p1: ok }), el("EXPENSE", { p1: ok })],
});
const r = reviewPlan(p, computePlan(p));
expect(r.stage).toBe("DONE");
expect(r.total).toBe(0);
expect(r.groups).toHaveLength(0);
});
it("verlangt keine Bestätigung für eine Zelle ohne Annahmen", () => {
// Seit 0.40 stehen Bestände in den Stammdaten und Raten im Verteil-Dialog. Eine
// «Sonstige Schuld» trägt in der Phasenzelle damit gar nichts mehr -- eine Bestätigung
// wäre eine Frage ohne Gegenstand.
const p = plan({
phases: [phase("p1", 1, 10)],
elements: [el("OTHER_DEBT", { p1: {} })],
});
const c = computePlan(p);
expect(unconfirmedCells(p, c, "p1")).toHaveLength(0);
expect(reviewPlan(p, c).stage).toBe("DONE");
});
it("verlangt keine Bestätigung für ein verkauftes Element", () => {
// Ein verkauftes Haus trägt in der Folgephase keine Annahmen mehr.
const p = plan({
phases: [phase("p1", 1, 5), phase("p2", 2, 5)],
elements: [
el("INCOME", { p1: ok, p2: ok }),
{
...el("REAL_ESTATE", { p1: { ...ok, purchasePrice: 800000, mortgage: 500000 }, p2: {} }),
transitionValues: { p1: { decision: "SELL" as const, salePrice: 900000 } },
},
],
});
const c = computePlan(p);
// In p2 ist die Immobilie verkauft -- nur das Einkommen zählt, und das ist bestätigt.
expect(unconfirmedCells(p, c, "p2")).toHaveLength(0);
});
});
describe("Bestätigen friert nichts ein", () => {
// Der wichtigste Punkt: Der Haken steht NEBEN den Werten. Eine bestätigte Phase erbt
// weiterhin live aus der Vorphase -- sonst wäre die Feld-Vererbung hinfällig.
it("lässt die Vererbung unberührt", () => {
const p = plan({
phases: [phase("p1", 1, 10), phase("p2", 2, 10)],
elements: [
{
...el("OTHER_ASSET"),
baseData: { startValue: 100000, expectedReturn: 5 },
// p2 ist bestätigt, hat aber KEINEN eigenen Renditewert -- sie muss erben.
phaseValues: { p1: ok, p2: ok },
},
],
});
const c = computePlan(p);
const p2 = c.phases[1].elements.find((e) => e.category === "OTHER_ASSET")!;
// 100'000 über 20 Jahre zu 5 % -- die Rendite gilt in beiden Phasen. Auf den Franken
// genau geht es nicht: Der Rechenkern rundet an jeder Phasengrenze.
expect(p2.endValue).toBeCloseTo(100000 * Math.pow(1.05, 20), -1);
expect(isCellConfirmed(p, "p2", p.elements[0].id)).toBe(true);
});
});
+131
View File
@@ -0,0 +1,131 @@
// Was ist in diesem Plan noch offen?
//
// Ersetzt den FPT-Assistenten. Der Unterschied ist nicht die Groesse, sondern der Charakter:
// Der Assistent war ein ABLAUF ("tu dies, dann das") und funktionierte nur beim ersten
// Aufsetzen -- wer einen bestehenden Plan oeffnete, bekam Schritte angeboten, die laengst
// erledigt waren. Diese Uebersicht ist ein ZUSTAND. Sie leitet ab, was tatsaechlich fehlt,
// und traegt damit bei jedem Plan, in jeder Reihenfolge, auch beim zwanzigsten Szenario.
//
// Der Mechanismus dahinter ist derselbe, den die Uebergaenge seit 0.35 haben, jetzt auf
// PHASENWERTE ausgeweitet: Eine neue Lebensphase uebernimmt alle Werte der Vorphase, aber sie
// gelten als unbestaetigt, bis jemand hingeschaut hat. Vorbelegen ja, stillschweigend
// uebernehmen nein.
//
// WICHTIG: Bestaetigen heisst "ich habe hingeschaut", NICHT "festnageln". Der Haken steht
// neben den Werten, er kopiert sie nicht in die Phase -- sonst waere jede bestaetigte Phase
// eingefroren und die Feld-Vererbung (SPEZIFIKATION 3.12.4) waere hinfaellig.
import { openTransitionCount, type DecisionCounts } from "@/lib/decisions";
import type { PlanComputed } from "@/lib/calculations";
import type { PlanInput } from "@/lib/types";
// Wo steht der Plan insgesamt? Die ersten beiden Zustaende sind KEINE Zaehlung offener
// Punkte -- ohne Elemente und ohne Phasen gibt es naturgemaess nichts Offenes, der Plan ist
// aber trotzdem leer. Diese beiden Faelle brauchen eine Aufforderung, keine Statistik.
export type ReviewStage = "NO_ELEMENTS" | "NO_PHASES" | "OPEN" | "DONE";
export interface ReviewGroup {
// Sprungziel: Phasen-Id bzw. die Von-Phase eines Uebergangs.
id: string;
kind: "phase" | "transition";
title: string;
open: number;
// Kurze Klartexte, was genau fehlt.
reasons: string[];
}
export interface PlanReview {
stage: ReviewStage;
total: number;
groups: ReviewGroup[];
}
// Kategorien, die in einer Phasenzelle ueberhaupt eine Annahme tragen. Seit 0.40 entstehen
// Bestaende in den Stammdaten und jaehrliche Raten im Verteil-Dialog; in der Zelle bleiben nur
// noch die Annahmen -- und die hat nicht jede Kategorie. Eine "Sonstige Schuld" traegt keine:
// Restschuld und Tilgung stehen anderswo. Eine Bestaetigung fuer eine leere Zelle zu
// verlangen, waere eine Frage ohne Gegenstand.
const CATEGORIES_WITH_ASSUMPTIONS = new Set([
"INCOME", // Lohnentwicklung
"EXPENSE", // Teuerung
"PENSION_FUND", // Verzinsung
"PILLAR_3A", // Rendite
"OTHER_ASSET", // Rendite
"REAL_ESTATE", // Hypothekarzins, Wertsteigerung, Zins-in-Ausgaben
"AHV", // Beitragsluecken, Beitrag als Nichterwerbstaetige(r)
]);
// Braucht dieses Element in dieser Phase eine Bestaetigung? Es muss in der Phase AKTIV sein
// (ein verkauftes Haus traegt keine Annahmen mehr) UND ueberhaupt eine Annahme tragen.
export function needsConfirmation(
computed: PlanComputed,
phaseId: string,
elementId: string
): boolean {
const ph = computed.phases.find((p) => p.id === phaseId);
const ce = ph?.elements.find((e) => e.elementId === elementId);
return !!ce && ce.status === "ACTIVE" && CATEGORIES_WITH_ASSUMPTIONS.has(ce.category);
}
// Ist der Phasenwert dieses Elements bestaetigt?
export function isCellConfirmed(plan: PlanInput, phaseId: string, elementId: string): boolean {
return plan.elements.find((e) => e.id === elementId)?.phaseValues[phaseId]?.confirmed === true;
}
// Unbestaetigte Zellen einer Phase.
export function unconfirmedCells(plan: PlanInput, computed: PlanComputed, phaseId: string): string[] {
return plan.elements
.filter((e) => needsConfirmation(computed, phaseId, e.id) && !isCellConfirmed(plan, phaseId, e.id))
.map((e) => e.id);
}
export function reviewPlan(plan: PlanInput, computed: PlanComputed): PlanReview {
if (plan.elements.length === 0) return { stage: "NO_ELEMENTS", total: 0, groups: [] };
if (computed.phases.length === 0) return { stage: "NO_PHASES", total: 0, groups: [] };
const groups: ReviewGroup[] = [];
computed.phases.forEach((ph, i) => {
const reasons: string[] = [];
let open = 0;
// 1. Annahmen je Element -- Renditen, Lohnentwicklung, Teuerung, Zins.
const cells = unconfirmedCells(plan, computed, ph.id);
if (cells.length > 0) {
open += cells.length;
reasons.push(`${cells.length} ${cells.length === 1 ? "Annahme" : "Annahmen"} nicht bestätigt`);
}
// 2. Die Spar- bzw. Verzehrquote. Zaehlt eigens: Man kann jede Zelle angeschaut haben und
// die Verteilung trotzdem nie getroffen haben -- dann bleibt alles still auf dem Cash.
const phase = plan.phases.find((p) => p.id === ph.id);
if (phase && phase.ratesConfirmed !== true) {
open += 1;
reasons.push(ph.isConsumption ? "Bezüge nicht verteilt" : "Sparquote nicht verteilt");
}
if (open > 0) groups.push({ id: ph.id, kind: "phase", title: ph.name, open, reasons });
// 3. Der Uebergang NACH dieser Phase.
const next = computed.phases[i + 1];
if (!next) return;
const counts: DecisionCounts = openTransitionCount(plan, computed, ph, next);
const tOpen = counts.open + counts.unconfirmed;
if (tOpen > 0) {
const r: string[] = [];
if (counts.open > 0) r.push(`${counts.open} ${counts.open === 1 ? "Entscheid" : "Entscheide"} offen`);
if (counts.unconfirmed > 0)
r.push(`${counts.unconfirmed} ${counts.unconfirmed === 1 ? "Vorgabe" : "Vorgaben"} ungeprüft`);
groups.push({
id: ph.id,
kind: "transition",
title: `Übergang nach ${next.name}`,
open: tOpen,
reasons: r,
});
}
});
const total = groups.reduce((n, g) => n + g.open, 0);
return { stage: total === 0 ? "DONE" : "OPEN", total, groups };
}
+2 -6
View File
@@ -5,7 +5,6 @@
import type { CashTransitionData, ElementCategory, OwnerRole, PhaseData, TransitionData } from "@/lib/elements"; import type { CashTransitionData, ElementCategory, OwnerRole, PhaseData, TransitionData } from "@/lib/elements";
import type { RetirementDecision } from "@/lib/retirement-decision"; import type { RetirementDecision } from "@/lib/retirement-decision";
import type { AssistantProgress } from "@/lib/assistant";
export type HouseholdType = "SINGLE" | "COUPLE"; export type HouseholdType = "SINGLE" | "COUPLE";
export type PersonRole = "PERSON_A" | "PERSON_B"; export type PersonRole = "PERSON_A" | "PERSON_B";
@@ -25,6 +24,8 @@ export interface PhaseInput {
durationYears: number; durationYears: number;
// Cash-Entscheid beim Übergang NACH dieser Phase (einmalige Sonderein-/ausgaben). // Cash-Entscheid beim Übergang NACH dieser Phase (einmalige Sonderein-/ausgaben).
cashTransition: CashTransitionData; cashTransition: CashTransitionData;
// Wurde die Spar-/Verzehrquote dieser Phase einmal bewusst verteilt?
ratesConfirmed?: boolean;
// Gegenstück im Eltern-Szenario (Diff-Grundlage); null im Basisszenario. // Gegenstück im Eltern-Szenario (Diff-Grundlage); null im Basisszenario.
sourcePhaseId?: string | null; sourcePhaseId?: string | null;
} }
@@ -83,11 +84,6 @@ export interface PlanInput {
// Kalenderjahr des Planbeginns (Jahr 1) -- nur für die Darstellung, nicht für die // Kalenderjahr des Planbeginns (Jahr 1) -- nur für die Darstellung, nicht für die
// Berechnung. Optional, damit Berechnungs-Fixtures es nicht setzen müssen. // Berechnung. Optional, damit Berechnungs-Fixtures es nicht setzen müssen.
startYear?: number | null; startYear?: number | null;
// Laufzeit der Planung in JAHREN. Die Endalter je Person werden daraus abgeleitet. Fehlt
// der Wert, ergibt sich das Planende wie bisher aus der Summe der Phasendauern.
planningHorizonYears?: number | null;
// Fortschritt des FPT-Assistenten (sieben Schritte).
assistantProgress?: AssistantProgress | null;
persons: PersonInput[]; persons: PersonInput[];
phases: PhaseInput[]; phases: PhaseInput[];
elements: ElementInput[]; elements: ElementInput[];
-2
View File
@@ -34,8 +34,6 @@ const EXEMPT: Record<string, string> = {
"plans/[planId]/actuals/[setId]": "dito (Löschen eines Ist-Satzes)", "plans/[planId]/actuals/[setId]": "dito (Löschen eines Ist-Satzes)",
"plans/[planId]/analyses": "gespeicherte Analysen sind read-only Momentaufnahmen kein Szenario betroffen", "plans/[planId]/analyses": "gespeicherte Analysen sind read-only Momentaufnahmen kein Szenario betroffen",
"plans/[planId]/analyses/[analysisId]": "dito (Öffnen/Löschen einer Analyse)", "plans/[planId]/analyses/[analysisId]": "dito (Öffnen/Löschen einer Analyse)",
"scenarios/[scenarioId]/assistant":
"das Abhaken eines Assistenten-Schritts ist eine Notiz des Benutzers über sich selbst, keine Planänderung eine Version dafür würde die Historie mit inhaltsgleichen Einträgen fluten",
"plans/[planId]/reports": "Berichte sind erzeugte Dokumente sie verändern kein Szenario", "plans/[planId]/reports": "Berichte sind erzeugte Dokumente sie verändern kein Szenario",
"plans/[planId]/reports/[reportId]": "dito (Herunterladen/Löschen eines Berichts)", "plans/[planId]/reports/[reportId]": "dito (Herunterladen/Löschen eines Berichts)",
}; };