Uebersicht der offenen Punkte statt Assistent, Bestaetigung je Phasenzelle
Deploy App / deploy (push) Successful in 1m55s

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-16 15:02:15 +02:00
parent 6ff144d7e1
commit f22b0a3f27
22 changed files with 710 additions and 1006 deletions
+102 -64
View File
@@ -4,10 +4,10 @@
| | |
|---|---|
| **Dokument** | Funktionale und Technische Spezifikation FPT |
| **Version** | 0.38 |
| **Version** | 0.39 |
| **Datum** | 2026-07-25 |
| **Status** | Lebendes Dokument |
| **Codestand** | Arbeitsstand nach `2f6b788` inkl. Assistent auf zwei Schritte (Branch `main`) |
| **Codestand** | Arbeitsstand nach `6ff144d` inkl. Übersicht der offenen Punkte (Branch `main`) |
| **Ersetzt** | `FDD_TDD_FPT.docx` (v1v5) im Ordner `Info Dateien` diese sind ab Version 0.1 dieses Dokuments obsolet |
| **Geltungsbereich** | Gesamter Code im Verzeichnis `FPT` |
@@ -17,6 +17,7 @@
| Version | Datum | Autor | Änderung |
|---|---|---|---|
| 0.39 | 2026-08-16 | Claude (Opus 5) | **Aus dem Assistenten wird eine Übersicht der offenen Punkte** (Kap. 3.14 neu geschrieben). Der Unterschied ist grundsätzlich: Ein Assistent ist ein **Ablauf** («tu dies, dann das») und trägt nur beim ersten Aufsetzen -- wer einen bestehenden Plan öffnete, bekam Schritte angeboten, die längst erledigt waren, und die Kachel wusste nichts davon. Die Übersicht ist ein **Zustand** («das ist noch offen»); sie wird aus dem Plan abgeleitet und trägt bei jedem Plan, in jeder Reihenfolge, auch beim zwanzigsten Szenario. (1) Der Assistent samt Schritten, gespeichertem Fortschritt (`Scenario.assistantProgress`) und Endpunkt entfällt. Oben rechts steht neu die Übersicht: je Lebensphase und je Übergang, was dort fehlt, jeweils mit Sprung dorthin. Ist nichts offen, meldet sie das ausdrücklich -- **grün und in Worten**, nicht als «0». (2) **Die Bestätigung gilt neu auch für Phasenwerte** (`PhaseData.confirmed`). Eine neue Lebensphase übernimmt die Werte der Vorphase, aber sie gelten als unbestätigt, bis jemand hingeschaut hat: vorbelegen ja, stillschweigend übernehmen nein. Betroffen sind die phasenspezifischen Annahmen -- Renditen, Lohnentwicklung, Teuerung, Hypothekarzins, Wertsteigerung. Damit gilt in der ganzen Matrix derselbe Mechanismus, den die Übergänge seit 0.35 haben. **Bestätigen heisst «ich habe hingeschaut», nicht «festnageln»**: Der Haken steht NEBEN den Werten und kopiert nichts -- die Feld-Vererbung ([3.12.4](#3124-punkt-a-aus-vorphase-übernehmen)) bleibt unberührt, sonst wäre jede bestätigte Phase eingefroren und der ganze Punkt-A-Mechanismus hinfällig. Ein Test sichert genau das. (3) **Die Spar-/Verzehrquote zählt eigens** (`Phase.ratesConfirmed`). Man kann jede Zelle angeschaut und die Verteilung trotzdem nie getroffen haben -- dann bliebe der ganze Überschuss still auf dem Cash-Konto liegen, und die Übersicht meldete «alles erledigt». (4) **Sammel-Bestätigung je Phase:** Bei sechs Elementen und fünf Phasen wären es dreissig Klicks; der Phasenkopf trägt deshalb ein Abzeichen mit der Anzahl offener Annahmen, das alle auf einmal bestätigt. Unbestätigte Zellen tragen dieselbe Attention-Markierung wie offene Übergänge. (5) **Die Bestandsaufnahme bleibt als eigener Dialog** (`InventoryDialog`, vormals `AssistantSteps`) -- als grosser Knopf im leeren Plan und dauerhaft unter den Schnellaktionen. Sie ist der einzige Sammel-Dialog, der geblieben ist, weil sie als einzige etwas leistet, das die Matrix nicht kann: sieben Kategorien in einem Zug erfassen, bevor man weiss, wie das Tool aufgebaut ist. (6) Zwei **Startzustände** statt einer Zahl: Ohne Elemente und ohne Lebensphasen gibt es naturgemäss nichts Offenes, obwohl der Plan leer ist -- eine «0» wäre dort eine Lüge. Die Kachel fordert stattdessen zum nächsten Handgriff auf. Neues Modul `review.ts`, neue Komponenten `ReviewTile` und `InventoryDialog`; entfallen sind `assistant.ts`, `Assistant` und `AssistantStepDialog`. **Keine Datenmigration** (Pläne wurden vorgängig gelöscht). 7 Tests ergänzt (322 → 329). |
| 0.38 | 2026-08-16 | Claude (Opus 5) | **Der Assistent führt bis zur ersten Lebensphase und nicht weiter.** Sieben Schritte waren ein Versprechen, das die letzten fünf nicht einlösten: Sie zeigten Stationen, statt zu führen. Neu sind es **zwei**, die tragen; wie es danach weitergeht, wird eigens entworfen. (1) **Trennung von Tatsache und Annahme.** Schritt 1 erfasst nur noch, was man nachschlagen kann Kontostand, Guthaben, Kaufpreis, Restschuld. Renditen, Lohnentwicklung, Teuerung, Hypothekarzins und Wertsteigerung wandern in Schritt 2, wo sie hingehören: **Annahmen gelten immer nur für einen Zeitraum.** Bis 0.37 standen beide zusammen in der Bestandsaufnahme, wodurch eine Annahme wie eine Tatsache aussah. Neu trägt jede Kategorie `baseFields` und `phaseFields`. (2) **Neuer Schritt 2 «Erste Lebensphase»**: Name → Dauer → jährliche Annahmen je Element → **Sparquote verteilen**. Erst danach ist der Schritt fertig; endete er nach den Annahmen, bliebe der ganze Überschuss stumm auf dem Cash-Konto liegen. Die Dauer ist an der Pensionierung **gekappt** und wird im Feld begrenzt, statt hinterher als Fehler zu erscheinen. Der **PK-Beitrag** steht hier er stammt aus dem Bruttolohn und lässt sich aus der Sparquote gar nicht verteilen, wäre also sonst durch alle Maschen gefallen. (3) **Pensionsalter im Basisszenario fest auf 65**, nicht änderbar. Vorbezug, Aufschub und gestaffelte Kapitalbezüge bleiben im Rechenkern vollständig erhalten (samt Tests), werden hier aber nicht angeboten sie gehören zu einer eigenen Szenario-Art (Roadmap Nr. 48). Der Knopf «Pensionsplanung» und der zugehörige Bildschirm entfallen; die Grundeinstellungen nennen das Pensionsalter je Person und weisen auf die spätere Szenario-Art hin. (4) **Der Planungshorizont wird abgeleitet** er ist die Summe der Lebensphasen. Dritter und letzter Anlauf: 0.35 führte ihn als Endalter je Person, 0.36 als Jahreszahl am Szenario, beide Male eine zweite Wahrheit über dieselbe Sache. Wer die Phasen einzeln plant, hat den Horizont bereits bestimmt. `Scenario.planningHorizonYears`, der Endpunkt `/horizon` und `planHorizonChange` entfallen. (5) Entfallen: die Schritte 3 bis 7 samt ihren Werkzeugen und `RetirementPanel`. |
| 0.37 | 2026-08-16 | Claude (Opus 5) | **Nachbesserungen zur Bestandsaufnahme** (aus einer Testrunde des Nutzers). (1) **Die Knöpfe tragen die Kategorienamen** (Einkommen · Ausgaben · Pensionskasse · Säule 3a · Sonstiges Vermögen · Immobilie · Sonstige Schulden), die Alltagssprache steht als Erklärzeile darunter. Vorher hatte der Assistent eigene Vokabeln erfunden am deutlichsten «Lebenshaltung», hinter der sich ein Feld «Ausgaben pro Jahr» verbarg. Wer hier eine andere Sprache lernt als die, die Matrix, Rechenwege und Bericht sprechen, sucht sie später vergeblich. Der Elementname darf konkret bleiben: Kategorie «Ausgaben», Zeile «Lebenshaltung». (2) **Kein Speichern-Knopf je Element mehr.** Der Entwurf liegt neu auf Ebene des ganzen Schritts statt in der einzelnen Karte nur so überlebt er den Wechsel des Reiters, denn dabei verschwinden die Karten des vorigen Bereichs samt ihrem Zustand. «Schritt abschliessen» schreibt alles in einem Zug. Weil damit alle Zahlen an einem Ort liegen, zeigt der Dialog neu die Summe **«Vermögen heute» live beim Tippen** die Rückmeldung, die sonst mit dem Speichern-Knopf verloren gegangen wäre. Wer den Dialog mit ungespeicherten Eingaben schliesst, wird gefragt. (3) **«Zurück/Weiter» entfällt** in Schritt 1 und 2: Die Reiter oben sind der Weg durch die Bereiche, zwei Navigationen für dieselbe Bewegung sind eine zu viel. (4) Der 3a-Hinweis zum Höchstbetrag ist gestrichen er beantwortete an dieser Stelle eine Frage, die niemand stellt. (5) **Neue Matrix-Spalte «Start»** und: **die Matrix erscheint, sobald es Elemente gibt** Lebensphasen sind dafür nicht mehr nötig. Das war ein Fehler in 0.36: Die Stammdaten wurden eigens dafür eingeführt, dass eine Bestandsaufnahme ohne Zeitachse möglich ist, und dann rendete die Matrix nichts, weil sie ganz an den Phasen hing. Die Spezifikation behauptete das Richtige, der Code hielt es nicht. Die Spalte löst zugleich ein zweites Problem: Sie ist der Ort, an dem ein Startwert **änderbar** ist. Ohne sie hätte man ihn in Phase 1 bearbeitet und dabei still einen Phasenwert geschrieben, der die Stammdaten überdeckt dieselbe Zahl an zwei Orten. (6) Der Text des leeren Zustands ist korrigiert: Seit dem Umbau kommen die Elemente zuerst und die Phasen danach. Neue gemeinsame Komponente `BaseFields` (Assistent und Matrix nutzen dieselben Felder), neues Panel für die Stammdaten. 2 Tests ergänzt (334 → 336). |
| 0.36 | 2026-07-26 | Claude (Opus 5) | **Der Assistent wird das Herzstück** (neues Kapitel 3.14). Ein Umbau von Onboarding, Bildschirmaufbau und Führung. (1) **Ein Weg hinein.** Die Übersicht zeigt im leeren Zustand nur noch «Meinen ersten Finanzplan anlegen»; der geführte Start und der Beispielplan entfallen. Drei Knöpfe waren eine Wahl, die niemand treffen kann, der das Tool noch nicht kennt. Der Plan-Dialog fragt nur noch sechs Dinge: Name, Haushaltsform, Personennamen, Startjahr, Alter, Inflation. **Das Pensionsalter wird nicht mehr abgefragt** es ist kein Stammdatum, sondern der erste Entscheid der Pensionsplanung, und es erzeugt eine Phasengrenze. Bis dahin gilt das Referenzalter. (2) **Element-Stammdaten** (`FinancialElement.baseData`): Bestand bei Planbeginn und Ausgangs-Annahmen hängen neu am ELEMENT statt in Phase 1. Zwei Gründe ein Startwert ist nicht «phase-1-spezifisch», sondern schlicht der Stand am Anfang; und Elemente lassen sich damit erfassen, **bevor es Lebensphasen gibt**. Genau das braucht die Bestandsaufnahme als erster Schritt. Zugleich sind die Stammdaten die **Wurzel der Feld-Vererbung**: Phase 1 hatte bisher nichts, von dem sie hätte erben können, und fiel auf 0. (3) **Aus einem Fixpunkt werden bis zu vier je Person.** `phaseplan.ts` kannte nur das Erwerbsende. Da AHV, Pensionskasse und jedes 3a-Konto eigene Bezugsalter haben (`pkWithdrawalAge` neu), erzwingt jeder Bezugsbeginn eine Phasengrenze sonst fiele er mitten in eine Phase und rutschte auf die nächste Grenze, unter Umständen Jahre später. Die Phasendauer-Kappung zählt sie mit; Ereignisse im selben Jahr teilen sich eine Grenze. (4) **Planungshorizont in JAHREN** am Szenario (`planningHorizonYears`) statt als Endalter je Person: eine Zahl statt zweier, die bei einem Paar auseinanderlaufen könnten; die Endalter sind die Ableitung. Ersetzt `Person.planningHorizonAge` aus 0.35. (5) **Neuer Szenario-Bildschirm.** Zwei farblich getrennte Hälften: oben die Steuerung in vier Kacheln (Grundeinstellungen mit «Pensionsplanung» je Person · Kennzahlen inkl. neuem **«Vermögen heute»** · Schnellaktionen · Assistent) plus die Zeitachse über die volle Breite; unten die Matrix. Die Aktionsleiste über der Matrix ist verschwunden: **«+ Element», «+ Phase», der Nominal/Real-Umschalter und «Alle auf-/zuklappen» sitzen jetzt in der Ecke oben links der Matrix** sie steuern die Matrix und lagen vorher lose darüber wie Aktionen der ganzen Seite. (6) **Der FPT-Assistent** ersetzt die Karte «Nächste Schritte». Sieben Schritte von der Bestandsaufnahme bis zum Feinschliff. Jeder öffnet ein Popup, das **zuerst erklärt** (welche Fragen der Schritt beantwortet, was man wissen sollte) und **danach das Werkzeug** anbietet; «Selbst erledigen» überspringt beides. Der Haken ist **manuell** das Tool masst sich nicht an zu wissen, wann jemand fertig ist , aber daneben steht der **abgeleitete Stand** («0 Lebensphasen»), damit ein abgehakter Schritt ohne Substanz auffällt. Erledigte rutschen nach unten und werden blass; die Kachel ist gelb, bis alle sieben stehen, dann grün. (7) **Neue Tour**: ein grosses Popup mit einem **nachgebauten** Bildschirm und erfundenen Zahlen, schrittweise erklärt. Das frühere Spotlight lag über der echten Ansicht und hatte auf einem frisch angelegten, leeren Plan nichts hervorzuheben, also gerade dann nicht, wenn es am nötigsten war. Der Preis ist, dass die Attrappe bei UI-Änderungen nachzuführen ist. (8) Entfallen: `PlanWizard` (der Assistent führt jetzt IM Plan statt davor) und `demoplan.ts`. Neue Module `assistant.ts`, neue Komponenten `Assistant`, `AssistantStepDialog`, `AssistantSteps`; neue Endpunkte `PUT /api/elements/<id>/base` und `POST /api/scenarios/<id>/assistant`. **Keine Datenmigration** (Pläne wurden vorgängig gelöscht). 12 Tests ergänzt (322 → 334). |
@@ -308,7 +309,7 @@ die ersten 72 Byte), keine ARIA-Labels auf der Login-Maske und der Befehls-Palet
Es gibt genau **einen** Weg: den Knopf «Meinen ersten Finanzplan anlegen» in der Übersicht
bzw. das «+» in der Seitenleiste. Beide öffnen denselben Dialog. Zur Begründung, warum die
frühere Auswahl aus drei Wegen entfallen ist, siehe
[3.2.8](#328-der-einstieg-ein-weg-eine-tour-ein-assistent).
[3.2.8](#328-der-einstieg-ein-weg-eine-tour-eine-bestandsaufnahme).
Der Dialog fragt Name plus Grundprofil -- **ohne Pensionsalter**, das gehört in die
Pensionsplanung:
@@ -429,7 +430,7 @@ und Grafiken beschriften damit Jahre statt nur Alter. Die Berechnung rechnet unv
Beim Anlegen wird das laufende Jahr vorbelegt; bestehende Pläne wurden per Migration darauf
gesetzt. Kalenderjahr eines Planjahrs: `startYear + (Jahr 1)`.
### 3.2.8 Der Einstieg: ein Weg, eine Tour, ein Assistent
### 3.2.8 Der Einstieg: ein Weg, eine Tour, eine Bestandsaufnahme
Bis 0.35 standen im leeren Zustand **drei** Knöpfe: geführt starten, Beispielplan ansehen, leer
starten. Das ist eine Wahl, die niemand treffen kann, der das Tool noch nicht kennt -- und sie
@@ -444,7 +445,7 @@ Seit 0.36 gibt es genau einen Weg:
Plan-Dialog (sechs Felder)
|
v
Basisszenario, leer -> Tour (Demo-Popup) -> FPT-Assistent
Basisszenario, leer -> Tour (Demo-Popup) -> Bestandsaufnahme
```
**Der Plan-Dialog** fragt nur noch: Name des Plans, Haushaltsform, Namen der Personen
@@ -453,14 +454,14 @@ Seitenleiste.
**Das Pensionsalter wird bewusst NICHT gefragt.** Es ist kein Stammdatum, sondern der erste
Entscheid der Pensionsplanung -- und es erzeugt eine Phasengrenze
([3.14.4](#3144-fixpunkte-jeder-bezugsbeginn-erzwingt-eine-phasengrenze)). Bis Schritt 2 des
Assistenten gilt das Referenzalter.
([3.14.4](#3144-fixpunkte-jeder-bezugsbeginn-erzwingt-eine-phasengrenze)). Im Basisszenario
gilt durchgehend das Referenzalter 65.
**Der frühere Plan-Assistent (`PlanWizard`) und der Beispielplan sind entfallen.** Der Wizard
führte VOR dem Plan durch ein Formular und liess einen danach mit der Matrix allein; der
Assistent führt jetzt IM Plan und bleibt dort, solange man ihn braucht
([3.14](#314-der-fpt-assistent)). Was der Beispielplan leistete -- einmal sehen, wie ein
gefüllter Plan aussieht --, übernimmt die Tour.
führte VOR dem Plan durch ein Formular und liess einen danach mit der Matrix allein. Was der
Beispielplan leistete -- einmal sehen, wie ein gefüllter Plan aussieht --, übernimmt die Tour;
was danach zu tun ist, sagt die Übersicht der offenen Punkte
([3.14](#314-bestandsaufnahme-und-offene-punkte)).
### 3.3.1 Phase anlegen
@@ -772,6 +773,12 @@ Der Übergangs-Spaltenkopf zeigt „N offen" (Akzentfarbe), „N Vorgaben" (gede
Handlungsdefizit ist) oder „geprüft" (grün, Häkchen). Offene Zellen sind hervorgehoben und
zeigen „?".
**Seit 0.39 gilt derselbe Mechanismus auch für Phasenzellen** (`PhaseData.confirmed`,
[3.14.2](#3142-bestätigen-heisst-ich-habe-hingeschaut)). Eine neue Phase übernimmt die Werte
der Vorphase; sie gelten als unbestätigt, bis jemand hingeschaut hat. Damit ist die Ampel
nicht mehr auf die Übergänge beschränkt, sondern deckt die ganze Matrix ab -- die Zählung
liegt in `review.ts`, die Anzeige teilt sich die Attention-Farbe mit den Übergängen.
**Auswirkung auf den PDF-Bericht:** Die Kennzahl «Offene Entscheide» weist beide Töpfe
zusammen aus. Berichte von vor 0.35 sind deshalb nicht direkt vergleichbar.
@@ -1362,7 +1369,7 @@ der Kontext, den Modals nehmen, bleibt erhalten. Genau ein Panel kann offen sein
`Panel`-Union ersetzt die früheren Einzel-Zustände); der `key` erzwingt beim Wechsel den
Neuaufbau des Formulars wie zuvor bei den Dialogen.
**Modals bleiben** für Erstell-Flows (Element, Phase, Plan, Assistent), den geführten
**Modals bleiben** für Erstell-Flows (Element, Phase, Plan, Bestandsaufnahme), den geführten
Übergang (mehrere Objekte auf einmal), die Analysen und die Detailansichten.
Der frühere Dialog «Plan-Einstellungen» heisst im Panel korrekt **«Szenario-Profil»** er
@@ -1392,8 +1399,8 @@ Schritte ohne vorhandenes Ziel werden übersprungen; der «Tour»-Knopf in der o
Funktions-Leiste startet sie jederzeit neu.
Sie liegt seit 0.28 in `AppShell` (nicht mehr in `PlanView`), weil dort die Plan-Erstellung
zusammenläuft, und hat **zwei Auslöser**: Nach **jeder** Plan-Erstellung (Assistent,
Beispielplan, leerer Plan) startet sie **einmal** unabhängig davon, ob sie schon beendet
zusammenläuft, und hat **zwei Auslöser**: Nach **jeder** Plan-Erstellung startet sie
**einmal** unabhängig davon, ob sie schon beendet
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
wurde (localStorage `fpt-tour-done`). Ein aus dem DOM gelesenes Ziel setzt voraus, dass die
@@ -1432,8 +1439,8 @@ Jedes **Szenario** trägt eine Version **A.B** und eine vollständige Änderungs
FPT hat **keinen Speichern-Knopf**: Jede Änderung schreibt sofort. Eine Version je
Schreibvorgang wäre deshalb ein Tastenprotokoll und keine Historie ein Durchlauf des
Plan-Assistenten macht rund **14** Schreibvorgänge, ein Klick im Verteil-Dialog einen **je
Zielelement**.
Bestandsaufnahme-Dialogs macht rund **14** Schreibvorgänge, ein Klick im Verteil-Dialog einen
**je Zielelement**.
Stattdessen werden alle Schreibvorgänge innerhalb eines **Zeitfensters von 10 Minuten** zu
**einer** Nebenversion zusammengefasst: Der erste legt sie an, alle weiteren aktualisieren
@@ -2108,68 +2115,96 @@ eigenes Feld.
Referenz: `src/lib/retirement-decision.ts`, `src/components/RetirementPanel.tsx`,
`src/components/RetirementFields.tsx`, `src/lib/retirement.ts` (`planHorizonChange`).
## 3.14 Der FPT-Assistent
## 3.14 Bestandsaufnahme und offene Punkte
### 3.14.1 Warum er die «Nächsten Schritte» ersetzt
### 3.14.1 Warum aus dem Assistenten eine Übersicht wurde
Die frühere Karte leitete AB, was zu tun wäre -- und liess einen damit allein. Sie konnte
sagen «4 Übergangs-Entscheide offen», aber nicht, was ein Übergangs-Entscheid überhaupt ist
oder in welcher Reihenfolge man vorgeht.
Ein Assistent ist ein **Ablauf**: tu dies, dann das, dann bist du fertig. Das trägt genau
einmal -- beim ersten Aufsetzen. Wer einen bestehenden Plan öffnete, bekam Schritte
angeboten, die längst erledigt waren, und ein Häkchen, das nichts wusste. Vier Anläufe
(sieben Schritte, dann zwei) haben dasselbe Grundproblem nur kleiner gemacht.
Der Assistent führt stattdessen. Jeder Schritt hat ein eigenes Werkzeug und eine Seite davor,
die erklärt, worum es geht:
Eine Übersicht ist ein **Zustand**: das ist noch offen. Sie wird bei jedem Rendern aus dem
Plan abgeleitet (`reviewPlan`), speichert nichts und kann deshalb nie veralten. Sie trägt bei
jedem Plan, in jeder Reihenfolge, auch beim zwanzigsten Szenario.
| # | Schritt | Was dabei entsteht |
|---|---|---|
| 1 | **Bestandsaufnahme** | alle Elemente mit ihrem heutigen Stand |
| 2 | **Erste Lebensphase** | Name, Dauer, die Annahmen dieser Jahre und die verteilte Sparquote |
Sie steht oben rechts, wo vorher der Assistent stand, und nennt **je Lebensphase und je
Übergang**, was dort fehlt -- jede Zeile ist ein Sprung an die Stelle:
**Warum nur zwei.** Bis 0.37 waren es sieben. Die ersten drei trugen, die letzten vier
zeigten Stationen, statt zu führen -- sie öffneten bestehende Dialoge und überliessen den
Rest dem Nutzer. Ein Schritt, der nicht führt, ist schlimmer als keiner: Er behauptet
Anleitung und liefert Verwaltung. Wie es nach der ersten Lebensphase weitergeht, wird eigens
entworfen statt aus dem Vorhandenen zusammengesetzt.
| Zustand | Was die Kachel zeigt |
|---|---|
| `NO_ELEMENTS` | «Noch nichts erfasst» + grosser Knopf **Bestandsaufnahme** |
| `NO_PHASES` | «Noch keine Zeitachse» + Knopf **Erste Lebensphase anlegen** |
| `OPEN` | die Liste der Gruppen mit Anzahl und Grund |
| `DONE` | grün: «Alles angeschaut» |
Die Reihenfolge der beiden ist nicht beliebig: Sie beginnt mit dem, was **feststeht** (was
habe ich?), und geht dann zu dem, was man **annimmt** (womit rechne ich?). Genau deshalb
steht die Bestandsaufnahme vor der Zeitachse -- und genau deshalb brauchte es die
Element-Stammdaten (3.14.3).
**Warum zwei Startzustände statt einer Zahl.** Ohne Elemente und ohne Phasen gibt es
naturgemäss nichts Offenes -- der Plan ist trotzdem leer. Eine «0 offene Punkte» wäre dort
eine Lüge, und ein grünes Häkchen die falscheste Auskunft überhaupt. Deshalb zählt die Kachel
in diesen beiden Fällen nicht, sondern fordert zum nächsten Handgriff auf.
### Tatsache und Annahme gehören getrennt
Der Schnitt zwischen den beiden Schritten ist kein Ablaufdetail, sondern der Kern:
Dieser Schnitt ist geblieben; er ist der Grund, warum die Bestandsaufnahme vor der Zeitachse
kommt:
| Schritt 1 -- Tatsachen (`baseData`) | Schritt 2 -- Annahmen (`phaseValues`) |
| Tatsachen (`baseData`) | Annahmen (`phaseValues`) |
|---|---|
| Nettolohn, Ausgaben pro Jahr | Lohnerhöhung, Teuerung |
| PK-Guthaben, 3a-Guthaben, Wert der Wertschriften | Verzinsung, erwartete Renditen, **PK-Beitrag** |
| Kaufpreis, Hypothek, Restschuld | Hypothekarzins, Wertsteigerung, Zins-in-Ausgaben |
Links steht, was man nachschlagen kann. Rechts steht, was man vermutet -- und eine Vermutung
gilt immer nur für einen Zeitraum. Bis 0.37 standen beide zusammen in der Bestandsaufnahme;
dadurch sah eine Annahme aus wie eine Tatsache, und man traf sie, bevor überhaupt feststand,
für welche Jahre sie gelten sollte.
gilt immer nur für einen Zeitraum. Bis 0.37 standen beide zusammen; dadurch sah eine Annahme
aus wie eine Tatsache, und man traf sie, bevor überhaupt feststand, für welche Jahre sie
gelten sollte.
Der **PK-Beitrag** steht bewusst bei den Annahmen und nicht in der Sparquoten-Verteilung: Er
stammt aus dem Bruttolohn und belastet das Cash-Konto nicht
([4.6.3](#463-pension_fund)) -- aus der Quote liesse er sich gar nicht verteilen.
### 3.14.2 Der Haken ist manuell -- der Stand daneben nicht
**Die Bestandsaufnahme bleibt als eigener Dialog** (`InventoryDialog`): als grosser Knopf im
leeren Plan und dauerhaft unter den Schnellaktionen. Sie ist der einzige Sammel-Dialog, der
geblieben ist, weil sie als einzige etwas leistet, das die Matrix nicht kann -- sieben
Kategorien in einem Zug erfassen, bevor man weiss, wie das Tool aufgebaut ist. Der Entwurf
liegt auf Ebene des ganzen Dialogs, nicht in der einzelnen Karte; nur so überlebt er den
Wechsel des Reiters. «Fertig» schreibt alles in einem Zug, und die Summe **«Vermögen heute»**
läuft beim Tippen mit.
Zwei Gestaltungsentscheide, die zusammengehören:
### 3.14.2 Bestätigen heisst «ich habe hingeschaut»
1. **Abgehakt wird von Hand.** Wann jemand mit einem Schritt fertig ist, ist eine Einschätzung
und keine Messgrösse. «Genug geplant» kann das Tool nicht wissen.
2. **Daneben steht der abgeleitete Stand** (`stepStatus`): «0 Lebensphasen», «2 Elemente»,
«Planungshorizont fehlt». Ein abgehakter Schritt ohne Substanz fällt damit auf, ohne dass
das Tool den Haken verweigert.
Eine neue Lebensphase übernimmt alle Werte der Vorphase. Das ist richtig -- ohne Vorbelegung
müsste man in jeder Phase alles neu eintippen. Es ist aber auch gefährlich: Eine Rendite von
5 %, die aus dem Erwerbsleben stillschweigend in die Pension weiterläuft, ist keine
Entscheidung, sondern ein Versehen.
Gesperrt wird nur das **Werkzeug**, nie die Selbstauskunft (`stepBlockedReason`): Die
Phasenplanung ohne Planungshorizont wäre gegenstandslos, die Übergangs-Schritte ohne Phasen
ebenso. Der Haken bleibt trotzdem jederzeit setzbar.
Deshalb gilt seit 0.39 in der ganzen Matrix derselbe Mechanismus, den die Übergänge seit 0.35
haben: **vorbelegen ja, stillschweigend übernehmen nein.** Jede Phasenzelle trägt ein
`confirmed`-Flag; solange es fehlt, ist die Zelle mit der Attention-Farbe markiert und zählt
in der Übersicht.
Erledigte Schritte rutschen nach unten und werden blass -- oben steht immer das Nächste. Die
Kachel ist **gelb**, solange etwas offen ist, und **grün**, wenn alle sieben stehen.
> **Der Haken steht NEBEN den Werten, nicht statt ihrer.** Bestätigen kopiert nichts und
> friert nichts ein -- eine bestätigte Phase erbt weiterhin live aus der Vorphase
> ([3.12.4](#3124-punkt-a-aus-vorphase-übernehmen)). Würde die Bestätigung die geerbten Werte
> materialisieren, wäre der ganze Punkt-A-Mechanismus hinfällig: Eine Änderung in Phase 1
> erreichte Phase 3 nicht mehr. Ein Test sichert genau das.
Was gilt überhaupt als offen? Nur Zellen, die in dieser Phase noch etwas beitragen
(`needsConfirmation` prüft `status === "ACTIVE"`). Ein verkauftes Haus trägt in der Folgephase
keine Annahmen mehr und verlangt auch keine Bestätigung.
**Die Spar-/Verzehrquote zählt eigens** (`Phase.ratesConfirmed`). Sie ist keine Eigenschaft
eines Elements, sondern der Phase, und sie lässt sich nicht aus den Zellen ableiten: Man kann
jede einzelne angeschaut und die Verteilung trotzdem nie getroffen haben. Dann bliebe der
ganze Überschuss still auf dem Cash-Konto liegen -- und die Übersicht meldete «alles
erledigt», während das Geld unverzinst herumliegt. Das Flag wird gesetzt, sobald der
Verteil-Dialog einmal gespeichert hat.
**Sammel-Bestätigung je Phase.** Bei sechs Elementen und fünf Phasen wären dreissig einzelne
Klicks nötig -- das erzieht zum Durchklicken, also genau zum Gegenteil dessen, was der
Mechanismus will. Der Phasenkopf trägt deshalb ein Abzeichen mit der Anzahl offener Annahmen;
ein Klick bestätigt alle auf einmal, nach Rückfrage. Wer eine einzelne Zelle öffnet und
speichert, bestätigt sie dabei ohnehin.
### 3.14.3 Element-Stammdaten: Bestand vor Zeitachse
@@ -2240,7 +2275,7 @@ Oben vier gleichrangige Kacheln plus die Zeitachse über die volle Breite:
| **Grundeinstellungen** | plan-weit (Personen, Startjahr, Inflation) und szenario-eigen (Horizont, Endjahr, Endalter, Pensionsalter). Stift zum Bearbeiten; je Person ein Knopf **«Pensionsplanung»** |
| **Kennzahlen** | **Vermögen heute** (Summe der Stammdaten -- die einzige Zahl, die schon vor jeder Zeitplanung etwas aussagt), Endvermögen nominal und real, Reichweite |
| **Schnellaktionen** | Neues Szenario · Tour · Änderungshistorie · Rechenwege · CSV-Export |
| **Assistent** | siehe 3.14.1 |
| **Offene Punkte** | siehe 3.14.1 |
Das **Pensionsalter ist im Basisszenario auf 65 festgelegt** und nicht änderbar. Vorbezug,
Aufschub, eigene Bezugsalter für Pensionskasse und Säule 3a sowie die daraus folgenden
@@ -2261,7 +2296,7 @@ darüber wie Aktionen der ganzen Seite.
### 3.14.6 Die Tour
Ein grosses Popup mit einem **nachgebauten** Bildschirm und erfundenen Zahlen, in neun
Schritten erklärt. Der letzte führt zum Assistenten.
Schritten erklärt. Der letzte führt zur Bestandsaufnahme.
Das frühere Spotlight legte sich über die echte Ansicht. Zwei Nachteile liessen sich nicht
beheben: Auf einem frisch angelegten, leeren Plan gab es kaum etwas hervorzuheben -- also
@@ -2272,9 +2307,8 @@ Der Preis ist bekannt und bewusst in Kauf genommen: **Die Attrappe muss bei UI-
nachgeführt werden.** Dafür funktioniert die Tour ab der ersten Sekunde und unabhängig davon,
was im Plan schon steht.
Referenz: `src/lib/assistant.ts`, `src/components/Assistant.tsx`,
`src/components/AssistantStepDialog.tsx`, `src/components/AssistantSteps.tsx`,
`src/components/Tour.tsx`.
Referenz: `src/lib/review.ts`, `src/components/ReviewTile.tsx`,
`src/components/InventoryDialog.tsx`, `src/components/Tour.tsx`.
---
@@ -3741,7 +3775,8 @@ PlanComputed ← an den Client geliefert
| `distribution.ts` | Kapitaltopf und Quoten-Zerlegung, Anwenden von Entwurfswerten für die Verteil-Werkzeuge (Kap. 3.6.9/3.6.10). Rein. |
| `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. |
| `phaseplan.ts` | Ableitung der Lebensabschnitte (Erwerb/Misch/Pension) aus den fixen Pensionierungszeitpunkten für den Assistenten (Kap. 3.2.8). Rein. |
| `phaseplan.ts` | Ableitung der Lebensabschnitte (Erwerb/Misch/Pension) aus den fixen Pensionierungszeitpunkten, Fixpunkte und Dauer-Kappung (Kap. 3.14.4). 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 |
| `diff.ts` | Abweichungs-Erkennung eines Szenarios gegen sein Eltern-Szenario (Kap. 3.2.6) |
| `queries.ts` | Prisma-Includes, `toPlanInput()`, Ownership-Abfragen |
@@ -3812,6 +3847,7 @@ PlanComputed ← an den Client geliefert
| `name` | String | |
| `durationYears` | Int | 180 |
| `cashTransition` | Json? | Cash-Entscheid beim Übergang **nach** dieser Phase (siehe 5.4.5) |
| `ratesConfirmed` | Boolean | Default `false` wurde die Spar-/Verzehrquote dieser Phase je verteilt? ([3.14.2](#3142-bestätigen-heisst-ich-habe-hingeschaut)) |
| `sourcePhaseId` | String? | Gegenstück in der Vorlage (**lose** Referenz, kein FK) Diff-Grundlage |
| `createdAt` / `updatedAt` | DateTime | |
| | | `@@unique([scenarioId, sequenceNumber])` |
@@ -3894,6 +3930,7 @@ Referenz: `prisma/schema.prisma` Zeilen 46, `src/lib/elements.ts` Zeilen 48
| `interestHandling` | REAL_ESTATE Doppelzählungs-Schalter | `INCLUDED` (Default) \| `ADD` |
| `valueGrowth` | REAL_ESTATE Wertsteigerung %/Jahr auf die Liegenschaft | 20 bis 20 |
| `annualRepayment` | OTHER_DEBT | ≥ 0 |
| `confirmed` | alle «ich habe hingeschaut» ([3.14.2](#3142-bestätigen-heisst-ich-habe-hingeschaut)) | Boolean, optional |
**Vererbbare Felder (Punkt A, Kap. 3.12.4):** `teuerungsausgleich`, `expectedReturn`,
`annualContribution`, `annualWithdrawal`, `amortization`, `valueGrowth`, `interestRate` und
@@ -3966,6 +4003,7 @@ sondern zu leeren Werten.
| `20260721090000_reports` | Tabelle `Report`: gewählte Parameter, eingefrorenes Modell und die PDF-Datei als `BYTEA` |
| `20260724120000_version_zero_start` | `Scenario.currentMajor` startet bei **0** statt 1 die Versionierung beginnt bei 0.1 (Kap. 3.8). Nur der Default; bestehende Zeilen bleiben |
| `20260720160000_saved_analyses` | Tabelle `SavedAnalysis` (Eingaben + Ergebnis als JSONB, denormalisierte Kerndaten) |
| `20260816150000_review_state` | `Phase.ratesConfirmed` (Default `false`); `Scenario.assistantProgress` entfällt mit dem Assistenten (Kap. 3.14) |
**Zur V6-Migration:** Sie benennt die bisherige `Plan`-Tabelle in `Scenario` um dadurch
bleiben alle IDs und damit sämtliche Kind-Fremdschlüssel gültig. Für jedes bisherige
@@ -4027,7 +4065,7 @@ wird der Plan neu geladen; die Berechnung kommt immer vom Server.
| `SpecView` | 65 | Rendert `SPEZIFIKATION.md` (via `/api/spec`) als lesbares Dokument, inkl. Sprungmarken aus den Rechenwegen |
| `InfoBubble` | 28 | Hilfe-Tooltip |
| `ui` | ~370 | UI-Primitiven: Button, Modal, InspectorShell, Confirm, Toast, Skeleton, EmptyState ([3.7.6](#376-sprache-und-ui-primitiven)/[3.7.7](#377-inspector-panel-statt-modals)) |
| `Assistant` · `AssistantStepDialog` · `AssistantSteps` | ~1200 | Der FPT-Assistent: Fortschrittskachel, Erklärseiten und die Werkzeuge der sieben Schritte ([3.14](#314-der-fpt-assistent)) |
| `ReviewTile` · `InventoryDialog` | ~900 | Übersicht der offenen Punkte und der Sammel-Dialog der Bestandsaufnahme ([3.14](#314-bestandsaufnahme-und-offene-punkte)) |
| `Tour` | ~140 | Interaktive Kurz-Tour über die Planansicht ([3.7.8](#378-tour-und-nächste-schritte)) |
| `CommandPalette` | ~130 | Befehls-Palette Ctrl/Cmd+K ([3.7.9](#379-befehls-palette-und-sparklines)) |
| `DistributionDialogs` | ~460 | Verteil-Werkzeuge für Kapital und Spar-/Verzehrquote ([3.6.10](#3610-verteil-werkzeuge)) |
@@ -4348,7 +4386,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) |
| `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 `"` |
| **Total** | **336** | |
| **Total** | **329** | |
## 8.2 Testfälle
@@ -4712,10 +4750,10 @@ seit 0.12 erklärt wird ([4.13.5](#4135-wirkungslose-treiber-werden-erklärt));
Vermögensbrücke erscheint stattdessen die Differenz `Verkaufspreis Verkehrswert` als eigener
Posten.
## 9.23 Assistent: Teilzustand bei Abbruch
## 9.23 Sammel-Dialoge: Teilzustand bei Abbruch
Der Plan-Assistent und der Beispielplan senden am Ende eine **Sequenz** bestehender API-Aufrufe
(Plan → Phase 1 → Elemente → Folgephasen). Bricht die Sequenz mittendrin ab (Netzfehler),
Die Bestandsaufnahme schreibt am Ende eine **Sequenz** bestehender API-Aufrufe
(Element anlegen → Stammdaten → Phasenwerte, je Element). Bricht die Sequenz mittendrin ab (Netzfehler),
existiert ein **Teil-Plan**. Der ist normal weiterbearbeitbar und der Fehlerhinweis sagt das
auch aber es gibt kein automatisches Rollback. Das wäre nur mit Backend-Unterstützung
(Transaktion über mehrere Requests oder Batch-Endpunkt) sauber lösbar und ist bewusst nicht
@@ -0,0 +1,18 @@
-- Der FPT-Assistent wird durch eine Uebersicht der OFFENEN PUNKTE ersetzt (SPEZIFIKATION 3.14).
--
-- Der Unterschied ist grundsaetzlich: Der Assistent war ein ABLAUF ("tu dies, dann das") und
-- funktionierte nur beim ersten Aufsetzen. Die Uebersicht ist ein ZUSTAND ("das ist noch
-- offen") und traegt bei jedem Plan, in jeder Reihenfolge, auch beim zwanzigsten Szenario.
--
-- (1) `Phase.ratesConfirmed` haelt fest, ob die Spar-/Verzehrquote dieser Phase einmal bewusst
-- verteilt wurde. Was man nicht verteilt, sammelt sich still als Cash an -- rechnerisch
-- richtig, aber selten die Absicht. Der Haken unterscheidet "bewusst so gelassen" von
-- "noch nie angeschaut".
--
-- (2) `Scenario.assistantProgress` entfaellt mit dem Assistenten. Der Fortschritt wird nicht
-- mehr erklaert, sondern ABGELEITET -- aus dem, was tatsaechlich noch offen ist.
--
-- Die Bestaetigung der Phasenwerte je Element liegt als `confirmed` im vorhandenen JSON von
-- `ElementPhaseValue` und braucht deshalb keine Spalte.
ALTER TABLE "Phase" ADD COLUMN "ratesConfirmed" BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE "Scenario" DROP COLUMN IF EXISTS "assistantProgress";
+5 -2
View File
@@ -223,8 +223,6 @@ model Scenario {
initialCash Float @default(0)
// Fortschritt des FPT-Assistenten (sieben Schritte, vom Benutzer abgehakt).
assistantProgress Json?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -284,6 +282,11 @@ model Phase {
// und nicht in ElementTransitionValue, weil Cash kein FinancialElement ist.
cashTransition Json?
// Wurde die Spar-/Verzehrquote dieser Phase einmal bewusst verteilt? Was man nicht verteilt,
// sammelt sich still als Cash an -- rechnerisch richtig, aber selten die Absicht. Der Haken
// unterscheidet "bewusst so gelassen" von "noch nie angeschaut".
ratesConfirmed Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
+3
View File
@@ -10,6 +10,8 @@ import { planDurationChange } from "@/lib/phaseplan";
const updatePhaseSchema = z.object({
name: z.string().min(1).max(120).optional(),
durationYears: z.number().int().min(1).max(80).optional(),
// Die Spar-/Verzehrquote dieser Phase wurde bewusst verteilt (SPEZIFIKATION 3.14).
ratesConfirmed: z.boolean().optional(),
});
export async function PUT(
@@ -58,6 +60,7 @@ export async function PUT(
data: {
name: parsed.data.name ?? undefined,
durationYears: duration ?? undefined,
ratesConfirmed: parsed.data.ratesConfirmed ?? undefined,
},
});
if (neighbour) {
@@ -1,34 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { prisma } from "@/lib/db";
import { getOwnedScenario } from "@/lib/queries";
import { getCurrentUserId } from "@/lib/session";
import { ASSISTANT_STEP_COUNT, normalizeProgress } from "@/lib/assistant";
// Fortschritt des FPT-Assistenten setzen (ein Haken je Schritt).
//
// Bewusst OHNE `touchScenario`: Das Abhaken ist eine Notiz des Benutzers über sich selbst,
// keine Änderung am Plan. Eine Version dafür anzulegen würde die Historie mit Einträgen
// fluten, die inhaltlich nichts unterscheiden.
const bodySchema = z.object({
step: z.number().int().min(0).max(ASSISTANT_STEP_COUNT - 1),
done: z.boolean(),
});
export async function POST(request: NextRequest, { params }: { params: Promise<{ scenarioId: string }> }) {
const userId = await getCurrentUserId();
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
const { scenarioId } = await params;
const scenario = await getOwnedScenario(scenarioId, userId);
if (!scenario) return NextResponse.json({ error: "Szenario nicht gefunden." }, { status: 404 });
const parsed = bodySchema.safeParse(await request.json().catch(() => ({})));
if (!parsed.success) return NextResponse.json({ error: "Ungültige Eingabe." }, { status: 400 });
const progress = normalizeProgress(scenario.assistantProgress);
progress[parsed.data.step] = parsed.data.done;
await prisma.scenario.update({ where: { id: scenarioId }, data: { assistantProgress: progress } });
return NextResponse.json({ ok: true, progress });
}
@@ -33,7 +33,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
// kopiert. Szenario-eigen sind nur Inflation, Cash-Anfangswert und Pensionsalter.
inflationRateDefault: source.inflationRateDefault,
initialCash: source.initialCash,
assistantProgress: source.assistantProgress ?? undefined,
persons: {
create: source.persons.map((p) => ({ role: p.role, retirementAge: p.retirementAge })),
},
-131
View File
@@ -1,131 +0,0 @@
"use client";
// Der FPT-Assistent -- das Herzstück der Anwendung.
//
// Er ersetzt die frühere Karte «Nächste Schritte». Der Unterschied ist nicht kosmetisch: Die
// alte Karte leitete AB, was zu tun wäre, und liess einen damit allein. Hier führt jeder
// Schritt sein eigenes Werkzeug mit sich -- und davor eine Seite, die erklärt, worum es
// überhaupt geht.
//
// Zwei Gestaltungsentscheide, die zusammengehören:
//
// 1. Der Haken ist MANUELL. Das Tool masst sich nicht an zu wissen, wann jemand mit einem
// Schritt fertig ist -- «genug geplant» ist eine Einschätzung, keine Messgrösse.
// 2. Daneben steht der ABGELEITETE Stand («0 Lebensphasen»). Ein abgehakter Schritt ohne
// Substanz fällt so auf, ohne dass das Tool den Haken verweigert.
//
// Erledigte Schritte rutschen nach unten und werden blass -- oben steht immer das, was als
// Nächstes ansteht.
import { useState } from "react";
import { Check, ChevronRight, Lock, Sparkles } from "lucide-react";
import { api } from "@/lib/api-client";
import { ASSISTANT_STEPS, stepBlockedReason, stepStatus, type AssistantProgress } from "@/lib/assistant";
import type { PlanInput } from "@/lib/types";
export function Assistant({
plan,
progress,
onOpenStep,
onChanged,
}: {
plan: PlanInput;
progress: AssistantProgress;
onOpenStep: (index: number) => void;
onChanged: () => void;
}) {
const [busy, setBusy] = useState<number | null>(null);
const done = progress.filter(Boolean).length;
const allDone = done === ASSISTANT_STEPS.length;
async function toggle(index: number, next: boolean) {
setBusy(index);
try {
await api.post(`/api/scenarios/${plan.id}/assistant`, { step: index, done: next });
onChanged();
} finally {
setBusy(null);
}
}
// Offene zuerst, erledigte darunter -- die Reihenfolge innerhalb der Gruppen bleibt.
const ordered = [...ASSISTANT_STEPS].sort((a, b) => {
const da = progress[a.index] ? 1 : 0;
const db = progress[b.index] ? 1 : 0;
return da - db || a.index - b.index;
});
return (
<div
data-tour="assistant"
className={`flex h-full flex-col rounded-xl border px-3 py-2.5 shadow-sm transition-colors ${
allDone ? "border-success bg-success-soft" : "border-attention bg-attention-soft"
}`}
>
<div className="mb-2 flex items-center gap-2">
<Sparkles className={`h-4 w-4 ${allDone ? "text-success" : "text-attention-fg"}`} />
<span className="text-xs font-semibold uppercase tracking-wide text-fg">FPT-Assistent</span>
<span className={`ml-auto text-[11px] font-semibold ${allDone ? "text-success" : "text-muted"}`}>
{done} / {ASSISTANT_STEPS.length}
</span>
</div>
<div className="flex min-h-0 flex-1 flex-col gap-1 overflow-auto">
{ordered.map((step) => {
const isDone = progress[step.index] === true;
const blocked = stepBlockedReason(plan, step.index);
const status = stepStatus(plan, step.index);
return (
<div
key={step.index}
className={`flex items-start gap-2 rounded-lg px-1.5 py-1 transition-colors ${
isDone ? "opacity-50" : "hover:bg-surface/60"
}`}
>
<button
type="button"
aria-label={isDone ? "Als offen markieren" : "Als erledigt markieren"}
disabled={busy === step.index}
onClick={() => toggle(step.index, !isDone)}
className={`mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded border transition-colors ${
isDone ? "border-success bg-success text-white" : "border-border bg-surface hover:border-accent"
}`}
>
{isDone && <Check className="h-3 w-3" />}
</button>
<button
type="button"
onClick={() => onOpenStep(step.index)}
className="min-w-0 flex-1 text-left"
>
<span
className={`flex items-center gap-1 text-xs font-semibold ${
isDone ? "text-muted line-through" : "text-fg"
}`}
>
<span className="truncate">
{step.index + 1}. {step.title}
</span>
{blocked && !isDone && <Lock className="h-3 w-3 shrink-0 text-faint" />}
<ChevronRight className="h-3 w-3 shrink-0 text-faint" />
</span>
{!isDone && (
<span className="mt-0.5 block truncate text-[11px] text-muted">
{status || step.short}
</span>
)}
</button>
</div>
);
})}
</div>
{allDone && (
<p className="mt-2 border-t border-success/30 pt-2 text-[11px] text-success">
Alle Schritte erledigt. Dein Plan steht verfeinere ihn jederzeit über die Matrix.
</p>
)}
</div>
);
}
-228
View File
@@ -1,228 +0,0 @@
"use client";
// Das Popup eines Assistenten-Schritts.
//
// Immer gleich aufgebaut: SEITE 1 erklärt, worum es geht -- welche Fragen man sich stellt,
// welche Möglichkeiten das Tool bietet, worauf es ankommt. Erst danach folgt das Werkzeug.
//
// Warum die Erklärseite nicht übersprungen wird: Finanzplanung scheitert selten an der
// Bedienung und fast immer daran, dass unklar ist, was der Schritt eigentlich bezweckt. Wer
// es schon weiss, klickt unten links auf «Selbst erledigen» und ist in zwei Sekunden draussen.
import { useState } from "react";
import { ArrowLeft, ArrowRight, Check, Lock } from "lucide-react";
import { Button, Modal, useConfirm } from "@/components/ui";
import { ASSISTANT_STEPS, stepBlockedReason } from "@/lib/assistant";
import type { PlanInput } from "@/lib/types";
export interface StepExplainer {
// Kurze Einordnung ganz oben.
lead: string;
// Die Fragen, die dieser Schritt beantwortet.
questions: string[];
// Was man wissen sollte, bevor man loslegt.
notes: { title: string; text: string }[];
}
export const STEP_EXPLAINERS: StepExplainer[] = [
{
lead:
"Bevor du planst, hältst du fest, was heute da ist. Alles Weitere baut darauf auf ohne Bestandsaufnahme rechnet das Tool ins Leere.",
questions: [
"Was verdienst du, was gibst du aus?",
"Welche Guthaben hast du Pensionskasse, Säule 3a, Wertschriften, Konto?",
"Besitzt du Wohneigentum? Wie hoch ist die Hypothek?",
"Hast du Schulden ausserhalb der Hypothek?",
],
notes: [
{
title: "Nur Tatsachen, noch keine Annahmen",
text: "Hier stehen Zahlen, die du nachschlagen kannst: Kontostand, Guthaben, Kaufpreis, Restschuld. Renditen, Lohnentwicklung und Sparraten kommen im nächsten Schritt sie sind Annahmen und gelten immer nur für einen Zeitraum.",
},
{
title: "Lieber grob als gar nicht",
text: "Ein geschätzter Wert ist besser als ein leeres Feld. Du kannst jede Zahl später überall im Tool korrigieren.",
},
{
title: "Getrennt nach Person",
text: "Pensionskasse und Säule 3a gehören immer einer Person. Wertschriften, Immobilien und Schulden können gemeinsam sein.",
},
],
},
{
lead:
"Eine Lebensphase ist ein Abschnitt, in dem die Verhältnisse ungefähr gleich bleiben: ähnliches Einkommen, ähnliche Ausgaben, dieselben Annahmen. Jetzt legst du die erste an.",
questions: [
"Wie lange bleiben deine Verhältnisse ungefähr so wie heute?",
"Womit rechnest du in dieser Zeit Renditen, Lohnentwicklung, Teuerung?",
"Wohin fliesst das Geld, das am Ende des Jahres übrig bleibt?",
],
notes: [
{
title: "Was eine Lebensphase zusammenhält",
text: "Nicht ein Kalenderabschnitt, sondern eine Konstellation: Solange du gleich viel verdienst, ähnlich viel ausgibst und dieselben Annahmen gelten, ist es eine Phase. Ändert sich etwas grundlegend ein Hauskauf, Teilzeit, die Pensionierung beginnt eine neue.",
},
{
title: "Annahmen gelten pro Phase",
text: "Die Rendite, die du hier einträgst, gilt nur für diese Jahre. In einer späteren Phase kannst du eine andere setzen etwa vorsichtiger, wenn du im Ruhestand anders anlegst.",
},
{
title: "Der Rest bleibt auf dem Konto",
text: "Am Schluss verteilst du deinen Sparbetrag. Was du nicht zuteilst, sammelt sich als Cash an rechnerisch richtig, aber meist nicht die Absicht.",
},
{
title: "Bis zur Pensionierung",
text: "Im Basisszenario wird mit Pensionsalter 65 gerechnet. Eine Lebensphase darf diesen Zeitpunkt nicht überspannen FPT begrenzt die Dauer deshalb automatisch.",
},
],
},
];
export function AssistantStepDialog({
plan,
stepIndex,
isDone,
onToggleDone,
onClose,
// Gibt es ungespeicherte Eingaben? Der Dialog fragt dann beim Schliessen nach, statt sie
// still zu verwerfen -- ohne Speichern-Knopf je Feld wäre das sonst die einzige Stelle,
// an der Arbeit verloren gehen kann.
dirty = false,
// Wird beim Abschliessen aufgerufen und schreibt, was das Werkzeug gesammelt hat.
onCommit,
children,
}: {
plan: PlanInput;
stepIndex: number;
isDone: boolean;
onToggleDone: (done: boolean) => void;
onClose: () => void;
dirty?: boolean;
onCommit?: () => Promise<void>;
// Das Werkzeug des Schritts. Fehlt es, ist der Schritt reine Information.
children?: React.ReactNode;
}) {
const step = ASSISTANT_STEPS[stepIndex];
const explainer = STEP_EXPLAINERS[stepIndex];
const blocked = stepBlockedReason(plan, stepIndex);
const [showTool, setShowTool] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const confirm = useConfirm();
const canGuide = step.guided && !blocked && !!children;
// Schliessen mit ungespeicherten Eingaben: nachfragen statt still verwerfen.
async function closeGuarded() {
if (!dirty) return onClose();
const ok = await confirm({
title: "Eingaben verwerfen?",
message:
"Du hast Angaben erfasst, die noch nicht gespeichert sind. Schliesst du jetzt, gehen sie verloren. " +
"Mit «Schritt abschliessen» werden sie übernommen.",
confirmLabel: "Verwerfen",
danger: true,
});
if (ok) onClose();
}
async function finish() {
setSaving(true);
setError(null);
try {
if (onCommit) await onCommit();
onToggleDone(true);
onClose();
} catch (e) {
setError(e instanceof Error ? e.message : "Speichern fehlgeschlagen.");
} finally {
setSaving(false);
}
}
return (
<Modal
title={`Schritt ${stepIndex + 1}: ${step.title}`}
subtitle={showTool ? undefined : step.lead}
onClose={closeGuarded}
xwide
>
{showTool ? (
<div className="flex flex-col gap-4">
{children}
{error && <p className="text-sm text-danger">{error}</p>}
<div className="flex items-center justify-between border-t border-border pt-3">
<Button variant="ghost" onClick={() => setShowTool(false)}>
<ArrowLeft className="h-4 w-4" /> Zurück zur Erklärung
</Button>
<Button disabled={saving} onClick={finish}>
<Check className="h-4 w-4" /> {saving ? "Wird gespeichert…" : "Schritt abschliessen"}
</Button>
</div>
</div>
) : (
<div className="flex flex-col gap-4">
<p className="text-sm text-fg">{explainer.lead}</p>
{explainer.questions.length > 0 && (
<div className="rounded-xl border border-border bg-surface-2 p-4">
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-faint">
Diese Fragen beantwortest du hier
</p>
<ul className="flex flex-col gap-1.5">
{explainer.questions.map((q) => (
<li key={q} className="flex items-start gap-2 text-sm text-fg">
<ArrowRight className="mt-0.5 h-3.5 w-3.5 shrink-0 text-accent" />
{q}
</li>
))}
</ul>
</div>
)}
<div className="grid gap-3 sm:grid-cols-2">
{explainer.notes.map((n) => (
<div key={n.title} className="rounded-xl border border-border p-3">
<p className="text-sm font-semibold text-fg">{n.title}</p>
<p className="mt-1 text-xs leading-relaxed text-muted">{n.text}</p>
</div>
))}
</div>
{blocked && (
<p className="flex items-start gap-2 rounded-lg border border-attention bg-attention-soft px-3 py-2 text-xs text-attention-soft-fg">
<Lock className="mt-0.5 h-3.5 w-3.5 shrink-0" />
{blocked}
</p>
)}
<div className="flex items-center justify-between border-t border-border pt-3">
<Button
variant="ghost"
onClick={() => {
onToggleDone(!isDone);
void closeGuarded();
}}
>
{isDone ? "Wieder als offen markieren" : "Selbst erledigen"}
</Button>
{canGuide ? (
<Button onClick={() => setShowTool(true)}>
Schritt für Schritt <ArrowRight className="h-4 w-4" />
</Button>
) : (
<Button
onClick={() => {
onToggleDone(true);
onClose();
}}
>
<Check className="h-4 w-4" /> Verstanden
</Button>
)}
</div>
</div>
)}
</Modal>
);
}
+3
View File
@@ -484,6 +484,9 @@ export function RateDistributionDialog({
for (const [elementId, data] of byElement) {
await api.put(`/api/elements/${elementId}/phase/${phaseId}`, data);
}
// Die Verteilung zählt eigens als offener Punkt: Man kann jede Zelle angeschaut und die
// Quote trotzdem nie verteilt haben -- dann bliebe alles still auf dem Cash-Konto.
await api.put(`/api/phases/${phaseId}`, { ratesConfirmed: true });
onSaved();
} catch (e) {
setError(e instanceof Error ? e.message : "Speichern fehlgeschlagen.");
+4 -1
View File
@@ -1068,7 +1068,10 @@ export function ElementDetail({
if (isTransition) {
await api.put(`/api/elements/${element.id}/transition/${context.phaseId}`, td);
} else {
const payload: PhaseData = { ...pd };
// Speichern heisst zugleich BESTAETIGEN: Wer die Zelle geoeffnet und gespeichert hat,
// hat hingeschaut. Der Haken steht neben den Werten und friert nichts ein -- leere
// Felder erben weiterhin live aus der Vorphase (SPEZIFIKATION 3.12.4).
const payload: PhaseData = { ...pd, confirmed: true };
// Einkommen/Ausgaben ab Phase 2: entspricht der Basiswert dem fortgeschriebenen Wert
// der Vorphase, KEINEN Override speichern -> Wert bleibt live vererbt (Änderungen in
// früheren Phasen wirken sich weiter aus). Nur ein bewusst abweichender Wert wird fix.
@@ -1,30 +1,25 @@
"use client";
// Die Werkzeuge der Assistenten-Schritte.
// Die Bestandsaufnahme: alles erfassen, was heute da ist.
//
// Alle arbeiten ausschliesslich über die bestehenden Endpunkte -- der Assistent ist
// Orchestrierung, kein zweiter Datenpfad. Was er schreibt, hätte man auch von Hand über die
// Matrix schreiben können; er nimmt einem nur das Suchen ab.
// Der einzige Sammel-Dialog, der geblieben ist. Er arbeitet ausschliesslich über die
// bestehenden Endpunkte -- was er schreibt, hätte man auch einzeln über die Matrix erfassen
// können; er nimmt einem nur das Suchen ab.
//
// Erfasst werden NUR Tatsachen: Kontostand, Guthaben, Kaufpreis, Restschuld. Renditen,
// Lohnentwicklung und Teuerung stehen bewusst nicht hier -- sie sind Annahmen, und eine
// Annahme gilt immer nur für einen Zeitraum. Sie werden in den Phasenzellen der Matrix
// gesetzt und dort bestätigt (SPEZIFIKATION 3.14).
import { useState } from "react";
import { Plus, Trash2 } from "lucide-react";
import { Check, Plus, Trash2 } from "lucide-react";
import { api } from "@/lib/api-client";
import { Button } from "@/components/ui";
import { Button, Modal, useConfirm } from "@/components/ui";
import { MoneyField, NumberField, SelectField, TextField } from "@/components/FormField";
import { RateDistributionDialog } from "@/components/DistributionDialogs";
import { formatChf } from "@/lib/format";
import { CATEGORY_LABELS, ownerLabel, num, type PhaseData } from "@/lib/elements";
import { maxPhaseDuration, type PlanComputed } from "@/lib/calculations";
import type { PlanInput } from "@/lib/types";
// ============================================================================================
// Schritt 1: Bestandsaufnahme
// ============================================================================================
//
// Legt Elemente an und schreibt ihre STAMMDATEN -- ohne dass Lebensphasen existieren müssen.
// Das ist der Grund, warum es `baseData` gibt: Eine Bestandsaufnahme ist keine Aussage über
// eine Phase, sondern über den Stand von heute.
type Scope = "HOUSEHOLD" | "PERSON_A" | "PERSON_B";
interface Kind {
@@ -241,12 +236,12 @@ const KINDS: Kind[] = [
// Reiters verschwinden die Karten des vorigen Bereichs vom Bildschirm -- mit ihrem eigenen
// Zustand waeren alle getippten Zahlen still weg. Und die Summe "Vermoegen heute" laesst sich
// nur berechnen, wenn alle Entwuerfe an EINEM Ort liegen.
export interface Step1Draft {
export interface InventoryDraft {
base: Record<string, PhaseData>;
names: Record<string, string>;
}
export function emptyStep1Draft(): Step1Draft {
export function emptyInventoryDraft(): InventoryDraft {
return { base: {}, names: {} };
}
@@ -276,15 +271,15 @@ export function BaseFields({
return <>{kind?.baseFields(values, set)}</>;
}
export function Step1Elements({
export function InventoryFields({
plan,
draft,
setDraft,
onChanged,
}: {
plan: PlanInput;
draft: Step1Draft;
setDraft: (fn: (d: Step1Draft) => Step1Draft) => void;
draft: InventoryDraft;
setDraft: (fn: (d: InventoryDraft) => InventoryDraft) => void;
onChanged: () => void;
}) {
const scopes: Scope[] =
@@ -338,8 +333,8 @@ function ScopeElements({
}: {
plan: PlanInput;
scope: Scope;
draft: Step1Draft;
setDraft: (fn: (d: Step1Draft) => Step1Draft) => void;
draft: InventoryDraft;
setDraft: (fn: (d: InventoryDraft) => InventoryDraft) => void;
onChanged: () => void;
}) {
const [busy, setBusy] = useState<string | null>(null);
@@ -422,8 +417,8 @@ function BaseDataCard({
}: {
plan: PlanInput;
elementId: string;
draft: Step1Draft;
setDraft: (fn: (d: Step1Draft) => Step1Draft) => void;
draft: InventoryDraft;
setDraft: (fn: (d: InventoryDraft) => InventoryDraft) => void;
onChanged: () => void;
}) {
const el = plan.elements.find((x) => x.id === elementId)!;
@@ -477,7 +472,7 @@ function BaseDataCard({
// Schreibt den gesammelten Entwurf. Wird vom Schritt-Dialog beim Abschliessen aufgerufen --
// es gibt bewusst keinen Speichern-Knopf je Karte mehr.
export async function saveStep1Draft(plan: PlanInput, draft: Step1Draft): Promise<void> {
export async function saveInventoryDraft(plan: PlanInput, draft: InventoryDraft): Promise<void> {
for (const el of plan.elements) {
const nextName = draft.names[el.id]?.trim();
if (nextName && nextName !== el.name) {
@@ -489,195 +484,85 @@ export async function saveStep1Draft(plan: PlanInput, draft: Step1Draft): Promis
}
// ============================================================================================
// Schritt 2: die erste Lebensphase
// ============================================================================================
//
// Drei Seiten, die zusammen EINE Frage beantworten: "Wie sehen die naechsten Jahre aus?"
//
// 1. Name und Dauer -> die Phase entsteht
// 2. Jaehrliche Annahmen je Element -> was in dieser Zeit gilt
// 3. Sparquote verteilen -> wohin das Geld fliesst, das uebrig bleibt
//
// Erst nach Seite 3 ist der Schritt fertig. Waere er nach Seite 2 zu Ende, staende eine Phase
// da, in der der ganze Ueberschuss stumm auf dem Cash-Konto liegen bliebe -- rechnerisch
// richtig, aber nie die Absicht.
export interface Step2Draft {
name: string;
years: number;
// Jaehrliche Annahmen je Element, gesammelt bis zum Anlegen der Phase.
phase: Record<string, PhaseData>;
// Ist die Phase bereits angelegt? Danach wird nur noch die Sparquote verteilt.
createdPhaseId: string | null;
}
export function emptyStep2Draft(): Step2Draft {
return { name: "Erwerbsjahre", years: 10, phase: {}, createdPhaseId: null };
}
export function Step2FirstPhase({
// --- Der Dialog drumherum -------------------------------------------------------------------
//
// Gespeichert wird EINMAL, beim Abschliessen. Ein Speichern-Knopf je Element hätte bedeutet,
// dass ein Reiter-Wechsel die getippten Zahlen verwirft -- deshalb liegt der Entwurf hier
// oben und nicht in den einzelnen Karten.
export function InventoryDialog({
plan,
computed,
draft,
setDraft,
onChanged,
onClose,
onSaved,
}: {
plan: PlanInput;
computed: PlanComputed;
draft: Step2Draft;
setDraft: (fn: (d: Step2Draft) => Step2Draft) => void;
onChanged: () => void;
onClose: () => void;
onSaved: () => void;
}) {
const [busy, setBusy] = useState(false);
const [draft, setDraft] = useState<InventoryDraft>(emptyInventoryDraft);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [showRates, setShowRates] = useState(false);
const confirm = useConfirm();
// Die Phase darf nicht ueber eine Pensionierung hinausreichen -- die Rechnung leitet den
// Erwerbsstatus am PHASENBEGINN ab. Statt das hinterher als Fehler zu melden, steht die
// Grenze hier und begrenzt das Feld.
const cap = maxPhaseDuration(
plan.persons.map((x) => ({ role: x.role, age: x.age, retirementAge: x.retirementAge })),
0
);
const maxYears = cap ?? 60;
const years = Math.min(Math.max(1, Math.round(draft.years)), maxYears);
const dirty = Object.keys(draft.base).length > 0 || Object.keys(draft.names).length > 0;
const existing = plan.phases.length > 0 ? plan.phases[0] : null;
const phaseId = draft.createdPhaseId ?? existing?.id ?? null;
async function closeGuarded() {
if (!dirty) return onClose();
const ok = await confirm({
title: "Eingaben verwerfen?",
message:
"Du hast Angaben erfasst, die noch nicht gespeichert sind. Schliesst du jetzt, gehen sie verloren. " +
"Mit «Bestandsaufnahme abschliessen» werden sie übernommen.",
confirmLabel: "Verwerfen",
danger: true,
});
if (ok) onClose();
}
async function createPhase() {
setBusy(true);
async function save() {
setSaving(true);
setError(null);
try {
const { phase } = await api.post<{ phase: { id: string } }>(`/api/scenarios/${plan.id}/phases`, {
name: draft.name.trim() || "Erste Lebensphase",
durationYears: years,
});
// Die jaehrlichen Annahmen gehoeren zu DIESER Phase -- deshalb erst jetzt schreibbar.
for (const [elementId, values] of Object.entries(draft.phase)) {
if (Object.keys(values).length > 0) {
await api.put(`/api/elements/${elementId}/phase/${phase.id}`, values);
}
}
setDraft((d) => ({ ...d, createdPhaseId: phase.id }));
onChanged();
setShowRates(true);
await saveInventoryDraft(plan, draft);
setDraft(emptyInventoryDraft());
onSaved();
} catch (e) {
setError(e instanceof Error ? e.message : "Anlegen fehlgeschlagen.");
} finally {
setBusy(false);
setError(e instanceof Error ? e.message : "Speichern fehlgeschlagen.");
setSaving(false);
}
}
// Seite 3: die Sparquote verteilen. Erst moeglich, wenn die Phase steht.
if (showRates && phaseId) {
return (
<RateDistributionDialog
plan={plan}
computed={computed}
phaseId={phaseId}
onClose={() => setShowRates(false)}
onSaved={() => {
setShowRates(false);
onChanged();
}}
/>
);
}
if (existing && !draft.createdPhaseId) {
return (
<div className="flex flex-col gap-3">
<p className="rounded-lg border border-dashed border-border bg-surface-2 p-3 text-sm text-muted">
Die erste Lebensphase «{existing.name}» steht bereits ({existing.durationYears} Jahre). Die jährlichen
Annahmen und die Sparquote änderst du direkt in der Matrix oder hier über die Verteilung.
</p>
<Button onClick={() => setShowRates(true)}>Sparquote verteilen</Button>
</div>
);
}
return (
<div className="flex flex-col gap-4">
{/* Seite 1: Name und Dauer. */}
<div className="grid gap-3 sm:grid-cols-2">
<TextField
label="Wie nennst du diese Phase?"
value={draft.name}
onChange={(v) => setDraft((d) => ({ ...d, name: v }))}
/>
<NumberField
label="Wie viele Jahre dauert sie?"
help={
cap !== null
? `Höchstens ${cap} Jahre danach beginnt die Pensionierung, und dort muss eine neue Lebensphase starten.`
: "Frei wählbar."
}
value={years}
min={1}
max={maxYears}
step={1}
onChange={(v) => setDraft((d) => ({ ...d, years: Math.round(v) }))}
<Modal
title="Bestandsaufnahme"
subtitle="Alles, was du heute hast Einkommen, Ausgaben, Guthaben, Immobilien, Schulden"
onClose={closeGuarded}
xwide
>
<div className="flex flex-col gap-4">
<div className="rounded-xl border border-border bg-surface-2 p-3 text-xs leading-relaxed text-muted">
Hier stehen nur Zahlen, die du <strong className="text-fg">nachschlagen</strong> kannst. Renditen,
Lohnentwicklung und Teuerung kommen später sie sind Annahmen, und eine Annahme gilt immer nur für
einen bestimmten Zeitraum. Du triffst sie in den Lebensphasen.
</div>
<InventoryFields
plan={plan}
draft={draft}
setDraft={(fn) => setDraft((d) => fn(d))}
onChanged={onSaved}
/>
{error && <p className="text-sm text-danger">{error}</p>}
<div className="flex items-center justify-between border-t border-border pt-3">
<Button variant="ghost" onClick={closeGuarded}>
Abbrechen
</Button>
<Button disabled={saving} onClick={save}>
<Check className="h-4 w-4" /> {saving ? "Wird gespeichert…" : "Bestandsaufnahme abschliessen"}
</Button>
</div>
</div>
{plan.startYear && (
<p className="text-xs text-faint">
Das sind die Jahre {plan.startYear} bis {plan.startYear + years - 1}
{plan.persons.length > 0 && (
<>
{" · "}
{plan.persons.map((x) => `${ownerLabel(plan.persons, x.role)} ${x.age}${x.age + years}`).join(" · ")}
</>
)}
</p>
)}
{/* Seite 2: die jaehrlichen Annahmen. */}
<div className="border-t border-border pt-3">
<p className="mb-1 text-sm font-semibold text-fg">Womit rechnest du in dieser Zeit?</p>
<p className="mb-3 text-xs text-muted">
Diese Annahmen gelten nur für diese Lebensphase. In einer späteren Phase kannst du andere setzen etwa
eine vorsichtigere Rendite, wenn du im Ruhestand anders anlegst.
</p>
{plan.elements.length === 0 ? (
<p className="rounded-lg border border-dashed border-border bg-surface-2 p-3 text-sm text-muted">
Es sind noch keine finanziellen Elemente erfasst. Geh zurück zu Schritt 1.
</p>
) : (
<div className="flex flex-col gap-2">
{plan.elements.map((el) => {
const kind = KINDS.find((k) => k.category === el.category);
if (!kind?.phaseFields) return null;
const values = draft.phase[el.id] ?? {};
const set = (patch: Partial<PhaseData>) =>
setDraft((d) => ({ ...d, phase: { ...d.phase, [el.id]: { ...(d.phase[el.id] ?? {}), ...patch } } }));
return (
<div key={el.id} className="rounded-xl border border-border p-3">
<div className="mb-2 flex items-baseline gap-2">
<span className="text-sm font-semibold text-fg">{el.name}</span>
<span className="text-xs text-faint">{CATEGORY_LABELS[el.category]}</span>
{el.ownerRole && el.ownerRole !== "HOUSEHOLD" && (
<span className="rounded bg-surface-2 px-1.5 py-0.5 text-[10px] text-muted">
{ownerLabel(plan.persons, el.ownerRole)}
</span>
)}
</div>
<div className="grid gap-3 sm:grid-cols-2">{kind.phaseFields(values, set)}</div>
</div>
);
})}
</div>
)}
</div>
{error && <p className="text-sm text-danger">{error}</p>}
<Button disabled={busy || plan.elements.length === 0} onClick={createPhase}>
{busy ? "…" : "Lebensphase anlegen und Sparquote verteilen"}
</Button>
</div>
</Modal>
);
}
+112 -75
View File
@@ -14,6 +14,7 @@ import {
Maximize2,
PiggyBank,
Sparkles,
ClipboardList,
Copy,
HelpCircle,
History as HistoryIcon,
@@ -44,19 +45,9 @@ import {
type RetirementDecision,
} from "@/lib/retirement-decision";
import { RetirementFields } from "@/components/RetirementFields";
import { Assistant } from "@/components/Assistant";
import { AssistantStepDialog } from "@/components/AssistantStepDialog";
import {
Step1Elements,
Step2FirstPhase,
BaseFields,
emptyStep1Draft,
emptyStep2Draft,
saveStep1Draft,
type Step1Draft,
type Step2Draft,
} from "@/components/AssistantSteps";
import { normalizeProgress } from "@/lib/assistant";
import { ReviewTile } from "@/components/ReviewTile";
import { BaseFields, InventoryDialog } from "@/components/InventoryDialog";
import { reviewPlan, unconfirmedCells } from "@/lib/review";
import { Button, EmptyState, InspectorShell, Modal, useConfirm, useToast } from "@/components/ui";
import { ElementDetailDialog, PhaseDetailDialog } from "@/components/DetailView";
import {
@@ -208,14 +199,38 @@ export function PlanView({
// weil beide mehrere Elemente auf einmal bearbeiten.
const [distribute, setDistribute] = useState<{ kind: "capital" | "rates"; phaseId: string } | null>(null);
const [valueMode, setValueMode] = useState<ValueMode>("nominal");
// Offener Assistenten-Schritt (Index) bzw. offene Pensionsplanung (Rolle).
const [assistantStep, setAssistantStep] = useState<number | null>(null);
// Der Entwurf der Bestandsaufnahme. Er liegt bewusst HIER und nicht in den einzelnen
// Karten: Beim Wechsel des Reiters (Gemeinsam -> Person A) verschwinden die Karten des
// vorigen Bereichs, und mit ihrem eigenen Zustand waeren alle getippten Zahlen still weg.
const [step1Draft, setStep1Draft] = useState<Step1Draft>(emptyStep1Draft);
const [step2Draft, setStep2Draft] = useState<Step2Draft>(emptyStep2Draft);
const progress = normalizeProgress(plan.assistantProgress);
const [showInventory, setShowInventory] = useState(false);
// Eine Zelle, deren Annahmen noch nie jemand angeschaut hat. Sie traegt dieselbe
// Attention-Farbe wie ein offener Uebergang -- "hier fehlt was" sieht ueberall gleich aus.
const cellOpen = (elementId: string, phaseId: string) =>
unconfirmedCells(plan, computed, phaseId).includes(elementId);
// Alle Annahmen einer Phase auf einmal bestaetigen. Bei sechs Elementen und fuenf Phasen
// waeren es sonst dreissig einzelne Klicks.
async function confirmPhase(phaseId: string, phaseName: string) {
const ids = unconfirmedCells(plan, computed, phaseId);
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.
const review = reviewPlan(plan, computed);
// Die Tour (Start-Knopf, Auto-Start bei Plan-Erstellung, Rendering) liegt seit dem
// Layout-Umbau in AppShell -- sie liest die data-tour-Ziele im DOM dieser Ansicht.
// Nur-Lese-Detailansicht (Roadmap Nr. 43). Der Rechenweg wird erst beim Öffnen erzeugt.
@@ -416,17 +431,22 @@ export function PlanView({
/>
<FiguresTile plan={plan} computed={computed} />
<QuickActionsTile
onInventory={() => setShowInventory(true)}
onHistory={onOpenHistory}
onTour={onStartTour}
onNewScenario={onCopyScenario}
onTraces={onOpenTraces}
exportHref={exportHref}
/>
<Assistant
plan={plan}
progress={progress}
onOpenStep={(i) => setAssistantStep(i)}
onChanged={onChanged}
<ReviewTile
review={review}
onOpenInventory={() => setShowInventory(true)}
onAddPhase={() => setShowAddPhase(true)}
onJump={(g) =>
g.kind === "phase"
? setPanel({ kind: "phase", phaseId: g.id })
: setReviewFromPhaseId(g.id)
}
/>
</div>
@@ -441,14 +461,14 @@ export function PlanView({
</div>
</div>
{!hasPhases && plan.elements.length === 0 && (
{plan.elements.length === 0 && (
<EmptyState
icon={<Plus className="h-6 w-6" />}
title="Noch nichts erfasst"
text="Fang mit dem an, was du heute hast Einkommen, Ausgaben, Guthaben, Immobilien. Die Lebensphasen kommen danach; sie ergeben sich aus deiner Pensionsplanung."
icon={<Sparkles className="h-6 w-6" />}
title="Beginne mit einer Bestandsaufnahme"
text="Halte fest, was du heute hast Einkommen, Ausgaben, Guthaben, Immobilien, Schulden. Danach legst du deine erste Lebensphase an, und ab da zeigt dir FPT, was noch zu entscheiden ist."
>
<Button onClick={() => setAssistantStep(0)}>
<Sparkles className="h-4 w-4" /> Mit dem Assistenten starten
<Button onClick={() => setShowInventory(true)}>
<Sparkles className="h-4 w-4" /> Bestandsaufnahme starten
</Button>
</EmptyState>
)}
@@ -600,6 +620,11 @@ export function PlanView({
? () => void deletePhase(col.phase.id, col.phase.name)
: null
}
openCells={unconfirmedCells(plan, computed, col.phase.id).length}
ratesOpen={
plan.phases.find((x) => x.id === col.phase.id)?.ratesConfirmed !== true
}
onConfirmAll={() => void confirmPhase(col.phase.id, col.phase.name)}
active={panel?.kind === "phase" && panel.phaseId === col.phase.id}
/>
) : (
@@ -615,10 +640,10 @@ export function PlanView({
<div className="text-xs font-semibold text-fg">Noch keine Lebensphasen</div>
<button
type="button"
onClick={() => setAssistantStep(1)}
onClick={() => setShowAddPhase(true)}
className="mt-1 rounded-lg border border-dashed border-accent px-2 py-1 text-[11px] font-normal text-accent-soft-fg hover:bg-accent-soft/30"
>
Weiter mit der Pensionsplanung
Erste Lebensphase anlegen
</button>
</th>
)}
@@ -830,9 +855,19 @@ export function PlanView({
<td
key={col.phase.id}
onClick={() => setPanel({ kind: "cell", elementId: el.id, phaseId: col.phase.id })}
title={cellDiff(el.id, col.phase.id) ? "Weicht von der Vorlage ab" : undefined}
title={
cellOpen(el.id, col.phase.id)
? "Annahmen dieser Phase noch nicht bestätigt"
: cellDiff(el.id, col.phase.id)
? "Weicht von der Vorlage ab"
: undefined
}
className={`cursor-pointer border-b border-r border-border px-2 py-1.5 text-center text-xs ${
ce?.locked ? "text-faint" : "text-fg"
} ${
cellOpen(el.id, col.phase.id)
? "ring-1 ring-inset ring-attention/50"
: ""
} ${cellDiff(el.id, col.phase.id)}`}
>
{phaseCellContent(ce, col.phase, valueMode, planEndOf(ce?.elementId, col.phase.id))}
@@ -930,48 +965,15 @@ export function PlanView({
/>
)}
{assistantStep !== null && (
<AssistantStepDialog
{showInventory && (
<InventoryDialog
plan={plan}
stepIndex={assistantStep}
isDone={progress[assistantStep] === true}
onToggleDone={async (done) => {
await api.post(`/api/scenarios/${plan.id}/assistant`, { step: assistantStep, done });
onClose={() => setShowInventory(false)}
onSaved={() => {
setShowInventory(false);
onChanged();
}}
onClose={() => {
setAssistantStep(null);
setStep1Draft(emptyStep1Draft());
}}
dirty={
assistantStep === 0 &&
(Object.keys(step1Draft.base).length > 0 || Object.keys(step1Draft.names).length > 0)
}
onCommit={async () => {
if (assistantStep === 0) {
await saveStep1Draft(plan, step1Draft);
setStep1Draft(emptyStep1Draft());
}
}}
>
{assistantStep === 0 && (
<Step1Elements
plan={plan}
draft={step1Draft}
setDraft={(fn) => setStep1Draft((d) => fn(d))}
onChanged={onChanged}
/>
)}
{assistantStep === 1 && (
<Step2FirstPhase
plan={plan}
computed={computed}
draft={step2Draft}
setDraft={(fn) => setStep2Draft((d) => fn(d))}
onChanged={onChanged}
/>
)}
</AssistantStepDialog>
/>
)}
{reviewFromPhaseId && (() => {
@@ -1392,6 +1394,9 @@ function PhaseHeader({
onDistributeCapital,
onDistributeRates,
onDelete,
openCells,
ratesOpen,
onConfirmAll,
active,
}: {
phase: PhaseComputed;
@@ -1406,6 +1411,11 @@ function PhaseHeader({
onDistributeRates: () => void;
// null = nicht die letzte Phase (nur die letzte lässt sich löschen).
onDelete: (() => void) | null;
// Wie viele Annahmen dieser Phase sind noch nicht bestätigt? 0 = alles angeschaut.
openCells: number;
// Wurde die Spar-/Verzehrquote verteilt?
ratesOpen: boolean;
onConfirmAll: () => void;
active: boolean;
}) {
const quotaLabel = phase.isConsumption ? "Verzehrquote" : "Sparquote";
@@ -1428,6 +1438,21 @@ function PhaseHeader({
>
<div className="flex items-center gap-1">
<span className="min-w-0 flex-1 truncate text-xs font-semibold text-fg">{phase.name}</span>
{/* Offene Punkte dieser Phase -- ein Klick bestätigt alle Annahmen auf einmal. Bei
sechs Elementen und fünf Phasen wären es sonst dreissig einzelne Klicks. */}
{openCells > 0 && (
<button
type="button"
title={`${openCells} ${openCells === 1 ? "Annahme" : "Annahmen"} nicht bestätigt alle bestätigen`}
onClick={(e) => {
e.stopPropagation();
onConfirmAll();
}}
className="shrink-0 rounded-full bg-attention px-1.5 py-0.5 text-[10px] font-semibold text-attention-fg hover:opacity-90"
>
{openCells}
</button>
)}
{diffKind === "added" && (
<span className="rounded bg-diff-added px-1 text-[9px] font-semibold uppercase text-white">neu</span>
)}
@@ -1536,7 +1561,12 @@ function PhaseHeader({
e.stopPropagation();
onDistributeRates();
}}
className="mt-1 w-full rounded border border-accent px-1.5 py-0.5 text-[10px] font-semibold text-accent transition-colors hover:bg-accent hover:text-accent-fg"
title={ratesOpen ? "Noch nicht verteilt -- was du nicht zuteilst, bleibt auf dem Cash-Konto" : undefined}
className={`mt-1 w-full rounded px-1.5 py-0.5 text-[10px] font-semibold transition-colors ${
ratesOpen
? "border border-attention bg-attention text-attention-fg hover:opacity-90"
: "border border-accent text-accent hover:bg-accent hover:text-accent-fg"
}`}
>
{phase.isConsumption ? "Bezug verteilen" : "Sparquote verteilen"}
</button>
@@ -2557,12 +2587,14 @@ function Figure({ label, value }: { label: string; value: number | null }) {
}
function QuickActionsTile({
onInventory,
onHistory,
onTour,
onNewScenario,
onTraces,
exportHref,
}: {
onInventory?: () => void;
onHistory?: () => void;
onTour?: () => void;
onNewScenario?: () => void;
@@ -2573,6 +2605,11 @@ function QuickActionsTile({
"flex w-full items-center gap-1.5 rounded-lg border border-border px-2 py-1 text-[11px] font-medium text-muted transition-colors hover:bg-surface-2 hover:text-fg";
return (
<Tile tour="toolbar" title="Schnellaktionen">
{onInventory && (
<button type="button" onClick={onInventory} className={cls}>
<ClipboardList className="h-3.5 w-3.5" /> Bestandsaufnahme
</button>
)}
{onNewScenario && (
<button type="button" onClick={onNewScenario} className={cls}>
<Copy className="h-3.5 w-3.5" /> Neues Szenario
+111
View File
@@ -0,0 +1,111 @@
"use client";
// Die Übersicht der offenen Punkte -- oben rechts, wo bis 0.38 der Assistent stand.
//
// Sie listet keine Schritte auf, sondern zeigt einen ZUSTAND: Was ist in diesem Plan noch
// nicht entschieden oder nicht bestätigt? Der Unterschied fällt auf, sobald man einen
// bestehenden Plan öffnet -- der Assistent bot dort Schritte an, die längst erledigt waren.
//
// Zwei Sonderzustände ganz am Anfang: Ohne Elemente und ohne Lebensphasen gibt es
// naturgemäss nichts Offenes, obwohl der Plan leer ist. Eine "0" wäre dort eine Lüge; deshalb
// steht dort eine Aufforderung statt einer Zahl.
import { CheckCircle2, ClipboardList, Sparkles } from "lucide-react";
import type { PlanReview } from "@/lib/review";
export function ReviewTile({
review,
onOpenInventory,
onAddPhase,
onJump,
}: {
review: PlanReview;
onOpenInventory: () => void;
onAddPhase: () => void;
onJump: (group: { id: string; kind: "phase" | "transition" }) => void;
}) {
const done = review.stage === "DONE";
const starting = review.stage === "NO_ELEMENTS" || review.stage === "NO_PHASES";
return (
<div
data-tour="assistant"
className={`flex h-full flex-col rounded-xl border px-3 py-2.5 shadow-sm transition-colors ${
done ? "border-success bg-success-soft" : "border-attention bg-attention-soft"
}`}
>
<div className="mb-2 flex items-center gap-2">
{done ? (
<CheckCircle2 className="h-4 w-4 text-success" />
) : (
<ClipboardList className="h-4 w-4 text-attention-fg" />
)}
<span className="text-xs font-semibold uppercase tracking-wide text-fg">Offene Punkte</span>
{!starting && (
<span className={`ml-auto text-[11px] font-semibold ${done ? "text-success" : "text-muted"}`}>
{done ? "keine" : review.total}
</span>
)}
</div>
{review.stage === "NO_ELEMENTS" && (
<div className="flex min-h-0 flex-1 flex-col gap-2">
<p className="text-[11px] leading-snug text-muted">
Dein Plan ist noch leer. Erfasse zuerst, was du heute hast danach planst du deine erste Lebensphase.
</p>
<button
type="button"
onClick={onOpenInventory}
className="mt-auto flex items-center justify-center gap-1.5 rounded-lg bg-accent px-2 py-1.5 text-[11px] font-semibold text-accent-fg hover:opacity-90"
>
<Sparkles className="h-3.5 w-3.5" /> Bestandsaufnahme
</button>
</div>
)}
{review.stage === "NO_PHASES" && (
<div className="flex min-h-0 flex-1 flex-col gap-2">
<p className="text-[11px] leading-snug text-muted">
Deine Bestandsaufnahme steht. Jetzt fehlt die erste Lebensphase erst dann gibt es etwas zu
entscheiden.
</p>
<button
type="button"
onClick={onAddPhase}
className="mt-auto flex items-center justify-center gap-1.5 rounded-lg bg-accent px-2 py-1.5 text-[11px] font-semibold text-accent-fg hover:opacity-90"
>
Erste Lebensphase anlegen
</button>
</div>
)}
{review.stage === "DONE" && (
<div className="flex min-h-0 flex-1 flex-col justify-center gap-1">
<p className="text-sm font-semibold text-success">Alles entschieden</p>
<p className="text-[11px] leading-snug text-muted">
In allen Lebensphasen und Übergängen ist nichts mehr offen. Änderst du etwas, erscheint es hier wieder.
</p>
</div>
)}
{review.stage === "OPEN" && (
<div className="flex min-h-0 flex-1 flex-col gap-1 overflow-auto">
{review.groups.map((g) => (
<button
key={`${g.kind}-${g.id}`}
type="button"
onClick={() => onJump(g)}
className="rounded-lg px-1.5 py-1 text-left transition-colors hover:bg-surface/60"
>
<span className="flex items-baseline gap-1.5">
<span className="min-w-0 flex-1 truncate text-xs font-semibold text-fg">{g.title}</span>
<span className="shrink-0 text-[11px] font-semibold text-attention-fg">{g.open}</span>
</span>
<span className="block truncate text-[11px] text-muted">{g.reasons.join(" · ")}</span>
</button>
))}
</div>
)}
</div>
);
}
-174
View File
@@ -1,174 +0,0 @@
// Der FPT-Assistent und die Element-Stammdaten (0.36).
//
// Der Assistent selbst ist UI; getestet wird hier die reine Logik dahinter -- der Fortschritt,
// der abgeleitete Stand und die Voraussetzungen je Schritt. Dazu die Eigenschaft, wegen der
// `baseData` überhaupt existiert: Ein Element muss sich erfassen lassen, BEVOR es Phasen gibt.
import { describe, it, expect } from "vitest";
import {
ASSISTANT_STEP_COUNT,
ASSISTANT_STEPS,
emptyProgress,
normalizeProgress,
stepBlockedReason,
stepStatus,
} from "@/lib/assistant";
import { computePlan } from "@/lib/calculations";
import type { PhaseData } from "@/lib/elements";
import type { PlanInput } from "@/lib/types";
function plan(over: Partial<PlanInput> = {}): PlanInput {
return {
id: "plan",
name: "T",
householdType: "SINGLE",
inflationRateDefault: 0,
initialCash: 0,
startYear: 2026,
persons: [{ id: "A", role: "PERSON_A", name: null, age: 40, retirementAge: 65 }],
phases: [],
elements: [],
...over,
};
}
function el(category: string, baseData: PhaseData = {}, phaseValues: Record<string, PhaseData> = {}) {
return {
id: `e-${category}-${Math.random().toString(36).slice(2, 7)}`,
category: category as never,
name: category,
ownerRole: "PERSON_A" as never,
orderIndex: 0,
phaseValues,
transitionValues: {},
baseData,
};
}
describe("Assistenten-Fortschritt", () => {
it("startet mit lauter offenen Schritten", () => {
expect(emptyProgress()).toHaveLength(ASSISTANT_STEP_COUNT);
expect(emptyProgress().some(Boolean)).toBe(false);
expect(ASSISTANT_STEPS).toHaveLength(ASSISTANT_STEP_COUNT);
});
it("verwirft kaputte Daten, statt daran zu scheitern", () => {
// Der Fortschritt liegt als JSON in der Datenbank -- ein alter oder manipulierter Stand
// darf die Ansicht nicht zerlegen.
expect(normalizeProgress(null)).toEqual(emptyProgress());
expect(normalizeProgress([true])).toEqual(emptyProgress()); // falsche Länge
expect(normalizeProgress([true, false, true])).toEqual(emptyProgress()); // ebenso
expect(normalizeProgress("kaputt")).toEqual(emptyProgress());
const gut = [true, false];
expect(normalizeProgress(gut)).toEqual(gut);
});
});
describe("Abgeleiteter Stand neben dem Haken", () => {
// Der Haken ist bewusst manuell -- aber der Assistent soll nichts Falsches behaupten.
// Deshalb steht daneben, was tatsächlich da ist.
it("zählt die erfassten Elemente", () => {
expect(stepStatus(plan(), 0)).toBe("noch nichts erfasst");
expect(stepStatus(plan({ elements: [el("INCOME")] }), 0)).toBe("1 Element");
expect(stepStatus(plan({ elements: [el("INCOME"), el("EXPENSE")] }), 0)).toBe("2 Elemente");
});
it("zählt die Lebensphasen -- auch wenn es keine gibt", () => {
expect(stepStatus(plan(), 1)).toBe("0 Lebensphasen");
expect(
stepStatus(
plan({ phases: [{ id: "p1", sequenceNumber: 1, name: "p1", durationYears: 5, cashTransition: {} }] }),
1
)
).toBe("1 Lebensphase");
});
});
describe("Voraussetzungen je Schritt", () => {
it("sperrt die Lebensphase, solange keine Elemente erfasst sind", () => {
// Eine Phase ohne Elemente waere ein leerer Zeitraum -- es gaebe nichts zu planen.
expect(stepBlockedReason(plan(), 1)).toContain("finanziellen Elemente");
expect(stepBlockedReason(plan({ elements: [el("INCOME")] }), 1)).toBeNull();
});
it("lässt die Bestandsaufnahme immer zu -- sie braucht keine Zeitachse", () => {
expect(stepBlockedReason(plan(), 0)).toBeNull();
});
});
describe("Element-Stammdaten (baseData)", () => {
// Der Grund für den ganzen Umbau: Eine Bestandsaufnahme ist keine Aussage über eine Phase.
it("erlaubt Elemente ohne jede Lebensphase", () => {
const p = plan({ elements: [el("OTHER_ASSET", { startValue: 100000, expectedReturn: 3 })] });
const c = computePlan(p);
expect(c.phases).toHaveLength(0);
expect(c.ruinAge).toBeNull();
});
it("dient der ersten Phase als Startwert", () => {
const p = plan({
phases: [{ id: "p1", sequenceNumber: 1, name: "p1", durationYears: 10, cashTransition: {} }],
elements: [el("OTHER_ASSET", { startValue: 100000, expectedReturn: 0 }, { p1: {} })],
});
const asset = computePlan(p).phases[0].elements.find((e) => e.category === "OTHER_ASSET")!;
expect(asset.startValue).toBe(100000);
});
it("ist die Wurzel der Vererbung -- Phase 1 erbt die Rendite von dort", () => {
// Vor 0.36 hatte Phase 1 nichts, von dem sie hätte erben können, und fiel auf 0.
const p = plan({
phases: [{ id: "p1", sequenceNumber: 1, name: "p1", durationYears: 10, cashTransition: {} }],
elements: [el("OTHER_ASSET", { startValue: 100000, expectedReturn: 5 }, { p1: {} })],
});
const asset = computePlan(p).phases[0].elements.find((e) => e.category === "OTHER_ASSET")!;
expect(asset.endValue).toBe(Math.round(100000 * Math.pow(1.05, 10)));
});
it("weicht einem erfassten Phasenwert -- der gewinnt", () => {
const p = plan({
phases: [{ id: "p1", sequenceNumber: 1, name: "p1", durationYears: 10, cashTransition: {} }],
elements: [el("OTHER_ASSET", { startValue: 100000, expectedReturn: 5 }, { p1: { expectedReturn: 0 } })],
});
const asset = computePlan(p).phases[0].elements.find((e) => e.category === "OTHER_ASSET")!;
expect(asset.endValue).toBe(100000);
});
});
describe("Stammdaten sind ein eigener Speicherort (0.37)", () => {
// Der Fund aus dem Memo: Nach der Bestandsaufnahme blieb die Matrix leer. Die Ursache lag
// nicht in der Rechnung, sondern in der Darstellung -- die Werte waren da, nur unsichtbar.
// Der Test nagelt fest, dass sie ohne jede Lebensphase abrufbar sind.
it("liefert Bestände auch ohne Lebensphasen", () => {
const p = plan({
elements: [
el("OTHER_ASSET", { startValue: 120000 }),
el("PENSION_FUND", { currentValue: 310000 }),
el("OTHER_DEBT", { startValue: 40000 }),
],
});
expect(p.phases).toHaveLength(0);
// Genau die Rechnung, die Matrix und Kennzahlen-Kachel anzeigen.
const sum = p.elements.reduce((n, e) => {
const b = e.baseData ?? {};
if (e.category === "OTHER_ASSET") return n + (b.startValue ?? 0);
if (e.category === "PENSION_FUND") return n + (b.currentValue ?? 0);
if (e.category === "OTHER_DEBT") return n - (b.startValue ?? 0);
return n;
}, 0);
expect(sum).toBe(390000);
});
it("bleibt erhalten, wenn später Phasen dazukommen", () => {
// Die Stammdaten dürfen von einer Phase nicht überschrieben, sondern nur überlagert
// werden -- sonst gäbe es dieselbe Zahl an zwei Orten.
const base = el("OTHER_ASSET", { startValue: 120000, expectedReturn: 4 });
const p = plan({
phases: [{ id: "p1", sequenceNumber: 1, name: "p1", durationYears: 5, cashTransition: {} }],
elements: [{ ...base, phaseValues: { p1: {} } }],
});
const asset = computePlan(p).phases[0].elements.find((e) => e.category === "OTHER_ASSET")!;
expect(asset.startValue).toBe(120000);
// Und die Stammdaten selbst sind unverändert geblieben.
expect(p.elements[0].baseData?.startValue).toBe(120000);
});
});
-89
View File
@@ -1,89 +0,0 @@
// Der FPT-Assistent: der Weg von der leeren Matrix zum Plan.
//
// Er ersetzt die frueheren "Naechsten Schritte". Der Unterschied ist nicht kosmetisch: Die
// alte Karte leitete AB, was zu tun waere; hier fuehrt jeder Schritt sein eigenes Werkzeug
// mit sich. Der Nutzer haelt den Fortschritt selbst fest (Haken) -- das Tool masst sich nicht
// an zu wissen, wann jemand mit einem Schritt fertig IST.
//
// Damit der Assistent trotzdem nichts Falsches behauptet, steht neben jedem Haken der
// ABGELEITETE Stand ("0 Lebensphasen"). Ein abgehakter Schritt ohne Substanz faellt so auf,
// ohne dass das Tool den Haken verweigert.
//
// Stand 0.38: Der Assistent fuehrt bis zur ERSTEN Lebensphase. Alles danach (weitere Phasen,
// Uebergaenge, Pensionierung) wird als eigener Ablauf nachgezogen -- lieber zwei Schritte,
// die tragen, als sieben, die nur behaupten zu fuehren.
import { z } from "zod";
import type { PlanInput } from "@/lib/types";
export const ASSISTANT_STEP_COUNT = 2;
export type AssistantProgress = boolean[];
export const assistantProgressSchema = z.array(z.boolean()).length(ASSISTANT_STEP_COUNT);
export function emptyProgress(): AssistantProgress {
return Array.from({ length: ASSISTANT_STEP_COUNT }, () => false);
}
export function normalizeProgress(raw: unknown): AssistantProgress {
const parsed = assistantProgressSchema.safeParse(raw);
return parsed.success ? parsed.data : emptyProgress();
}
export interface AssistantStep {
index: number;
title: string;
short: string;
// Ein Satz, der sagt, worum es geht -- steht in der Liste unter dem Titel.
lead: string;
// Hat dieser Schritt ein Werkzeug, oder ist er reine Information?
guided: boolean;
}
export const ASSISTANT_STEPS: AssistantStep[] = [
{
index: 0,
title: "Bestandsaufnahme",
short: "Was du hast",
lead: "Alle Konten, Guthaben, Immobilien und Schulden erfassen mit ihrem heutigen Stand.",
guided: true,
},
{
index: 1,
title: "Erste Lebensphase",
short: "Die ersten Jahre",
lead: "Wie lange die erste Phase dauert, womit du in dieser Zeit rechnest und wohin dein Sparbetrag fliesst.",
guided: true,
},
];
// --- Abgeleiteter Stand je Schritt -----------------------------------------------------------
//
// Bewusst NICHT zum Erzwingen des Hakens, sondern als Gegenprobe daneben. Wer "Erste
// Lebensphase" abhakt, ohne eine anzulegen, sieht "0 Lebensphasen" -- das reicht.
export function stepStatus(plan: PlanInput, index: number): string {
const phases = plan.phases.length;
const elements = plan.elements.length;
switch (index) {
case 0:
return elements === 0
? "noch nichts erfasst"
: `${elements} ${elements === 1 ? "Element" : "Elemente"}`;
case 1:
if (phases === 0) return "0 Lebensphasen";
return `${phases} ${phases === 1 ? "Lebensphase" : "Lebensphasen"}`;
default:
return "";
}
}
// Ein Schritt laesst sich erst sinnvoll oeffnen, wenn seine Voraussetzung erfuellt ist. Der
// Haken bleibt trotzdem jederzeit setzbar -- gesperrt wird nur das Werkzeug, nicht die
// Selbstauskunft.
export function stepBlockedReason(plan: PlanInput, index: number): string | null {
if (index === 1 && plan.elements.length === 0) {
return "Erfasse zuerst deine finanziellen Elemente ohne sie gibt es in einer Lebensphase nichts zu planen.";
}
return null;
}
+4
View File
@@ -50,6 +50,9 @@ export const CATEGORY_ORDER: ElementCategory[] = [
// Defaults, das UI zeigt je nach Kontext nur die relevanten Felder.
export interface PhaseData {
// Hat jemand die Annahmen dieser Zelle einmal angeschaut? Steht NEBEN den Werten und
// kopiert nichts -- eine bestaetigte Phase erbt weiterhin live von der Vorphase.
confirmed?: boolean;
// INCOME / EXPENSE
amount?: number;
// INCOME / EXPENSE: jährlicher Teuerungsausgleich (%). Indexiert den Flow über die
@@ -173,6 +176,7 @@ export const cashTransitionSchema = z
export const phaseDataSchema = z
.object({
confirmed: z.boolean().optional(),
amount: nonNeg.optional(),
teuerungsausgleich: z.number().min(-20).max(50).optional(),
gapYears: z.number().int().min(0).optional(),
+5 -2
View File
@@ -76,8 +76,11 @@ describe("Datenbank-Migrationen", () => {
expect(personCols).not.toContain("name");
expect(personCols).not.toContain("age");
const scenCols2 = await cols("Scenario");
expect(scenCols2).toContain("assistantProgress");
// Der Assistenten-Fortschritt ist mit dem Assistenten entfallen (0.39): Was offen ist,
// wird abgeleitet und nicht gespeichert.
expect(await cols("Scenario")).not.toContain("assistantProgress");
// Die Spar-/Verzehrquote je Phase zaehlt eigens als offener Punkt.
expect(await cols("Phase")).toContain("ratesConfirmed");
// Der Pensionierungs-Entscheid hängt am ELEMENT und bewusst an keiner Phase (0.34) --
// nur so überlebt er eine Verschiebung der Zeitachse. Die Stammdaten (0.36) ebenso: Sie
+1 -2
View File
@@ -2,7 +2,6 @@ import { Prisma } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import { cashTransitionSchema, phaseDataSchema, transitionDataSchema } from "@/lib/elements";
import { retirementDecisionSchema } from "@/lib/retirement-decision";
import { normalizeProgress } from "@/lib/assistant";
import type { CashTransitionData, PhaseData, TransitionData } from "@/lib/elements";
import type { RetirementDecision } from "@/lib/retirement-decision";
import type { PlanInput } from "@/lib/types";
@@ -51,7 +50,6 @@ export function toPlanInput(plan: PlanWithRelations): PlanInput {
inflationRateDefault: plan.inflationRateDefault,
initialCash: plan.initialCash,
startYear: plan.plan.startYear,
assistantProgress: normalizeProgress(plan.assistantProgress),
// Name und Alter vom Plan, Pensionsalter vom Szenario. Fehlt zu einer Rolle die
// Plan-Person, greift ein Notbehelf -- die Berechnung darf daran nicht scheitern.
persons: plan.persons.map((p) => {
@@ -70,6 +68,7 @@ export function toPlanInput(plan: PlanWithRelations): PlanInput {
name: phase.name,
durationYears: phase.durationYears,
cashTransition: parseCashTransition(phase.cashTransition),
ratesConfirmed: phase.ratesConfirmed,
sourcePhaseId: phase.sourcePhaseId,
})),
elements: plan.elements.map((e) => {
+141
View File
@@ -0,0 +1,141 @@
// Die Übersicht der offenen Punkte (0.39).
//
// Sie ersetzt den FPT-Assistenten. Getestet wird die Regel dahinter: Wann gilt etwas als
// offen, wann als erledigt -- und die beiden Sonderzustände am Anfang, in denen eine Zahl
// eine Lüge wäre.
import { describe, it, expect } from "vitest";
import { computePlan } from "@/lib/calculations";
import { reviewPlan, unconfirmedCells, isCellConfirmed } from "@/lib/review";
import type { PhaseData } from "@/lib/elements";
import type { PlanInput } from "@/lib/types";
function plan(over: Partial<PlanInput> = {}): PlanInput {
return {
id: "plan",
name: "T",
householdType: "SINGLE",
inflationRateDefault: 0,
initialCash: 0,
startYear: 2026,
persons: [{ id: "A", role: "PERSON_A", name: null, age: 40, retirementAge: 65 }],
phases: [],
elements: [],
...over,
};
}
function phase(id: string, seq: number, years: number, ratesConfirmed = true) {
return { id, sequenceNumber: seq, name: id, durationYears: years, cashTransition: { mode: "NONE" as const }, ratesConfirmed };
}
let n = 0;
function el(category: string, phaseValues: Record<string, PhaseData> = {}) {
return {
id: `e${n++}`,
category: category as never,
name: category,
ownerRole: "HOUSEHOLD" as never,
orderIndex: n,
phaseValues,
transitionValues: {},
baseData: {},
};
}
const ok = { confirmed: true };
describe("Die beiden Startzustände", () => {
// Ohne Elemente und ohne Phasen gibt es naturgemäss nichts Offenes -- der Plan ist trotzdem
// leer. Eine «0» wäre dort eine Lüge, deshalb eigene Zustände.
it("meldet einen Plan ohne Elemente als NO_ELEMENTS, nicht als fertig", () => {
const p = plan();
const r = reviewPlan(p, computePlan(p));
expect(r.stage).toBe("NO_ELEMENTS");
expect(r.total).toBe(0);
});
it("meldet Elemente ohne Lebensphasen als NO_PHASES", () => {
const p = plan({ elements: [el("OTHER_ASSET")] });
expect(reviewPlan(p, computePlan(p)).stage).toBe("NO_PHASES");
});
});
describe("Offene Punkte je Phase", () => {
it("zählt jede unbestätigte Zelle", () => {
const p = plan({
phases: [phase("p1", 1, 10)],
elements: [el("INCOME", { p1: {} }), el("EXPENSE", { p1: {} }), el("OTHER_ASSET", { p1: ok })],
});
const c = computePlan(p);
expect(unconfirmedCells(p, c, "p1")).toHaveLength(2);
const r = reviewPlan(p, c);
expect(r.stage).toBe("OPEN");
expect(r.total).toBe(2);
expect(r.groups[0].reasons[0]).toContain("2 Annahmen");
});
it("zählt die nicht verteilte Sparquote eigens", () => {
// Man kann jede Zelle angeschaut und die Quote trotzdem nie verteilt haben -- dann bliebe
// alles still auf dem Cash-Konto.
const p = plan({
phases: [phase("p1", 1, 10, false)],
elements: [el("INCOME", { p1: ok })],
});
const r = reviewPlan(p, computePlan(p));
expect(r.total).toBe(1);
expect(r.groups[0].reasons[0]).toContain("Sparquote nicht verteilt");
});
it("ist fertig, wenn alles bestätigt und verteilt ist", () => {
const p = plan({
phases: [phase("p1", 1, 10)],
elements: [el("INCOME", { p1: ok }), el("EXPENSE", { p1: ok })],
});
const r = reviewPlan(p, computePlan(p));
expect(r.stage).toBe("DONE");
expect(r.total).toBe(0);
expect(r.groups).toHaveLength(0);
});
it("verlangt keine Bestätigung für ein verkauftes Element", () => {
// Ein verkauftes Haus trägt in der Folgephase keine Annahmen mehr.
const p = plan({
phases: [phase("p1", 1, 5), phase("p2", 2, 5)],
elements: [
el("INCOME", { p1: ok, p2: ok }),
{
...el("REAL_ESTATE", { p1: { ...ok, purchasePrice: 800000, mortgage: 500000 }, p2: {} }),
transitionValues: { p1: { decision: "SELL" as const, salePrice: 900000 } },
},
],
});
const c = computePlan(p);
// In p2 ist die Immobilie verkauft -- nur das Einkommen zählt, und das ist bestätigt.
expect(unconfirmedCells(p, c, "p2")).toHaveLength(0);
});
});
describe("Bestätigen friert nichts ein", () => {
// Der wichtigste Punkt: Der Haken steht NEBEN den Werten. Eine bestätigte Phase erbt
// weiterhin live aus der Vorphase -- sonst wäre die Feld-Vererbung hinfällig.
it("lässt die Vererbung unberührt", () => {
const p = plan({
phases: [phase("p1", 1, 10), phase("p2", 2, 10)],
elements: [
{
...el("OTHER_ASSET"),
baseData: { startValue: 100000, expectedReturn: 5 },
// p2 ist bestätigt, hat aber KEINEN eigenen Renditewert -- sie muss erben.
phaseValues: { p1: ok, p2: ok },
},
],
});
const c = computePlan(p);
const p2 = c.phases[1].elements.find((e) => e.category === "OTHER_ASSET")!;
// 100'000 über 20 Jahre zu 5 % -- die Rendite gilt in beiden Phasen. Auf den Franken
// genau geht es nicht: Der Rechenkern rundet an jeder Phasengrenze.
expect(p2.endValue).toBeCloseTo(100000 * Math.pow(1.05, 20), -1);
expect(isCellConfirmed(p, "p2", p.elements[0].id)).toBe(true);
});
});
+116
View File
@@ -0,0 +1,116 @@
// Was ist in diesem Plan noch offen?
//
// Ersetzt den FPT-Assistenten. Der Unterschied ist nicht die Groesse, sondern der Charakter:
// Der Assistent war ein ABLAUF ("tu dies, dann das") und funktionierte nur beim ersten
// Aufsetzen -- wer einen bestehenden Plan oeffnete, bekam Schritte angeboten, die laengst
// erledigt waren. Diese Uebersicht ist ein ZUSTAND. Sie leitet ab, was tatsaechlich fehlt,
// und traegt damit bei jedem Plan, in jeder Reihenfolge, auch beim zwanzigsten Szenario.
//
// Der Mechanismus dahinter ist derselbe, den die Uebergaenge seit 0.35 haben, jetzt auf
// PHASENWERTE ausgeweitet: Eine neue Lebensphase uebernimmt alle Werte der Vorphase, aber sie
// gelten als unbestaetigt, bis jemand hingeschaut hat. Vorbelegen ja, stillschweigend
// uebernehmen nein.
//
// WICHTIG: Bestaetigen heisst "ich habe hingeschaut", NICHT "festnageln". Der Haken steht
// neben den Werten, er kopiert sie nicht in die Phase -- sonst waere jede bestaetigte Phase
// eingefroren und die Feld-Vererbung (SPEZIFIKATION 3.12.4) waere hinfaellig.
import { openTransitionCount, type DecisionCounts } from "@/lib/decisions";
import type { PlanComputed } from "@/lib/calculations";
import type { PlanInput } from "@/lib/types";
// Wo steht der Plan insgesamt? Die ersten beiden Zustaende sind KEINE Zaehlung offener
// Punkte -- ohne Elemente und ohne Phasen gibt es naturgemaess nichts Offenes, der Plan ist
// aber trotzdem leer. Diese beiden Faelle brauchen eine Aufforderung, keine Statistik.
export type ReviewStage = "NO_ELEMENTS" | "NO_PHASES" | "OPEN" | "DONE";
export interface ReviewGroup {
// Sprungziel: Phasen-Id bzw. die Von-Phase eines Uebergangs.
id: string;
kind: "phase" | "transition";
title: string;
open: number;
// Kurze Klartexte, was genau fehlt.
reasons: string[];
}
export interface PlanReview {
stage: ReviewStage;
total: number;
groups: ReviewGroup[];
}
// Braucht dieses Element in dieser Phase eine Bestaetigung? Alles, was in der Phase AKTIV ist:
// Ein verkauftes Haus oder eine getilgte Schuld traegt keine Annahmen mehr.
export function needsConfirmation(
computed: PlanComputed,
phaseId: string,
elementId: string
): boolean {
const ph = computed.phases.find((p) => p.id === phaseId);
const ce = ph?.elements.find((e) => e.elementId === elementId);
return !!ce && ce.status === "ACTIVE";
}
// Ist der Phasenwert dieses Elements bestaetigt?
export function isCellConfirmed(plan: PlanInput, phaseId: string, elementId: string): boolean {
return plan.elements.find((e) => e.id === elementId)?.phaseValues[phaseId]?.confirmed === true;
}
// Unbestaetigte Zellen einer Phase.
export function unconfirmedCells(plan: PlanInput, computed: PlanComputed, phaseId: string): string[] {
return plan.elements
.filter((e) => needsConfirmation(computed, phaseId, e.id) && !isCellConfirmed(plan, phaseId, e.id))
.map((e) => e.id);
}
export function reviewPlan(plan: PlanInput, computed: PlanComputed): PlanReview {
if (plan.elements.length === 0) return { stage: "NO_ELEMENTS", total: 0, groups: [] };
if (computed.phases.length === 0) return { stage: "NO_PHASES", total: 0, groups: [] };
const groups: ReviewGroup[] = [];
computed.phases.forEach((ph, i) => {
const reasons: string[] = [];
let open = 0;
// 1. Annahmen je Element -- Renditen, Lohnentwicklung, Teuerung, Zins.
const cells = unconfirmedCells(plan, computed, ph.id);
if (cells.length > 0) {
open += cells.length;
reasons.push(`${cells.length} ${cells.length === 1 ? "Annahme" : "Annahmen"} nicht bestätigt`);
}
// 2. Die Spar- bzw. Verzehrquote. Zaehlt eigens: Man kann jede Zelle angeschaut haben und
// die Verteilung trotzdem nie getroffen haben -- dann bleibt alles still auf dem Cash.
const phase = plan.phases.find((p) => p.id === ph.id);
if (phase && phase.ratesConfirmed !== true) {
open += 1;
reasons.push(ph.isConsumption ? "Bezüge nicht verteilt" : "Sparquote nicht verteilt");
}
if (open > 0) groups.push({ id: ph.id, kind: "phase", title: ph.name, open, reasons });
// 3. Der Uebergang NACH dieser Phase.
const next = computed.phases[i + 1];
if (!next) return;
const counts: DecisionCounts = openTransitionCount(plan, computed, ph, next);
const tOpen = counts.open + counts.unconfirmed;
if (tOpen > 0) {
const r: string[] = [];
if (counts.open > 0) r.push(`${counts.open} ${counts.open === 1 ? "Entscheid" : "Entscheide"} offen`);
if (counts.unconfirmed > 0)
r.push(`${counts.unconfirmed} ${counts.unconfirmed === 1 ? "Vorgabe" : "Vorgaben"} ungeprüft`);
groups.push({
id: ph.id,
kind: "transition",
title: `Übergang nach ${next.name}`,
open: tOpen,
reasons: r,
});
}
});
const total = groups.reduce((n, g) => n + g.open, 0);
return { stage: total === 0 ? "DONE" : "OPEN", total, groups };
}
+2 -3
View File
@@ -5,7 +5,6 @@
import type { CashTransitionData, ElementCategory, OwnerRole, PhaseData, TransitionData } from "@/lib/elements";
import type { RetirementDecision } from "@/lib/retirement-decision";
import type { AssistantProgress } from "@/lib/assistant";
export type HouseholdType = "SINGLE" | "COUPLE";
export type PersonRole = "PERSON_A" | "PERSON_B";
@@ -25,6 +24,8 @@ export interface PhaseInput {
durationYears: number;
// Cash-Entscheid beim Übergang NACH dieser Phase (einmalige Sonderein-/ausgaben).
cashTransition: CashTransitionData;
// Wurde die Spar-/Verzehrquote dieser Phase einmal bewusst verteilt?
ratesConfirmed?: boolean;
// Gegenstück im Eltern-Szenario (Diff-Grundlage); null im Basisszenario.
sourcePhaseId?: string | null;
}
@@ -83,8 +84,6 @@ export interface PlanInput {
// Kalenderjahr des Planbeginns (Jahr 1) -- nur für die Darstellung, nicht für die
// Berechnung. Optional, damit Berechnungs-Fixtures es nicht setzen müssen.
startYear?: number | null;
// Fortschritt des FPT-Assistenten (sieben Schritte).
assistantProgress?: AssistantProgress | null;
persons: PersonInput[];
phases: PhaseInput[];
elements: ElementInput[];
-2
View File
@@ -34,8 +34,6 @@ const EXEMPT: Record<string, string> = {
"plans/[planId]/actuals/[setId]": "dito (Löschen eines Ist-Satzes)",
"plans/[planId]/analyses": "gespeicherte Analysen sind read-only Momentaufnahmen kein Szenario betroffen",
"plans/[planId]/analyses/[analysisId]": "dito (Öffnen/Löschen einer Analyse)",
"scenarios/[scenarioId]/assistant":
"das Abhaken eines Assistenten-Schritts ist eine Notiz des Benutzers über sich selbst, keine Planänderung eine Version dafür würde die Historie mit inhaltsgleichen Einträgen fluten",
"plans/[planId]/reports": "Berichte sind erzeugte Dokumente sie verändern kein Szenario",
"plans/[planId]/reports/[reportId]": "dito (Herunterladen/Löschen eines Berichts)",
};