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
co-authored by Claude Opus 5
parent 9907fda6f4
commit 1865db5de7
12 changed files with 1181 additions and 130 deletions
@@ -0,0 +1,41 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { getOwnedElement } from "@/lib/queries";
import { getCurrentUserId } from "@/lib/session";
import { touchScenario } from "@/lib/versioning-db";
import { RETIREMENT_CATEGORIES, retirementDecisionSchema } from "@/lib/retirement-decision";
// Speichert den Pensionierungs-Entscheid eines Elements (AHV, PK, Säule 3a).
//
// Bewusst OHNE Phasenbezug in der Route: Der Entscheid gilt für die Pensionierung des
// Besitzers, wo immer die auf der Zeitachse gerade liegt. Genau das unterscheidet ihn vom
// Übergangs-Entscheid (`/transition/<fromPhaseId>`), der an einer konkreten Grenze hängt.
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ elementId: string }> }
) {
const userId = await getCurrentUserId();
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
const { elementId } = await params;
const element = await getOwnedElement(elementId, userId);
if (!element) return NextResponse.json({ error: "Element nicht gefunden." }, { status: 404 });
if (!RETIREMENT_CATEGORIES.includes(element.category)) {
return NextResponse.json(
{ error: "Nur AHV, Pensionskasse und Säule 3a kennen einen Pensionierungs-Entscheid." },
{ status: 400 }
);
}
const body = await request.json();
const parsed = retirementDecisionSchema.safeParse(body);
if (!parsed.success) return NextResponse.json({ error: "Ungültige Eingabe." }, { status: 400 });
await prisma.financialElement.update({
where: { id: elementId },
data: { retirementDecision: parsed.data },
});
await touchScenario(element.scenarioId, userId);
return NextResponse.json({ ok: true });
}
@@ -34,7 +34,11 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
inflationRateDefault: source.inflationRateDefault,
initialCash: source.initialCash,
persons: {
create: source.persons.map((p) => ({ role: p.role, retirementAge: p.retirementAge })),
create: source.persons.map((p) => ({
role: p.role,
retirementAge: p.retirementAge,
planningHorizonAge: p.planningHorizonAge,
})),
},
},
});
@@ -64,6 +68,9 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
name: el.name,
ownerRole: el.ownerRole,
orderIndex: el.orderIndex,
// Ohne das waere die Kopie eines Szenarios genau fuer den Zweck unbrauchbar, fuer
// den man sie am haeufigsten anlegt: ein anderes Pensionierungs-Szenario.
retirementDecision: el.retirementDecision ?? undefined,
sourceElementId: el.id,
},
});
@@ -0,0 +1,55 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { prisma } from "@/lib/db";
import { getOwnedScenario, toPlanInput } from "@/lib/queries";
import { getCurrentUserId } from "@/lib/session";
import { touchScenario } from "@/lib/versioning-db";
import { planHorizonChange } from "@/lib/retirement";
import { MAX_PLANNING_HORIZON_AGE, MIN_PLANNING_HORIZON_AGE } from "@/lib/constants";
// Planungshorizont setzen: bis zu welchem Alter gerechnet wird.
//
// Wie beim Pensionsalter gilt: Zahl stellen, Struktur folgt. Die LETZTE Lebensphase wird so
// verlängert oder gekürzt, dass der Plan genau bis zum Horizont läuft. Vorher ergab sich das
// Planende stillschweigend aus der Summe der Phasendauern -- zwei Szenarien konnten dadurch
// unbemerkt verschieden weit rechnen und waren nicht vergleichbar.
const bodySchema = z.object({
role: z.enum(["PERSON_A", "PERSON_B"]),
horizonAge: z.number().int().min(MIN_PLANNING_HORIZON_AGE).max(MAX_PLANNING_HORIZON_AGE),
});
export async function POST(request: NextRequest, { params }: { params: Promise<{ scenarioId: string }> }) {
const userId = await getCurrentUserId();
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
const { scenarioId } = await params;
const scenario = await getOwnedScenario(scenarioId, userId);
if (!scenario) return NextResponse.json({ error: "Szenario nicht gefunden." }, { status: 404 });
const parsed = bodySchema.safeParse(await request.json().catch(() => ({})));
if (!parsed.success) return NextResponse.json({ error: "Ungültige Eingabe." }, { status: 400 });
const { role, horizonAge } = parsed.data;
const planInput = toPlanInput(scenario);
const person = planInput.persons.find((p) => p.role === role);
if (!person) return NextResponse.json({ error: "Diese Person gibt es in diesem Szenario nicht." }, { status: 400 });
if (horizonAge <= person.retirementAge) {
return NextResponse.json(
{ error: "Der Planungshorizont muss nach der Pensionierung liegen." },
{ status: 400 }
);
}
const change = planHorizonChange(planInput, horizonAge, role);
if (!change) return NextResponse.json({ error: "Es gibt keine Lebensphase, die sich anpassen liesse." }, { status: 400 });
if (change.blocked) return NextResponse.json({ error: change.blocked }, { status: 400 });
await prisma.$transaction([
prisma.person.update({ where: { id: person.id }, data: { planningHorizonAge: horizonAge } }),
prisma.phase.update({ where: { id: change.lastPhaseId }, data: { durationYears: change.newDuration } }),
]);
await touchScenario(scenario.id, userId);
return NextResponse.json({ ok: true, lastPhaseDuration: change.newDuration });
}
+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>
);
}
+66 -11
View File
@@ -8,8 +8,9 @@
// von dort macht diese Datei serverseitig unbenutzbar ("Attempted to call ... from the
// server but it is on the client") -- genau daran scheiterte der PDF-Bericht.
import { isCashTransitionAnswered, isTransitionAnswered } from "@/lib/transitions";
import { RETIREMENT_CATEGORIES } from "@/lib/retirement-decision";
import type { ElementCategory } from "@/lib/elements";
import type { ElementInput, PlanInput } from "@/lib/types";
import type { ElementInput, PersonRole, PlanInput } from "@/lib/types";
import type { PhaseComputed, PlanComputed } from "@/lib/calculations";
export const TRANSITION_CATEGORIES: ElementCategory[] = [
@@ -56,29 +57,83 @@ export function transitionInactive(
return false;
}
// Drei Zustände statt zwei (SPEZIFIKATION 3.5.3).
//
// Bis 0.33 kannte das Modell nur "offen" oder "beantwortet". Seit die Pensionierungs-
// Entscheide durchgängige Vorgaben haben, gibt es einen dritten und interessanteren Zustand:
// Das System HAT eine Antwort -- sie ist nur nicht die des Benutzers. Diesen Fall als
// beantwortet zu zählen hiesse, eine stillschweigend gesetzte Vorgabe wie «volle Rente statt
// Kapitalbezug» durchrutschen zu lassen, obwohl sie das Ergebnis massiv verändert.
//
// Deshalb zählt eine Vorgabe MIT -- aber in einem eigenen Topf, damit sie sich nicht wie ein
// vergessenes Eingabefeld liest.
export interface DecisionCounts {
// Kein Entscheid vorhanden: Verkauf/Halten, Cash-Übergang, Tilgung.
open: number;
// Pensionierungs-Entscheid liegt auf Vorgabe und wurde nie bestätigt.
unconfirmed: number;
}
export const NO_DECISIONS: DecisionCounts = { open: 0, unconfirmed: 0 };
export function addCounts(a: DecisionCounts, b: DecisionCounts): DecisionCounts {
return { open: a.open + b.open, unconfirmed: a.unconfirmed + b.unconfirmed };
}
// Ist der Pensionierungs-Entscheid dieses Elements vom Benutzer bestätigt?
export function retirementConfirmed(el: ElementInput): boolean {
return el.retirementDecision?.confirmed === true;
}
// Offene Entscheide an EINER Phasengrenze. Der Cash-Entscheid zählt mit.
export function openTransitionCount(
plan: PlanInput,
computed: PlanComputed,
fromPhase: PhaseComputed,
toPhase: PhaseComputed
): number {
): DecisionCounts {
const cash = plan.phases.find((p) => p.id === fromPhase.id)?.cashTransition ?? {};
let n = isCashTransitionAnswered(cash) ? 0 : 1;
const counts: DecisionCounts = { open: isCashTransitionAnswered(cash) ? 0 : 1, unconfirmed: 0 };
for (const el of plan.elements) {
if (!TRANSITION_CATEGORIES.includes(el.category)) continue;
if (transitionInactive(computed, el, fromPhase, toPhase)) continue;
const retire = isRetirementTransition(el, fromPhase, toPhase);
// An einem Pensions-Übergang zählt bei AHV/PK/3a nicht mehr die Übergangszelle, sondern
// der Pensionierungs-Entscheid -- der liegt seit 0.34 am Element.
if (retire && RETIREMENT_CATEGORIES.includes(el.category)) {
if (!retirementConfirmed(el)) counts.unconfirmed++;
continue;
}
const td = el.transitionValues[fromPhase.id] ?? {};
if (!isTransitionAnswered(el.category, isRetirementTransition(el, fromPhase, toPhase), td)) n++;
if (!isTransitionAnswered(el.category, retire, td)) counts.open++;
}
return counts;
}
// Summe über alle Phasengrenzen -- die aktionierbarste Kennzahl des ganzen Werkzeugs.
export function totalOpenDecisions(plan: PlanInput, computed: PlanComputed): DecisionCounts {
let n = NO_DECISIONS;
for (let i = 0; i < computed.phases.length - 1; i++) {
n = addCounts(n, openTransitionCount(plan, computed, computed.phases[i], computed.phases[i + 1]));
}
// Pensionierungs-Entscheide von Personen, deren Pensionierung GAR NICHT im Plan liegt (weil
// sie bei Planbeginn bereits pensioniert sind), tauchen an keiner Grenze auf -- sie fehlten
// damit in der Zählung, obwohl ihre Vorgaben genauso wirken.
const retiredAtStart = new Set(
(computed.phases[0]?.persons ?? []).filter((p) => !p.working).map((p) => p.role)
);
for (const el of plan.elements) {
if (!RETIREMENT_CATEGORIES.includes(el.category)) continue;
if (!el.ownerRole || !retiredAtStart.has(el.ownerRole as PersonRole)) continue;
if (!retirementConfirmed(el)) n = addCounts(n, { open: 0, unconfirmed: 1 });
}
return n;
}
// Summe über alle Phasengrenzen -- die aktionierbarste Kennzahl des ganzen Werkzeugs.
export function totalOpenDecisions(plan: PlanInput, computed: PlanComputed): number {
let n = 0;
for (let i = 0; i < computed.phases.length - 1; i++) {
n += openTransitionCount(plan, computed, computed.phases[i], computed.phases[i + 1]);
}
return n;
// Kurzfassung für Badges: «2 offene Entscheide · 3 Vorgaben ungeprüft».
export function decisionsText(c: DecisionCounts): string {
const parts: string[] = [];
if (c.open > 0) parts.push(`${c.open} ${c.open === 1 ? "offener Entscheid" : "offene Entscheide"}`);
if (c.unconfirmed > 0) parts.push(`${c.unconfirmed} ${c.unconfirmed === 1 ? "Vorgabe" : "Vorgaben"} ungeprüft`);
return parts.join(" · ");
}
+9 -2
View File
@@ -104,7 +104,14 @@ export function computeScenarioDiff(scenario: PlanInput, base: PlanInput | null)
}
usedBaseEls.add(src.id);
if (el.name !== src.name || el.ownerRole !== src.ownerRole) {
// Der Pensionierungs-Entscheid haengt am Element und an keiner Phase -- eine Aenderung
// markiert deshalb die ZEILE. Ohne das bliebe der haeufigste Szenario-Unterschied
// ueberhaupt (Rente statt Kapital, anderes Bezugsalter) im Diff unsichtbar.
if (
el.name !== src.name ||
el.ownerRole !== src.ownerRole ||
!sameData(el.retirementDecision ?? {}, src.retirementDecision ?? {})
) {
d.elementRow.set(el.id, "changed");
}
@@ -132,7 +139,7 @@ export function computeScenarioDiff(scenario: PlanInput, base: PlanInput | null)
// --- Profil und Cash-Anfangswert ---
d.cashInitialChanged = Math.round(scenario.initialCash) !== Math.round(base.initialCash);
const personKey = (p: PlanInput["persons"][number]) =>
`${p.role}|${p.name ?? ""}|${p.age}|${p.retirementAge}`;
`${p.role}|${p.name ?? ""}|${p.age}|${p.retirementAge}|${p.planningHorizonAge ?? ""}`;
d.profileChanged =
scenario.householdType !== base.householdType ||
scenario.inflationRateDefault !== base.inflationRateDefault ||
+9
View File
@@ -1,7 +1,9 @@
import { Prisma } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import { cashTransitionSchema, phaseDataSchema, transitionDataSchema } from "@/lib/elements";
import { retirementDecisionSchema } from "@/lib/retirement-decision";
import type { CashTransitionData, PhaseData, TransitionData } from "@/lib/elements";
import type { RetirementDecision } from "@/lib/retirement-decision";
import type { PlanInput } from "@/lib/types";
export const planInclude = {
@@ -29,6 +31,11 @@ function parseTransitionData(raw: unknown): TransitionData {
return parsed.success ? parsed.data : {};
}
function parseRetirementDecision(raw: unknown): RetirementDecision {
const parsed = retirementDecisionSchema.safeParse(raw);
return parsed.success ? parsed.data : {};
}
function parseCashTransition(raw: unknown): CashTransitionData {
const parsed = cashTransitionSchema.safeParse(raw);
return parsed.success ? parsed.data : {};
@@ -53,6 +60,7 @@ export function toPlanInput(plan: PlanWithRelations): PlanInput {
name: hh?.name ?? null,
age: hh?.age ?? 0,
retirementAge: p.retirementAge,
planningHorizonAge: p.planningHorizonAge,
};
}),
phases: plan.phases.map((phase) => ({
@@ -76,6 +84,7 @@ export function toPlanInput(plan: PlanWithRelations): PlanInput {
orderIndex: e.orderIndex,
phaseValues,
transitionValues,
retirementDecision: parseRetirementDecision(e.retirementDecision),
sourceElementId: e.sourceElementId,
};
}),
+7 -5
View File
@@ -15,7 +15,7 @@
// in den Anhang, ein Haftungsausschluss ist Pflicht.
import { computePlan } from "@/lib/calculations";
import { totalOpenDecisions } from "@/lib/decisions";
import { decisionsText, totalOpenDecisions } from "@/lib/decisions";
import { num } from "@/lib/elements";
import { formatChf } from "@/lib/format";
import { resolveActuals, type ActualsSetInput, type ElementOrigin } from "@/lib/actuals";
@@ -239,11 +239,13 @@ function buildScenario(
if (pkPension > 0) {
figures.push({ label: "PK-Rente pro Jahr", value: formatChf(pkPension), basis: "Umwandlungssatz laut Systemparametern" });
}
const openTotal = open.open + open.unconfirmed;
figures.push({
label: "Offene Entscheide",
value: open === 0 ? "keine" : String(open),
tone: open === 0 ? "success" : undefined,
basis: "noch nicht getroffene Übergangs-Entscheide zwischen den Lebensphasen",
value: openTotal === 0 ? "keine" : decisionsText(open),
tone: openTotal === 0 ? "success" : undefined,
basis:
"noch nicht getroffene Übergangs-Entscheide sowie Pensionierungs-Vorgaben, die nie bestätigt wurden",
});
const phases: ReportTable = {
@@ -273,7 +275,7 @@ function buildScenario(
],
},
assumptions: assumptionsOf(plan),
openDecisions: open,
openDecisions: open.open + open.unconfirmed,
};
}
+12 -3
View File
@@ -177,12 +177,18 @@ export async function restoreVersion(
},
});
// Pensionsalter: an der Rolle festgemacht (je Szenario eindeutig).
// Pensionsalter und Planungshorizont: an der Rolle festgemacht (je Szenario eindeutig).
for (const p of snap.persons) {
await tx.person.upsert({
where: { scenarioId_role: { scenarioId, role: p.role } },
create: { id: p.id, scenarioId, role: p.role, retirementAge: p.retirementAge },
update: { retirementAge: p.retirementAge },
create: {
id: p.id,
scenarioId,
role: p.role,
retirementAge: p.retirementAge,
planningHorizonAge: p.planningHorizonAge ?? null,
},
update: { retirementAge: p.retirementAge, planningHorizonAge: p.planningHorizonAge ?? null },
});
}
if (plan.deletePersonRoles.length > 0) {
@@ -227,6 +233,9 @@ export async function restoreVersion(
name: el.name,
ownerRole: el.ownerRole ?? null,
orderIndex: el.orderIndex,
// Der Pensionierungs-Entscheid gehoert zum Inhalt des Szenarios -- ohne ihn wuerde
// eine Wiederherstellung die Bezugsentscheide still auf die Vorgaben zuruecksetzen.
retirementDecision: (el.retirementDecision ?? undefined) as Prisma.InputJsonValue | undefined,
sourceElementId: el.sourceElementId ?? null,
};
};