Ein Wert, ein Ort: Startwerte und Raten read-only, Verteil-Dialoge als einziger Eingabeort
Deploy App / deploy (push) Successful in 1m13s
Deploy App / deploy (push) Successful in 1m13s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+104
-21
@@ -4,10 +4,10 @@
|
|||||||
| | |
|
| | |
|
||||||
|---|---|
|
|---|---|
|
||||||
| **Dokument** | Funktionale und Technische Spezifikation FPT |
|
| **Dokument** | Funktionale und Technische Spezifikation FPT |
|
||||||
| **Version** | 0.39.1 |
|
| **Version** | 0.40 |
|
||||||
| **Datum** | 2026-07-25 |
|
| **Datum** | 2026-07-25 |
|
||||||
| **Status** | Lebendes Dokument |
|
| **Status** | Lebendes Dokument |
|
||||||
| **Codestand** | Arbeitsstand nach `f22b0a3` inkl. Übersicht der offenen Punkte (Branch `main`) |
|
| **Codestand** | Arbeitsstand nach `5ff9bc0` inkl. «ein Wert, ein Ort» (Branch `main`) |
|
||||||
| **Ersetzt** | `FDD_TDD_FPT.docx` (v1–v5) im Ordner `Info Dateien` – diese sind ab Version 0.1 dieses Dokuments obsolet |
|
| **Ersetzt** | `FDD_TDD_FPT.docx` (v1–v5) im Ordner `Info Dateien` – diese sind ab Version 0.1 dieses Dokuments obsolet |
|
||||||
| **Geltungsbereich** | Gesamter Code im Verzeichnis `FPT` |
|
| **Geltungsbereich** | Gesamter Code im Verzeichnis `FPT` |
|
||||||
|
|
||||||
@@ -17,6 +17,7 @@
|
|||||||
|
|
||||||
| Version | Datum | Autor | Änderung |
|
| Version | Datum | Autor | Änderung |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
|
| 0.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.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.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.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`. |
|
||||||
@@ -455,7 +456,7 @@ Seitenleiste.
|
|||||||
|
|
||||||
**Das Pensionsalter wird bewusst NICHT gefragt.** Es ist kein Stammdatum, sondern der erste
|
**Das Pensionsalter wird bewusst NICHT gefragt.** Es ist kein Stammdatum, sondern der erste
|
||||||
Entscheid der Pensionsplanung -- und es erzeugt eine Phasengrenze
|
Entscheid der Pensionsplanung -- und es erzeugt eine Phasengrenze
|
||||||
([3.14.4](#3144-fixpunkte-jeder-bezugsbeginn-erzwingt-eine-phasengrenze)). Im Basisszenario
|
([3.14.5](#3145-fixpunkte-jeder-bezugsbeginn-erzwingt-eine-phasengrenze)). Im Basisszenario
|
||||||
gilt durchgehend das Referenzalter 65.
|
gilt durchgehend das Referenzalter 65.
|
||||||
|
|
||||||
**Der frühere Plan-Assistent (`PlanWizard`) und der Beispielplan sind entfallen.** Der Wizard
|
**Der frühere Plan-Assistent (`PlanWizard`) und der Beispielplan sind entfallen.** Der Wizard
|
||||||
@@ -558,10 +559,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.
|
||||||
|
|
||||||
@@ -1171,6 +1178,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
|
||||||
@@ -1186,6 +1198,11 @@ 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 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
|
||||||
@@ -1196,9 +1213,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
|
||||||
@@ -1401,7 +1428,7 @@ 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 startet sie
|
zusammenläuft, und hat **zwei Auslöser**: Nach **jeder** Plan-Erstellung 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
|
||||||
@@ -2201,13 +2228,69 @@ ganze Überschuss still auf dem Cash-Konto liegen -- und die Übersicht meldete
|
|||||||
erledigt», während das Geld unverzinst herumliegt. Das Flag wird gesetzt, sobald der
|
erledigt», während das Geld unverzinst herumliegt. Das Flag wird gesetzt, sobald der
|
||||||
Verteil-Dialog einmal gespeichert hat.
|
Verteil-Dialog einmal gespeichert hat.
|
||||||
|
|
||||||
**Sammel-Bestätigung je Phase.** Bei sechs Elementen und fünf Phasen wären dreissig einzelne
|
**Das Abzeichen im Phasenkopf öffnet eine Maske mit allen offenen Annahmen** (seit 0.40,
|
||||||
Klicks nötig -- das erzieht zum Durchklicken, also genau zum Gegenteil dessen, was der
|
`PhaseReviewDialog`) -- das Gegenstück zum geführten Übergang ([3.5.4](#354-geführter-übergang-review-dialog)).
|
||||||
Mechanismus will. Der Phasenkopf trägt deshalb ein Abzeichen mit der Anzahl offener Annahmen;
|
Sie listet jede unbestätigte Zelle mit ihren Feldern, bearbeitbar, und schliesst mit «Alle
|
||||||
ein Klick bestätigt alle auf einmal, nach Rückfrage. Wer eine einzelne Zelle öffnet und
|
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. Der
|
||||||
|
Sammel-Abschluss bleibt trotzdem wichtig: Bei sechs Elementen und fünf Phasen wären dreissig
|
||||||
|
einzelne Klicks nötig, und das erzieht zum Durchklicken. Wer eine einzelne Zelle öffnet und
|
||||||
speichert, bestätigt sie dabei ohnehin.
|
speichert, bestätigt sie dabei ohnehin.
|
||||||
|
|
||||||
### 3.14.3 Element-Stammdaten: Bestand vor Zeitachse
|
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 legt 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.
|
||||||
|
|
||||||
|
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
|
Bis 0.35 lagen alle Elementwerte unter `phaseValues[phaseId]`. Ohne Phase gab es keinen
|
||||||
Schlüssel -- eine Bestandsaufnahme als erster Schritt war damit unmöglich.
|
Schlüssel -- eine Bestandsaufnahme als erster Schritt war damit unmöglich.
|
||||||
@@ -2243,7 +2326,7 @@ Orten, genau das Muster, das dieser Umbau beseitigt hat.
|
|||||||
> vollständig an den Phasen -- die Bestandsaufnahme verschwand nach dem Speichern im Nichts.
|
> vollständig an den Phasen -- die Bestandsaufnahme verschwand nach dem Speichern im Nichts.
|
||||||
> Behoben in 0.37.
|
> Behoben in 0.37.
|
||||||
|
|
||||||
### 3.14.4 Fixpunkte: jeder Bezugsbeginn erzwingt eine Phasengrenze
|
### 3.14.5 Fixpunkte: jeder Bezugsbeginn erzwingt eine Phasengrenze
|
||||||
|
|
||||||
`phaseplan.ts` kannte bis 0.35 genau einen Fixpunkt je Person -- das Erwerbsende. Seit AHV,
|
`phaseplan.ts` kannte bis 0.35 genau einen Fixpunkt je Person -- das Erwerbsende. Seit AHV,
|
||||||
Pensionskasse und jedes 3a-Konto ein eigenes Bezugsalter haben, sind es bis zu vier:
|
Pensionskasse und jedes 3a-Konto ein eigenes Bezugsalter haben, sind es bis zu vier:
|
||||||
@@ -2264,7 +2347,7 @@ dort mehrfach beschriftet.
|
|||||||
Die Folge ist ehrlich, aber spürbar: Ein Paar mit gestaffelten Bezügen kommt schnell auf acht
|
Die Folge ist ehrlich, aber spürbar: Ein Paar mit gestaffelten Bezügen kommt schnell auf acht
|
||||||
bis zehn Pflichtphasen.
|
bis zehn Pflichtphasen.
|
||||||
|
|
||||||
### 3.14.5 Der Bildschirm
|
### 3.14.6 Der Bildschirm
|
||||||
|
|
||||||
Zwei farblich getrennte Hälften, damit sichtbar ist: **oben stellst du ein, unten siehst du
|
Zwei farblich getrennte Hälften, damit sichtbar ist: **oben stellst du ein, unten siehst du
|
||||||
das Ergebnis.**
|
das Ergebnis.**
|
||||||
@@ -2294,7 +2377,7 @@ Unten die Matrix. Die frühere Aktionsleiste darüber ist verschwunden: **«+ El
|
|||||||
sitzen in der Ecke oben links der Matrix.** Sie steuern die Matrix und lagen vorher lose
|
sitzen in der Ecke oben links der Matrix.** Sie steuern die Matrix und lagen vorher lose
|
||||||
darüber wie Aktionen der ganzen Seite.
|
darüber wie Aktionen der ganzen Seite.
|
||||||
|
|
||||||
### 3.14.6 Die Tour
|
### 3.14.7 Die Tour
|
||||||
|
|
||||||
Ein grosses Popup mit einem **nachgebauten** Bildschirm und erfundenen Zahlen, in neun
|
Ein grosses Popup mit einem **nachgebauten** Bildschirm und erfundenen Zahlen, in neun
|
||||||
Schritten erklärt. Der letzte führt zur Bestandsaufnahme.
|
Schritten erklärt. Der letzte führt zur Bestandsaufnahme.
|
||||||
@@ -3776,7 +3859,7 @@ 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, Fixpunkte und Dauer-Kappung (Kap. 3.14.4). 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. |
|
| `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) |
|
||||||
@@ -4387,7 +4470,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** | **329** | |
|
| **Total** | **330** | |
|
||||||
|
|
||||||
## 8.2 Testfälle
|
## 8.2 Testfälle
|
||||||
|
|
||||||
|
|||||||
@@ -120,7 +120,11 @@ function AppShellInner({ username }: { username: string }) {
|
|||||||
const autoTourCheckedRef = useRef<Set<string>>(new Set());
|
const autoTourCheckedRef = useRef<Set<string>>(new Set());
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!detail || detail.plan.phases.length === 0) return;
|
// Bis 0.39 stand hier zusätzlich `plan.phases.length === 0`: Die alte Spotlight-Tour
|
||||||
|
// beleuchtete echte DOM-Ziele in der Matrix und hatte ohne Phasen nichts zu zeigen. Die
|
||||||
|
// Tour ist seit 0.36 eine Attrappe mit erfundenen Zahlen -- sie braucht nichts, und sie
|
||||||
|
// gehört genau an den Anfang, wo man noch gar nicht weiss, wie der Plan aufgebaut ist.
|
||||||
|
if (!detail) return;
|
||||||
const sid = detail.meta.id;
|
const sid = detail.meta.id;
|
||||||
if (pendingTourRef.current) {
|
if (pendingTourRef.current) {
|
||||||
pendingTourRef.current = false;
|
pendingTourRef.current = false;
|
||||||
|
|||||||
@@ -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,6 +503,7 @@ 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) {
|
||||||
@@ -535,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 (
|
||||||
@@ -547,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">
|
||||||
@@ -559,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,18 @@ 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;
|
||||||
|
// 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 {
|
||||||
@@ -118,6 +129,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 +540,48 @@ 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.
|
||||||
|
const bd = context.baseData;
|
||||||
|
const start = (key: "amount" | "currentValue" | "startValue" | "purchasePrice" | "mortgage", label: string, help?: string) => (
|
||||||
|
<LinkedField
|
||||||
|
label={label}
|
||||||
|
help={help}
|
||||||
|
value={Math.round(num(pd[key], num(bd[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 +589,8 @@ 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 =
|
||||||
|
typeof pd.amount === "number" ? pd.amount : carried ? context.derivedStart : num(bd.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 +601,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}
|
||||||
@@ -646,23 +754,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",
|
||||||
@@ -677,40 +781,27 @@ export function ElementPhaseFields({
|
|||||||
return <p className="col-span-2 text-sm text-muted">Die Säule 3a wird beim Pensions-Übergang vollständig bezogen.</p>;
|
return <p className="col-span-2 text-sm text-muted">Die Säule 3a wird beim Pensions-Übergang vollständig bezogen.</p>;
|
||||||
}
|
}
|
||||||
// «Grosse Säule 3a» für Selbstständige ohne PK: höhere Obergrenze (Roadmap-Feedback C7c).
|
// «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 +815,9 @@ 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 ? context.derivedMortgage : num(pd.mortgage, num(bd.mortgage));
|
||||||
const hypEnde = Math.max(0, hypStart - num(pd.amortization) * context.durationYears);
|
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 +834,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 +893,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 +926,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 +934,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 +952,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 +1079,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 +1120,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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -239,10 +239,15 @@ const KINDS: Kind[] = [
|
|||||||
export interface InventoryDraft {
|
export interface InventoryDraft {
|
||||||
base: Record<string, PhaseData>;
|
base: Record<string, PhaseData>;
|
||||||
names: Record<string, string>;
|
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 {
|
export function emptyInventoryDraft(): InventoryDraft {
|
||||||
return { base: {}, names: {} };
|
return { base: {}, names: {}, cash: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bestand eines Elements aus Stammdaten -- dieselbe Rechnung wie in der Kachel «Kennzahlen»,
|
// Bestand eines Elements aus Stammdaten -- dieselbe Rechnung wie in der Kachel «Kennzahlen»,
|
||||||
@@ -286,15 +291,27 @@ export function InventoryFields({
|
|||||||
plan.householdType === "COUPLE" ? ["HOUSEHOLD", "PERSON_A", "PERSON_B"] : ["PERSON_A"];
|
plan.householdType === "COUPLE" ? ["HOUSEHOLD", "PERSON_A", "PERSON_B"] : ["PERSON_A"];
|
||||||
const [page, setPage] = useState(0);
|
const [page, setPage] = useState(0);
|
||||||
const scope = scopes[page];
|
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.
|
// Live-Summe aus dem ENTWURF: Sie soll sich beim Tippen bewegen, nicht erst beim Speichern.
|
||||||
const today = plan.elements.reduce(
|
const today = plan.elements.reduce(
|
||||||
(sum, e) => sum + wealthOf(e.category, draft.base[e.id] ?? e.baseData ?? {}),
|
(sum, e) => sum + wealthOf(e.category, draft.base[e.id] ?? e.baseData ?? {}),
|
||||||
plan.initialCash || 0
|
cash
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<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 && (
|
{scopes.length > 1 && (
|
||||||
<div className="flex gap-1 rounded-lg border border-border bg-surface-2 p-1 text-xs">
|
<div className="flex gap-1 rounded-lg border border-border bg-surface-2 p-1 text-xs">
|
||||||
{scopes.map((sc, i) => (
|
{scopes.map((sc, i) => (
|
||||||
@@ -436,7 +453,7 @@ function BaseDataCard({
|
|||||||
const names = { ...d.names };
|
const names = { ...d.names };
|
||||||
delete base[el.id];
|
delete base[el.id];
|
||||||
delete names[el.id];
|
delete names[el.id];
|
||||||
return { base, names };
|
return { ...d, base, names };
|
||||||
});
|
});
|
||||||
onChanged();
|
onChanged();
|
||||||
}
|
}
|
||||||
@@ -481,6 +498,10 @@ export async function saveInventoryDraft(plan: PlanInput, draft: InventoryDraft)
|
|||||||
const base = draft.base[el.id];
|
const base = draft.base[el.id];
|
||||||
if (base) await api.put(`/api/elements/${el.id}/base`, base);
|
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 });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -508,7 +529,10 @@ export function InventoryDialog({
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const confirm = useConfirm();
|
const confirm = useConfirm();
|
||||||
|
|
||||||
const dirty = Object.keys(draft.base).length > 0 || Object.keys(draft.names).length > 0;
|
const dirty =
|
||||||
|
Object.keys(draft.base).length > 0 ||
|
||||||
|
Object.keys(draft.names).length > 0 ||
|
||||||
|
(draft.cash !== null && draft.cash !== plan.initialCash);
|
||||||
|
|
||||||
async function closeGuarded() {
|
async function closeGuarded() {
|
||||||
if (!dirty) return onClose();
|
if (!dirty) return onClose();
|
||||||
|
|||||||
+189
-81
@@ -3,6 +3,7 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import {
|
import {
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
|
AlertTriangle,
|
||||||
Building2,
|
Building2,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
@@ -205,30 +206,10 @@ export function PlanView({
|
|||||||
const cellOpen = (elementId: string, phaseId: string) =>
|
const cellOpen = (elementId: string, phaseId: string) =>
|
||||||
unconfirmedCells(plan, computed, phaseId).includes(elementId);
|
unconfirmedCells(plan, computed, phaseId).includes(elementId);
|
||||||
|
|
||||||
// Alle Annahmen einer Phase auf einmal bestaetigen. Bei sechs Elementen und fuenf Phasen
|
// Die Maske mit ALLEN offenen Annahmen einer Phase -- das Gegenstück zum Übergangs-Review.
|
||||||
// waeren es sonst dreissig einzelne Klicks.
|
// Bis 0.39 fragte das Abzeichen nur "alles bestätigen?", ohne zu zeigen, worum es geht:
|
||||||
async function confirmPhase(phaseId: string, phaseName: string) {
|
// eine Zustimmung zu etwas Ungesehenem, also genau das, was der Mechanismus verhindern soll.
|
||||||
const ids = unconfirmedCells(plan, computed, phaseId);
|
const [reviewPhaseId, setReviewPhaseId] = useState<string | null>(null);
|
||||||
if (ids.length === 0) return;
|
|
||||||
const ok = await confirmDialog({
|
|
||||||
title: "Annahmen bestätigen",
|
|
||||||
message:
|
|
||||||
`In «${phaseName}» sind ${ids.length} ${ids.length === 1 ? "Annahme" : "Annahmen"} noch nicht bestätigt. ` +
|
|
||||||
"Damit hältst du fest, dass du sie angeschaut hast – die Werte selbst bleiben unverändert und erben " +
|
|
||||||
"weiterhin aus der Vorphase.",
|
|
||||||
confirmLabel: "Alle bestätigen",
|
|
||||||
});
|
|
||||||
if (!ok) return;
|
|
||||||
for (const id of ids) {
|
|
||||||
const el = plan.elements.find((x) => x.id === id)!;
|
|
||||||
await api.put(`/api/elements/${id}/phase/${phaseId}`, {
|
|
||||||
...(el.phaseValues[phaseId] ?? {}),
|
|
||||||
confirmed: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
toast("success", "Annahmen bestätigt.");
|
|
||||||
onChanged();
|
|
||||||
}
|
|
||||||
// Was ist in diesem Plan noch offen? Abgeleitet, nicht gespeichert -- deshalb immer aktuell.
|
// Was ist in diesem Plan noch offen? Abgeleitet, nicht gespeichert -- deshalb immer aktuell.
|
||||||
const review = reviewPlan(plan, computed);
|
const review = reviewPlan(plan, computed);
|
||||||
// Die Tour (Start-Knopf, Auto-Start bei Plan-Erstellung, Rendering) liegt seit dem
|
// Die Tour (Start-Knopf, Auto-Start bei Plan-Erstellung, Rendering) liegt seit dem
|
||||||
@@ -321,6 +302,10 @@ export function PlanView({
|
|||||||
// Baut den Kontext für eine Phasenzelle.
|
// Baut den Kontext für eine Phasenzelle.
|
||||||
function buildPhaseContext(phase: PhaseComputed, element: ElementInput): CellContext {
|
function buildPhaseContext(phase: PhaseComputed, element: ElementInput): CellContext {
|
||||||
const ce = computedElement(phase.id, element.id);
|
const ce = computedElement(phase.id, element.id);
|
||||||
|
// Einmalige Kapitalverwendungen liegen technisch am Übergang DAVOR, gehören sichtbar aber
|
||||||
|
// in diese Phase: Sie zehren von dem Topf, der hier verteilt wird.
|
||||||
|
const prevPhase = computed.phases[computed.phases.findIndex((p) => p.id === phase.id) - 1];
|
||||||
|
const prevTd = prevPhase ? element.transitionValues[prevPhase.id] ?? {} : {};
|
||||||
const ownerWorking =
|
const ownerWorking =
|
||||||
element.ownerRole && element.ownerRole !== "HOUSEHOLD"
|
element.ownerRole && element.ownerRole !== "HOUSEHOLD"
|
||||||
? phase.persons.find((p) => p.role === element.ownerRole)?.working ?? false
|
? phase.persons.find((p) => p.role === element.ownerRole)?.working ?? false
|
||||||
@@ -347,6 +332,12 @@ export function PlanView({
|
|||||||
investTargets: [],
|
investTargets: [],
|
||||||
capitalFromTransfer: ce?.capitalFromTransfer ?? 0,
|
capitalFromTransfer: ce?.capitalFromTransfer ?? 0,
|
||||||
capitalFromTransferSources: ce?.capitalFromTransferSources ?? [],
|
capitalFromTransferSources: ce?.capitalFromTransferSources ?? [],
|
||||||
|
baseData: element.baseData ?? {},
|
||||||
|
oneOffAmortization: num(prevTd.extraAmortization),
|
||||||
|
oneOffRepayment: num(prevTd.immediateRepayment),
|
||||||
|
onEditBase: () => setPanel({ kind: "base", elementId: element.id }),
|
||||||
|
onDistributeRates: () => setDistribute({ kind: "rates", phaseId: phase.id }),
|
||||||
|
onDistributeCapital: prevPhase ? () => setDistribute({ kind: "capital", phaseId: phase.id }) : undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -375,6 +366,10 @@ export function PlanView({
|
|||||||
investTargets: investTargetsOf(plan, toPhase),
|
investTargets: investTargetsOf(plan, toPhase),
|
||||||
capitalFromTransfer: 0,
|
capitalFromTransfer: 0,
|
||||||
capitalFromTransferSources: [],
|
capitalFromTransferSources: [],
|
||||||
|
baseData: element.baseData ?? {},
|
||||||
|
// Einmalige Kapitalverwendungen gehören in die FOLGEphase, nicht an den Übergang selbst.
|
||||||
|
oneOffAmortization: 0,
|
||||||
|
oneOffRepayment: 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -414,7 +409,6 @@ export function PlanView({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const hasPhases = computed.phases.length > 0;
|
const hasPhases = computed.phases.length > 0;
|
||||||
const firstPhase = computed.phases[0] ?? null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-5">
|
<div className="flex flex-col gap-5">
|
||||||
@@ -624,7 +618,7 @@ export function PlanView({
|
|||||||
ratesOpen={
|
ratesOpen={
|
||||||
plan.phases.find((x) => x.id === col.phase.id)?.ratesConfirmed !== true
|
plan.phases.find((x) => x.id === col.phase.id)?.ratesConfirmed !== true
|
||||||
}
|
}
|
||||||
onConfirmAll={() => void confirmPhase(col.phase.id, col.phase.name)}
|
onConfirmAll={() => setReviewPhaseId(col.phase.id)}
|
||||||
active={panel?.kind === "phase" && panel.phaseId === col.phase.id}
|
active={panel?.kind === "phase" && panel.phaseId === col.phase.id}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
@@ -665,7 +659,7 @@ Erste Lebensphase anlegen
|
|||||||
diff?.cashInitialChanged ? "bg-diff-soft ring-1 ring-inset ring-diff/40" : ""
|
diff?.cashInitialChanged ? "bg-diff-soft ring-1 ring-inset ring-diff/40" : ""
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{formatChf(Math.round(plan.initialCash || 0))}
|
<ValueSingle value={Math.round(plan.initialCash || 0)} deflator={1} mode={valueMode} />
|
||||||
</td>
|
</td>
|
||||||
{!hasPhases && <td className="border-b border-border" />}
|
{!hasPhases && <td className="border-b border-border" />}
|
||||||
{columns.map((col) => {
|
{columns.map((col) => {
|
||||||
@@ -681,11 +675,15 @@ Erste Lebensphase anlegen
|
|||||||
isFirst && diff?.cashInitialChanged ? "bg-diff-soft ring-1 ring-inset ring-diff/40" : ""
|
isFirst && diff?.cashInitialChanged ? "bg-diff-soft ring-1 ring-inset ring-diff/40" : ""
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className="whitespace-nowrap">
|
{/* Dieselbe Darstellung wie bei allen anderen Zeilen: der Realwert
|
||||||
{valStr(col.phase.cashStart, col.phase.cumulativeInflationStart, valueMode)}{" "}
|
steht im Modus «Beide» darunter, nicht daneben. */}
|
||||||
<span className="text-faint">→</span>{" "}
|
<ValuePair
|
||||||
{valStr(col.phase.cashEnd, col.phase.cumulativeInflationEnd, valueMode)}
|
start={col.phase.cashStart}
|
||||||
</span>
|
end={col.phase.cashEnd}
|
||||||
|
deflatorStart={col.phase.cumulativeInflationStart}
|
||||||
|
deflatorEnd={col.phase.cumulativeInflationEnd}
|
||||||
|
mode={valueMode}
|
||||||
|
/>
|
||||||
</td>
|
</td>
|
||||||
) : (
|
) : (
|
||||||
(() => {
|
(() => {
|
||||||
@@ -851,25 +849,33 @@ Erste Lebensphase anlegen
|
|||||||
{columns.map((col) => {
|
{columns.map((col) => {
|
||||||
if (col.kind === "phase") {
|
if (col.kind === "phase") {
|
||||||
const ce = computedElement(col.phase.id, el.id);
|
const ce = computedElement(col.phase.id, el.id);
|
||||||
|
const open = cellOpen(el.id, col.phase.id);
|
||||||
return (
|
return (
|
||||||
<td
|
<td
|
||||||
key={col.phase.id}
|
key={col.phase.id}
|
||||||
onClick={() => setPanel({ kind: "cell", elementId: el.id, phaseId: col.phase.id })}
|
onClick={() => setPanel({ kind: "cell", elementId: el.id, phaseId: col.phase.id })}
|
||||||
title={
|
title={
|
||||||
cellOpen(el.id, col.phase.id)
|
open
|
||||||
? "Annahmen dieser Phase noch nicht bestätigt"
|
? "Annahmen dieser Phase noch nicht bestätigt"
|
||||||
: cellDiff(el.id, col.phase.id)
|
: cellDiff(el.id, col.phase.id)
|
||||||
? "Weicht von der Vorlage ab"
|
? "Weicht von der Vorlage ab"
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
className={`cursor-pointer border-b border-r border-border px-2 py-1.5 text-center text-xs ${
|
// Der dünne Ring aus 0.39 ging in einer vollen Matrix unter.
|
||||||
|
// Unbestätigte Zellen tragen deshalb jetzt dieselbe Sprache
|
||||||
|
// wie offene Übergänge: getönter Grund, kräftiger linker
|
||||||
|
// Balken, Warnzeichen.
|
||||||
|
className={`relative cursor-pointer border-b border-r border-border py-1.5 pl-3 pr-2 text-center text-xs ${
|
||||||
ce?.locked ? "text-faint" : "text-fg"
|
ce?.locked ? "text-faint" : "text-fg"
|
||||||
} ${
|
} ${
|
||||||
cellOpen(el.id, col.phase.id)
|
open
|
||||||
? "ring-1 ring-inset ring-attention/50"
|
? "border-l-[3px] border-l-attention bg-attention-soft ring-1 ring-inset ring-attention/40"
|
||||||
: ""
|
: ""
|
||||||
} ${cellDiff(el.id, col.phase.id)}`}
|
} ${cellDiff(el.id, col.phase.id)}`}
|
||||||
>
|
>
|
||||||
|
{open && (
|
||||||
|
<AlertTriangle className="absolute left-0.5 top-1 h-3 w-3 text-attention" />
|
||||||
|
)}
|
||||||
{phaseCellContent(ce, col.phase, valueMode, planEndOf(ce?.elementId, col.phase.id))}
|
{phaseCellContent(ce, col.phase, valueMode, planEndOf(ce?.elementId, col.phase.id))}
|
||||||
</td>
|
</td>
|
||||||
);
|
);
|
||||||
@@ -939,10 +945,11 @@ Erste Lebensphase anlegen
|
|||||||
übrig bleibt (gleiches Muster wie zuvor bei den Dialogen). */}
|
übrig bleibt (gleiches Muster wie zuvor bei den Dialogen). */}
|
||||||
{panel && renderPanel()}
|
{panel && renderPanel()}
|
||||||
|
|
||||||
{showAdd && firstPhase && (
|
{/* Braucht seit 0.40 keine Lebensphase mehr: Der Dialog erfasst Stammdaten, und die
|
||||||
|
hängen am Element. Ein Bestand lässt sich damit vor jeder Zeitplanung aufnehmen. */}
|
||||||
|
{showAdd && (
|
||||||
<AddElementDialog
|
<AddElementDialog
|
||||||
plan={plan}
|
plan={plan}
|
||||||
firstPhase={firstPhase}
|
|
||||||
initialCategory={addCategory}
|
initialCategory={addCategory}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
setShowAdd(false);
|
setShowAdd(false);
|
||||||
@@ -977,6 +984,31 @@ Erste Lebensphase anlegen
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{reviewPhaseId && (() => {
|
||||||
|
const phase = computed.phases.find((p) => p.id === reviewPhaseId);
|
||||||
|
if (!phase) return null;
|
||||||
|
const openIds = unconfirmedCells(plan, computed, phase.id);
|
||||||
|
const els = plan.elements.filter((e) => openIds.includes(e.id));
|
||||||
|
return (
|
||||||
|
<PhaseReviewDialog
|
||||||
|
phase={phase}
|
||||||
|
elements={els}
|
||||||
|
buildContext={(el) => buildPhaseContext(phase, el)}
|
||||||
|
ratesOpen={plan.phases.find((x) => x.id === phase.id)?.ratesConfirmed !== true}
|
||||||
|
onDistributeRates={() => {
|
||||||
|
setReviewPhaseId(null);
|
||||||
|
setDistribute({ kind: "rates", phaseId: phase.id });
|
||||||
|
}}
|
||||||
|
onClose={() => setReviewPhaseId(null)}
|
||||||
|
onSaved={() => {
|
||||||
|
setReviewPhaseId(null);
|
||||||
|
toast("success", "Annahmen bestätigt.");
|
||||||
|
onChanged();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
{reviewFromPhaseId && (() => {
|
{reviewFromPhaseId && (() => {
|
||||||
const fromPhase = computed.phases.find((p) => p.id === reviewFromPhaseId);
|
const fromPhase = computed.phases.find((p) => p.id === reviewFromPhaseId);
|
||||||
if (!fromPhase) return null;
|
if (!fromPhase) return null;
|
||||||
@@ -1520,8 +1552,10 @@ function PhaseHeader({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Verfügbares Kapital -- die Grösse, die man für die Planung DIESER Phase braucht.
|
{/* Verfügbares Kapital -- die Grösse, die man für die Planung DIESER Phase braucht.
|
||||||
Nur zeigen, wenn überhaupt etwas da ist. */}
|
Bis 0.39 nur sichtbar, solange ein Rest übrig war. Seit die Beträge im Element
|
||||||
{onDistributeCapital && pot.total > 0 && (
|
read-only sind, ist dieser Knopf der einzige Weg dorthin: Wer alles verteilt hätte,
|
||||||
|
käme an seine eigene Zuteilung nie mehr heran. Deshalb immer sichtbar. */}
|
||||||
|
{onDistributeCapital && (
|
||||||
<div className="rounded-md bg-surface-2 px-1.5 py-1">
|
<div className="rounded-md bg-surface-2 px-1.5 py-1">
|
||||||
<div className="flex items-baseline justify-between gap-2 whitespace-nowrap">
|
<div className="flex items-baseline justify-between gap-2 whitespace-nowrap">
|
||||||
<span className="font-medium text-fg">Verfügbares Kapital</span>
|
<span className="font-medium text-fg">Verfügbares Kapital</span>
|
||||||
@@ -1646,13 +1680,11 @@ function FragmentRows({ children }: { children: React.ReactNode }) {
|
|||||||
// --- Dialog: neues finanzielles Element mit Direkteingabe der Phase-1-Werte ---
|
// --- Dialog: neues finanzielles Element mit Direkteingabe der Phase-1-Werte ---
|
||||||
function AddElementDialog({
|
function AddElementDialog({
|
||||||
plan,
|
plan,
|
||||||
firstPhase,
|
|
||||||
initialCategory,
|
initialCategory,
|
||||||
onClose,
|
onClose,
|
||||||
onCreated,
|
onCreated,
|
||||||
}: {
|
}: {
|
||||||
plan: PlanInput;
|
plan: PlanInput;
|
||||||
firstPhase: PhaseComputed;
|
|
||||||
initialCategory?: ElementCategory | null;
|
initialCategory?: ElementCategory | null;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onCreated: () => void;
|
onCreated: () => void;
|
||||||
@@ -1689,36 +1721,6 @@ function AddElementDialog({
|
|||||||
{ value: "PERSON_A", label: pLabel("PERSON_A") },
|
{ value: "PERSON_A", label: pLabel("PERSON_A") },
|
||||||
];
|
];
|
||||||
|
|
||||||
// Kontext für die Phase-1-Felder des neuen Elements.
|
|
||||||
const owner = needsPerson || ownerRole !== "HOUSEHOLD" ? ownerRole : null;
|
|
||||||
const ownerWorking =
|
|
||||||
owner && owner !== "HOUSEHOLD"
|
|
||||||
? firstPhase.persons.find((p) => p.role === owner)?.working ?? false
|
|
||||||
: firstPhase.type !== "PENSION";
|
|
||||||
const context: CellContext = {
|
|
||||||
kind: "phase",
|
|
||||||
phaseId: firstPhase.id,
|
|
||||||
ownerWorking,
|
|
||||||
isConsumption: firstPhase.isConsumption,
|
|
||||||
durationYears: firstPhase.durationYears,
|
|
||||||
isRetirementTransition: false,
|
|
||||||
carriedEndValue: 0,
|
|
||||||
carried: false,
|
|
||||||
derivedStart: 0,
|
|
||||||
derivedMortgage: 0,
|
|
||||||
mortgageEnd: 0,
|
|
||||||
propertyValueEnd: 0,
|
|
||||||
deflatorStart: firstPhase.cumulativeInflationStart,
|
|
||||||
laterPhaseCount: 0, // beim Anlegen bewusst kein Warnhinweis
|
|
||||||
ahvCareer: null,
|
|
||||||
// Beim Anlegen gibt es keine Vorphase, aus der etwas zu erben waere.
|
|
||||||
inheritedValues: {},
|
|
||||||
ownerAgeStart: firstPhase.persons.find((p) => p.role === (owner ?? "PERSON_A"))?.startAge ?? 0,
|
|
||||||
investTargets: [],
|
|
||||||
capitalFromTransfer: 0,
|
|
||||||
capitalFromTransferSources: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
async function create() {
|
async function create() {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -1728,9 +1730,9 @@ function AddElementDialog({
|
|||||||
name: name.trim() || CATEGORY_LABELS[category],
|
name: name.trim() || CATEGORY_LABELS[category],
|
||||||
ownerRole,
|
ownerRole,
|
||||||
});
|
});
|
||||||
// Ist-Zustand direkt in Phase 1 speichern (sofern Felder ausgefuellt).
|
// Der Bestand gehört an das ELEMENT, nicht in Phase 1 (Kap. 3.14.3).
|
||||||
if (Object.keys(pd).length > 0) {
|
if (Object.keys(pd).length > 0) {
|
||||||
await api.put(`/api/elements/${element.id}/phase/${firstPhase.id}`, pd);
|
await api.put(`/api/elements/${element.id}/base`, pd);
|
||||||
}
|
}
|
||||||
onCreated();
|
onCreated();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -1788,16 +1790,19 @@ function AddElementDialog({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Erfasst wird der BESTAND von heute -- dieselben Felder wie in der Bestandsaufnahme,
|
||||||
|
und er landet auch am selben Ort (den Stammdaten). Bis 0.39 schrieb dieser Dialog in
|
||||||
|
die Phase 1 und legte damit einen zweiten Ort für denselben Betrag an. Die Annahmen
|
||||||
|
(Renditen, Teuerung, Zins) folgen in der Phasenzelle, wo sie hingehören. */}
|
||||||
<div className="mt-1 border-t border-border pt-3">
|
<div className="mt-1 border-t border-border pt-3">
|
||||||
<div className="mb-2 text-xs font-semibold uppercase tracking-wide text-faint">Werte (erste Lebensphase)</div>
|
<div className="mb-2 text-xs font-semibold uppercase tracking-wide text-faint">Bestand heute</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">
|
||||||
<ElementPhaseFields
|
<BaseFields category={category} values={pd} set={(patch) => setPd((prev) => ({ ...prev, ...patch }))} />
|
||||||
element={{ category }}
|
|
||||||
context={context}
|
|
||||||
pd={pd}
|
|
||||||
setP={(patch) => setPd((prev) => ({ ...prev, ...patch }))}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
<p className="mt-2 text-[11px] text-muted">
|
||||||
|
Renditen, Lohnentwicklung und Teuerung erfasst du danach in der Lebensphase – sie sind Annahmen und
|
||||||
|
gelten immer nur für einen Zeitraum.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && <p className="text-sm text-danger">{error}</p>}
|
{error && <p className="text-sm text-danger">{error}</p>}
|
||||||
@@ -2074,6 +2079,109 @@ function TransitionReviewDialog({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Dialog: alle offenen Annahmen einer Lebensphase --------------------------------------
|
||||||
|
//
|
||||||
|
// Das Gegenstück zum Übergangs-Review. Bis 0.39 fragte das Abzeichen im Phasenkopf nur
|
||||||
|
// "N Annahmen bestätigen?" -- eine Zustimmung zu etwas, das man gar nicht sah. Das ist genau
|
||||||
|
// die Bewegung, die der Bestätigungs-Mechanismus verhindern soll. Hier stehen sie alle,
|
||||||
|
// bearbeitbar, mit einem Abschluss am Ende.
|
||||||
|
function PhaseReviewDialog({
|
||||||
|
phase,
|
||||||
|
elements,
|
||||||
|
buildContext,
|
||||||
|
ratesOpen,
|
||||||
|
onDistributeRates,
|
||||||
|
onClose,
|
||||||
|
onSaved,
|
||||||
|
}: {
|
||||||
|
phase: PhaseComputed;
|
||||||
|
elements: ElementInput[];
|
||||||
|
buildContext: (el: ElementInput) => CellContext;
|
||||||
|
ratesOpen: boolean;
|
||||||
|
onDistributeRates: () => void;
|
||||||
|
onClose: () => void;
|
||||||
|
onSaved: () => void;
|
||||||
|
}) {
|
||||||
|
const [pds, setPds] = useState<Record<string, PhaseData>>(() =>
|
||||||
|
Object.fromEntries(elements.map((e) => [e.id, { ...(e.phaseValues[phase.id] ?? {}) }]))
|
||||||
|
);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
async function saveAll() {
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
for (const e of elements) {
|
||||||
|
// `confirmed` steht NEBEN den Werten: Der Haken hält fest, dass jemand hingeschaut
|
||||||
|
// hat. Er kopiert nichts -- was leer bleibt, erbt weiterhin aus der Vorphase.
|
||||||
|
await api.put(`/api/elements/${e.id}/phase/${phase.id}`, { ...(pds[e.id] ?? {}), confirmed: true });
|
||||||
|
}
|
||||||
|
onSaved();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Speichern fehlgeschlagen.");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DialogShell title={`Annahmen prüfen: ${phase.name}`} onClose={onClose} wide>
|
||||||
|
<p className="text-sm text-muted">
|
||||||
|
Diese Werte gelten für die {phase.durationYears} Jahre dieser Lebensphase. Sie sind aus der Vorphase
|
||||||
|
übernommen – geh sie durch und passe an, was hier anders ist. Mit «Alle bestätigen» hältst du fest,
|
||||||
|
dass du sie angeschaut hast; die Werte selbst bleiben unverändert und erben weiterhin.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Die Verteilung zählt eigens: Man kann jede Zelle angeschaut und die Quote trotzdem nie
|
||||||
|
verteilt haben -- dann bliebe der ganze Überschuss still auf dem Cash-Konto. */}
|
||||||
|
{ratesOpen && (
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-attention bg-attention-soft p-3">
|
||||||
|
<span className="flex items-start gap-2 text-xs text-attention-soft-fg">
|
||||||
|
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||||
|
<span>
|
||||||
|
Die {phase.isConsumption ? "Verzehrquote" : "Sparquote"} dieser Phase ist noch nicht verteilt.
|
||||||
|
Solange sie das nicht ist, bleibt der ganze Betrag unverzinst auf dem Cash-Konto liegen.
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<Button variant="ghost" onClick={onDistributeRates}>
|
||||||
|
Jetzt verteilen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{elements.length === 0 ? (
|
||||||
|
<p className="rounded-lg border border-dashed border-border bg-surface-2 p-3 text-sm text-muted">
|
||||||
|
In dieser Lebensphase gibt es keine offenen Annahmen mehr.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
elements.map((el) => (
|
||||||
|
<div key={el.id} className="rounded-xl border border-border bg-surface-2 p-3">
|
||||||
|
<div className="mb-2 flex items-center gap-2">
|
||||||
|
<span className="text-accent">{CATEGORY_ICON[el.category]}</span>
|
||||||
|
<span className="text-sm font-semibold text-fg">{el.name}</span>
|
||||||
|
<span className="text-xs text-faint">{CATEGORY_LABELS[el.category]}</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
|
<ElementPhaseFields
|
||||||
|
element={el}
|
||||||
|
context={buildContext(el)}
|
||||||
|
pd={pds[el.id] ?? {}}
|
||||||
|
setP={(patch) => setPds((prev) => ({ ...prev, [el.id]: { ...prev[el.id], ...patch } }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="text-sm text-danger">{error}</p>}
|
||||||
|
<DialogActions saving={saving} onConfirm={saveAll} onClose={onClose} confirmLabel="Alle bestätigen" />
|
||||||
|
</DialogShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// --- Panel: Cash-Übergang (einmalige Sonderein-/ausgaben) ---
|
// --- Panel: Cash-Übergang (einmalige Sonderein-/ausgaben) ---
|
||||||
function CashTransitionPanel({
|
function CashTransitionPanel({
|
||||||
fromPhase,
|
fromPhase,
|
||||||
|
|||||||
@@ -98,6 +98,19 @@ describe("Offene Punkte je Phase", () => {
|
|||||||
expect(r.groups).toHaveLength(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", () => {
|
it("verlangt keine Bestätigung für ein verkauftes Element", () => {
|
||||||
// Ein verkauftes Haus trägt in der Folgephase keine Annahmen mehr.
|
// Ein verkauftes Haus trägt in der Folgephase keine Annahmen mehr.
|
||||||
const p = plan({
|
const p = plan({
|
||||||
|
|||||||
+18
-3
@@ -40,8 +40,23 @@ export interface PlanReview {
|
|||||||
groups: ReviewGroup[];
|
groups: ReviewGroup[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Braucht dieses Element in dieser Phase eine Bestaetigung? Alles, was in der Phase AKTIV ist:
|
// Kategorien, die in einer Phasenzelle ueberhaupt eine Annahme tragen. Seit 0.40 entstehen
|
||||||
// Ein verkauftes Haus oder eine getilgte Schuld traegt keine Annahmen mehr.
|
// 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(
|
export function needsConfirmation(
|
||||||
computed: PlanComputed,
|
computed: PlanComputed,
|
||||||
phaseId: string,
|
phaseId: string,
|
||||||
@@ -49,7 +64,7 @@ export function needsConfirmation(
|
|||||||
): boolean {
|
): boolean {
|
||||||
const ph = computed.phases.find((p) => p.id === phaseId);
|
const ph = computed.phases.find((p) => p.id === phaseId);
|
||||||
const ce = ph?.elements.find((e) => e.elementId === elementId);
|
const ce = ph?.elements.find((e) => e.elementId === elementId);
|
||||||
return !!ce && ce.status === "ACTIVE";
|
return !!ce && ce.status === "ACTIVE" && CATEGORIES_WITH_ASSUMPTIONS.has(ce.category);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ist der Phasenwert dieses Elements bestaetigt?
|
// Ist der Phasenwert dieses Elements bestaetigt?
|
||||||
|
|||||||
Reference in New Issue
Block a user