Pensionierungs-Bildschirm, Ampel mit drei Zustaenden, Planungshorizont
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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
@@ -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>}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user