Assistent auf zwei Schritte, Pensionsalter fix 65, Horizont abgeleitet
Deploy App / deploy (push) Successful in 1m57s
Deploy App / deploy (push) Successful in 1m57s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -36,8 +36,8 @@ export const STEP_EXPLAINERS: StepExplainer[] = [
|
||||
],
|
||||
notes: [
|
||||
{
|
||||
title: "Nur Bestände, noch keine Raten",
|
||||
text: "Wie viel du jährlich sparst oder amortisierst, kommt später – das hängt von deinen Lebensphasen ab. Hier zählt nur der Stand von heute.",
|
||||
title: "Nur Tatsachen, noch keine Annahmen",
|
||||
text: "Hier stehen Zahlen, die du nachschlagen kannst: Kontostand, Guthaben, Kaufpreis, Restschuld. Renditen, Lohnentwicklung und Sparraten kommen im nächsten Schritt – sie sind Annahmen und gelten immer nur für einen Zeitraum.",
|
||||
},
|
||||
{
|
||||
title: "Lieber grob als gar nicht",
|
||||
@@ -51,132 +51,28 @@ export const STEP_EXPLAINERS: StepExplainer[] = [
|
||||
},
|
||||
{
|
||||
lead:
|
||||
"Jetzt legst du die Zeitachse fest: wie weit du planst und wann welche Rente oder welches Kapital zu fliessen beginnt. Das bestimmt, wo deine Lebensphasen zwingend beginnen müssen.",
|
||||
"Eine Lebensphase ist ein Abschnitt, in dem die Verhältnisse ungefähr gleich bleiben: ähnliches Einkommen, ähnliche Ausgaben, dieselben Annahmen. Jetzt legst du die erste an.",
|
||||
questions: [
|
||||
"Über wie viele Jahre soll die Planung laufen?",
|
||||
"Wann hörst du auf zu arbeiten?",
|
||||
"Wann beziehst du die AHV – früher, mit 65, oder später?",
|
||||
"Wann beziehst du Pensionskasse und Säule 3a?",
|
||||
"Wie lange bleiben deine Verhältnisse ungefähr so wie heute?",
|
||||
"Womit rechnest du in dieser Zeit – Renditen, Lohnentwicklung, Teuerung?",
|
||||
"Wohin fliesst das Geld, das am Ende des Jahres übrig bleibt?",
|
||||
],
|
||||
notes: [
|
||||
{
|
||||
title: "Noch keine Beträge",
|
||||
text: "Hier geht es nur um Zeitpunkte. Ob Rente oder Kapital, und wohin das Geld fliesst, entscheidest du in Schritt 5.",
|
||||
title: "Was eine Lebensphase zusammenhält",
|
||||
text: "Nicht ein Kalenderabschnitt, sondern eine Konstellation: Solange du gleich viel verdienst, ähnlich viel ausgibst und dieselben Annahmen gelten, ist es eine Phase. Ändert sich etwas grundlegend – ein Hauskauf, Teilzeit, die Pensionierung – beginnt eine neue.",
|
||||
},
|
||||
{
|
||||
title: "Jeder Bezug erzeugt eine Phasengrenze",
|
||||
text: "Beziehst du die AHV mit 63 und die Säule 3a mit 65, sind das zwei Fixpunkte. FPT rechnet phasenweise – deshalb muss dort jeweils eine neue Lebensphase beginnen.",
|
||||
title: "Annahmen gelten pro Phase",
|
||||
text: "Die Rendite, die du hier einträgst, gilt nur für diese Jahre. In einer späteren Phase kannst du eine andere setzen – etwa vorsichtiger, wenn du im Ruhestand anders anlegst.",
|
||||
},
|
||||
{
|
||||
title: "Früher kostet, später bringt",
|
||||
text: "Ein AHV-Vorbezug kürzt die Rente lebenslang um 6,8 % pro Jahr, ein Aufschub erhöht sie um bis zu 31,5 %. Das Tool rechnet beides für dich aus.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
lead:
|
||||
"Deine Planung zerfällt in Abschnitte mit gleichbleibenden Verhältnissen. Die Fixpunkte aus Schritt 2 stehen fest – dazwischen teilst du frei ein.",
|
||||
questions: [
|
||||
"Gibt es Jahre, die sich deutlich voneinander unterscheiden?",
|
||||
"Planst du einen Hauskauf, eine Weiterbildung, ein Sabbatical?",
|
||||
"Wie fein willst du planen – wenige grosse Phasen oder viele kleine?",
|
||||
],
|
||||
notes: [
|
||||
{
|
||||
title: "Eine Phase = gleichbleibende Verhältnisse",
|
||||
text: "Innerhalb einer Lebensphase laufen Einkommen, Ausgaben und Sparraten gleichmässig weiter. Ändert sich etwas grundlegend, beginnt eine neue Phase.",
|
||||
},
|
||||
{
|
||||
title: "Weniger ist oft mehr",
|
||||
text: "Drei bis sechs Phasen reichen für die meisten Planungen. Jede zusätzliche Phase bedeutet mehr Entscheide, die du treffen musst.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
lead:
|
||||
"Für die Jahre, in denen du noch arbeitest: Wohin fliesst das Geld, das übrig bleibt – und was passiert an den Übergängen dazwischen?",
|
||||
questions: [
|
||||
"Wie viel sparst du pro Jahr, und wohin?",
|
||||
"Zahlst du in die Säule 3a ein? Amortisierst du die Hypothek?",
|
||||
"Verkaufst du unterwegs etwas, oder kommt Geld herein?",
|
||||
],
|
||||
notes: [
|
||||
{
|
||||
title: "Der Rest bleibt auf dem Konto",
|
||||
text: "Was du nicht zuteilst, sammelt sich als Cash an. Das ist kein Fehler – aber meist nicht die Absicht.",
|
||||
text: "Am Schluss verteilst du deinen Sparbetrag. Was du nicht zuteilst, sammelt sich als Cash an – rechnerisch richtig, aber meist nicht die Absicht.",
|
||||
},
|
||||
{
|
||||
title: "Übergänge sind Entscheidungspunkte",
|
||||
text: "Zwischen zwei Lebensphasen entscheidest du, was mit Immobilien, Schulden und einmaligen Ereignissen geschieht.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
lead:
|
||||
"Der wichtigste Schritt: Was passiert mit deinem Altersguthaben? Hier entscheidest du über Rente oder Kapital – und darüber, wovon du in der Pension lebst.",
|
||||
questions: [
|
||||
"Rente oder Kapital – oder eine Mischung?",
|
||||
"Wenn Kapital: wohin damit? Hypothek amortisieren, anlegen, liegen lassen?",
|
||||
"Reicht das Renteneinkommen für deine Ausgaben?",
|
||||
],
|
||||
notes: [
|
||||
{
|
||||
title: "Rente ist sicher, Kapital ist flexibel",
|
||||
text: "Eine Rente fliesst lebenslang, unabhängig von den Märkten. Kapital bleibt verfügbar und vererbbar, trägt aber das Anlagerisiko – und muss reichen.",
|
||||
},
|
||||
{
|
||||
title: "Die Rentenlücke ist normal",
|
||||
text: "Fast niemand deckt seine Ausgaben allein aus AHV und Pensionskasse. Entscheidend ist nicht, ob eine Lücke besteht, sondern wie lange dein Vermögen sie trägt.",
|
||||
},
|
||||
{
|
||||
title: "Steuern beim Kapitalbezug",
|
||||
text: "Ein Kapitalbezug wird einmalig und getrennt vom Einkommen besteuert. Alle Bezüge desselben Jahres werden zusammengezählt – gestaffelt über mehrere Jahre zahlst du weniger.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
lead:
|
||||
"Die Jahre nach der Pensionierung: Kein Lohn mehr, dafür Renten – und das Vermögen, das du aufgebaut hast. Jetzt legst du fest, wovon du lebst.",
|
||||
questions: [
|
||||
"Wie viel entnimmst du jährlich aus deinem Vermögen?",
|
||||
"Ändern sich deine Ausgaben im Alter – Reisen zuerst, Pflege später?",
|
||||
"Verkaufst du irgendwann die Immobilie?",
|
||||
],
|
||||
notes: [
|
||||
{
|
||||
title: "Verzehr statt Sparen",
|
||||
text: "In diesen Phasen ist die Quote meist negativ: Du gibst mehr aus, als hereinkommt. Die Differenz kommt aus dem Vermögen – das ist der Zweck des Sparens.",
|
||||
},
|
||||
{
|
||||
title: "Achte auf das Ruinalter",
|
||||
text: "Reicht das Vermögen nicht bis zum Planungshorizont, meldet FPT das. Dann helfen: später aufhören, weniger ausgeben, oder anders anlegen.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
lead:
|
||||
"Dein Plan steht. Was jetzt noch möglich ist – und wo du es findest.",
|
||||
questions: [],
|
||||
notes: [
|
||||
{
|
||||
title: "Werte je Lebensphase anpassen",
|
||||
text: "Willst du für eine einzelne Phase eine andere Rendite oder Sparrate? Klicke die Zelle in der Matrix an. Leere Felder erben automatisch von der Vorphase – so gilt eine Änderung ab dort weiter.",
|
||||
},
|
||||
{
|
||||
title: "Pensionsplanung zentral ändern",
|
||||
text: "Alle Entscheide zu AHV, Pensionskasse und Säule 3a stehen gebündelt unter «Pensionsplanung» in den Grundeinstellungen – für jede Person einzeln.",
|
||||
},
|
||||
{
|
||||
title: "Varianten vergleichen",
|
||||
text: "Lege ein zweites Szenario an (Schnellaktionen), ändere dort etwa das Pensionsalter, und vergleiche beide. Deine Entscheide werden mitkopiert.",
|
||||
},
|
||||
{
|
||||
title: "Unsicherheiten prüfen",
|
||||
text: "Unter «Analysen» findest du die Einflussfaktoren (welcher Hebel wirkt am stärksten), die Live-Simulation (an Reglern drehen) und die Monte-Carlo-Simulation (wie robust ist der Plan).",
|
||||
},
|
||||
{
|
||||
title: "Nachvollziehen, was gerechnet wird",
|
||||
text: "«Rechenwege» in den Schnellaktionen zeigt zu jeder Zahl den vollständigen Weg – inklusive der Vereinfachungen, die FPT bewusst macht.",
|
||||
title: "Bis zur Pensionierung",
|
||||
text: "Im Basisszenario wird mit Pensionsalter 65 gerechnet. Eine Lebensphase darf diesen Zeitpunkt nicht überspannen – FPT begrenzt die Dauer deshalb automatisch.",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
+242
-551
@@ -7,22 +7,15 @@
|
||||
// Matrix schreiben können; er nimmt einem nur das Suchen ab.
|
||||
|
||||
import { useState } from "react";
|
||||
import { AlertTriangle, ArrowLeft, ArrowRight, Plus, Trash2 } from "lucide-react";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import { api } from "@/lib/api-client";
|
||||
import { Button } from "@/components/ui";
|
||||
import { MoneyField, NumberField, SelectField, TextField } from "@/components/FormField";
|
||||
import { RetirementFields } from "@/components/RetirementFields";
|
||||
import { CapitalDistributionDialog, RateDistributionDialog } from "@/components/DistributionDialogs";
|
||||
import { RateDistributionDialog } from "@/components/DistributionDialogs";
|
||||
import { formatChf } from "@/lib/format";
|
||||
import { CATEGORY_LABELS, ownerLabel, num, type PhaseData } from "@/lib/elements";
|
||||
import {
|
||||
DEFAULT_PLANNING_HORIZON_YEARS,
|
||||
MAX_PLANNING_HORIZON_YEARS,
|
||||
MIN_PLANNING_HORIZON_YEARS,
|
||||
} from "@/lib/constants";
|
||||
import { planSegments, defaultOpenDuration, type PlanSegment } from "@/lib/phaseplan";
|
||||
import { fixpointPersonsOf, type PlanComputed } from "@/lib/calculations";
|
||||
import type { PlanInput, PersonRole } from "@/lib/types";
|
||||
import { maxPhaseDuration, type PlanComputed } from "@/lib/calculations";
|
||||
import type { PlanInput } from "@/lib/types";
|
||||
|
||||
// ============================================================================================
|
||||
// Schritt 1: Bestandsaufnahme
|
||||
@@ -46,7 +39,16 @@ interface Kind {
|
||||
defaultName: string;
|
||||
hint: string;
|
||||
personOnly?: boolean;
|
||||
fields: (v: PhaseData, set: (p: Partial<PhaseData>) => void, plan: PlanInput) => React.ReactNode;
|
||||
// SCHRITT 1 -- der Bestand von heute. Nur Zahlen, die man nachschlagen kann: Kontostand,
|
||||
// Guthaben, Kaufpreis, Restschuld.
|
||||
baseFields: (v: PhaseData, set: (p: Partial<PhaseData>) => void) => React.ReactNode;
|
||||
// SCHRITT 2 -- die Annahmen fuer EINE Lebensphase. Alles, was pro Jahr geschieht und sich
|
||||
// von Phase zu Phase aendern darf: Renditen, Lohnentwicklung, Wertsteigerung, Zins.
|
||||
//
|
||||
// Die Trennung ist der Kern: Ein Guthaben ist eine Tatsache, eine Rendite eine Annahme --
|
||||
// und Annahmen gelten immer nur fuer einen Zeitraum. Bis 0.37 standen beide zusammen in der
|
||||
// Bestandsaufnahme, wodurch eine Annahme wie eine Tatsache aussah.
|
||||
phaseFields?: (v: PhaseData, set: (p: Partial<PhaseData>) => void) => React.ReactNode;
|
||||
}
|
||||
|
||||
const KINDS: Kind[] = [
|
||||
@@ -57,22 +59,22 @@ const KINDS: Kind[] = [
|
||||
defaultName: "Lohn",
|
||||
hint: "Dein Nettolohn – das, was auf dem Konto ankommt.",
|
||||
personOnly: true,
|
||||
fields: (v, set) => (
|
||||
<>
|
||||
<MoneyField
|
||||
label="Nettolohn pro Jahr"
|
||||
help="Was tatsächlich aufs Konto kommt, nach allen Abzügen. FPT rechnet für die AHV intern auf den Bruttolohn hoch."
|
||||
value={num(v.amount)}
|
||||
onChange={(x) => set({ amount: x })}
|
||||
/>
|
||||
<NumberField
|
||||
label="Jährliche Lohnerhöhung (%)"
|
||||
help="Nominal, also inklusive Teuerung. 1 % ist ein vorsichtiger Ausgangswert."
|
||||
step={0.5}
|
||||
value={num(v.teuerungsausgleich, 1)}
|
||||
onChange={(x) => set({ teuerungsausgleich: x })}
|
||||
/>
|
||||
</>
|
||||
baseFields: (v, set) => (
|
||||
<MoneyField
|
||||
label="Nettolohn pro Jahr"
|
||||
help="Was tatsächlich aufs Konto kommt, nach allen Abzügen. FPT rechnet für die AHV intern auf den Bruttolohn hoch."
|
||||
value={num(v.amount)}
|
||||
onChange={(x) => set({ amount: x })}
|
||||
/>
|
||||
),
|
||||
phaseFields: (v, set) => (
|
||||
<NumberField
|
||||
label="Jährliche Lohnerhöhung (%)"
|
||||
help="Nominal, also inklusive Teuerung. 1 % ist ein vorsichtiger Ausgangswert."
|
||||
step={0.5}
|
||||
value={num(v.teuerungsausgleich, 1)}
|
||||
onChange={(x) => set({ teuerungsausgleich: x })}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -81,7 +83,7 @@ const KINDS: Kind[] = [
|
||||
label: "Ausgaben",
|
||||
defaultName: "Lebenshaltung",
|
||||
hint: "Alles, was du im Jahr ausgibst – Wohnen, Essen, Versicherungen, Freizeit.",
|
||||
fields: (v, set) => (
|
||||
baseFields: (v, set) => (
|
||||
<MoneyField
|
||||
label="Ausgaben pro Jahr"
|
||||
help="In heutiger Kaufkraft. Die Teuerung rechnet FPT selbst dazu."
|
||||
@@ -89,6 +91,15 @@ const KINDS: Kind[] = [
|
||||
onChange={(x) => set({ amount: x })}
|
||||
/>
|
||||
),
|
||||
phaseFields: (v, set) => (
|
||||
<NumberField
|
||||
label="Jährliche Teuerung (%)"
|
||||
help="Leer lassen heisst: die Inflationsannahme des Szenarios. Eine eigene Zahl nur, wenn du für diese Ausgaben etwas anderes erwartest."
|
||||
step={0.25}
|
||||
value={num(v.teuerungsausgleich)}
|
||||
onChange={(x) => set({ teuerungsausgleich: x })}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "pk",
|
||||
@@ -97,14 +108,16 @@ const KINDS: Kind[] = [
|
||||
defaultName: "Pensionskasse",
|
||||
hint: "Dein Altersguthaben laut PK-Ausweis.",
|
||||
personOnly: true,
|
||||
fields: (v, set) => (
|
||||
baseFields: (v, set) => (
|
||||
<MoneyField
|
||||
label="Altersguthaben heute"
|
||||
help="Steht auf deinem Vorsorgeausweis."
|
||||
value={num(v.currentValue)}
|
||||
onChange={(x) => set({ currentValue: x })}
|
||||
/>
|
||||
),
|
||||
phaseFields: (v, set) => (
|
||||
<>
|
||||
<MoneyField
|
||||
label="Altersguthaben heute"
|
||||
help="Steht auf deinem Vorsorgeausweis."
|
||||
value={num(v.currentValue)}
|
||||
onChange={(x) => set({ currentValue: x })}
|
||||
/>
|
||||
<NumberField
|
||||
label="Verzinsung (%/Jahr)"
|
||||
help="Der BVG-Mindestzins liegt aktuell tief; viele Kassen verzinsen etwas darüber."
|
||||
@@ -112,6 +125,15 @@ const KINDS: Kind[] = [
|
||||
value={num(v.expectedReturn, 1.25)}
|
||||
onChange={(x) => set({ expectedReturn: x })}
|
||||
/>
|
||||
{/* Der PK-Beitrag steht hier und nicht bei der Sparquote: Er stammt aus dem
|
||||
Bruttolohn und belastet das Cash-Konto nicht -- aus der Quote liesse er sich
|
||||
gar nicht verteilen (SPEZIFIKATION 4.6.3). */}
|
||||
<MoneyField
|
||||
label="Jährlicher Beitrag (Arbeitnehmer + Arbeitgeber)"
|
||||
help="Die Altersgutschrift laut Vorsorgeausweis. Sie kommt aus dem Bruttolohn und schmälert deine Sparquote deshalb nicht."
|
||||
value={num(v.annualContribution)}
|
||||
onChange={(x) => set({ annualContribution: x })}
|
||||
/>
|
||||
</>
|
||||
),
|
||||
},
|
||||
@@ -122,21 +144,17 @@ const KINDS: Kind[] = [
|
||||
defaultName: "Säule 3a",
|
||||
hint: "Ein Konto oder Depot der gebundenen Vorsorge. Mehrere Konten legst du einzeln an.",
|
||||
personOnly: true,
|
||||
fields: (v, set) => (
|
||||
<>
|
||||
<MoneyField
|
||||
label="Guthaben heute"
|
||||
value={num(v.currentValue)}
|
||||
onChange={(x) => set({ currentValue: x })}
|
||||
/>
|
||||
<NumberField
|
||||
label="Erwartete Rendite (%/Jahr)"
|
||||
help="Auf einem Konto praktisch 0, in einer Wertschriftenlösung deutlich mehr."
|
||||
step={0.5}
|
||||
value={num(v.expectedReturn, 2)}
|
||||
onChange={(x) => set({ expectedReturn: x })}
|
||||
/>
|
||||
</>
|
||||
baseFields: (v, set) => (
|
||||
<MoneyField label="Guthaben heute" value={num(v.currentValue)} onChange={(x) => set({ currentValue: x })} />
|
||||
),
|
||||
phaseFields: (v, set) => (
|
||||
<NumberField
|
||||
label="Erwartete Rendite (%/Jahr)"
|
||||
help="Auf einem Konto praktisch 0, in einer Wertschriftenlösung deutlich mehr."
|
||||
step={0.5}
|
||||
value={num(v.expectedReturn, 2)}
|
||||
onChange={(x) => set({ expectedReturn: x })}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -145,17 +163,17 @@ const KINDS: Kind[] = [
|
||||
label: "Sonstiges Vermögen",
|
||||
defaultName: "Wertschriften",
|
||||
hint: "Depot, ETF, Sparkonto – alles freie Vermögen ausser dem Cash-Konto.",
|
||||
fields: (v, set) => (
|
||||
<>
|
||||
<MoneyField label="Wert heute" value={num(v.startValue)} onChange={(x) => set({ startValue: x })} />
|
||||
<NumberField
|
||||
label="Erwartete Rendite (%/Jahr)"
|
||||
help="Langfristiger Durchschnitt. Für ein gemischtes Portfolio sind 3 bis 5 % ein üblicher Ansatz."
|
||||
step={0.5}
|
||||
value={num(v.expectedReturn, 4)}
|
||||
onChange={(x) => set({ expectedReturn: x })}
|
||||
/>
|
||||
</>
|
||||
baseFields: (v, set) => (
|
||||
<MoneyField label="Wert heute" value={num(v.startValue)} onChange={(x) => set({ startValue: x })} />
|
||||
),
|
||||
phaseFields: (v, set) => (
|
||||
<NumberField
|
||||
label="Erwartete Rendite (%/Jahr)"
|
||||
help="Langfristiger Durchschnitt. Für ein gemischtes Portfolio sind 3 bis 5 % ein üblicher Ansatz."
|
||||
step={0.5}
|
||||
value={num(v.expectedReturn, 4)}
|
||||
onChange={(x) => set({ expectedReturn: x })}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -164,7 +182,7 @@ const KINDS: Kind[] = [
|
||||
label: "Immobilie",
|
||||
defaultName: "Wohneigentum",
|
||||
hint: "Haus oder Wohnung samt Hypothek.",
|
||||
fields: (v, set) => (
|
||||
baseFields: (v, set) => (
|
||||
<>
|
||||
<MoneyField
|
||||
label="Kaufpreis"
|
||||
@@ -172,11 +190,11 @@ const KINDS: Kind[] = [
|
||||
value={num(v.purchasePrice)}
|
||||
onChange={(x) => set({ purchasePrice: x })}
|
||||
/>
|
||||
<MoneyField
|
||||
label="Hypothek heute"
|
||||
value={num(v.mortgage)}
|
||||
onChange={(x) => set({ mortgage: x })}
|
||||
/>
|
||||
<MoneyField label="Hypothek heute" value={num(v.mortgage)} onChange={(x) => set({ mortgage: x })} />
|
||||
</>
|
||||
),
|
||||
phaseFields: (v, set) => (
|
||||
<>
|
||||
<NumberField
|
||||
label="Hypothekarzins (%)"
|
||||
step={0.25}
|
||||
@@ -211,7 +229,7 @@ const KINDS: Kind[] = [
|
||||
label: "Sonstige Schulden",
|
||||
defaultName: "Schulden",
|
||||
hint: "Kredite ausserhalb der Hypothek.",
|
||||
fields: (v, set) => (
|
||||
baseFields: (v, set) => (
|
||||
<MoneyField label="Restschuld heute" value={num(v.startValue)} onChange={(x) => set({ startValue: x })} />
|
||||
),
|
||||
},
|
||||
@@ -249,15 +267,13 @@ export function BaseFields({
|
||||
category,
|
||||
values,
|
||||
set,
|
||||
plan,
|
||||
}: {
|
||||
category: string;
|
||||
values: PhaseData;
|
||||
set: (patch: Partial<PhaseData>) => void;
|
||||
plan: PlanInput;
|
||||
}) {
|
||||
const kind = KINDS.find((k) => k.category === category);
|
||||
return <>{kind?.fields(values, set, plan)}</>;
|
||||
return <>{kind?.baseFields(values, set)}</>;
|
||||
}
|
||||
|
||||
export function Step1Elements({
|
||||
@@ -453,7 +469,7 @@ function BaseDataCard({
|
||||
onChange={(v) => setDraft((d) => ({ ...d, names: { ...d.names, [el.id]: v } }))}
|
||||
/>
|
||||
</div>
|
||||
{kind?.fields(values, set, plan)}
|
||||
{kind?.baseFields(values, set)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -472,268 +488,81 @@ export async function saveStep1Draft(plan: PlanInput, draft: Step1Draft): Promis
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================================
|
||||
// Schritt 2: Eckdaten und Pensionsplanung
|
||||
// ============================================================================================
|
||||
|
||||
export function Step2Timeline({
|
||||
// ============================================================================================
|
||||
// Schritt 2: die erste Lebensphase
|
||||
// ============================================================================================
|
||||
//
|
||||
// Drei Seiten, die zusammen EINE Frage beantworten: "Wie sehen die naechsten Jahre aus?"
|
||||
//
|
||||
// 1. Name und Dauer -> die Phase entsteht
|
||||
// 2. Jaehrliche Annahmen je Element -> was in dieser Zeit gilt
|
||||
// 3. Sparquote verteilen -> wohin das Geld fliesst, das uebrig bleibt
|
||||
//
|
||||
// Erst nach Seite 3 ist der Schritt fertig. Waere er nach Seite 2 zu Ende, staende eine Phase
|
||||
// da, in der der ganze Ueberschuss stumm auf dem Cash-Konto liegen bliebe -- rechnerisch
|
||||
// richtig, aber nie die Absicht.
|
||||
|
||||
export interface Step2Draft {
|
||||
name: string;
|
||||
years: number;
|
||||
// Jaehrliche Annahmen je Element, gesammelt bis zum Anlegen der Phase.
|
||||
phase: Record<string, PhaseData>;
|
||||
// Ist die Phase bereits angelegt? Danach wird nur noch die Sparquote verteilt.
|
||||
createdPhaseId: string | null;
|
||||
}
|
||||
|
||||
export function emptyStep2Draft(): Step2Draft {
|
||||
return { name: "Erwerbsjahre", years: 10, phase: {}, createdPhaseId: null };
|
||||
}
|
||||
|
||||
export function Step2FirstPhase({
|
||||
plan,
|
||||
computed,
|
||||
draft,
|
||||
setDraft,
|
||||
onChanged,
|
||||
}: {
|
||||
plan: PlanInput;
|
||||
computed: PlanComputed;
|
||||
draft: Step2Draft;
|
||||
setDraft: (fn: (d: Step2Draft) => Step2Draft) => void;
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
const [page, setPage] = useState(0);
|
||||
const pages = ["HORIZON", ...plan.persons.map((p) => p.role)];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex gap-1 rounded-lg border border-border bg-surface-2 p-1 text-xs">
|
||||
{pages.map((pg, i) => (
|
||||
<button
|
||||
key={pg}
|
||||
type="button"
|
||||
onClick={() => setPage(i)}
|
||||
className={`flex-1 rounded-md px-2 py-1 font-medium ${
|
||||
page === i ? "bg-accent text-accent-fg" : "text-muted hover:bg-surface"
|
||||
}`}
|
||||
>
|
||||
{pg === "HORIZON" ? "Planungshorizont" : `Pensionierung ${ownerLabel(plan.persons, pg)}`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{page === 0 ? (
|
||||
<HorizonStep plan={plan} onChanged={onChanged} />
|
||||
) : (
|
||||
<PersonTimingStep
|
||||
plan={plan}
|
||||
computed={computed}
|
||||
role={pages[page] as PersonRole}
|
||||
onChanged={onChanged}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Kein Zurueck/Weiter: Die Reiter oben sind der Weg durch die Bereiche. Zwei
|
||||
Navigationen fuer dieselbe Bewegung sind eine zu viel. */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HorizonStep({ plan, onChanged }: { plan: PlanInput; onChanged: () => void }) {
|
||||
const [years, setYears] = useState(plan.planningHorizonYears ?? DEFAULT_PLANNING_HORIZON_YEARS);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showRates, setShowRates] = useState(false);
|
||||
|
||||
async function save() {
|
||||
// Die Phase darf nicht ueber eine Pensionierung hinausreichen -- die Rechnung leitet den
|
||||
// Erwerbsstatus am PHASENBEGINN ab. Statt das hinterher als Fehler zu melden, steht die
|
||||
// Grenze hier und begrenzt das Feld.
|
||||
const cap = maxPhaseDuration(
|
||||
plan.persons.map((x) => ({ role: x.role, age: x.age, retirementAge: x.retirementAge })),
|
||||
0
|
||||
);
|
||||
const maxYears = cap ?? 60;
|
||||
const years = Math.min(Math.max(1, Math.round(draft.years)), maxYears);
|
||||
|
||||
const existing = plan.phases.length > 0 ? plan.phases[0] : null;
|
||||
const phaseId = draft.createdPhaseId ?? existing?.id ?? null;
|
||||
|
||||
async function createPhase() {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.post(`/api/scenarios/${plan.id}/horizon`, { horizonYears: Math.round(years) });
|
||||
onChanged();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Nicht möglich.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="text-sm text-muted">
|
||||
Über wie viele Jahre soll gerechnet werden? Der Horizont sollte deutlich über der Lebenserwartung liegen –
|
||||
eine zu kurze Planung sieht tragfähig aus, obwohl das Geld nur nicht lange genug reichen muss.
|
||||
</p>
|
||||
<div className="flex items-end gap-3">
|
||||
<div className="w-40">
|
||||
<NumberField
|
||||
label="Planungshorizont (Jahre)"
|
||||
value={years}
|
||||
min={MIN_PLANNING_HORIZON_YEARS}
|
||||
max={MAX_PLANNING_HORIZON_YEARS}
|
||||
step={1}
|
||||
onChange={setYears}
|
||||
/>
|
||||
</div>
|
||||
<Button disabled={busy} onClick={save}>
|
||||
{busy ? "…" : "Übernehmen"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-border bg-surface-2 p-3">
|
||||
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-faint">Daraus ergibt sich</p>
|
||||
<div className="flex flex-col gap-1 text-sm">
|
||||
{plan.persons.map((p) => (
|
||||
<div key={p.role} className="flex justify-between gap-3">
|
||||
<span className="text-muted">{ownerLabel(plan.persons, p.role)} am Planende</span>
|
||||
<span className="tabular-nums text-fg">{p.age + Math.round(years)} Jahre</span>
|
||||
</div>
|
||||
))}
|
||||
{plan.startYear && (
|
||||
<div className="flex justify-between gap-3 border-t border-border pt-1">
|
||||
<span className="text-muted">Letztes Planjahr</span>
|
||||
<span className="tabular-nums text-fg">{plan.startYear + Math.round(years) - 1}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{error && <p className="text-sm text-danger">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PersonTimingStep({
|
||||
plan,
|
||||
computed,
|
||||
role,
|
||||
onChanged,
|
||||
}: {
|
||||
plan: PlanInput;
|
||||
computed: PlanComputed;
|
||||
role: PersonRole;
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
const person = plan.persons.find((p) => p.role === role)!;
|
||||
const own = plan.elements.filter(
|
||||
(e) => e.ownerRole === role && ["AHV", "PENSION_FUND", "PILLAR_3A"].includes(e.category)
|
||||
);
|
||||
const summary = computed.retirement.perPerson.find((x) => x.role === role);
|
||||
const [age, setAge] = useState(person.retirementAge);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [draft, setDraft] = useState<Record<string, Record<string, unknown>>>({});
|
||||
|
||||
async function saveAge() {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.patch(`/api/scenarios/${plan.id}`, {
|
||||
persons: plan.persons.map((p) => ({
|
||||
role: p.role,
|
||||
retirementAge: p.role === role ? Math.round(age) : p.retirementAge,
|
||||
})),
|
||||
const { phase } = await api.post<{ phase: { id: string } }>(`/api/scenarios/${plan.id}/phases`, {
|
||||
name: draft.name.trim() || "Erste Lebensphase",
|
||||
durationYears: years,
|
||||
});
|
||||
onChanged();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Nicht möglich.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveDecisions() {
|
||||
setBusy(true);
|
||||
try {
|
||||
for (const [id, patch] of Object.entries(draft)) {
|
||||
const el = plan.elements.find((x) => x.id === id);
|
||||
await api.put(`/api/elements/${id}/retirement`, { ...(el?.retirementDecision ?? {}), ...patch });
|
||||
}
|
||||
setDraft({});
|
||||
onChanged();
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="rounded-xl border border-border p-3">
|
||||
<div className="flex items-end gap-3">
|
||||
<div className="w-44">
|
||||
<NumberField
|
||||
label="Erwerbstätigkeit endet mit"
|
||||
help="Ab diesem Alter fliesst kein Lohn mehr. Das ist nicht zwingend dasselbe Alter, in dem deine Renten beginnen."
|
||||
value={age}
|
||||
min={40}
|
||||
max={75}
|
||||
step={1}
|
||||
onChange={setAge}
|
||||
/>
|
||||
</div>
|
||||
<Button disabled={busy || Math.round(age) === person.retirementAge} onClick={saveAge}>
|
||||
Übernehmen
|
||||
</Button>
|
||||
</div>
|
||||
{plan.startYear && (
|
||||
<p className="mt-1 text-xs text-faint">
|
||||
Das ist das Jahr {plan.startYear + (Math.round(age) - person.age)}.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{own.length === 0 ? (
|
||||
<p className="rounded-lg border border-dashed border-border bg-surface-2 p-3 text-sm text-muted">
|
||||
Für {ownerLabel(plan.persons, role)} sind noch keine Vorsorge-Elemente erfasst. Gehe zurück zu Schritt 1
|
||||
und lege AHV, Pensionskasse und Säule 3a an – dann kannst du hier die Bezugszeitpunkte festlegen.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm text-muted">
|
||||
Wann soll welches Guthaben zu fliessen beginnen? Jeder dieser Zeitpunkte erzeugt in Schritt 3 eine
|
||||
Phasengrenze – FPT rechnet phasenweise, deshalb muss ein Bezug immer am Anfang einer Lebensphase stehen.
|
||||
</p>
|
||||
<div className="flex flex-col gap-2">
|
||||
{own.map((el) => (
|
||||
<RetirementFields
|
||||
key={el.id}
|
||||
plan={plan}
|
||||
el={el}
|
||||
defaultOpen
|
||||
patch={(id, patch) =>
|
||||
setDraft((d) => ({ ...d, [id]: { ...(d[id] ?? {}), ...patch } }))
|
||||
}
|
||||
summary={summary}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{Object.keys(draft).length > 0 && (
|
||||
<Button disabled={busy} onClick={saveDecisions}>
|
||||
{busy ? "…" : "Bezugszeitpunkte speichern"}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{error && <p className="text-sm text-danger">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================================
|
||||
// Schritt 3: Lebensphasen
|
||||
// ============================================================================================
|
||||
|
||||
export function Step3Phases({ plan, onChanged }: { plan: PlanInput; onChanged: () => void }) {
|
||||
const segments = planSegments(fixpointPersonsOf(plan));
|
||||
const horizon = plan.planningHorizonYears ?? DEFAULT_PLANNING_HORIZON_YEARS;
|
||||
const [draft, setDraft] = useState<{ name: string; years: number }[][]>(() =>
|
||||
segments.map((seg, i) => [
|
||||
{
|
||||
name: defaultName(seg, i),
|
||||
years: seg.fixedYears ?? defaultOpenDuration(plan.persons, seg.fromYear, horizon),
|
||||
},
|
||||
])
|
||||
);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const sums = draft.map((phs) => phs.reduce((s, p) => s + Math.max(0, p.years), 0));
|
||||
const valid = segments.every((seg, i) => seg.fixedYears === null || sums[i] === seg.fixedYears);
|
||||
|
||||
async function create() {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
for (const phs of draft) {
|
||||
for (const ph of phs) {
|
||||
await api.post(`/api/scenarios/${plan.id}/phases`, {
|
||||
name: ph.name.trim() || "Lebensphase",
|
||||
durationYears: Math.max(1, Math.round(ph.years)),
|
||||
});
|
||||
// Die jaehrlichen Annahmen gehoeren zu DIESER Phase -- deshalb erst jetzt schreibbar.
|
||||
for (const [elementId, values] of Object.entries(draft.phase)) {
|
||||
if (Object.keys(values).length > 0) {
|
||||
await api.put(`/api/elements/${elementId}/phase/${phase.id}`, values);
|
||||
}
|
||||
}
|
||||
setDraft((d) => ({ ...d, createdPhaseId: phase.id }));
|
||||
onChanged();
|
||||
setShowRates(true);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Anlegen fehlgeschlagen.");
|
||||
} finally {
|
||||
@@ -741,252 +570,114 @@ export function Step3Phases({ plan, onChanged }: { plan: PlanInput; onChanged: (
|
||||
}
|
||||
}
|
||||
|
||||
if (plan.phases.length > 0) {
|
||||
// Seite 3: die Sparquote verteilen. Erst moeglich, wenn die Phase steht.
|
||||
if (showRates && phaseId) {
|
||||
return (
|
||||
<p className="rounded-lg border border-dashed border-border bg-surface-2 p-3 text-sm text-muted">
|
||||
Dieses Szenario hat bereits {plan.phases.length} Lebensphasen. Weitere legst du direkt in der Matrix an
|
||||
(«+ Lebensphase» in der Ecke oben links) – dort siehst du auch gleich, was sich dadurch ändert.
|
||||
</p>
|
||||
<RateDistributionDialog
|
||||
plan={plan}
|
||||
computed={computed}
|
||||
phaseId={phaseId}
|
||||
onClose={() => setShowRates(false)}
|
||||
onSaved={() => {
|
||||
setShowRates(false);
|
||||
onChanged();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (existing && !draft.createdPhaseId) {
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="rounded-lg border border-dashed border-border bg-surface-2 p-3 text-sm text-muted">
|
||||
Die erste Lebensphase «{existing.name}» steht bereits ({existing.durationYears} Jahre). Die jährlichen
|
||||
Annahmen und die Sparquote änderst du direkt in der Matrix – oder hier über die Verteilung.
|
||||
</p>
|
||||
<Button onClick={() => setShowRates(true)}>Sparquote verteilen</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="text-sm text-muted">
|
||||
Die dick umrandeten Abschnitte sind gesetzt – sie ergeben sich aus deinen Bezugszeitpunkten. Innerhalb
|
||||
eines Abschnitts kannst du beliebig fein unterteilen, die Summe muss aber genau aufgehen.
|
||||
</p>
|
||||
{/* Seite 1: Name und Dauer. */}
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<TextField
|
||||
label="Wie nennst du diese Phase?"
|
||||
value={draft.name}
|
||||
onChange={(v) => setDraft((d) => ({ ...d, name: v }))}
|
||||
/>
|
||||
<NumberField
|
||||
label="Wie viele Jahre dauert sie?"
|
||||
help={
|
||||
cap !== null
|
||||
? `Höchstens ${cap} Jahre – danach beginnt die Pensionierung, und dort muss eine neue Lebensphase starten.`
|
||||
: "Frei wählbar."
|
||||
}
|
||||
value={years}
|
||||
min={1}
|
||||
max={maxYears}
|
||||
step={1}
|
||||
onChange={(v) => setDraft((d) => ({ ...d, years: Math.round(v) }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{segments.map((seg, i) => (
|
||||
<div key={i} className="rounded-xl border border-border p-3">
|
||||
<div className="mb-2 flex flex-wrap items-baseline gap-2">
|
||||
<span className="text-sm font-semibold text-fg">
|
||||
{seg.type === "ERWERB" ? "Erwerbsphase" : seg.type === "MIXED" ? "Übergangszeit" : "Ruhestand"}
|
||||
</span>
|
||||
<span className="text-xs text-muted">
|
||||
ab Jahr {seg.fromYear + 1}
|
||||
{seg.fixedYears !== null ? ` · ${seg.fixedYears} Jahre fest` : " · Dauer frei"}
|
||||
</span>
|
||||
{seg.endedBy.length > 0 && (
|
||||
<span className="text-xs text-faint">endet mit: {seg.endedBy.map((f) => f.label).join(", ")}</span>
|
||||
)}
|
||||
{seg.fixedYears !== null && (
|
||||
<span
|
||||
className={`ml-auto text-xs font-semibold ${
|
||||
sums[i] === seg.fixedYears ? "text-success" : "text-danger"
|
||||
}`}
|
||||
>
|
||||
{sums[i]} / {seg.fixedYears} Jahre
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
{draft[i].map((ph, j) => (
|
||||
<div key={j} className="flex items-end gap-2">
|
||||
<div className="flex-1">
|
||||
<TextField
|
||||
label="Bezeichnung"
|
||||
value={ph.name}
|
||||
onChange={(v) =>
|
||||
setDraft((d) => d.map((x, xi) => (xi === i ? x.map((y, yj) => (yj === j ? { ...y, name: v } : y)) : x)))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-28">
|
||||
<NumberField
|
||||
label="Jahre"
|
||||
value={ph.years}
|
||||
min={1}
|
||||
step={1}
|
||||
onChange={(v) =>
|
||||
setDraft((d) => d.map((x, xi) => (xi === i ? x.map((y, yj) => (yj === j ? { ...y, years: Math.round(v) } : y)) : x)))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{draft[i].length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDraft((d) => d.map((x, xi) => (xi === i ? x.filter((_, yj) => yj !== j) : x)))}
|
||||
className="mb-1 rounded p-1.5 text-faint hover:bg-danger-soft hover:text-danger"
|
||||
aria-label="Phase entfernen"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setDraft((d) =>
|
||||
d.map((x, xi) => (xi === i ? [...x, { name: `Abschnitt ${x.length + 1}`, years: 1 }] : x))
|
||||
)
|
||||
}
|
||||
className="flex w-fit items-center gap-1 rounded-lg border border-dashed border-border px-2 py-1 text-xs text-muted hover:border-accent"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" /> Weiter unterteilen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{!valid && (
|
||||
<p className="flex items-start gap-2 text-xs text-danger">
|
||||
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
||||
In mindestens einem festen Abschnitt geht die Summe der Jahre nicht auf.
|
||||
{plan.startYear && (
|
||||
<p className="text-xs text-faint">
|
||||
Das sind die Jahre {plan.startYear} bis {plan.startYear + years - 1}
|
||||
{plan.persons.length > 0 && (
|
||||
<>
|
||||
{" · "}
|
||||
{plan.persons.map((x) => `${ownerLabel(plan.persons, x.role)} ${x.age} → ${x.age + years}`).join(" · ")}
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Seite 2: die jaehrlichen Annahmen. */}
|
||||
<div className="border-t border-border pt-3">
|
||||
<p className="mb-1 text-sm font-semibold text-fg">Womit rechnest du in dieser Zeit?</p>
|
||||
<p className="mb-3 text-xs text-muted">
|
||||
Diese Annahmen gelten nur für diese Lebensphase. In einer späteren Phase kannst du andere setzen – etwa
|
||||
eine vorsichtigere Rendite, wenn du im Ruhestand anders anlegst.
|
||||
</p>
|
||||
|
||||
{plan.elements.length === 0 ? (
|
||||
<p className="rounded-lg border border-dashed border-border bg-surface-2 p-3 text-sm text-muted">
|
||||
Es sind noch keine finanziellen Elemente erfasst. Geh zurück zu Schritt 1.
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{plan.elements.map((el) => {
|
||||
const kind = KINDS.find((k) => k.category === el.category);
|
||||
if (!kind?.phaseFields) return null;
|
||||
const values = draft.phase[el.id] ?? {};
|
||||
const set = (patch: Partial<PhaseData>) =>
|
||||
setDraft((d) => ({ ...d, phase: { ...d.phase, [el.id]: { ...(d.phase[el.id] ?? {}), ...patch } } }));
|
||||
return (
|
||||
<div key={el.id} className="rounded-xl border border-border p-3">
|
||||
<div className="mb-2 flex items-baseline gap-2">
|
||||
<span className="text-sm font-semibold text-fg">{el.name}</span>
|
||||
<span className="text-xs text-faint">{CATEGORY_LABELS[el.category]}</span>
|
||||
{el.ownerRole && el.ownerRole !== "HOUSEHOLD" && (
|
||||
<span className="rounded bg-surface-2 px-1.5 py-0.5 text-[10px] text-muted">
|
||||
{ownerLabel(plan.persons, el.ownerRole)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-2">{kind.phaseFields(values, set)}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-danger">{error}</p>}
|
||||
<Button disabled={busy || !valid} onClick={create}>
|
||||
{busy ? "…" : "Lebensphasen anlegen"}
|
||||
|
||||
<Button disabled={busy || plan.elements.length === 0} onClick={createPhase}>
|
||||
{busy ? "…" : "Lebensphase anlegen und Sparquote verteilen"}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function defaultName(seg: PlanSegment, i: number): string {
|
||||
if (seg.type === "ERWERB") return i === 0 ? "Erwerbsjahre" : "Erwerbsjahre II";
|
||||
if (seg.type === "MIXED") return "Übergangszeit";
|
||||
return "Ruhestand";
|
||||
}
|
||||
|
||||
// ============================================================================================
|
||||
// Schritte 4 bis 6: durch die Phasen und Übergänge führen
|
||||
// ============================================================================================
|
||||
//
|
||||
// Alle drei arbeiten gleich -- sie unterscheiden sich nur im Ausschnitt der Zeitachse. Statt
|
||||
// drei fast gleicher Komponenten gibt es eine mit einem Filter.
|
||||
|
||||
export type WalkScope = "WORK" | "RETIRE" | "PENSION";
|
||||
|
||||
export function StepWalkthrough({
|
||||
plan,
|
||||
computed,
|
||||
scope,
|
||||
onChanged,
|
||||
}: {
|
||||
plan: PlanInput;
|
||||
computed: PlanComputed;
|
||||
scope: WalkScope;
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
const [pos, setPos] = useState(0);
|
||||
const [open, setOpen] = useState<"capital" | "rates" | null>(null);
|
||||
|
||||
// Die Stationen: Phasen und die Übergänge dazwischen, gefiltert auf den Ausschnitt.
|
||||
const stations = computed.phases.flatMap((ph, i) => {
|
||||
const next = computed.phases[i + 1];
|
||||
const kind = phaseKind(ph.type, next?.type);
|
||||
const out: { phaseId: string; label: string; type: "phase" | "transition"; toName?: string }[] = [];
|
||||
if (inScope(scope, ph.type === "ERWERB" ? "WORK" : ph.type === "MIXED" ? "RETIRE" : "PENSION"))
|
||||
out.push({ phaseId: ph.id, label: ph.name, type: "phase" });
|
||||
if (next && inScope(scope, kind))
|
||||
out.push({ phaseId: ph.id, label: `Übergang nach ${next.name}`, type: "transition", toName: next.name });
|
||||
return out;
|
||||
});
|
||||
|
||||
if (stations.length === 0) {
|
||||
return (
|
||||
<p className="rounded-lg border border-dashed border-border bg-surface-2 p-3 text-sm text-muted">
|
||||
In diesem Abschnitt deiner Planung gibt es nichts zu entscheiden.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
const st = stations[Math.min(pos, stations.length - 1)];
|
||||
const phase = computed.phases.find((p) => p.id === st.phaseId)!;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted">
|
||||
Station {pos + 1} von {stations.length}
|
||||
</span>
|
||||
<div className="flex flex-1 gap-1">
|
||||
{stations.map((_, i) => (
|
||||
<div key={i} className={`h-1 flex-1 rounded-full ${i <= pos ? "bg-accent" : "bg-surface-2"}`} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-border p-4">
|
||||
<h4 className="text-base font-semibold text-fg">{st.label}</h4>
|
||||
<p className="mt-1 text-xs text-muted">
|
||||
{st.type === "phase"
|
||||
? `${phase.durationYears} Jahre · ${phase.isConsumption ? "Verzehrquote" : "Sparquote"} ${formatChf(phase.quotaStart)} im ersten Jahr`
|
||||
: "Was geschieht beim Wechsel in die nächste Lebensphase?"}
|
||||
</p>
|
||||
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{st.type === "phase" ? (
|
||||
<>
|
||||
<Button size="sm" onClick={() => setOpen("rates")}>
|
||||
{phase.isConsumption ? "Bezüge verteilen" : "Sparquote verteilen"}
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" onClick={() => setOpen("capital")}>
|
||||
Kapital verteilen
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm text-muted">
|
||||
Die Entscheide dieses Übergangs triffst du in der Matrix – klicke dort auf die Übergangsspalte
|
||||
nach «{phase.name}».
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between">
|
||||
<Button variant="ghost" disabled={pos === 0} onClick={() => setPos((p) => p - 1)}>
|
||||
<ArrowLeft className="h-4 w-4" /> Vorherige
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={pos >= stations.length - 1}
|
||||
onClick={() => setPos((p) => p + 1)}
|
||||
>
|
||||
Nächste <ArrowRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{open === "capital" && (
|
||||
<CapitalDistributionDialog
|
||||
plan={plan}
|
||||
computed={computed}
|
||||
phaseId={st.phaseId}
|
||||
onClose={() => setOpen(null)}
|
||||
onSaved={() => {
|
||||
setOpen(null);
|
||||
onChanged();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{open === "rates" && (
|
||||
<RateDistributionDialog
|
||||
plan={plan}
|
||||
computed={computed}
|
||||
phaseId={st.phaseId}
|
||||
onClose={() => setOpen(null)}
|
||||
onSaved={() => {
|
||||
setOpen(null);
|
||||
onChanged();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Zu welchem Abschnitt gehört ein Übergang? Massgeblich ist, WOHIN er führt.
|
||||
function phaseKind(from: string, to?: string): WalkScope {
|
||||
if (!to) return from === "ERWERB" ? "WORK" : from === "MIXED" ? "RETIRE" : "PENSION";
|
||||
if (to === "ERWERB") return "WORK";
|
||||
if (to === "MIXED") return "RETIRE";
|
||||
return from === "PENSION" ? "PENSION" : "RETIRE";
|
||||
}
|
||||
|
||||
function inScope(scope: WalkScope, kind: WalkScope): boolean {
|
||||
return scope === kind;
|
||||
}
|
||||
|
||||
|
||||
+34
-48
@@ -48,16 +48,15 @@ import { Assistant } from "@/components/Assistant";
|
||||
import { AssistantStepDialog } from "@/components/AssistantStepDialog";
|
||||
import {
|
||||
Step1Elements,
|
||||
Step2Timeline,
|
||||
Step3Phases,
|
||||
StepWalkthrough,
|
||||
Step2FirstPhase,
|
||||
BaseFields,
|
||||
emptyStep1Draft,
|
||||
emptyStep2Draft,
|
||||
saveStep1Draft,
|
||||
type Step1Draft,
|
||||
type Step2Draft,
|
||||
} from "@/components/AssistantSteps";
|
||||
import { normalizeProgress } from "@/lib/assistant";
|
||||
import { RetirementPanel } from "@/components/RetirementPanel";
|
||||
import { Button, EmptyState, InspectorShell, Modal, useConfirm, useToast } from "@/components/ui";
|
||||
import { ElementDetailDialog, PhaseDetailDialog } from "@/components/DetailView";
|
||||
import {
|
||||
@@ -93,7 +92,7 @@ import {
|
||||
} from "@/lib/elements";
|
||||
import { computePlan, type PhaseComputed, type PlanComputed } from "@/lib/calculations";
|
||||
import type { ScenarioDiff } from "@/lib/diff";
|
||||
import type { ElementInput, PersonRole, PlanInput } from "@/lib/types";
|
||||
import type { ElementInput, PlanInput } from "@/lib/types";
|
||||
|
||||
const CATEGORY_ICON: Record<ElementCategory, React.ReactNode> = {
|
||||
INCOME: <Wallet className="h-4 w-4" />,
|
||||
@@ -215,7 +214,7 @@ export function PlanView({
|
||||
// Karten: Beim Wechsel des Reiters (Gemeinsam -> Person A) verschwinden die Karten des
|
||||
// vorigen Bereichs, und mit ihrem eigenen Zustand waeren alle getippten Zahlen still weg.
|
||||
const [step1Draft, setStep1Draft] = useState<Step1Draft>(emptyStep1Draft);
|
||||
const [retirementRole, setRetirementRole] = useState<PersonRole | null>(null);
|
||||
const [step2Draft, setStep2Draft] = useState<Step2Draft>(emptyStep2Draft);
|
||||
const progress = normalizeProgress(plan.assistantProgress);
|
||||
// Die Tour (Start-Knopf, Auto-Start bei Plan-Erstellung, Rendering) liegt seit dem
|
||||
// Layout-Umbau in AppShell -- sie liest die data-tour-Ziele im DOM dieser Ansicht.
|
||||
@@ -414,7 +413,6 @@ export function PlanView({
|
||||
computed={computed}
|
||||
diffChanged={!!diff?.profileChanged}
|
||||
onEdit={() => setPanel({ kind: "profile" })}
|
||||
onRetirement={(role) => setRetirementRole(role)}
|
||||
/>
|
||||
<FiguresTile plan={plan} computed={computed} />
|
||||
<QuickActionsTile
|
||||
@@ -964,31 +962,18 @@ export function PlanView({
|
||||
onChanged={onChanged}
|
||||
/>
|
||||
)}
|
||||
{assistantStep === 1 && <Step2Timeline plan={plan} computed={computed} onChanged={onChanged} />}
|
||||
{assistantStep === 2 && <Step3Phases plan={plan} onChanged={onChanged} />}
|
||||
{assistantStep === 3 && (
|
||||
<StepWalkthrough plan={plan} computed={computed} scope="WORK" onChanged={onChanged} />
|
||||
)}
|
||||
{assistantStep === 4 && (
|
||||
<StepWalkthrough plan={plan} computed={computed} scope="RETIRE" onChanged={onChanged} />
|
||||
)}
|
||||
{assistantStep === 5 && (
|
||||
<StepWalkthrough plan={plan} computed={computed} scope="PENSION" onChanged={onChanged} />
|
||||
{assistantStep === 1 && (
|
||||
<Step2FirstPhase
|
||||
plan={plan}
|
||||
computed={computed}
|
||||
draft={step2Draft}
|
||||
setDraft={(fn) => setStep2Draft((d) => fn(d))}
|
||||
onChanged={onChanged}
|
||||
/>
|
||||
)}
|
||||
</AssistantStepDialog>
|
||||
)}
|
||||
|
||||
{retirementRole && (
|
||||
<Modal
|
||||
title={`Pensionsplanung · ${ownerLabel(plan.persons, retirementRole)}`}
|
||||
subtitle="Alle Entscheide zu AHV, Pensionskasse und Säule 3a an einem Ort"
|
||||
onClose={() => setRetirementRole(null)}
|
||||
xwide
|
||||
>
|
||||
<RetirementPanel plan={plan} computed={computed} role={retirementRole} onSaved={onChanged} />
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{reviewFromPhaseId && (() => {
|
||||
const fromPhase = computed.phases.find((p) => p.id === reviewFromPhaseId);
|
||||
if (!fromPhase) return null;
|
||||
@@ -2455,16 +2440,18 @@ function SettingsTile({
|
||||
computed,
|
||||
diffChanged,
|
||||
onEdit,
|
||||
onRetirement,
|
||||
}: {
|
||||
plan: PlanInput;
|
||||
computed: PlanComputed;
|
||||
diffChanged: boolean;
|
||||
onEdit: () => void;
|
||||
onRetirement: (role: PersonRole) => void;
|
||||
}) {
|
||||
const horizon = plan.planningHorizonYears ?? null;
|
||||
const endYear = plan.startYear && horizon ? plan.startYear + horizon - 1 : null;
|
||||
// Der Planungshorizont wird ABGELEITET -- er ist die Summe der Lebensphasen. Bis 0.37 war
|
||||
// er eine eigene Zahl, die man setzte und der die letzte Phase folgte. Das war eine zweite
|
||||
// Wahrheit über dieselbe Sache: Wer die Phasen ohnehin einzeln plant, hat den Horizont
|
||||
// damit schon bestimmt.
|
||||
const horizon = computed.phases.reduce((n, ph) => n + ph.durationYears, 0);
|
||||
const endYear = plan.startYear && horizon > 0 ? plan.startYear + horizon - 1 : null;
|
||||
|
||||
return (
|
||||
<Tile
|
||||
@@ -2491,29 +2478,28 @@ function SettingsTile({
|
||||
<div>Start {plan.startYear ?? "—"} · Inflation {plan.inflationRateDefault} %</div>
|
||||
</div>
|
||||
|
||||
{/* Szenario-eigen: das, was man zwischen Varianten unterscheidet. */}
|
||||
{/* Szenario-eigen: Laufzeit und Pensionsalter. */}
|
||||
<div className="mt-1 border-t border-border pt-1 text-[11px] text-muted">
|
||||
<div>
|
||||
Horizont {horizon ? `${horizon} Jahre` : "—"}
|
||||
Planungshorizont {horizon > 0 ? `${horizon} Jahre` : "—"}
|
||||
{endYear ? ` · bis ${endYear}` : ""}
|
||||
</div>
|
||||
{plan.persons.map((p) => (
|
||||
<div key={p.role} className="mt-0.5 flex items-center gap-1">
|
||||
<span className="truncate">
|
||||
Pension {p.retirementAge}
|
||||
{horizon ? ` · Ende ${p.age + horizon}` : ""}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRetirement(p.role)}
|
||||
className="ml-auto shrink-0 rounded border border-border px-1.5 py-0.5 text-[10px] font-medium text-muted hover:bg-surface-2"
|
||||
title={`Pensionsplanung ${ownerLabel(plan.persons, p.role)}`}
|
||||
>
|
||||
Pensionsplanung
|
||||
</button>
|
||||
<div key={p.role} className="mt-0.5 truncate">
|
||||
Pensionsalter {ownerLabel(plan.persons, p.role)}: {p.retirementAge}
|
||||
{horizon > 0 ? ` · Endalter ${p.age + horizon}` : ""}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Im Basisszenario wird mit dem Referenzalter gerechnet. Abweichende Pensionsmodelle
|
||||
(Vorbezug, Aufschub, gestaffelte Kapitalbezüge) sind im Rechenkern vorhanden, werden
|
||||
hier aber bewusst nicht angeboten -- sie gehören zu einer eigenen Szenario-Art. */}
|
||||
<p className="mt-1 border-t border-border pt-1 text-[10px] leading-snug text-faint">
|
||||
Im Basisszenario wird mit Pensionsalter {AHV_REFERENCE_AGE} gerechnet. Abweichende Pensionsmodelle folgen
|
||||
als eigene Szenario-Art.
|
||||
</p>
|
||||
|
||||
{computed.retirement.gapAnnual !== null && (
|
||||
<div className="mt-1 text-[11px] text-faint">
|
||||
Rentenlücke {formatChf(computed.retirement.gapAnnual)}/Jahr
|
||||
@@ -2664,7 +2650,7 @@ function BaseDataPanel({
|
||||
Planung. Abweichende Werte für einzelne Phasen erfasst du in der jeweiligen Zelle.
|
||||
</p>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<BaseFields category={el.category} values={draft} set={set} plan={plan} />
|
||||
<BaseFields category={el.category} values={draft} set={set} />
|
||||
</div>
|
||||
{error && <p className="text-sm text-danger">{error}</p>}
|
||||
<div className="flex gap-2 pt-1">
|
||||
|
||||
@@ -1,266 +0,0 @@
|
||||
"use client";
|
||||
|
||||
// Der Pensionierungs-Bildschirm.
|
||||
//
|
||||
// Bis 0.33 lagen die Entscheide, die inhaltlich EINE Frage sind, in drei weit auseinander
|
||||
// liegenden Matrix-Zellen (AHV, PK, 3a) am Pensions-Übergang. Hier stehen sie beieinander --
|
||||
// in der Reihenfolge, in der man tatsächlich darüber nachdenkt:
|
||||
//
|
||||
// Wann höre ich auf? → Was kommt dann rein? → Was habe ich auf einen Schlag?
|
||||
// → Reicht das? → Was mache ich mit dem Haufen?
|
||||
//
|
||||
// Die Leitzahl ganz oben ist die RENTENLÜCKE. Sie ist keine neue Rechnung, sondern die
|
||||
// Verzehrquote im ersten voll pensionierten Jahr -- es fehlte bisher nur der Name dafür.
|
||||
// Gerechnet wird sie im Rechenkern (`computed.retirement`), damit Bildschirm und PDF-Bericht
|
||||
// nicht auseinanderlaufen können.
|
||||
//
|
||||
// Gestaltungsprinzip: KEIN leeres Formular, sondern ein vollständiger Vorschlag, den man
|
||||
// korrigiert. Sonst müsste man am Anfang Fragen beantworten, die man erst am Ende beantworten
|
||||
// kann -- und ohne Vorgaben wäre der Plan bis dahin gar nicht rechenbar.
|
||||
|
||||
import { useState } from "react";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
import { api } from "@/lib/api-client";
|
||||
import { Button, useToast } from "@/components/ui";
|
||||
import { InfoBubble } from "@/components/InfoBubble";
|
||||
import { RetirementAdjuster } from "@/components/RetirementAdjuster";
|
||||
import { RetirementFields } from "@/components/RetirementFields";
|
||||
import { formatChf } from "@/lib/format";
|
||||
import { ownerLabel } from "@/lib/elements";
|
||||
import { AHV_REFERENCE_AGE, PK_MIN_RETIREMENT_AGE } from "@/lib/constants";
|
||||
import { withRetirementDefaults, type RetirementDecision } from "@/lib/retirement-decision";
|
||||
import type { PlanComputed, RetirementPersonSummary } from "@/lib/calculations";
|
||||
import type { ElementInput, PersonRole, PlanInput } from "@/lib/types";
|
||||
|
||||
type Draft = Record<string, RetirementDecision>;
|
||||
|
||||
export function RetirementPanel({
|
||||
plan,
|
||||
computed,
|
||||
role,
|
||||
onSaved,
|
||||
}: {
|
||||
plan: PlanInput;
|
||||
computed: PlanComputed;
|
||||
// Seit 0.36 immer GENAU eine Person: Der Bildschirm wird über den Knopf «Pensionsplanung»
|
||||
// in den Grundeinstellungen geöffnet, und der steht je Person.
|
||||
role: PersonRole;
|
||||
onSaved: () => void;
|
||||
}) {
|
||||
const toast = useToast();
|
||||
const [draft, setDraft] = useState<Draft>({});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Effektiver Entscheid je Element: gespeicherter Wert, überlagert vom Entwurf, aufgefüllt
|
||||
// mit den Vorgaben. Genau das rechnet auch der Rechenkern.
|
||||
const effective = (el: ElementInput): RetirementDecision => {
|
||||
const person = plan.persons.find((p) => p.role === el.ownerRole);
|
||||
return withRetirementDefaults(el.category, person?.retirementAge ?? AHV_REFERENCE_AGE, {
|
||||
...(el.retirementDecision ?? {}),
|
||||
...(draft[el.id] ?? {}),
|
||||
});
|
||||
};
|
||||
|
||||
const patch = (elementId: string, p: Partial<RetirementDecision>) =>
|
||||
setDraft((d) => ({ ...d, [elementId]: { ...(d[elementId] ?? {}), ...p } }));
|
||||
|
||||
const dirty = Object.keys(draft).length > 0;
|
||||
|
||||
async function save() {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
for (const [elementId, changes] of Object.entries(draft)) {
|
||||
const el = plan.elements.find((x) => x.id === elementId);
|
||||
if (!el) continue;
|
||||
await api.put(`/api/elements/${elementId}/retirement`, {
|
||||
...(el.retirementDecision ?? {}),
|
||||
...changes,
|
||||
});
|
||||
}
|
||||
setDraft({});
|
||||
toast("success", "Pensionierung gespeichert.");
|
||||
onSaved();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Speichern fehlgeschlagen.");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
<p className="text-sm text-muted">
|
||||
Was du hier festlegst, steht auch in der Matrix am Pensions-Übergang – es ist derselbe Entscheid, nur
|
||||
anders angeschaut.
|
||||
</p>
|
||||
|
||||
<PersonBlock
|
||||
plan={plan}
|
||||
computed={computed}
|
||||
role={role}
|
||||
summary={computed.retirement.perPerson.find((s) => s.role === role)}
|
||||
effective={effective}
|
||||
patch={patch}
|
||||
onSaved={onSaved}
|
||||
/>
|
||||
|
||||
{error && <p className="text-sm text-danger">{error}</p>}
|
||||
{dirty && (
|
||||
<div className="sticky bottom-0 flex items-center gap-3 border-t border-border bg-surface px-1 py-3">
|
||||
<Button disabled={saving} onClick={save}>
|
||||
{saving ? "…" : "Änderungen speichern"}
|
||||
</Button>
|
||||
<Button variant="ghost" disabled={saving} onClick={() => setDraft({})}>
|
||||
Verwerfen
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- ein Block je Person -------------------------------------------------------------------
|
||||
|
||||
function PersonBlock({
|
||||
plan,
|
||||
computed,
|
||||
role,
|
||||
summary,
|
||||
effective,
|
||||
patch,
|
||||
onSaved,
|
||||
}: {
|
||||
plan: PlanInput;
|
||||
computed: PlanComputed;
|
||||
role: PersonRole;
|
||||
summary: RetirementPersonSummary | undefined;
|
||||
effective: (el: ElementInput) => RetirementDecision;
|
||||
patch: (elementId: string, p: Partial<RetirementDecision>) => void;
|
||||
onSaved: () => void;
|
||||
}) {
|
||||
const person = plan.persons.find((p) => p.role === role)!;
|
||||
const label = ownerLabel(plan.persons, role);
|
||||
const own = (cat: string) => plan.elements.filter((e) => e.category === cat && e.ownerRole === role);
|
||||
const ahvEl = own("AHV")[0];
|
||||
const pkEls = own("PENSION_FUND");
|
||||
const a3Els = own("PILLAR_3A");
|
||||
|
||||
return (
|
||||
<section className="rounded-2xl border border-border">
|
||||
<header className="border-b border-border px-4 py-3">
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-2">
|
||||
<h3 className="text-base font-semibold text-fg">
|
||||
{label} · Pensionierung mit {person.retirementAge}
|
||||
</h3>
|
||||
{person.retirementAge < PK_MIN_RETIREMENT_AGE && (
|
||||
<span className="flex items-center gap-1 text-xs text-attention-fg">
|
||||
<AlertTriangle className="h-3.5 w-3.5" />
|
||||
Vor {PK_MIN_RETIREMENT_AGE} lässt kaum eine Pensionskasse eine Pensionierung zu.
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="px-4 py-4">
|
||||
<RetirementAdjuster plan={plan} onSaved={onSaved} />
|
||||
</div>
|
||||
|
||||
<GapBox plan={plan} computed={computed} summary={summary} />
|
||||
|
||||
<div className="flex flex-col gap-2 px-4 pb-4">
|
||||
{[ahvEl, ...pkEls, ...a3Els].filter(Boolean).map((el) => (
|
||||
<RetirementFields key={el!.id} plan={plan} el={el!} rd={effective(el!)} patch={patch} summary={summary} />
|
||||
))}
|
||||
{!ahvEl && pkEls.length === 0 && a3Els.length === 0 && (
|
||||
<p className="rounded-lg border border-dashed border-border bg-surface-2 p-3 text-sm text-muted">
|
||||
Für {label} sind noch keine Vorsorge-Elemente erfasst. Lege in der Matrix AHV, Pensionskasse oder Säule 3a
|
||||
an – die Entscheide dazu erscheinen dann hier.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Leitzahlen ----------------------------------------------------------------------------
|
||||
|
||||
function GapBox({
|
||||
plan,
|
||||
computed,
|
||||
summary,
|
||||
}: {
|
||||
plan: PlanInput;
|
||||
computed: PlanComputed;
|
||||
summary: RetirementPersonSummary | undefined;
|
||||
}) {
|
||||
const r = computed.retirement;
|
||||
const startYear = plan.startYear ?? null;
|
||||
const horizon = summary?.planningHorizonAge ?? null;
|
||||
const reachesEnd = computed.ruinAge === null;
|
||||
|
||||
return (
|
||||
<div className="mx-4 mb-4 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">
|
||||
<Line label="AHV-Rente" value={summary?.ahvAnnual ?? 0} note={summary?.ahvDraw} />
|
||||
<Line label="PK-Rente" value={summary?.pkPensionAnnual ?? 0} />
|
||||
<div className="my-1 border-t border-border" />
|
||||
<Line label="Renteneinkommen (Haushalt)" value={r.pensionIncome ?? 0} strong />
|
||||
<Line label="Ausgaben (Haushalt)" value={r.expenses ?? 0} />
|
||||
</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. Ein negativer Wert ist normal – er wird aus dem Vermögen gedeckt. Entscheidend ist, wie lange das trägt." />
|
||||
</div>
|
||||
<div
|
||||
className={`text-2xl font-semibold tabular-nums ${
|
||||
(r.gapAnnual ?? 0) < 0 ? "text-danger" : "text-success"
|
||||
}`}
|
||||
>
|
||||
{r.gapAnnual === null ? "–" : `${formatChf(r.gapAnnual)} / Jahr`}
|
||||
</div>
|
||||
{r.firstRetirementYear !== null && startYear && (
|
||||
<div className="text-xs text-faint">
|
||||
gerechnet für {startYear + r.firstRetirementYear - 1}, das erste voll pensionierte Jahr
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs uppercase tracking-wide text-faint">Vermögen reicht</div>
|
||||
<div className={`text-lg font-semibold ${reachesEnd ? "text-success" : "text-danger"}`}>
|
||||
{reachesEnd
|
||||
? horizon
|
||||
? `bis zum Horizont (Alter ${horizon})`
|
||||
: "über die ganze Planung"
|
||||
: `bis Alter ${computed.ruinAge}`}
|
||||
</div>
|
||||
</div>
|
||||
{summary && summary.capitalAtRetirement > 0 && (
|
||||
<div className="text-xs text-muted">
|
||||
Einmalig verfügbar: <strong className="text-fg">{formatChf(summary.capitalAtRetirement)}</strong> netto
|
||||
aus PK und Säule 3a
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Line({ label, value, note, strong }: { label: string; value: number; note?: string; strong?: boolean }) {
|
||||
return (
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<span className={strong ? "font-semibold text-fg" : "text-muted"}>
|
||||
{label}
|
||||
{note && <span className="ml-1 text-xs text-faint">({note})</span>}
|
||||
</span>
|
||||
<span className={`tabular-nums ${strong ? "font-semibold text-fg" : "text-fg"}`}>{formatChf(value)}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user