Das Pensionsalter laesst sich neu veraendern: Nicht das Alter wird gesetzt, sondern die Phasengrenze verschoben -- Vorphase laenger, Folgephase kuerzer, Gesamtdauer gleich. Faellt eine Phase dabei weg, werden die beiden Uebergaenge nach Bestaetigung zusammengelegt. - neues reines Modul lib/retirement.ts + POST /api/scenarios/<id>/retirement - Pensionsalter als Tornado-Treiber und Live-Simulations-Regler - AHV-Referenzalter 65: Rente ab 65 unabhaengig vom Pensionsalter; vor 65 Beitrag als Nichterwerbstaetige(r) (neues Feld ahvContribution). AHV wird dafuer jahresweise statt phasenweise gerechnet. - Punkt B: personenzugeordnetes Einkommen faellt bei Pensionierung auf 0 - Punkt A: Wiederkehr-Parameter werden live aus der Vorphase geerbt, sichtbar als "Aus Vorphase uebernehmen" - Punkt C: Kapitalzufluss am Pensions-Uebergang per Quote auf Amortisation, Anlage und Cash verteilbar - Fix: carry.flowBasis wurde vor der Jahresschleife berechnet, effektive Werte kamen deshalb nie in der Folgephase an SPEZIFIKATION 0.26 (neue Kapitel 3.12, 4.4.7, 4.16). 221 -> 261 Tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
import { useState } from "react";
|
||||
import { Trash2 } from "lucide-react";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
import { FieldLabel, MoneyField, NumberField, SelectField, TextField } from "@/components/FormField";
|
||||
import { FieldLabel, InheritableField, MoneyField, NumberField, SelectField, TextField } from "@/components/FormField";
|
||||
import { formatChf } from "@/lib/format";
|
||||
import { api } from "@/lib/api-client";
|
||||
// Die reinen Uebergangs-Regeln liegen in lib/transitions.ts (auch serverseitig nutzbar) und
|
||||
@@ -24,8 +24,9 @@ import {
|
||||
type RateScope,
|
||||
} from "@/lib/ratefields";
|
||||
import { ahvAnnualPension, ahvMdje, type AhvCareer } from "@/lib/calculations";
|
||||
import { CATEGORY_LABELS, num } from "@/lib/elements";
|
||||
import { CATEGORY_LABELS, num, type InheritableKey } from "@/lib/elements";
|
||||
import {
|
||||
AHV_REFERENCE_AGE,
|
||||
DEFAULT_CAPITAL_TAX_RATE,
|
||||
DEFAULT_PK_CONVERSION_RATE,
|
||||
DEFAULT_PROPERTY_GAINS_TAX_RATE,
|
||||
@@ -59,6 +60,12 @@ export interface CellContext {
|
||||
laterPhaseCount: number;
|
||||
// AHV-Beitragskarriere des Element-Besitzers (für die Prüfung am Pensions-Übergang).
|
||||
ahvCareer: AhvCareer | null;
|
||||
// Werte, die in dieser Phase gelten würden, wenn das jeweilige Feld leer bleibt
|
||||
// (Roadmap Nr. 44, Punkt A). Leer in der ersten Phase.
|
||||
inheritedValues: Record<string, number>;
|
||||
// Alter des Element-Besitzers im ersten Jahr dieser Phase. Entscheidet bei der AHV, ob
|
||||
// das Referenzalter in diese Phase fällt (Kap. 4.4.6).
|
||||
ownerAgeStart: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
@@ -244,10 +251,18 @@ export function CashTransitionFields({
|
||||
ct,
|
||||
setC,
|
||||
deflatorEnd,
|
||||
// Roadmap Nr. 44, Punkt C: Nur am Pensions-Übergang gefragt -- dort kommt das Kapital
|
||||
// (PK, 3a, Immobilienverkauf) auf einmal herein und will verwendet werden.
|
||||
showCapitalUse = false,
|
||||
capitalInflow = 0,
|
||||
investTargets = [],
|
||||
}: {
|
||||
ct: CashTransitionData;
|
||||
setC: (patch: Partial<CashTransitionData>) => void;
|
||||
deflatorEnd: number; // Bestands-Deflator an der Phasengrenze
|
||||
showCapitalUse?: boolean;
|
||||
capitalInflow?: number;
|
||||
investTargets?: { id: string; name: string }[];
|
||||
}) {
|
||||
const mode = ct.mode ?? "NONE";
|
||||
const showIn = mode === "INFLOW" || mode === "BOTH";
|
||||
@@ -347,6 +362,75 @@ export function CashTransitionFields({
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{showCapitalUse && <CapitalUseFields ct={ct} setC={setC} inflow={capitalInflow} targets={investTargets} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Verwendung des Kapitalzuflusses am Pensions-Übergang (Roadmap Nr. 44, Punkt C).
|
||||
//
|
||||
// In PROZENT und nicht in Franken: Verschiebt man das Pensionsalter, ändert sich das bezogene
|
||||
// Kapital. Ein Frankenbetrag müsste dann von Hand nachgezogen werden -- und würde bis dahin
|
||||
// still eine falsche Aufteilung rechnen. Eine Quote skaliert mit.
|
||||
function CapitalUseFields({
|
||||
ct,
|
||||
setC,
|
||||
inflow,
|
||||
targets,
|
||||
}: {
|
||||
ct: CashTransitionData;
|
||||
setC: (patch: Partial<CashTransitionData>) => void;
|
||||
inflow: number;
|
||||
targets: { id: string; name: string }[];
|
||||
}) {
|
||||
const amort = Math.max(0, Math.min(100, num(ct.capitalUseAmortizationPct)));
|
||||
const invest = Math.max(0, Math.min(100 - amort, num(ct.capitalUseInvestPct)));
|
||||
const cash = Math.max(0, 100 - amort - invest);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="sm:col-span-2 rounded-lg bg-surface-2 px-3 py-2 text-xs text-muted">
|
||||
<strong className="text-fg">Verwendung des Kapitalzuflusses</strong> (PK-Kapital, Säule 3a,
|
||||
Verkaufserlös). Bei diesem Übergang fliessen{" "}
|
||||
<strong className="text-fg">{formatChf(inflow)}</strong> netto herein. Erfasst wird die
|
||||
Aufteilung in Prozent – so bleibt sie richtig, wenn du das Pensionsalter verschiebst und
|
||||
sich der Betrag dadurch ändert. Was nicht zugeteilt ist, bleibt Cash.
|
||||
</div>
|
||||
<NumberField
|
||||
label="… in die Hypothek (Amortisation, %)"
|
||||
help="Reduziert die Resthypothek der Immobilie einmalig. Am Restsaldo gekappt – ist die Hypothek kleiner, bleibt der Rest Cash."
|
||||
step={5}
|
||||
min={0}
|
||||
max={100}
|
||||
value={amort}
|
||||
onChange={(v) => setC({ capitalUseAmortizationPct: Math.max(0, Math.min(100, v)) })}
|
||||
/>
|
||||
<NumberField
|
||||
label="… in die Anlage (%)"
|
||||
help="Fliesst als Zusatzinvestition in das gewählte Vermögens-Element und wächst dort mit dessen Rendite weiter."
|
||||
step={5}
|
||||
min={0}
|
||||
max={100 - amort}
|
||||
value={invest}
|
||||
onChange={(v) => setC({ capitalUseInvestPct: Math.max(0, Math.min(100 - amort, v)) })}
|
||||
/>
|
||||
{invest > 0 && targets.length > 0 && (
|
||||
<div className="sm:col-span-2">
|
||||
<SelectField
|
||||
label="Ziel der Anlage-Quote"
|
||||
help="Welches Vermögens-Element wird aufgestockt?"
|
||||
value={ct.capitalUseTargetElementId ?? targets[0].id}
|
||||
onChange={(v: string) => setC({ capitalUseTargetElementId: v })}
|
||||
options={targets.map((t) => ({ value: t.id, label: t.name }))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<DerivedField
|
||||
label={`… bleibt Cash (${cash} %)`}
|
||||
value={Math.round((inflow * cash) / 100)}
|
||||
help="Der nicht zugeteilte Rest. Wird automatisch berechnet."
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -365,6 +449,35 @@ export function ElementPhaseFields({
|
||||
setP: (patch: Partial<PhaseData>) => void;
|
||||
}) {
|
||||
const carried = context.carried;
|
||||
|
||||
// Punkt A (Roadmap Nr. 44): Wiederkehr-Parameter werden aus der Vorphase übernommen,
|
||||
// solange das Feld leer bleibt. `inh` baut die Hülle mit dem sichtbaren Häkchen.
|
||||
const inh = (
|
||||
key: InheritableKey,
|
||||
label: string,
|
||||
render: (value: number, set: (v: number) => void) => React.ReactNode,
|
||||
format: (v: number) => string,
|
||||
help?: string
|
||||
) => {
|
||||
const own = pd[key];
|
||||
const fallback = context.inheritedValues[key] ?? 0;
|
||||
const isInheriting = carried && typeof own !== "number";
|
||||
return (
|
||||
<InheritableField
|
||||
label={label}
|
||||
help={help}
|
||||
canInherit={carried}
|
||||
isInheriting={isInheriting}
|
||||
inheritedDisplay={format(fallback)}
|
||||
onToggle={(inherit) => setP({ [key]: inherit ? undefined : fallback } as Partial<PhaseData>)}
|
||||
>
|
||||
{render(typeof own === "number" ? own : fallback, (v) => setP({ [key]: v } as Partial<PhaseData>))}
|
||||
</InheritableField>
|
||||
);
|
||||
};
|
||||
const asPct = (v: number) => `${v} %`;
|
||||
const asChf = (v: number) => formatChf(v);
|
||||
|
||||
switch (element.category) {
|
||||
case "INCOME":
|
||||
case "EXPENSE": {
|
||||
@@ -372,7 +485,6 @@ export function ElementPhaseFields({
|
||||
// Basiswert (erstes Jahr). Ab Phase 2 mit dem fortgeschriebenen Wert der Vorphase
|
||||
// vorbelegt, aber bewusst änderbar (Teilzeit, Beförderung, Jobwechsel …).
|
||||
const baseValue = typeof pd.amount === "number" ? pd.amount : carried ? context.derivedStart : 0;
|
||||
const rate = num(pd.teuerungsausgleich, 0);
|
||||
const d = context.deflatorStart || 1;
|
||||
// Info-Gegenwert im ersten Jahr: Einkommen -> real; Ausgaben -> nominal.
|
||||
const otherValue = isIncome ? Math.round(baseValue / d) : Math.round(baseValue * d);
|
||||
@@ -394,21 +506,36 @@ export function ElementPhaseFields({
|
||||
value={otherValue}
|
||||
help="Nur zur Info, wird automatisch berechnet."
|
||||
/>
|
||||
<NumberField
|
||||
label={isIncome ? "Nominale Lohnerhöhung (%/Jahr)" : "Reale Mehrausgaben (%/Jahr)"}
|
||||
help={
|
||||
isIncome
|
||||
? "Geschätzte jährliche Lohnerhöhung. 0% = Lohn bleibt nominal gleich und verliert real an Kaufkraft."
|
||||
: "Zusätzliche reale Mehrausgaben pro Jahr (z. B. weil man sich mehr gönnt). Die Inflation kommt separat automatisch dazu. 0% = gleicher Lebensstandard."
|
||||
}
|
||||
step={0.1}
|
||||
value={rate}
|
||||
onChange={(v) => setP({ teuerungsausgleich: v })}
|
||||
/>
|
||||
{inh(
|
||||
"teuerungsausgleich",
|
||||
isIncome ? "Nominale Lohnerhöhung (%/Jahr)" : "Reale Mehrausgaben (%/Jahr)",
|
||||
(v, set) => (
|
||||
<NumberField
|
||||
label={isIncome ? "Nominale Lohnerhöhung (%/Jahr)" : "Reale Mehrausgaben (%/Jahr)"}
|
||||
step={0.1}
|
||||
value={v}
|
||||
onChange={set}
|
||||
/>
|
||||
),
|
||||
asPct,
|
||||
isIncome
|
||||
? "Geschätzte jährliche Lohnerhöhung. 0% = Lohn bleibt nominal gleich und verliert real an Kaufkraft."
|
||||
: "Zusätzliche reale Mehrausgaben pro Jahr (z. B. weil man sich mehr gönnt). Die Inflation kommt separat automatisch dazu. 0% = gleicher Lebensstandard."
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
case "AHV":
|
||||
case "AHV": {
|
||||
// Seit Roadmap Nr. 44 beginnt die AHV-Rente IMMER mit dem Referenzalter -- unabhängig
|
||||
// davon, wann die Person aufhört zu arbeiten (Kap. 4.4.6). Daraus folgen zwei Fälle,
|
||||
// die es vorher nicht gab und die hier sichtbar gemacht werden müssen:
|
||||
// * Pensionierung VOR 65 -> beitragspflichtig als Nichterwerbstätige(r) bis 65.
|
||||
// * Erwerbstätig ÜBER 65 -> die Rente fliesst trotzdem ab 65.
|
||||
const ageStart = context.ownerAgeStart;
|
||||
const ageEnd = ageStart + context.durationYears - 1;
|
||||
const reachesReference = ageEnd >= AHV_REFERENCE_AGE;
|
||||
const startsBeforeReference = ageStart < AHV_REFERENCE_AGE;
|
||||
|
||||
if (!context.ownerWorking) {
|
||||
// Sonderfall: bei Planbeginn bereits pensioniert -> es gibt keinen Pensions-Übergang,
|
||||
// an dem die Karriere geprüft werden könnte. Dann hier erfassen (nur erste Phase).
|
||||
@@ -442,23 +569,61 @@ export function ElementPhaseFields({
|
||||
);
|
||||
}
|
||||
return (
|
||||
<p className="col-span-2 text-sm text-muted">
|
||||
Die AHV-Rente wird aus der beim Pensions-Übergang geprüften Beitragskarriere berechnet
|
||||
(massgebendes Durchschnittseinkommen und Ausfalljahre). Bei Ehepaaren greift die
|
||||
Plafonierung auf 150% der Maximalrente.
|
||||
</p>
|
||||
<>
|
||||
<p className="col-span-2 text-sm text-muted">
|
||||
Die AHV-Rente wird aus der beim Pensions-Übergang geprüften Beitragskarriere berechnet
|
||||
(massgebendes Durchschnittseinkommen und Ausfalljahre). Bei Ehepaaren greift die
|
||||
Plafonierung auf 150% der Maximalrente. Sie fliesst ab Alter {AHV_REFERENCE_AGE}
|
||||
{reachesReference && startsBeforeReference
|
||||
? ` – also erst im Verlauf dieser Lebensphase (Alter ${ageStart} bis ${ageEnd}).`
|
||||
: "."}
|
||||
</p>
|
||||
{startsBeforeReference && (
|
||||
<>
|
||||
<p className="col-span-2 rounded-lg border border-attention bg-attention-soft px-3 py-2 text-xs text-attention-soft-fg">
|
||||
<strong>Frühpensionierung:</strong> Wer vor Alter {AHV_REFERENCE_AGE} aufhört zu
|
||||
arbeiten, bleibt bis dahin AHV-beitragspflichtig – als Nichterwerbstätige(r). Der
|
||||
Beitrag ist eine laufende Ausgabe und fällt mit {AHV_REFERENCE_AGE} weg, wenn die
|
||||
Rente einsetzt.
|
||||
</p>
|
||||
<MoneyField
|
||||
label="AHV-Beitrag als Nichterwerbstätige(r) (CHF/Jahr)"
|
||||
help={
|
||||
"Der Beitrag bemisst sich am Vermögen und am Renteneinkommen, nicht am Lohn – die Bandbreite ist deshalb enorm: " +
|
||||
"vom Mindestbeitrag von rund 530 CHF pro Jahr bis zum Höchstbeitrag von rund 26'500 CHF pro Jahr. " +
|
||||
"Faustregel: Bis etwa 300'000 CHF Vermögen liegt man nahe am Minimum; ab rund 8.7 Mio. am Maximum. " +
|
||||
"Verheiratete zahlen den Beitrag je einzeln, wenn beide nicht erwerbstätig sind; ist der Partner erwerbstätig " +
|
||||
"und zahlt genug, entfällt er. Die AHV-Ausgleichskasse rechnet den Betrag auf Anfrage aus."
|
||||
}
|
||||
value={num(pd.ahvContribution)}
|
||||
onChange={(v) => setP({ ahvContribution: v })}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<NumberField
|
||||
label="Geplante Ausfalljahre"
|
||||
help="Jahre ohne AHV-Beiträge in dieser Phase. Jedes Ausfalljahr kürzt die spätere Rente um 1/44."
|
||||
value={num(pd.gapYears)}
|
||||
min={0}
|
||||
max={context.durationYears}
|
||||
onChange={(v) => setP({ gapYears: Math.max(0, Math.min(context.durationYears, Math.round(v))) })}
|
||||
/>
|
||||
<>
|
||||
<NumberField
|
||||
label="Geplante Ausfalljahre"
|
||||
help={`Jahre ohne AHV-Beiträge in dieser Phase. Jedes Ausfalljahr kürzt die spätere Rente um 1/44. Nur Jahre VOR dem Referenzalter ${AHV_REFERENCE_AGE} zählen.`}
|
||||
value={num(pd.gapYears)}
|
||||
min={0}
|
||||
max={context.durationYears}
|
||||
onChange={(v) => setP({ gapYears: Math.max(0, Math.min(context.durationYears, Math.round(v))) })}
|
||||
/>
|
||||
{reachesReference && (
|
||||
<p className="col-span-2 rounded-lg border border-attention bg-attention-soft px-3 py-2 text-xs text-attention-soft-fg">
|
||||
<strong>Weiterarbeiten über {AHV_REFERENCE_AGE} hinaus:</strong> Die AHV-Rente
|
||||
beginnt trotzdem mit {AHV_REFERENCE_AGE} und fliesst neben dem Lohn. Ein Aufschub
|
||||
der Rente wird nicht abgebildet. Erfasse deshalb nur die Ausfalljahre bis zum
|
||||
Referenzalter.
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
case "PENSION_FUND":
|
||||
if (!context.ownerWorking) {
|
||||
return (
|
||||
@@ -484,13 +649,19 @@ export function ElementPhaseFields({
|
||||
) : (
|
||||
<MoneyField label="Aktueller PK-Wert (CHF)" value={num(pd.currentValue)} onChange={(v) => setP({ currentValue: v })} />
|
||||
)}
|
||||
<MoneyField
|
||||
label="Jährliche Einzahlung (CHF)"
|
||||
help="Arbeitnehmer- und Arbeitgeberbeiträge. Fliesst NICHT in die Sparquote ein (bereits in den Ausgaben berücksichtigt)."
|
||||
value={num(pd.annualContribution)}
|
||||
onChange={(v) => setP({ annualContribution: v })}
|
||||
/>
|
||||
<NumberField label="Erwartete Rendite (%/Jahr)" step={0.1} value={num(pd.expectedReturn)} onChange={(v) => setP({ expectedReturn: v })} />
|
||||
{inh(
|
||||
"annualContribution",
|
||||
"Jährliche Einzahlung (CHF)",
|
||||
(v, set) => <MoneyField label="Jährliche Einzahlung (CHF)" value={v} onChange={set} />,
|
||||
asChf,
|
||||
"Arbeitnehmer- und Arbeitgeberbeiträge. Fliesst NICHT in die Sparquote ein (bereits in den Ausgaben berücksichtigt)."
|
||||
)}
|
||||
{inh(
|
||||
"expectedReturn",
|
||||
"Erwartete Rendite (%/Jahr)",
|
||||
(v, set) => <NumberField label="Erwartete Rendite (%/Jahr)" step={0.1} value={v} onChange={set} />,
|
||||
asPct
|
||||
)}
|
||||
</>
|
||||
);
|
||||
case "PILLAR_3A":
|
||||
@@ -513,14 +684,21 @@ export function ElementPhaseFields({
|
||||
) : (
|
||||
<MoneyField label="Aktueller 3a-Wert (CHF)" value={num(pd.currentValue)} onChange={(v) => setP({ currentValue: v })} />
|
||||
)}
|
||||
<MoneyField
|
||||
label="Jährliche Einzahlung (CHF)"
|
||||
help={`Maximal CHF ${PILLAR_3A_MAX_ANNUAL.toLocaleString("de-CH")} (2026, mit PK) und höchstens die Sparquote. Wird von der Sparquote abgezogen.`}
|
||||
value={num(pd.annualContribution)}
|
||||
max={PILLAR_3A_MAX_ANNUAL}
|
||||
onChange={(v) => setP({ annualContribution: v })}
|
||||
/>
|
||||
<NumberField label="Erwartete Rendite (%/Jahr)" step={0.1} value={num(pd.expectedReturn)} onChange={(v) => setP({ expectedReturn: v })} />
|
||||
{inh(
|
||||
"annualContribution",
|
||||
"Jährliche Einzahlung (CHF)",
|
||||
(v, set) => (
|
||||
<MoneyField label="Jährliche Einzahlung (CHF)" value={v} max={PILLAR_3A_MAX_ANNUAL} onChange={set} />
|
||||
),
|
||||
asChf,
|
||||
`Maximal CHF ${PILLAR_3A_MAX_ANNUAL.toLocaleString("de-CH")} (2026, mit PK) und höchstens die Sparquote. Wird von der Sparquote abgezogen.`
|
||||
)}
|
||||
{inh(
|
||||
"expectedReturn",
|
||||
"Erwartete Rendite (%/Jahr)",
|
||||
(v, set) => <NumberField label="Erwartete Rendite (%/Jahr)" step={0.1} value={v} onChange={set} />,
|
||||
asPct
|
||||
)}
|
||||
</>
|
||||
);
|
||||
case "REAL_ESTATE": {
|
||||
@@ -548,19 +726,20 @@ export function ElementPhaseFields({
|
||||
<MoneyField label="Hypothek (CHF)" value={num(pd.mortgage)} onChange={(v) => setP({ mortgage: v })} />
|
||||
</>
|
||||
)}
|
||||
<MoneyField
|
||||
label="Amortisation (CHF/Jahr)"
|
||||
help="Jährliche Reduktion der Hypothek. Zählt gegen die Sparquote und endet, sobald die Hypothek abbezahlt ist."
|
||||
value={num(pd.amortization)}
|
||||
onChange={(v) => setP({ amortization: v })}
|
||||
/>
|
||||
<NumberField
|
||||
label="Hypothekarzins (%/Jahr)"
|
||||
help="Zinssatz auf der Restschuld. Der Zinsbetrag sinkt automatisch mit der Amortisation."
|
||||
step={0.1}
|
||||
value={num(pd.interestRate)}
|
||||
onChange={(v) => setP({ interestRate: v })}
|
||||
/>
|
||||
{inh(
|
||||
"amortization",
|
||||
"Amortisation (CHF/Jahr)",
|
||||
(v, set) => <MoneyField label="Amortisation (CHF/Jahr)" value={v} onChange={set} />,
|
||||
asChf,
|
||||
"Jährliche Reduktion der Hypothek. Zählt gegen die Sparquote und endet, sobald die Hypothek abbezahlt ist."
|
||||
)}
|
||||
{inh(
|
||||
"interestRate",
|
||||
"Hypothekarzins (%/Jahr)",
|
||||
(v, set) => <NumberField label="Hypothekarzins (%/Jahr)" step={0.1} value={v} onChange={set} />,
|
||||
asPct,
|
||||
"Zinssatz auf der Restschuld. Der Zinsbetrag sinkt automatisch mit der Amortisation."
|
||||
)}
|
||||
<div>
|
||||
<FieldLabel
|
||||
label="Hypothekarzins-Betrag (Beginn → Ende)"
|
||||
@@ -570,13 +749,13 @@ export function ElementPhaseFields({
|
||||
{formatChf(zinsStart)} <span className="text-faint">→</span> {formatChf(zinsEnde)}
|
||||
</div>
|
||||
</div>
|
||||
<NumberField
|
||||
label="Geschätzte Wertsteigerung (%/Jahr)"
|
||||
help="Wirkt auf den Wert der LIEGENSCHAFT, nicht auf das Eigenkapital. 1% von 1 Mio sind 10'000 pro Jahr – bei 100'000 Eigenkapital also 10% darauf (Hebel)."
|
||||
step={0.1}
|
||||
value={num(pd.valueGrowth)}
|
||||
onChange={(v) => setP({ valueGrowth: v })}
|
||||
/>
|
||||
{inh(
|
||||
"valueGrowth",
|
||||
"Geschätzte Wertsteigerung (%/Jahr)",
|
||||
(v, set) => <NumberField label="Geschätzte Wertsteigerung (%/Jahr)" step={0.1} value={v} onChange={set} />,
|
||||
asPct,
|
||||
"Wirkt auf den Wert der LIEGENSCHAFT, nicht auf das Eigenkapital. 1% von 1 Mio sind 10'000 pro Jahr – bei 100'000 Eigenkapital also 10% darauf (Hebel)."
|
||||
)}
|
||||
<div className="sm:col-span-2">
|
||||
<SelectField
|
||||
label="Sind die Hypothekarzinsen bereits in den Ausgaben enthalten?"
|
||||
@@ -609,19 +788,26 @@ export function ElementPhaseFields({
|
||||
) : (
|
||||
<MoneyField label="Startwert (CHF)" value={num(pd.startValue)} onChange={(v) => setP({ startValue: v })} />
|
||||
)}
|
||||
<NumberField label="Erwartete Rendite (%/Jahr)" step={0.1} value={num(pd.expectedReturn)} onChange={(v) => setP({ expectedReturn: v })} />
|
||||
<MoneyField
|
||||
label="Jährlicher Sparbeitrag (CHF)"
|
||||
help="Flacher Jahresbetrag, der ins Vermögen fliesst und vom Cash abgezogen wird (geplante Sparrate)."
|
||||
value={num(pd.annualContribution)}
|
||||
onChange={(v) => setP({ annualContribution: v })}
|
||||
/>
|
||||
<MoneyField
|
||||
label="Jährliche Bezugsrate (CHF)"
|
||||
help="Entnahme aus dem Vermögen (z. B. laufende Renten-Entnahme im Alter). Mindert das Vermögen und fliesst jährlich ins Cash (geplante Verzehrrate)."
|
||||
value={num(pd.annualWithdrawal)}
|
||||
onChange={(v) => setP({ annualWithdrawal: v })}
|
||||
/>
|
||||
{inh(
|
||||
"expectedReturn",
|
||||
"Erwartete Rendite (%/Jahr)",
|
||||
(v, set) => <NumberField label="Erwartete Rendite (%/Jahr)" step={0.1} value={v} onChange={set} />,
|
||||
asPct
|
||||
)}
|
||||
{inh(
|
||||
"annualContribution",
|
||||
"Jährlicher Sparbeitrag (CHF)",
|
||||
(v, set) => <MoneyField label="Jährlicher Sparbeitrag (CHF)" value={v} onChange={set} />,
|
||||
asChf,
|
||||
"Flacher Jahresbetrag, der ins Vermögen fliesst und vom Cash abgezogen wird (geplante Sparrate)."
|
||||
)}
|
||||
{inh(
|
||||
"annualWithdrawal",
|
||||
"Jährliche Bezugsrate (CHF)",
|
||||
(v, set) => <MoneyField label="Jährliche Bezugsrate (CHF)" value={v} onChange={set} />,
|
||||
asChf,
|
||||
"Entnahme aus dem Vermögen (z. B. laufende Renten-Entnahme im Alter). Mindert das Vermögen und fliesst jährlich ins Cash (geplante Verzehrrate)."
|
||||
)}
|
||||
</>
|
||||
);
|
||||
case "OTHER_DEBT":
|
||||
@@ -632,13 +818,13 @@ export function ElementPhaseFields({
|
||||
) : (
|
||||
<MoneyField label="Restschuld (CHF)" value={num(pd.startValue)} onChange={(v) => setP({ startValue: v })} />
|
||||
)}
|
||||
<MoneyField
|
||||
label="Jährliche Tilgung (CHF)"
|
||||
help="Zählt gegen die Sparquote (max. die Sparquote)."
|
||||
value={num(pd.annualRepayment)}
|
||||
|
||||
onChange={(v) => setP({ annualRepayment: v })}
|
||||
/>
|
||||
{inh(
|
||||
"annualRepayment",
|
||||
"Jährliche Tilgung (CHF)",
|
||||
(v, set) => <MoneyField label="Jährliche Tilgung (CHF)" value={v} onChange={set} />,
|
||||
asChf,
|
||||
"Zählt gegen die Sparquote (max. die Sparquote)."
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -320,3 +320,54 @@ export function SelectField<T extends string>({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- "Aus Vorphase uebernehmen" (Roadmap Nr. 44, Punkt A) --------------------------------
|
||||
//
|
||||
// Wiederkehr-Parameter (Raten, Beitraege, Amortisation) gelten weiter, bis man sie bewusst
|
||||
// aendert. Bisher war das unsichtbar: Beim Anlegen einer Phase wurde der Wert KOPIERT, und
|
||||
// spaetere Aenderungen an der Vorphase kamen deshalb nie an. Jetzt entscheidet ein sichtbares
|
||||
// Haekchen: angehakt = Feld leer lassen und live erben; abgehakt = eigener Wert fuer diese
|
||||
// Phase. In der ersten Phase gibt es nichts zu erben -- dort erscheint das Haekchen nicht.
|
||||
export function InheritableField({
|
||||
label,
|
||||
help,
|
||||
canInherit,
|
||||
isInheriting,
|
||||
inheritedDisplay,
|
||||
onToggle,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
help?: string;
|
||||
canInherit: boolean;
|
||||
isInheriting: boolean;
|
||||
inheritedDisplay: string;
|
||||
onToggle: (inherit: boolean) => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
{isInheriting ? (
|
||||
<>
|
||||
<FieldLabel label={label} help={help} />
|
||||
<div className="w-full rounded-lg border border-dashed border-border bg-surface-2 px-2.5 py-1.5 text-sm text-muted">
|
||||
{inheritedDisplay} <span className="text-xs text-faint">(aus der Vorphase)</span>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
{canInherit && (
|
||||
<label className="mt-1 flex cursor-pointer items-center gap-1.5 text-xs text-muted">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isInheriting}
|
||||
onChange={(e) => onToggle(e.target.checked)}
|
||||
className="h-3.5 w-3.5 accent-accent"
|
||||
/>
|
||||
Aus Vorphase übernehmen
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { NumberField, SelectField, TextField } from "@/components/FormField";
|
||||
import { FieldLabel, NumberField, SelectField, TextField } from "@/components/FormField";
|
||||
import type { HouseholdType, PersonRole } from "@/lib/types";
|
||||
|
||||
export interface ProfileDraft {
|
||||
@@ -24,9 +24,14 @@ export function emptyProfileDraft(): ProfileDraft {
|
||||
export function PlanProfileFields({
|
||||
draft,
|
||||
onChange,
|
||||
// Sobald Lebensphasen bestehen, liegt jede Pensionierung auf einer Phasengrenze. Ein frei
|
||||
// änderbares Alter würde diese Grenze zerreissen -- deshalb übernimmt dort die eigene
|
||||
// Bedienung «Pensionsalter anpassen» (Roadmap Nr. 44), und das Feld ist nur noch Anzeige.
|
||||
lockRetirement = false,
|
||||
}: {
|
||||
draft: ProfileDraft;
|
||||
onChange: (next: ProfileDraft) => void;
|
||||
lockRetirement?: boolean;
|
||||
}) {
|
||||
function setType(type: HouseholdType) {
|
||||
if (type === "SINGLE") {
|
||||
@@ -89,14 +94,26 @@ export function PlanProfileFields({
|
||||
max={120}
|
||||
onChange={(v) => updatePerson(index, { age: Math.round(v) })}
|
||||
/>
|
||||
<NumberField
|
||||
label="Pensionierungsalter (nur dieses Szenario)"
|
||||
help="Steuert die Ableitung des Phasentyps (Erwerb/Pension). Als einzige Personenangabe szenario-eigen -- das ist der Kern jedes Früh-/Spätpensionierungs-Szenarios."
|
||||
value={person.retirementAge}
|
||||
min={30}
|
||||
max={100}
|
||||
onChange={(v) => updatePerson(index, { retirementAge: Math.round(v) })}
|
||||
/>
|
||||
{lockRetirement ? (
|
||||
<div>
|
||||
<FieldLabel
|
||||
label="Pensionierungsalter"
|
||||
help="Liegt immer auf einer Phasengrenze und wird deshalb unten unter «Pensionsalter anpassen» verschoben -- dabei wandern die angrenzenden Phasendauern mit."
|
||||
/>
|
||||
<div className="mt-1 rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-muted">
|
||||
{person.retirementAge} Jahre
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<NumberField
|
||||
label="Pensionierungsalter (nur dieses Szenario)"
|
||||
help="Steuert die Ableitung des Phasentyps (Erwerb/Pension). Als einzige Personenangabe szenario-eigen -- das ist der Kern jedes Früh-/Spätpensionierungs-Szenarios."
|
||||
value={person.retirementAge}
|
||||
min={30}
|
||||
max={100}
|
||||
onChange={(v) => updatePerson(index, { retirementAge: Math.round(v) })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
|
||||
@@ -50,6 +50,7 @@ import {
|
||||
} from "@/components/ElementDetail";
|
||||
import { PhaseDetail } from "@/components/PhaseDetail";
|
||||
import { PlanProfileFields, type ProfileDraft } from "@/components/PlanProfileFields";
|
||||
import { RetirementAdjuster } from "@/components/RetirementAdjuster";
|
||||
import { MoneyField } from "@/components/FormField";
|
||||
import { api } from "@/lib/api-client";
|
||||
import { formatChf } from "@/lib/format";
|
||||
@@ -58,6 +59,7 @@ import {
|
||||
CATEGORY_LABELS,
|
||||
CATEGORY_ORDER,
|
||||
PERSON_ONLY_CATEGORIES,
|
||||
inheritedPhaseValues,
|
||||
num,
|
||||
type CashTransitionData,
|
||||
type ElementCategory,
|
||||
@@ -256,6 +258,16 @@ export function PlanView({
|
||||
return computed.phases.length - phase.sequenceNumber;
|
||||
}
|
||||
|
||||
// Phasen in ihrer Reihenfolge -- Grundlage der Vererbung (Roadmap Nr. 44, Punkt A).
|
||||
const orderedPhaseIds = computed.phases.map((p) => p.id);
|
||||
|
||||
// Alter des Element-Besitzers im ersten Jahr einer Phase. Ohne Personenzuordnung gilt
|
||||
// Person A, weil die Zeitachse ohnehin an ihr hängt.
|
||||
function ownerAgeAtPhaseStart(phase: PhaseComputed, element: ElementInput): number {
|
||||
const role = element.ownerRole && element.ownerRole !== "HOUSEHOLD" ? element.ownerRole : "PERSON_A";
|
||||
return phase.persons.find((p) => p.role === role)?.startAge ?? 0;
|
||||
}
|
||||
|
||||
// Baut den Kontext für eine Phasenzelle.
|
||||
function buildPhaseContext(phase: PhaseComputed, element: ElementInput): CellContext {
|
||||
const ce = computedElement(phase.id, element.id);
|
||||
@@ -280,6 +292,8 @@ export function PlanView({
|
||||
deflatorStart: phase.cumulativeInflationStart,
|
||||
laterPhaseCount: laterPhaseCount(phase),
|
||||
ahvCareer: careerFor(element),
|
||||
inheritedValues: inheritedPhaseValues(element.phaseValues, orderedPhaseIds, phase.id),
|
||||
ownerAgeStart: ownerAgeAtPhaseStart(phase, element),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -302,6 +316,8 @@ export function PlanView({
|
||||
deflatorStart: fromPhase.cumulativeInflationStart,
|
||||
laterPhaseCount: laterPhaseCount(fromPhase),
|
||||
ahvCareer: careerFor(element),
|
||||
inheritedValues: {},
|
||||
ownerAgeStart: ownerAgeAtPhaseStart(fromPhase, element),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1468,6 +1484,9 @@ function AddElementDialog({
|
||||
deflatorStart: firstPhase.cumulativeInflationStart,
|
||||
laterPhaseCount: 0, // beim Anlegen bewusst kein Warnhinweis
|
||||
ahvCareer: null,
|
||||
// Beim Anlegen gibt es keine Vorphase, aus der etwas zu erben waere.
|
||||
inheritedValues: {},
|
||||
ownerAgeStart: firstPhase.persons.find((p) => p.role === (owner ?? "PERSON_A"))?.startAge ?? 0,
|
||||
};
|
||||
|
||||
async function create() {
|
||||
@@ -1615,6 +1634,14 @@ function AddPhaseDialog({
|
||||
);
|
||||
}
|
||||
|
||||
// Ziele für die Anlage-Quote der Kapitalverwendung (Roadmap Nr. 44, Punkt C): alle
|
||||
// Vermögens-Elemente, die in der Folgephase noch aktiv sind.
|
||||
function investTargetsOf(toPhase: PhaseComputed | undefined): { id: string; name: string }[] {
|
||||
return (toPhase?.elements ?? [])
|
||||
.filter((e) => e.category === "OTHER_ASSET" && e.status === "ACTIVE")
|
||||
.map((e) => ({ id: e.elementId, name: e.name }));
|
||||
}
|
||||
|
||||
// --- Panel: Szenario-Profil (Grundprofil bearbeiten) ---
|
||||
function ProfilePanel({ plan, onClose, onSaved }: { plan: PlanInput; onClose: () => void; onSaved: () => void }) {
|
||||
const [draft, setDraft] = useState<ProfileDraft>({
|
||||
@@ -1642,12 +1669,21 @@ function ProfilePanel({ plan, onClose, onSaved }: { plan: PlanInput; onClose: ()
|
||||
return (
|
||||
<InspectorShell title="Szenario-Profil" subtitle="Haushalt, Personen, Planstart und Inflation dieses Szenarios" onClose={onClose}>
|
||||
<div className="flex flex-col gap-3">
|
||||
<PlanProfileFields draft={draft} onChange={setDraft} />
|
||||
<PlanProfileFields draft={draft} onChange={setDraft} lockRetirement={plan.phases.length > 0} />
|
||||
{error && <p className="text-sm text-danger">{error}</p>}
|
||||
<div className="flex gap-2 pt-1">
|
||||
<Button disabled={saving} onClick={save}>{saving ? "…" : "Speichern"}</Button>
|
||||
<Button variant="secondary" onClick={onClose}>Abbrechen</Button>
|
||||
</div>
|
||||
|
||||
{plan.phases.length > 0 && (
|
||||
<div className="mt-3 border-t border-border pt-3">
|
||||
<h3 className="mb-2 text-sm font-semibold text-fg">Pensionsalter anpassen</h3>
|
||||
{/* Wirkt SOFORT (eigener Endpunkt), weil dabei Phasendauern verschoben werden --
|
||||
das lässt sich nicht sinnvoll mit dem Profil-Formular zusammen abspeichern. */}
|
||||
<RetirementAdjuster plan={plan} onSaved={onSaved} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</InspectorShell>
|
||||
);
|
||||
@@ -1723,6 +1759,9 @@ function TransitionReviewDialog({
|
||||
ct={ct}
|
||||
setC={(patch) => setCt((prev) => ({ ...prev, ...patch }))}
|
||||
deflatorEnd={fromPhase.cumulativeInflationEnd}
|
||||
showCapitalUse={(toPhase?.capitalInflow ?? 0) > 0}
|
||||
capitalInflow={toPhase?.capitalInflow ?? 0}
|
||||
investTargets={investTargetsOf(toPhase)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1814,6 +1853,9 @@ function CashTransitionPanel({
|
||||
ct={ct}
|
||||
setC={(patch) => setCt((prev) => ({ ...prev, ...patch }))}
|
||||
deflatorEnd={fromPhase.cumulativeInflationEnd}
|
||||
showCapitalUse={(toPhase?.capitalInflow ?? 0) > 0}
|
||||
capitalInflow={toPhase?.capitalInflow ?? 0}
|
||||
investTargets={investTargetsOf(toPhase)}
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="mt-3 text-sm text-danger">{error}</p>}
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { api } from "@/lib/api-client";
|
||||
import { limitsText, retirementBoundaries, shiftRetirement, sortedPhases } from "@/lib/retirement";
|
||||
import { Button, useConfirm } from "@/components/ui";
|
||||
import type { PlanInput } from "@/lib/types";
|
||||
|
||||
// Pensionsalter anpassen (Roadmap Nr. 44).
|
||||
//
|
||||
// Warum eine eigene Bedienung statt eines Zahlenfeldes im Profil: Eine Pensionierung liegt
|
||||
// immer auf einer Phasengrenze. Ein frei änderbares Alter würde diese Grenze zerreissen --
|
||||
// die Phasen blieben, wo sie sind, und der Phasentyp (Erwerb/Pension) käme mitten in einer
|
||||
// Phase ins Rutschen. Hier wird stattdessen die GRENZE verschoben: Die Phase davor wird
|
||||
// länger, die danach kürzer, die Gesamtdauer des Plans bleibt gleich.
|
||||
|
||||
function personLabel(plan: PlanInput, role: string): string {
|
||||
const p = plan.persons.find((x) => x.role === role);
|
||||
if (p?.name) return p.name;
|
||||
if (plan.householdType === "SINGLE") return "Du";
|
||||
return role === "PERSON_A" ? "Person A" : "Person B";
|
||||
}
|
||||
|
||||
export function RetirementAdjuster({ plan, onSaved }: { plan: PlanInput; onSaved: () => void }) {
|
||||
const confirm = useConfirm();
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const boundaries = useMemo(() => retirementBoundaries(plan), [plan]);
|
||||
|
||||
async function shift(role: "PERSON_A" | "PERSON_B", delta: number) {
|
||||
setError(null);
|
||||
const preview = shiftRetirement(plan, role, delta);
|
||||
if (!preview) {
|
||||
setError("So weit lässt sich das Pensionsalter hier nicht verschieben.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Zusammenlegung: Die entfallende Phase nimmt einen ganzen Übergang mit. Das muss VORHER
|
||||
// klar sein -- nachher liesse es sich nur durch erneutes Erfassen rückgängig machen.
|
||||
if (preview.removedPhaseId) {
|
||||
const removed = sortedPhases(plan).find((p) => p.id === preview.removedPhaseId);
|
||||
const target = sortedPhases(plan).find((p) => p.id === preview.mergedIntoPhaseId);
|
||||
const ok = await confirm({
|
||||
title: "Lebensphase fällt weg",
|
||||
message:
|
||||
`Bei dieser Verschiebung entfällt die Lebensphase «${removed?.name ?? ""}» vollständig. ` +
|
||||
(target
|
||||
? `Ihre Übergangs-Entscheide werden in den Übergang nach «${target.name}» gezogen: Bereits ` +
|
||||
`getroffene Entscheide bleiben, leere Felder werden von dort ergänzt, und einmalige ` +
|
||||
`Cash-Beträge werden addiert. `
|
||||
: "") +
|
||||
"Fortfahren?",
|
||||
confirmLabel: "Phase zusammenlegen",
|
||||
danger: true,
|
||||
});
|
||||
if (!ok) return;
|
||||
}
|
||||
|
||||
setBusy(role);
|
||||
try {
|
||||
await api.post(`/api/scenarios/${plan.id}/retirement`, { role, delta, confirmMerge: true });
|
||||
onSaved();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Anpassung fehlgeschlagen.");
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{boundaries.map((b) => {
|
||||
const label = personLabel(plan, b.role);
|
||||
const canMergeDown = b.mergeDeltaDown !== null;
|
||||
const canMergeUp = b.mergeDeltaUp !== null;
|
||||
return (
|
||||
<div key={b.role} className="rounded-xl border border-border bg-surface-2 p-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-faint">{label}</div>
|
||||
<div className="text-lg font-semibold text-fg">
|
||||
Pension mit {b.retirementAge}
|
||||
{b.blocked ? "" : <span className="ml-2 text-xs font-normal text-muted">Planjahr {b.planYear}</span>}
|
||||
</div>
|
||||
</div>
|
||||
{!b.blocked && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={busy !== null || (b.minDelta === 0 && !canMergeDown)}
|
||||
onClick={() => shift(b.role as "PERSON_A" | "PERSON_B", -1)}
|
||||
>
|
||||
−1 Jahr
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={busy !== null || (b.maxDelta === 0 && !canMergeUp)}
|
||||
onClick={() => shift(b.role as "PERSON_A" | "PERSON_B", 1)}
|
||||
>
|
||||
+1 Jahr
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Hilfebox: Was ist möglich -- und warum nicht mehr. Ohne diese Erklärung wirkt
|
||||
ein deaktivierter Knopf wie ein Fehler. */}
|
||||
<p className="mt-2 rounded-lg border border-attention bg-attention-soft px-3 py-2 text-xs text-attention-soft-fg">
|
||||
{limitsText(b, label)}
|
||||
</p>
|
||||
|
||||
{!b.blocked && (b.minDelta < -1 || b.maxDelta > 1) && (
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{[-5, -3, -2, 2, 3, 5]
|
||||
.filter((d) => d >= (b.mergeDeltaDown ?? b.minDelta) && d <= (b.mergeDeltaUp ?? b.maxDelta))
|
||||
.map((d) => (
|
||||
<button
|
||||
key={d}
|
||||
type="button"
|
||||
disabled={busy !== null}
|
||||
onClick={() => shift(b.role as "PERSON_A" | "PERSON_B", d)}
|
||||
className="rounded-full border border-border px-2 py-0.5 text-xs text-muted hover:border-accent hover:text-fg disabled:opacity-50"
|
||||
>
|
||||
{d > 0 ? "+" : ""}
|
||||
{d} → {b.retirementAge + d}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{error && <p className="text-sm text-danger">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user