From 1865db5de7516fe5bd6ce16439a74b8b1bf9c489 Mon Sep 17 00:00:00 2001
From: kelle
Date: Sun, 26 Jul 2026 13:37:08 +0200
Subject: [PATCH] Pensionierungs-Bildschirm, Ampel mit drei Zustaenden,
Planungshorizont
Co-Authored-By: Claude Opus 5
---
.../elements/[elementId]/retirement/route.ts | 41 ++
.../api/scenarios/[scenarioId]/copy/route.ts | 9 +-
.../scenarios/[scenarioId]/horizon/route.ts | 55 ++
src/components/ElementDetail.tsx | 108 +---
src/components/PlanView.tsx | 168 ++++++-
src/components/RetirementFields.tsx | 468 ++++++++++++++++++
src/components/RetirementPanel.tsx | 338 +++++++++++++
src/lib/decisions.ts | 77 ++-
src/lib/diff.ts | 11 +-
src/lib/queries.ts | 9 +
src/lib/report.ts | 12 +-
src/lib/versioning-db.ts | 15 +-
12 files changed, 1181 insertions(+), 130 deletions(-)
create mode 100644 src/app/api/elements/[elementId]/retirement/route.ts
create mode 100644 src/app/api/scenarios/[scenarioId]/horizon/route.ts
create mode 100644 src/components/RetirementFields.tsx
create mode 100644 src/components/RetirementPanel.tsx
diff --git a/src/app/api/elements/[elementId]/retirement/route.ts b/src/app/api/elements/[elementId]/retirement/route.ts
new file mode 100644
index 0000000..0e18a8b
--- /dev/null
+++ b/src/app/api/elements/[elementId]/retirement/route.ts
@@ -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/`), 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 });
+}
diff --git a/src/app/api/scenarios/[scenarioId]/copy/route.ts b/src/app/api/scenarios/[scenarioId]/copy/route.ts
index 61c66be..68fb327 100644
--- a/src/app/api/scenarios/[scenarioId]/copy/route.ts
+++ b/src/app/api/scenarios/[scenarioId]/copy/route.ts
@@ -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,
},
});
diff --git a/src/app/api/scenarios/[scenarioId]/horizon/route.ts b/src/app/api/scenarios/[scenarioId]/horizon/route.ts
new file mode 100644
index 0000000..2a01087
--- /dev/null
+++ b/src/app/api/scenarios/[scenarioId]/horizon/route.ts
@@ -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 });
+}
diff --git a/src/components/ElementDetail.tsx b/src/components/ElementDetail.tsx
index 83f98f5..d3aa65c 100644
--- a/src/components/ElementDetail.tsx
+++ b/src/components/ElementDetail.tsx
@@ -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) => 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({
);
case "AHV":
- if (context.isRetirementTransition && context.ahvCareer) {
- return ;
+ // 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 {retirement}
;
}
return (
@@ -906,103 +911,14 @@ export function ElementTransitionFields({
);
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 (
- <>
- setT({ payoutMode: v })}
- options={[
- { value: "PENSION", label: "Rente" },
- { value: "CAPITAL", label: "Kapitalbezug" },
- { value: "COMBI", label: "Kombination" },
- ]}
- />
- {(mode === "PENSION" || mode === "COMBI") && (
- setT({ conversionRate: v })}
- />
- )}
- {mode === "COMBI" && (
- 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") && (
- <>
-
- setT({ capitalTaxRate: v })}
- />
-
-
- >
- )}
- >
- );
+ if (context.isRetirementTransition && retirement) {
+ return {retirement}
;
}
return ;
}
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 (
- <>
-
- Die Säule 3a wird bei der Pensionierung vollständig bezogen . Zu
- entscheiden sind der Steuersatz und die Verwendung des Geldes.
-
-
- setT({ capitalTaxRate: v })}
- />
-
-
- >
- );
+ if (context.isRetirementTransition && retirement) {
+ return {retirement}
;
}
return ;
}
diff --git a/src/components/PlanView.tsx b/src/components/PlanView.tsx
index 0dea8b2..9139d3e 100644
--- a/src/components/PlanView.tsx
+++ b/src/components/PlanView.tsx
@@ -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("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({
+ {/* 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 && (
+
+
+ {(["matrix", "retirement"] as const).map((v) => (
+
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" ? : }
+ {v === "matrix" ? "Matrix" : "Pensionierung"}
+
+ ))}
+
+ {view === "matrix" && retirementSummaryText && (
+
setView("retirement")}
+ className="truncate text-xs text-muted underline decoration-dotted underline-offset-2 hover:text-fg"
+ title="Zur Pensionierung"
+ >
+ {retirementSummaryText}
+
+ )}
+
+ )}
+
+ {view === "retirement" && (
+
+ )}
+
{/* Anzeige-Umschalter (nominal/real, Plan/Ist) -- direkt über der Matrix, weil er nur
deren Zahlen steuert. */}
- {hasPhases && (
+ {hasPhases && view === "matrix" && (
Anzeige
@@ -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" && (
{/* 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({
) : (
setReviewFromPhaseId(col.fromPhase.id)}
/>
)
@@ -880,6 +943,8 @@ export function PlanView({
const els = transitionElements(fromPhase, toPhase);
return (
p.id === fromPhase.id) + 1];
return (
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 (
Übergang
@@ -1467,8 +1542,17 @@ function TransitionHeader({ openCount, onClick }: { openCount: number; onClick:
geprüft
) : (
-
- {openCount} offen
+
+ {counts.open > 0 && (
+
+ {counts.open} offen
+
+ )}
+ {counts.unconfirmed > 0 && (
+
+ {counts.unconfirmed} Vorgabe{counts.unconfirmed === 1 ? "" : "n"}
+
+ )}
)}
@@ -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(() => 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>({});
+ const effectiveRd = (el: ElementInput) =>
+ withRetirementDefaults(el.category, retirementAgeOf(plan, el), {
+ ...(el.retirementDecision ?? {}),
+ ...(rds[el.id] ?? {}),
+ });
const [saving, setSaving] = useState(false);
const [error, setError] = useState(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={
+ setRds((prev) => ({ ...prev, [id]: { ...prev[id], ...patch } }))}
+ summary={computed.retirement.perPerson.find((x) => x.role === el.ownerRole)}
+ />
+ }
/>
@@ -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
(() =>
withTransitionDefaults(element.category, context.isRetirementTransition, element.transitionValues[fromPhase.id] ?? {})
);
+ const [rd, setRd] = useState({});
+ const effectiveRd = (el: ElementInput) =>
+ withRetirementDefaults(el.category, retirementAgeOf(plan, el), {
+ ...(el.retirementDecision ?? {}),
+ ...rd,
+ });
const [saving, setSaving] = useState(false);
const [error, setError] = useState(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={
+ ) =>
+ setRd((prev) => ({ ...prev, ...patch }))
+ }
+ summary={computed.retirement.perPerson.find((x) => x.role === element.ownerRole)}
+ />
+ }
/>
{error && {error}
}
diff --git a/src/components/RetirementFields.tsx b/src/components/RetirementFields.tsx
new file mode 100644
index 0000000..305346c
--- /dev/null
+++ b/src/components/RetirementFields.tsx
@@ -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) => 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 ;
+ if (el.category === "PENSION_FUND")
+ return ;
+ if (el.category === "PILLAR_3A")
+ return (
+
+ );
+ 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 (
+
+
setOpen((o) => !o)}
+ className="flex w-full items-center gap-2 px-3 py-2 text-left"
+ >
+ {open ? : }
+ {title}
+ {subtitle}
+ {confirmed ? (
+
+ bestätigt
+
+ ) : (
+
+ Vorgabe ungeprüft
+
+ )}
+
+ {open && (
+
+
{children}
+
+ onConfirm(e.target.checked)}
+ className="mt-0.5"
+ />
+
+ 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.
+
+
+
+ )}
+
+ );
+}
+
+export function AhvBlock({
+ el,
+ rd,
+ patch,
+ summary,
+ defaultOpen,
+}: {
+ el: ElementInput;
+ rd: RetirementDecision;
+ patch: (elementId: string, p: Partial) => 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 (
+ patch(el.id, { confirmed: v })}
+ defaultOpen={defaultOpen}
+ >
+
+ 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" && (
+ patch(el.id, { ahvMonths: Math.max(0, Math.min(maxMonths, Math.round(v))) })}
+ />
+ )}
+ {draw !== "REFERENCE" && summary && (
+
+ Rente ab Alter {summary.ahvFromAge} :{" "}
+ {formatChf(summary.ahvAnnual)} 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.
+
+ )}
+
+
+
Grundlage der Schätzung
+
+ patch(el.id, { avgIncomeBefore: v })}
+ />
+ patch(el.id, { gapYearsBefore: Math.max(0, Math.round(v)) })}
+ />
+
+
+
+ );
+}
+
+export function PkBlock({
+ plan,
+ el,
+ rd,
+ patch,
+ summary,
+ defaultOpen,
+}: {
+ plan: PlanInput;
+ el: ElementInput;
+ rd: RetirementDecision;
+ patch: (elementId: string, p: Partial) => void;
+ summary?: RetirementPersonSummary;
+ defaultOpen?: boolean;
+}) {
+ const share = rd.capitalSharePct ?? 0;
+ const targets = plan.elements.filter((e) => e.category === "OTHER_ASSET");
+
+ return (
+ patch(el.id, { confirmed: v })}
+ defaultOpen={defaultOpen}
+ >
+
+
+ Kapitalanteil
+
+
+
+ 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)]"
+ />
+
+ {share} % Kapital
+
+
+
+ volle Rente
+ volles Kapital
+
+
+
+ patch(el.id, { conversionRate: v })}
+ />
+ {share > 0 && (
+ patch(el.id, { capitalTaxRate: v })}
+ />
+ )}
+
+ {share > 0 && (
+ <>
+
+
+ patch(el.id, { recentBuyIn: e.target.checked })}
+ className="mt-0.5"
+ />
+
+ In den letzten {PK_BUYIN_BLOCKING_YEARS} Jahren in die Pensionskasse eingekauft
+
+
+ {rd.recentBuyIn && (
+
+
+ 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.
+
+ )}
+
+
+ >
+ )}
+
+ );
+}
+
+export function Pillar3aBlock({
+ el,
+ rd,
+ patch,
+ retirementAge,
+ siblings,
+ defaultOpen,
+}: {
+ el: ElementInput;
+ rd: RetirementDecision;
+ patch: (elementId: string, p: Partial) => void;
+ retirementAge: number;
+ siblings: RetirementDecision[];
+ defaultOpen?: boolean;
+}) {
+ const age = rd.withdrawalAge ?? retirementAge;
+ const clash = siblings.some((s) => (s.withdrawalAge ?? retirementAge) === age);
+
+ return (
+ patch(el.id, { confirmed: v })}
+ defaultOpen={defaultOpen}
+ >
+ patch(el.id, { withdrawalAge: Math.round(v) })}
+ />
+ patch(el.id, { capitalTaxRate: v })}
+ />
+
+ Ein 3a-Konto lässt sich bei der Pensionierung nur ganz 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.
+ >
+ )}
+
+ {clash && (
+
+
+ Ein weiteres 3a-Konto wird im selben Jahr bezogen. Die Beträge werden steuerlich zusammengezählt – ein
+ anderes Bezugsjahr senkt die Progression.
+
+ )}
+
+ );
+}
+
+// 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) => 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 (
+ <>
+
+
+ Wohin fliesst das bezogene Kapital?
+
+
+
patch(elementId, { capitalUseAmortizationPct: Math.max(0, Math.min(100, v)) })}
+ />
+ patch(elementId, { capitalUseInvestPct: Math.max(0, Math.min(100 - amort, v)) })}
+ />
+ {invest > 0 && targets.length > 0 && (
+
+ patch(elementId, { capitalUseTargetElementId: v })}
+ options={targets.map((t) => ({
+ value: t.id,
+ label: `${t.name} · ${ownerLabel(plan.persons, t.ownerRole)}`,
+ }))}
+ />
+
+ )}
+ {invest > 0 && targets.length === 0 && (
+
+
+ Es gibt kein Element «Sonstiges Vermögen», in das die Anlage-Quote fliessen könnte. Der Betrag bliebe auf
+ dem Cash-Konto liegen.
+
+ )}
+
+ Nicht zugeteilt: {cash} % – bleibt auf dem Cash-Konto.
+
+
+
+ >
+ );
+}
diff --git a/src/components/RetirementPanel.tsx b/src/components/RetirementPanel.tsx
new file mode 100644
index 0000000..b720c04
--- /dev/null
+++ b/src/components/RetirementPanel.tsx
@@ -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;
+
+export function RetirementPanel({
+ plan,
+ computed,
+ onSaved,
+}: {
+ plan: PlanInput;
+ computed: PlanComputed;
+ onSaved: () => void;
+}) {
+ const toast = useToast();
+ const [draft, setDraft] = useState({});
+ const [saving, setSaving] = useState(false);
+ const [error, setError] = useState(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) =>
+ 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 (
+
+
+
Pensionierung
+
+ 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.
+
+
+
+ {plan.persons.map((person) => {
+ const summary = computed.retirement.perPerson.find((s) => s.role === person.role);
+ return (
+
+ );
+ })}
+
+ {error &&
{error}
}
+ {dirty && (
+
+
+ {saving ? "…" : "Änderungen speichern"}
+
+ setDraft({})}>
+ Verwerfen
+
+
+ )}
+
+ );
+}
+
+// --- 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) => 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 (
+
+
+
+
+
+
+
+
+
+
+
+ {[ahvEl, ...pkEls, ...a3Els].filter(Boolean).map((el) => (
+
+ ))}
+ {!ahvEl && pkEls.length === 0 && a3Els.length === 0 && (
+
+ 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.
+
+ )}
+
+
+ );
+}
+
+// --- 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 (
+
+
+
+
+
+
+
+ Rentenlücke
+
+
+
+ {r.gapAnnual === null ? "–" : `${formatChf(r.gapAnnual)} / Jahr`}
+
+ {r.firstRetirementYear !== null && startYear && (
+
+ gerechnet für {startYear + r.firstRetirementYear - 1}, das erste voll pensionierte Jahr
+
+ )}
+
+
+
Vermögen reicht
+
+ {reachesEnd
+ ? horizon
+ ? `bis zum Horizont (Alter ${horizon})`
+ : "über die ganze Planung"
+ : `bis Alter ${computed.ruinAge}`}
+
+
+ {summary && summary.capitalAtRetirement > 0 && (
+
+ Einmalig verfügbar: {formatChf(summary.capitalAtRetirement)} netto
+ aus PK und Säule 3a
+
+ )}
+
+
+
+ );
+}
+
+function Line({ label, value, note, strong }: { label: string; value: number; note?: string; strong?: boolean }) {
+ return (
+
+
+ {label}
+ {note && ({note}) }
+
+ {formatChf(value)}
+
+ );
+}
+
+// --- 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(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 (
+
+
+ Planungshorizont
+
+
+
+
+
+
+
value !== null && submit(value)}
+ >
+ Setzen
+
+
+ {person.planningHorizonAge === null && (
+
+ Noch nicht gesetzt – der Plan endet aktuell rechnerisch mit Alter {implied}.
+
+ )}
+ {error &&
{error}
}
+
+ );
+}
+
diff --git a/src/lib/decisions.ts b/src/lib/decisions.ts
index aaebf46..3031fe3 100644
--- a/src/lib/decisions.ts
+++ b/src/lib/decisions.ts
@@ -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(" · ");
}
diff --git a/src/lib/diff.ts b/src/lib/diff.ts
index 64f5163..5a9ba09 100644
--- a/src/lib/diff.ts
+++ b/src/lib/diff.ts
@@ -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 ||
diff --git a/src/lib/queries.ts b/src/lib/queries.ts
index 830b6c1..b226931 100644
--- a/src/lib/queries.ts
+++ b/src/lib/queries.ts
@@ -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,
};
}),
diff --git a/src/lib/report.ts b/src/lib/report.ts
index f7b72ba..7e462a1 100644
--- a/src/lib/report.ts
+++ b/src/lib/report.ts
@@ -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,
};
}
diff --git a/src/lib/versioning-db.ts b/src/lib/versioning-db.ts
index 5e7bdb1..4c069b8 100644
--- a/src/lib/versioning-db.ts
+++ b/src/lib/versioning-db.ts
@@ -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,
};
};