Assistent: Ueberblicksschritt Pensionierung mit gerechneter Vorschau
Deploy App / deploy (push) Successful in 1m6s
Deploy App / deploy (push) Successful in 1m6s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+268
-13
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user