Pensionierungs-Bildschirm, Ampel mit drei Zustaenden, Planungshorizont

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-26 13:37:08 +02:00
parent 9907fda6f4
commit 1865db5de7
12 changed files with 1181 additions and 130 deletions
@@ -0,0 +1,41 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { getOwnedElement } from "@/lib/queries";
import { getCurrentUserId } from "@/lib/session";
import { touchScenario } from "@/lib/versioning-db";
import { RETIREMENT_CATEGORIES, retirementDecisionSchema } from "@/lib/retirement-decision";
// Speichert den Pensionierungs-Entscheid eines Elements (AHV, PK, Säule 3a).
//
// Bewusst OHNE Phasenbezug in der Route: Der Entscheid gilt für die Pensionierung des
// Besitzers, wo immer die auf der Zeitachse gerade liegt. Genau das unterscheidet ihn vom
// Übergangs-Entscheid (`/transition/<fromPhaseId>`), der an einer konkreten Grenze hängt.
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ elementId: string }> }
) {
const userId = await getCurrentUserId();
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
const { elementId } = await params;
const element = await getOwnedElement(elementId, userId);
if (!element) return NextResponse.json({ error: "Element nicht gefunden." }, { status: 404 });
if (!RETIREMENT_CATEGORIES.includes(element.category)) {
return NextResponse.json(
{ error: "Nur AHV, Pensionskasse und Säule 3a kennen einen Pensionierungs-Entscheid." },
{ status: 400 }
);
}
const body = await request.json();
const parsed = retirementDecisionSchema.safeParse(body);
if (!parsed.success) return NextResponse.json({ error: "Ungültige Eingabe." }, { status: 400 });
await prisma.financialElement.update({
where: { id: elementId },
data: { retirementDecision: parsed.data },
});
await touchScenario(element.scenarioId, userId);
return NextResponse.json({ ok: true });
}
@@ -34,7 +34,11 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
inflationRateDefault: source.inflationRateDefault, inflationRateDefault: source.inflationRateDefault,
initialCash: source.initialCash, initialCash: source.initialCash,
persons: { 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, name: el.name,
ownerRole: el.ownerRole, ownerRole: el.ownerRole,
orderIndex: el.orderIndex, 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, sourceElementId: el.id,
}, },
}); });
@@ -0,0 +1,55 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { prisma } from "@/lib/db";
import { getOwnedScenario, toPlanInput } from "@/lib/queries";
import { getCurrentUserId } from "@/lib/session";
import { touchScenario } from "@/lib/versioning-db";
import { planHorizonChange } from "@/lib/retirement";
import { MAX_PLANNING_HORIZON_AGE, MIN_PLANNING_HORIZON_AGE } from "@/lib/constants";
// Planungshorizont setzen: bis zu welchem Alter gerechnet wird.
//
// Wie beim Pensionsalter gilt: Zahl stellen, Struktur folgt. Die LETZTE Lebensphase wird so
// verlängert oder gekürzt, dass der Plan genau bis zum Horizont läuft. Vorher ergab sich das
// Planende stillschweigend aus der Summe der Phasendauern -- zwei Szenarien konnten dadurch
// unbemerkt verschieden weit rechnen und waren nicht vergleichbar.
const bodySchema = z.object({
role: z.enum(["PERSON_A", "PERSON_B"]),
horizonAge: z.number().int().min(MIN_PLANNING_HORIZON_AGE).max(MAX_PLANNING_HORIZON_AGE),
});
export async function POST(request: NextRequest, { params }: { params: Promise<{ scenarioId: string }> }) {
const userId = await getCurrentUserId();
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
const { scenarioId } = await params;
const scenario = await getOwnedScenario(scenarioId, userId);
if (!scenario) return NextResponse.json({ error: "Szenario nicht gefunden." }, { status: 404 });
const parsed = bodySchema.safeParse(await request.json().catch(() => ({})));
if (!parsed.success) return NextResponse.json({ error: "Ungültige Eingabe." }, { status: 400 });
const { role, horizonAge } = parsed.data;
const planInput = toPlanInput(scenario);
const person = planInput.persons.find((p) => p.role === role);
if (!person) return NextResponse.json({ error: "Diese Person gibt es in diesem Szenario nicht." }, { status: 400 });
if (horizonAge <= person.retirementAge) {
return NextResponse.json(
{ error: "Der Planungshorizont muss nach der Pensionierung liegen." },
{ status: 400 }
);
}
const change = planHorizonChange(planInput, horizonAge, role);
if (!change) return NextResponse.json({ error: "Es gibt keine Lebensphase, die sich anpassen liesse." }, { status: 400 });
if (change.blocked) return NextResponse.json({ error: change.blocked }, { status: 400 });
await prisma.$transaction([
prisma.person.update({ where: { id: person.id }, data: { planningHorizonAge: horizonAge } }),
prisma.phase.update({ where: { id: change.lastPhaseId }, data: { durationYears: change.newDuration } }),
]);
await touchScenario(scenario.id, userId);
return NextResponse.json({ ok: true, lastPhaseDuration: change.newDuration });
}
+12 -96
View File
@@ -29,7 +29,6 @@ import { CATEGORY_LABELS, num, type InheritableKey } from "@/lib/elements";
import { import {
AHV_REFERENCE_AGE, AHV_REFERENCE_AGE,
DEFAULT_CAPITAL_TAX_RATE, DEFAULT_CAPITAL_TAX_RATE,
DEFAULT_PK_CONVERSION_RATE,
DEFAULT_PROPERTY_GAINS_TAX_RATE, DEFAULT_PROPERTY_GAINS_TAX_RATE,
PILLAR_3A_MAX_ANNUAL, PILLAR_3A_MAX_ANNUAL,
PILLAR_3A_MAX_SELF_EMPLOYED, PILLAR_3A_MAX_SELF_EMPLOYED,
@@ -881,11 +880,15 @@ export function ElementTransitionFields({
context, context,
td, td,
setT, setT,
retirement,
}: { }: {
element: { category: ElementCategory }; element: { category: ElementCategory };
context: CellContext; context: CellContext;
td: TransitionData; td: TransitionData;
setT: (patch: Partial<TransitionData>) => void; setT: (patch: Partial<TransitionData>) => void;
// Der fertig gerenderte Pensionierungs-Baustein (AHV/PK/3a). Wird vom Aufrufer geliefert,
// damit dieses Modul nichts über den Pensionierungs-Bildschirm wissen muss.
retirement?: React.ReactNode;
}) { }) {
switch (element.category) { switch (element.category) {
case "INCOME": case "INCOME":
@@ -897,8 +900,10 @@ export function ElementTransitionFields({
</p> </p>
); );
case "AHV": case "AHV":
if (context.isRetirementTransition && context.ahvCareer) { // Der Bezugs-Entscheid liegt seit 0.34 am Element und wird hier nur ANDERS ANGESCHAUT --
return <AhvReviewFields career={context.ahvCareer} td={td} setT={setT} />; // es ist dieselbe Komponente wie im Pensionierungs-Bildschirm, kein Duplikat.
if (context.isRetirementTransition && retirement) {
return <div className="col-span-2">{retirement}</div>;
} }
return ( return (
<p className="col-span-2 text-sm text-muted"> <p className="col-span-2 text-sm text-muted">
@@ -906,103 +911,14 @@ export function ElementTransitionFields({
</p> </p>
); );
case "PENSION_FUND": { case "PENSION_FUND": {
if (context.isRetirementTransition) { if (context.isRetirementTransition && retirement) {
const mode = td.payoutMode ?? "PENSION"; return <div className="col-span-2">{retirement}</div>;
const taxRate = num(td.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE);
// Brutto = ganzes Guthaben (Kapitalbezug) bzw. der gewählte Teil (Kombination).
const brutto =
mode === "COMBI"
? Math.min(context.carriedEndValue, Math.round(num(td.capitalAmount)))
: context.carriedEndValue;
const netto = Math.round(brutto * (1 - taxRate / 100));
return (
<>
<SelectField
label="Bezugsart bei Pensionierung"
value={mode}
onChange={(v: "CAPITAL" | "PENSION" | "COMBI") => setT({ payoutMode: v })}
options={[
{ value: "PENSION", label: "Rente" },
{ value: "CAPITAL", label: "Kapitalbezug" },
{ value: "COMBI", label: "Kombination" },
]}
/>
{(mode === "PENSION" || mode === "COMBI") && (
<NumberField
label="Umwandlungssatz (%)"
help="Jährliche Rente = verrentetes Kapital x Umwandlungssatz."
step={0.1}
value={num(td.conversionRate, DEFAULT_PK_CONVERSION_RATE)}
onChange={(v) => setT({ conversionRate: v })}
/>
)}
{mode === "COMBI" && (
<MoneyField
label="Davon Kapitalbezug (CHF)"
help={`Der Rest wird verrentet. Maximal ${formatChf(context.carriedEndValue)}.`}
value={num(td.capitalAmount)}
max={context.carriedEndValue}
onChange={(v) => setT({ capitalAmount: v })}
/>
)}
{/* Erst der Betrag, dann die Steuer, dann die Verwendung -- in der Reihenfolge,
in der man die Entscheidung tatsächlich trifft. */}
{(mode === "CAPITAL" || mode === "COMBI") && (
<>
<DerivedField
label="Kapitalbezug brutto"
value={brutto}
help="Das Guthaben am Ende der Vorphase (bei einer Kombination der oben gewählte Anteil)."
/>
<NumberField
label="Kapitalbezugssteuer (%)"
help="Pauschalsatz die tatsächliche Steuer ist kantonal und progressiv."
step={0.5}
value={taxRate}
onChange={(v) => setT({ capitalTaxRate: v })}
/>
<DerivedField
label="Auszahlung netto"
value={netto}
help="Brutto abzüglich Kapitalbezugssteuer. Dieser Betrag wird unten verteilt."
/>
<CapitalUseFields td={td} setT={setT} netAmount={netto} targets={context.investTargets} />
</>
)}
</>
);
} }
return <WithdrawalDecision td={td} setT={setT} max={context.carriedEndValue} label="PK-Bezug (CHF)" />; return <WithdrawalDecision td={td} setT={setT} max={context.carriedEndValue} label="PK-Bezug (CHF)" />;
} }
case "PILLAR_3A": { case "PILLAR_3A": {
if (context.isRetirementTransition) { if (context.isRetirementTransition && retirement) {
// Die Säule 3a wird bei der Pensionierung IMMER vollständig bezogen -- entschieden return <div className="col-span-2">{retirement}</div>;
// wird hier der Steuersatz und die Verwendung des Geldes.
const taxRate = num(td.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE);
const brutto = context.carriedEndValue;
const netto = Math.round(brutto * (1 - taxRate / 100));
return (
<>
<p className="col-span-2 rounded-lg bg-surface-2 px-3 py-2 text-xs text-muted">
Die Säule 3a wird bei der Pensionierung <strong className="text-fg">vollständig bezogen</strong>. Zu
entscheiden sind der Steuersatz und die Verwendung des Geldes.
</p>
<DerivedField label="Bezug brutto" value={brutto} help="Das Guthaben am Ende der Vorphase." />
<NumberField
label="Kapitalbezugssteuer (%)"
help="Pauschalsatz die tatsächliche Steuer ist kantonal und progressiv. Gestaffelte Bezüge (PK und 3a in verschiedenen Jahren) senken sie; das bildet das Tool noch nicht ab."
step={0.5}
value={taxRate}
onChange={(v) => setT({ capitalTaxRate: v })}
/>
<DerivedField
label="Auszahlung netto"
value={netto}
help="Brutto abzüglich Kapitalbezugssteuer. Dieser Betrag wird unten verteilt."
/>
<CapitalUseFields td={td} setT={setT} netAmount={netto} targets={context.investTargets} />
</>
);
} }
return <WithdrawalDecision td={td} setT={setT} max={context.carriedEndValue} label="3a-Bezug (CHF)" />; return <WithdrawalDecision td={td} setT={setT} max={context.carriedEndValue} label="3a-Bezug (CHF)" />;
} }
+156 -12
View File
@@ -15,6 +15,7 @@ import {
Pencil, Pencil,
Maximize2, Maximize2,
PiggyBank, PiggyBank,
Table2,
Plus, Plus,
Settings2, Settings2,
ShoppingCart, ShoppingCart,
@@ -30,9 +31,20 @@ import { capitalPot } from "@/lib/distribution";
import { import {
isRetirementTransition, isRetirementTransition,
openTransitionCount, openTransitionCount,
decisionsText,
type DecisionCounts,
transitionInactive as inactiveAtTransition, transitionInactive as inactiveAtTransition,
TRANSITION_CATEGORIES, TRANSITION_CATEGORIES,
retirementConfirmed,
} from "@/lib/decisions"; } 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 { Button, EmptyState, InspectorShell, Modal, useConfirm, useToast } from "@/components/ui";
import { ElementDetailDialog, PhaseDetailDialog } from "@/components/DetailView"; import { ElementDetailDialog, PhaseDetailDialog } from "@/components/DetailView";
import { import {
@@ -170,6 +182,8 @@ export function PlanView({
// weil beide mehrere Elemente auf einmal bearbeiten. // weil beide mehrere Elemente auf einmal bearbeiten.
const [distribute, setDistribute] = useState<{ kind: "capital" | "rates"; phaseId: string } | null>(null); const [distribute, setDistribute] = useState<{ kind: "capital" | "rates"; phaseId: string } | null>(null);
const [valueMode, setValueMode] = useState<ValueMode>("nominal"); const [valueMode, setValueMode] = useState<ValueMode>("nominal");
// Matrix oder Pensionierung. Kein URL-Zustand: Es ist eine Arbeitsansicht, kein Ort.
const [view, setView] = useState<"matrix" | "retirement">("matrix");
// Die Tour (Start-Knopf, Auto-Start bei Plan-Erstellung, Rendering) liegt seit dem // 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. // 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. // Nur-Lese-Detailansicht (Roadmap Nr. 43). Der Rechenweg wird erst beim Öffnen erzeugt.
@@ -352,6 +366,18 @@ export function PlanView({
onChanged(); 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 hasPhases = computed.phases.length > 0;
const firstPhase = computed.phases[0] ?? null; const firstPhase = computed.phases[0] ?? null;
const lastPhase = computed.phases[computed.phases.length - 1] ?? null; const lastPhase = computed.phases[computed.phases.length - 1] ?? null;
@@ -438,9 +464,46 @@ export function PlanView({
</div> </div>
</div> </div>
{/* Ansicht: Matrix oder Pensionierung. Die Pensionierung steht bewusst gleichrangig
NEBEN der Matrix und nicht in ihr: Sie ist keine Zelle, sondern eine eigene Frage --
und die einzige, die man zwischen Szenarien systematisch variiert. */}
{hasPhases && (
<div className="flex flex-wrap items-center gap-2">
<div className="inline-flex rounded-lg border border-border bg-surface p-0.5 text-sm">
{(["matrix", "retirement"] as const).map((v) => (
<button
key={v}
type="button"
onClick={() => setView(v)}
className={`flex items-center gap-1.5 rounded-md px-3 py-1 font-medium ${
view === v ? "bg-accent text-accent-fg" : "text-muted hover:bg-surface-2"
}`}
>
{v === "matrix" ? <Table2 className="h-4 w-4" /> : <PiggyBank className="h-4 w-4" />}
{v === "matrix" ? "Matrix" : "Pensionierung"}
</button>
))}
</div>
{view === "matrix" && retirementSummaryText && (
<button
type="button"
onClick={() => setView("retirement")}
className="truncate text-xs text-muted underline decoration-dotted underline-offset-2 hover:text-fg"
title="Zur Pensionierung"
>
{retirementSummaryText}
</button>
)}
</div>
)}
{view === "retirement" && (
<RetirementPanel plan={plan} computed={computed} onSaved={onChanged} />
)}
{/* Anzeige-Umschalter (nominal/real, Plan/Ist) -- direkt über der Matrix, weil er nur {/* Anzeige-Umschalter (nominal/real, Plan/Ist) -- direkt über der Matrix, weil er nur
deren Zahlen steuert. */} deren Zahlen steuert. */}
{hasPhases && ( {hasPhases && view === "matrix" && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-xs text-muted">Anzeige</span> <span className="text-xs text-muted">Anzeige</span>
<div className="inline-flex rounded-lg border border-border bg-surface p-0.5 text-xs"> <div className="inline-flex rounded-lg border border-border bg-surface p-0.5 text-xs">
@@ -554,7 +617,7 @@ export function PlanView({
{/* Matrix: eigener Scrollbereich, damit Phasen-Köpfe (oben) UND Elementnamen (links) {/* Matrix: eigener Scrollbereich, damit Phasen-Köpfe (oben) UND Elementnamen (links)
beim Scrollen sichtbar bleiben. */} beim Scrollen sichtbar bleiben. */}
{hasPhases && ( {hasPhases && view === "matrix" && (
<div data-tour="matrix" className="max-h-[75vh] overflow-auto rounded-xl border border-border bg-surface shadow-sm"> <div data-tour="matrix" className="max-h-[75vh] overflow-auto rounded-xl border border-border bg-surface shadow-sm">
{/* Feste Spaltenbreiten: Alle Phasenspalten sind gleich breit -- bei einer einzigen {/* Feste Spaltenbreiten: Alle Phasenspalten sind gleich breit -- bei einer einzigen
Phase bleibt die Tabelle dadurch schmal, bei vielen wird horizontal gescrollt. Phase bleibt die Tabelle dadurch schmal, bei vielen wird horizontal gescrollt.
@@ -589,7 +652,7 @@ export function PlanView({
) : ( ) : (
<TransitionHeader <TransitionHeader
key={`t-${col.fromPhase.id}`} key={`t-${col.fromPhase.id}`}
openCount={transitionOpenCount(col.fromPhase, col.toPhase)} counts={transitionOpenCount(col.fromPhase, col.toPhase)}
onClick={() => setReviewFromPhaseId(col.fromPhase.id)} onClick={() => setReviewFromPhaseId(col.fromPhase.id)}
/> />
) )
@@ -880,6 +943,8 @@ export function PlanView({
const els = transitionElements(fromPhase, toPhase); const els = transitionElements(fromPhase, toPhase);
return ( return (
<TransitionReviewDialog <TransitionReviewDialog
plan={plan}
computed={computed}
fromPhase={fromPhase} fromPhase={fromPhase}
toPhase={toPhase} toPhase={toPhase}
elements={els} elements={els}
@@ -1084,6 +1149,8 @@ export function PlanView({
const toPhase = computed.phases[computed.phases.findIndex((p) => p.id === fromPhase.id) + 1]; const toPhase = computed.phases[computed.phases.findIndex((p) => p.id === fromPhase.id) + 1];
return ( return (
<TransitionCellPanel <TransitionCellPanel
plan={plan}
computed={computed}
key={`${panel.elementId}-${panel.fromPhaseId}`} key={`${panel.elementId}-${panel.fromPhaseId}`}
element={element} element={element}
fromPhase={fromPhase} fromPhase={fromPhase}
@@ -1451,14 +1518,22 @@ function PhaseHeader({
); );
} }
function TransitionHeader({ openCount, onClick }: { openCount: number; onClick: () => void }) { function TransitionHeader({ counts, onClick }: { counts: DecisionCounts; onClick: () => void }) {
const done = openCount === 0; // 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 ( return (
<th <th
onClick={onClick} onClick={onClick}
data-tour="transition" data-tour="transition"
title={done ? undefined : decisionsText(counts)}
className={`sticky top-0 z-30 w-24 min-w-24 cursor-pointer border-b border-r border-border px-2 py-2 text-center align-top text-[11px] font-medium transition-colors ${ className={`sticky top-0 z-30 w-24 min-w-24 cursor-pointer border-b border-r border-border px-2 py-2 text-center align-top text-[11px] font-medium transition-colors ${
done ? "bg-surface-2 text-success" : "bg-attention text-attention-fg" done
? "bg-surface-2 text-success"
: onlyDefaults
? "bg-surface-2 text-muted"
: "bg-attention text-attention-fg"
}`} }`}
> >
<div>Übergang</div> <div>Übergang</div>
@@ -1467,8 +1542,17 @@ function TransitionHeader({ openCount, onClick }: { openCount: number; onClick:
<CheckCircle2 className="h-3 w-3" /> geprüft <CheckCircle2 className="h-3 w-3" /> geprüft
</div> </div>
) : ( ) : (
<div className="mt-1 rounded-full bg-attention-fg/20 px-1.5 py-0.5 text-[10px] font-semibold"> <div className="mt-1 flex flex-col gap-0.5">
{openCount} offen {counts.open > 0 && (
<div className="rounded-full bg-attention-fg/20 px-1.5 py-0.5 text-[10px] font-semibold">
{counts.open} offen
</div>
)}
{counts.unconfirmed > 0 && (
<div className="rounded-full border border-border px-1.5 py-0.5 text-[10px] font-semibold">
{counts.unconfirmed} Vorgabe{counts.unconfirmed === 1 ? "" : "n"}
</div>
)}
</div> </div>
)} )}
</th> </th>
@@ -1496,7 +1580,7 @@ function NextSteps({
plan: PlanInput; plan: PlanInput;
computed: PlanComputed; computed: PlanComputed;
columns: Column[]; columns: Column[];
openCountFor: (fromPhase: PhaseComputed, toPhase: PhaseComputed) => number; openCountFor: (fromPhase: PhaseComputed, toPhase: PhaseComputed) => DecisionCounts;
onReview: (fromPhaseId: string) => void; onReview: (fromPhaseId: string) => void;
onAddElement: (category: ElementCategory) => void; onAddElement: (category: ElementCategory) => void;
onAddPhase: () => void; onAddPhase: () => void;
@@ -1512,14 +1596,14 @@ function NextSteps({
for (const col of columns) { for (const col of columns) {
if (col.kind !== "transition") continue; if (col.kind !== "transition") continue;
const n = openCountFor(col.fromPhase, col.toPhase); const n = openCountFor(col.fromPhase, col.toPhase);
openTotal += n; openTotal += n.open;
if (n > 0 && firstOpenPhaseId === null) firstOpenPhaseId = col.fromPhase.id; if (n.open > 0 && firstOpenPhaseId === null) firstOpenPhaseId = col.fromPhase.id;
} }
if (openTotal > 0 && firstOpenPhaseId) { if (openTotal > 0 && firstOpenPhaseId) {
const target = firstOpenPhaseId; const target = firstOpenPhaseId;
items.push({ items.push({
key: "transitions", 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", action: "Jetzt durchgehen",
run: () => onReview(target), run: () => onReview(target),
}); });
@@ -1869,7 +1953,14 @@ function ProfilePanel({ plan, onClose, onSaved }: { plan: PlanInput; onClose: ()
} }
// --- Dialog: geführter Übergang --- // --- 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({ function TransitionReviewDialog({
plan,
computed,
fromPhase, fromPhase,
toPhase, toPhase,
elements, elements,
@@ -1879,6 +1970,8 @@ function TransitionReviewDialog({
onClose, onClose,
onSaved, onSaved,
}: { }: {
plan: PlanInput;
computed: PlanComputed;
fromPhase: PhaseComputed; fromPhase: PhaseComputed;
toPhase: PhaseComputed | undefined; toPhase: PhaseComputed | undefined;
elements: ElementInput[]; elements: ElementInput[];
@@ -1894,6 +1987,14 @@ function TransitionReviewDialog({
) )
); );
const [ct, setCt] = useState<CashTransitionData>(() => withCashTransitionDefaults(initialCash)); const [ct, setCt] = useState<CashTransitionData>(() => withCashTransitionDefaults(initialCash));
// Pensionierungs-Entscheide: eigener Entwurf, weil sie an einem anderen Ort liegen als die
// Übergangswerte -- und beim Speichern über einen eigenen Endpunkt gehen.
const [rds, setRds] = useState<Record<string, RetirementDecision>>({});
const effectiveRd = (el: ElementInput) =>
withRetirementDefaults(el.category, retirementAgeOf(plan, el), {
...(el.retirementDecision ?? {}),
...(rds[el.id] ?? {}),
});
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -1905,6 +2006,11 @@ function TransitionReviewDialog({
for (const e of elements) { for (const e of elements) {
await api.put(`/api/elements/${e.id}/transition/${fromPhase.id}`, tds[e.id] ?? {}); 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(); onSaved();
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Speichern fehlgeschlagen."); setError(err instanceof Error ? err.message : "Speichern fehlgeschlagen.");
@@ -1974,6 +2080,16 @@ function TransitionReviewDialog({
context={ctx} context={ctx}
td={tds[el.id] ?? {}} td={tds[el.id] ?? {}}
setT={(patch) => setTds((prev) => ({ ...prev, [el.id]: { ...prev[el.id], ...patch } }))} setT={(patch) => setTds((prev) => ({ ...prev, [el.id]: { ...prev[el.id], ...patch } }))}
retirement={
<RetirementFields
plan={plan}
el={el}
defaultOpen
rd={effectiveRd(el)}
patch={(id, patch) => setRds((prev) => ({ ...prev, [id]: { ...prev[id], ...patch } }))}
summary={computed.retirement.perPerson.find((x) => x.role === el.ownerRole)}
/>
}
/> />
</div> </div>
</div> </div>
@@ -2073,6 +2189,8 @@ function CashInitialPanel({ plan, onClose, onSaved }: { plan: PlanInput; onClose
// --- Panel: einzelner Übergangs-Entscheid (per Klick auf eine Übergangszelle) --- // --- Panel: einzelner Übergangs-Entscheid (per Klick auf eine Übergangszelle) ---
function TransitionCellPanel({ function TransitionCellPanel({
plan,
computed,
element, element,
fromPhase, fromPhase,
toPhase, toPhase,
@@ -2080,6 +2198,8 @@ function TransitionCellPanel({
onClose, onClose,
onSaved, onSaved,
}: { }: {
plan: PlanInput;
computed: PlanComputed;
element: ElementInput; element: ElementInput;
fromPhase: PhaseComputed; fromPhase: PhaseComputed;
toPhase: PhaseComputed | undefined; toPhase: PhaseComputed | undefined;
@@ -2090,6 +2210,12 @@ function TransitionCellPanel({
const [td, setTd] = useState<TransitionData>(() => const [td, setTd] = useState<TransitionData>(() =>
withTransitionDefaults(element.category, context.isRetirementTransition, element.transitionValues[fromPhase.id] ?? {}) withTransitionDefaults(element.category, context.isRetirementTransition, element.transitionValues[fromPhase.id] ?? {})
); );
const [rd, setRd] = useState<RetirementDecision>({});
const effectiveRd = (el: ElementInput) =>
withRetirementDefaults(el.category, retirementAgeOf(plan, el), {
...(el.retirementDecision ?? {}),
...rd,
});
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -2098,6 +2224,12 @@ function TransitionCellPanel({
setError(null); setError(null);
try { try {
await api.put(`/api/elements/${element.id}/transition/${fromPhase.id}`, td); 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(); onSaved();
} catch (e) { } catch (e) {
setError(e instanceof Error ? e.message : "Speichern fehlgeschlagen."); setError(e instanceof Error ? e.message : "Speichern fehlgeschlagen.");
@@ -2118,6 +2250,18 @@ function TransitionCellPanel({
context={context} context={context}
td={td} td={td}
setT={(patch) => setTd((prev) => ({ ...prev, ...patch }))} setT={(patch) => setTd((prev) => ({ ...prev, ...patch }))}
retirement={
<RetirementFields
plan={plan}
el={element}
defaultOpen
rd={effectiveRd(element)}
patch={(_id: string, patch: Partial<RetirementDecision>) =>
setRd((prev) => ({ ...prev, ...patch }))
}
summary={computed.retirement.perPerson.find((x) => x.role === element.ownerRole)}
/>
}
/> />
</div> </div>
{error && <p className="mt-3 text-sm text-danger">{error}</p>} {error && <p className="mt-3 text-sm text-danger">{error}</p>}
+468
View File
@@ -0,0 +1,468 @@
"use client";
// Die drei Säulen-Bausteine des Pensionierungs-Entscheids.
//
// Eigenes Modul, weil sie an ZWEI Orten erscheinen: im Pensionierungs-Bildschirm (alle
// beieinander) und in der Matrix-Zelle am Pensions-Übergang (je einer). Das ist kein
// Duplikat, sondern zwei Ansichten auf dasselbe Objekt -- deshalb auch genau eine
// Implementierung. Wären es zwei, liefen sie garantiert auseinander.
import { useState } from "react";
import { AlertTriangle, Check, ChevronDown, ChevronRight, Info } from "lucide-react";
import { InfoBubble } from "@/components/InfoBubble";
import { MoneyField, NumberField, SelectField } from "@/components/FormField";
import { formatChf } from "@/lib/format";
import { ownerLabel } from "@/lib/elements";
import {
AHV_DEFER_MAX_MONTHS,
AHV_EARLY_MAX_MONTHS,
AHV_REFERENCE_AGE,
PILLAR_3A_MAX_WITHDRAWAL_AGE,
PILLAR_3A_MIN_WITHDRAWAL_AGE,
PK_BUYIN_BLOCKING_YEARS,
} from "@/lib/constants";
import { ahvDrawLabel, withRetirementDefaults, type AhvDraw, type RetirementDecision } from "@/lib/retirement-decision";
import type { RetirementPersonSummary } from "@/lib/calculations";
import type { ElementInput, PlanInput } from "@/lib/types";
export type PatchFn = (elementId: string, p: Partial<RetirementDecision>) => void;
// Dispatcher: rendert den passenden Baustein zur Kategorie. Wird von der Matrix-Zelle
// genutzt, die immer genau ein Element vor sich hat.
export function RetirementFields({
plan,
el,
patch,
summary,
defaultOpen,
// Der bereits mit Vorgaben aufgefüllte Entscheid. Der Pensionierungs-Bildschirm reicht hier
// seinen Entwurf herein (ungespeicherte Änderungen); die Matrix-Zelle lässt ihn weg und
// bekommt den gespeicherten Stand.
rd: rdOverride,
}: {
plan: PlanInput;
el: ElementInput;
patch: PatchFn;
summary?: RetirementPersonSummary;
defaultOpen?: boolean;
rd?: RetirementDecision;
}) {
const person = plan.persons.find((p) => p.role === el.ownerRole);
const retirementAge = person?.retirementAge ?? AHV_REFERENCE_AGE;
const rd = rdOverride ?? withRetirementDefaults(el.category, retirementAge, el.retirementDecision);
const siblings = plan.elements
.filter((x) => x.category === "PILLAR_3A" && x.ownerRole === el.ownerRole && x.id !== el.id)
.map((x) => withRetirementDefaults("PILLAR_3A", retirementAge, x.retirementDecision));
if (el.category === "AHV") return <AhvBlock el={el} rd={rd} patch={patch} summary={summary} defaultOpen={defaultOpen} />;
if (el.category === "PENSION_FUND")
return <PkBlock plan={plan} el={el} rd={rd} patch={patch} summary={summary} defaultOpen={defaultOpen} />;
if (el.category === "PILLAR_3A")
return (
<Pillar3aBlock
el={el}
rd={rd}
patch={patch}
retirementAge={retirementAge}
siblings={siblings}
defaultOpen={defaultOpen}
/>
);
return null;
}
// --- Säulen-Blöcke -------------------------------------------------------------------------
export function Pillar({
title,
subtitle,
confirmed,
onConfirm,
children,
defaultOpen,
}: {
title: string;
subtitle: string;
confirmed: boolean;
onConfirm: (v: boolean) => void;
children: React.ReactNode;
defaultOpen?: boolean;
}) {
const [open, setOpen] = useState(defaultOpen ?? false);
return (
<div className="rounded-xl border border-border">
<button
type="button"
onClick={() => setOpen((o) => !o)}
className="flex w-full items-center gap-2 px-3 py-2 text-left"
>
{open ? <ChevronDown className="h-4 w-4 text-faint" /> : <ChevronRight className="h-4 w-4 text-faint" />}
<span className="text-sm font-semibold text-fg">{title}</span>
<span className="flex-1 truncate text-xs text-muted">{subtitle}</span>
{confirmed ? (
<span className="flex items-center gap-1 whitespace-nowrap text-[11px] font-semibold text-success">
<Check className="h-3.5 w-3.5" /> bestätigt
</span>
) : (
<span className="flex items-center gap-1 whitespace-nowrap rounded-full border border-border px-2 py-0.5 text-[11px] text-muted">
<Info className="h-3.5 w-3.5" /> Vorgabe ungeprüft
</span>
)}
</button>
{open && (
<div className="border-t border-border px-3 py-3">
<div className="grid gap-3 sm:grid-cols-2">{children}</div>
<label className="mt-3 flex cursor-pointer items-start gap-2 text-xs text-muted">
<input
type="checkbox"
checked={confirmed}
onChange={(e) => onConfirm(e.target.checked)}
className="mt-0.5"
/>
<span>
Ich habe das angeschaut und bestätige es. Ohne Häkchen rechnet das Tool mit der Vorgabe sie wird als
«ungeprüft» ausgewiesen, damit sie nicht unbemerkt durchgeht.
</span>
</label>
</div>
)}
</div>
);
}
export function AhvBlock({
el,
rd,
patch,
summary,
defaultOpen,
}: {
el: ElementInput;
rd: RetirementDecision;
patch: (elementId: string, p: Partial<RetirementDecision>) => void;
summary?: RetirementPersonSummary;
defaultOpen?: boolean;
}) {
const draw = rd.ahvDraw ?? "REFERENCE";
const months = rd.ahvMonths ?? 0;
const maxMonths = draw === "EARLY" ? AHV_EARLY_MAX_MONTHS : AHV_DEFER_MAX_MONTHS;
return (
<Pillar
title="AHV"
subtitle={`${ahvDrawLabel(rd)} · ${formatChf(summary?.ahvAnnual ?? 0)} / Jahr`}
confirmed={rd.confirmed === true}
onConfirm={(v) => patch(el.id, { confirmed: v })}
defaultOpen={defaultOpen}
>
<SelectField
label="Bezug"
help={`Referenzalter ist ${AHV_REFERENCE_AGE}. Ein Vorbezug kürzt die Rente lebenslang um 6,8 % pro Jahr, ein Aufschub erhöht sie (nach 5 Jahren um 31,5 %).`}
value={draw}
onChange={(v: AhvDraw) =>
patch(el.id, { ahvDraw: v, ahvMonths: v === "REFERENCE" ? 0 : months || 12 })
}
options={[
{ value: "REFERENCE", label: `Ab Referenzalter (${AHV_REFERENCE_AGE})` },
{ value: "EARLY", label: "Vorbeziehen" },
{ value: "DEFERRED", label: "Aufschieben" },
]}
/>
{draw !== "REFERENCE" && (
<NumberField
label={draw === "EARLY" ? "Vorbezug (Monate)" : "Aufschub (Monate)"}
help={
draw === "EARLY"
? `Höchstens ${AHV_EARLY_MAX_MONTHS} Monate (3 Jahre), seit AHV 21 monatsgenau.`
: `Mindestens 12, höchstens ${AHV_DEFER_MAX_MONTHS} Monate (5 Jahre).`
}
value={months}
min={draw === "EARLY" ? 1 : 12}
max={maxMonths}
step={1}
onChange={(v) => patch(el.id, { ahvMonths: Math.max(0, Math.min(maxMonths, Math.round(v))) })}
/>
)}
{draw !== "REFERENCE" && summary && (
<div className="sm:col-span-2 rounded-lg bg-surface-2 px-3 py-2 text-xs text-muted">
Rente ab Alter <strong className="text-fg">{summary.ahvFromAge}</strong>:{" "}
<strong className="text-fg">{formatChf(summary.ahvAnnual)}</strong> pro Jahr. Die Beitragspflicht endet davon
unabhängig erst mit {AHV_REFERENCE_AGE} wer vorher aufhört zu arbeiten, zahlt bis dahin als
Nichterwerbstätige(r) weiter.
</div>
)}
<div className="sm:col-span-2 mt-1 border-t border-border pt-3">
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-faint">Grundlage der Schätzung</p>
<div className="grid gap-3 sm:grid-cols-2">
<MoneyField
label="Durchschnittseinkommen vor Planbeginn (brutto, heutige Kaufkraft)"
help="Aus der AHV-Rentenvorausberechnung. Bleibt das Feld leer, schätzt das Tool den Wert aus dem geplanten Durchschnitt das ist besser als 0, aber ungenauer als dein echter Auszug."
value={rd.avgIncomeBefore ?? 0}
onChange={(v) => patch(el.id, { avgIncomeBefore: v })}
/>
<NumberField
label="Beitragslücken vor Planbeginn (Jahre)"
help="Jahre ohne AHV-Beitrag, etwa durch Auslandaufenthalt. Jedes fehlende Jahr kürzt die Rente um rund 1/44."
value={rd.gapYearsBefore ?? 0}
min={0}
max={50}
step={1}
onChange={(v) => patch(el.id, { gapYearsBefore: Math.max(0, Math.round(v)) })}
/>
</div>
</div>
</Pillar>
);
}
export function PkBlock({
plan,
el,
rd,
patch,
summary,
defaultOpen,
}: {
plan: PlanInput;
el: ElementInput;
rd: RetirementDecision;
patch: (elementId: string, p: Partial<RetirementDecision>) => void;
summary?: RetirementPersonSummary;
defaultOpen?: boolean;
}) {
const share = rd.capitalSharePct ?? 0;
const targets = plan.elements.filter((e) => e.category === "OTHER_ASSET");
return (
<Pillar
title={`Pensionskasse · ${el.name}`}
subtitle={
share === 0
? `Volle Rente · ${formatChf(summary?.pkPensionAnnual ?? 0)} / Jahr`
: share === 100
? "Volles Kapital"
: `${100 - share} % Rente / ${share} % Kapital`
}
confirmed={rd.confirmed === true}
onConfirm={(v) => patch(el.id, { confirmed: v })}
defaultOpen={defaultOpen}
>
<div className="sm:col-span-2">
<label className="mb-1 flex items-center text-xs font-medium text-muted">
Kapitalanteil
<InfoBubble text="0 % = volle Rente, 100 % = volles Kapital, alles dazwischen ist die Kombination. Bewusst eine Quote und kein Frankenbetrag: Verschiebst du das Pensionsalter, ändert sich das Guthaben die Quote skaliert mit, ein fixer Betrag würde still ein anderes Verhältnis bedeuten." />
</label>
<div className="flex items-center gap-3">
<input
type="range"
min={0}
max={100}
step={5}
value={share}
onChange={(e) => patch(el.id, { capitalSharePct: Number(e.target.value) })}
className="h-2 flex-1 cursor-pointer appearance-none rounded-full bg-surface-2 accent-[var(--accent)]"
/>
<span className="w-28 whitespace-nowrap text-right text-sm tabular-nums text-fg">
{share} % Kapital
</span>
</div>
<div className="mt-1 flex justify-between text-[11px] text-faint">
<span>volle Rente</span>
<span>volles Kapital</span>
</div>
</div>
<NumberField
label="Umwandlungssatz (%)"
help="Aus deinem PK-Ausweis. Der gesetzliche Mindestsatz von 6,8 % gilt nur für den obligatorischen Teil umhüllende Kassen liegen effektiv oft bei 5 bis 5,6 %, bei einer Frühpensionierung tiefer."
value={rd.conversionRate ?? 0}
step={0.1}
min={0}
max={20}
onChange={(v) => patch(el.id, { conversionRate: v })}
/>
{share > 0 && (
<NumberField
label="Kapitalbezugssteuer (%)"
help="Pauschalsatz. Die tatsächliche Steuer ist kantonal und progressiv und alle Kapitalbezüge desselben Jahres werden zusammengezählt, bei Ehepaaren auch die des Partners. Wer staffelt, zahlt weniger."
value={rd.capitalTaxRate ?? 0}
step={0.5}
min={0}
max={100}
onChange={(v) => patch(el.id, { capitalTaxRate: v })}
/>
)}
{share > 0 && (
<>
<div className="sm:col-span-2">
<label className="flex cursor-pointer items-start gap-2 text-xs text-muted">
<input
type="checkbox"
checked={rd.recentBuyIn === true}
onChange={(e) => patch(el.id, { recentBuyIn: e.target.checked })}
className="mt-0.5"
/>
<span>
In den letzten {PK_BUYIN_BLOCKING_YEARS} Jahren in die Pensionskasse eingekauft
</span>
</label>
{rd.recentBuyIn && (
<p className="mt-1 flex items-start gap-2 rounded-lg border border-attention bg-attention/10 px-3 py-2 text-xs text-attention-fg">
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
Ein Kapitalbezug innerhalb von {PK_BUYIN_BLOCKING_YEARS} Jahren nach einem Einkauf lässt den
Steuerabzug für diesen Einkauf nachträglich entfallen (Art. 79b Abs. 3 BVG). Das Tool rechnet diesen
Effekt nicht prüfe die Fristen mit deiner Kasse.
</p>
)}
</div>
<CapitalUse rd={rd} elementId={el.id} patch={patch} targets={targets} plan={plan} />
</>
)}
</Pillar>
);
}
export function Pillar3aBlock({
el,
rd,
patch,
retirementAge,
siblings,
defaultOpen,
}: {
el: ElementInput;
rd: RetirementDecision;
patch: (elementId: string, p: Partial<RetirementDecision>) => void;
retirementAge: number;
siblings: RetirementDecision[];
defaultOpen?: boolean;
}) {
const age = rd.withdrawalAge ?? retirementAge;
const clash = siblings.some((s) => (s.withdrawalAge ?? retirementAge) === age);
return (
<Pillar
title={`Säule 3a · ${el.name}`}
subtitle={`Bezug mit ${age}`}
confirmed={rd.confirmed === true}
onConfirm={(v) => patch(el.id, { confirmed: v })}
defaultOpen={defaultOpen}
>
<NumberField
label="Bezugsalter"
help={`Frühestens ${PILLAR_3A_MIN_WITHDRAWAL_AGE}, spätestens ${PILLAR_3A_MAX_WITHDRAWAL_AGE} und nach dem Referenzalter nur, solange du erwerbstätig bleibst. Der Bezug erfolgt an der ersten Phasengrenze bei oder nach diesem Alter.`}
value={age}
min={PILLAR_3A_MIN_WITHDRAWAL_AGE}
max={PILLAR_3A_MAX_WITHDRAWAL_AGE}
step={1}
onChange={(v) => patch(el.id, { withdrawalAge: Math.round(v) })}
/>
<NumberField
label="Kapitalbezugssteuer (%)"
help="Pauschalsatz. Alle Kapitalbezüge desselben Jahres werden zusammengezählt deshalb lohnt sich das Staffeln über mehrere Jahre und mehrere Konten."
value={rd.capitalTaxRate ?? 0}
step={0.5}
min={0}
max={100}
onChange={(v) => patch(el.id, { capitalTaxRate: v })}
/>
<div className="sm:col-span-2 rounded-lg bg-surface-2 px-3 py-2 text-xs text-muted">
Ein 3a-Konto lässt sich bei der Pensionierung nur <strong className="text-fg">ganz</strong> auflösen. Gestaffelt
wird über mehrere Konten mit unterschiedlichen Bezugsjahren.
{age > AHV_REFERENCE_AGE && (
<>
{" "}
Ein Bezug nach {AHV_REFERENCE_AGE} setzt voraus, dass du weiterhin erwerbstätig bist.
</>
)}
</div>
{clash && (
<p className="sm:col-span-2 flex items-start gap-2 text-xs text-attention-fg">
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
Ein weiteres 3a-Konto wird im selben Jahr bezogen. Die Beträge werden steuerlich zusammengezählt ein
anderes Bezugsjahr senkt die Progression.
</p>
)}
</Pillar>
);
}
// Verwendung des bezogenen Kapitals (Punkt C). Dieselbe Frage wie in der Matrix-Zelle -- hier
// nur an dem Ort, an dem man ohnehin über den Bezug nachdenkt.
function CapitalUse({
rd,
elementId,
patch,
targets,
plan,
}: {
rd: RetirementDecision;
elementId: string;
patch: (elementId: string, p: Partial<RetirementDecision>) => void;
targets: ElementInput[];
plan: PlanInput;
}) {
const amort = Math.max(0, Math.min(100, rd.capitalUseAmortizationPct ?? 0));
const invest = Math.max(0, Math.min(100 - amort, rd.capitalUseInvestPct ?? 0));
const cash = Math.max(0, 100 - amort - invest);
const hasMortgage = plan.elements.some((e) => e.category === "REAL_ESTATE");
return (
<>
<div className="sm:col-span-2 mt-1 border-t border-border pt-3">
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-faint">
Wohin fliesst das bezogene Kapital?
</p>
<div className="grid gap-3 sm:grid-cols-2">
<NumberField
label="… in die Hypothek (%)"
help={
hasMortgage
? "Einmalige Amortisation. Am Restsaldo gekappt ist die Hypothek kleiner, bleibt der Rest Cash."
: "Es gibt keine Immobilie in diesem Szenario dieser Anteil bliebe wirkungslos auf dem Cash."
}
value={amort}
step={5}
min={0}
max={100}
onChange={(v) => patch(elementId, { capitalUseAmortizationPct: Math.max(0, Math.min(100, v)) })}
/>
<NumberField
label="… in die Anlage (%)"
help="Fliesst als Zusatzeinlage in das gewählte Vermögens-Element und wächst dort weiter."
value={invest}
step={5}
min={0}
max={100 - amort}
onChange={(v) => patch(elementId, { capitalUseInvestPct: Math.max(0, Math.min(100 - amort, v)) })}
/>
{invest > 0 && targets.length > 0 && (
<div className="sm:col-span-2">
<SelectField
label="Ziel der Anlage-Quote"
value={rd.capitalUseTargetElementId ?? targets[0].id}
onChange={(v: string) => patch(elementId, { capitalUseTargetElementId: v })}
options={targets.map((t) => ({
value: t.id,
label: `${t.name} · ${ownerLabel(plan.persons, t.ownerRole)}`,
}))}
/>
</div>
)}
{invest > 0 && targets.length === 0 && (
<p className="sm:col-span-2 flex items-start gap-2 text-xs text-attention-fg">
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
Es gibt kein Element «Sonstiges Vermögen», in das die Anlage-Quote fliessen könnte. Der Betrag bliebe auf
dem Cash-Konto liegen.
</p>
)}
<p className="sm:col-span-2 text-xs text-faint">
Nicht zugeteilt: <strong className="text-fg">{cash} %</strong> bleibt auf dem Cash-Konto.
</p>
</div>
</div>
</>
);
}
+338
View File
@@ -0,0 +1,338 @@
"use client";
// Der Pensionierungs-Bildschirm.
//
// Bis 0.33 lagen die Entscheide, die inhaltlich EINE Frage sind, in drei weit auseinander
// liegenden Matrix-Zellen (AHV, PK, 3a) am Pensions-Übergang. Hier stehen sie beieinander --
// in der Reihenfolge, in der man tatsächlich darüber nachdenkt:
//
// Wann höre ich auf? → Was kommt dann rein? → Was habe ich auf einen Schlag?
// → Reicht das? → Was mache ich mit dem Haufen?
//
// Die Leitzahl ganz oben ist die RENTENLÜCKE. Sie ist keine neue Rechnung, sondern die
// Verzehrquote im ersten voll pensionierten Jahr -- es fehlte bisher nur der Name dafür.
// Gerechnet wird sie im Rechenkern (`computed.retirement`), damit Bildschirm und PDF-Bericht
// nicht auseinanderlaufen können.
//
// Gestaltungsprinzip: KEIN leeres Formular, sondern ein vollständiger Vorschlag, den man
// korrigiert. Sonst müsste man am Anfang Fragen beantworten, die man erst am Ende beantworten
// kann -- und ohne Vorgaben wäre der Plan bis dahin gar nicht rechenbar.
import { useState } from "react";
import { AlertTriangle } from "lucide-react";
import { api } from "@/lib/api-client";
import { Button, useToast } from "@/components/ui";
import { InfoBubble } from "@/components/InfoBubble";
import { NumberField } from "@/components/FormField";
import { RetirementAdjuster } from "@/components/RetirementAdjuster";
import { RetirementFields } from "@/components/RetirementFields";
import { formatChf } from "@/lib/format";
import { ownerLabel } from "@/lib/elements";
import {
AHV_REFERENCE_AGE,
MAX_PLANNING_HORIZON_AGE,
MIN_PLANNING_HORIZON_AGE,
PK_MIN_RETIREMENT_AGE,
} from "@/lib/constants";
import { withRetirementDefaults, type RetirementDecision } from "@/lib/retirement-decision";
import type { PlanComputed, RetirementPersonSummary } from "@/lib/calculations";
import type { ElementInput, PersonRole, PlanInput } from "@/lib/types";
type Draft = Record<string, RetirementDecision>;
export function RetirementPanel({
plan,
computed,
onSaved,
}: {
plan: PlanInput;
computed: PlanComputed;
onSaved: () => void;
}) {
const toast = useToast();
const [draft, setDraft] = useState<Draft>({});
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
// Effektiver Entscheid je Element: gespeicherter Wert, überlagert vom Entwurf, aufgefüllt
// mit den Vorgaben. Genau das rechnet auch der Rechenkern.
const effective = (el: ElementInput): RetirementDecision => {
const person = plan.persons.find((p) => p.role === el.ownerRole);
return withRetirementDefaults(el.category, person?.retirementAge ?? AHV_REFERENCE_AGE, {
...(el.retirementDecision ?? {}),
...(draft[el.id] ?? {}),
});
};
const patch = (elementId: string, p: Partial<RetirementDecision>) =>
setDraft((d) => ({ ...d, [elementId]: { ...(d[elementId] ?? {}), ...p } }));
const dirty = Object.keys(draft).length > 0;
async function save() {
setSaving(true);
setError(null);
try {
for (const [elementId, changes] of Object.entries(draft)) {
const el = plan.elements.find((x) => x.id === elementId);
if (!el) continue;
await api.put(`/api/elements/${elementId}/retirement`, {
...(el.retirementDecision ?? {}),
...changes,
});
}
setDraft({});
toast("success", "Pensionierung gespeichert.");
onSaved();
} catch (e) {
setError(e instanceof Error ? e.message : "Speichern fehlgeschlagen.");
} finally {
setSaving(false);
}
}
return (
<div className="flex flex-col gap-5">
<div>
<h2 className="text-lg font-semibold text-fg">Pensionierung</h2>
<p className="mt-1 text-sm text-muted">
Alle Entscheide zu AHV, Pensionskasse und Säule 3a an einem Ort. Was du hier festlegst, steht auch in der
Matrix am Pensions-Übergang es ist derselbe Entscheid, nur anders angeschaut.
</p>
</div>
{plan.persons.map((person) => {
const summary = computed.retirement.perPerson.find((s) => s.role === person.role);
return (
<PersonBlock
key={person.role}
plan={plan}
computed={computed}
role={person.role}
summary={summary}
effective={effective}
patch={patch}
onSaved={onSaved}
/>
);
})}
{error && <p className="text-sm text-danger">{error}</p>}
{dirty && (
<div className="sticky bottom-0 flex items-center gap-3 border-t border-border bg-surface px-1 py-3">
<Button disabled={saving} onClick={save}>
{saving ? "…" : "Änderungen speichern"}
</Button>
<Button variant="ghost" disabled={saving} onClick={() => setDraft({})}>
Verwerfen
</Button>
</div>
)}
</div>
);
}
// --- ein Block je Person -------------------------------------------------------------------
function PersonBlock({
plan,
computed,
role,
summary,
effective,
patch,
onSaved,
}: {
plan: PlanInput;
computed: PlanComputed;
role: PersonRole;
summary: RetirementPersonSummary | undefined;
effective: (el: ElementInput) => RetirementDecision;
patch: (elementId: string, p: Partial<RetirementDecision>) => void;
onSaved: () => void;
}) {
const person = plan.persons.find((p) => p.role === role)!;
const label = ownerLabel(plan.persons, role);
const own = (cat: string) => plan.elements.filter((e) => e.category === cat && e.ownerRole === role);
const ahvEl = own("AHV")[0];
const pkEls = own("PENSION_FUND");
const a3Els = own("PILLAR_3A");
return (
<section className="rounded-2xl border border-border">
<header className="border-b border-border px-4 py-3">
<div className="flex flex-wrap items-baseline justify-between gap-2">
<h3 className="text-base font-semibold text-fg">
{label} · Pensionierung mit {person.retirementAge}
</h3>
{person.retirementAge < PK_MIN_RETIREMENT_AGE && (
<span className="flex items-center gap-1 text-xs text-attention-fg">
<AlertTriangle className="h-3.5 w-3.5" />
Vor {PK_MIN_RETIREMENT_AGE} lässt kaum eine Pensionskasse eine Pensionierung zu.
</span>
)}
</div>
</header>
<div className="grid gap-4 px-4 py-4 md:grid-cols-2">
<RetirementAdjuster plan={plan} onSaved={onSaved} />
<HorizonControl plan={plan} role={role} onSaved={onSaved} />
</div>
<GapBox plan={plan} computed={computed} summary={summary} />
<div className="flex flex-col gap-2 px-4 pb-4">
{[ahvEl, ...pkEls, ...a3Els].filter(Boolean).map((el) => (
<RetirementFields key={el!.id} plan={plan} el={el!} rd={effective(el!)} patch={patch} summary={summary} />
))}
{!ahvEl && pkEls.length === 0 && a3Els.length === 0 && (
<p className="rounded-lg border border-dashed border-border bg-surface-2 p-3 text-sm text-muted">
Für {label} sind noch keine Vorsorge-Elemente erfasst. Lege in der Matrix AHV, Pensionskasse oder Säule 3a
an die Entscheide dazu erscheinen dann hier.
</p>
)}
</div>
</section>
);
}
// --- Leitzahlen ----------------------------------------------------------------------------
function GapBox({
plan,
computed,
summary,
}: {
plan: PlanInput;
computed: PlanComputed;
summary: RetirementPersonSummary | undefined;
}) {
const r = computed.retirement;
const startYear = plan.startYear ?? null;
const horizon = summary?.planningHorizonAge ?? null;
const reachesEnd = computed.ruinAge === null;
return (
<div className="mx-4 mb-4 rounded-xl border border-border bg-surface-2 p-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="flex flex-col gap-1 text-sm">
<Line label="AHV-Rente" value={summary?.ahvAnnual ?? 0} note={summary?.ahvDraw} />
<Line label="PK-Rente" value={summary?.pkPensionAnnual ?? 0} />
<div className="my-1 border-t border-border" />
<Line label="Renteneinkommen (Haushalt)" value={r.pensionIncome ?? 0} strong />
<Line label="Ausgaben (Haushalt)" value={r.expenses ?? 0} />
</div>
<div className="flex flex-col justify-center gap-3">
<div>
<div className="flex items-center text-xs uppercase tracking-wide text-faint">
Rentenlücke
<InfoBubble text="Renteneinkommen minus Ausgaben im ersten Jahr, in dem niemand mehr arbeitet. Ein negativer Wert ist normal er wird aus dem Vermögen gedeckt. Entscheidend ist, wie lange das trägt." />
</div>
<div
className={`text-2xl font-semibold tabular-nums ${
(r.gapAnnual ?? 0) < 0 ? "text-danger" : "text-success"
}`}
>
{r.gapAnnual === null ? "" : `${formatChf(r.gapAnnual)} / Jahr`}
</div>
{r.firstRetirementYear !== null && startYear && (
<div className="text-xs text-faint">
gerechnet für {startYear + r.firstRetirementYear - 1}, das erste voll pensionierte Jahr
</div>
)}
</div>
<div>
<div className="text-xs uppercase tracking-wide text-faint">Vermögen reicht</div>
<div className={`text-lg font-semibold ${reachesEnd ? "text-success" : "text-danger"}`}>
{reachesEnd
? horizon
? `bis zum Horizont (Alter ${horizon})`
: "über die ganze Planung"
: `bis Alter ${computed.ruinAge}`}
</div>
</div>
{summary && summary.capitalAtRetirement > 0 && (
<div className="text-xs text-muted">
Einmalig verfügbar: <strong className="text-fg">{formatChf(summary.capitalAtRetirement)}</strong> netto
aus PK und Säule 3a
</div>
)}
</div>
</div>
</div>
);
}
function Line({ label, value, note, strong }: { label: string; value: number; note?: string; strong?: boolean }) {
return (
<div className="flex items-baseline justify-between gap-3">
<span className={strong ? "font-semibold text-fg" : "text-muted"}>
{label}
{note && <span className="ml-1 text-xs text-faint">({note})</span>}
</span>
<span className={`tabular-nums ${strong ? "font-semibold text-fg" : "text-fg"}`}>{formatChf(value)}</span>
</div>
);
}
// --- Planungshorizont ----------------------------------------------------------------------
function HorizonControl({ plan, role, onSaved }: { plan: PlanInput; role: PersonRole; onSaved: () => void }) {
const person = plan.persons.find((p) => p.role === role)!;
const [value, setValue] = useState(person.planningHorizonAge ?? null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const toast = useToast();
// Ohne erfassten Horizont: das Alter, bei dem der Plan heute faktisch endet.
const implied = person.age + plan.phases.reduce((s, p) => s + p.durationYears, 0);
async function submit(next: number) {
setBusy(true);
setError(null);
try {
await api.post(`/api/scenarios/${plan.id}/horizon`, { role, horizonAge: next });
toast("success", `Planungshorizont auf Alter ${next} gesetzt.`);
onSaved();
} catch (e) {
setError(e instanceof Error ? e.message : "Nicht möglich.");
setValue(person.planningHorizonAge ?? null);
} finally {
setBusy(false);
}
}
return (
<div className="rounded-xl border border-border p-3">
<h4 className="mb-2 flex items-center text-sm font-semibold text-fg">
Planungshorizont
<InfoBubble text="Bis zu welchem Alter gerechnet wird. Die letzte Lebensphase passt sich an. Ohne diesen Wert ergibt sich das Planende stillschweigend aus der Summe der Phasendauern zwei Szenarien rechnen dann womöglich unbemerkt verschieden weit und sind nicht vergleichbar." />
</h4>
<div className="flex items-end gap-2">
<div className="flex-1">
<NumberField
label="Alter"
value={value ?? implied}
min={MIN_PLANNING_HORIZON_AGE}
max={MAX_PLANNING_HORIZON_AGE}
step={1}
onChange={setValue}
/>
</div>
<Button
disabled={busy || value === null || value === person.planningHorizonAge}
onClick={() => value !== null && submit(value)}
>
Setzen
</Button>
</div>
{person.planningHorizonAge === null && (
<p className="mt-1 text-xs text-faint">
Noch nicht gesetzt der Plan endet aktuell rechnerisch mit Alter {implied}.
</p>
)}
{error && <p className="mt-1 text-xs text-danger">{error}</p>}
</div>
);
}
+66 -11
View File
@@ -8,8 +8,9 @@
// von dort macht diese Datei serverseitig unbenutzbar ("Attempted to call ... from the // 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. // server but it is on the client") -- genau daran scheiterte der PDF-Bericht.
import { isCashTransitionAnswered, isTransitionAnswered } from "@/lib/transitions"; import { isCashTransitionAnswered, isTransitionAnswered } from "@/lib/transitions";
import { RETIREMENT_CATEGORIES } from "@/lib/retirement-decision";
import type { ElementCategory } from "@/lib/elements"; 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"; import type { PhaseComputed, PlanComputed } from "@/lib/calculations";
export const TRANSITION_CATEGORIES: ElementCategory[] = [ export const TRANSITION_CATEGORIES: ElementCategory[] = [
@@ -56,29 +57,83 @@ export function transitionInactive(
return false; 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. // Offene Entscheide an EINER Phasengrenze. Der Cash-Entscheid zählt mit.
export function openTransitionCount( export function openTransitionCount(
plan: PlanInput, plan: PlanInput,
computed: PlanComputed, computed: PlanComputed,
fromPhase: PhaseComputed, fromPhase: PhaseComputed,
toPhase: PhaseComputed toPhase: PhaseComputed
): number { ): DecisionCounts {
const cash = plan.phases.find((p) => p.id === fromPhase.id)?.cashTransition ?? {}; 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) { for (const el of plan.elements) {
if (!TRANSITION_CATEGORIES.includes(el.category)) continue; if (!TRANSITION_CATEGORIES.includes(el.category)) continue;
if (transitionInactive(computed, el, fromPhase, toPhase)) 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] ?? {}; 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; return n;
} }
// Summe über alle Phasengrenzen -- die aktionierbarste Kennzahl des ganzen Werkzeugs. // Kurzfassung für Badges: «2 offene Entscheide · 3 Vorgaben ungeprüft».
export function totalOpenDecisions(plan: PlanInput, computed: PlanComputed): number { export function decisionsText(c: DecisionCounts): string {
let n = 0; const parts: string[] = [];
for (let i = 0; i < computed.phases.length - 1; i++) { if (c.open > 0) parts.push(`${c.open} ${c.open === 1 ? "offener Entscheid" : "offene Entscheide"}`);
n += openTransitionCount(plan, computed, computed.phases[i], computed.phases[i + 1]); if (c.unconfirmed > 0) parts.push(`${c.unconfirmed} ${c.unconfirmed === 1 ? "Vorgabe" : "Vorgaben"} ungeprüft`);
} return parts.join(" · ");
return n;
} }
+9 -2
View File
@@ -104,7 +104,14 @@ export function computeScenarioDiff(scenario: PlanInput, base: PlanInput | null)
} }
usedBaseEls.add(src.id); 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"); d.elementRow.set(el.id, "changed");
} }
@@ -132,7 +139,7 @@ export function computeScenarioDiff(scenario: PlanInput, base: PlanInput | null)
// --- Profil und Cash-Anfangswert --- // --- Profil und Cash-Anfangswert ---
d.cashInitialChanged = Math.round(scenario.initialCash) !== Math.round(base.initialCash); d.cashInitialChanged = Math.round(scenario.initialCash) !== Math.round(base.initialCash);
const personKey = (p: PlanInput["persons"][number]) => 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 = d.profileChanged =
scenario.householdType !== base.householdType || scenario.householdType !== base.householdType ||
scenario.inflationRateDefault !== base.inflationRateDefault || scenario.inflationRateDefault !== base.inflationRateDefault ||
+9
View File
@@ -1,7 +1,9 @@
import { Prisma } from "@/generated/prisma/client"; import { Prisma } from "@/generated/prisma/client";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { cashTransitionSchema, phaseDataSchema, transitionDataSchema } from "@/lib/elements"; import { cashTransitionSchema, phaseDataSchema, transitionDataSchema } from "@/lib/elements";
import { retirementDecisionSchema } from "@/lib/retirement-decision";
import type { CashTransitionData, PhaseData, TransitionData } from "@/lib/elements"; import type { CashTransitionData, PhaseData, TransitionData } from "@/lib/elements";
import type { RetirementDecision } from "@/lib/retirement-decision";
import type { PlanInput } from "@/lib/types"; import type { PlanInput } from "@/lib/types";
export const planInclude = { export const planInclude = {
@@ -29,6 +31,11 @@ function parseTransitionData(raw: unknown): TransitionData {
return parsed.success ? parsed.data : {}; 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 { function parseCashTransition(raw: unknown): CashTransitionData {
const parsed = cashTransitionSchema.safeParse(raw); const parsed = cashTransitionSchema.safeParse(raw);
return parsed.success ? parsed.data : {}; return parsed.success ? parsed.data : {};
@@ -53,6 +60,7 @@ export function toPlanInput(plan: PlanWithRelations): PlanInput {
name: hh?.name ?? null, name: hh?.name ?? null,
age: hh?.age ?? 0, age: hh?.age ?? 0,
retirementAge: p.retirementAge, retirementAge: p.retirementAge,
planningHorizonAge: p.planningHorizonAge,
}; };
}), }),
phases: plan.phases.map((phase) => ({ phases: plan.phases.map((phase) => ({
@@ -76,6 +84,7 @@ export function toPlanInput(plan: PlanWithRelations): PlanInput {
orderIndex: e.orderIndex, orderIndex: e.orderIndex,
phaseValues, phaseValues,
transitionValues, transitionValues,
retirementDecision: parseRetirementDecision(e.retirementDecision),
sourceElementId: e.sourceElementId, sourceElementId: e.sourceElementId,
}; };
}), }),
+7 -5
View File
@@ -15,7 +15,7 @@
// in den Anhang, ein Haftungsausschluss ist Pflicht. // in den Anhang, ein Haftungsausschluss ist Pflicht.
import { computePlan } from "@/lib/calculations"; import { computePlan } from "@/lib/calculations";
import { totalOpenDecisions } from "@/lib/decisions"; import { decisionsText, totalOpenDecisions } from "@/lib/decisions";
import { num } from "@/lib/elements"; import { num } from "@/lib/elements";
import { formatChf } from "@/lib/format"; import { formatChf } from "@/lib/format";
import { resolveActuals, type ActualsSetInput, type ElementOrigin } from "@/lib/actuals"; import { resolveActuals, type ActualsSetInput, type ElementOrigin } from "@/lib/actuals";
@@ -239,11 +239,13 @@ function buildScenario(
if (pkPension > 0) { if (pkPension > 0) {
figures.push({ label: "PK-Rente pro Jahr", value: formatChf(pkPension), basis: "Umwandlungssatz laut Systemparametern" }); figures.push({ label: "PK-Rente pro Jahr", value: formatChf(pkPension), basis: "Umwandlungssatz laut Systemparametern" });
} }
const openTotal = open.open + open.unconfirmed;
figures.push({ figures.push({
label: "Offene Entscheide", label: "Offene Entscheide",
value: open === 0 ? "keine" : String(open), value: openTotal === 0 ? "keine" : decisionsText(open),
tone: open === 0 ? "success" : undefined, tone: openTotal === 0 ? "success" : undefined,
basis: "noch nicht getroffene Übergangs-Entscheide zwischen den Lebensphasen", basis:
"noch nicht getroffene Übergangs-Entscheide sowie Pensionierungs-Vorgaben, die nie bestätigt wurden",
}); });
const phases: ReportTable = { const phases: ReportTable = {
@@ -273,7 +275,7 @@ function buildScenario(
], ],
}, },
assumptions: assumptionsOf(plan), assumptions: assumptionsOf(plan),
openDecisions: open, openDecisions: open.open + open.unconfirmed,
}; };
} }
+12 -3
View File
@@ -177,12 +177,18 @@ export async function restoreVersion(
}, },
}); });
// Pensionsalter: an der Rolle festgemacht (je Szenario eindeutig). // Pensionsalter und Planungshorizont: an der Rolle festgemacht (je Szenario eindeutig).
for (const p of snap.persons) { for (const p of snap.persons) {
await tx.person.upsert({ await tx.person.upsert({
where: { scenarioId_role: { scenarioId, role: p.role } }, where: { scenarioId_role: { scenarioId, role: p.role } },
create: { id: p.id, scenarioId, role: p.role, retirementAge: p.retirementAge }, create: {
update: { retirementAge: p.retirementAge }, 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) { if (plan.deletePersonRoles.length > 0) {
@@ -227,6 +233,9 @@ export async function restoreVersion(
name: el.name, name: el.name,
ownerRole: el.ownerRole ?? null, ownerRole: el.ownerRole ?? null,
orderIndex: el.orderIndex, 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, sourceElementId: el.sourceElementId ?? null,
}; };
}; };