Pensionierungs-Bildschirm, Ampel mit drei Zustaenden, Planungshorizont

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-26 13:37:08 +02:00
parent 9907fda6f4
commit 1865db5de7
12 changed files with 1181 additions and 130 deletions
+12 -96
View File
@@ -29,7 +29,6 @@ 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,
PILLAR_3A_MAX_ANNUAL,
PILLAR_3A_MAX_SELF_EMPLOYED,
@@ -881,11 +880,15 @@ export function ElementTransitionFields({
context,
td,
setT,
retirement,
}: {
element: { category: ElementCategory };
context: CellContext;
td: TransitionData;
setT: (patch: Partial<TransitionData>) => void;
// Der fertig gerenderte Pensionierungs-Baustein (AHV/PK/3a). Wird vom Aufrufer geliefert,
// damit dieses Modul nichts über den Pensionierungs-Bildschirm wissen muss.
retirement?: React.ReactNode;
}) {
switch (element.category) {
case "INCOME":
@@ -897,8 +900,10 @@ export function ElementTransitionFields({
</p>
);
case "AHV":
if (context.isRetirementTransition && context.ahvCareer) {
return <AhvReviewFields career={context.ahvCareer} td={td} setT={setT} />;
// Der Bezugs-Entscheid liegt seit 0.34 am Element und wird hier nur ANDERS ANGESCHAUT --
// es ist dieselbe Komponente wie im Pensionierungs-Bildschirm, kein Duplikat.
if (context.isRetirementTransition && retirement) {
return <div className="col-span-2">{retirement}</div>;
}
return (
<p className="col-span-2 text-sm text-muted">
@@ -906,103 +911,14 @@ export function ElementTransitionFields({
</p>
);
case "PENSION_FUND": {
if (context.isRetirementTransition) {
const mode = td.payoutMode ?? "PENSION";
const taxRate = num(td.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE);
// Brutto = ganzes Guthaben (Kapitalbezug) bzw. der gewählte Teil (Kombination).
const brutto =
mode === "COMBI"
? Math.min(context.carriedEndValue, Math.round(num(td.capitalAmount)))
: context.carriedEndValue;
const netto = Math.round(brutto * (1 - taxRate / 100));
return (
<>
<SelectField
label="Bezugsart bei Pensionierung"
value={mode}
onChange={(v: "CAPITAL" | "PENSION" | "COMBI") => setT({ payoutMode: v })}
options={[
{ value: "PENSION", label: "Rente" },
{ value: "CAPITAL", label: "Kapitalbezug" },
{ value: "COMBI", label: "Kombination" },
]}
/>
{(mode === "PENSION" || mode === "COMBI") && (
<NumberField
label="Umwandlungssatz (%)"
help="Jährliche Rente = verrentetes Kapital x Umwandlungssatz."
step={0.1}
value={num(td.conversionRate, DEFAULT_PK_CONVERSION_RATE)}
onChange={(v) => setT({ conversionRate: v })}
/>
)}
{mode === "COMBI" && (
<MoneyField
label="Davon Kapitalbezug (CHF)"
help={`Der Rest wird verrentet. Maximal ${formatChf(context.carriedEndValue)}.`}
value={num(td.capitalAmount)}
max={context.carriedEndValue}
onChange={(v) => setT({ capitalAmount: v })}
/>
)}
{/* Erst der Betrag, dann die Steuer, dann die Verwendung -- in der Reihenfolge,
in der man die Entscheidung tatsächlich trifft. */}
{(mode === "CAPITAL" || mode === "COMBI") && (
<>
<DerivedField
label="Kapitalbezug brutto"
value={brutto}
help="Das Guthaben am Ende der Vorphase (bei einer Kombination der oben gewählte Anteil)."
/>
<NumberField
label="Kapitalbezugssteuer (%)"
help="Pauschalsatz die tatsächliche Steuer ist kantonal und progressiv."
step={0.5}
value={taxRate}
onChange={(v) => setT({ capitalTaxRate: v })}
/>
<DerivedField
label="Auszahlung netto"
value={netto}
help="Brutto abzüglich Kapitalbezugssteuer. Dieser Betrag wird unten verteilt."
/>
<CapitalUseFields td={td} setT={setT} netAmount={netto} targets={context.investTargets} />
</>
)}
</>
);
if (context.isRetirementTransition && retirement) {
return <div className="col-span-2">{retirement}</div>;
}
return <WithdrawalDecision td={td} setT={setT} max={context.carriedEndValue} label="PK-Bezug (CHF)" />;
}
case "PILLAR_3A": {
if (context.isRetirementTransition) {
// Die Säule 3a wird bei der Pensionierung IMMER vollständig bezogen -- entschieden
// wird hier der Steuersatz und die Verwendung des Geldes.
const taxRate = num(td.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE);
const brutto = context.carriedEndValue;
const netto = Math.round(brutto * (1 - taxRate / 100));
return (
<>
<p className="col-span-2 rounded-lg bg-surface-2 px-3 py-2 text-xs text-muted">
Die Säule 3a wird bei der Pensionierung <strong className="text-fg">vollständig bezogen</strong>. Zu
entscheiden sind der Steuersatz und die Verwendung des Geldes.
</p>
<DerivedField label="Bezug brutto" value={brutto} help="Das Guthaben am Ende der Vorphase." />
<NumberField
label="Kapitalbezugssteuer (%)"
help="Pauschalsatz die tatsächliche Steuer ist kantonal und progressiv. Gestaffelte Bezüge (PK und 3a in verschiedenen Jahren) senken sie; das bildet das Tool noch nicht ab."
step={0.5}
value={taxRate}
onChange={(v) => setT({ capitalTaxRate: v })}
/>
<DerivedField
label="Auszahlung netto"
value={netto}
help="Brutto abzüglich Kapitalbezugssteuer. Dieser Betrag wird unten verteilt."
/>
<CapitalUseFields td={td} setT={setT} netAmount={netto} targets={context.investTargets} />
</>
);
if (context.isRetirementTransition && retirement) {
return <div className="col-span-2">{retirement}</div>;
}
return <WithdrawalDecision td={td} setT={setT} max={context.carriedEndValue} label="3a-Bezug (CHF)" />;
}
+156 -12
View File
@@ -15,6 +15,7 @@ import {
Pencil,
Maximize2,
PiggyBank,
Table2,
Plus,
Settings2,
ShoppingCart,
@@ -30,9 +31,20 @@ import { capitalPot } from "@/lib/distribution";
import {
isRetirementTransition,
openTransitionCount,
decisionsText,
type DecisionCounts,
transitionInactive as inactiveAtTransition,
TRANSITION_CATEGORIES,
retirementConfirmed,
} from "@/lib/decisions";
import { AHV_REFERENCE_AGE } from "@/lib/constants";
import {
RETIREMENT_CATEGORIES,
withRetirementDefaults,
type RetirementDecision,
} from "@/lib/retirement-decision";
import { RetirementFields } from "@/components/RetirementFields";
import { RetirementPanel } from "@/components/RetirementPanel";
import { Button, EmptyState, InspectorShell, Modal, useConfirm, useToast } from "@/components/ui";
import { ElementDetailDialog, PhaseDetailDialog } from "@/components/DetailView";
import {
@@ -170,6 +182,8 @@ export function PlanView({
// weil beide mehrere Elemente auf einmal bearbeiten.
const [distribute, setDistribute] = useState<{ kind: "capital" | "rates"; phaseId: string } | null>(null);
const [valueMode, setValueMode] = useState<ValueMode>("nominal");
// Matrix oder Pensionierung. Kein URL-Zustand: Es ist eine Arbeitsansicht, kein Ort.
const [view, setView] = useState<"matrix" | "retirement">("matrix");
// 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.
// Nur-Lese-Detailansicht (Roadmap Nr. 43). Der Rechenweg wird erst beim Öffnen erzeugt.
@@ -352,6 +366,18 @@ export function PlanView({
onChanged();
}
// Kurzfassung der Pensionierung fuer die Matrix-Ansicht: die eine Zahl, die zaehlt, plus
// der Hinweis auf ungepruefte Vorgaben. Ein Klick fuehrt in den Bildschirm.
const retirementSummaryText = (() => {
const r = computed.retirement;
if (r.gapAnnual === null) return null;
const unconfirmed = plan.elements.filter(
(e) => RETIREMENT_CATEGORIES.includes(e.category) && !retirementConfirmed(e)
).length;
const luecke = r.gapAnnual < 0 ? `Rentenluecke ${formatChf(r.gapAnnual)}/Jahr` : `Ueberschuss ${formatChf(r.gapAnnual)}/Jahr`;
return unconfirmed > 0 ? `${luecke} · ${unconfirmed} Vorgaben ungeprueft` : luecke;
})();
const hasPhases = computed.phases.length > 0;
const firstPhase = computed.phases[0] ?? null;
const lastPhase = computed.phases[computed.phases.length - 1] ?? null;
@@ -438,9 +464,46 @@ export function PlanView({
</div>
</div>
{/* Ansicht: Matrix oder Pensionierung. Die Pensionierung steht bewusst gleichrangig
NEBEN der Matrix und nicht in ihr: Sie ist keine Zelle, sondern eine eigene Frage --
und die einzige, die man zwischen Szenarien systematisch variiert. */}
{hasPhases && (
<div className="flex flex-wrap items-center gap-2">
<div className="inline-flex rounded-lg border border-border bg-surface p-0.5 text-sm">
{(["matrix", "retirement"] as const).map((v) => (
<button
key={v}
type="button"
onClick={() => setView(v)}
className={`flex items-center gap-1.5 rounded-md px-3 py-1 font-medium ${
view === v ? "bg-accent text-accent-fg" : "text-muted hover:bg-surface-2"
}`}
>
{v === "matrix" ? <Table2 className="h-4 w-4" /> : <PiggyBank className="h-4 w-4" />}
{v === "matrix" ? "Matrix" : "Pensionierung"}
</button>
))}
</div>
{view === "matrix" && retirementSummaryText && (
<button
type="button"
onClick={() => setView("retirement")}
className="truncate text-xs text-muted underline decoration-dotted underline-offset-2 hover:text-fg"
title="Zur Pensionierung"
>
{retirementSummaryText}
</button>
)}
</div>
)}
{view === "retirement" && (
<RetirementPanel plan={plan} computed={computed} onSaved={onChanged} />
)}
{/* Anzeige-Umschalter (nominal/real, Plan/Ist) -- direkt über der Matrix, weil er nur
deren Zahlen steuert. */}
{hasPhases && (
{hasPhases && view === "matrix" && (
<div className="flex items-center gap-2">
<span className="text-xs text-muted">Anzeige</span>
<div className="inline-flex rounded-lg border border-border bg-surface p-0.5 text-xs">
@@ -554,7 +617,7 @@ export function PlanView({
{/* Matrix: eigener Scrollbereich, damit Phasen-Köpfe (oben) UND Elementnamen (links)
beim Scrollen sichtbar bleiben. */}
{hasPhases && (
{hasPhases && view === "matrix" && (
<div data-tour="matrix" className="max-h-[75vh] overflow-auto rounded-xl border border-border bg-surface shadow-sm">
{/* Feste Spaltenbreiten: Alle Phasenspalten sind gleich breit -- bei einer einzigen
Phase bleibt die Tabelle dadurch schmal, bei vielen wird horizontal gescrollt.
@@ -589,7 +652,7 @@ export function PlanView({
) : (
<TransitionHeader
key={`t-${col.fromPhase.id}`}
openCount={transitionOpenCount(col.fromPhase, col.toPhase)}
counts={transitionOpenCount(col.fromPhase, col.toPhase)}
onClick={() => setReviewFromPhaseId(col.fromPhase.id)}
/>
)
@@ -880,6 +943,8 @@ export function PlanView({
const els = transitionElements(fromPhase, toPhase);
return (
<TransitionReviewDialog
plan={plan}
computed={computed}
fromPhase={fromPhase}
toPhase={toPhase}
elements={els}
@@ -1084,6 +1149,8 @@ export function PlanView({
const toPhase = computed.phases[computed.phases.findIndex((p) => p.id === fromPhase.id) + 1];
return (
<TransitionCellPanel
plan={plan}
computed={computed}
key={`${panel.elementId}-${panel.fromPhaseId}`}
element={element}
fromPhase={fromPhase}
@@ -1451,14 +1518,22 @@ function PhaseHeader({
);
}
function TransitionHeader({ openCount, onClick }: { openCount: number; onClick: () => void }) {
const done = openCount === 0;
function TransitionHeader({ counts, onClick }: { counts: DecisionCounts; onClick: () => void }) {
// Eine ungeprüfte Vorgabe ist kein vergessenes Eingabefeld -- sie wird deshalb eigens
// benannt und in gedeckterem Ton gezeigt (SPEZIFIKATION 3.5.3).
const done = counts.open === 0 && counts.unconfirmed === 0;
const onlyDefaults = counts.open === 0 && counts.unconfirmed > 0;
return (
<th
onClick={onClick}
data-tour="transition"
title={done ? undefined : decisionsText(counts)}
className={`sticky top-0 z-30 w-24 min-w-24 cursor-pointer border-b border-r border-border px-2 py-2 text-center align-top text-[11px] font-medium transition-colors ${
done ? "bg-surface-2 text-success" : "bg-attention text-attention-fg"
done
? "bg-surface-2 text-success"
: onlyDefaults
? "bg-surface-2 text-muted"
: "bg-attention text-attention-fg"
}`}
>
<div>Übergang</div>
@@ -1467,8 +1542,17 @@ function TransitionHeader({ openCount, onClick }: { openCount: number; onClick:
<CheckCircle2 className="h-3 w-3" /> geprüft
</div>
) : (
<div className="mt-1 rounded-full bg-attention-fg/20 px-1.5 py-0.5 text-[10px] font-semibold">
{openCount} offen
<div className="mt-1 flex flex-col gap-0.5">
{counts.open > 0 && (
<div className="rounded-full bg-attention-fg/20 px-1.5 py-0.5 text-[10px] font-semibold">
{counts.open} offen
</div>
)}
{counts.unconfirmed > 0 && (
<div className="rounded-full border border-border px-1.5 py-0.5 text-[10px] font-semibold">
{counts.unconfirmed} Vorgabe{counts.unconfirmed === 1 ? "" : "n"}
</div>
)}
</div>
)}
</th>
@@ -1496,7 +1580,7 @@ function NextSteps({
plan: PlanInput;
computed: PlanComputed;
columns: Column[];
openCountFor: (fromPhase: PhaseComputed, toPhase: PhaseComputed) => number;
openCountFor: (fromPhase: PhaseComputed, toPhase: PhaseComputed) => DecisionCounts;
onReview: (fromPhaseId: string) => void;
onAddElement: (category: ElementCategory) => void;
onAddPhase: () => void;
@@ -1512,14 +1596,14 @@ function NextSteps({
for (const col of columns) {
if (col.kind !== "transition") continue;
const n = openCountFor(col.fromPhase, col.toPhase);
openTotal += n;
if (n > 0 && firstOpenPhaseId === null) firstOpenPhaseId = col.fromPhase.id;
openTotal += n.open;
if (n.open > 0 && firstOpenPhaseId === null) firstOpenPhaseId = col.fromPhase.id;
}
if (openTotal > 0 && firstOpenPhaseId) {
const target = firstOpenPhaseId;
items.push({
key: "transitions",
text: `${openTotal} Übergangs-Entscheid${openTotal === 1 ? "" : "e"} offen z. B. was bei der Pensionierung mit PK und 3a passiert.`,
text: `${openTotal} Übergangs-Entscheid${openTotal === 1 ? "" : "e"} offen z. B. ob eine Immobilie verkauft wird.`,
action: "Jetzt durchgehen",
run: () => onReview(target),
});
@@ -1869,7 +1953,14 @@ function ProfilePanel({ plan, onClose, onSaved }: { plan: PlanInput; onClose: ()
}
// --- Dialog: geführter Übergang ---
// Pensionsalter des Element-Besitzers -- Bezugspunkt fuer die Vorgaben des Entscheids.
function retirementAgeOf(plan: PlanInput, el: ElementInput): number {
return plan.persons.find((p) => p.role === el.ownerRole)?.retirementAge ?? AHV_REFERENCE_AGE;
}
function TransitionReviewDialog({
plan,
computed,
fromPhase,
toPhase,
elements,
@@ -1879,6 +1970,8 @@ function TransitionReviewDialog({
onClose,
onSaved,
}: {
plan: PlanInput;
computed: PlanComputed;
fromPhase: PhaseComputed;
toPhase: PhaseComputed | undefined;
elements: ElementInput[];
@@ -1894,6 +1987,14 @@ function TransitionReviewDialog({
)
);
const [ct, setCt] = useState<CashTransitionData>(() => withCashTransitionDefaults(initialCash));
// Pensionierungs-Entscheide: eigener Entwurf, weil sie an einem anderen Ort liegen als die
// Übergangswerte -- und beim Speichern über einen eigenen Endpunkt gehen.
const [rds, setRds] = useState<Record<string, RetirementDecision>>({});
const effectiveRd = (el: ElementInput) =>
withRetirementDefaults(el.category, retirementAgeOf(plan, el), {
...(el.retirementDecision ?? {}),
...(rds[el.id] ?? {}),
});
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -1905,6 +2006,11 @@ function TransitionReviewDialog({
for (const e of elements) {
await api.put(`/api/elements/${e.id}/transition/${fromPhase.id}`, tds[e.id] ?? {});
}
// Pensionierungs-Entscheide liegen am ELEMENT und haben deshalb einen eigenen Endpunkt.
for (const [elementId, changes] of Object.entries(rds)) {
const el = plan.elements.find((x) => x.id === elementId);
await api.put(`/api/elements/${elementId}/retirement`, { ...(el?.retirementDecision ?? {}), ...changes });
}
onSaved();
} catch (err) {
setError(err instanceof Error ? err.message : "Speichern fehlgeschlagen.");
@@ -1974,6 +2080,16 @@ function TransitionReviewDialog({
context={ctx}
td={tds[el.id] ?? {}}
setT={(patch) => setTds((prev) => ({ ...prev, [el.id]: { ...prev[el.id], ...patch } }))}
retirement={
<RetirementFields
plan={plan}
el={el}
defaultOpen
rd={effectiveRd(el)}
patch={(id, patch) => setRds((prev) => ({ ...prev, [id]: { ...prev[id], ...patch } }))}
summary={computed.retirement.perPerson.find((x) => x.role === el.ownerRole)}
/>
}
/>
</div>
</div>
@@ -2073,6 +2189,8 @@ function CashInitialPanel({ plan, onClose, onSaved }: { plan: PlanInput; onClose
// --- Panel: einzelner Übergangs-Entscheid (per Klick auf eine Übergangszelle) ---
function TransitionCellPanel({
plan,
computed,
element,
fromPhase,
toPhase,
@@ -2080,6 +2198,8 @@ function TransitionCellPanel({
onClose,
onSaved,
}: {
plan: PlanInput;
computed: PlanComputed;
element: ElementInput;
fromPhase: PhaseComputed;
toPhase: PhaseComputed | undefined;
@@ -2090,6 +2210,12 @@ function TransitionCellPanel({
const [td, setTd] = useState<TransitionData>(() =>
withTransitionDefaults(element.category, context.isRetirementTransition, element.transitionValues[fromPhase.id] ?? {})
);
const [rd, setRd] = useState<RetirementDecision>({});
const effectiveRd = (el: ElementInput) =>
withRetirementDefaults(el.category, retirementAgeOf(plan, el), {
...(el.retirementDecision ?? {}),
...rd,
});
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -2098,6 +2224,12 @@ function TransitionCellPanel({
setError(null);
try {
await api.put(`/api/elements/${element.id}/transition/${fromPhase.id}`, td);
if (Object.keys(rd).length > 0) {
await api.put(`/api/elements/${element.id}/retirement`, {
...(element.retirementDecision ?? {}),
...rd,
});
}
onSaved();
} catch (e) {
setError(e instanceof Error ? e.message : "Speichern fehlgeschlagen.");
@@ -2118,6 +2250,18 @@ function TransitionCellPanel({
context={context}
td={td}
setT={(patch) => setTd((prev) => ({ ...prev, ...patch }))}
retirement={
<RetirementFields
plan={plan}
el={element}
defaultOpen
rd={effectiveRd(element)}
patch={(_id: string, patch: Partial<RetirementDecision>) =>
setRd((prev) => ({ ...prev, ...patch }))
}
summary={computed.retirement.perPerson.find((x) => x.role === element.ownerRole)}
/>
}
/>
</div>
{error && <p className="mt-3 text-sm text-danger">{error}</p>}
+468
View File
@@ -0,0 +1,468 @@
"use client";
// Die drei Säulen-Bausteine des Pensionierungs-Entscheids.
//
// Eigenes Modul, weil sie an ZWEI Orten erscheinen: im Pensionierungs-Bildschirm (alle
// beieinander) und in der Matrix-Zelle am Pensions-Übergang (je einer). Das ist kein
// Duplikat, sondern zwei Ansichten auf dasselbe Objekt -- deshalb auch genau eine
// Implementierung. Wären es zwei, liefen sie garantiert auseinander.
import { useState } from "react";
import { AlertTriangle, Check, ChevronDown, ChevronRight, Info } from "lucide-react";
import { InfoBubble } from "@/components/InfoBubble";
import { MoneyField, NumberField, SelectField } from "@/components/FormField";
import { formatChf } from "@/lib/format";
import { ownerLabel } from "@/lib/elements";
import {
AHV_DEFER_MAX_MONTHS,
AHV_EARLY_MAX_MONTHS,
AHV_REFERENCE_AGE,
PILLAR_3A_MAX_WITHDRAWAL_AGE,
PILLAR_3A_MIN_WITHDRAWAL_AGE,
PK_BUYIN_BLOCKING_YEARS,
} from "@/lib/constants";
import { ahvDrawLabel, withRetirementDefaults, type AhvDraw, type RetirementDecision } from "@/lib/retirement-decision";
import type { RetirementPersonSummary } from "@/lib/calculations";
import type { ElementInput, PlanInput } from "@/lib/types";
export type PatchFn = (elementId: string, p: Partial<RetirementDecision>) => void;
// Dispatcher: rendert den passenden Baustein zur Kategorie. Wird von der Matrix-Zelle
// genutzt, die immer genau ein Element vor sich hat.
export function RetirementFields({
plan,
el,
patch,
summary,
defaultOpen,
// Der bereits mit Vorgaben aufgefüllte Entscheid. Der Pensionierungs-Bildschirm reicht hier
// seinen Entwurf herein (ungespeicherte Änderungen); die Matrix-Zelle lässt ihn weg und
// bekommt den gespeicherten Stand.
rd: rdOverride,
}: {
plan: PlanInput;
el: ElementInput;
patch: PatchFn;
summary?: RetirementPersonSummary;
defaultOpen?: boolean;
rd?: RetirementDecision;
}) {
const person = plan.persons.find((p) => p.role === el.ownerRole);
const retirementAge = person?.retirementAge ?? AHV_REFERENCE_AGE;
const rd = rdOverride ?? withRetirementDefaults(el.category, retirementAge, el.retirementDecision);
const siblings = plan.elements
.filter((x) => x.category === "PILLAR_3A" && x.ownerRole === el.ownerRole && x.id !== el.id)
.map((x) => withRetirementDefaults("PILLAR_3A", retirementAge, x.retirementDecision));
if (el.category === "AHV") return <AhvBlock el={el} rd={rd} patch={patch} summary={summary} defaultOpen={defaultOpen} />;
if (el.category === "PENSION_FUND")
return <PkBlock plan={plan} el={el} rd={rd} patch={patch} summary={summary} defaultOpen={defaultOpen} />;
if (el.category === "PILLAR_3A")
return (
<Pillar3aBlock
el={el}
rd={rd}
patch={patch}
retirementAge={retirementAge}
siblings={siblings}
defaultOpen={defaultOpen}
/>
);
return null;
}
// --- Säulen-Blöcke -------------------------------------------------------------------------
export function Pillar({
title,
subtitle,
confirmed,
onConfirm,
children,
defaultOpen,
}: {
title: string;
subtitle: string;
confirmed: boolean;
onConfirm: (v: boolean) => void;
children: React.ReactNode;
defaultOpen?: boolean;
}) {
const [open, setOpen] = useState(defaultOpen ?? false);
return (
<div className="rounded-xl border border-border">
<button
type="button"
onClick={() => setOpen((o) => !o)}
className="flex w-full items-center gap-2 px-3 py-2 text-left"
>
{open ? <ChevronDown className="h-4 w-4 text-faint" /> : <ChevronRight className="h-4 w-4 text-faint" />}
<span className="text-sm font-semibold text-fg">{title}</span>
<span className="flex-1 truncate text-xs text-muted">{subtitle}</span>
{confirmed ? (
<span className="flex items-center gap-1 whitespace-nowrap text-[11px] font-semibold text-success">
<Check className="h-3.5 w-3.5" /> bestätigt
</span>
) : (
<span className="flex items-center gap-1 whitespace-nowrap rounded-full border border-border px-2 py-0.5 text-[11px] text-muted">
<Info className="h-3.5 w-3.5" /> Vorgabe ungeprüft
</span>
)}
</button>
{open && (
<div className="border-t border-border px-3 py-3">
<div className="grid gap-3 sm:grid-cols-2">{children}</div>
<label className="mt-3 flex cursor-pointer items-start gap-2 text-xs text-muted">
<input
type="checkbox"
checked={confirmed}
onChange={(e) => onConfirm(e.target.checked)}
className="mt-0.5"
/>
<span>
Ich habe das angeschaut und bestätige es. Ohne Häkchen rechnet das Tool mit der Vorgabe sie wird als
«ungeprüft» ausgewiesen, damit sie nicht unbemerkt durchgeht.
</span>
</label>
</div>
)}
</div>
);
}
export function AhvBlock({
el,
rd,
patch,
summary,
defaultOpen,
}: {
el: ElementInput;
rd: RetirementDecision;
patch: (elementId: string, p: Partial<RetirementDecision>) => void;
summary?: RetirementPersonSummary;
defaultOpen?: boolean;
}) {
const draw = rd.ahvDraw ?? "REFERENCE";
const months = rd.ahvMonths ?? 0;
const maxMonths = draw === "EARLY" ? AHV_EARLY_MAX_MONTHS : AHV_DEFER_MAX_MONTHS;
return (
<Pillar
title="AHV"
subtitle={`${ahvDrawLabel(rd)} · ${formatChf(summary?.ahvAnnual ?? 0)} / Jahr`}
confirmed={rd.confirmed === true}
onConfirm={(v) => patch(el.id, { confirmed: v })}
defaultOpen={defaultOpen}
>
<SelectField
label="Bezug"
help={`Referenzalter ist ${AHV_REFERENCE_AGE}. Ein Vorbezug kürzt die Rente lebenslang um 6,8 % pro Jahr, ein Aufschub erhöht sie (nach 5 Jahren um 31,5 %).`}
value={draw}
onChange={(v: AhvDraw) =>
patch(el.id, { ahvDraw: v, ahvMonths: v === "REFERENCE" ? 0 : months || 12 })
}
options={[
{ value: "REFERENCE", label: `Ab Referenzalter (${AHV_REFERENCE_AGE})` },
{ value: "EARLY", label: "Vorbeziehen" },
{ value: "DEFERRED", label: "Aufschieben" },
]}
/>
{draw !== "REFERENCE" && (
<NumberField
label={draw === "EARLY" ? "Vorbezug (Monate)" : "Aufschub (Monate)"}
help={
draw === "EARLY"
? `Höchstens ${AHV_EARLY_MAX_MONTHS} Monate (3 Jahre), seit AHV 21 monatsgenau.`
: `Mindestens 12, höchstens ${AHV_DEFER_MAX_MONTHS} Monate (5 Jahre).`
}
value={months}
min={draw === "EARLY" ? 1 : 12}
max={maxMonths}
step={1}
onChange={(v) => patch(el.id, { ahvMonths: Math.max(0, Math.min(maxMonths, Math.round(v))) })}
/>
)}
{draw !== "REFERENCE" && summary && (
<div className="sm:col-span-2 rounded-lg bg-surface-2 px-3 py-2 text-xs text-muted">
Rente ab Alter <strong className="text-fg">{summary.ahvFromAge}</strong>:{" "}
<strong className="text-fg">{formatChf(summary.ahvAnnual)}</strong> pro Jahr. Die Beitragspflicht endet davon
unabhängig erst mit {AHV_REFERENCE_AGE} wer vorher aufhört zu arbeiten, zahlt bis dahin als
Nichterwerbstätige(r) weiter.
</div>
)}
<div className="sm:col-span-2 mt-1 border-t border-border pt-3">
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-faint">Grundlage der Schätzung</p>
<div className="grid gap-3 sm:grid-cols-2">
<MoneyField
label="Durchschnittseinkommen vor Planbeginn (brutto, heutige Kaufkraft)"
help="Aus der AHV-Rentenvorausberechnung. Bleibt das Feld leer, schätzt das Tool den Wert aus dem geplanten Durchschnitt das ist besser als 0, aber ungenauer als dein echter Auszug."
value={rd.avgIncomeBefore ?? 0}
onChange={(v) => patch(el.id, { avgIncomeBefore: v })}
/>
<NumberField
label="Beitragslücken vor Planbeginn (Jahre)"
help="Jahre ohne AHV-Beitrag, etwa durch Auslandaufenthalt. Jedes fehlende Jahr kürzt die Rente um rund 1/44."
value={rd.gapYearsBefore ?? 0}
min={0}
max={50}
step={1}
onChange={(v) => patch(el.id, { gapYearsBefore: Math.max(0, Math.round(v)) })}
/>
</div>
</div>
</Pillar>
);
}
export function PkBlock({
plan,
el,
rd,
patch,
summary,
defaultOpen,
}: {
plan: PlanInput;
el: ElementInput;
rd: RetirementDecision;
patch: (elementId: string, p: Partial<RetirementDecision>) => void;
summary?: RetirementPersonSummary;
defaultOpen?: boolean;
}) {
const share = rd.capitalSharePct ?? 0;
const targets = plan.elements.filter((e) => e.category === "OTHER_ASSET");
return (
<Pillar
title={`Pensionskasse · ${el.name}`}
subtitle={
share === 0
? `Volle Rente · ${formatChf(summary?.pkPensionAnnual ?? 0)} / Jahr`
: share === 100
? "Volles Kapital"
: `${100 - share} % Rente / ${share} % Kapital`
}
confirmed={rd.confirmed === true}
onConfirm={(v) => patch(el.id, { confirmed: v })}
defaultOpen={defaultOpen}
>
<div className="sm:col-span-2">
<label className="mb-1 flex items-center text-xs font-medium text-muted">
Kapitalanteil
<InfoBubble text="0 % = volle Rente, 100 % = volles Kapital, alles dazwischen ist die Kombination. Bewusst eine Quote und kein Frankenbetrag: Verschiebst du das Pensionsalter, ändert sich das Guthaben die Quote skaliert mit, ein fixer Betrag würde still ein anderes Verhältnis bedeuten." />
</label>
<div className="flex items-center gap-3">
<input
type="range"
min={0}
max={100}
step={5}
value={share}
onChange={(e) => patch(el.id, { capitalSharePct: Number(e.target.value) })}
className="h-2 flex-1 cursor-pointer appearance-none rounded-full bg-surface-2 accent-[var(--accent)]"
/>
<span className="w-28 whitespace-nowrap text-right text-sm tabular-nums text-fg">
{share} % Kapital
</span>
</div>
<div className="mt-1 flex justify-between text-[11px] text-faint">
<span>volle Rente</span>
<span>volles Kapital</span>
</div>
</div>
<NumberField
label="Umwandlungssatz (%)"
help="Aus deinem PK-Ausweis. Der gesetzliche Mindestsatz von 6,8 % gilt nur für den obligatorischen Teil umhüllende Kassen liegen effektiv oft bei 5 bis 5,6 %, bei einer Frühpensionierung tiefer."
value={rd.conversionRate ?? 0}
step={0.1}
min={0}
max={20}
onChange={(v) => patch(el.id, { conversionRate: v })}
/>
{share > 0 && (
<NumberField
label="Kapitalbezugssteuer (%)"
help="Pauschalsatz. Die tatsächliche Steuer ist kantonal und progressiv und alle Kapitalbezüge desselben Jahres werden zusammengezählt, bei Ehepaaren auch die des Partners. Wer staffelt, zahlt weniger."
value={rd.capitalTaxRate ?? 0}
step={0.5}
min={0}
max={100}
onChange={(v) => patch(el.id, { capitalTaxRate: v })}
/>
)}
{share > 0 && (
<>
<div className="sm:col-span-2">
<label className="flex cursor-pointer items-start gap-2 text-xs text-muted">
<input
type="checkbox"
checked={rd.recentBuyIn === true}
onChange={(e) => patch(el.id, { recentBuyIn: e.target.checked })}
className="mt-0.5"
/>
<span>
In den letzten {PK_BUYIN_BLOCKING_YEARS} Jahren in die Pensionskasse eingekauft
</span>
</label>
{rd.recentBuyIn && (
<p className="mt-1 flex items-start gap-2 rounded-lg border border-attention bg-attention/10 px-3 py-2 text-xs text-attention-fg">
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
Ein Kapitalbezug innerhalb von {PK_BUYIN_BLOCKING_YEARS} Jahren nach einem Einkauf lässt den
Steuerabzug für diesen Einkauf nachträglich entfallen (Art. 79b Abs. 3 BVG). Das Tool rechnet diesen
Effekt nicht prüfe die Fristen mit deiner Kasse.
</p>
)}
</div>
<CapitalUse rd={rd} elementId={el.id} patch={patch} targets={targets} plan={plan} />
</>
)}
</Pillar>
);
}
export function Pillar3aBlock({
el,
rd,
patch,
retirementAge,
siblings,
defaultOpen,
}: {
el: ElementInput;
rd: RetirementDecision;
patch: (elementId: string, p: Partial<RetirementDecision>) => void;
retirementAge: number;
siblings: RetirementDecision[];
defaultOpen?: boolean;
}) {
const age = rd.withdrawalAge ?? retirementAge;
const clash = siblings.some((s) => (s.withdrawalAge ?? retirementAge) === age);
return (
<Pillar
title={`Säule 3a · ${el.name}`}
subtitle={`Bezug mit ${age}`}
confirmed={rd.confirmed === true}
onConfirm={(v) => patch(el.id, { confirmed: v })}
defaultOpen={defaultOpen}
>
<NumberField
label="Bezugsalter"
help={`Frühestens ${PILLAR_3A_MIN_WITHDRAWAL_AGE}, spätestens ${PILLAR_3A_MAX_WITHDRAWAL_AGE} und nach dem Referenzalter nur, solange du erwerbstätig bleibst. Der Bezug erfolgt an der ersten Phasengrenze bei oder nach diesem Alter.`}
value={age}
min={PILLAR_3A_MIN_WITHDRAWAL_AGE}
max={PILLAR_3A_MAX_WITHDRAWAL_AGE}
step={1}
onChange={(v) => patch(el.id, { withdrawalAge: Math.round(v) })}
/>
<NumberField
label="Kapitalbezugssteuer (%)"
help="Pauschalsatz. Alle Kapitalbezüge desselben Jahres werden zusammengezählt deshalb lohnt sich das Staffeln über mehrere Jahre und mehrere Konten."
value={rd.capitalTaxRate ?? 0}
step={0.5}
min={0}
max={100}
onChange={(v) => patch(el.id, { capitalTaxRate: v })}
/>
<div className="sm:col-span-2 rounded-lg bg-surface-2 px-3 py-2 text-xs text-muted">
Ein 3a-Konto lässt sich bei der Pensionierung nur <strong className="text-fg">ganz</strong> auflösen. Gestaffelt
wird über mehrere Konten mit unterschiedlichen Bezugsjahren.
{age > AHV_REFERENCE_AGE && (
<>
{" "}
Ein Bezug nach {AHV_REFERENCE_AGE} setzt voraus, dass du weiterhin erwerbstätig bist.
</>
)}
</div>
{clash && (
<p className="sm:col-span-2 flex items-start gap-2 text-xs text-attention-fg">
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
Ein weiteres 3a-Konto wird im selben Jahr bezogen. Die Beträge werden steuerlich zusammengezählt ein
anderes Bezugsjahr senkt die Progression.
</p>
)}
</Pillar>
);
}
// Verwendung des bezogenen Kapitals (Punkt C). Dieselbe Frage wie in der Matrix-Zelle -- hier
// nur an dem Ort, an dem man ohnehin über den Bezug nachdenkt.
function CapitalUse({
rd,
elementId,
patch,
targets,
plan,
}: {
rd: RetirementDecision;
elementId: string;
patch: (elementId: string, p: Partial<RetirementDecision>) => void;
targets: ElementInput[];
plan: PlanInput;
}) {
const amort = Math.max(0, Math.min(100, rd.capitalUseAmortizationPct ?? 0));
const invest = Math.max(0, Math.min(100 - amort, rd.capitalUseInvestPct ?? 0));
const cash = Math.max(0, 100 - amort - invest);
const hasMortgage = plan.elements.some((e) => e.category === "REAL_ESTATE");
return (
<>
<div className="sm:col-span-2 mt-1 border-t border-border pt-3">
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-faint">
Wohin fliesst das bezogene Kapital?
</p>
<div className="grid gap-3 sm:grid-cols-2">
<NumberField
label="… in die Hypothek (%)"
help={
hasMortgage
? "Einmalige Amortisation. Am Restsaldo gekappt ist die Hypothek kleiner, bleibt der Rest Cash."
: "Es gibt keine Immobilie in diesem Szenario dieser Anteil bliebe wirkungslos auf dem Cash."
}
value={amort}
step={5}
min={0}
max={100}
onChange={(v) => patch(elementId, { capitalUseAmortizationPct: Math.max(0, Math.min(100, v)) })}
/>
<NumberField
label="… in die Anlage (%)"
help="Fliesst als Zusatzeinlage in das gewählte Vermögens-Element und wächst dort weiter."
value={invest}
step={5}
min={0}
max={100 - amort}
onChange={(v) => patch(elementId, { 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"
value={rd.capitalUseTargetElementId ?? targets[0].id}
onChange={(v: string) => patch(elementId, { capitalUseTargetElementId: v })}
options={targets.map((t) => ({
value: t.id,
label: `${t.name} · ${ownerLabel(plan.persons, t.ownerRole)}`,
}))}
/>
</div>
)}
{invest > 0 && targets.length === 0 && (
<p className="sm:col-span-2 flex items-start gap-2 text-xs text-attention-fg">
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
Es gibt kein Element «Sonstiges Vermögen», in das die Anlage-Quote fliessen könnte. Der Betrag bliebe auf
dem Cash-Konto liegen.
</p>
)}
<p className="sm:col-span-2 text-xs text-faint">
Nicht zugeteilt: <strong className="text-fg">{cash} %</strong> bleibt auf dem Cash-Konto.
</p>
</div>
</div>
</>
);
}
+338
View File
@@ -0,0 +1,338 @@
"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 { NumberField } from "@/components/FormField";
import { RetirementAdjuster } from "@/components/RetirementAdjuster";
import { RetirementFields } from "@/components/RetirementFields";
import { formatChf } from "@/lib/format";
import { ownerLabel } from "@/lib/elements";
import {
AHV_REFERENCE_AGE,
MAX_PLANNING_HORIZON_AGE,
MIN_PLANNING_HORIZON_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,
onSaved,
}: {
plan: PlanInput;
computed: PlanComputed;
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">
<div>
<h2 className="text-lg font-semibold text-fg">Pensionierung</h2>
<p className="mt-1 text-sm text-muted">
Alle Entscheide zu AHV, Pensionskasse und Säule 3a an einem Ort. Was du hier festlegst, steht auch in der
Matrix am Pensions-Übergang es ist derselbe Entscheid, nur anders angeschaut.
</p>
</div>
{plan.persons.map((person) => {
const summary = computed.retirement.perPerson.find((s) => s.role === person.role);
return (
<PersonBlock
key={person.role}
plan={plan}
computed={computed}
role={person.role}
summary={summary}
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="grid gap-4 px-4 py-4 md:grid-cols-2">
<RetirementAdjuster plan={plan} onSaved={onSaved} />
<HorizonControl plan={plan} role={role} 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>
);
}
// --- Planungshorizont ----------------------------------------------------------------------
function HorizonControl({ plan, role, onSaved }: { plan: PlanInput; role: PersonRole; onSaved: () => void }) {
const person = plan.persons.find((p) => p.role === role)!;
const [value, setValue] = useState(person.planningHorizonAge ?? null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const toast = useToast();
// Ohne erfassten Horizont: das Alter, bei dem der Plan heute faktisch endet.
const implied = person.age + plan.phases.reduce((s, p) => s + p.durationYears, 0);
async function submit(next: number) {
setBusy(true);
setError(null);
try {
await api.post(`/api/scenarios/${plan.id}/horizon`, { role, horizonAge: next });
toast("success", `Planungshorizont auf Alter ${next} gesetzt.`);
onSaved();
} catch (e) {
setError(e instanceof Error ? e.message : "Nicht möglich.");
setValue(person.planningHorizonAge ?? null);
} finally {
setBusy(false);
}
}
return (
<div className="rounded-xl border border-border p-3">
<h4 className="mb-2 flex items-center text-sm font-semibold text-fg">
Planungshorizont
<InfoBubble text="Bis zu welchem Alter gerechnet wird. Die letzte Lebensphase passt sich an. Ohne diesen Wert ergibt sich das Planende stillschweigend aus der Summe der Phasendauern zwei Szenarien rechnen dann womöglich unbemerkt verschieden weit und sind nicht vergleichbar." />
</h4>
<div className="flex items-end gap-2">
<div className="flex-1">
<NumberField
label="Alter"
value={value ?? implied}
min={MIN_PLANNING_HORIZON_AGE}
max={MAX_PLANNING_HORIZON_AGE}
step={1}
onChange={setValue}
/>
</div>
<Button
disabled={busy || value === null || value === person.planningHorizonAge}
onClick={() => value !== null && submit(value)}
>
Setzen
</Button>
</div>
{person.planningHorizonAge === null && (
<p className="mt-1 text-xs text-faint">
Noch nicht gesetzt der Plan endet aktuell rechnerisch mit Alter {implied}.
</p>
)}
{error && <p className="mt-1 text-xs text-danger">{error}</p>}
</div>
);
}