Plan-Assistent: abschnittsbasierte Phasenplanung + Vermoegen pro Person
Deploy App / deploy (push) Successful in 58s
Deploy App / deploy (push) Successful in 58s
Rein an der Oberflaeche, keine Aenderung an Berechnung, Datenmodell oder API. Schritt 2 (Lebensphasen) an den fixen Pensionierungszeitpunkten ausgerichtet: - neues reines Modul phaseplan.ts (planSegments) leitet die Abschnitte ab: Erwerb (alle arbeiten) / Misch (eine pensioniert, eine arbeitet) / Pension (alle pensioniert), je mit kurzer Definition - feste Abschnitte (durch Pensionierung begrenzt): beliebig viele Phasen mit +/Papierkorb und eigenem Namen; Live-Summe erzwingt exaktes Aufgehen, "Weiter" bis dahin gesperrt - offener Pensions-Abschnitt: Dauer frei - Anzahl Abschnitte wird abgeleitet (Einzel: 2, Paar unterschiedl. Ret: 3, bereits pensioniert: Misch/Pension zuerst) - neue Zeitachse mit Pensionierungs-Flaggen; Phasen nummeriert und UNTER dem Balken beschriftet (kurze Phasen bleiben lesbar) - behebt den Fehler, dass die Erwerbsphase beliebig ueber die Pensionierung hinaus gesetzt werden konnte Schritt 4 (Vorsorge & Vermoegen) bei Paaren aufgeteilt in Gemeinsam / Person A / Person B; PK und 3a je Person, Wertschriften/ Wohneigentum/Schulden je Bereich. 8 Tests (111 -> 119). SPEZIFIKATION auf 0.15 (Kap. 3.2.8 neu gefasst). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+43
-12
@@ -4,10 +4,10 @@
|
||||
| | |
|
||||
|---|---|
|
||||
| **Dokument** | Funktionale und Technische Spezifikation FPT |
|
||||
| **Version** | 0.14 |
|
||||
| **Version** | 0.15 |
|
||||
| **Datum** | 2026-07-18 |
|
||||
| **Status** | Lebendes Dokument |
|
||||
| **Codestand** | Arbeitsstand nach `c2fb82b` inkl. Phasenkopf-Überarbeitung und Verteil-Werkzeugen (Branch `main`) |
|
||||
| **Codestand** | Arbeitsstand nach `9252f71` inkl. abschnittsbasiertem Plan-Assistenten (Branch `main`) |
|
||||
| **Ersetzt** | `FDD_TDD_FPT.docx` (v1–v5) im Ordner `Info Dateien` – diese sind ab Version 0.1 dieses Dokuments obsolet |
|
||||
| **Geltungsbereich** | Gesamter Code im Verzeichnis `FPT` |
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
|
||||
| Version | Datum | Autor | Änderung |
|
||||
|---|---|---|---|
|
||||
| 0.15 | 2026-07-19 | Claude (Opus 4.8) | **Plan-Assistent überarbeitet** (Schritt 2 und 4). Rein an der Oberfläche, keine Änderung an Berechnung, Datenmodell oder API. **(Schritt 2 – Lebensphasen):** Die Lebenslinie zerfällt neu an den **fixen Pensionierungszeitpunkten** in Abschnitte (neues reines Modul `phaseplan.ts`, `planSegments`): Erwerb (alle arbeiten), Misch (eine pensioniert, eine arbeitet), Pension (alle pensioniert) – jeweils mit **kurzer Definition**. In den durch eine Pensionierung **fest begrenzten** Abschnitten verteilt der Nutzer beliebig viele Phasen mit **+/Papierkorb** und **eigenem Namen je Phase**; eine Live-Summe erzwingt, dass die Phasendauern exakt aufgehen («Weiter» ist bis dahin gesperrt). Der letzte Pensions-Abschnitt ist **offen** (Lebensdauer frei). Die Anzahl Abschnitte wird **abgeleitet** – Einzelplan: 2 (Erwerb, Pension); Paar mit unterschiedlichem Pensionsalter: 3. Neue **Zeitachse** mit Pensionierungs-Flaggen und nummerierter Beschriftung **unter** dem Balken (auch kurze Phasen bleiben lesbar). Behebt den Fehler, dass die Erwerbsphase zuvor beliebig über die Pensionierung hinaus gesetzt werden konnte. **(Schritt 4 – Vorsorge & Vermögen):** bei Paaren aufgeteilt in **Gemeinsam / Person A / Person B**; PK und 3a sind je Person, Wertschriften/Wohneigentum/Schulden je Bereich (gemeinsam oder pro Person). Neue Kapitel 3.2.8 überarbeitet; 8 Tests ergänzt (111 → 119). |
|
||||
| 0.14 | 2026-07-18 | Claude (Opus 4.8) | **Phasenkopf überarbeitet und zwei Verteil-Werkzeuge.** (1) **Zweizeilige Wertdarstellung:** Im Anzeigemodus «Beide» steht der Realwert neu in Klammern in einer **eigenen Zeile** unter dem nominalen Wert statt daneben – im Phasenkopf *und* in den Matrix-Zellen. Der Pfeil wiederholt sich auf der zweiten Zeile, damit der Bezug Start → Ende erhalten bleibt. Nebeneffekt: Die Spalten werden schmaler, wodurch **jede Kennzahl umbruchfrei** (`whitespace-nowrap`) dargestellt werden kann. (2) Die Kennzahl heisst korrekt **«Sparquote»** bzw. **«Verzehrquote»** statt «Quote»/«Verzehr». (3) Neuer Block **«Verfügbares Kapital»** im Phasenkopf (ab Phase 2, nur wenn > 0): der beim Übergang zugeflossene Topf mit «davon verteilt» und «Rest auf Cash». (4) Zwei neue Werkzeuge als eigene Popups: **«Kapital verteilen»** (Zusatzeinlagen in PK/3a/Vermögen, Sonderamortisation, Sofort-Tilgung) und **«Sparquote/Bezug verteilen»** (jährliche Raten), beide mit **Live-Vorschau** über eine erneute `computePlan`-Rechnung im Browser – die angezeigte Wirkung ist dadurch per Konstruktion exakt die spätere, inklusive aller Kappungen. Der Quoten-Dialog weist neben erstem und letztem Jahr die **absolute Quote über die ganze Phase** aus und warnt, wenn die Quote über die Phase sinkt. Neues reines Modul `distribution.ts`. Neue Kapitel 3.6.9, 3.6.10, 9.25; 3.6.1 und 3.6.3 überarbeitet. 8 Tests ergänzt (103 → 111). **Keine Änderung an Berechnung, Datenmodell oder API** – beide Werkzeuge schreiben ausschliesslich bestehende Felder über bestehende Endpunkte. |
|
||||
| 0.13 | 2026-07-18 | Claude (Fable 5) | **UI-Gesamtumbau** – rein an der Oberfläche, Berechnung, Datenmodell und API-Semantik unverändert. **(A) Fundament:** durchgehende **Du-Form** und **echte Umlaute** in allen sichtbaren Texten (inkl. API-Fehlermeldungen); neue UI-Primitiven in `ui.tsx` (Button, Modal mit ESC/Fokus-Falle/Animation, Bestätigungs-Dialog statt `window.confirm`, Toasts statt `alert`, Skeleton-Loader, EmptyState); eigene **Attention-Farbe** (Amber) für offene Entscheide, getrennt vom Akzent; Micro-Interactions mit `prefers-reduced-motion`-Fallback. **(B) Onboarding (Roadmap Nr. 10):** geführter **Plan-Assistent** in fünf Schritten (reine Orchestrierung bestehender Endpunkte, Einkommen bewusst pro Person – räumt die 9.9-Falle aus), **Beispielplan mit einem Klick** (Übergänge absichtlich offen – die Ampel lehrt sich selbst), **interaktive Tour** über die Planansicht, abgeleitete **«Nächste Schritte»**-Karte. **(C) Struktur:** Einzel-Bearbeitungen laufen neu über ein rechtes **Inspector-Panel** statt Modals (Matrix bleibt sichtbar; Klick auf andere Zelle wechselt den Inhalt); **Phasenkopf entschlackt** auf vier Kern-Infos (Rest wohnt in der Detailansicht aus 0.11); Matrix mit eigenem Scrollbereich und **beidachsig fixierten Köpfen**; Sidebar-Gruppen «Meine Pläne»/«Wissen» («So rechnet FPT», Systemparameter); Terminologie-Fix «Szenario-Profil» statt «Plan-Einstellungen»; Aktions-Icons auch ohne Hover sichtbar (Touch). **(D) Extras:** **Sparklines** je Element-Zeile (aus den 0.11-Verlaufswerten, keine Neuberechnung), **Befehls-Palette** (Ctrl/Cmd+K), Ruin-Banner verlinkt auf die Einflussfaktoren. Neue Kapitel 3.2.8, 3.7.6–3.7.9, 9.23, 9.24; 9.17 bereinigt (der `Selection`-Rest und der ProfileMenu-Lint-Fehler sind behoben – `npm run lint` ist erstmals fehlerfrei). Testbestand unverändert 103. |
|
||||
| 0.12 | 2026-07-18 | Claude (Opus 4.8) | **Lesbarkeit der Wasserfälle, Verkaufspreis-Abgleich und Erklärung wirkungsloser Tornado-Treiber.** (1) Die beiden Wasserfälle werden **nicht mehr mit Recharts** gezeichnet, sondern als eigene liegende Darstellung: Verbindungslinien zwischen den Balken, Wertbeschriftung an jedem Schritt, Abschnitts-Überschriften („Am Übergang" / „Innerhalb der Phase") und eine aufklappbare Tabelle mit **laufendem Zwischenstand**. Anlass war, dass die bisherige Darstellung faktisch nicht lesbar war – die Zahlen waren korrekt, die Grafik nicht. (2) Der Restposten beider Brücken wird bei Abweichung neu als **Fehlermeldung** ausgewiesen statt als beiläufige „Rundungsdifferenz"; eine nicht aufgehende Zerlegung ist ein Rechenfehler und kein Schönheitsproblem. (3) **Verkaufspreis einer Immobilie** wird beim Wechsel auf „Verkaufen" neu mit dem **modellierten Verkehrswert** vorbelegt; der Dialog weist Verkehrswert und Abweichung aus und warnt ab 10 % Differenz (Kap. 3.5.8, 9.22). Damit fällt auf, wenn angenommene Wertsteigerung und erwarteter Verkaufspreis nicht zusammenpassen. (4) Der Tornado erklärt neu **Nullbalken** statt sie stumm zu zeigen – insbesondere den Fall, dass die Immobilien-Wertsteigerung bei einem Verkauf nachweislich wirkungslos ist (`ineffectiveReason`, Kap. 4.13.5). Neue Kapitel 3.5.8, 4.13.5, 9.22; 11 Tests ergänzt (92 → 103), darunter die Invariante `residual === 0` über sieben Plankonstellationen. Keine DB-Änderung, keine Änderung an der Berechnung. |
|
||||
@@ -366,14 +367,42 @@ gesetzt. Kalenderjahr eines Planjahrs: `startYear + (Jahr − 1)`.
|
||||
### 3.2.8 Geführter Assistent und Beispielplan
|
||||
|
||||
**Der Plan-Assistent** (Roadmap Nr. 10: «Schritt für Schritt statt leerer Matrix») fragt in
|
||||
fünf Schritten in Alltagssprache: (1) Grundprofil, (2) Lebensphasen – vom Tool anhand des
|
||||
Pensionsalters **vorgeschlagen** (dieselbe Kappungslogik `maxPhaseDuration` wie beim manuellen
|
||||
Anlegen; bei Paaren mit unterschiedlichem Pensionsalter inkl. Übergangsphase), Dauer editierbar,
|
||||
(3) Einkommen und Ausgaben plus Kontostand, (4) Vorsorge und Vermögen als Checkliste (PK, 3a,
|
||||
Wertschriften, Wohneigentum, Schulden – nur Angehaktes fragt Details; die AHV wird automatisch
|
||||
je Person angelegt), (5) Zusammenfassung.
|
||||
fünf Schritten in Alltagssprache: (1) Grundprofil, (2) Lebensphasen, (3) Einkommen und Ausgaben
|
||||
plus Kontostand, (4) Vorsorge und Vermögen, (5) Zusammenfassung.
|
||||
|
||||
Zwei bewusste Entscheide:
|
||||
**Schritt 2 ist an den fixen Pensionierungszeitpunkten ausgerichtet.** Das Pensionsalter jeder
|
||||
Person ist ein Fixpunkt auf der Lebenslinie; dazwischen entstehen Abschnitte mit konstantem
|
||||
Erwerbsstatus (reines Modul `phaseplan.ts`, `planSegments`):
|
||||
|
||||
| Abschnitt | Bedeutung | Länge |
|
||||
|---|---|---|
|
||||
| **Erwerb** | alle arbeiten | fest (bis zur ersten Pensionierung) |
|
||||
| **Misch** | eine Person pensioniert, eine arbeitet | fest (zwischen den Pensionierungen) |
|
||||
| **Pension** | alle pensioniert | **offen** (Lebensdauer frei) |
|
||||
|
||||
Die **Anzahl** Abschnitte wird abgeleitet, nicht vorgegeben: Einzelplan → 2 (Erwerb, Pension);
|
||||
Paar mit gleichem Pensionsalter → 2 (keine Mischzeit); Paar mit unterschiedlichem Pensionsalter
|
||||
→ 3. Ist eine Person bei Planbeginn bereits pensioniert, beginnt die Linie mit einem Misch- oder
|
||||
Pensions-Abschnitt.
|
||||
|
||||
In jedem **fest begrenzten** Abschnitt verteilt der Nutzer beliebig viele Phasen (mit
|
||||
+/Papierkorb und eigenem Namen je Phase); eine **Live-Summe** erzwingt, dass die Phasendauern
|
||||
exakt der festen Länge entsprechen – «Weiter» bleibt gesperrt, bis es aufgeht. Das ist zwingend:
|
||||
Die Berechnung leitet den Phasentyp am Phasenbeginn ab und **kappt jede Phase am nächsten
|
||||
Pensionsereignis** ([2.3](#23-phasentyp--abgeleitet-nicht-gespeichert)) – eine Phase, die eine
|
||||
Pensionierung überspannt, gäbe es nicht. Der frühere Assistent liess die Erwerbsphase beliebig
|
||||
über das Pensionsalter hinaus setzen; das ist damit behoben. Der **offene** Pensions-Abschnitt
|
||||
nimmt beliebige Dauern (Summe = geplante Restlebensdauer).
|
||||
|
||||
Eine **Zeitachse** zeigt den proportionalen Verlauf mit den Pensionierungs-Fixpunkten als
|
||||
Flaggen; die Phasen sind nummeriert und **unter** dem Balken beschriftet, damit auch kurze
|
||||
Phasen lesbar bleiben.
|
||||
|
||||
**Schritt 4 (Vorsorge & Vermögen)** ist bei Paaren in **Gemeinsam / Person A / Person B**
|
||||
aufgeteilt. Pensionskasse und Säule 3a sind immer persönlich (personengebundene Kategorien);
|
||||
Wertschriften, Wohneigentum und Schulden lassen sich gemeinsam **oder** je Person erfassen.
|
||||
|
||||
Zwei bewusste Entscheide bleiben:
|
||||
- **Einkommen wird pro Person erfasst**, nie als «Gemeinsam» – in Paar-Plänen zählt
|
||||
Haushalts-Einkommen nicht für die AHV ([9.9](#99-gemeinsames-einkommen-zählt-bei-paaren-nicht-für-die-ahv));
|
||||
der Assistent räumt diese Falle von Anfang an aus.
|
||||
@@ -2271,6 +2300,7 @@ PlanComputed ← an den Client geliefert
|
||||
| `montecarlo.ts` | Monte-Carlo-Simulation (Sampler + Treiber), Szenario-Vergleich und Element-Gruppierung über die Herkunfts-Kette. Keine I/O, läuft im Browser. |
|
||||
| `sensitivity.ts` | Sensitivitätsanalyse / Tornado: Treiber-Katalog, Parameter-Transformationen, `computeTornado`. Rein, läuft im Browser. |
|
||||
| `distribution.ts` | Kapitaltopf und Quoten-Zerlegung, Anwenden von Entwurfswerten für die Verteil-Werkzeuge (Kap. 3.6.9/3.6.10). Rein. |
|
||||
| `phaseplan.ts` | Ableitung der Lebensabschnitte (Erwerb/Misch/Pension) aus den fixen Pensionierungszeitpunkten für den Assistenten (Kap. 3.2.8). 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 |
|
||||
@@ -2532,7 +2562,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)) |
|
||||
| `PlanWizard` | ~400 | Geführter Plan-Assistent in fünf Schritten ([3.2.8](#328-geführter-assistent-und-beispielplan)) |
|
||||
| `PlanWizard` | ~560 | Geführter Plan-Assistent in fünf Schritten, abschnittsbasierte Phasenplanung ([3.2.8](#328-geführter-assistent-und-beispielplan)) |
|
||||
| `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)) |
|
||||
@@ -2768,10 +2798,11 @@ Include `src/**/*.test.ts`). Es gibt **keine** Komponenten-, API- oder E2E-Tests
|
||||
| `explain.test.ts` | 12 | Verlaufswerte je Element, Vollständigkeit beider Wasserfall-Zerlegungen, Rechenweg-Protokoll, Gültigkeit der Spezifikations-Verweise |
|
||||
| `montecarlo.test.ts` | 13 | Determinismus, Volatilität/Vol-Drag, Böden, Reproduzierbarkeit; Element-Gruppierung und Szenario-Vergleich |
|
||||
| `distribution.test.ts` | 8 | Kapitaltopf und Quoten-Zerlegung gegen die Cash-Brücke; Entwurfswerte anwenden ohne Verlust bestehender Felder |
|
||||
| `phaseplan.test.ts` | 8 | Ableitung der Lebensabschnitte aus den Pensionierungszeitpunkten (Einzel/Paar/bereits pensioniert); letzter Teil immer offen |
|
||||
| `bridges.test.ts` | 10 | Vermögens- und Cash-Brücke gehen über sieben Plankonstellationen ohne Restgrösse auf; Umbuchungen bleiben aus der Vermögensbrücke heraus |
|
||||
| `diff.test.ts` | 9 | Abweichungs-Erkennung gegen das Eltern-Szenario |
|
||||
| `migrations.test.ts` | 1 | spielt alle Migrationen gegen echtes PostgreSQL (PGlite) ein |
|
||||
| **Total** | **111** | |
|
||||
| **Total** | **119** | |
|
||||
|
||||
## 8.2 Testfälle
|
||||
|
||||
@@ -3215,4 +3246,4 @@ werden soll). Das Werkzeug informiert, es bevormundet nicht.
|
||||
|
||||
---
|
||||
|
||||
*Ende der Spezifikation v0.14*
|
||||
*Ende der Spezifikation v0.15*
|
||||
|
||||
+394
-137
@@ -5,21 +5,25 @@
|
||||
// die BESTEHENDEN API-Endpunkte -- reine Orchestrierung, keine Änderung an Berechnung
|
||||
// oder Datenmodell.
|
||||
//
|
||||
// Reihenfolge beim Anlegen (wichtig): erst Phase 1, dann Elemente samt Phase-1-Werten,
|
||||
// dann die Folgephasen -- so belegt die Phasen-Route die Folgephasen korrekt vor.
|
||||
// Schritt 2 (Lebensphasen) ist an den fixen Pensionierungszeitpunkten ausgerichtet: Die
|
||||
// Lebenslinie zerfällt in Abschnitte (Erwerb / Misch / Pension, siehe lib/phaseplan). In den
|
||||
// FESTEN Abschnitten (durch eine Pensionierung begrenzt) verteilt der Nutzer beliebig viele
|
||||
// Phasen, deren Dauer exakt aufgehen muss; der letzte Pensions-Abschnitt ist offen.
|
||||
//
|
||||
// Ehrliche Grenze: Bricht die Sequenz mittendrin ab (Netzfehler), existiert ein Teil-Plan.
|
||||
// Der ist normal weiterbearbeitbar; ein Rollback bräuchte Backend-Unterstützung und ist
|
||||
// bewusst nicht gebaut (siehe SPEZIFIKATION 9.23).
|
||||
// Reihenfolge beim Anlegen (wichtig): erst Phase 1, dann Elemente samt Phase-1-Werten, dann
|
||||
// die Folgephasen -- so belegt die Phasen-Route die Folgephasen korrekt vor.
|
||||
//
|
||||
// Ehrliche Grenze: Bricht die Sequenz mittendrin ab (Netzfehler), existiert ein Teil-Plan
|
||||
// (siehe SPEZIFIKATION 9.23).
|
||||
|
||||
import { useState } from "react";
|
||||
import { ArrowLeft, ArrowRight, Sparkles } from "lucide-react";
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { ArrowLeft, ArrowRight, Flag, Plus, Sparkles, Trash2 } from "lucide-react";
|
||||
import { Modal, Button } from "@/components/ui";
|
||||
import { MoneyField, NumberField, TextField } from "@/components/FormField";
|
||||
import { InfoBubble } from "@/components/InfoBubble";
|
||||
import { PlanProfileFields, emptyProfileDraft, type ProfileDraft } from "@/components/PlanProfileFields";
|
||||
import { api } from "@/lib/api-client";
|
||||
import { maxPhaseDuration } from "@/lib/calculations";
|
||||
import { planSegments, defaultOpenDuration, type PlanSegment, type SegmentType } from "@/lib/phaseplan";
|
||||
import { PILLAR_3A_MAX_ANNUAL } from "@/lib/constants";
|
||||
import type { PhaseData } from "@/lib/elements";
|
||||
|
||||
@@ -28,38 +32,68 @@ interface PhaseDraft {
|
||||
durationYears: number;
|
||||
}
|
||||
|
||||
interface AssetToggles {
|
||||
pk: boolean;
|
||||
p3a: boolean;
|
||||
etf: boolean;
|
||||
realEstate: boolean;
|
||||
debt: boolean;
|
||||
}
|
||||
|
||||
const STEP_TITLES = ["Über dich", "Deine Lebensphasen", "Einkommen & Ausgaben", "Vorsorge & Vermögen", "Fertig"];
|
||||
|
||||
// Vorschlag für die Phasenkette: Erwerbsphase bis zum nächsten Pensionsereignis
|
||||
// (dieselbe Kappungslogik wie beim manuellen Anlegen), danach eine Pensionsphase bis ~90.
|
||||
function suggestPhases(draft: ProfileDraft): PhaseDraft[] {
|
||||
const persons = draft.persons.map((p) => ({ role: p.role, age: p.age, retirementAge: p.retirementAge }));
|
||||
const cap = maxPhaseDuration(persons, 0);
|
||||
const phases: PhaseDraft[] = [];
|
||||
let elapsed = 0;
|
||||
if (cap != null && cap > 0) {
|
||||
phases.push({ name: "Erwerbsphase", durationYears: cap });
|
||||
elapsed = cap;
|
||||
// Bei Paaren mit unterschiedlichem Pensionsalter folgt eine Mischphase bis zur
|
||||
// zweiten Pensionierung.
|
||||
const cap2 = maxPhaseDuration(persons, elapsed);
|
||||
if (cap2 != null && cap2 > 0) {
|
||||
phases.push({ name: "Übergangsphase", durationYears: cap2 });
|
||||
elapsed += cap2;
|
||||
const SEGMENT_META: Record<SegmentType, { label: string; phase: string; def: string; color: string; soft: string }> = {
|
||||
ERWERB: {
|
||||
label: "Erwerb",
|
||||
phase: "Erwerbsphase",
|
||||
def: "Alle arbeiten noch – Einkommen fliesst, Vorsorge wächst.",
|
||||
color: "bg-accent/70 text-accent-fg",
|
||||
soft: "border-accent",
|
||||
},
|
||||
MIXED: {
|
||||
label: "Misch",
|
||||
phase: "Übergangsphase",
|
||||
def: "Eine Person ist pensioniert, die andere arbeitet noch.",
|
||||
color: "bg-accent/40 text-accent-fg",
|
||||
soft: "border-accent",
|
||||
},
|
||||
PENSION: {
|
||||
label: "Pension",
|
||||
phase: "Pensionsphase",
|
||||
def: "Alle sind pensioniert – gelebt wird aus Renten und Vermögen.",
|
||||
color: "bg-accent-soft text-accent-soft-fg",
|
||||
soft: "border-accent",
|
||||
},
|
||||
};
|
||||
|
||||
function personsSignature(draft: ProfileDraft): string {
|
||||
return draft.householdType + "|" + draft.persons.map((p) => `${p.age}/${p.retirementAge}`).join(",");
|
||||
}
|
||||
|
||||
// Eine Phase je Abschnitt als Startpunkt: feste Länge in den begrenzten Abschnitten, eine
|
||||
// sinnvolle Vorgabe im offenen Pensions-Abschnitt.
|
||||
function defaultSegPhases(segments: PlanSegment[], persons: { age: number }[]): PhaseDraft[][] {
|
||||
const typeCount: Record<SegmentType, number> = { ERWERB: 0, MIXED: 0, PENSION: 0 };
|
||||
return segments.map((seg) => {
|
||||
typeCount[seg.type] += 1;
|
||||
const dur = seg.fixedYears ?? defaultOpenDuration(persons, seg.fromYear);
|
||||
return [{ name: SEGMENT_META[seg.type].phase, durationYears: Math.max(1, dur) }];
|
||||
});
|
||||
}
|
||||
const oldest = Math.max(...draft.persons.map((p) => p.age));
|
||||
const pensionYears = Math.max(5, 90 - (oldest + elapsed));
|
||||
phases.push({ name: "Pensionsphase", durationYears: pensionYears });
|
||||
return phases;
|
||||
|
||||
// --- Vermögens-/Vorsorge-Erfassung je Eigentümer-Bereich ---------------------------------
|
||||
|
||||
type Scope = "SELF" | "HOUSEHOLD" | "PERSON_A" | "PERSON_B";
|
||||
type Category = "pk" | "p3a" | "etf" | "re" | "debt";
|
||||
|
||||
interface ScopeAssets {
|
||||
pk: { on: boolean; value: number; contribution: number };
|
||||
p3a: { on: boolean; value: number; contribution: number };
|
||||
etf: { on: boolean; value: number; contribution: number };
|
||||
re: { on: boolean; price: number; mortgage: number; amortization: number; interest: number };
|
||||
debt: { on: boolean; value: number; repayment: number };
|
||||
}
|
||||
|
||||
function emptyScope(): ScopeAssets {
|
||||
return {
|
||||
pk: { on: false, value: 0, contribution: 0 },
|
||||
p3a: { on: false, value: 0, contribution: 0 },
|
||||
etf: { on: false, value: 0, contribution: 0 },
|
||||
re: { on: false, price: 0, mortgage: 0, amortization: 0, interest: 1.5 },
|
||||
debt: { on: false, value: 0, repayment: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
export function PlanWizard({
|
||||
@@ -72,30 +106,47 @@ export function PlanWizard({
|
||||
const [step, setStep] = useState(0);
|
||||
const [planName, setPlanName] = useState("Meine Planung");
|
||||
const [profile, setProfile] = useState<ProfileDraft>(emptyProfileDraft);
|
||||
const [phases, setPhases] = useState<PhaseDraft[] | null>(null);
|
||||
// Einkommen bewusst PRO PERSON: In Paar-Plänen zählt "Gemeinsam" nicht für die
|
||||
// AHV-Rente (SPEZIFIKATION 9.9) -- der Assistent räumt diese Falle von Anfang an aus.
|
||||
|
||||
// Abschnitte der Lebenslinie und die je Abschnitt geplanten Phasen (parallel indexiert).
|
||||
const segments = useMemo(() => planSegments(profile.persons), [profile.persons]);
|
||||
const [segPhases, setSegPhases] = useState<PhaseDraft[][]>([]);
|
||||
const segSig = useRef<string>("");
|
||||
|
||||
// Einkommen bewusst PRO PERSON: In Paar-Plänen zählt "Gemeinsam" nicht für die AHV-Rente
|
||||
// (SPEZIFIKATION 9.9) -- der Assistent räumt diese Falle von Anfang an aus.
|
||||
const [incomes, setIncomes] = useState<Record<string, number>>({ PERSON_A: 0 });
|
||||
const [expenses, setExpenses] = useState(0);
|
||||
const [initialCash, setInitialCash] = useState(0);
|
||||
const [toggles, setToggles] = useState<AssetToggles>({ pk: false, p3a: false, etf: false, realEstate: false, debt: false });
|
||||
const [pk, setPk] = useState({ value: 0, contribution: 0 });
|
||||
const [p3a, setP3a] = useState({ value: 0, contribution: 0 });
|
||||
const [etf, setEtf] = useState({ value: 0, contribution: 0 });
|
||||
const [re, setRe] = useState({ price: 0, mortgage: 0, amortization: 0, interest: 1.5 });
|
||||
const [debt, setDebt] = useState({ value: 0, repayment: 0 });
|
||||
|
||||
const [assets, setAssets] = useState<Record<Scope, ScopeAssets>>({
|
||||
SELF: emptyScope(),
|
||||
HOUSEHOLD: emptyScope(),
|
||||
PERSON_A: emptyScope(),
|
||||
PERSON_B: emptyScope(),
|
||||
});
|
||||
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const isCouple = profile.householdType === "COUPLE";
|
||||
const personLabel = (role: string) => {
|
||||
const p = profile.persons.find((x) => x.role === role);
|
||||
return p?.name?.trim() || (role === "PERSON_A" ? "Person A" : "Person B");
|
||||
};
|
||||
|
||||
function setScope(scope: Scope, cat: Category, patch: Record<string, number | boolean>) {
|
||||
setAssets((prev) => ({ ...prev, [scope]: { ...prev[scope], [cat]: { ...prev[scope][cat], ...patch } } }));
|
||||
}
|
||||
|
||||
function goNext() {
|
||||
if (step === 0) {
|
||||
// Phasenvorschlag aus dem (evtl. geänderten) Profil neu ableiten.
|
||||
setPhases(suggestPhases(profile));
|
||||
// Abschnitte neu ableiten; Phasen nur zurücksetzen, wenn sich das Profil strukturell
|
||||
// geändert hat (sonst blieben Bearbeitungen des Nutzers erhalten).
|
||||
const sig = personsSignature(profile);
|
||||
if (sig !== segSig.current || segPhases.length === 0) {
|
||||
setSegPhases(defaultSegPhases(segments, profile.persons));
|
||||
segSig.current = sig;
|
||||
}
|
||||
const inc: Record<string, number> = {};
|
||||
for (const p of profile.persons) inc[p.role] = incomes[p.role] ?? 0;
|
||||
setIncomes(inc);
|
||||
@@ -103,6 +154,40 @@ export function PlanWizard({
|
||||
setStep((s) => Math.min(s + 1, STEP_TITLES.length - 1));
|
||||
}
|
||||
|
||||
// --- Validierung Schritt 1 ---
|
||||
const segSums = segPhases.map((phs) => phs.reduce((s, p) => s + Math.max(0, p.durationYears), 0));
|
||||
const step1Valid =
|
||||
segPhases.length === segments.length &&
|
||||
segments.every((seg, i) => {
|
||||
const phs = segPhases[i] ?? [];
|
||||
if (phs.length === 0) return false;
|
||||
if (phs.some((p) => p.durationYears < 1)) return false;
|
||||
return seg.fixedYears === null ? segSums[i] >= 1 : segSums[i] === seg.fixedYears;
|
||||
});
|
||||
|
||||
// Flache, geordnete Phasenliste (für Vorschau, Timeline und Anlegen).
|
||||
const flatPhases: PhaseDraft[] = segPhases.flat();
|
||||
|
||||
function updatePhase(segIdx: number, phaseIdx: number, patch: Partial<PhaseDraft>) {
|
||||
setSegPhases((prev) =>
|
||||
prev.map((phs, i) => (i === segIdx ? phs.map((p, j) => (j === phaseIdx ? { ...p, ...patch } : p)) : phs))
|
||||
);
|
||||
}
|
||||
function addPhase(segIdx: number) {
|
||||
const seg = segments[segIdx];
|
||||
const rest = seg.fixedYears === null ? 5 : Math.max(0, seg.fixedYears - segSums[segIdx]);
|
||||
setSegPhases((prev) =>
|
||||
prev.map((phs, i) =>
|
||||
i === segIdx
|
||||
? [...phs, { name: `${SEGMENT_META[seg.type].phase} ${phs.length + 1}`, durationYears: Math.max(1, rest) }]
|
||||
: phs
|
||||
)
|
||||
);
|
||||
}
|
||||
function removePhase(segIdx: number, phaseIdx: number) {
|
||||
setSegPhases((prev) => prev.map((phs, i) => (i === segIdx ? phs.filter((_, j) => j !== phaseIdx) : phs)));
|
||||
}
|
||||
|
||||
async function create() {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
@@ -117,10 +202,9 @@ export function PlanWizard({
|
||||
const sid = scenario.id;
|
||||
if (initialCash > 0) await api.patch(`/api/scenarios/${sid}`, { initialCash });
|
||||
|
||||
const phaseList = phases ?? suggestPhases(profile);
|
||||
const first = await api.post<{ phase: { id: string } }>(`/api/scenarios/${sid}/phases`, {
|
||||
name: phaseList[0].name,
|
||||
durationYears: phaseList[0].durationYears,
|
||||
name: flatPhases[0].name,
|
||||
durationYears: flatPhases[0].durationYears,
|
||||
});
|
||||
const firstPhaseId = first.phase.id;
|
||||
|
||||
@@ -139,42 +223,53 @@ export function PlanWizard({
|
||||
}
|
||||
if (expenses > 0) await addEl("EXPENSE", "Lebenshaltung", "HOUSEHOLD", { amount: expenses, teuerungsausgleich: 0 });
|
||||
for (const p of profile.persons) {
|
||||
await addEl("AHV", profile.persons.length > 1 ? `AHV ${personLabel(p.role)}` : "AHV", p.role, { gapYears: 0 });
|
||||
await addEl("AHV", isCouple ? `AHV ${personLabel(p.role)}` : "AHV", p.role, { gapYears: 0 });
|
||||
}
|
||||
if (toggles.pk) {
|
||||
await addEl("PENSION_FUND", "Pensionskasse", "PERSON_A", {
|
||||
currentValue: pk.value,
|
||||
annualContribution: pk.contribution,
|
||||
|
||||
// Vermögen/Vorsorge je Eigentümer-Bereich anlegen.
|
||||
for (const scope of renderedScopes) {
|
||||
const a = assets[scope.id];
|
||||
const suffix = scope.nameSuffix; // z. B. " Anna" oder " (gemeinsam)"
|
||||
if (scope.showVorsorge) {
|
||||
if (a.pk.on) {
|
||||
await addEl("PENSION_FUND", `Pensionskasse${suffix}`, scope.vorsorgeRole!, {
|
||||
currentValue: a.pk.value,
|
||||
annualContribution: a.pk.contribution,
|
||||
expectedReturn: 1.5,
|
||||
});
|
||||
}
|
||||
if (toggles.p3a) {
|
||||
await addEl("PILLAR_3A", "Säule 3a", "PERSON_A", {
|
||||
currentValue: p3a.value,
|
||||
annualContribution: Math.min(p3a.contribution, PILLAR_3A_MAX_ANNUAL),
|
||||
if (a.p3a.on) {
|
||||
await addEl("PILLAR_3A", `Säule 3a${suffix}`, scope.vorsorgeRole!, {
|
||||
currentValue: a.p3a.value,
|
||||
annualContribution: Math.min(a.p3a.contribution, PILLAR_3A_MAX_ANNUAL),
|
||||
expectedReturn: 2,
|
||||
});
|
||||
}
|
||||
if (toggles.etf) {
|
||||
await addEl("OTHER_ASSET", "Wertschriften / ETF", "HOUSEHOLD", {
|
||||
startValue: etf.value,
|
||||
annualContribution: etf.contribution,
|
||||
}
|
||||
if (a.etf.on) {
|
||||
await addEl("OTHER_ASSET", `Wertschriften${suffix}`, scope.otherRole, {
|
||||
startValue: a.etf.value,
|
||||
annualContribution: a.etf.contribution,
|
||||
expectedReturn: 5,
|
||||
});
|
||||
}
|
||||
if (toggles.realEstate) {
|
||||
await addEl("REAL_ESTATE", "Eigenheim", "HOUSEHOLD", {
|
||||
purchasePrice: re.price,
|
||||
mortgage: re.mortgage,
|
||||
amortization: re.amortization,
|
||||
interestRate: re.interest,
|
||||
if (a.re.on) {
|
||||
await addEl("REAL_ESTATE", `Wohneigentum${suffix}`, scope.otherRole, {
|
||||
purchasePrice: a.re.price,
|
||||
mortgage: a.re.mortgage,
|
||||
amortization: a.re.amortization,
|
||||
interestRate: a.re.interest,
|
||||
});
|
||||
}
|
||||
if (a.debt.on) {
|
||||
await addEl("OTHER_DEBT", `Schulden${suffix}`, scope.otherRole, {
|
||||
startValue: a.debt.value,
|
||||
annualRepayment: a.debt.repayment,
|
||||
});
|
||||
}
|
||||
if (toggles.debt) {
|
||||
await addEl("OTHER_DEBT", "Kredit", "HOUSEHOLD", { startValue: debt.value, annualRepayment: debt.repayment });
|
||||
}
|
||||
|
||||
for (const ph of phaseList.slice(1)) {
|
||||
for (const ph of flatPhases.slice(1)) {
|
||||
await api.post(`/api/scenarios/${sid}/phases`, { name: ph.name, durationYears: ph.durationYears });
|
||||
}
|
||||
|
||||
@@ -188,7 +283,29 @@ export function PlanWizard({
|
||||
}
|
||||
}
|
||||
|
||||
const workingPersons = profile.persons.filter((p) => p.age < p.retirementAge);
|
||||
// Welche Eigentümer-Bereiche in Schritt 4 gezeigt werden.
|
||||
const renderedScopes: {
|
||||
id: Scope;
|
||||
label: string;
|
||||
nameSuffix: string;
|
||||
showVorsorge: boolean;
|
||||
vorsorgeRole: string | null;
|
||||
otherRole: string;
|
||||
cats: Category[];
|
||||
}[] = isCouple
|
||||
? [
|
||||
{ id: "HOUSEHOLD", label: "Gemeinsam", nameSuffix: " (gemeinsam)", showVorsorge: false, vorsorgeRole: null, otherRole: "HOUSEHOLD", cats: ["etf", "re", "debt"] },
|
||||
{ id: "PERSON_A", label: personLabel("PERSON_A"), nameSuffix: ` ${personLabel("PERSON_A")}`, showVorsorge: true, vorsorgeRole: "PERSON_A", otherRole: "PERSON_A", cats: ["pk", "p3a", "etf", "re", "debt"] },
|
||||
{ id: "PERSON_B", label: personLabel("PERSON_B"), nameSuffix: ` ${personLabel("PERSON_B")}`, showVorsorge: true, vorsorgeRole: "PERSON_B", otherRole: "PERSON_B", cats: ["pk", "p3a", "etf", "re", "debt"] },
|
||||
]
|
||||
: [
|
||||
{ id: "SELF", label: "Deine Angaben", nameSuffix: "", showVorsorge: true, vorsorgeRole: "PERSON_A", otherRole: "HOUSEHOLD", cats: ["pk", "p3a", "etf", "re", "debt"] },
|
||||
];
|
||||
|
||||
const enabledAssetCount = renderedScopes.reduce((n, sc) => {
|
||||
const a = assets[sc.id];
|
||||
return n + sc.cats.filter((c) => a[c].on).length;
|
||||
}, 0);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
@@ -197,7 +314,6 @@ export function PlanWizard({
|
||||
onClose={onClose}
|
||||
wide
|
||||
>
|
||||
{/* Fortschritt */}
|
||||
<div className="flex gap-1">
|
||||
{STEP_TITLES.map((_, i) => (
|
||||
<div key={i} className={`h-1 flex-1 rounded-full ${i <= step ? "bg-accent" : "bg-surface-2"}`} />
|
||||
@@ -214,44 +330,87 @@ export function PlanWizard({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 1 && phases && (
|
||||
<div className="flex flex-col gap-3">
|
||||
{step === 1 && (
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="text-sm text-muted">
|
||||
Deine Planung besteht aus <strong className="text-fg">Lebensphasen</strong> – Abschnitten, in denen deine
|
||||
Finanzen ähnlich laufen. Das Tool schlägt sie dir anhand deines Pensionsalters vor; die Dauer kannst du
|
||||
anpassen.
|
||||
Deine Planung besteht aus <strong className="text-fg">Lebensphasen</strong>. Das Pensionsalter setzt feste
|
||||
Grenzen auf der Lebenslinie – dazwischen kannst du beliebig viele Phasen einplanen.
|
||||
</p>
|
||||
{/* Mini-Vorschau der Kette */}
|
||||
<div className="flex w-full overflow-hidden rounded-lg border border-border">
|
||||
{phases.map((ph, i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{ width: `${(ph.durationYears / phases.reduce((s, x) => s + x.durationYears, 0)) * 100}%` }}
|
||||
className={`px-2 py-1.5 text-[10px] font-semibold ${i === 0 && workingPersons.length > 0 ? "bg-accent/70 text-accent-fg" : "bg-accent-soft text-accent-soft-fg"} ${i > 0 ? "border-l border-border" : ""}`}
|
||||
|
||||
<WizardTimeline segments={segments} segPhases={segPhases} profile={profile} />
|
||||
|
||||
{segments.map((seg, segIdx) => {
|
||||
const meta = SEGMENT_META[seg.type];
|
||||
const sum = segSums[segIdx] ?? 0;
|
||||
const fixed = seg.fixedYears;
|
||||
const ok = fixed === null ? sum >= 1 : sum === fixed;
|
||||
return (
|
||||
<div key={segIdx} className={`rounded-xl border-l-4 border border-border ${meta.soft} bg-surface-2 p-3`}>
|
||||
<div className="mb-1 flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm font-semibold text-fg">
|
||||
Teil {segIdx + 1}: {meta.label}
|
||||
</span>
|
||||
{fixed === null ? (
|
||||
<span className="rounded bg-surface px-1.5 py-0.5 text-[10px] font-medium text-muted">
|
||||
Dauer frei · aktuell {sum} J.
|
||||
</span>
|
||||
) : (
|
||||
<span
|
||||
className={`rounded px-1.5 py-0.5 text-[10px] font-semibold ${
|
||||
ok ? "bg-success/15 text-success" : "bg-danger-soft text-danger"
|
||||
}`}
|
||||
>
|
||||
<span className="block truncate">{ph.name}</span>
|
||||
<span className="opacity-80">{ph.durationYears} J.</span>
|
||||
Summe {sum} / {fixed} Jahre{ok ? " ✓" : ""}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
{phases.map((ph, i) => (
|
||||
<p className="mb-2 text-xs text-muted">{meta.def}</p>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
{(segPhases[segIdx] ?? []).map((ph, phaseIdx) => (
|
||||
<div key={phaseIdx} className="grid grid-cols-1 gap-2 sm:grid-cols-[1fr_9rem_auto] sm:items-end">
|
||||
<TextField
|
||||
label={phaseIdx === 0 ? "Bezeichnung" : ""}
|
||||
value={ph.name}
|
||||
onChange={(v) => updatePhase(segIdx, phaseIdx, { name: v })}
|
||||
/>
|
||||
<NumberField
|
||||
key={i}
|
||||
label={`${ph.name} – Dauer (Jahre)`}
|
||||
label={phaseIdx === 0 ? "Dauer (Jahre)" : ""}
|
||||
value={ph.durationYears}
|
||||
min={1}
|
||||
max={80}
|
||||
onChange={(v) =>
|
||||
setPhases((prev) => prev!.map((x, j) => (j === i ? { ...x, durationYears: Math.max(1, Math.round(v)) } : x)))
|
||||
}
|
||||
onChange={(v) => updatePhase(segIdx, phaseIdx, { durationYears: Math.max(1, Math.round(v)) })}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Phase entfernen"
|
||||
disabled={(segPhases[segIdx]?.length ?? 0) <= 1}
|
||||
onClick={() => removePhase(segIdx, phaseIdx)}
|
||||
className="mb-0.5 flex h-8 w-8 items-center justify-center rounded-lg border border-border text-faint transition-colors hover:border-danger hover:bg-danger-soft hover:text-danger disabled:opacity-30"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-[11px] text-faint">
|
||||
Eine Phase darf keine Pensionierung überspannen – die Dauer der Erwerbsphase ist deshalb ans Pensionsalter
|
||||
gekoppelt und wird beim Anlegen automatisch begrenzt.
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => addPhase(segIdx)}
|
||||
className="mt-2 inline-flex items-center gap-1 rounded-lg border border-dashed border-accent px-2.5 py-1 text-xs font-medium text-accent transition-colors hover:bg-accent-soft"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" /> Phase hinzufügen
|
||||
</button>
|
||||
|
||||
{fixed !== null && !ok && (
|
||||
<p className="mt-2 text-[11px] text-danger">
|
||||
Dieser Teil ist durch die Pensionierung auf genau {fixed} Jahre festgelegt. Die Phasendauern müssen
|
||||
zusammen {fixed} ergeben (aktuell {sum}).
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -283,61 +442,80 @@ export function PlanWizard({
|
||||
)}
|
||||
|
||||
{step === 3 && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="text-sm text-muted">
|
||||
Was besitzt du? Hake an, was auf dich zutrifft – nur das Angehakte fragt nach Details.
|
||||
{profile.persons.length > 1 && " Vorsorge (PK/3a) wird Person A zugeordnet – weitere Konten kannst du später ergänzen."}
|
||||
Was besitzt ihr? Hake an, was zutrifft – nur das Angehakte fragt nach Details.
|
||||
{isCouple && " Pensionskasse und Säule 3a sind immer persönlich; Wertschriften, Wohneigentum und Schulden kannst du gemeinsam oder je Person erfassen."}
|
||||
</p>
|
||||
{renderedScopes.map((sc) => (
|
||||
<div key={sc.id} className="rounded-xl border border-border p-3">
|
||||
{isCouple && (
|
||||
<div className="mb-2 text-xs font-semibold uppercase tracking-wide text-faint">{sc.label}</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-2">
|
||||
{sc.cats.includes("pk") && (
|
||||
<WizardToggle
|
||||
label="Pensionskasse"
|
||||
help="Dein PK-Guthaben laut Vorsorgeausweis und die jährlichen Einzahlungen (Arbeitnehmer + Arbeitgeber)."
|
||||
checked={toggles.pk}
|
||||
onChange={(v) => setToggles((t) => ({ ...t, pk: v }))}
|
||||
help="PK-Guthaben laut Vorsorgeausweis und die jährlichen Einzahlungen (Arbeitnehmer + Arbeitgeber)."
|
||||
checked={assets[sc.id].pk.on}
|
||||
onChange={(v) => setScope(sc.id, "pk", { on: v })}
|
||||
>
|
||||
<MoneyField label="Aktuelles Guthaben (CHF)" value={pk.value} onChange={(v) => setPk((s) => ({ ...s, value: v }))} />
|
||||
<MoneyField label="Einzahlung pro Jahr (CHF)" value={pk.contribution} onChange={(v) => setPk((s) => ({ ...s, contribution: v }))} />
|
||||
<MoneyField label="Aktuelles Guthaben (CHF)" value={assets[sc.id].pk.value} onChange={(v) => setScope(sc.id, "pk", { value: v })} />
|
||||
<MoneyField label="Einzahlung pro Jahr (CHF)" value={assets[sc.id].pk.contribution} onChange={(v) => setScope(sc.id, "pk", { contribution: v })} />
|
||||
</WizardToggle>
|
||||
)}
|
||||
{sc.cats.includes("p3a") && (
|
||||
<WizardToggle
|
||||
label="Säule 3a"
|
||||
help={`Dein 3a-Guthaben und die jährliche Einzahlung (max. ${PILLAR_3A_MAX_ANNUAL.toLocaleString("de-CH")} CHF).`}
|
||||
checked={toggles.p3a}
|
||||
onChange={(v) => setToggles((t) => ({ ...t, p3a: v }))}
|
||||
help={`3a-Guthaben und die jährliche Einzahlung (max. ${PILLAR_3A_MAX_ANNUAL.toLocaleString("de-CH")} CHF).`}
|
||||
checked={assets[sc.id].p3a.on}
|
||||
onChange={(v) => setScope(sc.id, "p3a", { on: v })}
|
||||
>
|
||||
<MoneyField label="Aktuelles Guthaben (CHF)" value={p3a.value} onChange={(v) => setP3a((s) => ({ ...s, value: v }))} />
|
||||
<MoneyField label="Einzahlung pro Jahr (CHF)" value={p3a.contribution} max={PILLAR_3A_MAX_ANNUAL} onChange={(v) => setP3a((s) => ({ ...s, contribution: v }))} />
|
||||
<MoneyField label="Aktuelles Guthaben (CHF)" value={assets[sc.id].p3a.value} onChange={(v) => setScope(sc.id, "p3a", { value: v })} />
|
||||
<MoneyField label="Einzahlung pro Jahr (CHF)" value={assets[sc.id].p3a.contribution} max={PILLAR_3A_MAX_ANNUAL} onChange={(v) => setScope(sc.id, "p3a", { contribution: v })} />
|
||||
</WizardToggle>
|
||||
)}
|
||||
{sc.cats.includes("etf") && (
|
||||
<WizardToggle
|
||||
label="Wertschriften / ETF / Sparen"
|
||||
help="Depots, Fonds, Sparkonten – alles, was Rendite bringt und nicht Vorsorge ist."
|
||||
checked={toggles.etf}
|
||||
onChange={(v) => setToggles((t) => ({ ...t, etf: v }))}
|
||||
checked={assets[sc.id].etf.on}
|
||||
onChange={(v) => setScope(sc.id, "etf", { on: v })}
|
||||
>
|
||||
<MoneyField label="Aktueller Wert (CHF)" value={etf.value} onChange={(v) => setEtf((s) => ({ ...s, value: v }))} />
|
||||
<MoneyField label="Sparbeitrag pro Jahr (CHF)" value={etf.contribution} onChange={(v) => setEtf((s) => ({ ...s, contribution: v }))} />
|
||||
<MoneyField label="Aktueller Wert (CHF)" value={assets[sc.id].etf.value} onChange={(v) => setScope(sc.id, "etf", { value: v })} />
|
||||
<MoneyField label="Sparbeitrag pro Jahr (CHF)" value={assets[sc.id].etf.contribution} onChange={(v) => setScope(sc.id, "etf", { contribution: v })} />
|
||||
</WizardToggle>
|
||||
)}
|
||||
{sc.cats.includes("re") && (
|
||||
<WizardToggle
|
||||
label="Wohneigentum"
|
||||
help="Kaufpreis, Hypothek und jährliche Amortisation gemäss Hypothekarvertrag."
|
||||
checked={toggles.realEstate}
|
||||
onChange={(v) => setToggles((t) => ({ ...t, realEstate: v }))}
|
||||
checked={assets[sc.id].re.on}
|
||||
onChange={(v) => setScope(sc.id, "re", { on: v })}
|
||||
>
|
||||
<MoneyField label="Kaufpreis (CHF)" value={re.price} onChange={(v) => setRe((s) => ({ ...s, price: v }))} />
|
||||
<MoneyField label="Hypothek (CHF)" value={re.mortgage} onChange={(v) => setRe((s) => ({ ...s, mortgage: v }))} />
|
||||
<MoneyField label="Amortisation pro Jahr (CHF)" value={re.amortization} onChange={(v) => setRe((s) => ({ ...s, amortization: v }))} />
|
||||
<NumberField label="Hypothekarzins (%)" step={0.1} value={re.interest} onChange={(v) => setRe((s) => ({ ...s, interest: v }))} />
|
||||
<MoneyField label="Kaufpreis (CHF)" value={assets[sc.id].re.price} onChange={(v) => setScope(sc.id, "re", { price: v })} />
|
||||
<MoneyField label="Hypothek (CHF)" value={assets[sc.id].re.mortgage} onChange={(v) => setScope(sc.id, "re", { mortgage: v })} />
|
||||
<MoneyField label="Amortisation pro Jahr (CHF)" value={assets[sc.id].re.amortization} onChange={(v) => setScope(sc.id, "re", { amortization: v })} />
|
||||
<NumberField label="Hypothekarzins (%)" step={0.1} value={assets[sc.id].re.interest} onChange={(v) => setScope(sc.id, "re", { interest: v })} />
|
||||
</WizardToggle>
|
||||
)}
|
||||
{sc.cats.includes("debt") && (
|
||||
<WizardToggle
|
||||
label="Schulden / Kredite"
|
||||
help="Privatkredite, Autoleasing-Restwert und Ähnliches – ohne die Hypothek (die gehört zum Wohneigentum)."
|
||||
checked={toggles.debt}
|
||||
onChange={(v) => setToggles((t) => ({ ...t, debt: v }))}
|
||||
checked={assets[sc.id].debt.on}
|
||||
onChange={(v) => setScope(sc.id, "debt", { on: v })}
|
||||
>
|
||||
<MoneyField label="Restschuld (CHF)" value={debt.value} onChange={(v) => setDebt((s) => ({ ...s, value: v }))} />
|
||||
<MoneyField label="Rückzahlung pro Jahr (CHF)" value={debt.repayment} onChange={(v) => setDebt((s) => ({ ...s, repayment: v }))} />
|
||||
<MoneyField label="Restschuld (CHF)" value={assets[sc.id].debt.value} onChange={(v) => setScope(sc.id, "debt", { value: v })} />
|
||||
<MoneyField label="Rückzahlung pro Jahr (CHF)" value={assets[sc.id].debt.repayment} onChange={(v) => setScope(sc.id, "debt", { repayment: v })} />
|
||||
</WizardToggle>
|
||||
<p className="mt-1 text-[11px] text-faint">
|
||||
Die AHV wird automatisch für {profile.persons.length > 1 ? "beide Personen" : "dich"} angelegt – die Rente
|
||||
berechnet das Tool aus deinem Einkommen.
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<p className="text-[11px] text-faint">
|
||||
Die AHV wird automatisch für {isCouple ? "beide Personen" : "dich"} angelegt – die Rente berechnet das Tool
|
||||
aus dem Einkommen.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -347,15 +525,11 @@ export function PlanWizard({
|
||||
<p className="text-sm text-muted">Das wird angelegt:</p>
|
||||
<ul className="flex flex-col gap-1.5 rounded-xl border border-border bg-surface-2 p-4 text-sm text-fg">
|
||||
<li>📋 Plan «{planName.trim() || "Meine Planung"}» mit Basisszenario</li>
|
||||
<li>📅 {(phases ?? []).map((p) => `${p.name} (${p.durationYears} J.)`).join(" → ")}</li>
|
||||
<li>📅 {flatPhases.map((p) => `${p.name} (${p.durationYears} J.)`).join(" → ")}</li>
|
||||
<li>
|
||||
💰 {profile.persons.filter((p) => (incomes[p.role] ?? 0) > 0).length > 0 ? "Einkommen, " : ""}
|
||||
{expenses > 0 ? "Ausgaben, " : ""}AHV
|
||||
{toggles.pk ? ", Pensionskasse" : ""}
|
||||
{toggles.p3a ? ", Säule 3a" : ""}
|
||||
{toggles.etf ? ", Wertschriften" : ""}
|
||||
{toggles.realEstate ? ", Wohneigentum" : ""}
|
||||
{toggles.debt ? ", Schulden" : ""}
|
||||
{enabledAssetCount > 0 ? ` + ${enabledAssetCount} Vermögens-/Vorsorgeposten` : ""}
|
||||
</li>
|
||||
</ul>
|
||||
<p className="text-xs text-muted">
|
||||
@@ -379,7 +553,7 @@ export function PlanWizard({
|
||||
</Button>
|
||||
)}
|
||||
{step < STEP_TITLES.length - 1 ? (
|
||||
<Button onClick={goNext}>
|
||||
<Button onClick={goNext} disabled={step === 1 && !step1Valid}>
|
||||
Weiter <ArrowRight className="h-4 w-4" />
|
||||
</Button>
|
||||
) : (
|
||||
@@ -393,6 +567,89 @@ export function PlanWizard({
|
||||
);
|
||||
}
|
||||
|
||||
// Zeitachse: proportionaler Balken mit nummerierten Phasen; Pensionierungs-Fixpunkte als
|
||||
// Flaggen darüber; die Beschriftung steht als nummerierte Liste UNTER dem Balken, damit auch
|
||||
// kurze Phasen sauber lesbar sind.
|
||||
function WizardTimeline({
|
||||
segments,
|
||||
segPhases,
|
||||
profile,
|
||||
}: {
|
||||
segments: PlanSegment[];
|
||||
segPhases: PhaseDraft[][];
|
||||
profile: ProfileDraft;
|
||||
}) {
|
||||
const flat = segments.flatMap((seg, segIdx) =>
|
||||
(segPhases[segIdx] ?? []).map((ph) => ({ ...ph, type: seg.type }))
|
||||
);
|
||||
const total = flat.reduce((s, p) => s + Math.max(1, p.durationYears), 0);
|
||||
if (total <= 0 || flat.length === 0) return null;
|
||||
|
||||
const minAge = Math.min(...profile.persons.map((p) => p.age));
|
||||
const markers = profile.persons
|
||||
.map((p) => ({ label: p.name?.trim() || (p.role === "PERSON_A" ? "A" : "B"), year: Math.max(0, p.retirementAge - p.age), age: p.retirementAge, role: p.role }))
|
||||
.filter((m) => m.year > 0 && m.year < total);
|
||||
|
||||
const durs = flat.map((ph) => Math.max(1, ph.durationYears));
|
||||
const numbered = flat.map((ph, i) => {
|
||||
const from = durs.slice(0, i).reduce((s, d) => s + d, 0);
|
||||
return { ...ph, index: i + 1, from, to: from + durs[i] };
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-surface p-3">
|
||||
<div className="relative pt-5">
|
||||
{/* Pensionierungs-Fixpunkte */}
|
||||
{markers.map((m) => (
|
||||
<div
|
||||
key={m.role}
|
||||
className="absolute top-0 flex -translate-x-1/2 flex-col items-center"
|
||||
style={{ left: `${(m.year / total) * 100}%` }}
|
||||
title={`${m.label}: Pensionierung mit ${m.age}`}
|
||||
>
|
||||
<span className="flex items-center gap-0.5 whitespace-nowrap text-[10px] font-medium text-accent">
|
||||
<Flag className="h-3 w-3" fill="var(--accent)" /> {m.label} · {m.age}
|
||||
</span>
|
||||
<div className="h-2 w-px bg-accent" />
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Balken mit nummerierten Segmenten */}
|
||||
<div className="flex w-full overflow-hidden rounded-lg border border-border">
|
||||
{numbered.map((ph, i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{ width: `${(Math.max(1, ph.durationYears) / total) * 100}%` }}
|
||||
title={`${ph.name} · ${ph.durationYears} J.`}
|
||||
className={`flex items-center justify-center py-1.5 text-[10px] font-bold ${SEGMENT_META[ph.type].color} ${i > 0 ? "border-l border-border/50" : ""}`}
|
||||
>
|
||||
{ph.index}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Alters-Achse */}
|
||||
<div className="mt-0.5 flex justify-between text-[10px] text-faint">
|
||||
<span>{minAge} J.</span>
|
||||
<span>{minAge + total} J.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Beschriftung unter dem Balken */}
|
||||
<div className="mt-2 flex flex-wrap gap-x-3 gap-y-1 text-[11px]">
|
||||
{numbered.map((ph, i) => (
|
||||
<span key={i} className="inline-flex items-center gap-1 whitespace-nowrap text-muted">
|
||||
<span className="flex h-4 w-4 items-center justify-center rounded-full bg-surface-2 text-[9px] font-bold text-fg">
|
||||
{ph.index}
|
||||
</span>
|
||||
{ph.name} · {ph.durationYears} J.
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WizardToggle({
|
||||
label,
|
||||
help,
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { planSegments, defaultOpenDuration } from "@/lib/phaseplan";
|
||||
|
||||
describe("planSegments", () => {
|
||||
it("Einzelperson: genau zwei Teile (Erwerb fix, Pension offen)", () => {
|
||||
const segs = planSegments([{ age: 45, retirementAge: 65 }]);
|
||||
expect(segs).toEqual([
|
||||
{ type: "ERWERB", fromYear: 0, fixedYears: 20 },
|
||||
{ type: "PENSION", fromYear: 20, fixedYears: null },
|
||||
]);
|
||||
});
|
||||
|
||||
it("Paar mit gleichem Pensionsalter: zwei Teile, KEINE Mischphase", () => {
|
||||
const segs = planSegments([
|
||||
{ age: 40, retirementAge: 65 },
|
||||
{ age: 40, retirementAge: 65 },
|
||||
]);
|
||||
expect(segs.map((s) => s.type)).toEqual(["ERWERB", "PENSION"]);
|
||||
expect(segs[0].fixedYears).toBe(25);
|
||||
expect(segs[1].fixedYears).toBeNull();
|
||||
});
|
||||
|
||||
it("Paar mit unterschiedlichem Pensionsalter: drei Teile (Erwerb, Misch, Pension)", () => {
|
||||
const segs = planSegments([
|
||||
{ age: 45, retirementAge: 65 }, // Ret in 20 J.
|
||||
{ age: 50, retirementAge: 62 }, // Ret in 12 J.
|
||||
]);
|
||||
expect(segs).toEqual([
|
||||
{ type: "ERWERB", fromYear: 0, fixedYears: 12 },
|
||||
{ type: "MIXED", fromYear: 12, fixedYears: 8 },
|
||||
{ type: "PENSION", fromYear: 20, fixedYears: null },
|
||||
]);
|
||||
});
|
||||
|
||||
it("Eine Person bei Planbeginn bereits pensioniert: Misch beginnt bei Jahr 0", () => {
|
||||
const segs = planSegments([
|
||||
{ age: 67, retirementAge: 65 }, // bereits pensioniert -> Ret in 0 J.
|
||||
{ age: 60, retirementAge: 65 }, // Ret in 5 J.
|
||||
]);
|
||||
expect(segs).toEqual([
|
||||
{ type: "MIXED", fromYear: 0, fixedYears: 5 },
|
||||
{ type: "PENSION", fromYear: 5, fixedYears: null },
|
||||
]);
|
||||
});
|
||||
|
||||
it("Beide bereits pensioniert: nur ein offener Pensions-Teil", () => {
|
||||
const segs = planSegments([
|
||||
{ age: 70, retirementAge: 65 },
|
||||
{ age: 68, retirementAge: 64 },
|
||||
]);
|
||||
expect(segs).toEqual([{ type: "PENSION", fromYear: 0, fixedYears: null }]);
|
||||
});
|
||||
|
||||
it("Einzelperson bereits pensioniert: nur Pension", () => {
|
||||
expect(planSegments([{ age: 66, retirementAge: 65 }])).toEqual([
|
||||
{ type: "PENSION", fromYear: 0, fixedYears: null },
|
||||
]);
|
||||
});
|
||||
|
||||
it("der letzte Teil ist immer offen und vom Typ PENSION", () => {
|
||||
for (const persons of [
|
||||
[{ age: 30, retirementAge: 65 }],
|
||||
[
|
||||
{ age: 45, retirementAge: 65 },
|
||||
{ age: 48, retirementAge: 60 },
|
||||
],
|
||||
]) {
|
||||
const segs = planSegments(persons);
|
||||
const last = segs[segs.length - 1];
|
||||
expect(last.fixedYears).toBeNull();
|
||||
expect(last.type).toBe("PENSION");
|
||||
}
|
||||
});
|
||||
|
||||
it("defaultOpenDuration zielt auf ~90 Jahre, mindestens 5", () => {
|
||||
expect(defaultOpenDuration([{ age: 45 }], 20)).toBe(25); // 90 - (45+20)
|
||||
expect(defaultOpenDuration([{ age: 88 }], 6)).toBe(5); // Untergrenze
|
||||
expect(defaultOpenDuration([{ age: 50 }, { age: 60 }], 5)).toBe(25); // ältester zählt: 90-(60+5)
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
// Ableitung der Lebensabschnitts-"Teile" aus den fixen Pensionierungszeitpunkten.
|
||||
//
|
||||
// Die Pensionsalter der Personen sind Fixpunkte auf der Lebenslinie. Zwischen Planbeginn und
|
||||
// diesen Punkten entstehen Abschnitte mit konstantem Erwerbsstatus -- Erwerb (alle arbeiten),
|
||||
// Misch (eine Person pensioniert, eine arbeitet) oder Pension (alle pensioniert). Genau diese
|
||||
// Abschnitte muss der Assistent respektieren: Eine Phase darf keinen Pensionierungspunkt
|
||||
// überspannen (die Berechnung leitet den Phasentyp am Phasenbeginn ab und kappt entsprechend,
|
||||
// siehe SPEZIFIKATION 2.3 / maxPhaseDuration).
|
||||
//
|
||||
// Reine Funktion, ohne UI und ohne I/O -- nur vom Assistenten verwendet.
|
||||
|
||||
export type SegmentType = "ERWERB" | "MIXED" | "PENSION";
|
||||
|
||||
export interface PlanSegment {
|
||||
type: SegmentType;
|
||||
fromYear: number; // Jahre ab Planbeginn (Beginn des Abschnitts)
|
||||
// Feste Länge in Jahren (durch den nächsten Pensionierungspunkt bestimmt) ODER null für den
|
||||
// letzten, OFFENEN Abschnitt (Pension bis Lebensende -- Dauer frei wählbar).
|
||||
fixedYears: number | null;
|
||||
}
|
||||
|
||||
// Ein Person-Erwerbsstatus wechselt genau einmal: im Jahr `retirementAge - age` (auf 0 gekappt,
|
||||
// falls bei Planbeginn bereits pensioniert). Die distinkten dieser Zeitpunkte sind die
|
||||
// Abschnittsgrenzen; der Abschnitt nach dem letzten Zeitpunkt ist offen.
|
||||
export function planSegments(persons: { age: number; retirementAge: number }[]): PlanSegment[] {
|
||||
if (persons.length === 0) return [{ type: "PENSION", fromYear: 0, fixedYears: null }];
|
||||
|
||||
const retYears = persons.map((p) => Math.max(0, p.retirementAge - p.age));
|
||||
const events = [...new Set(retYears.filter((r) => r > 0))].sort((a, b) => a - b);
|
||||
const boundaries = [0, ...events];
|
||||
|
||||
const segments: PlanSegment[] = [];
|
||||
for (let i = 0; i < boundaries.length; i++) {
|
||||
const from = boundaries[i];
|
||||
const next = i + 1 < boundaries.length ? boundaries[i + 1] : null;
|
||||
// Eine Person arbeitet in einem Abschnitt, der bei Jahr `from` beginnt, genau dann, wenn
|
||||
// ihr Pensionierungszeitpunkt echt später liegt.
|
||||
const working = retYears.filter((r) => r > from).length;
|
||||
const type: SegmentType = working === persons.length ? "ERWERB" : working > 0 ? "MIXED" : "PENSION";
|
||||
segments.push({ type, fromYear: from, fixedYears: next === null ? null : next - from });
|
||||
}
|
||||
return segments;
|
||||
}
|
||||
|
||||
// Vorgeschlagene Dauer des offenen Pensions-Abschnitts: bis das älteste Haushaltsmitglied
|
||||
// ungefähr 90 ist, mindestens aber 5 Jahre.
|
||||
export function defaultOpenDuration(persons: { age: number }[], fromYear: number): number {
|
||||
const oldestNow = persons.length > 0 ? Math.max(...persons.map((p) => p.age)) : 65;
|
||||
return Math.max(5, 90 - (oldestNow + fromYear));
|
||||
}
|
||||
Reference in New Issue
Block a user