Assistent auf zwei Schritte, Pensionsalter fix 65, Horizont abgeleitet
Deploy App / deploy (push) Successful in 1m57s

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-16 12:18:56 +02:00
parent 2f6b788bd4
commit 6ff144d7e1
18 changed files with 391 additions and 1185 deletions
@@ -33,7 +33,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
// kopiert. Szenario-eigen sind nur Inflation, Cash-Anfangswert und Pensionsalter.
inflationRateDefault: source.inflationRateDefault,
initialCash: source.initialCash,
planningHorizonYears: source.planningHorizonYears,
assistantProgress: source.assistantProgress ?? undefined,
persons: {
create: source.persons.map((p) => ({ role: p.role, retirementAge: p.retirementAge })),
@@ -1,46 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { prisma } from "@/lib/db";
import { getOwnedScenario, toPlanInput } from "@/lib/queries";
import { getCurrentUserId } from "@/lib/session";
import { touchScenario } from "@/lib/versioning-db";
import { planHorizonChange } from "@/lib/retirement";
import { MAX_PLANNING_HORIZON_YEARS, MIN_PLANNING_HORIZON_YEARS } from "@/lib/constants";
// Planungshorizont setzen: wie viele JAHRE die Planung umfasst.
//
// Wie beim Pensionsalter gilt: Zahl stellen, Struktur folgt. Die LETZTE Lebensphase wird so
// verlängert oder gekürzt, dass der Plan genau bis zum Horizont läuft. Gibt es noch keine
// Phasen, wird nur die Zahl gespeichert -- sie ist dann die Grundlage, auf der der Assistent
// die Zeitachse aufspannt.
const bodySchema = z.object({
horizonYears: z.number().int().min(MIN_PLANNING_HORIZON_YEARS).max(MAX_PLANNING_HORIZON_YEARS),
});
export async function POST(request: NextRequest, { params }: { params: Promise<{ scenarioId: string }> }) {
const userId = await getCurrentUserId();
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
const { scenarioId } = await params;
const scenario = await getOwnedScenario(scenarioId, userId);
if (!scenario) return NextResponse.json({ error: "Szenario nicht gefunden." }, { status: 404 });
const parsed = bodySchema.safeParse(await request.json().catch(() => ({})));
if (!parsed.success) return NextResponse.json({ error: "Ungültige Eingabe." }, { status: 400 });
const { horizonYears } = parsed.data;
const planInput = toPlanInput(scenario);
const change = planInput.phases.length > 0 ? planHorizonChange(planInput, horizonYears) : null;
if (change?.blocked) return NextResponse.json({ error: change.blocked }, { status: 400 });
await prisma.$transaction([
prisma.scenario.update({ where: { id: scenarioId }, data: { planningHorizonYears: horizonYears } }),
...(change
? [prisma.phase.update({ where: { id: change.lastPhaseId }, data: { durationYears: change.newDuration } })]
: []),
]);
await touchScenario(scenario.id, userId);
return NextResponse.json({ ok: true, lastPhaseDuration: change?.newDuration ?? null });
}
+13 -117
View File
@@ -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
View File
@@ -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
View File
@@ -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">
-266
View File
@@ -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>
);
}
+10 -20
View File
@@ -25,7 +25,6 @@ function plan(over: Partial<PlanInput> = {}): PlanInput {
inflationRateDefault: 0,
initialCash: 0,
startYear: 2026,
planningHorizonYears: null,
persons: [{ id: "A", role: "PERSON_A", name: null, age: 40, retirementAge: 65 }],
phases: [],
elements: [],
@@ -47,7 +46,7 @@ function el(category: string, baseData: PhaseData = {}, phaseValues: Record<stri
}
describe("Assistenten-Fortschritt", () => {
it("startet mit sieben offenen Schritten", () => {
it("startet mit lauter offenen Schritten", () => {
expect(emptyProgress()).toHaveLength(ASSISTANT_STEP_COUNT);
expect(emptyProgress().some(Boolean)).toBe(false);
expect(ASSISTANT_STEPS).toHaveLength(ASSISTANT_STEP_COUNT);
@@ -57,9 +56,10 @@ describe("Assistenten-Fortschritt", () => {
// Der Fortschritt liegt als JSON in der Datenbank -- ein alter oder manipulierter Stand
// darf die Ansicht nicht zerlegen.
expect(normalizeProgress(null)).toEqual(emptyProgress());
expect(normalizeProgress([true, false])).toEqual(emptyProgress()); // falsche Länge
expect(normalizeProgress([true])).toEqual(emptyProgress()); // falsche Länge
expect(normalizeProgress([true, false, true])).toEqual(emptyProgress()); // ebenso
expect(normalizeProgress("kaputt")).toEqual(emptyProgress());
const gut = [true, false, true, false, true, false, true];
const gut = [true, false];
expect(normalizeProgress(gut)).toEqual(gut);
});
});
@@ -73,36 +73,26 @@ describe("Abgeleiteter Stand neben dem Haken", () => {
expect(stepStatus(plan({ elements: [el("INCOME"), el("EXPENSE")] }), 0)).toBe("2 Elemente");
});
it("meldet den fehlenden Planungshorizont", () => {
expect(stepStatus(plan(), 1)).toBe("Planungshorizont fehlt");
expect(stepStatus(plan({ planningHorizonYears: 40 }), 1)).toContain("40 Jahre");
});
it("zählt die Lebensphasen -- auch wenn es keine gibt", () => {
expect(stepStatus(plan(), 2)).toBe("0 Lebensphasen");
expect(stepStatus(plan(), 1)).toBe("0 Lebensphasen");
expect(
stepStatus(
plan({ phases: [{ id: "p1", sequenceNumber: 1, name: "p1", durationYears: 5, cashTransition: {} }] }),
2
1
)
).toBe("1 Lebensphase");
});
});
describe("Voraussetzungen je Schritt", () => {
it("sperrt die Phasenplanung, solange der Horizont fehlt", () => {
expect(stepBlockedReason(plan(), 2)).toContain("Planungshorizont");
expect(stepBlockedReason(plan({ planningHorizonYears: 40 }), 2)).toBeNull();
});
it("sperrt die Übergangs-Schritte, solange es keine Phasen gibt", () => {
const p = plan({ planningHorizonYears: 40 });
for (const step of [3, 4, 5]) expect(stepBlockedReason(p, step)).toContain("Lebensphasen");
it("sperrt die Lebensphase, solange keine Elemente erfasst sind", () => {
// Eine Phase ohne Elemente waere ein leerer Zeitraum -- es gaebe nichts zu planen.
expect(stepBlockedReason(plan(), 1)).toContain("finanziellen Elemente");
expect(stepBlockedReason(plan({ elements: [el("INCOME")] }), 1)).toBeNull();
});
it("lässt die Bestandsaufnahme immer zu -- sie braucht keine Zeitachse", () => {
expect(stepBlockedReason(plan(), 0)).toBeNull();
expect(stepBlockedReason(plan(), 1)).toBeNull();
});
});
+19 -62
View File
@@ -1,4 +1,4 @@
// Der FPT-Assistent: sieben Schritte von der leeren Matrix zum fertigen Plan.
// Der FPT-Assistent: der Weg von der leeren Matrix zum Plan.
//
// Er ersetzt die frueheren "Naechsten Schritte". Der Unterschied ist nicht kosmetisch: Die
// alte Karte leitete AB, was zu tun waere; hier fuehrt jeder Schritt sein eigenes Werkzeug
@@ -8,11 +8,15 @@
// Damit der Assistent trotzdem nichts Falsches behauptet, steht neben jedem Haken der
// ABGELEITETE Stand ("0 Lebensphasen"). Ein abgehakter Schritt ohne Substanz faellt so auf,
// ohne dass das Tool den Haken verweigert.
//
// Stand 0.38: Der Assistent fuehrt bis zur ERSTEN Lebensphase. Alles danach (weitere Phasen,
// Uebergaenge, Pensionierung) wird als eigener Ablauf nachgezogen -- lieber zwei Schritte,
// die tragen, als sieben, die nur behaupten zu fuehren.
import { z } from "zod";
import type { PlanInput } from "@/lib/types";
export const ASSISTANT_STEP_COUNT = 7;
export const ASSISTANT_STEP_COUNT = 2;
export type AssistantProgress = boolean[];
@@ -47,52 +51,17 @@ export const ASSISTANT_STEPS: AssistantStep[] = [
},
{
index: 1,
title: "Eckdaten und Pensionsplanung",
short: "Wann was passiert",
lead: "Wie weit du planst und wann du aufhörst zu arbeiten und deine Renten beziehst.",
title: "Erste Lebensphase",
short: "Die ersten Jahre",
lead: "Wie lange die erste Phase dauert, womit du in dieser Zeit rechnest und wohin dein Sparbetrag fliesst.",
guided: true,
},
{
index: 2,
title: "Lebensphasen",
short: "Die Zeitachse",
lead: "Deine Planung in Abschnitte teilen. Die Fixpunkte aus Schritt 2 sind gesetzt.",
guided: true,
},
{
index: 3,
title: "Erwerbsjahre planen",
short: "Sparen bis zur Pension",
lead: "Wohin dein Sparbetrag fliesst und was an den Übergängen dazwischen passiert.",
guided: true,
},
{
index: 4,
title: "Pensionierung planen",
short: "Der Übergang",
lead: "Rente oder Kapital, wohin das Geld fliesst und wie es in den Jahren danach weitergeht.",
guided: true,
},
{
index: 5,
title: "Ruhestand planen",
short: "Die Jahre danach",
lead: "Wovon du lebst, wenn kein Lohn mehr kommt Bezüge aus dem Vermögen und die restlichen Übergänge.",
guided: true,
},
{
index: 6,
title: "Feinschliff",
short: "Was noch geht",
lead: "Was du jetzt noch verfeinern kannst und wo du es findest.",
guided: false,
},
];
// --- Abgeleiteter Stand je Schritt -----------------------------------------------------------
//
// Bewusst NICHT zum Erzwingen des Hakens, sondern als Gegenprobe daneben. Wer "Lebensphasen"
// abhakt, ohne eine anzulegen, sieht "0 Lebensphasen" -- das reicht.
// Bewusst NICHT zum Erzwingen des Hakens, sondern als Gegenprobe daneben. Wer "Erste
// Lebensphase" abhakt, ohne eine anzulegen, sieht "0 Lebensphasen" -- das reicht.
export function stepStatus(plan: PlanInput, index: number): string {
const phases = plan.phases.length;
const elements = plan.elements.length;
@@ -101,32 +70,20 @@ export function stepStatus(plan: PlanInput, index: number): string {
return elements === 0
? "noch nichts erfasst"
: `${elements} ${elements === 1 ? "Element" : "Elemente"}`;
case 1: {
const geplant = plan.elements.filter(
(e) => e.category === "AHV" && e.retirementDecision?.confirmed === true
).length;
const horizon = plan.planningHorizonYears;
if (!horizon) return "Planungshorizont fehlt";
return geplant === plan.persons.length
? `${horizon} Jahre · Pensionsplanung steht`
: `${horizon} Jahre · Pensionsplanung offen`;
}
case 2:
return phases === 0 ? "0 Lebensphasen" : `${phases} ${phases === 1 ? "Lebensphase" : "Lebensphasen"}`;
case 1:
if (phases === 0) return "0 Lebensphasen";
return `${phases} ${phases === 1 ? "Lebensphase" : "Lebensphasen"}`;
default:
return "";
}
}
// Ein Schritt lässt sich erst sinnvoll öffnen, wenn seine Voraussetzung erfüllt ist. Der Haken
// bleibt trotzdem jederzeit setzbar -- gesperrt wird nur das Werkzeug, nicht die Selbstauskunft.
// Ein Schritt laesst sich erst sinnvoll oeffnen, wenn seine Voraussetzung erfuellt ist. Der
// Haken bleibt trotzdem jederzeit setzbar -- gesperrt wird nur das Werkzeug, nicht die
// Selbstauskunft.
export function stepBlockedReason(plan: PlanInput, index: number): string | null {
const hasHorizon = typeof plan.planningHorizonYears === "number" && plan.planningHorizonYears > 0;
if (index === 2 && !hasHorizon) {
return "Lege zuerst den Planungshorizont und die Pensionsplanung fest daraus ergeben sich die Fixpunkte der Zeitachse.";
}
if (index >= 3 && plan.phases.length === 0) {
return "Lege zuerst deine Lebensphasen an ohne sie gibt es keine Übergänge zu entscheiden.";
if (index === 1 && plan.elements.length === 0) {
return "Erfasse zuerst deine finanziellen Elemente ohne sie gibt es in einer Lebensphase nichts zu planen.";
}
return null;
}
+2 -2
View File
@@ -1989,8 +1989,8 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
personId: p.id,
role: p.role,
retirementAge: retirementAge.get(p.id) ?? AHV_REFERENCE_AGE,
// Alter am Planende -- abgeleitet aus dem Horizont, nicht erfasst.
planningHorizonAge: typeof plan.planningHorizonYears === "number" ? p.age + plan.planningHorizonYears : null,
// Alter am Planende -- abgeleitet aus der Summe der Lebensphasen.
planningHorizonAge: totalYears > 0 ? p.age + totalYears : null,
ahvAnnual: Math.round(ahvFinalByPerson.get(p.id) ?? 0),
ahvFromAge: Math.round(ahvStartAge(rd)),
ahvDraw: ahvDrawLabel(rd),
-1
View File
@@ -144,7 +144,6 @@ export function computeScenarioDiff(scenario: PlanInput, base: PlanInput | null)
d.profileChanged =
scenario.householdType !== base.householdType ||
scenario.inflationRateDefault !== base.inflationRateDefault ||
(scenario.planningHorizonYears ?? null) !== (base.planningHorizonYears ?? null) ||
scenario.persons.map(personKey).sort().join(";") !== base.persons.map(personKey).sort().join(";");
d.total =
-1
View File
@@ -77,7 +77,6 @@ describe("Datenbank-Migrationen", () => {
expect(personCols).not.toContain("age");
const scenCols2 = await cols("Scenario");
expect(scenCols2).toContain("planningHorizonYears");
expect(scenCols2).toContain("assistantProgress");
// Der Pensionierungs-Entscheid hängt am ELEMENT und bewusst an keiner Phase (0.34) --
-1
View File
@@ -51,7 +51,6 @@ export function toPlanInput(plan: PlanWithRelations): PlanInput {
inflationRateDefault: plan.inflationRateDefault,
initialCash: plan.initialCash,
startYear: plan.plan.startYear,
planningHorizonYears: plan.planningHorizonYears,
assistantProgress: normalizeProgress(plan.assistantProgress),
// Name und Alter vom Plan, Pensionsalter vom Szenario. Fehlt zu einer Rolle die
// Plan-Person, greift ein Notbehelf -- die Berechnung darf daran nicht scheitern.
-1
View File
@@ -64,7 +64,6 @@ function plan(opts: {
durationYears: p.durationYears,
cashTransition: p.cashTransition ?? {},
})),
planningHorizonYears: opts.horizon ?? null,
elements: opts.elements,
};
}
+7 -35
View File
@@ -53,13 +53,14 @@ export function sortedPhases(plan: PlanInput) {
// konnten dadurch unbemerkt verschieden weit rechnen -- und waren dann nicht vergleichbar,
// obwohl genau das ihr Zweck ist.
//
// Seit 0.36 ist der Horizont eine Zahl in JAHREN am Szenario. Vorher stand er als Endalter je
// PERSON -- bei einem Paar zwei Zahlen, die auseinanderlaufen konnten und aus denen sich das
// Planende erst per Maximum ergab. Eine Laufzeit ist eine Eigenschaft der PLANUNG, nicht der
// Person; die Endalter sind die Ableitung davon, nicht umgekehrt.
// Seit 0.38 wird der Horizont ABGELEITET: Er ist die Summe der Lebensphasen. Zwei Anlaeufe
// davor -- Endalter je Person (0.35), dann eine eigene Jahreszahl am Szenario (0.36) -- waren
// beide eine zweite Wahrheit ueber dieselbe Sache. Wer die Phasen einzeln plant, hat den
// Horizont damit bereits bestimmt; ihn zusaetzlich zu erfassen hiess nur, zwei Zahlen
// synchron halten zu muessen.
export function planEndYear(plan: PlanInput): number | null {
const y = plan.planningHorizonYears;
return typeof y === "number" && y > 0 ? y : null;
const total = plan.phases.reduce((n, p) => n + Math.max(0, p.durationYears), 0);
return total > 0 ? total : null;
}
// Alter, das eine Person am Ende der Planung erreicht. Read-only-Anzeige neben dem Horizont.
@@ -69,35 +70,6 @@ export function endAgeOf(plan: PlanInput, role: PersonRole): number | null {
return years !== null && person ? person.age + years : null;
}
export interface HorizonChange {
lastPhaseId: string;
oldDuration: number;
newDuration: number;
blocked: string | null;
}
// Was muesste an der letzten Phase geschehen, damit der Plan genau bis zum Horizont laeuft?
// Dieselbe Richtung wie beim Pensionsalter: Zahl stellen, Struktur folgt.
export function planHorizonChange(plan: PlanInput, horizonYears: number): HorizonChange | null {
const phases = sortedPhases(plan);
const last = phases[phases.length - 1];
if (!last) return null;
const before = phases.slice(0, -1).reduce((s, p) => s + p.durationYears, 0);
const newDuration = horizonYears - before;
const change: HorizonChange = {
lastPhaseId: last.id,
oldDuration: last.durationYears,
newDuration,
blocked: null,
};
if (newDuration < 1) {
change.blocked =
"Der Horizont liegt vor dem Ende der zweitletzten Lebensphase. Die letzte Phase muss mindestens ein Jahr dauern kürze zuerst eine frühere Phase oder verlängere den Horizont.";
}
return change;
}
// Analysiert für JEDE Person, ob und wie weit sich ihr Pensionsalter verschieben lässt.
export function retirementBoundaries(plan: PlanInput): RetirementBoundary[] {
const phases = sortedPhases(plan);
-3
View File
@@ -83,9 +83,6 @@ export interface PlanInput {
// Kalenderjahr des Planbeginns (Jahr 1) -- nur für die Darstellung, nicht für die
// Berechnung. Optional, damit Berechnungs-Fixtures es nicht setzen müssen.
startYear?: number | null;
// Laufzeit der Planung in JAHREN. Die Endalter je Person werden daraus abgeleitet. Fehlt
// der Wert, ergibt sich das Planende wie bisher aus der Summe der Phasendauern.
planningHorizonYears?: number | null;
// Fortschritt des FPT-Assistenten (sieben Schritte).
assistantProgress?: AssistantProgress | null;
persons: PersonInput[];