Plan-Assistent: abschnittsbasierte Phasenplanung + Vermoegen pro Person
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:
2026-07-19 13:32:43 +02:00
parent 9252f7188d
commit 1d046e9f7e
4 changed files with 613 additions and 195 deletions
+43 -12
View File
@@ -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` (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.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.63.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*
+396 -139
View File
@@ -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 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;
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) }];
});
}
// --- 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,
+80
View File
@@ -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)
});
});
+50
View File
@@ -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));
}