Compare commits
15 Commits
cc61a120ee
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| a3c86fc559 | |||
| 5842a84b20 | |||
| ff862ed030 | |||
| f41c3614b5 | |||
| 113616cc1f | |||
| f9d7ca07fd | |||
| ce987b9978 | |||
| 5ff9bc0709 | |||
| f22b0a3f27 | |||
| 6ff144d7e1 | |||
| 2f6b788bd4 | |||
| 42d5585969 | |||
| c440063a93 | |||
| 79fb2b9ca6 | |||
| 2f762175d2 |
+660
-141
@@ -4,10 +4,10 @@
|
|||||||
| | |
|
| | |
|
||||||
|---|---|
|
|---|---|
|
||||||
| **Dokument** | Funktionale und Technische Spezifikation FPT |
|
| **Dokument** | Funktionale und Technische Spezifikation FPT |
|
||||||
| **Version** | 0.35 |
|
| **Version** | 0.42.3 |
|
||||||
| **Datum** | 2026-07-25 |
|
| **Datum** | 2026-07-25 |
|
||||||
| **Status** | Lebendes Dokument |
|
| **Status** | Lebendes Dokument |
|
||||||
| **Codestand** | Arbeitsstand nach `1865db5` inkl. Pensionierung als Eigenschaft der Person (Branch `main`) |
|
| **Codestand** | Arbeitsstand nach `5842a84` inkl. Zeitstrahl (Branch `main`) |
|
||||||
| **Ersetzt** | `FDD_TDD_FPT.docx` (v1–v5) im Ordner `Info Dateien` – diese sind ab Version 0.1 dieses Dokuments obsolet |
|
| **Ersetzt** | `FDD_TDD_FPT.docx` (v1–v5) im Ordner `Info Dateien` – diese sind ab Version 0.1 dieses Dokuments obsolet |
|
||||||
| **Geltungsbereich** | Gesamter Code im Verzeichnis `FPT` |
|
| **Geltungsbereich** | Gesamter Code im Verzeichnis `FPT` |
|
||||||
|
|
||||||
@@ -17,6 +17,18 @@
|
|||||||
|
|
||||||
| 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.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 1–5 Jahren, Teilbezug 20–80 %. 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` (0–100 %). 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** (60–70) 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 1–5 Jahren, Teilbezug 20–80 %. 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` (0–100 %). 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** (60–70) 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). |
|
||||||
| 0.33 | 2026-07-25 | Claude (Opus 5) | **Modul-Review 4 (Matrix: Phasen und Elemente).** (1) **Kapitalverwendung neu am Vorsorge-Element** (Punkt C aus Roadmap Nr. 44): Die Prozent-Aufteilung des bezogenen Alterskapitals hing am **Cash-Übergang** – dem falschen Ort, denn mit zwei Guthaben (PK und 3a) liess sie sich dort gar nicht getrennt beantworten. Sie steht jetzt beim **Bezugs-Entscheid** der Pensionskasse (nur bei Kapitalbezug) bzw. der **Säule 3a**. Beide Dialoge führen neu **brutto → Steuersatz → netto** und darunter die Verteilung. Der zugeteilte Betrag fliesst über den regulären Weg (`Carry.capitalIn` → Zusatzeinlage der Folgephase) und ist damit **überall sichtbar**: am Ziel-Element, in der Cash-Brücke als Investition und im «Kapital verteilen»-Dialog. Vorher erhöhte er still den Bestand, weshalb Element und Dialog eine **0** zeigten. Die **Säule 3a** ist am Pensions-Übergang neu ein **offener Entscheid** (Steuersatz und Verwendung); vorher galt sie als automatisch beantwortet. (2) **Phasendauer: die Folgephase gleicht aus** (Kap. 3.3.2). Bis 0.32 prüfte die Kappung nur die **bearbeitete** Phase – wurde Phase 1 von 10 auf 12 Jahre verlängert, überspannte danach Phase 2 die Pensionierung, und die tragende Invariante aus Roadmap Nr. 44 kippte. Neu trägt die Folgephase die Differenz (Gesamtdauer bleibt gleich, wie beim Verschieben des Pensionsalters); passt sie nicht, wird blockiert; vorher erscheint eine Rückfrage. Neue reine Funktion `planDurationChange`. (3) **Element und Phase direkt bedienbar:** In der Matrix tragen Element-Zeile und Phasenkopf neu **Stift** (umbenennen, beim Element inkl. **Zuordnung**) und **Papierkorb**; das Expand-Symbol ist **immer** sichtbar statt nur bei Mouseover. `PATCH /api/elements/<id>` nimmt dafür neu auch `ownerRole` (bleibt für AHV/PK/3a personengebunden). (4) **Hilfetexte** werden über ein **Portal** gezeichnet – in scrollenden Dialogen schnitt der Container sie vorher ab; sie klappen nach oben, wenn unten kein Platz ist. (5) **Verteil-Dialoge:** Zeilen zeigen die **Zuordnung** (Person A/B/Gemeinsam) und sind nach **«vom Cash»/«ins Cash»** gruppiert; die Vorbelegung nutzt neu den **effektiven** Wert inklusive Vererbung aus der Vorphase – ein geerbter 3a-Beitrag erschien vorher als 0. (6) **Matrix:** alle Phasenspalten **gleich breit**, bei vielen Phasen wird horizontal gescrollt; **«Alle auf-/zuklappen»**; eine zugeklappte Kategorie zeigt je Phase die **Summe** ihrer Elemente. (7) **Phasen-Detailansicht** nutzt die neue Aufteilungs-Grafik (Fläche + Ring) statt der alten Balken. (8) **Übersicht:** «Leer starten» steht neu auch im leeren Zustand zur Wahl. (9) Nebenbei: dritte vom Umlaut-Sweep verstümmelte Hex-Farbe (`#7c3äd`) repariert, das Phasen-Panel nutzt den eigenen Bestätigungs-Dialog statt `window.confirm`. 10 Tests ergänzt (278 → 288). |
|
| 0.33 | 2026-07-25 | Claude (Opus 5) | **Modul-Review 4 (Matrix: Phasen und Elemente).** (1) **Kapitalverwendung neu am Vorsorge-Element** (Punkt C aus Roadmap Nr. 44): Die Prozent-Aufteilung des bezogenen Alterskapitals hing am **Cash-Übergang** – dem falschen Ort, denn mit zwei Guthaben (PK und 3a) liess sie sich dort gar nicht getrennt beantworten. Sie steht jetzt beim **Bezugs-Entscheid** der Pensionskasse (nur bei Kapitalbezug) bzw. der **Säule 3a**. Beide Dialoge führen neu **brutto → Steuersatz → netto** und darunter die Verteilung. Der zugeteilte Betrag fliesst über den regulären Weg (`Carry.capitalIn` → Zusatzeinlage der Folgephase) und ist damit **überall sichtbar**: am Ziel-Element, in der Cash-Brücke als Investition und im «Kapital verteilen»-Dialog. Vorher erhöhte er still den Bestand, weshalb Element und Dialog eine **0** zeigten. Die **Säule 3a** ist am Pensions-Übergang neu ein **offener Entscheid** (Steuersatz und Verwendung); vorher galt sie als automatisch beantwortet. (2) **Phasendauer: die Folgephase gleicht aus** (Kap. 3.3.2). Bis 0.32 prüfte die Kappung nur die **bearbeitete** Phase – wurde Phase 1 von 10 auf 12 Jahre verlängert, überspannte danach Phase 2 die Pensionierung, und die tragende Invariante aus Roadmap Nr. 44 kippte. Neu trägt die Folgephase die Differenz (Gesamtdauer bleibt gleich, wie beim Verschieben des Pensionsalters); passt sie nicht, wird blockiert; vorher erscheint eine Rückfrage. Neue reine Funktion `planDurationChange`. (3) **Element und Phase direkt bedienbar:** In der Matrix tragen Element-Zeile und Phasenkopf neu **Stift** (umbenennen, beim Element inkl. **Zuordnung**) und **Papierkorb**; das Expand-Symbol ist **immer** sichtbar statt nur bei Mouseover. `PATCH /api/elements/<id>` nimmt dafür neu auch `ownerRole` (bleibt für AHV/PK/3a personengebunden). (4) **Hilfetexte** werden über ein **Portal** gezeichnet – in scrollenden Dialogen schnitt der Container sie vorher ab; sie klappen nach oben, wenn unten kein Platz ist. (5) **Verteil-Dialoge:** Zeilen zeigen die **Zuordnung** (Person A/B/Gemeinsam) und sind nach **«vom Cash»/«ins Cash»** gruppiert; die Vorbelegung nutzt neu den **effektiven** Wert inklusive Vererbung aus der Vorphase – ein geerbter 3a-Beitrag erschien vorher als 0. (6) **Matrix:** alle Phasenspalten **gleich breit**, bei vielen Phasen wird horizontal gescrollt; **«Alle auf-/zuklappen»**; eine zugeklappte Kategorie zeigt je Phase die **Summe** ihrer Elemente. (7) **Phasen-Detailansicht** nutzt die neue Aufteilungs-Grafik (Fläche + Ring) statt der alten Balken. (8) **Übersicht:** «Leer starten» steht neu auch im leeren Zustand zur Wahl. (9) Nebenbei: dritte vom Umlaut-Sweep verstümmelte Hex-Farbe (`#7c3äd`) repariert, das Phasen-Panel nutzt den eigenen Bestätigungs-Dialog statt `window.confirm`. 10 Tests ergänzt (278 → 288). |
|
||||||
@@ -302,15 +314,13 @@ die ersten 72 Byte), keine ARIA-Labels auf der Login-Maske und der Befehls-Palet
|
|||||||
|
|
||||||
### 3.2.1 Plan erstellen
|
### 3.2.1 Plan erstellen
|
||||||
|
|
||||||
Der «+»-Knopf öffnet eine Auswahl mit drei Wegen:
|
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
|
||||||
|
frühere Auswahl aus drei Wegen entfallen ist, siehe
|
||||||
|
[3.2.8](#328-der-einstieg-ein-weg-eine-tour-eine-bestandsaufnahme).
|
||||||
|
|
||||||
| Weg | Für wen | Was passiert |
|
Der Dialog fragt Name plus Grundprofil -- **ohne Pensionsalter**, das gehört in die
|
||||||
|---|---|---|
|
Pensionsplanung:
|
||||||
| **Geführt erstellen** (empfohlen) | neue Nutzer | der Assistent aus [3.2.8](#328-geführter-assistent-und-beispielplan) |
|
|
||||||
| **Leer starten** | geübte Nutzer | der bisherige Dialog (unten) – nur Grundprofil, keine Phasen/Elemente |
|
|
||||||
| **Beispielplan ansehen** | Erkunden | legt einen fiktiven, voll ausgefüllten Plan an ([3.2.8](#328-geführter-assistent-und-beispielplan)) |
|
|
||||||
|
|
||||||
Der Dialog «Leer starten» fragt Name plus das vollständige Grundprofil:
|
|
||||||
|
|
||||||
| Feld | Typ | Default | Wertebereich |
|
| Feld | Typ | Default | Wertebereich |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
@@ -428,105 +438,39 @@ 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 Geführter Assistent und Beispielplan
|
### 3.2.8 Der Einstieg: ein Weg, eine Tour, eine Bestandsaufnahme
|
||||||
|
|
||||||
**Der Plan-Assistent** (Roadmap Nr. 10: «Schritt für Schritt statt leerer Matrix») fragt in
|
Bis 0.35 standen im leeren Zustand **drei** Knöpfe: geführt starten, Beispielplan ansehen, leer
|
||||||
**sieben** Schritten in Alltagssprache: (1) Grundprofil, (2) Lebensphasen, (3) Einkommen und
|
starten. Das ist eine Wahl, die niemand treffen kann, der das Tool noch nicht kennt -- und sie
|
||||||
Ausgaben plus Kontostand, (4) Vorsorge und Vermögen (**nur Bestandswerte**), (5) Sparen und
|
kostete den ersten Eindruck.
|
||||||
Verteilen, (6) **Deine Pensionierung**, (7) Zusammenfassung. Im **Einzelmodus** durchgehend in
|
|
||||||
**Du-Form** («Was verdienst du?»); im Paarmodus je Person bzw. «ihr».
|
|
||||||
|
|
||||||
**Schritt 6 ist ein Überblick, kein Erfassungsschritt.** An dieser Stelle weiss niemand, wie
|
Seit 0.36 gibt es genau einen Weg:
|
||||||
hoch seine PK-Rente sein wird – danach zu fragen hiesse, eine unbeantwortbare Frage zu stellen.
|
|
||||||
Die Antwort zu *zeigen* ist dagegen der stärkste Moment im ganzen Onboarding: Der Schritt weist
|
|
||||||
**Rentenlücke** und **Kapitalreichweite** aus und darunter die Vorgaben zu AHV, Pensionskasse
|
|
||||||
und Säule 3a ([3.13](#313-pensionierung)) – über exakt dieselben Bausteine wie der
|
|
||||||
Pensionierungs-Bildschirm. Ändern kann man sie hier, muss aber nicht.
|
|
||||||
|
|
||||||
Gerechnet wird die Vorschau, **bevor der Plan existiert**: `computePlan` ist rein und läuft im
|
```
|
||||||
Browser in Bruchteilen einer Millisekunde. Damit Vorschau und erzeugter Plan nicht auseinander-
|
"Meinen ersten Finanzplan anlegen"
|
||||||
laufen können, speist **eine einzige Element-Liste** (`elementSpecs`) beides – die Vorschau und
|
|
|
||||||
die Anlage über die API. Zwei Aufbauten desselben Plans wären garantiert irgendwann verschieden,
|
v
|
||||||
und die Vorschau zeigte dann Zahlen, die der erzeugte Plan nie hat. Die im Schritt getroffenen
|
Plan-Dialog (sechs Felder)
|
||||||
Entscheide werden **nach** dem Anlegen geschrieben; vorher gibt es keine Element-Ids, an denen
|
|
|
||||||
sie hängen könnten.
|
v
|
||||||
|
Basisszenario, leer -> Tour (Demo-Popup) -> Bestandsaufnahme
|
||||||
|
```
|
||||||
|
|
||||||
**Schritt 2 ist an den fixen Pensionierungszeitpunkten ausgerichtet.** Das Pensionsalter jeder
|
**Der Plan-Dialog** fragt nur noch: Name des Plans, Haushaltsform, Namen der Personen
|
||||||
Person ist ein Fixpunkt auf der Lebenslinie; dazwischen entstehen Abschnitte mit konstantem
|
(freiwillig), Startjahr, Alter, Inflation. Dasselbe Fenster öffnet auch das Plus in der
|
||||||
Erwerbsstatus (reines Modul `phaseplan.ts`, `planSegments`):
|
Seitenleiste.
|
||||||
|
|
||||||
| Abschnitt | Bedeutung | Länge |
|
**Das Pensionsalter wird bewusst NICHT gefragt.** Es ist kein Stammdatum, sondern der erste
|
||||||
|---|---|---|
|
Entscheid der Pensionsplanung -- und es erzeugt eine Phasengrenze
|
||||||
| **Erwerb** | alle arbeiten | fest (bis zur ersten Pensionierung) |
|
([3.14.5](#3145-fixpunkte-jeder-bezugsbeginn-erzwingt-eine-phasengrenze)). Im Basisszenario
|
||||||
| **Misch** | eine Person pensioniert, eine arbeitet | fest (zwischen den Pensionierungen) |
|
gilt durchgehend das Referenzalter 65.
|
||||||
| **Pension** | alle pensioniert | **offen** (Lebensdauer frei) |
|
|
||||||
|
|
||||||
Die **Anzahl** Abschnitte wird abgeleitet, nicht vorgegeben: Einzelplan → 2 (Erwerb, Pension);
|
**Der frühere Plan-Assistent (`PlanWizard`) und der Beispielplan sind entfallen.** Der Wizard
|
||||||
Paar mit gleichem Pensionsalter → 2 (keine Mischzeit); Paar mit unterschiedlichem Pensionsalter
|
führte VOR dem Plan durch ein Formular und liess einen danach mit der Matrix allein. Was der
|
||||||
→ 3. Ist eine Person bei Planbeginn bereits pensioniert, beginnt die Linie mit einem Misch- oder
|
Beispielplan leistete -- einmal sehen, wie ein gefüllter Plan aussieht --, übernimmt die Tour;
|
||||||
Pensions-Abschnitt.
|
was danach zu tun ist, sagt die Übersicht der offenen Punkte
|
||||||
|
([3.14](#314-bestandsaufnahme-und-offene-punkte)).
|
||||||
|
|
||||||
In jedem **fest begrenzten** Abschnitt verteilt der Nutzer beliebig viele Phasen (mit
|
|
||||||
+/Papierkorb und eigenem Namen je Phase); eine **Live-Summe** erzwingt, dass die Phasendauern
|
|
||||||
exakt der festen Länge entsprechen – «Weiter» bleibt gesperrt, bis es aufgeht. Das ist zwingend:
|
|
||||||
Die Berechnung leitet den Phasentyp am Phasenbeginn ab und **kappt jede Phase am nächsten
|
|
||||||
Pensionsereignis** ([2.3](#23-phasentyp--abgeleitet-nicht-gespeichert)) – eine Phase, die eine
|
|
||||||
Pensionierung überspannt, gäbe es nicht. Der frühere Assistent liess die Erwerbsphase beliebig
|
|
||||||
über das Pensionsalter hinaus setzen; das ist damit behoben. Der **offene** Pensions-Abschnitt
|
|
||||||
nimmt beliebige Dauern (Summe = geplante Restlebensdauer).
|
|
||||||
|
|
||||||
Eine **Zeitachse** zeigt den proportionalen Verlauf mit den Pensionierungs-Fixpunkten als
|
|
||||||
Flaggen; die Phasen sind nummeriert und **unter** dem Balken beschriftet, damit auch kurze
|
|
||||||
Phasen lesbar bleiben.
|
|
||||||
|
|
||||||
**Schritt 4 (Vorsorge & Vermögen)** ist bei Paaren in **Gemeinsam / Person A / Person B**
|
|
||||||
aufgeteilt. Pensionskasse und Säule 3a sind immer persönlich (personengebundene Kategorien);
|
|
||||||
Wertschriften, Wohneigentum und Schulden lassen sich gemeinsam **oder** je Person erfassen. Hier
|
|
||||||
werden nur die **heutigen Bestandswerte** erfasst (Guthaben, Kaufpreis, Hypothek, Restschuld) –
|
|
||||||
die laufenden Jahresbeträge folgen in Schritt 5. Zusätzlich fragt das Wohneigentum die
|
|
||||||
**Wertsteigerung** und den **Zins-in-Ausgaben-Schalter** ab (damit Hypothekarzinsen nicht
|
|
||||||
doppelt zählen). Der Schalter **«Selbstständig ohne PK (grosse Säule 3a)»** der Säule 3a steht
|
|
||||||
in **Schritt 5** (direkt bei der 3a-Einzahlung, denn er betrifft deren Obergrenze) und hebt die
|
|
||||||
Beitrags-Obergrenze an (siehe 4.11 / Feld `selfEmployed3a`).
|
|
||||||
|
|
||||||
**Schritt 5 (Sparen & Verteilen)** bringt das Kernmodell des Tools zum Anfassen: Aus
|
|
||||||
`Nettoeinkommen − Ausgaben` entsteht die **Sparquote**; der Nutzer verteilt sie auf Säule 3a,
|
|
||||||
Wertschriften, Amortisation und Schuldtilgung, und der **noch nicht verteilte Rest** steht
|
|
||||||
**prominent zwischen PK-Block und Verteilung** und bleibt auf dem Cash-Konto (live gerechnet,
|
|
||||||
negativer Rest wird gewarnt). Die Verteilung ist nach **Gemeinsam / Person A / Person B**
|
|
||||||
gruppiert. Hypothekarzinsen, die im vorigen Schritt als «noch nicht in den Ausgaben» markiert
|
|
||||||
sind, rechnet die Sparquote-Vorschau zu den Ausgaben dazu – so wie der Rechenkern bei
|
|
||||||
`interestHandling: ADD`.
|
|
||||||
|
|
||||||
**Vor Schritt 1** steht ein **Willkommens-Screen** mit dem Gesamtbild der fünf Schritte (Icon,
|
|
||||||
Titel, ein Satz je Schritt); danach begleitet eine **persistente Schritt-Leiste** den ganzen
|
|
||||||
Ablauf (links im breiten Modal, auf schmalen Screens als Fortschrittsbalken): aktueller Schritt
|
|
||||||
hervorgehoben mit Kurzbeschreibung, erledigte mit Haken, kommende gedämpft. Der Nutzer weiss so
|
|
||||||
jederzeit, wo er steht und was noch folgt. Die **Zusammenfassung** ist der Abschluss und trägt
|
|
||||||
keine eigene Schritt-Nummer. Die **PK-Einzahlung** steht bewusst
|
|
||||||
in einem **eigenen** Block mit dem Hinweis, dass sie vom **Bruttolohn** bezahlt wird – also
|
|
||||||
**vor** dem Nettoeinkommen – und die Sparquote deshalb **nicht** schmälert. Das deckt sich exakt
|
|
||||||
mit dem Rechenkern, wo der PK-Beitrag nicht zur Quote zählt ([4.6.3](#463-pension_fund)). Der
|
|
||||||
Schritt entfällt fachlich nie, aber wenn keine Spar-/Vorsorgeposten angehakt sind, weist er nur
|
|
||||||
darauf hin, dass der ganze Betrag auf dem Cash-Konto wächst.
|
|
||||||
|
|
||||||
Zwei bewusste Entscheide bleiben:
|
|
||||||
- **Einkommen wird pro Person erfasst**, nie als «Gemeinsam» – in Paar-Plänen zählt
|
|
||||||
Haushalts-Einkommen nicht für die AHV ([9.9](#99-gemeinsames-einkommen-zählt-bei-paaren-nicht-für-die-ahv));
|
|
||||||
der Assistent räumt diese Falle von Anfang an aus.
|
|
||||||
- Technisch ist der Assistent **reine Orchestrierung bestehender Endpunkte** (Plan → Phase 1 →
|
|
||||||
Elemente samt Werten → Folgephasen; die Reihenfolge stellt sicher, dass die Phasen-Route die
|
|
||||||
Folgephasen korrekt vorbelegt). Kein neuer Endpunkt, keine Berechnungsänderung. Grenze: 9.23.
|
|
||||||
|
|
||||||
**Der Beispielplan** («Beispiel: Alex Muster», `src/lib/demoplan.ts`) ist ein fiktiver, voll
|
|
||||||
ausgefüllter Plan per Ein-Klick – ebenfalls reine Orchestrierung. Die Übergangs-Entscheide
|
|
||||||
bleiben **absichtlich offen**: Der neue Nutzer sieht die Ampel («N offen») in Aktion und lernt
|
|
||||||
das wichtigste Konzept am Beispiel statt aus einer Erklärung.
|
|
||||||
|
|
||||||
Nach dem ersten Öffnen eines Plans mit Phasen startet einmalig die **Tour**
|
|
||||||
([3.7.8](#378-tour-und-nächste-schritte)).
|
|
||||||
|
|
||||||
## 3.3 Lebensphasen
|
|
||||||
|
|
||||||
### 3.3.1 Phase anlegen
|
### 3.3.1 Phase anlegen
|
||||||
|
|
||||||
@@ -535,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".
|
||||||
@@ -621,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.
|
||||||
|
|
||||||
@@ -811,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
|
||||||
@@ -837,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.
|
||||||
|
|
||||||
@@ -1228,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
|
||||||
@@ -1243,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
|
||||||
@@ -1253,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
|
||||||
@@ -1347,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>`
|
||||||
@@ -1419,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
|
||||||
@@ -1427,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
|
||||||
@@ -1457,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
|
||||||
@@ -1497,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
|
||||||
@@ -2006,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**.
|
||||||
@@ -2019,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.
|
||||||
|
|
||||||
@@ -2139,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
|
||||||
|
|
||||||
@@ -2172,6 +2208,483 @@ 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 Bestandsaufnahme und offene Punkte
|
||||||
|
|
||||||
|
### 3.14.1 Warum aus dem Assistenten eine Übersicht wurde
|
||||||
|
|
||||||
|
Ein Assistent ist ein **Ablauf**: tu dies, dann das, dann bist du fertig. Das trägt genau
|
||||||
|
einmal -- beim ersten Aufsetzen. Wer einen bestehenden Plan öffnete, bekam Schritte
|
||||||
|
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.
|
||||||
|
|
||||||
|
Eine Übersicht ist ein **Zustand**: das ist noch offen. Sie wird bei jedem Rendern aus dem
|
||||||
|
Plan abgeleitet (`reviewPlan`), speichert nichts und kann deshalb nie veralten. Sie trägt bei
|
||||||
|
jedem Plan, in jeder Reihenfolge, auch beim zwanzigsten Szenario.
|
||||||
|
|
||||||
|
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 |
|
||||||
|
|---|---|---|
|
||||||
|
| Bestand bei Planbeginn | Stammdaten – Spalte «Start» bzw. Bestandsaufnahme | Phasenzelle der **ersten** Phase |
|
||||||
|
| Jährliche Rate (Sparbeitrag, Bezugsrate, Amortisation, Tilgung, 3a- und PK-Einzahlung) | «Quote verteilen» | Phasenzelle jeder Phase |
|
||||||
|
| Einmalige Kapitalverwendung (Zusatzeinlage, Sonderamortisation, Sofort-Tilgung) | «Kapital verteilen» | Phasenzelle der Phase, in der das Kapital anfällt |
|
||||||
|
| Annahme (Rendite, Lohnentwicklung, Teuerung, Hypothekarzins, Wertsteigerung) | Phasenzelle | – |
|
||||||
|
| Cash bei Planbeginn | Bestandsaufnahme bzw. Spalte «Start» der Cash-Zeile | – |
|
||||||
|
|
||||||
|
**Warum das kein Kosmetikpunkt ist.** Der Rechenkern legte in der ersten Phase die Phasenwerte
|
||||||
|
über die Stammdaten (`{...baseData, ...phaseValues}`). Ein zweites Eingabefeld für den
|
||||||
|
Startwert bedeutete also: Man tippt in Phase 1 eine Zahl, sie **gewinnt still** gegen die
|
||||||
|
Bestandsaufnahme, und in der Spalte «Start» steht weiterhin die alte. Niemand sieht den
|
||||||
|
Konflikt – man sieht nur, dass eine Korrektur wirkungslos bleibt.
|
||||||
|
|
||||||
|
**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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
Bei den Raten ist der Grund ein anderer, aber nicht kleiner: **Nur der Verteil-Dialog kennt
|
||||||
|
die Quote.** Er zeigt, wie viel überhaupt zu verteilen ist, ob die Summe aufgeht und ob das
|
||||||
|
Cash-Konto dabei ins Minus fällt. Ein Eingabefeld am Element liess sich beliebig darüber
|
||||||
|
hinaus füllen, ohne dass es irgendwo auffiel.
|
||||||
|
|
||||||
|
**Der PK-Beitrag ist die Ausnahme, die die Regel trägt.** Er ist kein Teil der Quote – er
|
||||||
|
stammt aus dem Bruttolohn und belastet das Cash-Konto nicht ([4.6.3](#463-pension_fund)).
|
||||||
|
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.
|
||||||
|
|
||||||
|
**Was die Vererbung angeht, bleibt es beim Verhalten von 0.32:** Der Verteil-Dialog schreibt
|
||||||
|
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)).
|
||||||
|
|
||||||
|
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
|
||||||
|
Schlüssel -- eine Bestandsaufnahme als erster Schritt war damit unmöglich.
|
||||||
|
|
||||||
|
Seit 0.36 trennt `FinancialElement.baseData` zwei Dinge, die nie dasselbe waren:
|
||||||
|
|
||||||
|
| gehört zum **Element** (`baseData`) | gehört zur **Phase** (`phaseValues`) |
|
||||||
|
|---|---|
|
||||||
|
| Bestand bei Planbeginn, Kaufpreis, Anfangshypothek, Restschuld | Sparraten, Amortisation, Bezugsraten |
|
||||||
|
| Ausgangs-Annahmen: Rendite, Wertsteigerung, Zinssatz | abweichende Werte einzelner Phasen |
|
||||||
|
|
||||||
|
Das ist nicht nur ein Kunstgriff für Schritt 1. Ein Startwert war **nie** «phase-1-spezifisch»
|
||||||
|
-- er ist der Stand am Anfang der Planung, und dass er in Phase 1 stand, war eine Eigenheit
|
||||||
|
der Speicherung. Nebenbei löst der Umbau eine alte Unstimmigkeit: Die Feld-Vererbung
|
||||||
|
([3.12.4](#3124-punkt-a-aus-vorphase-übernehmen)) hatte in Phase 1 nichts, von dem sie hätte
|
||||||
|
erben können, und fiel auf 0. Die Stammdaten sind jetzt die **Wurzel** dieser Kette:
|
||||||
|
|
||||||
|
```
|
||||||
|
eigener Phasenwert -> aus der Vorphase geerbt -> Stammdaten -> 0
|
||||||
|
```
|
||||||
|
|
||||||
|
**Die Matrix erscheint, sobald es Elemente gibt** -- Lebensphasen sind dafür nicht nötig. Ihre
|
||||||
|
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.
|
||||||
|
|
||||||
|
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,
|
||||||
|
Pensionskasse und jedes 3a-Konto ein eigenes Bezugsalter haben, sind es bis zu vier:
|
||||||
|
|
||||||
|
| Fixpunkt | Quelle |
|
||||||
|
|---|---|
|
||||||
|
| Erwerbsende | `Person.retirementAge` |
|
||||||
|
| AHV-Rentenbeginn | `ahvStartAge(retirementDecision)` |
|
||||||
|
| PK-Bezug | `pkWithdrawalAge` (neu in 0.36) |
|
||||||
|
| je 3a-Konto | `withdrawalAge` |
|
||||||
|
|
||||||
|
Der Grund ist derselbe wie beim Erwerbsende: Die Rechnung leitet Erwerbsstatus und Bezüge am
|
||||||
|
**Phasenbeginn** ab. Fiele ein Bezug mitten in eine Phase, rutschte er auf die nächste Grenze
|
||||||
|
-- unter Umständen Jahre später, und die Zahlen wären still falsch. `maxPhaseDuration` zählt
|
||||||
|
die Fixpunkte deshalb mit. Ereignisse im selben Jahr teilen sich **eine** Grenze und werden
|
||||||
|
dort mehrfach beschriftet.
|
||||||
|
|
||||||
|
Die Folge ist ehrlich, aber spürbar: Ein Paar mit gestaffelten Bezügen kommt schnell auf acht
|
||||||
|
bis zehn Pflichtphasen.
|
||||||
|
|
||||||
|
### 3.14.6 Der Bildschirm
|
||||||
|
|
||||||
|
Zwei farblich getrennte Hälften, damit sichtbar ist: **oben stellst du ein, unten siehst du
|
||||||
|
das Ergebnis.**
|
||||||
|
|
||||||
|
Oben vier gleichrangige Kacheln plus die Zeitachse über die volle Breite:
|
||||||
|
|
||||||
|
| Kachel | Inhalt |
|
||||||
|
|---|---|
|
||||||
|
| **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 |
|
||||||
|
| **Schnellaktionen** | Neues Szenario · Tour · Änderungshistorie · Rechenwege · CSV-Export |
|
||||||
|
| **Offene Punkte** | siehe 3.14.1 |
|
||||||
|
|
||||||
|
Das **Pensionsalter ist im Basisszenario auf 65 festgelegt** und nicht änderbar. Vorbezug,
|
||||||
|
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»,
|
||||||
|
«+ 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
|
||||||
|
darüber wie Aktionen der ganzen Seite.
|
||||||
|
|
||||||
|
### 3.14.7 Die Tour
|
||||||
|
|
||||||
|
Ein grosses Popup mit einem **nachgebauten** Bildschirm und erfundenen Zahlen, in neun
|
||||||
|
Schritten erklärt. Der letzte führt zur Bestandsaufnahme.
|
||||||
|
|
||||||
|
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
|
||||||
|
gerade dann nicht, wenn die Tour am nötigsten ist. Und das Ausschneiden kämpfte dauernd mit
|
||||||
|
Scroll-Containern und Z-Ebenen ([9.24](#924-tour-spotlight-ohne-engine)).
|
||||||
|
|
||||||
|
Der Preis ist bekannt und bewusst in Kauf genommen: **Die Attrappe muss bei UI-Änderungen
|
||||||
|
nachgeführt werden.** Dafür funktioniert die Tour ab der ersten Sekunde und unabhängig davon,
|
||||||
|
was im Plan schon steht.
|
||||||
|
|
||||||
|
Referenz: `src/lib/review.ts`, `src/components/ReviewTile.tsx`,
|
||||||
|
`src/components/InventoryDialog.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`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# 4. Berechnungsmodell
|
# 4. Berechnungsmodell
|
||||||
@@ -3637,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 |
|
||||||
@@ -3708,6 +4222,7 @@ PlanComputed ← an den Client geliefert
|
|||||||
| `name` | String | |
|
| `name` | String | |
|
||||||
| `durationYears` | Int | 1–80 |
|
| `durationYears` | Int | 1–80 |
|
||||||
| `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])` |
|
||||||
@@ -3790,6 +4305,7 @@ Referenz: `prisma/schema.prisma` Zeilen 4–6, `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
|
||||||
@@ -3815,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 | 0–100 |
|
| `saleTaxRate` | REAL_ESTATE | 0–100 |
|
||||||
| `immediateRepayment` | OTHER_DEBT | ≥ 0 |
|
| `immediateRepayment` | OTHER_DEBT | ≥ 0 |
|
||||||
| `capitalUseAmortizationPct` | PENSION_FUND (Kapitalbezug), PILLAR_3A – Anteil des bezogenen Kapitals in die Amortisation | 0–100 |
|
| `capitalUseAmortizationPct` | PENSION_FUND (Kapitalbezug), PILLAR_3A – Anteil des bezogenen Kapitals in die **Schuldentilgung** | 0–100 |
|
||||||
| `capitalUseInvestPct` | dito – Anteil in eine Anlage | 0–100 |
|
| `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** | 0–100 |
|
||||||
|
| `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`
|
||||||
|
|
||||||
@@ -3862,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
|
||||||
@@ -3923,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)) |
|
||||||
| `PlanWizard` | ~560 | Geführter Plan-Assistent in fünf Schritten, abschnittsbasierte Phasenplanung ([3.2.8](#328-geführter-assistent-und-beispielplan)) |
|
| `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)) |
|
||||||
@@ -4244,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** | **315** | |
|
| **Total** | **337** | |
|
||||||
|
|
||||||
## 8.2 Testfälle
|
## 8.2 Testfälle
|
||||||
|
|
||||||
@@ -4608,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,19 @@
|
|||||||
|
-- Umbau 0.36: Bestandsaufnahme vor der Zeitachse, Assistent als Herzstueck.
|
||||||
|
--
|
||||||
|
-- (1) `FinancialElement.baseData` haelt den Bestand bei PLANBEGINN und die Ausgangs-Annahmen.
|
||||||
|
-- Bisher lagen diese Werte in `ElementPhaseValue` der ersten Phase. Das war schon immer
|
||||||
|
-- schief -- ein Startwert ist nicht "phase-1-spezifisch", sondern der Stand am Anfang --
|
||||||
|
-- und es machte den ersten Schritt des Assistenten unmoeglich: Eine Bestandsaufnahme
|
||||||
|
-- braucht noch keine Lebensphasen. Zugleich ist es die Wurzel der Feld-Vererbung.
|
||||||
|
--
|
||||||
|
-- (2) `Scenario.planningHorizonYears` fuehrt den Horizont in JAHREN statt als Endalter je
|
||||||
|
-- Person. Eine Zahl statt zweier, die bei einem Paar auseinanderlaufen koennten; die
|
||||||
|
-- Endalter werden abgeleitet. Ersetzt `Person.planningHorizonAge` aus 0.35.
|
||||||
|
--
|
||||||
|
-- (3) `Scenario.assistantProgress` haelt die sieben Haken des FPT-Assistenten.
|
||||||
|
--
|
||||||
|
-- Bewusst OHNE Datenmigration (Plaene wurden vorgaengig geloescht).
|
||||||
|
ALTER TABLE "FinancialElement" ADD COLUMN "baseData" JSONB;
|
||||||
|
ALTER TABLE "Scenario" ADD COLUMN "planningHorizonYears" INTEGER;
|
||||||
|
ALTER TABLE "Scenario" ADD COLUMN "assistantProgress" JSONB;
|
||||||
|
ALTER TABLE "Person" DROP COLUMN IF EXISTS "planningHorizonAge";
|
||||||
@@ -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";
|
||||||
+17
-4
@@ -76,11 +76,9 @@ model Person {
|
|||||||
scenarioId String
|
scenarioId String
|
||||||
scenario Scenario @relation(fields: [scenarioId], references: [id], onDelete: Cascade)
|
scenario Scenario @relation(fields: [scenarioId], references: [id], onDelete: Cascade)
|
||||||
role PersonRole
|
role PersonRole
|
||||||
|
// Alter, in dem die Person die Erwerbstaetigkeit aufgibt. Wird bei der Plan-Anlage nicht
|
||||||
|
// mehr abgefragt (Default 65) -- die Pensionsplanung im Assistenten legt es fest.
|
||||||
retirementAge Int
|
retirementAge Int
|
||||||
// Bis zu welchem Alter gerechnet wird. Bisher ergab sich das Planende stillschweigend aus
|
|
||||||
// der Summe der Phasendauern -- zwei Szenarien konnten dadurch unbemerkt verschieden weit
|
|
||||||
// rechnen und waren nicht vergleichbar.
|
|
||||||
planningHorizonAge Int?
|
|
||||||
|
|
||||||
@@unique([scenarioId, role])
|
@@unique([scenarioId, role])
|
||||||
}
|
}
|
||||||
@@ -224,6 +222,8 @@ model Scenario {
|
|||||||
inflationRateDefault Float
|
inflationRateDefault Float
|
||||||
initialCash Float @default(0)
|
initialCash Float @default(0)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@ -282,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
|
||||||
|
|
||||||
@@ -309,6 +314,14 @@ model FinancialElement {
|
|||||||
// er jede Verschiebung der Zeitachse. Siehe src/lib/retirement-decision.ts.
|
// er jede Verschiebung der Zeitachse. Siehe src/lib/retirement-decision.ts.
|
||||||
retirementDecision Json?
|
retirementDecision Json?
|
||||||
|
|
||||||
|
// Stammdaten des Elements: der Bestand bei PLANBEGINN und die Ausgangs-Annahmen (Rendite,
|
||||||
|
// Wertsteigerung, Zins). Zwei Gruende dafuer, dass das nicht in Phase 1 liegt:
|
||||||
|
// 1. Ein Startwert ist nicht "phase-1-spezifisch", sondern schlicht der Stand am Anfang.
|
||||||
|
// 2. Elemente lassen sich damit erfassen, BEVOR es Lebensphasen gibt -- der erste Schritt
|
||||||
|
// des Assistenten ist eine Bestandsaufnahme und braucht noch keine Zeitachse.
|
||||||
|
// Zugleich die Wurzel der Feld-Vererbung: Phase 1 erbt von hier.
|
||||||
|
baseData Json?
|
||||||
|
|
||||||
phaseValues ElementPhaseValue[]
|
phaseValues ElementPhaseValue[]
|
||||||
transitionValues ElementTransitionValue[]
|
transitionValues ElementTransitionValue[]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { getOwnedElement } from "@/lib/queries";
|
||||||
|
import { getCurrentUserId } from "@/lib/session";
|
||||||
|
import { touchScenario } from "@/lib/versioning-db";
|
||||||
|
import { phaseDataSchema } from "@/lib/elements";
|
||||||
|
|
||||||
|
// Stammdaten eines Elements: der Bestand bei PLANBEGINN und die Ausgangs-Annahmen.
|
||||||
|
//
|
||||||
|
// Bewusst ohne Phasenbezug. 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 der erste Schritt des Assistenten: eine
|
||||||
|
// Bestandsaufnahme, die noch keine Zeitachse voraussetzt.
|
||||||
|
export async function PUT(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ elementId: string }> }
|
||||||
|
) {
|
||||||
|
const userId = await getCurrentUserId();
|
||||||
|
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
|
||||||
|
const { elementId } = await params;
|
||||||
|
|
||||||
|
const element = await getOwnedElement(elementId, userId);
|
||||||
|
if (!element) return NextResponse.json({ error: "Element nicht gefunden." }, { status: 404 });
|
||||||
|
|
||||||
|
const parsed = phaseDataSchema.safeParse(await request.json());
|
||||||
|
if (!parsed.success) return NextResponse.json({ error: "Ungültige Eingabe." }, { status: 400 });
|
||||||
|
|
||||||
|
await prisma.financialElement.update({
|
||||||
|
where: { id: elementId },
|
||||||
|
data: { baseData: parsed.data },
|
||||||
|
});
|
||||||
|
|
||||||
|
await touchScenario(element.scenarioId, userId);
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -34,11 +34,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
|||||||
inflationRateDefault: source.inflationRateDefault,
|
inflationRateDefault: source.inflationRateDefault,
|
||||||
initialCash: source.initialCash,
|
initialCash: source.initialCash,
|
||||||
persons: {
|
persons: {
|
||||||
create: source.persons.map((p) => ({
|
create: source.persons.map((p) => ({ role: p.role, retirementAge: p.retirementAge })),
|
||||||
role: p.role,
|
|
||||||
retirementAge: p.retirementAge,
|
|
||||||
planningHorizonAge: p.planningHorizonAge,
|
|
||||||
})),
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -71,6 +67,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
|||||||
// Ohne das waere die Kopie eines Szenarios genau fuer den Zweck unbrauchbar, fuer
|
// Ohne das waere die Kopie eines Szenarios genau fuer den Zweck unbrauchbar, fuer
|
||||||
// den man sie am haeufigsten anlegt: ein anderes Pensionierungs-Szenario.
|
// den man sie am haeufigsten anlegt: ein anderes Pensionierungs-Szenario.
|
||||||
retirementDecision: el.retirementDecision ?? undefined,
|
retirementDecision: el.retirementDecision ?? undefined,
|
||||||
|
baseData: el.baseData ?? undefined,
|
||||||
sourceElementId: el.id,
|
sourceElementId: el.id,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,55 +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_AGE, MIN_PLANNING_HORIZON_AGE } from "@/lib/constants";
|
|
||||||
|
|
||||||
// Planungshorizont setzen: bis zu welchem Alter gerechnet wird.
|
|
||||||
//
|
|
||||||
// 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. Vorher ergab sich das
|
|
||||||
// Planende stillschweigend aus der Summe der Phasendauern -- zwei Szenarien konnten dadurch
|
|
||||||
// unbemerkt verschieden weit rechnen und waren nicht vergleichbar.
|
|
||||||
|
|
||||||
const bodySchema = z.object({
|
|
||||||
role: z.enum(["PERSON_A", "PERSON_B"]),
|
|
||||||
horizonAge: z.number().int().min(MIN_PLANNING_HORIZON_AGE).max(MAX_PLANNING_HORIZON_AGE),
|
|
||||||
});
|
|
||||||
|
|
||||||
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 { role, horizonAge } = parsed.data;
|
|
||||||
|
|
||||||
const planInput = toPlanInput(scenario);
|
|
||||||
const person = planInput.persons.find((p) => p.role === role);
|
|
||||||
if (!person) return NextResponse.json({ error: "Diese Person gibt es in diesem Szenario nicht." }, { status: 400 });
|
|
||||||
if (horizonAge <= person.retirementAge) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Der Planungshorizont muss nach der Pensionierung liegen." },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const change = planHorizonChange(planInput, horizonAge, role);
|
|
||||||
if (!change) return NextResponse.json({ error: "Es gibt keine Lebensphase, die sich anpassen liesse." }, { status: 400 });
|
|
||||||
if (change.blocked) return NextResponse.json({ error: change.blocked }, { status: 400 });
|
|
||||||
|
|
||||||
await prisma.$transaction([
|
|
||||||
prisma.person.update({ where: { id: person.id }, data: { planningHorizonAge: horizonAge } }),
|
|
||||||
prisma.phase.update({ where: { id: change.lastPhaseId }, data: { durationYears: change.newDuration } }),
|
|
||||||
]);
|
|
||||||
|
|
||||||
await touchScenario(scenario.id, userId);
|
|
||||||
return NextResponse.json({ ok: true, lastPhaseDuration: change.newDuration });
|
|
||||||
}
|
|
||||||
+508
-386
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
<MoneyField
|
{carried ? (
|
||||||
label={isIncome ? "Jahreseinkommen NETTO, nominal (erstes Jahr)" : "Jahresausgaben REAL (erstes Jahr, heutige Kaufkraft)"}
|
// Ab Phase 2 bewusst änderbar: Teilzeit, Beförderung, Jobwechsel, anderer
|
||||||
help={carried ? "Vorbelegt mit dem fortgeschriebenen Wert der Vorphase; hier bewusst änderbar." : undefined}
|
// Lebensstandard -- das sind echte Entscheide dieser Phase.
|
||||||
value={baseValue}
|
<MoneyField
|
||||||
onChange={(v) => setP({ amount: v })}
|
label={isIncome ? "Jahreseinkommen NETTO, nominal (erstes Jahr)" : "Jahresausgaben REAL (erstes Jahr, heutige Kaufkraft)"}
|
||||||
/>
|
help="Vorbelegt mit dem fortgeschriebenen Wert der Vorphase; hier bewusst änderbar."
|
||||||
|
value={baseValue}
|
||||||
|
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 "Vollständig bezogen".
|
<>
|
||||||
</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,15 +992,15 @@ 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(
|
|
||||||
"annualRepayment",
|
|
||||||
"Jährliche Tilgung (CHF)",
|
|
||||||
(v, set) => <MoneyField label="Jährliche Tilgung (CHF)" value={v} onChange={set} />,
|
|
||||||
asChf,
|
|
||||||
"Zählt gegen die Sparquote (max. die Sparquote)."
|
|
||||||
)}
|
)}
|
||||||
|
{rate("annualRepayment", "Jährliche Tilgung (CHF)", "Zählt gegen die Sparquote (max. die Sparquote).")}
|
||||||
|
{context.oneOffRepayment > 0 &&
|
||||||
|
capital(
|
||||||
|
"Sofort-Tilgung aus Kapital (CHF)",
|
||||||
|
context.oneOffRepayment,
|
||||||
|
"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. */}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { FieldLabel, NumberField, SelectField, TextField } from "@/components/FormField";
|
import { NumberField, SelectField, TextField } from "@/components/FormField";
|
||||||
|
import { AHV_REFERENCE_AGE } from "@/lib/constants";
|
||||||
import type { HouseholdType, PersonRole } from "@/lib/types";
|
import type { HouseholdType, PersonRole } from "@/lib/types";
|
||||||
|
|
||||||
export interface ProfileDraft {
|
export interface ProfileDraft {
|
||||||
@@ -15,23 +16,28 @@ export function emptyProfileDraft(): ProfileDraft {
|
|||||||
householdType: "SINGLE",
|
householdType: "SINGLE",
|
||||||
inflationRateDefault: 1.5,
|
inflationRateDefault: 1.5,
|
||||||
startYear: new Date().getFullYear(),
|
startYear: new Date().getFullYear(),
|
||||||
persons: [{ role: "PERSON_A", name: "", age: 35, retirementAge: 65 }],
|
// Das Pensionsalter wird beim Anlegen nicht gefragt: Es ist der erste Entscheid der
|
||||||
|
// Pensionsplanung. Bis dahin gilt das Referenzalter.
|
||||||
|
persons: [{ role: "PERSON_A", name: "", age: 35, retirementAge: AHV_REFERENCE_AGE }],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Gemeinsame Formularfelder für das Grundprofil eines Plans (Haushaltsform, Personen,
|
// Gemeinsame Formularfelder für das Grundprofil eines Plans (Haushaltsform, Personen,
|
||||||
// Inflation). Wird beim Plan-Erstellen und in den Plan-Einstellungen verwendet.
|
// Startjahr, Inflation). Wird beim Plan-Erstellen und in den Grundeinstellungen verwendet.
|
||||||
|
//
|
||||||
|
// Das PENSIONSALTER fehlt hier seit 0.36 bewusst. Es ist kein Stammdatum, sondern der erste
|
||||||
|
// Entscheid der Pensionsplanung -- und es erzeugt eine Phasengrenze. Es wird deshalb dort
|
||||||
|
// gesetzt («Pensionsplanung» je Person) und hier höchstens angezeigt.
|
||||||
export function PlanProfileFields({
|
export function PlanProfileFields({
|
||||||
draft,
|
draft,
|
||||||
onChange,
|
onChange,
|
||||||
// Sobald Lebensphasen bestehen, liegt jede Pensionierung auf einer Phasengrenze. Ein frei
|
// Blendet den Hinweis aus, dass diese Angaben plan-weit gelten -- beim Anlegen eines Plans
|
||||||
// änderbares Alter würde diese Grenze zerreissen -- deshalb übernimmt dort die eigene
|
// gibt es noch keine anderen Szenarien, die betroffen sein könnten.
|
||||||
// Bedienung «Pensionsalter anpassen» (Roadmap Nr. 44), und das Feld ist nur noch Anzeige.
|
hideScopeNote = false,
|
||||||
lockRetirement = false,
|
|
||||||
}: {
|
}: {
|
||||||
draft: ProfileDraft;
|
draft: ProfileDraft;
|
||||||
onChange: (next: ProfileDraft) => void;
|
onChange: (next: ProfileDraft) => void;
|
||||||
lockRetirement?: boolean;
|
hideScopeNote?: boolean;
|
||||||
}) {
|
}) {
|
||||||
function setType(type: HouseholdType) {
|
function setType(type: HouseholdType) {
|
||||||
if (type === "SINGLE") {
|
if (type === "SINGLE") {
|
||||||
@@ -39,7 +45,7 @@ export function PlanProfileFields({
|
|||||||
} else {
|
} else {
|
||||||
const persons =
|
const persons =
|
||||||
draft.persons.length < 2
|
draft.persons.length < 2
|
||||||
? [...draft.persons, { role: "PERSON_B" as PersonRole, name: "", age: 35, retirementAge: 65 }]
|
? [...draft.persons, { role: "PERSON_B" as PersonRole, name: "", age: 35, retirementAge: AHV_REFERENCE_AGE }]
|
||||||
: draft.persons;
|
: draft.persons;
|
||||||
onChange({ ...draft, householdType: type, persons });
|
onChange({ ...draft, householdType: type, persons });
|
||||||
}
|
}
|
||||||
@@ -57,11 +63,12 @@ export function PlanProfileFields({
|
|||||||
{/* Seit V7 beschreiben Haushaltsform, Personen und Startjahr den PLAN und gelten für
|
{/* Seit V7 beschreiben Haushaltsform, Personen und Startjahr den PLAN und gelten für
|
||||||
ALLE Szenarien. Das muss sichtbar sein -- sonst ändert man beim Bearbeiten eines
|
ALLE Szenarien. Das muss sichtbar sein -- sonst ändert man beim Bearbeiten eines
|
||||||
Nebenszenarios unbemerkt auch alle anderen. */}
|
Nebenszenarios unbemerkt auch alle anderen. */}
|
||||||
<p className="rounded-lg border border-attention bg-attention-soft px-3 py-2 text-xs text-attention-soft-fg">
|
{!hideScopeNote && (
|
||||||
Haushaltsform, Personen und Planstart gelten für <strong>alle Szenarien</strong> dieses
|
<p className="rounded-lg border border-attention bg-attention-soft px-3 py-2 text-xs text-attention-soft-fg">
|
||||||
Plans. Unterscheiden sie sich, ist es ein anderer Plan. Szenario-eigen sind nur das{" "}
|
Haushaltsform, Personen und Planstart gelten für <strong>alle Szenarien</strong> dieses
|
||||||
<strong>Pensionsalter</strong> und die <strong>Inflation</strong>.
|
Plans. Unterscheiden sie sich, ist es ein anderer Plan.
|
||||||
</p>
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
<SelectField
|
<SelectField
|
||||||
label="Haushaltsform (plan-weit)"
|
label="Haushaltsform (plan-weit)"
|
||||||
@@ -94,26 +101,7 @@ export function PlanProfileFields({
|
|||||||
max={120}
|
max={120}
|
||||||
onChange={(v) => updatePerson(index, { age: Math.round(v) })}
|
onChange={(v) => updatePerson(index, { age: Math.round(v) })}
|
||||||
/>
|
/>
|
||||||
{lockRetirement ? (
|
{/* Das Pensionsalter steht bewusst NICHT hier -- siehe Kommentar oben. */}
|
||||||
<div>
|
|
||||||
<FieldLabel
|
|
||||||
label="Pensionierungsalter"
|
|
||||||
help="Liegt immer auf einer Phasengrenze und wird deshalb unten unter «Pensionsalter anpassen» verschoben -- dabei wandern die angrenzenden Phasendauern mit."
|
|
||||||
/>
|
|
||||||
<div className="mt-1 rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-muted">
|
|
||||||
{person.retirementAge} Jahre
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<NumberField
|
|
||||||
label="Pensionierungsalter (nur dieses Szenario)"
|
|
||||||
help="Steuert die Ableitung des Phasentyps (Erwerb/Pension). Als einzige Personenangabe szenario-eigen -- das ist der Kern jedes Früh-/Spätpensionierungs-Szenarios."
|
|
||||||
value={person.retirementAge}
|
|
||||||
min={30}
|
|
||||||
max={100}
|
|
||||||
onChange={(v) => updatePerson(index, { retirementAge: Math.round(v) })}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
|||||||
+1147
-398
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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,15 +284,19 @@ export function PkBlock({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<NumberField
|
{/* Bei 100 % Kapital wird nichts verrentet -- dann ist der Satz gegenstandslos, genau wie
|
||||||
label="Umwandlungssatz (%)"
|
die Kapitalbezugssteuer bei 0 % Kapital. */}
|
||||||
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."
|
{share < 100 && (
|
||||||
value={rd.conversionRate ?? 0}
|
<NumberField
|
||||||
step={0.1}
|
label="Umwandlungssatz (%)"
|
||||||
min={0}
|
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."
|
||||||
max={20}
|
value={rd.conversionRate ?? 0}
|
||||||
onChange={(v) => patch(el.id, { conversionRate: v })}
|
step={0.1}
|
||||||
/>
|
min={0}
|
||||||
|
max={20}
|
||||||
|
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="flex flex-col gap-2">
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
<CapitalUseRow
|
||||||
<NumberField
|
label={label}
|
||||||
label="… in die Hypothek (%)"
|
title="Schuldentilgung (%)"
|
||||||
help={
|
help="Einmalige Tilgung. Am Restsaldo gekappt – ist die Schuld kleiner als der Anteil, bleibt der Rest Cash."
|
||||||
hasMortgage
|
value={amort}
|
||||||
? "Einmalige Amortisation. Am Restsaldo gekappt – ist die Hypothek kleiner, bleibt der Rest Cash."
|
max={100}
|
||||||
: "Es gibt keine Immobilie in diesem Szenario – dieser Anteil bliebe wirkungslos auf dem Cash."
|
onChange={(v) => patch(elementId, { capitalUseAmortizationPct: Math.max(0, Math.min(100, v)) })}
|
||||||
}
|
targets={debtTargets}
|
||||||
value={amort}
|
targetValue={rd.capitalUseDebtTargetElementId}
|
||||||
step={5}
|
onTarget={(v) => patch(elementId, { capitalUseDebtTargetElementId: v })}
|
||||||
min={0}
|
targetLabel="Welche Schuld?"
|
||||||
max={100}
|
missing="Es gibt weder Immobilie noch Schuld in diesem Szenario – der Anteil bliebe auf dem Cash-Konto."
|
||||||
onChange={(v) => patch(elementId, { capitalUseAmortizationPct: Math.max(0, Math.min(100, v)) })}
|
/>
|
||||||
/>
|
<CapitalUseRow
|
||||||
<NumberField
|
label={label}
|
||||||
label="… in die Anlage (%)"
|
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}
|
max={100 - amort}
|
||||||
min={0}
|
onChange={(v) => patch(elementId, { capitalUseInvestPct: Math.max(0, Math.min(100 - amort, v)) })}
|
||||||
max={100 - amort}
|
targets={investTargets}
|
||||||
onChange={(v) => patch(elementId, { capitalUseInvestPct: Math.max(0, Math.min(100 - amort, v)) })}
|
targetValue={rd.capitalUseTargetElementId}
|
||||||
/>
|
onTarget={(v) => patch(elementId, { capitalUseTargetElementId: v })}
|
||||||
{invest > 0 && targets.length > 0 && (
|
targetLabel="Wohin investieren?"
|
||||||
<div className="sm:col-span-2">
|
missing="Es gibt kein Element «Sonstiges Vermögen» – der Anteil bliebe auf dem Cash-Konto."
|
||||||
<SelectField
|
/>
|
||||||
label="Ziel der Anlage-Quote"
|
{/* Cash ist der Rest, aber als vollwertige Zeile dargestellt. */}
|
||||||
value={rd.capitalUseTargetElementId ?? targets[0].id}
|
<div className="rounded-lg border border-dashed border-border bg-surface-2 p-3">
|
||||||
onChange={(v: string) => patch(elementId, { capitalUseTargetElementId: v })}
|
<div className="flex items-baseline justify-between gap-3">
|
||||||
options={targets.map((t) => ({
|
<span className="text-xs font-medium text-muted">Cash (%)</span>
|
||||||
value: t.id,
|
<span className="text-sm font-semibold tabular-nums text-fg">{cash} %</span>
|
||||||
label: `${t.name} · ${ownerLabel(plan.persons, t.ownerRole)}`,
|
</div>
|
||||||
}))}
|
<p className="mt-1 text-[11px] text-faint">
|
||||||
/>
|
Was du nicht zuteilst, bleibt auf dem Cash-Konto – unverzinst, aber jederzeit verfügbar.
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{invest > 0 && targets.length === 0 && (
|
|
||||||
<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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,338 +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 { NumberField } from "@/components/FormField";
|
|
||||||
import { RetirementAdjuster } from "@/components/RetirementAdjuster";
|
|
||||||
import { RetirementFields } from "@/components/RetirementFields";
|
|
||||||
import { formatChf } from "@/lib/format";
|
|
||||||
import { ownerLabel } from "@/lib/elements";
|
|
||||||
import {
|
|
||||||
AHV_REFERENCE_AGE,
|
|
||||||
MAX_PLANNING_HORIZON_AGE,
|
|
||||||
MIN_PLANNING_HORIZON_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,
|
|
||||||
onSaved,
|
|
||||||
}: {
|
|
||||||
plan: PlanInput;
|
|
||||||
computed: PlanComputed;
|
|
||||||
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">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-lg font-semibold text-fg">Pensionierung</h2>
|
|
||||||
<p className="mt-1 text-sm text-muted">
|
|
||||||
Alle Entscheide zu AHV, Pensionskasse und Säule 3a an einem Ort. Was du hier festlegst, steht auch in der
|
|
||||||
Matrix am Pensions-Übergang – es ist derselbe Entscheid, nur anders angeschaut.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{plan.persons.map((person) => {
|
|
||||||
const summary = computed.retirement.perPerson.find((s) => s.role === person.role);
|
|
||||||
return (
|
|
||||||
<PersonBlock
|
|
||||||
key={person.role}
|
|
||||||
plan={plan}
|
|
||||||
computed={computed}
|
|
||||||
role={person.role}
|
|
||||||
summary={summary}
|
|
||||||
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="grid gap-4 px-4 py-4 md:grid-cols-2">
|
|
||||||
<RetirementAdjuster plan={plan} onSaved={onSaved} />
|
|
||||||
<HorizonControl plan={plan} role={role} 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Planungshorizont ----------------------------------------------------------------------
|
|
||||||
|
|
||||||
function HorizonControl({ plan, role, onSaved }: { plan: PlanInput; role: PersonRole; onSaved: () => void }) {
|
|
||||||
const person = plan.persons.find((p) => p.role === role)!;
|
|
||||||
const [value, setValue] = useState(person.planningHorizonAge ?? null);
|
|
||||||
const [busy, setBusy] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const toast = useToast();
|
|
||||||
// Ohne erfassten Horizont: das Alter, bei dem der Plan heute faktisch endet.
|
|
||||||
const implied = person.age + plan.phases.reduce((s, p) => s + p.durationYears, 0);
|
|
||||||
|
|
||||||
async function submit(next: number) {
|
|
||||||
setBusy(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
await api.post(`/api/scenarios/${plan.id}/horizon`, { role, horizonAge: next });
|
|
||||||
toast("success", `Planungshorizont auf Alter ${next} gesetzt.`);
|
|
||||||
onSaved();
|
|
||||||
} catch (e) {
|
|
||||||
setError(e instanceof Error ? e.message : "Nicht möglich.");
|
|
||||||
setValue(person.planningHorizonAge ?? null);
|
|
||||||
} finally {
|
|
||||||
setBusy(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="rounded-xl border border-border p-3">
|
|
||||||
<h4 className="mb-2 flex items-center text-sm font-semibold text-fg">
|
|
||||||
Planungshorizont
|
|
||||||
<InfoBubble text="Bis zu welchem Alter gerechnet wird. Die letzte Lebensphase passt sich an. Ohne diesen Wert ergibt sich das Planende stillschweigend aus der Summe der Phasendauern – zwei Szenarien rechnen dann womöglich unbemerkt verschieden weit und sind nicht vergleichbar." />
|
|
||||||
</h4>
|
|
||||||
<div className="flex items-end gap-2">
|
|
||||||
<div className="flex-1">
|
|
||||||
<NumberField
|
|
||||||
label="Alter"
|
|
||||||
value={value ?? implied}
|
|
||||||
min={MIN_PLANNING_HORIZON_AGE}
|
|
||||||
max={MAX_PLANNING_HORIZON_AGE}
|
|
||||||
step={1}
|
|
||||||
onChange={setValue}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
disabled={busy || value === null || value === person.planningHorizonAge}
|
|
||||||
onClick={() => value !== null && submit(value)}
|
|
||||||
>
|
|
||||||
Setzen
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{person.planningHorizonAge === null && (
|
|
||||||
<p className="mt-1 text-xs text-faint">
|
|
||||||
Noch nicht gesetzt – der Plan endet aktuell rechnerisch mit Alter {implied}.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{error && <p className="mt-1 text-xs text-danger">{error}</p>}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
+171
-116
@@ -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 };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const clickable = !!onSelectPhase;
|
||||||
|
const BAR = "var(--fg)";
|
||||||
|
|
||||||
|
// Ein Beschriftungsblock. Oben steht er auf dem Kopf (Karte, Linie, Dreieck), unten in
|
||||||
|
// umgekehrter Reihenfolge -- so zeigt das Dreieck immer zum Balken hin.
|
||||||
|
const labelFor = (s: (typeof segments)[number], above: boolean) => {
|
||||||
|
const active = activePhaseId === s.phase.id;
|
||||||
|
const from = yearOf(s.from);
|
||||||
|
const to = yearOf(s.to);
|
||||||
|
const tri = (
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
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"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className={`block whitespace-nowrap text-[13px] font-semibold ${active ? "" : "text-fg"}`}>
|
||||||
|
{from ? (s.phase.durationYears === 1 ? `${from}` : `${from}–${to! - 1}`) : `Phase ${s.index + 1}`}
|
||||||
|
</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 (
|
return (
|
||||||
<div className="rounded-xl border border-border bg-surface p-4 shadow-sm">
|
<div className="flex items-stretch gap-3">
|
||||||
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
|
{/* Das Startjahr steht markant am linken Ende -- es ist der Nullpunkt, auf den sich alle
|
||||||
<h3 className="text-sm font-semibold text-fg">Zeitachse</h3>
|
realen Werte und die ganze Achse beziehen. */}
|
||||||
<div className="flex gap-3 text-xs text-muted">
|
<div className="flex shrink-0 flex-col justify-center pb-1 text-right">
|
||||||
{persons.map((p) => (
|
<span className="text-xl font-semibold leading-none text-fg">{startYear ?? minAge}</span>
|
||||||
<span key={p.role} className="flex items-center gap-1">
|
<span className="mt-0.5 text-[10px] uppercase tracking-wide text-faint">
|
||||||
<span className="inline-block h-2 w-2 rounded-full" style={{ backgroundColor: p.color }} />
|
{startYear ? "Start" : "Jahre"}
|
||||||
{p.label} (heute {p.currentAge})
|
</span>
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative pt-6">
|
<div className="relative min-w-0 flex-1">
|
||||||
{/* Pensionsmarker je Person */}
|
{/* Beschriftungen oberhalb (Phase 1, 3, 5 …) */}
|
||||||
{persons.map((p, i) =>
|
<div className="relative h-[68px]">{segments.filter((s) => s.index % 2 === 0).map((s) => labelFor(s, true))}</div>
|
||||||
p.retirementAge > minAge && p.retirementAge < maxAge ? (
|
|
||||||
<div
|
|
||||||
key={p.role}
|
|
||||||
className="absolute top-0 flex -translate-x-1/2 flex-col items-center"
|
|
||||||
style={{ left: pct(p.retirementAge) }}
|
|
||||||
title={`${p.label}: Pensionierung mit ${p.retirementAge}${
|
|
||||||
yearOf(p.retirementAge - minAge) ? ` (${yearOf(p.retirementAge - minAge)})` : ""
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<Flag className="h-3.5 w-3.5" style={{ color: p.color }} fill={p.color} />
|
|
||||||
<span className="whitespace-nowrap text-[10px] font-medium" style={{ color: p.color }}>
|
|
||||||
{p.retirementAge}
|
|
||||||
</span>
|
|
||||||
<div className="mt-0.5 h-3 w-px" style={{ backgroundColor: p.color, marginTop: i * 2 }} />
|
|
||||||
</div>
|
|
||||||
) : null
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Ruin-Marker (Person A) */}
|
{/* Der Balken. Die Segmente teilen sich die Breite streng nach Dauer -- eine Phase von
|
||||||
{ruinAge != null && ruinAge > minAge && ruinAge <= maxAge && (
|
fünf Jahren ist halb so breit wie eine von zehn. */}
|
||||||
<div
|
<div className="relative flex h-9 gap-[3px] overflow-hidden rounded-full">
|
||||||
className="absolute top-0 flex -translate-x-1/2 flex-col items-center"
|
{segments.map((s) => {
|
||||||
style={{ left: pct(ruinAge) }}
|
const active = activePhaseId === s.phase.id;
|
||||||
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 &&
|
|
||||||
actualYears.map((y) => {
|
|
||||||
const age = minAge + (y - startYear);
|
|
||||||
if (age < minAge || age > maxAge) return null;
|
|
||||||
const isLatest = y === actualYears[actualYears.length - 1];
|
|
||||||
return (
|
return (
|
||||||
<div
|
<button
|
||||||
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
|
|
||||||
className="whitespace-nowrap text-[10px] font-medium"
|
|
||||||
style={{ color: isLatest ? "var(--attention)" : "var(--muted)" }}
|
|
||||||
>
|
|
||||||
{y}
|
|
||||||
</span>
|
|
||||||
<div
|
|
||||||
className="mt-0.5 h-3 w-px"
|
|
||||||
style={{ backgroundColor: isLatest ? "var(--attention)" : "var(--muted)" }}
|
|
||||||
/>
|
|
||||||
</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}
|
key={s.phase.id}
|
||||||
style={{ width: `${(s.phase.durationYears / span) * 100}%` }}
|
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`}
|
title={`${s.phase.name} · ${s.phase.durationYears} Jahre`}
|
||||||
className={`flex min-w-0 flex-col justify-center px-2 py-1.5 ${
|
className={`min-w-0 transition-all ${clickable ? "cursor-pointer hover:brightness-110" : ""}`}
|
||||||
i > 0 ? "border-l border-border" : ""
|
style={{
|
||||||
} ${s.phase.type === "PENSION" ? "bg-accent-soft" : s.phase.type === "MIXED" ? "bg-accent/25" : "bg-accent/70"}`}
|
width: `${(s.phase.durationYears / span) * 100}%`,
|
||||||
>
|
backgroundColor: active ? "var(--accent)" : BAR,
|
||||||
<span className={`truncate text-[10px] font-semibold ${soft ? "text-accent-soft-fg" : "text-accent-fg"}`}>
|
opacity: active ? 1 : 0.85,
|
||||||
{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>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
|
{/* 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
|
||||||
|
)}
|
||||||
|
{ruinAge != null && ruinAge > minAge && ruinAge <= maxAge && (
|
||||||
|
<span
|
||||||
|
className="pointer-events-none absolute inset-y-0 w-1 -translate-x-1/2"
|
||||||
|
style={{ left: `${pctOfAge(ruinAge)}%`, backgroundColor: "var(--danger)" }}
|
||||||
|
title={`Kapital aufgebraucht mit Alter ${ruinAge}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{startYear &&
|
||||||
|
actualYears.map((y) => {
|
||||||
|
const age = minAge + (y - startYear);
|
||||||
|
if (age < minAge || age > maxAge) return null;
|
||||||
|
const isLatest = y === actualYears[actualYears.length - 1];
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={y}
|
||||||
|
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={{
|
||||||
|
left: `${pctOfAge(age)}%`,
|
||||||
|
backgroundColor: isLatest ? "var(--attention)" : "var(--faint)",
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
["--tw-ring-color" as any]: "var(--surface)",
|
||||||
|
}}
|
||||||
|
title={isLatest ? `Effektive Werte für ${y} (aktuell)` : `Effektive Werte für ${y} (überholt)`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</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>
|
</div>
|
||||||
{minAge} J.{yearOf(0) ? ` · ${yearOf(0)}` : ""}
|
|
||||||
</span>
|
{/* Rechts das Planende und, falls vorhanden, die Marker-Erklärung in Kurzform. */}
|
||||||
<span>
|
<div className="flex shrink-0 flex-col justify-center pb-1">
|
||||||
{maxAge} J.{yearOf(totalYears) ? ` · ${yearOf(totalYears)}` : ""}
|
<span className="text-sm font-semibold leading-none text-muted">
|
||||||
</span>
|
{startYear ? startYear + totalYears - 1 : maxAge}
|
||||||
</div>
|
</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>
|
||||||
);
|
);
|
||||||
|
|||||||
+286
-156
@@ -1,211 +1,341 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
// Interaktive Kurz-Tour über die Planansicht (Teil von Roadmap Nr. 10).
|
// Die Tour: eine kurze Demo statt eines Spotlights.
|
||||||
//
|
//
|
||||||
// Spotlight ohne Engine-Bibliothek: Der abgedunkelte Bereich entsteht aus VIER fixed-Flächen
|
// Bis 0.35 legte sich die Tour über den ECHTEN Bildschirm und hob Bereiche hervor. Das hatte
|
||||||
// um die Bounding-Box des Ziels herum (oben/unten/links/rechts). Das lässt ein «Loch» am Ziel
|
// zwei Nachteile, die sich nicht beheben liessen: Auf einem frisch angelegten, leeren Plan gab
|
||||||
// frei und funktioniert -- anders als ein box-shadow-Trick -- unabhängig von z-index (sticky
|
// es kaum etwas hervorzuheben -- gerade dann, wenn die Tour am nötigsten ist. Und das
|
||||||
// Matrix-Köpfe) und overflow (Matrix-Scrollbereich). Ziele, die es im aktuellen Plan nicht
|
// Ausschneiden der Hervorhebung kämpfte dauernd mit Scroll-Containern und Z-Ebenen.
|
||||||
// gibt oder die unsichtbar sind, werden übersprungen.
|
//
|
||||||
|
// Neu zeigt die Tour einen NACHGEBAUTEN Bildschirm mit erfundenen Zahlen. Er ist bewusst
|
||||||
|
// keine echte Ansicht, sondern eine Illustration: gerade so detailliert, dass man den Aufbau
|
||||||
|
// erkennt, und so einfach, dass sie sich in einem Popup erklären lässt. Der Preis ist, dass
|
||||||
|
// die Attrappe bei UI-Änderungen nachgeführt werden muss -- dafür funktioniert die Tour ab
|
||||||
|
// der ersten Sekunde und unabhängig davon, was im Plan schon steht.
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useState } from "react";
|
||||||
import { Lightbulb, X } from "lucide-react";
|
import { ArrowLeft, ArrowRight, Sparkles, X } from "lucide-react";
|
||||||
import { Button } from "@/components/ui";
|
import { Button } from "@/components/ui";
|
||||||
|
|
||||||
export const TOUR_DONE_KEY = "fpt-tour-done";
|
export const TOUR_DONE_KEY = "fpt-tour-done";
|
||||||
|
|
||||||
|
type Focus = "none" | "settings" | "figures" | "actions" | "assistant" | "timeline" | "matrix" | "sidebar";
|
||||||
|
|
||||||
interface TourStep {
|
interface TourStep {
|
||||||
target: string; // data-tour="…"
|
|
||||||
title: string;
|
title: string;
|
||||||
text: string;
|
text: string;
|
||||||
|
focus: Focus;
|
||||||
}
|
}
|
||||||
|
|
||||||
const STEPS: TourStep[] = [
|
const STEPS: TourStep[] = [
|
||||||
{
|
{
|
||||||
target: "profil",
|
title: "Willkommen bei FPT",
|
||||||
title: "Dein Grundprofil",
|
text: "In zwei Minuten siehst du, wie das Tool aufgebaut ist. Alles, was hier steht, sind erfundene Beispielzahlen – dein eigener Plan ist noch leer.",
|
||||||
text: "Alter, Pensionsalter und Inflationsannahme – die Basis dieses Szenarios. Über «Bearbeiten» änderst du sie jederzeit.",
|
focus: "none",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Oben stellst du ein, unten siehst du das Ergebnis",
|
||||||
|
text: "Der Bildschirm hat zwei Hälften. Oben die Steuerung: Grundeinstellungen, Kennzahlen, Schnellaktionen und der Assistent. Unten die Matrix mit allen Zahlen.",
|
||||||
|
focus: "none",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Die Grundeinstellungen",
|
||||||
|
text: "Wer plant, ab wann und wie lange. Alter, Startjahr und Inflation gelten für den ganzen Plan; Planungshorizont und Pensionsalter gehören zum Szenario. Je Person führt ein Knopf in die Pensionsplanung.",
|
||||||
|
focus: "settings",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Die Kennzahlen",
|
||||||
|
text: "Was unter dem Strich herauskommt: dein Vermögen heute, das Endvermögen nominal und real – und ob das Kapital über die ganze Planung reicht.",
|
||||||
|
focus: "figures",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Der FPT-Assistent",
|
||||||
|
text: "Dein Wegweiser. Sieben Schritte führen von der Bestandsaufnahme bis zum fertigen Plan. Jeder Schritt erklärt zuerst, worum es geht, und bietet danach das passende Werkzeug an. Wann ein Schritt erledigt ist, entscheidest du.",
|
||||||
|
focus: "assistant",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
target: "timeline",
|
|
||||||
title: "Die Zeitachse",
|
title: "Die Zeitachse",
|
||||||
text: "Deine Lebensphasen im Zeitverlauf: Die Breite zeigt die Dauer, die Fähnchen die Pensionierungen – und falls es dazu kommt, den Punkt, an dem das Kapital ausgeht. Die ganze Planung denkt in diesen Phasen.",
|
text: "Deine Planung im Überblick: Lebensphasen, die Zeitpunkte der Pensionierung – und, falls das Geld nicht reicht, das Jahr, in dem es knapp wird.",
|
||||||
|
focus: "timeline",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
target: "endwealth",
|
title: "Die Matrix",
|
||||||
title: "Dein Endvermögen",
|
text: "Das Herz des Tools. Jede Zeile ist ein finanzielles Element, jede Spalte eine Lebensphase. Die schmalen Spalten dazwischen sind die Übergänge – dort entscheidest du, was beim Wechsel geschieht.",
|
||||||
text: "Das Ergebnis auf einen Blick: das Vermögen am Ende der Planung – nominal und real (in heutiger Kaufkraft). Reicht das Kapital nicht bis zum Schluss, steht hier stattdessen das Alter, in dem es ausgeht.",
|
focus: "matrix",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
target: "matrix",
|
title: "Pläne und Szenarien",
|
||||||
title: "Die Matrix – das Herzstück",
|
text: "Links verwaltest du alles. Ein Szenario ist eine Variante deines Plans – etwa mit anderem Pensionsalter. Du kannst beliebig viele anlegen und vergleichen.",
|
||||||
text: "Zeilen sind deine Finanzen, Spalten deine Lebensphasen. Klick auf eine Zelle: rechts öffnet sich das Bearbeiten-Panel, die Matrix bleibt sichtbar.",
|
focus: "sidebar",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
target: "transition",
|
title: "Jetzt bist du dran",
|
||||||
title: "Übergänge",
|
text: "Starte mit dem FPT-Assistenten oben rechts. Schritt 1 ist die Bestandsaufnahme: alles, was du heute besitzt. Von dort führt dich der Assistent weiter.",
|
||||||
text: "Zwischen zwei Phasen triffst du einmalige Entscheide – verkaufen, beziehen, verrenten. Orange heisst: noch offen. Klick auf den Spaltenkopf führt dich durch alle Entscheide.",
|
focus: "assistant",
|
||||||
},
|
|
||||||
{
|
|
||||||
target: "cash",
|
|
||||||
title: "Das Cash-Konto",
|
|
||||||
text: "Dein Ausgleichskonto: Was übrig bleibt, landet hier – was fehlt, macht es rot. Negativ = Liquiditätslücke.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
target: "toolbar",
|
|
||||||
title: "Aktionen zum Szenario",
|
|
||||||
text: "Hier oben: die Änderungshistorie ansehen, ein neues Szenario aus diesem ableiten, die Rechenwege einsehen, die Matrix als CSV exportieren – und diese Tour jederzeit neu starten.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
target: "menu",
|
|
||||||
title: "Analysen & Berichte",
|
|
||||||
text: "Links im Menü öffnest du die grossen Werkzeuge: unter «Analysen» die Grafiken, die Live-Simulation, Monte-Carlo und die Einflussfaktoren; unter «Berichte» erzeugst du ein PDF. «Effektive Werte» hält deine echten Zahlen gegen den Plan.",
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
interface Box {
|
|
||||||
top: number;
|
|
||||||
left: number;
|
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Tour({ onClose }: { onClose: () => void }) {
|
export function Tour({ onClose }: { onClose: () => void }) {
|
||||||
// Nur Schritte, deren Ziel im DOM existiert UND sichtbar ist (z. B. die Menü-Leiste ist auf
|
|
||||||
// schmalen Screens ausgeblendet -- getClientRects() ist dann leer).
|
|
||||||
const steps = useMemo(
|
|
||||||
() =>
|
|
||||||
STEPS.filter((s) => {
|
|
||||||
const el = document.querySelector(`[data-tour="${s.target}"]`);
|
|
||||||
return el !== null && el.getClientRects().length > 0;
|
|
||||||
}),
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
const [index, setIndex] = useState(0);
|
const [index, setIndex] = useState(0);
|
||||||
const [rect, setRect] = useState<Box | null>(null);
|
const step = STEPS[index];
|
||||||
const step = steps[index];
|
const last = index === STEPS.length - 1;
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!step) return;
|
|
||||||
const el = document.querySelector(`[data-tour="${step.target}"]`);
|
|
||||||
if (!el) return;
|
|
||||||
el.scrollIntoView({ block: "center", behavior: "smooth" });
|
|
||||||
|
|
||||||
// Die Bounding-Box jedes Frame nachführen (folgt dem sanften Scrollen und Resize), aber
|
|
||||||
// den State nur bei tatsächlicher Änderung neu setzen.
|
|
||||||
let raf = 0;
|
|
||||||
const update = () => {
|
|
||||||
const r = el.getBoundingClientRect();
|
|
||||||
setRect((prev) =>
|
|
||||||
prev && prev.top === r.top && prev.left === r.left && prev.width === r.width && prev.height === r.height
|
|
||||||
? prev
|
|
||||||
: { top: r.top, left: r.left, width: r.width, height: r.height }
|
|
||||||
);
|
|
||||||
raf = requestAnimationFrame(update);
|
|
||||||
};
|
|
||||||
update();
|
|
||||||
return () => cancelAnimationFrame(raf);
|
|
||||||
}, [step]);
|
|
||||||
|
|
||||||
if (!step || !rect) return null;
|
|
||||||
|
|
||||||
const pad = 6;
|
|
||||||
const hole: Box = {
|
|
||||||
top: Math.max(0, rect.top - pad),
|
|
||||||
left: Math.max(0, rect.left - pad),
|
|
||||||
width: rect.width + pad * 2,
|
|
||||||
height: rect.height + pad * 2,
|
|
||||||
};
|
|
||||||
const cardAtBottom = rect.top + rect.height / 2 < window.innerHeight * 0.5;
|
|
||||||
|
|
||||||
function finish() {
|
function finish() {
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(TOUR_DONE_KEY, "1");
|
localStorage.setItem(TOUR_DONE_KEY, "1");
|
||||||
} catch {
|
} catch {
|
||||||
/* localStorage nicht verfügbar -> Tour erscheint beim nächsten Mal erneut */
|
// Privater Modus o. Ä. -- die Tour lässt sich dann eben erneut öffnen.
|
||||||
}
|
}
|
||||||
onClose();
|
onClose();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Vier Abdunkel-Flächen um das Loch. pointer-events-none, damit nichts blockiert wird.
|
|
||||||
const dim = "fixed z-[45] bg-black/60 pointer-events-none";
|
|
||||||
const belowTop = hole.top + hole.height;
|
|
||||||
const rightOfLeft = hole.left + hole.width;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/60 p-3 sm:p-6">
|
||||||
<div className={dim} style={{ top: 0, left: 0, width: "100vw", height: hole.top }} />
|
<div className="flex h-full max-h-[900px] w-full max-w-6xl flex-col overflow-hidden rounded-2xl border border-border bg-surface shadow-2xl">
|
||||||
<div className={dim} style={{ top: belowTop, left: 0, width: "100vw", height: `calc(100vh - ${belowTop}px)` }} />
|
<header className="flex items-center gap-3 border-b border-border px-4 py-3">
|
||||||
<div className={dim} style={{ top: hole.top, left: 0, width: hole.left, height: hole.height }} />
|
<Sparkles className="h-4 w-4 shrink-0 text-accent" />
|
||||||
<div
|
<div className="min-w-0 flex-1">
|
||||||
className={dim}
|
<h2 className="truncate text-sm font-semibold text-fg">{step.title}</h2>
|
||||||
style={{ top: hole.top, left: rightOfLeft, width: `calc(100vw - ${rightOfLeft}px)`, height: hole.height }}
|
<p className="text-[11px] text-muted">
|
||||||
/>
|
Schritt {index + 1} von {STEPS.length}
|
||||||
|
</p>
|
||||||
{/* Pulsierender Rahmen ums Ziel. */}
|
|
||||||
<div
|
|
||||||
className="tour-ring pointer-events-none fixed z-[46] rounded-xl"
|
|
||||||
style={{ top: hole.top, left: hole.left, width: hole.width, height: hole.height }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Karte -- springt auf die dem Ziel gegenüberliegende Bildschirmhälfte. */}
|
|
||||||
<div
|
|
||||||
key={index}
|
|
||||||
className={`ui-pop fixed left-1/2 z-[50] w-[32rem] max-w-[calc(100vw-2rem)] -translate-x-1/2 rounded-2xl border-2 border-accent bg-surface p-5 shadow-2xl ${
|
|
||||||
cardAtBottom ? "bottom-8" : "top-8"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="flex items-start justify-between gap-2">
|
|
||||||
<div className="flex items-center gap-2.5">
|
|
||||||
<span className="flex h-9 w-9 items-center justify-center rounded-xl bg-accent text-accent-fg shadow-sm">
|
|
||||||
<Lightbulb className="h-5 w-5" />
|
|
||||||
</span>
|
|
||||||
<div>
|
|
||||||
<div className="text-base font-semibold text-fg">{step.title}</div>
|
|
||||||
<div className="text-[11px] font-medium uppercase tracking-wide text-accent">
|
|
||||||
Schritt {index + 1} von {steps.length}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={finish}
|
onClick={finish}
|
||||||
aria-label="Tour beenden"
|
aria-label="Tour schliessen"
|
||||||
className="rounded-md p-1 text-faint hover:bg-surface-2 hover:text-fg"
|
className="rounded-lg p-1.5 text-faint hover:bg-surface-2 hover:text-fg"
|
||||||
>
|
>
|
||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Die Attrappe. Rein illustrativ -- nichts darin ist klickbar. */}
|
||||||
|
<div className="min-h-0 flex-1 overflow-auto bg-surface-2/40 p-4">
|
||||||
|
<MockScreen focus={step.focus} />
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-3 text-sm leading-relaxed text-muted">{step.text}</p>
|
|
||||||
<div className="mt-4 flex items-center justify-between gap-2">
|
<footer className="border-t border-border px-4 py-3">
|
||||||
<button type="button" onClick={finish} className="text-xs font-medium text-faint hover:text-muted">
|
<p className="mb-3 text-sm leading-relaxed text-fg">{step.text}</p>
|
||||||
Überspringen
|
<div className="flex items-center gap-2">
|
||||||
</button>
|
<div className="flex flex-1 gap-1">
|
||||||
<div className="flex items-center gap-3">
|
{STEPS.map((_, i) => (
|
||||||
<div className="flex gap-1">
|
<div
|
||||||
{steps.map((_, i) => (
|
key={i}
|
||||||
<span key={i} className={`h-1.5 w-1.5 rounded-full ${i === index ? "bg-accent" : "bg-border-strong"}`} />
|
className={`h-1 flex-1 rounded-full transition-colors ${i <= index ? "bg-accent" : "bg-surface-2"}`}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<Button variant="ghost" size="sm" disabled={index === 0} onClick={() => setIndex((i) => i - 1)}>
|
||||||
{index > 0 && (
|
<ArrowLeft className="h-4 w-4" /> Zurück
|
||||||
<Button variant="secondary" size="sm" onClick={() => setIndex(index - 1)}>
|
</Button>
|
||||||
Zurück
|
{last ? (
|
||||||
</Button>
|
<Button size="sm" onClick={finish}>
|
||||||
)}
|
<Sparkles className="h-4 w-4" /> Mit dem Assistenten starten
|
||||||
{index < steps.length - 1 ? (
|
</Button>
|
||||||
<Button size="sm" onClick={() => setIndex(index + 1)}>
|
) : (
|
||||||
Weiter
|
<Button size="sm" onClick={() => setIndex((i) => i + 1)}>
|
||||||
</Button>
|
Weiter <ArrowRight className="h-4 w-4" />
|
||||||
) : (
|
</Button>
|
||||||
<Button size="sm" onClick={finish}>
|
)}
|
||||||
Fertig
|
</div>
|
||||||
</Button>
|
</footer>
|
||||||
)}
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Die Attrappe ---------------------------------------------------------------------------
|
||||||
|
//
|
||||||
|
// Feste, erfundene Zahlen. Sie müssen weder stimmen noch zueinander passen -- ihr einziger
|
||||||
|
// Zweck ist, den Aufbau greifbar zu machen.
|
||||||
|
|
||||||
|
function MockScreen({ focus }: { focus: Focus }) {
|
||||||
|
const dim = (area: Focus) => (focus === "none" || focus === area ? "opacity-100" : "opacity-20");
|
||||||
|
const ring = (area: Focus) => (focus === area ? "ring-2 ring-accent" : "");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex gap-3 text-[10px]">
|
||||||
|
{/* Seitenleiste */}
|
||||||
|
<div
|
||||||
|
className={`hidden w-36 shrink-0 flex-col gap-1 rounded-lg border border-border bg-surface p-2 transition-opacity sm:flex ${dim("sidebar")} ${ring("sidebar")}`}
|
||||||
|
>
|
||||||
|
<div className="mb-1 font-semibold uppercase tracking-wide text-faint">Meine Pläne</div>
|
||||||
|
<div className="rounded bg-accent-soft px-1.5 py-1 font-medium text-accent-soft-fg">Familie Muster</div>
|
||||||
|
<div className="pl-2 text-muted">Szenarien</div>
|
||||||
|
<div className="rounded bg-surface-2 px-1.5 py-0.5 pl-3 text-fg">Basis</div>
|
||||||
|
<div className="px-1.5 py-0.5 pl-3 text-muted">Pension mit 62</div>
|
||||||
|
<div className="pl-2 text-muted">Effektive Werte</div>
|
||||||
|
<div className="pl-2 text-muted">Analysen</div>
|
||||||
|
<div className="pl-2 text-muted">Berichte</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col gap-3">
|
||||||
|
{/* Oberer Bereich */}
|
||||||
|
<div className="rounded-lg border border-border bg-surface-2/60 p-2">
|
||||||
|
<div className="grid grid-cols-2 gap-2 lg:grid-cols-4">
|
||||||
|
<MockTile title="Grundeinstellungen" cls={`${dim("settings")} ${ring("settings")}`}>
|
||||||
|
<div>Anna · 42 J.</div>
|
||||||
|
<div>Beat · 45 J.</div>
|
||||||
|
<div className="text-faint">Start 2026 · Inflation 1,5 %</div>
|
||||||
|
<div className="mt-1 border-t border-border pt-1">Horizont 40 Jahre · bis 2065</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
Pension 65 · Ende 82
|
||||||
|
<span className="ml-auto rounded border border-border px-1 text-[9px]">Pensionsplanung</span>
|
||||||
|
</div>
|
||||||
|
</MockTile>
|
||||||
|
|
||||||
|
<MockTile title="Kennzahlen" cls={`${dim("figures")} ${ring("figures")}`}>
|
||||||
|
<MockFigure label="Vermögen heute" value="412'000" />
|
||||||
|
<MockFigure label="Endvermögen nominal" value="1'284'000" />
|
||||||
|
<MockFigure label="Endvermögen real" value="702'000" />
|
||||||
|
<div className="mt-1 rounded bg-success-soft px-1.5 py-0.5 text-success">
|
||||||
|
Kapital reicht über die ganze Planung
|
||||||
|
</div>
|
||||||
|
</MockTile>
|
||||||
|
|
||||||
|
<MockTile title="Schnellaktionen" cls={`${dim("actions")} ${ring("actions")}`}>
|
||||||
|
{["Neues Szenario", "Tour", "Änderungshistorie", "Rechenwege", "CSV-Export"].map((a) => (
|
||||||
|
<div key={a} className="rounded border border-border px-1.5 py-0.5 text-muted">
|
||||||
|
{a}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</MockTile>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={`flex flex-col gap-1 rounded-lg border border-attention bg-attention-soft px-2 py-1.5 transition-opacity ${dim("assistant")} ${ring("assistant")}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Sparkles className="h-3 w-3 text-attention-fg" />
|
||||||
|
<span className="font-semibold uppercase tracking-wide text-fg">FPT-Assistent</span>
|
||||||
|
<span className="ml-auto font-semibold text-muted">2 / 7</span>
|
||||||
|
</div>
|
||||||
|
{[
|
||||||
|
["3. Lebensphasen", "4 Lebensphasen"],
|
||||||
|
["4. Erwerbsjahre planen", ""],
|
||||||
|
["5. Pensionierung planen", ""],
|
||||||
|
].map(([t, sub]) => (
|
||||||
|
<div key={t} className="flex items-start gap-1">
|
||||||
|
<span className="mt-0.5 h-2.5 w-2.5 shrink-0 rounded-sm border border-border bg-surface" />
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="block truncate font-semibold text-fg">{t}</span>
|
||||||
|
{sub && <span className="block truncate text-muted">{sub}</span>}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{["1. Bestandsaufnahme", "2. Eckdaten"].map((t) => (
|
||||||
|
<div key={t} className="flex items-start gap-1 opacity-50">
|
||||||
|
<span className="mt-0.5 flex h-2.5 w-2.5 shrink-0 items-center justify-center rounded-sm bg-success text-[7px] text-white">
|
||||||
|
✓
|
||||||
|
</span>
|
||||||
|
<span className="truncate text-muted line-through">{t}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Zeitachse */}
|
||||||
|
<div
|
||||||
|
className={`mt-2 rounded-lg border border-border bg-surface p-2 transition-opacity ${dim("timeline")} ${ring("timeline")}`}
|
||||||
|
>
|
||||||
|
<div className="mb-1 flex justify-between text-faint">
|
||||||
|
<span>2026</span>
|
||||||
|
<span>2065</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex h-4 overflow-hidden rounded">
|
||||||
|
<div className="flex w-[45%] items-center justify-center bg-accent/70 text-[8px] text-white">
|
||||||
|
Erwerbsjahre
|
||||||
|
</div>
|
||||||
|
<div className="flex w-[15%] items-center justify-center bg-attention/70 text-[8px] text-white">
|
||||||
|
Übergangszeit
|
||||||
|
</div>
|
||||||
|
<div className="flex w-[40%] items-center justify-center bg-success/60 text-[8px] text-white">
|
||||||
|
Ruhestand
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 flex justify-between text-faint">
|
||||||
|
<span>Anna 42 · Beat 45</span>
|
||||||
|
<span>Pension 65</span>
|
||||||
|
<span>Anna 82 · Beat 85</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Matrix */}
|
||||||
|
<div
|
||||||
|
className={`overflow-hidden rounded-lg border border-border bg-surface transition-opacity ${dim("matrix")} ${ring("matrix")}`}
|
||||||
|
>
|
||||||
|
<table className="w-full border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-surface-2">
|
||||||
|
<th className="border-b border-r border-border px-2 py-1.5 text-left align-top">
|
||||||
|
<div className="font-semibold uppercase tracking-wide text-faint">Matrix</div>
|
||||||
|
<div className="mt-1 flex gap-1">
|
||||||
|
<span className="rounded border border-dashed border-border px-1 text-[9px] font-normal text-muted">
|
||||||
|
+ Element
|
||||||
|
</span>
|
||||||
|
<span className="rounded border border-dashed border-border px-1 text-[9px] font-normal text-muted">
|
||||||
|
+ Phase
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
{["Erwerbsjahre", "Übergang", "Ruhestand I", "Übergang", "Ruhestand II"].map((h, i) => (
|
||||||
|
<th
|
||||||
|
key={i}
|
||||||
|
className={`border-b border-r border-border px-2 py-1.5 text-center font-medium ${
|
||||||
|
h === "Übergang" ? "bg-attention text-attention-fg" : "text-fg"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{h}
|
||||||
|
{h === "Übergang" && <div className="text-[8px] font-normal">2 offen</div>}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{[
|
||||||
|
["Lohn Anna", "96'000", "→", "—", "→", "—"],
|
||||||
|
["Lebenshaltung", "78'000", "→", "72'000", "→", "68'000"],
|
||||||
|
["Pensionskasse Anna", "310'000", "40 % Kapital", "Rente 18'400", "→", "18'400"],
|
||||||
|
["Säule 3a Anna", "84'000", "Bezug mit 64", "—", "→", "—"],
|
||||||
|
["Wertschriften", "120'000", "→", "486'000", "→", "310'000"],
|
||||||
|
].map((row, i) => (
|
||||||
|
<tr key={i} className={i % 2 ? "bg-surface-2/40" : ""}>
|
||||||
|
<td className="border-b border-r border-border px-2 py-1 text-fg">{row[0]}</td>
|
||||||
|
{row.slice(1).map((c, j) => (
|
||||||
|
<td
|
||||||
|
key={j}
|
||||||
|
className={`border-b border-r border-border px-2 py-1 text-center tabular-nums ${
|
||||||
|
j % 2 === 1 ? "bg-attention-soft text-attention-soft-fg" : "text-muted"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{c}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MockTile({ title, cls, children }: { title: string; cls: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className={`flex flex-col gap-0.5 rounded-lg border border-border bg-surface px-2 py-1.5 transition-opacity ${cls}`}>
|
||||||
|
<div className="mb-0.5 font-semibold uppercase tracking-wide text-faint">{title}</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MockFigure({ label, value }: { label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-between gap-1">
|
||||||
|
<span className="truncate text-muted">{label}</span>
|
||||||
|
<span className="shrink-0 font-semibold tabular-nums text-fg">{value}</span>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
+148
-50
@@ -12,9 +12,12 @@ import {
|
|||||||
DEFAULT_PROPERTY_GAINS_TAX_RATE,
|
DEFAULT_PROPERTY_GAINS_TAX_RATE,
|
||||||
PILLAR_3A_MAX_WITHDRAWAL_AGE,
|
PILLAR_3A_MAX_WITHDRAWAL_AGE,
|
||||||
PILLAR_3A_MIN_WITHDRAWAL_AGE,
|
PILLAR_3A_MIN_WITHDRAWAL_AGE,
|
||||||
|
PK_MAX_RETIREMENT_AGE,
|
||||||
|
PK_MIN_RETIREMENT_AGE,
|
||||||
} from "@/lib/constants";
|
} from "@/lib/constants";
|
||||||
import { num } from "@/lib/elements";
|
import { num } from "@/lib/elements";
|
||||||
import { actualsForYear, rebaseFlow, type ResolvedActuals } from "@/lib/actuals";
|
import { actualsForYear, rebaseFlow, type ResolvedActuals } from "@/lib/actuals";
|
||||||
|
import { planFixpoints, type Fixpoint, type FixpointPerson } from "@/lib/phaseplan";
|
||||||
import {
|
import {
|
||||||
ahvDrawLabel,
|
ahvDrawLabel,
|
||||||
ahvFactor,
|
ahvFactor,
|
||||||
@@ -23,7 +26,7 @@ import {
|
|||||||
withRetirementDefaults,
|
withRetirementDefaults,
|
||||||
type RetirementDecision,
|
type RetirementDecision,
|
||||||
} from "@/lib/retirement-decision";
|
} from "@/lib/retirement-decision";
|
||||||
import type { ElementCategory } from "@/lib/elements";
|
import type { ElementCategory, PhaseData } from "@/lib/elements";
|
||||||
import type { ElementInput, PersonRole, PlanInput } from "@/lib/types";
|
import type { ElementInput, PersonRole, PlanInput } from "@/lib/types";
|
||||||
|
|
||||||
export type PhaseType = "ERWERB" | "PENSION" | "MIXED";
|
export type PhaseType = "ERWERB" | "PENSION" | "MIXED";
|
||||||
@@ -317,16 +320,49 @@ export function ahvYearsBeforePlan(ageAtPlanStart: number): number {
|
|||||||
return Math.max(0, ageAtPlanStart - AHV_CONTRIBUTION_START_AGE);
|
return Math.max(0, ageAtPlanStart - AHV_CONTRIBUTION_START_AGE);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Maximale Dauer einer neuen Phase bis zum nächsten Pensionsereignis (null = unbegrenzt).
|
// Alle Fixpunkte eines Plans als Personen-Sicht für `phaseplan`. Bündelt an EINER Stelle,
|
||||||
|
// welche Ereignisse eine Phasengrenze erzwingen -- Erwerbsende plus die drei Bezugsbeginne.
|
||||||
|
export function fixpointPersonsOf(plan: PlanInput): FixpointPerson[] {
|
||||||
|
return plan.persons.map((p) => {
|
||||||
|
const own = (cat: ElementCategory) =>
|
||||||
|
plan.elements.filter((e) => e.category === cat && e.ownerRole === p.role);
|
||||||
|
const ahvEl = own("AHV")[0];
|
||||||
|
const pkEl = own("PENSION_FUND")[0];
|
||||||
|
const rdAhv = ahvEl ? withRetirementDefaults("AHV", p.retirementAge, ahvEl.retirementDecision) : null;
|
||||||
|
const rdPk = pkEl ? withRetirementDefaults("PENSION_FUND", p.retirementAge, pkEl.retirementDecision) : null;
|
||||||
|
return {
|
||||||
|
role: p.role,
|
||||||
|
name: p.name,
|
||||||
|
age: p.age,
|
||||||
|
retirementAge: p.retirementAge,
|
||||||
|
ahvStartAge: rdAhv ? Math.round(ahvStartAge(rdAhv)) : undefined,
|
||||||
|
pkWithdrawalAge: rdPk?.pkWithdrawalAge,
|
||||||
|
pillar3aAges: own("PILLAR_3A")
|
||||||
|
.map((e) => withRetirementDefaults("PILLAR_3A", p.retirementAge, e.retirementDecision).withdrawalAge)
|
||||||
|
.filter((x): x is number => typeof x === "number"),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Maximale Dauer einer neuen Phase bis zum nächsten FIXPUNKT (null = unbegrenzt).
|
||||||
|
//
|
||||||
|
// Bis 0.35 zählte nur das Erwerbsende. Seit auch die Bezugsbeginne von AHV, PK und 3a eigene
|
||||||
|
// Zeitpunkte haben, muss die Kappung sie mitzählen: Fiele ein Bezug mitten in eine Phase,
|
||||||
|
// würde er auf die nächste Grenze rutschen -- unter Umständen Jahre später.
|
||||||
export function maxPhaseDuration(
|
export function maxPhaseDuration(
|
||||||
persons: { role: PersonRole; age: number; retirementAge: number }[],
|
persons: { role: PersonRole; age: number; retirementAge: number }[],
|
||||||
yearsBefore: number
|
yearsBefore: number,
|
||||||
|
extraFixpoints: Fixpoint[] = []
|
||||||
): number | null {
|
): number | null {
|
||||||
const caps: number[] = [];
|
const caps: number[] = [];
|
||||||
for (const p of persons) {
|
for (const p of persons) {
|
||||||
const startAge = p.age + yearsBefore;
|
const startAge = p.age + yearsBefore;
|
||||||
if (startAge < p.retirementAge) caps.push(p.retirementAge - startAge);
|
if (startAge < p.retirementAge) caps.push(p.retirementAge - startAge);
|
||||||
}
|
}
|
||||||
|
for (const f of extraFixpoints) {
|
||||||
|
const d = f.year - yearsBefore;
|
||||||
|
if (d > 0) caps.push(d);
|
||||||
|
}
|
||||||
return caps.length > 0 ? Math.min(...caps) : null;
|
return caps.length > 0 ? Math.min(...caps) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -390,6 +426,11 @@ function sourceLabelOf(
|
|||||||
return `${e.name} (${who})`;
|
return `${e.name} (${who})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Alter innerhalb eines gesetzlichen Fensters halten.
|
||||||
|
function clampAge(v: number, lo: number, hi: number): number {
|
||||||
|
return Math.max(lo, Math.min(hi, Math.round(v)));
|
||||||
|
}
|
||||||
|
|
||||||
function personByRole<T extends { role: PersonRole }>(persons: T[], role: string): T | null {
|
function personByRole<T extends { role: PersonRole }>(persons: T[], role: string): T | null {
|
||||||
return persons.find((p) => p.role === role) ?? null;
|
return persons.find((p) => p.role === role) ?? null;
|
||||||
}
|
}
|
||||||
@@ -414,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} %`;
|
||||||
}
|
}
|
||||||
@@ -514,7 +581,9 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
|
|||||||
const capsFromWorking = personInfos
|
const capsFromWorking = personInfos
|
||||||
.filter((p) => p.working)
|
.filter((p) => p.working)
|
||||||
.map((p) => retirementAge.get(p.personId)! - p.startAge)
|
.map((p) => retirementAge.get(p.personId)! - p.startAge)
|
||||||
.filter((d) => d > 0);
|
.filter((d) => d > 0)
|
||||||
|
// Dazu jeder noch bevorstehende Bezugsbeginn: Auch er erzwingt eine Phasengrenze.
|
||||||
|
.concat(planFixpoints(fixpointPersonsOf(plan)).map((f) => f.year - yearsBefore).filter((d) => d > 0));
|
||||||
const maxDurationYears = capsFromWorking.length > 0 ? Math.min(...capsFromWorking) : null;
|
const maxDurationYears = capsFromWorking.length > 0 ? Math.min(...capsFromWorking) : null;
|
||||||
const workingByPerson = new Map(personInfos.map((p) => [p.personId, p.working]));
|
const workingByPerson = new Map(personInfos.map((p) => [p.personId, p.working]));
|
||||||
|
|
||||||
@@ -618,7 +687,12 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
|
|||||||
|
|
||||||
for (const e of orderedElements) {
|
for (const e of orderedElements) {
|
||||||
const carry = carries.get(e.id)!;
|
const carry = carries.get(e.id)!;
|
||||||
const pd = e.phaseValues[phase.id] ?? {};
|
// Stammdaten (Bestand bei Planbeginn, Ausgangs-Annahmen) und Phasenwerte. In der ERSTEN
|
||||||
|
// Phase gilt der Phasenwert, wo einer erfasst ist, sonst die Stammdaten -- damit ist
|
||||||
|
// Phase 1 nichts Besonderes mehr, sondern erbt schlicht von der Wurzel.
|
||||||
|
const bd = e.baseData ?? {};
|
||||||
|
const raw = e.phaseValues[phase.id] ?? {};
|
||||||
|
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;
|
||||||
|
|
||||||
@@ -628,7 +702,14 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
|
|||||||
// verwendete Wert wird mitgeführt, damit die Kette über mehrere Phasen trägt.
|
// verwendete Wert wird mitgeführt, damit die Kette über mehrere Phasen trägt.
|
||||||
const inherited = (key: string, fallback = 0): number => {
|
const inherited = (key: string, fallback = 0): number => {
|
||||||
const own = (pd as Record<string, unknown>)[key];
|
const own = (pd as Record<string, unknown>)[key];
|
||||||
const v = typeof own === "number" ? own : carry.rates[key] ?? fallback;
|
// Reihenfolge: eigener Wert -> aus der Vorphase geerbt -> Stammdaten -> Fallback.
|
||||||
|
// Die Stammdaten sind damit die WURZEL der Kette; vor 0.36 hatte Phase 1 nichts, von
|
||||||
|
// dem sie hätte erben können, und fiel auf 0.
|
||||||
|
const root = (bd as Record<string, unknown>)[key];
|
||||||
|
const v =
|
||||||
|
typeof own === "number"
|
||||||
|
? own
|
||||||
|
: carry.rates[key] ?? (typeof root === "number" ? root : fallback);
|
||||||
carry.rates[key] = v;
|
carry.rates[key] = v;
|
||||||
return v;
|
return v;
|
||||||
};
|
};
|
||||||
@@ -1541,24 +1622,30 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
|
|||||||
// und innerhalb einer Phase kennt das Modell kein Einzelereignis -- gezogen wird deshalb
|
// und innerhalb einer Phase kennt das Modell kein Einzelereignis -- gezogen wird deshalb
|
||||||
// an der ERSTEN Grenze bei oder nach dem Wunschalter. Liegt der Wunsch hinter dem
|
// an der ERSTEN Grenze bei oder nach dem Wunschalter. Liegt der Wunsch hinter dem
|
||||||
// Planende, greift die letzte Grenze, damit das Guthaben nicht unbezogen liegen bleibt.
|
// Planende, greift die letzte Grenze, damit das Guthaben nicht unbezogen liegen bleibt.
|
||||||
const drawsPillar3aHere = (
|
// Zieht dieses Vorsorge-Guthaben an DIESER Phasengrenze? Gilt gleichermassen für die
|
||||||
|
// Pensionskasse und die Säule 3a: Beide haben seit 0.36 ein eigenes Bezugsalter, und
|
||||||
|
// innerhalb einer Phase kennt das Modell kein Einzelereignis. Gezogen wird an der ERSTEN
|
||||||
|
// Grenze bei oder nach dem Wunschalter; liegt der Wunsch hinter dem Planende, greift die
|
||||||
|
// letzte Grenze, damit kein Guthaben unbezogen liegen bleibt.
|
||||||
|
const drawsHere = (
|
||||||
el: ElementInput,
|
el: ElementInput,
|
||||||
ownerPerson: { id: string; age: number } | null,
|
ownerPerson: { id: string; age: number } | null,
|
||||||
ageAtBoundary: number
|
ageAtBoundary: number
|
||||||
): boolean => {
|
): boolean => {
|
||||||
// 3a ist personengebunden -- ohne Besitzer gibt es kein Bezugsalter und keinen Bezug.
|
// Beide Kategorien sind personengebunden -- ohne Besitzer gibt es kein Bezugsalter.
|
||||||
if (!ownerPerson) return false;
|
if (!ownerPerson) return false;
|
||||||
const rd = withRetirementDefaults(
|
const ra = retirementAge.get(ownerPerson.id) ?? AHV_REFERENCE_AGE;
|
||||||
"PILLAR_3A",
|
const rd = withRetirementDefaults(el.category, ra, el.retirementDecision);
|
||||||
retirementAge.get(ownerPerson.id) ?? AHV_REFERENCE_AGE,
|
const wish =
|
||||||
el.retirementDecision
|
el.category === "PENSION_FUND"
|
||||||
);
|
? clampAge(num(rd.pkWithdrawalAge, ra), PK_MIN_RETIREMENT_AGE, PK_MAX_RETIREMENT_AGE)
|
||||||
const wish = Math.max(
|
: clampAge(
|
||||||
PILLAR_3A_MIN_WITHDRAWAL_AGE,
|
num(rd.withdrawalAge, ageAtBoundary),
|
||||||
Math.min(PILLAR_3A_MAX_WITHDRAWAL_AGE, Math.round(num(rd.withdrawalAge, ageAtBoundary)))
|
PILLAR_3A_MIN_WITHDRAWAL_AGE,
|
||||||
);
|
PILLAR_3A_MAX_WITHDRAWAL_AGE
|
||||||
|
);
|
||||||
const prevBoundaryAge = ownerPerson.age + yearsBefore;
|
const prevBoundaryAge = ownerPerson.age + yearsBefore;
|
||||||
if (prevBoundaryAge >= wish) return false; // in einer früheren Phase bereits gezogen
|
if (prevBoundaryAge >= wish) return false; // an einer früheren Grenze bereits gezogen
|
||||||
return ageAtBoundary >= wish || !nextPhase;
|
return ageAtBoundary >= wish || !nextPhase;
|
||||||
};
|
};
|
||||||
// Echte Vermögensänderungen an dieser Grenze (für die Brücke der Folgephase).
|
// Echte Vermögensänderungen an dieser Grenze (für die Brücke der Folgephase).
|
||||||
@@ -1597,11 +1684,10 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
|
|||||||
const ec = ecById.get(e.id)!;
|
const ec = ecById.get(e.id)!;
|
||||||
const td = e.transitionValues[phase.id] ?? {};
|
const td = e.transitionValues[phase.id] ?? {};
|
||||||
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 ownerRetiresNext =
|
|
||||||
!!owner && !!nextPhase && workingByPerson.get(owner.id) === true &&
|
|
||||||
retiresInPhase(owner.id, persons, retirementAge, yearsBefore + duration);
|
|
||||||
// Pensionsalter des Besitzers und sein Alter AN dieser Phasengrenze -- Bezugspunkt für
|
// Pensionsalter des Besitzers und sein Alter AN dieser Phasengrenze -- Bezugspunkt für
|
||||||
// die Vorgaben des Pensionierungs-Entscheids und für das 3a-Bezugsalter.
|
// die Vorgaben des Pensionierungs-Entscheids und für die Bezugsalter von PK und 3a.
|
||||||
|
// Ob ein Guthaben HIER gezogen wird, entscheidet seit 0.36 `drawsHere` anhand des
|
||||||
|
// jeweiligen Bezugsalters, nicht mehr das Erwerbsende.
|
||||||
const ownerRetirementAge = owner ? retirementAge.get(owner.id) ?? AHV_REFERENCE_AGE : AHV_REFERENCE_AGE;
|
const ownerRetirementAge = owner ? retirementAge.get(owner.id) ?? AHV_REFERENCE_AGE : AHV_REFERENCE_AGE;
|
||||||
const ownerAgeAtBoundary = owner ? owner.age + yearsBefore + duration : 0;
|
const ownerAgeAtBoundary = owner ? owner.age + yearsBefore + duration : 0;
|
||||||
|
|
||||||
@@ -1628,7 +1714,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
|
|||||||
|
|
||||||
switch (e.category) {
|
switch (e.category) {
|
||||||
case "PENSION_FUND": {
|
case "PENSION_FUND": {
|
||||||
if (ownerRetiresNext) {
|
if (drawsHere(e, owner, ownerAgeAtBoundary)) {
|
||||||
// EINE Quote statt PENSION/CAPITAL/COMBI plus Frankenbetrag: 0 % = volle Rente,
|
// EINE Quote statt PENSION/CAPITAL/COMBI plus Frankenbetrag: 0 % = volle Rente,
|
||||||
// 100 % = volles Kapital, alles dazwischen ist die Kombination. Als Quote, weil
|
// 100 % = volles Kapital, alles dazwischen ist die Kombination. Als Quote, weil
|
||||||
// sich das Guthaben mit dem Pensionsalter ändert -- ein fixer Betrag würde beim
|
// sich das Guthaben mit dem Pensionsalter ändert -- ein fixer Betrag würde beim
|
||||||
@@ -1665,7 +1751,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
|
|||||||
// Übergang: Ein 3a-Konto lässt sich nur GANZ auflösen, und alle Bezüge desselben
|
// Übergang: Ein 3a-Konto lässt sich nur GANZ auflösen, und alle Bezüge desselben
|
||||||
// Jahres werden steuerlich zusammengezählt -- gestaffelt wird deshalb über Konten
|
// Jahres werden steuerlich zusammengezählt -- gestaffelt wird deshalb über Konten
|
||||||
// und Jahre. Gezogen wird an der ersten Phasengrenze bei oder nach dem Wunschalter.
|
// und Jahre. Gezogen wird an der ersten Phasengrenze bei oder nach dem Wunschalter.
|
||||||
if (drawsPillar3aHere(e, owner, ownerAgeAtBoundary)) {
|
if (drawsHere(e, owner, ownerAgeAtBoundary)) {
|
||||||
const rd = withRetirementDefaults("PILLAR_3A", ownerRetirementAge, e.retirementDecision);
|
const rd = withRetirementDefaults("PILLAR_3A", ownerRetirementAge, e.retirementDecision);
|
||||||
const net = Math.round(ec.endValue * (1 - num(rd.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE) / 100));
|
const net = Math.round(ec.endValue * (1 - num(rd.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE) / 100));
|
||||||
txInflow += net;
|
txInflow += net;
|
||||||
@@ -1746,17 +1832,22 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
|
|||||||
|
|
||||||
if (e.category === "PENSION_FUND" || e.category === "PILLAR_3A") {
|
if (e.category === "PENSION_FUND" || e.category === "PILLAR_3A") {
|
||||||
anchor = e.category === "PENSION_FUND" ? "491-pension_fund" : "492-pillar_3a";
|
anchor = e.category === "PENSION_FUND" ? "491-pension_fund" : "492-pillar_3a";
|
||||||
const mode = ownerRetiresNext ? td.payoutMode ?? "PENSION" : td.withdrawalMode ?? "NONE";
|
// Der Bezugs-Entscheid liegt seit 0.34 am Element; der ZEITPUNKT hat seit 0.36 ein
|
||||||
title = `Übergang «${ec.name}» – ${ownerRetiresNext ? "Pensionierung" : "Vorbezug"}`;
|
// eigenes Alter und muss nicht mehr mit dem Erwerbsende zusammenfallen.
|
||||||
if (ownerRetiresNext && e.category === "PENSION_FUND") {
|
const drawnHere = drawsHere(e, owner, ownerAgeAtBoundary);
|
||||||
steps.push(st("Gewählte Bezugsart", 0, undefined, mode === "CAPITAL" ? "Kapitalbezug" : mode === "COMBI" ? "Kombination" : "Rente", undefined, ""));
|
const rd = withRetirementDefaults(e.category, ownerRetirementAge, e.retirementDecision);
|
||||||
if (mode === "PENSION" || mode === "COMBI") {
|
title = `Übergang «${ec.name}» – ${drawnHere ? "Bezug" : "Vorbezug"}`;
|
||||||
steps.push(st("Umwandlungssatz", num(td.conversionRate, DEFAULT_PK_CONVERSION_RATE), undefined, undefined, undefined, "%"));
|
if (drawnHere && e.category === "PENSION_FUND") {
|
||||||
|
const share = Math.max(0, Math.min(100, num(rd.capitalSharePct)));
|
||||||
|
steps.push(st("Anteil Kapitalbezug", share, undefined, undefined, "0 % = volle Rente, 100 % = volles Kapital.", "%"));
|
||||||
|
if (share < 100) {
|
||||||
|
steps.push(st("Umwandlungssatz", num(rd.conversionRate, DEFAULT_PK_CONVERSION_RATE), undefined, undefined, undefined, "%"));
|
||||||
steps.push(st("Jährliche Rente", carry.pkPensionAnnual, "verrentetes Kapital × Umwandlungssatz", undefined, "Das verrentete Kapital verlässt die Vermögensbilanz und erscheint fortan als Renteneinkommen."));
|
steps.push(st("Jährliche Rente", carry.pkPensionAnnual, "verrentetes Kapital × Umwandlungssatz", undefined, "Das verrentete Kapital verlässt die Vermögensbilanz und erscheint fortan als Renteneinkommen."));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (taxHere > 0) {
|
if (taxHere > 0) {
|
||||||
steps.push(st("Kapitalbezugssteuer", num(td.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE), undefined, undefined, "Pauschalsatz – die tatsächliche Steuer ist kantonal und progressiv.", "%"));
|
const satz = drawnHere ? num(rd.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE) : num(td.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE);
|
||||||
|
steps.push(st("Kapitalbezugssteuer", satz, undefined, undefined, "Pauschalsatz – die tatsächliche Steuer ist kantonal und progressiv.", "%"));
|
||||||
steps.push(st("Steuerbetrag", -taxHere));
|
steps.push(st("Steuerbetrag", -taxHere));
|
||||||
}
|
}
|
||||||
if (inflowHere !== 0) steps.push(st("Netto ins Cash", inflowHere, "Bruttobezug − Kapitalbezugssteuer"));
|
if (inflowHere !== 0) steps.push(st("Netto ins Cash", inflowHere, "Bruttobezug − Kapitalbezugssteuer"));
|
||||||
@@ -1827,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;
|
||||||
const pay = Math.min(amortBudget, c.mortgage);
|
if (e.category === "REAL_ESTATE") {
|
||||||
c.mortgage -= pay;
|
if (c.mortgage <= 0) continue;
|
||||||
amortBudget -= pay;
|
const pay = Math.min(amortBudget, c.mortgage);
|
||||||
txImmediateRepay += pay;
|
c.mortgage -= pay;
|
||||||
|
amortBudget -= 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);
|
||||||
@@ -1924,7 +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,
|
||||||
planningHorizonAge: p.planningHorizonAge ?? null,
|
// Alter am Planende -- abgeleitet aus der Summe der Lebensphasen.
|
||||||
|
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),
|
||||||
@@ -2027,15 +2136,4 @@ function buildCareer(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function retiresInPhase(
|
|
||||||
personId: string,
|
|
||||||
persons: { id: string; role: PersonRole; age: number }[],
|
|
||||||
retirementAge: Map<string, number>,
|
|
||||||
yearsBeforeNext: number
|
|
||||||
): boolean {
|
|
||||||
const p = persons.find((x) => x.id === personId);
|
|
||||||
if (!p) return false;
|
|
||||||
return p.age + yearsBeforeNext >= retirementAge.get(personId)!;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Der CSV-Export liegt seit 0.31 in lib/csv.ts (vollstaendige Matrix statt Phasen-Summary).
|
// Der CSV-Export liegt seit 0.31 in lib/csv.ts (vollstaendige Matrix statt Phasen-Summary).
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -72,9 +72,11 @@ export const PK_BUYIN_BLOCKING_YEARS = 3;
|
|||||||
// Summe der Phasendauern -- zwei Szenarien konnten dadurch unbemerkt verschieden weit rechnen
|
// Summe der Phasendauern -- zwei Szenarien konnten dadurch unbemerkt verschieden weit rechnen
|
||||||
// und waren nicht vergleichbar. Der Default liegt bewusst ueber der Lebenserwartung: Eine zu
|
// und waren nicht vergleichbar. Der Default liegt bewusst ueber der Lebenserwartung: Eine zu
|
||||||
// kurze Planung sieht tragfaehig aus, obwohl das Geld nur nicht lange genug reichen muss.
|
// kurze Planung sieht tragfaehig aus, obwohl das Geld nur nicht lange genug reichen muss.
|
||||||
export const DEFAULT_PLANNING_HORIZON_AGE = 90;
|
// Gefuehrt wird die Laufzeit in JAHREN und nicht als Endalter je Person: eine Zahl statt
|
||||||
export const MIN_PLANNING_HORIZON_AGE = 70;
|
// zweier, die bei einem Paar auseinanderlaufen koennten. Die Endalter sind die Ableitung.
|
||||||
export const MAX_PLANNING_HORIZON_AGE = 110;
|
export const DEFAULT_PLANNING_HORIZON_YEARS = 35;
|
||||||
|
export const MIN_PLANNING_HORIZON_YEARS = 1;
|
||||||
|
export const MAX_PLANNING_HORIZON_YEARS = 70;
|
||||||
|
|
||||||
// Umrechnung Netto- -> Bruttolohn für die AHV. Das Tool erfasst das Einkommen NETTO (so
|
// Umrechnung Netto- -> Bruttolohn für die AHV. Das Tool erfasst das Einkommen NETTO (so
|
||||||
// denkt der Nutzer, und so stimmt der Cash-Fluss), die AHV bemisst sich aber am BRUTTOlohn.
|
// denkt der Nutzer, und so stimmt der Cash-Fluss), die AHV bemisst sich aber am BRUTTOlohn.
|
||||||
|
|||||||
@@ -1,96 +0,0 @@
|
|||||||
// Beispielplan mit einem Klick (Teil von Roadmap Nr. 10). Erzeugt einen realistischen,
|
|
||||||
// vollständig ausgefuellten Plan über die BESTEHENDEN API-Endpunkte -- reine Orchestrierung,
|
|
||||||
// kein neuer Endpunkt, keine Änderung an der Berechnung.
|
|
||||||
//
|
|
||||||
// Didaktischer Entscheid: Die Übergangs-Entscheide bleiben absichtlich OFFEN. Der neue
|
|
||||||
// Nutzer sieht dadurch die Ampel ("N offen") in Aktion und lernt das wichtigste Konzept des
|
|
||||||
// Tools am Beispiel, statt es erklärt zu bekommen.
|
|
||||||
//
|
|
||||||
// WICHTIG zur Reihenfolge: erst Phase 1, dann die Elemente samt Phase-1-Werten, dann die
|
|
||||||
// Folgephase. Die Phasen-Route belegt beim Anlegen einer Phase die editierbaren Felder aus
|
|
||||||
// der Vorphase vor -- existieren die Elemente noch nicht, bleibt die Folgephase leer
|
|
||||||
// (Renditen/Beiträge wären dort 0).
|
|
||||||
|
|
||||||
import { api } from "@/lib/api-client";
|
|
||||||
import type { PhaseData } from "@/lib/elements";
|
|
||||||
|
|
||||||
async function addElement(
|
|
||||||
scenarioId: string,
|
|
||||||
firstPhaseId: string,
|
|
||||||
category: string,
|
|
||||||
name: string,
|
|
||||||
ownerRole: string,
|
|
||||||
values?: PhaseData
|
|
||||||
): Promise<void> {
|
|
||||||
const { element } = await api.post<{ element: { id: string } }>(
|
|
||||||
`/api/scenarios/${scenarioId}/elements`,
|
|
||||||
{ category, name, ownerRole }
|
|
||||||
);
|
|
||||||
if (values && Object.keys(values).length > 0) {
|
|
||||||
await api.put(`/api/elements/${element.id}/phase/${firstPhaseId}`, values);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Liefert die Szenario-Id des angelegten Beispielplans.
|
|
||||||
export async function createDemoPlan(): Promise<string> {
|
|
||||||
const currentYear = new Date().getFullYear();
|
|
||||||
|
|
||||||
const { scenario } = await api.post<{ plan: { id: string }; scenario: { id: string } }>(
|
|
||||||
"/api/plans",
|
|
||||||
{
|
|
||||||
name: "Beispiel: Alex Muster",
|
|
||||||
householdType: "SINGLE",
|
|
||||||
inflationRateDefault: 1.5,
|
|
||||||
startYear: currentYear,
|
|
||||||
persons: [{ role: "PERSON_A", name: "Alex", age: 45, retirementAge: 65 }],
|
|
||||||
}
|
|
||||||
);
|
|
||||||
const sid = scenario.id;
|
|
||||||
|
|
||||||
await api.patch(`/api/scenarios/${sid}`, { initialCash: 40000 });
|
|
||||||
|
|
||||||
// Phase 1: Erwerbsphase bis zur Pension (die Route kappt die Dauer ohnehin am
|
|
||||||
// Pensionsereignis; 20 Jahre = 45 -> 65).
|
|
||||||
const p1 = await api.post<{ phase: { id: string } }>(`/api/scenarios/${sid}/phases`, {
|
|
||||||
name: "Erwerbsphase",
|
|
||||||
durationYears: 20,
|
|
||||||
});
|
|
||||||
const firstPhaseId = p1.phase.id;
|
|
||||||
|
|
||||||
await addElement(sid, firstPhaseId, "INCOME", "Lohn Alex", "PERSON_A", {
|
|
||||||
amount: 110000,
|
|
||||||
teuerungsausgleich: 1,
|
|
||||||
});
|
|
||||||
await addElement(sid, firstPhaseId, "EXPENSE", "Lebenshaltung", "HOUSEHOLD", {
|
|
||||||
amount: 78000,
|
|
||||||
teuerungsausgleich: 0,
|
|
||||||
});
|
|
||||||
await addElement(sid, firstPhaseId, "AHV", "AHV Alex", "PERSON_A", { gapYears: 0 });
|
|
||||||
await addElement(sid, firstPhaseId, "PENSION_FUND", "Pensionskasse", "PERSON_A", {
|
|
||||||
currentValue: 380000,
|
|
||||||
annualContribution: 14000,
|
|
||||||
expectedReturn: 1.5,
|
|
||||||
});
|
|
||||||
await addElement(sid, firstPhaseId, "PILLAR_3A", "Säule 3a", "PERSON_A", {
|
|
||||||
currentValue: 75000,
|
|
||||||
annualContribution: 7000,
|
|
||||||
expectedReturn: 2,
|
|
||||||
});
|
|
||||||
await addElement(sid, firstPhaseId, "OTHER_ASSET", "ETF-Depot", "HOUSEHOLD", {
|
|
||||||
startValue: 120000,
|
|
||||||
annualContribution: 12000,
|
|
||||||
expectedReturn: 5,
|
|
||||||
});
|
|
||||||
await addElement(sid, firstPhaseId, "REAL_ESTATE", "Eigenheim", "HOUSEHOLD", {
|
|
||||||
purchasePrice: 900000,
|
|
||||||
mortgage: 600000,
|
|
||||||
amortization: 12000,
|
|
||||||
interestRate: 1.5,
|
|
||||||
valueGrowth: 1,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Pensionsphase 65 -> 90. Die Route belegt die editierbaren Felder aus Phase 1 vor.
|
|
||||||
await api.post(`/api/scenarios/${sid}/phases`, { name: "Pensionsphase", durationYears: 25 });
|
|
||||||
|
|
||||||
return sid;
|
|
||||||
}
|
|
||||||
+3
-2
@@ -110,7 +110,8 @@ export function computeScenarioDiff(scenario: PlanInput, base: PlanInput | null)
|
|||||||
if (
|
if (
|
||||||
el.name !== src.name ||
|
el.name !== src.name ||
|
||||||
el.ownerRole !== src.ownerRole ||
|
el.ownerRole !== src.ownerRole ||
|
||||||
!sameData(el.retirementDecision ?? {}, src.retirementDecision ?? {})
|
!sameData(el.retirementDecision ?? {}, src.retirementDecision ?? {}) ||
|
||||||
|
!sameData(el.baseData ?? {}, src.baseData ?? {})
|
||||||
) {
|
) {
|
||||||
d.elementRow.set(el.id, "changed");
|
d.elementRow.set(el.id, "changed");
|
||||||
}
|
}
|
||||||
@@ -139,7 +140,7 @@ export function computeScenarioDiff(scenario: PlanInput, base: PlanInput | null)
|
|||||||
// --- Profil und Cash-Anfangswert ---
|
// --- Profil und Cash-Anfangswert ---
|
||||||
d.cashInitialChanged = Math.round(scenario.initialCash) !== Math.round(base.initialCash);
|
d.cashInitialChanged = Math.round(scenario.initialCash) !== Math.round(base.initialCash);
|
||||||
const personKey = (p: PlanInput["persons"][number]) =>
|
const personKey = (p: PlanInput["persons"][number]) =>
|
||||||
`${p.role}|${p.name ?? ""}|${p.age}|${p.retirementAge}|${p.planningHorizonAge ?? ""}`;
|
`${p.role}|${p.name ?? ""}|${p.age}|${p.retirementAge}`;
|
||||||
d.profileChanged =
|
d.profileChanged =
|
||||||
scenario.householdType !== base.householdType ||
|
scenario.householdType !== base.householdType ||
|
||||||
scenario.inflationRateDefault !== base.inflationRateDefault ||
|
scenario.inflationRateDefault !== base.inflationRateDefault ||
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -68,17 +68,25 @@ describe("Datenbank-Migrationen", () => {
|
|||||||
expect(scenCols).not.toContain("startYear");
|
expect(scenCols).not.toContain("startYear");
|
||||||
expect(scenCols).not.toContain("userId"); // Eigentümer hängt am Plan
|
expect(scenCols).not.toContain("userId"); // Eigentümer hängt am Plan
|
||||||
|
|
||||||
// Person trägt Pensionsalter und Planungshorizont -- Name und Alter beschreiben den
|
// Person trägt nur noch das Pensionsalter -- Name und Alter beschreiben den Haushalt und
|
||||||
// Haushalt und liegen am Plan.
|
// liegen am Plan, der Planungshorizont liegt seit 0.36 als JAHRESZAHL am Szenario.
|
||||||
const personCols = await cols("Person");
|
const personCols = await cols("Person");
|
||||||
expect(personCols).toContain("retirementAge");
|
expect(personCols).toContain("retirementAge");
|
||||||
expect(personCols).toContain("planningHorizonAge");
|
expect(personCols).not.toContain("planningHorizonAge");
|
||||||
expect(personCols).not.toContain("name");
|
expect(personCols).not.toContain("name");
|
||||||
expect(personCols).not.toContain("age");
|
expect(personCols).not.toContain("age");
|
||||||
|
|
||||||
|
// Der Assistenten-Fortschritt ist mit dem Assistenten entfallen (0.39): Was offen ist,
|
||||||
|
// wird abgeleitet und nicht gespeichert.
|
||||||
|
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.
|
// nur so überlebt er eine Verschiebung der Zeitachse. Die Stammdaten (0.36) ebenso: Sie
|
||||||
|
// beschreiben den Bestand bei Planbeginn und existieren, bevor es Phasen gibt.
|
||||||
expect(await cols("FinancialElement")).toContain("retirementDecision");
|
expect(await cols("FinancialElement")).toContain("retirementDecision");
|
||||||
|
expect(await cols("FinancialElement")).toContain("baseData");
|
||||||
|
|
||||||
// Er muss NULL zulassen: Ohne erfassten Entscheid gelten die Vorgaben.
|
// Er muss NULL zulassen: Ohne erfassten Entscheid gelten die Vorgaben.
|
||||||
const rd = await db.query<{ is_nullable: string }>(
|
const rd = await db.query<{ is_nullable: string }>(
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import { planSegments, defaultOpenDuration, planDurationChange } from "@/lib/phaseplan";
|
import { planFixpoints, planSegments, defaultOpenDuration, planDurationChange } from "@/lib/phaseplan";
|
||||||
|
|
||||||
|
// Seit 0.36 tragen Abschnitte zusaetzlich `endedBy` (Beschriftung). Die Struktur-Tests
|
||||||
|
// projizieren deshalb auf die drei Kernfelder.
|
||||||
|
const shape = (segs: ReturnType<typeof planSegments>) =>
|
||||||
|
segs.map((s) => ({ type: s.type, fromYear: s.fromYear, fixedYears: s.fixedYears }));
|
||||||
|
|
||||||
describe("planSegments", () => {
|
describe("planSegments", () => {
|
||||||
it("Einzelperson: genau zwei Teile (Erwerb fix, Pension offen)", () => {
|
it("Einzelperson: genau zwei Teile (Erwerb fix, Pension offen)", () => {
|
||||||
const segs = planSegments([{ age: 45, retirementAge: 65 }]);
|
const segs = planSegments([{ age: 45, retirementAge: 65 }]);
|
||||||
expect(segs).toEqual([
|
expect(shape(segs)).toEqual([
|
||||||
{ type: "ERWERB", fromYear: 0, fixedYears: 20 },
|
{ type: "ERWERB", fromYear: 0, fixedYears: 20 },
|
||||||
{ type: "PENSION", fromYear: 20, fixedYears: null },
|
{ type: "PENSION", fromYear: 20, fixedYears: null },
|
||||||
]);
|
]);
|
||||||
@@ -25,7 +30,7 @@ describe("planSegments", () => {
|
|||||||
{ age: 45, retirementAge: 65 }, // Ret in 20 J.
|
{ age: 45, retirementAge: 65 }, // Ret in 20 J.
|
||||||
{ age: 50, retirementAge: 62 }, // Ret in 12 J.
|
{ age: 50, retirementAge: 62 }, // Ret in 12 J.
|
||||||
]);
|
]);
|
||||||
expect(segs).toEqual([
|
expect(shape(segs)).toEqual([
|
||||||
{ type: "ERWERB", fromYear: 0, fixedYears: 12 },
|
{ type: "ERWERB", fromYear: 0, fixedYears: 12 },
|
||||||
{ type: "MIXED", fromYear: 12, fixedYears: 8 },
|
{ type: "MIXED", fromYear: 12, fixedYears: 8 },
|
||||||
{ type: "PENSION", fromYear: 20, fixedYears: null },
|
{ type: "PENSION", fromYear: 20, fixedYears: null },
|
||||||
@@ -37,7 +42,7 @@ describe("planSegments", () => {
|
|||||||
{ age: 67, retirementAge: 65 }, // bereits pensioniert -> Ret in 0 J.
|
{ age: 67, retirementAge: 65 }, // bereits pensioniert -> Ret in 0 J.
|
||||||
{ age: 60, retirementAge: 65 }, // Ret in 5 J.
|
{ age: 60, retirementAge: 65 }, // Ret in 5 J.
|
||||||
]);
|
]);
|
||||||
expect(segs).toEqual([
|
expect(shape(segs)).toEqual([
|
||||||
{ type: "MIXED", fromYear: 0, fixedYears: 5 },
|
{ type: "MIXED", fromYear: 0, fixedYears: 5 },
|
||||||
{ type: "PENSION", fromYear: 5, fixedYears: null },
|
{ type: "PENSION", fromYear: 5, fixedYears: null },
|
||||||
]);
|
]);
|
||||||
@@ -48,11 +53,11 @@ describe("planSegments", () => {
|
|||||||
{ age: 70, retirementAge: 65 },
|
{ age: 70, retirementAge: 65 },
|
||||||
{ age: 68, retirementAge: 64 },
|
{ age: 68, retirementAge: 64 },
|
||||||
]);
|
]);
|
||||||
expect(segs).toEqual([{ type: "PENSION", fromYear: 0, fixedYears: null }]);
|
expect(shape(segs)).toEqual([{ type: "PENSION", fromYear: 0, fixedYears: null }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("Einzelperson bereits pensioniert: nur Pension", () => {
|
it("Einzelperson bereits pensioniert: nur Pension", () => {
|
||||||
expect(planSegments([{ age: 66, retirementAge: 65 }])).toEqual([
|
expect(shape(planSegments([{ age: 66, retirementAge: 65 }]))).toEqual([
|
||||||
{ type: "PENSION", fromYear: 0, fixedYears: null },
|
{ type: "PENSION", fromYear: 0, fixedYears: null },
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
@@ -127,3 +132,64 @@ describe("planDurationChange", () => {
|
|||||||
expect(after).toBe(before);
|
expect(after).toBe(before);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("planFixpoints (0.36)", () => {
|
||||||
|
// Bis 0.35 gab es genau einen Fixpunkt pro Person: das Erwerbsende. Seit die Bezugsbeginne
|
||||||
|
// von AHV, PK und 3a eigene Zeitpunkte haben, erzwingt jeder davon eine Phasengrenze --
|
||||||
|
// sonst fiele ein Bezug mitten in eine Phase und rutschte auf die nächste Grenze.
|
||||||
|
const person = {
|
||||||
|
role: "PERSON_A",
|
||||||
|
name: "Anna",
|
||||||
|
age: 60,
|
||||||
|
retirementAge: 62,
|
||||||
|
ahvStartAge: 63,
|
||||||
|
pkWithdrawalAge: 62,
|
||||||
|
pillar3aAges: [64, 65],
|
||||||
|
};
|
||||||
|
|
||||||
|
it("sammelt jeden Bezugsbeginn als eigenen Zeitpunkt", () => {
|
||||||
|
const f = planFixpoints([person]);
|
||||||
|
// Erwerbsende 62 und PK 62 fallen auf dasselbe Jahr -> zwei Eintraege, EIN Jahr.
|
||||||
|
expect(f.map((x) => x.year)).toEqual([2, 2, 3, 4, 5]);
|
||||||
|
expect(new Set(f.map((x) => x.kind))).toEqual(
|
||||||
|
new Set(["RETIREMENT", "PENSION_FUND", "AHV", "PILLAR_3A"])
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("erzeugt je distinktem Jahr genau eine Abschnittsgrenze", () => {
|
||||||
|
const segs = planSegments([person]);
|
||||||
|
// Grenzen bei 2, 3, 4, 5 -> fuenf Abschnitte (der letzte offen).
|
||||||
|
expect(segs.map((x) => x.fromYear)).toEqual([0, 2, 3, 4, 5]);
|
||||||
|
expect(segs[segs.length - 1].fixedYears).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("beschriftet jede Grenze mit den Ereignissen, die sie ausloesen", () => {
|
||||||
|
const segs = planSegments([person]);
|
||||||
|
// Der erste Abschnitt endet mit Erwerbsende UND PK-Bezug -- beide im selben Jahr.
|
||||||
|
expect(segs[0].endedBy).toHaveLength(2);
|
||||||
|
expect(segs[0].endedBy.every((f) => f.label.includes("Anna"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignoriert Ereignisse, die bei Planbeginn schon vorbei sind", () => {
|
||||||
|
const f = planFixpoints([{ role: "PERSON_A", age: 67, retirementAge: 65, ahvStartAge: 65 }]);
|
||||||
|
expect(f).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("faellt ohne Bezugsalter auf das alte Verhalten zurueck", () => {
|
||||||
|
const segs = planSegments([{ role: "PERSON_A", age: 45, retirementAge: 65 }]);
|
||||||
|
expect(shape(segs)).toEqual([
|
||||||
|
{ type: "ERWERB", fromYear: 0, fixedYears: 20 },
|
||||||
|
{ type: "PENSION", fromYear: 20, fixedYears: null },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("defaultOpenDuration mit Planungshorizont", () => {
|
||||||
|
it("leitet die Dauer aus dem Horizont ab, wenn er erfasst ist", () => {
|
||||||
|
expect(defaultOpenDuration([{ age: 45 }], 20, 35)).toBe(15);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("faellt ohne Horizont auf die Schaetzung bis Alter 90 zurueck", () => {
|
||||||
|
expect(defaultOpenDuration([{ age: 45 }], 20)).toBe(25);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+88
-14
@@ -11,40 +11,114 @@
|
|||||||
|
|
||||||
export type SegmentType = "ERWERB" | "MIXED" | "PENSION";
|
export type SegmentType = "ERWERB" | "MIXED" | "PENSION";
|
||||||
|
|
||||||
|
// Ein Zeitpunkt, an dem ZWINGEND eine neue Lebensphase beginnen muss.
|
||||||
|
//
|
||||||
|
// Bis 0.35 gab es genau einen pro Person: das Erwerbsende. Seit 0.36 sind es bis zu vier --
|
||||||
|
// jeder Beginn eines Renten- oder Kapitalbezugs ist einer. Der Grund ist derselbe wie beim
|
||||||
|
// Erwerbsende: Die Rechnung leitet Erwerbsstatus und Bezuege am PHASENBEGINN ab. Faellt ein
|
||||||
|
// Bezug mitten in eine Phase, waere der halbe Phasenertrag falsch.
|
||||||
|
export type FixpointKind = "RETIREMENT" | "AHV" | "PENSION_FUND" | "PILLAR_3A";
|
||||||
|
|
||||||
|
export interface Fixpoint {
|
||||||
|
// Jahre ab Planbeginn (1-basiert wie die Phasengrenzen: Jahr 5 = Ende des 5. Planjahres).
|
||||||
|
year: number;
|
||||||
|
age: number;
|
||||||
|
role: string;
|
||||||
|
kind: FixpointKind;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface PlanSegment {
|
export interface PlanSegment {
|
||||||
type: SegmentType;
|
type: SegmentType;
|
||||||
fromYear: number; // Jahre ab Planbeginn (Beginn des Abschnitts)
|
fromYear: number; // Jahre ab Planbeginn (Beginn des Abschnitts)
|
||||||
// Feste Länge in Jahren (durch den nächsten Pensionierungspunkt bestimmt) ODER null für den
|
// Feste Länge in Jahren (durch den nächsten Fixpunkt bestimmt) ODER null für den letzten,
|
||||||
// letzten, OFFENEN Abschnitt (Pension bis Lebensende -- Dauer frei wählbar).
|
// OFFENEN Abschnitt (Dauer frei wählbar bzw. durch den Planungshorizont bestimmt).
|
||||||
fixedYears: number | null;
|
fixedYears: number | null;
|
||||||
|
// Welche Ereignisse den Abschnitt beenden -- für die Beschriftung der Zeitachse.
|
||||||
|
endedBy: Fixpoint[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ein Person-Erwerbsstatus wechselt genau einmal: im Jahr `retirementAge - age` (auf 0 gekappt,
|
export interface FixpointPerson {
|
||||||
// falls bei Planbeginn bereits pensioniert). Die distinkten dieser Zeitpunkte sind die
|
// Optional, damit Aufrufer, die nur Abschnitte brauchen (Zeitachse), nichts erfinden muessen.
|
||||||
// Abschnittsgrenzen; der Abschnitt nach dem letzten Zeitpunkt ist offen.
|
role?: string;
|
||||||
export function planSegments(persons: { age: number; retirementAge: number }[]): PlanSegment[] {
|
name?: string | null;
|
||||||
if (persons.length === 0) return [{ type: "PENSION", fromYear: 0, fixedYears: null }];
|
age: number;
|
||||||
|
retirementAge: number;
|
||||||
|
// Bezugsalter aus dem Pensionierungs-Entscheid. Fehlen sie, gilt das Erwerbsende.
|
||||||
|
ahvStartAge?: number;
|
||||||
|
pkWithdrawalAge?: number;
|
||||||
|
pillar3aAges?: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const KIND_LABEL: Record<FixpointKind, string> = {
|
||||||
|
RETIREMENT: "Erwerbsende",
|
||||||
|
AHV: "AHV-Rente",
|
||||||
|
PENSION_FUND: "Pensionskasse",
|
||||||
|
PILLAR_3A: "Säule 3a",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Alle Fixpunkte, sortiert und ohne Duplikate im selben Jahr (mehrere Ereignisse im gleichen
|
||||||
|
// Jahr brauchen nur EINE Phasengrenze -- sie wird dann mehrfach beschriftet).
|
||||||
|
export function planFixpoints(persons: FixpointPerson[]): Fixpoint[] {
|
||||||
|
const out: Fixpoint[] = [];
|
||||||
|
const add = (person: FixpointPerson, age: number | undefined, kind: FixpointKind) => {
|
||||||
|
if (typeof age !== "number") return;
|
||||||
|
const year = Math.round(age - person.age);
|
||||||
|
// Ereignisse vor oder bei Planbeginn sind keine Grenze -- sie sind bereits Geschichte.
|
||||||
|
if (year <= 0) return;
|
||||||
|
out.push({
|
||||||
|
year,
|
||||||
|
age: Math.round(age),
|
||||||
|
role: person.role ?? "PERSON_A",
|
||||||
|
kind,
|
||||||
|
label: `${KIND_LABEL[kind]}${person.name ? " " + person.name : ""} (${Math.round(age)})`,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
for (const p of persons) {
|
||||||
|
add(p, p.retirementAge, "RETIREMENT");
|
||||||
|
add(p, p.ahvStartAge, "AHV");
|
||||||
|
add(p, p.pkWithdrawalAge, "PENSION_FUND");
|
||||||
|
for (const a of p.pillar3aAges ?? []) add(p, a, "PILLAR_3A");
|
||||||
|
}
|
||||||
|
return out.sort((a, b) => a.year - b.year || a.kind.localeCompare(b.kind));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Die distinkten Fixpunkt-Jahre sind die Abschnittsgrenzen; der Abschnitt nach dem letzten
|
||||||
|
// ist offen.
|
||||||
|
export function planSegments(persons: FixpointPerson[]): PlanSegment[] {
|
||||||
|
if (persons.length === 0) return [{ type: "PENSION", fromYear: 0, fixedYears: null, endedBy: [] }];
|
||||||
|
|
||||||
|
const fixpoints = planFixpoints(persons);
|
||||||
const retYears = persons.map((p) => Math.max(0, p.retirementAge - p.age));
|
const retYears = persons.map((p) => Math.max(0, p.retirementAge - p.age));
|
||||||
const events = [...new Set(retYears.filter((r) => r > 0))].sort((a, b) => a - b);
|
const years = [...new Set(fixpoints.map((f) => f.year))].sort((a, b) => a - b);
|
||||||
const boundaries = [0, ...events];
|
const boundaries = [0, ...years];
|
||||||
|
|
||||||
const segments: PlanSegment[] = [];
|
const segments: PlanSegment[] = [];
|
||||||
for (let i = 0; i < boundaries.length; i++) {
|
for (let i = 0; i < boundaries.length; i++) {
|
||||||
const from = boundaries[i];
|
const from = boundaries[i];
|
||||||
const next = i + 1 < boundaries.length ? boundaries[i + 1] : null;
|
const next = i + 1 < boundaries.length ? boundaries[i + 1] : null;
|
||||||
// Eine Person arbeitet in einem Abschnitt, der bei Jahr `from` beginnt, genau dann, wenn
|
// Eine Person arbeitet in einem Abschnitt, der bei Jahr `from` beginnt, genau dann, wenn
|
||||||
// ihr Pensionierungszeitpunkt echt später liegt.
|
// ihr Erwerbsende echt später liegt.
|
||||||
const working = retYears.filter((r) => r > from).length;
|
const working = retYears.filter((r) => r > from).length;
|
||||||
const type: SegmentType = working === persons.length ? "ERWERB" : working > 0 ? "MIXED" : "PENSION";
|
const type: SegmentType = working === persons.length ? "ERWERB" : working > 0 ? "MIXED" : "PENSION";
|
||||||
segments.push({ type, fromYear: from, fixedYears: next === null ? null : next - from });
|
segments.push({
|
||||||
|
type,
|
||||||
|
fromYear: from,
|
||||||
|
fixedYears: next === null ? null : next - from,
|
||||||
|
endedBy: next === null ? [] : fixpoints.filter((f) => f.year === next),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return segments;
|
return segments;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Vorgeschlagene Dauer des offenen Pensions-Abschnitts: bis das älteste Haushaltsmitglied
|
// Vorgeschlagene Dauer des offenen Abschnitts. Mit erfasstem Planungshorizont ergibt sie sich
|
||||||
// ungefähr 90 ist, mindestens aber 5 Jahre.
|
// daraus; ohne ihn bis etwa Alter 90, mindestens aber 5 Jahre.
|
||||||
export function defaultOpenDuration(persons: { age: number }[], fromYear: number): number {
|
export function defaultOpenDuration(
|
||||||
|
persons: { age: number }[],
|
||||||
|
fromYear: number,
|
||||||
|
horizonYears?: number | null
|
||||||
|
): number {
|
||||||
|
if (typeof horizonYears === "number" && horizonYears > fromYear) return horizonYears - fromYear;
|
||||||
const oldestNow = persons.length > 0 ? Math.max(...persons.map((p) => p.age)) : 65;
|
const oldestNow = persons.length > 0 ? Math.max(...persons.map((p) => p.age)) : 65;
|
||||||
return Math.max(5, 90 - (oldestNow + fromYear));
|
return Math.max(5, 90 - (oldestNow + fromYear));
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-1
@@ -60,7 +60,6 @@ export function toPlanInput(plan: PlanWithRelations): PlanInput {
|
|||||||
name: hh?.name ?? null,
|
name: hh?.name ?? null,
|
||||||
age: hh?.age ?? 0,
|
age: hh?.age ?? 0,
|
||||||
retirementAge: p.retirementAge,
|
retirementAge: p.retirementAge,
|
||||||
planningHorizonAge: p.planningHorizonAge,
|
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
phases: plan.phases.map((phase) => ({
|
phases: plan.phases.map((phase) => ({
|
||||||
@@ -69,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) => {
|
||||||
@@ -85,6 +85,7 @@ export function toPlanInput(plan: PlanWithRelations): PlanInput {
|
|||||||
phaseValues,
|
phaseValues,
|
||||||
transitionValues,
|
transitionValues,
|
||||||
retirementDecision: parseRetirementDecision(e.retirementDecision),
|
retirementDecision: parseRetirementDecision(e.retirementDecision),
|
||||||
|
baseData: parsePhaseData(e.baseData),
|
||||||
sourceElementId: e.sourceElementId,
|
sourceElementId: e.sourceElementId,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -55,14 +55,7 @@ function plan(opts: {
|
|||||||
inflationRateDefault: 0,
|
inflationRateDefault: 0,
|
||||||
initialCash: 0,
|
initialCash: 0,
|
||||||
persons: [
|
persons: [
|
||||||
{
|
{ id: "A", role: "PERSON_A", name: null, age: opts.age, retirementAge: opts.retirementAge },
|
||||||
id: "A",
|
|
||||||
role: "PERSON_A",
|
|
||||||
name: null,
|
|
||||||
age: opts.age,
|
|
||||||
retirementAge: opts.retirementAge,
|
|
||||||
planningHorizonAge: opts.horizon ?? null,
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
phases: opts.phases.map((p, i) => ({
|
phases: opts.phases.map((p, i) => ({
|
||||||
id: p.id,
|
id: p.id,
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ import {
|
|||||||
DEFAULT_PK_CONVERSION_RATE,
|
DEFAULT_PK_CONVERSION_RATE,
|
||||||
PILLAR_3A_MAX_WITHDRAWAL_AGE,
|
PILLAR_3A_MAX_WITHDRAWAL_AGE,
|
||||||
PILLAR_3A_MIN_WITHDRAWAL_AGE,
|
PILLAR_3A_MIN_WITHDRAWAL_AGE,
|
||||||
|
PK_MAX_RETIREMENT_AGE,
|
||||||
|
PK_MIN_RETIREMENT_AGE,
|
||||||
} from "@/lib/constants";
|
} from "@/lib/constants";
|
||||||
import type { ElementCategory } from "@/lib/elements";
|
import type { ElementCategory } from "@/lib/elements";
|
||||||
|
|
||||||
@@ -58,6 +60,11 @@ export interface RetirementDecision {
|
|||||||
// Guthaben mit dem Pensionsalter aendert -- ein fixer Betrag wuerde still falsch.
|
// Guthaben mit dem Pensionsalter aendert -- ein fixer Betrag wuerde still falsch.
|
||||||
capitalSharePct?: number;
|
capitalSharePct?: number;
|
||||||
conversionRate?: number;
|
conversionRate?: number;
|
||||||
|
// Alter, in dem das PK-Guthaben bezogen bzw. verrentet wird. Bis 0.35 war das starr an das
|
||||||
|
// Erwerbsende gekoppelt -- wer mit 62 aufhoert, aber die PK erst mit 65 beziehen will,
|
||||||
|
// konnte das nicht abbilden. Reglementarisch fruehestens 58, Aufschub bis 70 nur bei
|
||||||
|
// fortgesetzter Erwerbstaetigkeit.
|
||||||
|
pkWithdrawalAge?: number;
|
||||||
// Reines Hinweis-Flag: Ein Kapitalbezug innerhalb von drei Jahren nach einem Einkauf laesst
|
// Reines Hinweis-Flag: Ein Kapitalbezug innerhalb von drei Jahren nach einem Einkauf laesst
|
||||||
// den Steuerabzug nachtraeglich entfallen (Art. 79b Abs. 3 BVG). Das Tool kennt keine
|
// den Steuerabzug nachtraeglich entfallen (Art. 79b Abs. 3 BVG). Das Tool kennt keine
|
||||||
// Einkaeufe und kann das nicht selbst pruefen -- deshalb die Frage statt einer Automatik.
|
// Einkaeufe und kann das nicht selbst pruefen -- deshalb die Frage statt einer Automatik.
|
||||||
@@ -72,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;
|
||||||
}
|
}
|
||||||
@@ -91,6 +102,7 @@ export const retirementDecisionSchema = z
|
|||||||
|
|
||||||
capitalSharePct: pct.optional(),
|
capitalSharePct: pct.optional(),
|
||||||
conversionRate: z.number().min(0).max(20).optional(),
|
conversionRate: z.number().min(0).max(20).optional(),
|
||||||
|
pkWithdrawalAge: z.number().int().min(PK_MIN_RETIREMENT_AGE).max(PK_MAX_RETIREMENT_AGE).optional(),
|
||||||
recentBuyIn: z.boolean().optional(),
|
recentBuyIn: z.boolean().optional(),
|
||||||
|
|
||||||
withdrawalAge: z
|
withdrawalAge: z
|
||||||
@@ -102,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(),
|
||||||
})
|
})
|
||||||
@@ -130,6 +143,8 @@ export function withRetirementDefaults(
|
|||||||
d.capitalSharePct ??= 0;
|
d.capitalSharePct ??= 0;
|
||||||
d.conversionRate ??= DEFAULT_PK_CONVERSION_RATE;
|
d.conversionRate ??= DEFAULT_PK_CONVERSION_RATE;
|
||||||
d.capitalTaxRate ??= DEFAULT_CAPITAL_TAX_RATE;
|
d.capitalTaxRate ??= DEFAULT_CAPITAL_TAX_RATE;
|
||||||
|
// Vorgabe: mit dem Erwerbsende, im gesetzlichen Fenster gehalten.
|
||||||
|
d.pkWithdrawalAge ??= clamp(retirementAge, PK_MIN_RETIREMENT_AGE, PK_MAX_RETIREMENT_AGE);
|
||||||
} else if (category === "PILLAR_3A") {
|
} else if (category === "PILLAR_3A") {
|
||||||
// Im Pensionierungsjahr, aber nie ausserhalb des gesetzlichen Fensters: Wer mit 58
|
// Im Pensionierungsjahr, aber nie ausserhalb des gesetzlichen Fensters: Wer mit 58
|
||||||
// aufhoert, kann die 3a trotzdem erst mit 60 beziehen.
|
// aufhoert, kann die 3a trotzdem erst mit 60 beziehen.
|
||||||
|
|||||||
+13
-42
@@ -49,54 +49,25 @@ export function sortedPhases(plan: PlanInput) {
|
|||||||
|
|
||||||
// --- Planungshorizont -----------------------------------------------------------------------
|
// --- Planungshorizont -----------------------------------------------------------------------
|
||||||
//
|
//
|
||||||
// Bis 0.33 ergab sich das Planende stillschweigend als Summe der Phasendauern. Zwei Szenarien
|
// Bis 0.35 ergab sich das Planende stillschweigend als Summe der Phasendauern. Zwei Szenarien
|
||||||
// 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. Neu ist der Horizont eine erfasste Zahl, und die LETZTE
|
// obwohl genau das ihr Zweck ist.
|
||||||
// Phase folgt ihr (dieselbe Richtung wie beim Pensionsalter: Zahl stellen, Struktur folgt).
|
|
||||||
//
|
//
|
||||||
// Referenzperson ist die aelteste: Sie erreicht ihren Horizont zuerst, und der Plan muss so
|
// Seit 0.38 wird der Horizont ABGELEITET: Er ist die Summe der Lebensphasen. Zwei Anlaeufe
|
||||||
// lange laufen, bis die LETZTE Person ihren erreicht hat.
|
// davor -- Endalter je Person (0.35), dann eine eigene Jahreszahl am Szenario (0.36) -- waren
|
||||||
|
// beide eine zweite Wahrheit ueber dieselbe Sache. Wer die Phasen einzeln plant, hat den
|
||||||
|
// 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 years = plan.persons
|
const total = plan.phases.reduce((n, p) => n + Math.max(0, p.durationYears), 0);
|
||||||
.filter((p) => typeof p.planningHorizonAge === "number")
|
return total > 0 ? total : null;
|
||||||
.map((p) => (p.planningHorizonAge as number) - p.age);
|
|
||||||
return years.length > 0 ? Math.max(...years) : null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HorizonChange {
|
// Alter, das eine Person am Ende der Planung erreicht. Read-only-Anzeige neben dem Horizont.
|
||||||
lastPhaseId: string;
|
export function endAgeOf(plan: PlanInput, role: PersonRole): number | null {
|
||||||
oldDuration: number;
|
const years = planEndYear(plan);
|
||||||
newDuration: number;
|
|
||||||
blocked: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Was muesste an der letzten Phase geschehen, damit der Plan bis zum Horizont laeuft?
|
|
||||||
export function planHorizonChange(plan: PlanInput, horizonAge: number, role: PersonRole): HorizonChange | null {
|
|
||||||
const phases = sortedPhases(plan);
|
|
||||||
const last = phases[phases.length - 1];
|
|
||||||
if (!last) return null;
|
|
||||||
const person = plan.persons.find((p) => p.role === role);
|
const person = plan.persons.find((p) => p.role === role);
|
||||||
if (!person) return null;
|
return years !== null && person ? person.age + years : null;
|
||||||
|
|
||||||
// Zielgesamtdauer aus SICHT DIESER Person; die uebrigen Horizonte bleiben unberuehrt und
|
|
||||||
// koennen laenger sein -- deshalb das Maximum ueber alle.
|
|
||||||
const wish = Math.max(
|
|
||||||
horizonAge - person.age,
|
|
||||||
...plan.persons.filter((p) => p.role !== role && typeof p.planningHorizonAge === "number")
|
|
||||||
.map((p) => (p.planningHorizonAge as number) - p.age)
|
|
||||||
);
|
|
||||||
const before = phases.slice(0, -1).reduce((s, p) => s + p.durationYears, 0);
|
|
||||||
const newDuration = wish - 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.`;
|
|
||||||
}
|
|
||||||
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.
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
+5
-3
@@ -15,9 +15,6 @@ export interface PersonInput {
|
|||||||
name: string | null;
|
name: string | null;
|
||||||
age: number;
|
age: number;
|
||||||
retirementAge: number;
|
retirementAge: number;
|
||||||
// Bis zu welchem Alter gerechnet wird. Fehlt der Wert, ergibt sich das Planende wie bisher
|
|
||||||
// aus der Summe der Phasendauern -- deshalb optional und nicht mit Default belegt.
|
|
||||||
planningHorizonAge?: number | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PhaseInput {
|
export interface PhaseInput {
|
||||||
@@ -27,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;
|
||||||
}
|
}
|
||||||
@@ -43,6 +42,9 @@ export interface ElementInput {
|
|||||||
// Pensionierungs-Entscheid (nur AHV, PENSION_FUND, PILLAR_3A). Bewusst OHNE Phasenbezug --
|
// Pensionierungs-Entscheid (nur AHV, PENSION_FUND, PILLAR_3A). Bewusst OHNE Phasenbezug --
|
||||||
// er gilt für die Pensionierung des Besitzers, wo immer die gerade liegt.
|
// er gilt für die Pensionierung des Besitzers, wo immer die gerade liegt.
|
||||||
retirementDecision?: RetirementDecision | null;
|
retirementDecision?: RetirementDecision | null;
|
||||||
|
// Stammdaten: Bestand bei Planbeginn und Ausgangs-Annahmen. Existieren, BEVOR es Phasen
|
||||||
|
// gibt, und sind zugleich die Wurzel der Feld-Vererbung (Phase 1 erbt von hier).
|
||||||
|
baseData?: PhaseData | null;
|
||||||
// Gegenstück im Eltern-Szenario (Diff-Grundlage); null im Basisszenario.
|
// Gegenstück im Eltern-Szenario (Diff-Grundlage); null im Basisszenario.
|
||||||
sourceElementId?: string | null;
|
sourceElementId?: string | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -181,14 +181,8 @@ export async function restoreVersion(
|
|||||||
for (const p of snap.persons) {
|
for (const p of snap.persons) {
|
||||||
await tx.person.upsert({
|
await tx.person.upsert({
|
||||||
where: { scenarioId_role: { scenarioId, role: p.role } },
|
where: { scenarioId_role: { scenarioId, role: p.role } },
|
||||||
create: {
|
create: { id: p.id, scenarioId, role: p.role, retirementAge: p.retirementAge },
|
||||||
id: p.id,
|
update: { retirementAge: p.retirementAge },
|
||||||
scenarioId,
|
|
||||||
role: p.role,
|
|
||||||
retirementAge: p.retirementAge,
|
|
||||||
planningHorizonAge: p.planningHorizonAge ?? null,
|
|
||||||
},
|
|
||||||
update: { retirementAge: p.retirementAge, planningHorizonAge: p.planningHorizonAge ?? null },
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (plan.deletePersonRoles.length > 0) {
|
if (plan.deletePersonRoles.length > 0) {
|
||||||
@@ -236,6 +230,9 @@ export async function restoreVersion(
|
|||||||
// Der Pensionierungs-Entscheid gehoert zum Inhalt des Szenarios -- ohne ihn wuerde
|
// Der Pensionierungs-Entscheid gehoert zum Inhalt des Szenarios -- ohne ihn wuerde
|
||||||
// eine Wiederherstellung die Bezugsentscheide still auf die Vorgaben zuruecksetzen.
|
// eine Wiederherstellung die Bezugsentscheide still auf die Vorgaben zuruecksetzen.
|
||||||
retirementDecision: (el.retirementDecision ?? undefined) as Prisma.InputJsonValue | undefined,
|
retirementDecision: (el.retirementDecision ?? undefined) as Prisma.InputJsonValue | undefined,
|
||||||
|
// Stammdaten gehoeren zum Inhalt -- ohne sie verloere eine Wiederherstellung alle
|
||||||
|
// Bestaende bei Planbeginn.
|
||||||
|
baseData: (el.baseData ?? undefined) as Prisma.InputJsonValue | undefined,
|
||||||
sourceElementId: el.sourceElementId ?? null,
|
sourceElementId: el.sourceElementId ?? null,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user