Assistent: Ueberblicksschritt Pensionierung mit gerechneter Vorschau
Deploy App / deploy (push) Successful in 1m6s

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-26 14:19:31 +02:00
parent 0f656f4070
commit cc61a120ee
2 changed files with 287 additions and 17 deletions
+268 -13
View File
@@ -25,7 +25,21 @@ import { PlanProfileFields, emptyProfileDraft, type ProfileDraft } from "@/compo
import { api } from "@/lib/api-client";
import { planSegments, defaultOpenDuration, type PlanSegment, type SegmentType } from "@/lib/phaseplan";
import { PILLAR_3A_MAX_ANNUAL, PILLAR_3A_MAX_SELF_EMPLOYED } from "@/lib/constants";
import type { PhaseData } from "@/lib/elements";
import { computePlan } from "@/lib/calculations";
import { formatChf } from "@/lib/format";
import { RetirementFields } from "@/components/RetirementFields";
import { RETIREMENT_CATEGORIES, type RetirementDecision } from "@/lib/retirement-decision";
import type { ElementCategory, PhaseData } from "@/lib/elements";
import type { ElementInput, PlanInput } from "@/lib/types";
// Ein anzulegendes Element. Dient zugleich als Bauplan für die rechenbare Vorschau, damit
// Vorschau und erzeugter Plan nicht auseinanderlaufen können.
interface ElementSpec {
category: string;
name: string;
ownerRole: string;
values?: PhaseData;
}
interface PhaseDraft {
name: string;
@@ -38,6 +52,7 @@ const STEP_TITLES = [
"Einkommen & Ausgaben",
"Vorsorge & Vermögen",
"Sparen & Verteilen",
"Deine Pensionierung",
"Fertig",
];
@@ -49,6 +64,7 @@ const WIZARD_OVERVIEW: { icon: React.ComponentType<{ className?: string }>; titl
{ icon: Wallet, title: "Einkommen & Ausgaben", desc: "Was reinkommt, was rausgeht, was auf dem Konto liegt." },
{ icon: PiggyBank, title: "Vorsorge & Vermögen", desc: "Was du heute besitzt PK, 3a, Wertschriften, Wohneigentum." },
{ icon: Coins, title: "Sparen & Verteilen", desc: "Deine Sparquote auf die Sparziele aufteilen." },
{ icon: Flag, title: "Deine Pensionierung", desc: "Was am Ende herauskommt und was du daran drehen kannst." },
];
const SEGMENT_META: Record<SegmentType, { label: string; phase: string; def: string; color: string; soft: string }> = {
@@ -185,9 +201,14 @@ export function PlanWizard({
return seg.fixedYears === null ? segSums[i] >= 1 : segSums[i] === seg.fixedYears;
});
// Pensionierungs-Entscheide je Person, im Assistenten getroffen. Sie werden erst NACH dem
// Anlegen geschrieben -- vorher gibt es keine Element-Ids, an denen sie hängen könnten.
const [retireDraft, setRetireDraft] = useState<Record<string, RetirementDecision>>({});
// 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))
@@ -235,16 +256,18 @@ export function PlanWizard({
ownerRole,
});
if (values) await api.put(`/api/elements/${element.id}/phase/${firstPhaseId}`, values);
return element.id;
}
for (const p of profile.persons) {
const amount = incomes[p.role] ?? 0;
// 1 % nominale Lohnerhöhung als dezenter Default -- 0 % liesse den Lohn real verlieren.
if (amount > 0) await addEl("INCOME", `Lohn ${personLabel(p.role)}`, p.role, { amount, teuerungsausgleich: 1 });
}
if (expenses > 0) await addEl("EXPENSE", "Lebenshaltung", "HOUSEHOLD", { amount: expenses, teuerungsausgleich: 0 });
for (const p of profile.persons) {
await addEl("AHV", isCouple ? `AHV ${personLabel(p.role)}` : "AHV", p.role, { gapYears: 0 });
// Genau die Liste, aus der auch die Vorschau gerechnet wurde.
for (const spec of elementSpecs) {
const id = await addEl(spec.category, spec.name, spec.ownerRole, spec.values);
// Pensionierungs-Entscheid nur dort, wo er hingehört -- und nur, wenn der Nutzer im
// Überblicksschritt etwas verändert hat.
const rd = retireDraft[spec.ownerRole];
if (id && rd && Object.keys(rd).length > 0 && RETIREMENT_CATEGORIES.includes(spec.category as ElementCategory)) {
await api.put(`/api/elements/${id}/retirement`, rd);
}
}
// Vermögen/Vorsorge je Eigentümer-Bereich anlegen.
@@ -331,6 +354,139 @@ export function PlanWizard({
return n + sc.cats.filter((c) => a[c].on).length;
}, 0);
// Die anzulegenden Elemente -- EINE Liste, die sowohl die Pensionierungs-Vorschau als auch
// das Anlegen speist. Zwei Aufbauten desselben Plans würden garantiert auseinanderlaufen,
// und die Vorschau zeigte dann Zahlen, die der erzeugte Plan nie hat.
const elementSpecs = useMemo<ElementSpec[]>(() => {
const out: ElementSpec[] = [];
for (const person of profile.persons) {
const amount = incomes[person.role] ?? 0;
// 1 % nominale Lohnerhöhung als dezenter Default -- 0 % liesse den Lohn real verlieren.
if (amount > 0)
out.push({
category: "INCOME",
name: `Lohn ${personLabel(person.role)}`,
ownerRole: person.role,
values: { amount, teuerungsausgleich: 1 },
});
}
if (expenses > 0)
out.push({
category: "EXPENSE",
name: "Lebenshaltung",
ownerRole: "HOUSEHOLD",
values: { amount: expenses, teuerungsausgleich: 0 },
});
for (const person of profile.persons)
out.push({
category: "AHV",
name: isCouple ? `AHV ${personLabel(person.role)}` : "AHV",
ownerRole: person.role,
values: { gapYears: 0 },
});
for (const scope of renderedScopes) {
const a = assets[scope.id];
const suffix = scope.nameSuffix;
if (scope.showVorsorge) {
if (a.pk.on)
out.push({
category: "PENSION_FUND",
name: `Pensionskasse${suffix}`,
ownerRole: scope.vorsorgeRole!,
values: { currentValue: a.pk.value, annualContribution: a.pk.contribution, expectedReturn: a.pk.ret },
});
if (a.p3a.on)
out.push({
category: "PILLAR_3A",
name: `Säule 3a${suffix}`,
ownerRole: scope.vorsorgeRole!,
values: {
currentValue: a.p3a.value,
annualContribution: Math.min(a.p3a.contribution, max3aFor(scope.id)),
expectedReturn: a.p3a.ret,
selfEmployed3a: a.p3a.selfEmployed,
},
});
}
if (a.etf.on)
out.push({
category: "OTHER_ASSET",
name: `Wertschriften${suffix}`,
ownerRole: scope.otherRole,
values: { startValue: a.etf.value, annualContribution: a.etf.contribution, expectedReturn: a.etf.ret },
});
if (a.re.on)
out.push({
category: "REAL_ESTATE",
name: `Wohneigentum${suffix}`,
ownerRole: scope.otherRole,
values: {
purchasePrice: a.re.price,
mortgage: a.re.mortgage,
amortization: a.re.amortization,
interestRate: a.re.interest,
valueGrowth: a.re.growth,
interestHandling: a.re.interestIncluded ? "INCLUDED" : "ADD",
},
});
if (a.debt.on)
out.push({
category: "OTHER_DEBT",
name: `Schulden${suffix}`,
ownerRole: scope.otherRole,
values: { startValue: a.debt.value, annualRepayment: a.debt.repayment },
});
}
return out;
// eslint-disable-next-line react-hooks/exhaustive-deps -- renderedScopes/max3aFor folgen denselben Zuständen
}, [profile, incomes, expenses, assets, isCouple]);
// Rechenbare Vorschau OHNE dass der Plan existiert. computePlan ist rein und läuft im
// Browser in Bruchteilen einer Millisekunde -- genau dieselbe Rechnung wie später.
const previewPlan: PlanInput = {
id: "preview",
name: planName.trim() || "Meine Planung",
householdType: profile.householdType,
inflationRateDefault: profile.inflationRateDefault,
initialCash,
startYear: profile.startYear,
persons: profile.persons.map((x) => ({
id: x.role,
role: x.role,
name: x.name.trim() || null,
age: x.age,
retirementAge: x.retirementAge,
planningHorizonAge: null,
})),
phases: flatPhases.map((ph, i) => ({
id: `ph${i}`,
sequenceNumber: i + 1,
name: ph.name,
durationYears: ph.durationYears,
cashTransition: {},
})),
elements: elementSpecs.map((spec, i) => ({
id: `el${i}`,
category: spec.category as ElementCategory,
name: spec.name,
ownerRole: spec.ownerRole as ElementInput["ownerRole"],
orderIndex: i,
// Werte gelten für Phase 1; die Folgephasen erben live (Punkt A).
phaseValues: { ph0: spec.values ?? {} },
transitionValues: {},
retirementDecision: retireDraft[spec.ownerRole] ?? {},
})),
};
// Eine unvollständige Zwischeneingabe darf den Assistenten nicht abstürzen lassen.
let previewComputed: ReturnType<typeof computePlan> | null = null;
try {
previewComputed = computePlan(previewPlan);
} catch {
previewComputed = null;
}
// --- Sparquote (Schritt "Sparen & Verteilen") ---
// Die Sparquote entsteht aus Nettoeinkommen minus Ausgaben. PK-Beiträge kommen VOM
// Bruttolohn (vor dem Netto) und reduzieren sie deshalb NICHT -- genau wie im Rechenkern.
@@ -404,7 +560,7 @@ export function PlanWizard({
return (
<Modal
title="Plan erstellen geführt"
subtitle={step < 5 ? `Schritt ${step + 1} von 5: ${STEP_TITLES[step]}` : "Zusammenfassung"}
subtitle={step < 6 ? `Schritt ${step + 1} von 6: ${STEP_TITLES[step]}` : "Zusammenfassung"}
onClose={onClose}
xwide
>
@@ -441,12 +597,12 @@ export function PlanWizard({
<div className="mt-1 flex items-center gap-2 rounded-lg px-2 py-1.5 text-xs">
<span
className={`flex h-5 w-5 shrink-0 items-center justify-center rounded-full ${
step === 5 ? "bg-accent text-accent-fg" : "border border-border text-faint"
step === 6 ? "bg-accent text-accent-fg" : "border border-border text-faint"
}`}
>
<Flag className="h-3 w-3" />
<Check className="h-3 w-3" />
</span>
<span className={step === 5 ? "font-semibold text-fg" : "text-faint"}>Fertig</span>
<span className={step === 6 ? "font-semibold text-fg" : "text-faint"}>Fertig</span>
</div>
</nav>
@@ -817,7 +973,106 @@ export function PlanWizard({
</div>
)}
{/* Überblick statt Erfassung: Zu diesem Zeitpunkt weiss niemand, wie hoch seine PK-Rente
sein wird -- danach zu fragen hiesse, eine unbeantwortbare Frage zu stellen. Die
Antwort zu ZEIGEN ist dagegen der stärkste Moment im ganzen Onboarding. Was hier
steht, sind die Vorgaben; ändern kann man sie, muss aber nicht. */}
{step === 5 && (
<div className="flex flex-col gap-4">
<p className="text-sm text-muted">
So sieht deine Pensionierung mit den bisherigen Angaben aus. Das Tool rechnet mit sinnvollen Vorgaben
du kannst sie jetzt anpassen oder später jederzeit im Bildschirm «Pensionierung».
</p>
{previewComputed && previewComputed.retirement.gapAnnual !== null ? (
<>
<div className="rounded-xl border border-border bg-surface-2 p-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="flex flex-col gap-1 text-sm">
{previewComputed.retirement.perPerson.map((r) => (
<div key={r.role} className="flex items-baseline justify-between gap-3">
<span className="text-muted">
{personLabel(r.role)} · AHV + PK
<span className="ml-1 text-xs text-faint">(ab {r.retirementAge})</span>
</span>
<span className="tabular-nums text-fg">{formatChf(r.ahvAnnual + r.pkPensionAnnual)}</span>
</div>
))}
<div className="my-1 border-t border-border" />
<div className="flex items-baseline justify-between gap-3">
<span className="font-semibold text-fg">Renteneinkommen</span>
<span className="tabular-nums font-semibold text-fg">
{formatChf(previewComputed.retirement.pensionIncome ?? 0)}
</span>
</div>
<div className="flex items-baseline justify-between gap-3">
<span className="text-muted">Ausgaben</span>
<span className="tabular-nums text-fg">
{formatChf(previewComputed.retirement.expenses ?? 0)}
</span>
</div>
</div>
<div className="flex flex-col justify-center gap-3">
<div>
<div className="flex items-center text-xs uppercase tracking-wide text-faint">
Rentenlücke
<InfoBubble text="Renteneinkommen minus Ausgaben im ersten Jahr, in dem niemand mehr arbeitet. Eine Lücke ist normal sie wird aus dem Vermögen gedeckt. Entscheidend ist, wie lange das trägt." />
</div>
<div
className={`text-2xl font-semibold tabular-nums ${
(previewComputed.retirement.gapAnnual ?? 0) < 0 ? "text-danger" : "text-success"
}`}
>
{formatChf(previewComputed.retirement.gapAnnual ?? 0)} / Jahr
</div>
</div>
<div>
<div className="text-xs uppercase tracking-wide text-faint">Vermögen reicht</div>
<div
className={`text-lg font-semibold ${
previewComputed.ruinAge === null ? "text-success" : "text-danger"
}`}
>
{previewComputed.ruinAge === null
? "über die ganze Planung"
: `bis Alter ${previewComputed.ruinAge}`}
</div>
</div>
</div>
</div>
</div>
{/* Dieselben Bausteine wie im Pensionierungs-Bildschirm -- eine Implementierung. */}
<div className="flex flex-col gap-2">
{previewPlan.elements
.filter((e) => RETIREMENT_CATEGORIES.includes(e.category))
.map((e) => (
<RetirementFields
key={e.id}
plan={previewPlan}
el={e}
patch={(_id, patch) =>
setRetireDraft((prev) => ({
...prev,
[e.ownerRole ?? "HOUSEHOLD"]: { ...(prev[e.ownerRole ?? "HOUSEHOLD"] ?? {}), ...patch },
}))
}
summary={previewComputed.retirement.perPerson.find((x) => x.role === e.ownerRole)}
/>
))}
</div>
</>
) : (
<p className="rounded-lg border border-dashed border-border bg-surface-2 p-3 text-sm text-muted">
Für eine Vorschau fehlen noch Angaben lege mindestens eine Lebensphase nach der Pensionierung sowie
Einkommen und Ausgaben an. Du kannst den Plan trotzdem erstellen und alles später ergänzen.
</p>
)}
</div>
)}
{step === 6 && (
<div className="flex flex-col gap-3">
<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">