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) => ( + + ))} +
+ {view === "matrix" && 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 ( +
+ + {open && ( +
+
{children}
+ +
+ )} +
+ ); +} + +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} + > +
+ +
+ 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 && ( + <> +
+ + {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 && ( +
+ + +
+ )} +
+ ); +} + +// --- 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 ( +
+
+
+

+ {label} · Pensionierung mit {person.retirementAge} +

+ {person.retirementAge < PK_MIN_RETIREMENT_AGE && ( + + + Vor {PK_MIN_RETIREMENT_AGE} lässt kaum eine Pensionskasse eine Pensionierung zu. + + )} +
+
+ +
+ + +
+ + + +
+ {[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 + +

+
+
+ +
+ +
+ {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, }; };