Pensionsalter anpassen (Roadmap 44)
Deploy App / deploy (push) Successful in 1m1s

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:
2026-07-21 14:24:40 +02:00
parent c429624635
commit d6855ef1a0
20 changed files with 2057 additions and 184 deletions
+269 -83
View File
@@ -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)."
)}
</>
);
}
+51
View File
@@ -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>
);
}
+26 -9
View File
@@ -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>
))}
+43 -1
View File
@@ -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>}
+136
View File
@@ -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>
);
}