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
+440 -183
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 });
}
if (toggles.pk) {
await addEl("PENSION_FUND", "Pensionskasse", "PERSON_A", {
currentValue: pk.value,
annualContribution: 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),
expectedReturn: 2,
});
}
if (toggles.etf) {
await addEl("OTHER_ASSET", "Wertschriften / ETF", "HOUSEHOLD", {
startValue: etf.value,
annualContribution: 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 (toggles.debt) {
await addEl("OTHER_DEBT", "Kredit", "HOUSEHOLD", { startValue: debt.value, annualRepayment: debt.repayment });
await addEl("AHV", isCouple ? `AHV ${personLabel(p.role)}` : "AHV", p.role, { gapYears: 0 });
}
for (const ph of phaseList.slice(1)) {
// 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 (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 (a.etf.on) {
await addEl("OTHER_ASSET", `Wertschriften${suffix}`, scope.otherRole, {
startValue: a.etf.value,
annualContribution: a.etf.contribution,
expectedReturn: 5,
});
}
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,
});
}
}
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" : ""}`}
>
<span className="block truncate">{ph.name}</span>
<span className="opacity-80">{ph.durationYears} J.</span>
<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"
}`}
>
Summe {sum} / {fixed} Jahre{ok ? " ✓" : ""}
</span>
)}
</div>
<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
label={phaseIdx === 0 ? "Dauer (Jahre)" : ""}
value={ph.durationYears}
min={1}
max={80}
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>
<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>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{phases.map((ph, i) => (
<NumberField
key={i}
label={`${ph.name} 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)))
}
/>
))}
</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.
</p>
);
})}
</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>
<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 }))}
>
<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 }))} />
</WizardToggle>
<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 }))}
>
<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 }))} />
</WizardToggle>
<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 }))}
>
<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 }))} />
</WizardToggle>
<WizardToggle
label="Wohneigentum"
help="Kaufpreis, Hypothek und jährliche Amortisation gemäss Hypothekarvertrag."
checked={toggles.realEstate}
onChange={(v) => setToggles((t) => ({ ...t, realEstate: 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 }))} />
</WizardToggle>
<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 }))}
>
<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 }))} />
</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.
{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="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={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={`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={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={assets[sc.id].etf.on}
onChange={(v) => setScope(sc.id, "etf", { on: 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={assets[sc.id].re.on}
onChange={(v) => setScope(sc.id, "re", { on: 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={assets[sc.id].debt.on}
onChange={(v) => setScope(sc.id, "debt", { on: 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>
)}
</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,