Pensionierungs-Bildschirm, Ampel mit drei Zustaenden, Planungshorizont
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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
@@ -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>}
|
||||||
|
|||||||
@@ -0,0 +1,468 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
// Die drei Säulen-Bausteine des Pensionierungs-Entscheids.
|
||||||
|
//
|
||||||
|
// Eigenes Modul, weil sie an ZWEI Orten erscheinen: im Pensionierungs-Bildschirm (alle
|
||||||
|
// beieinander) und in der Matrix-Zelle am Pensions-Übergang (je einer). Das ist kein
|
||||||
|
// Duplikat, sondern zwei Ansichten auf dasselbe Objekt -- deshalb auch genau eine
|
||||||
|
// Implementierung. Wären es zwei, liefen sie garantiert auseinander.
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { AlertTriangle, Check, ChevronDown, ChevronRight, Info } from "lucide-react";
|
||||||
|
import { InfoBubble } from "@/components/InfoBubble";
|
||||||
|
import { MoneyField, NumberField, SelectField } from "@/components/FormField";
|
||||||
|
import { formatChf } from "@/lib/format";
|
||||||
|
import { ownerLabel } from "@/lib/elements";
|
||||||
|
import {
|
||||||
|
AHV_DEFER_MAX_MONTHS,
|
||||||
|
AHV_EARLY_MAX_MONTHS,
|
||||||
|
AHV_REFERENCE_AGE,
|
||||||
|
PILLAR_3A_MAX_WITHDRAWAL_AGE,
|
||||||
|
PILLAR_3A_MIN_WITHDRAWAL_AGE,
|
||||||
|
PK_BUYIN_BLOCKING_YEARS,
|
||||||
|
} from "@/lib/constants";
|
||||||
|
import { ahvDrawLabel, withRetirementDefaults, type AhvDraw, type RetirementDecision } from "@/lib/retirement-decision";
|
||||||
|
import type { RetirementPersonSummary } from "@/lib/calculations";
|
||||||
|
import type { ElementInput, PlanInput } from "@/lib/types";
|
||||||
|
|
||||||
|
export type PatchFn = (elementId: string, p: Partial<RetirementDecision>) => void;
|
||||||
|
|
||||||
|
// Dispatcher: rendert den passenden Baustein zur Kategorie. Wird von der Matrix-Zelle
|
||||||
|
// genutzt, die immer genau ein Element vor sich hat.
|
||||||
|
export function RetirementFields({
|
||||||
|
plan,
|
||||||
|
el,
|
||||||
|
patch,
|
||||||
|
summary,
|
||||||
|
defaultOpen,
|
||||||
|
// Der bereits mit Vorgaben aufgefüllte Entscheid. Der Pensionierungs-Bildschirm reicht hier
|
||||||
|
// seinen Entwurf herein (ungespeicherte Änderungen); die Matrix-Zelle lässt ihn weg und
|
||||||
|
// bekommt den gespeicherten Stand.
|
||||||
|
rd: rdOverride,
|
||||||
|
}: {
|
||||||
|
plan: PlanInput;
|
||||||
|
el: ElementInput;
|
||||||
|
patch: PatchFn;
|
||||||
|
summary?: RetirementPersonSummary;
|
||||||
|
defaultOpen?: boolean;
|
||||||
|
rd?: RetirementDecision;
|
||||||
|
}) {
|
||||||
|
const person = plan.persons.find((p) => p.role === el.ownerRole);
|
||||||
|
const retirementAge = person?.retirementAge ?? AHV_REFERENCE_AGE;
|
||||||
|
const rd = rdOverride ?? withRetirementDefaults(el.category, retirementAge, el.retirementDecision);
|
||||||
|
const siblings = plan.elements
|
||||||
|
.filter((x) => x.category === "PILLAR_3A" && x.ownerRole === el.ownerRole && x.id !== el.id)
|
||||||
|
.map((x) => withRetirementDefaults("PILLAR_3A", retirementAge, x.retirementDecision));
|
||||||
|
|
||||||
|
if (el.category === "AHV") return <AhvBlock el={el} rd={rd} patch={patch} summary={summary} defaultOpen={defaultOpen} />;
|
||||||
|
if (el.category === "PENSION_FUND")
|
||||||
|
return <PkBlock plan={plan} el={el} rd={rd} patch={patch} summary={summary} defaultOpen={defaultOpen} />;
|
||||||
|
if (el.category === "PILLAR_3A")
|
||||||
|
return (
|
||||||
|
<Pillar3aBlock
|
||||||
|
el={el}
|
||||||
|
rd={rd}
|
||||||
|
patch={patch}
|
||||||
|
retirementAge={retirementAge}
|
||||||
|
siblings={siblings}
|
||||||
|
defaultOpen={defaultOpen}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Säulen-Blöcke -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function Pillar({
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
confirmed,
|
||||||
|
onConfirm,
|
||||||
|
children,
|
||||||
|
defaultOpen,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
confirmed: boolean;
|
||||||
|
onConfirm: (v: boolean) => void;
|
||||||
|
children: React.ReactNode;
|
||||||
|
defaultOpen?: boolean;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(defaultOpen ?? false);
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-border">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen((o) => !o)}
|
||||||
|
className="flex w-full items-center gap-2 px-3 py-2 text-left"
|
||||||
|
>
|
||||||
|
{open ? <ChevronDown className="h-4 w-4 text-faint" /> : <ChevronRight className="h-4 w-4 text-faint" />}
|
||||||
|
<span className="text-sm font-semibold text-fg">{title}</span>
|
||||||
|
<span className="flex-1 truncate text-xs text-muted">{subtitle}</span>
|
||||||
|
{confirmed ? (
|
||||||
|
<span className="flex items-center gap-1 whitespace-nowrap text-[11px] font-semibold text-success">
|
||||||
|
<Check className="h-3.5 w-3.5" /> bestätigt
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="flex items-center gap-1 whitespace-nowrap rounded-full border border-border px-2 py-0.5 text-[11px] text-muted">
|
||||||
|
<Info className="h-3.5 w-3.5" /> Vorgabe ungeprüft
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div className="border-t border-border px-3 py-3">
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">{children}</div>
|
||||||
|
<label className="mt-3 flex cursor-pointer items-start gap-2 text-xs text-muted">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={confirmed}
|
||||||
|
onChange={(e) => onConfirm(e.target.checked)}
|
||||||
|
className="mt-0.5"
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
Ich habe das angeschaut und bestätige es. Ohne Häkchen rechnet das Tool mit der Vorgabe – sie wird als
|
||||||
|
«ungeprüft» ausgewiesen, damit sie nicht unbemerkt durchgeht.
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AhvBlock({
|
||||||
|
el,
|
||||||
|
rd,
|
||||||
|
patch,
|
||||||
|
summary,
|
||||||
|
defaultOpen,
|
||||||
|
}: {
|
||||||
|
el: ElementInput;
|
||||||
|
rd: RetirementDecision;
|
||||||
|
patch: (elementId: string, p: Partial<RetirementDecision>) => void;
|
||||||
|
summary?: RetirementPersonSummary;
|
||||||
|
defaultOpen?: boolean;
|
||||||
|
}) {
|
||||||
|
const draw = rd.ahvDraw ?? "REFERENCE";
|
||||||
|
const months = rd.ahvMonths ?? 0;
|
||||||
|
const maxMonths = draw === "EARLY" ? AHV_EARLY_MAX_MONTHS : AHV_DEFER_MAX_MONTHS;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pillar
|
||||||
|
title="AHV"
|
||||||
|
subtitle={`${ahvDrawLabel(rd)} · ${formatChf(summary?.ahvAnnual ?? 0)} / Jahr`}
|
||||||
|
confirmed={rd.confirmed === true}
|
||||||
|
onConfirm={(v) => patch(el.id, { confirmed: v })}
|
||||||
|
defaultOpen={defaultOpen}
|
||||||
|
>
|
||||||
|
<SelectField
|
||||||
|
label="Bezug"
|
||||||
|
help={`Referenzalter ist ${AHV_REFERENCE_AGE}. Ein Vorbezug kürzt die Rente lebenslang um 6,8 % pro Jahr, ein Aufschub erhöht sie (nach 5 Jahren um 31,5 %).`}
|
||||||
|
value={draw}
|
||||||
|
onChange={(v: AhvDraw) =>
|
||||||
|
patch(el.id, { ahvDraw: v, ahvMonths: v === "REFERENCE" ? 0 : months || 12 })
|
||||||
|
}
|
||||||
|
options={[
|
||||||
|
{ value: "REFERENCE", label: `Ab Referenzalter (${AHV_REFERENCE_AGE})` },
|
||||||
|
{ value: "EARLY", label: "Vorbeziehen" },
|
||||||
|
{ value: "DEFERRED", label: "Aufschieben" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
{draw !== "REFERENCE" && (
|
||||||
|
<NumberField
|
||||||
|
label={draw === "EARLY" ? "Vorbezug (Monate)" : "Aufschub (Monate)"}
|
||||||
|
help={
|
||||||
|
draw === "EARLY"
|
||||||
|
? `Höchstens ${AHV_EARLY_MAX_MONTHS} Monate (3 Jahre), seit AHV 21 monatsgenau.`
|
||||||
|
: `Mindestens 12, höchstens ${AHV_DEFER_MAX_MONTHS} Monate (5 Jahre).`
|
||||||
|
}
|
||||||
|
value={months}
|
||||||
|
min={draw === "EARLY" ? 1 : 12}
|
||||||
|
max={maxMonths}
|
||||||
|
step={1}
|
||||||
|
onChange={(v) => patch(el.id, { ahvMonths: Math.max(0, Math.min(maxMonths, Math.round(v))) })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{draw !== "REFERENCE" && summary && (
|
||||||
|
<div className="sm:col-span-2 rounded-lg bg-surface-2 px-3 py-2 text-xs text-muted">
|
||||||
|
Rente ab Alter <strong className="text-fg">{summary.ahvFromAge}</strong>:{" "}
|
||||||
|
<strong className="text-fg">{formatChf(summary.ahvAnnual)}</strong> pro Jahr. Die Beitragspflicht endet davon
|
||||||
|
unabhängig erst mit {AHV_REFERENCE_AGE} – wer vorher aufhört zu arbeiten, zahlt bis dahin als
|
||||||
|
Nichterwerbstätige(r) weiter.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="sm:col-span-2 mt-1 border-t border-border pt-3">
|
||||||
|
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-faint">Grundlage der Schätzung</p>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<MoneyField
|
||||||
|
label="Durchschnittseinkommen vor Planbeginn (brutto, heutige Kaufkraft)"
|
||||||
|
help="Aus der AHV-Rentenvorausberechnung. Bleibt das Feld leer, schätzt das Tool den Wert aus dem geplanten Durchschnitt – das ist besser als 0, aber ungenauer als dein echter Auszug."
|
||||||
|
value={rd.avgIncomeBefore ?? 0}
|
||||||
|
onChange={(v) => patch(el.id, { avgIncomeBefore: v })}
|
||||||
|
/>
|
||||||
|
<NumberField
|
||||||
|
label="Beitragslücken vor Planbeginn (Jahre)"
|
||||||
|
help="Jahre ohne AHV-Beitrag, etwa durch Auslandaufenthalt. Jedes fehlende Jahr kürzt die Rente um rund 1/44."
|
||||||
|
value={rd.gapYearsBefore ?? 0}
|
||||||
|
min={0}
|
||||||
|
max={50}
|
||||||
|
step={1}
|
||||||
|
onChange={(v) => patch(el.id, { gapYearsBefore: Math.max(0, Math.round(v)) })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Pillar>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PkBlock({
|
||||||
|
plan,
|
||||||
|
el,
|
||||||
|
rd,
|
||||||
|
patch,
|
||||||
|
summary,
|
||||||
|
defaultOpen,
|
||||||
|
}: {
|
||||||
|
plan: PlanInput;
|
||||||
|
el: ElementInput;
|
||||||
|
rd: RetirementDecision;
|
||||||
|
patch: (elementId: string, p: Partial<RetirementDecision>) => void;
|
||||||
|
summary?: RetirementPersonSummary;
|
||||||
|
defaultOpen?: boolean;
|
||||||
|
}) {
|
||||||
|
const share = rd.capitalSharePct ?? 0;
|
||||||
|
const targets = plan.elements.filter((e) => e.category === "OTHER_ASSET");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pillar
|
||||||
|
title={`Pensionskasse · ${el.name}`}
|
||||||
|
subtitle={
|
||||||
|
share === 0
|
||||||
|
? `Volle Rente · ${formatChf(summary?.pkPensionAnnual ?? 0)} / Jahr`
|
||||||
|
: share === 100
|
||||||
|
? "Volles Kapital"
|
||||||
|
: `${100 - share} % Rente / ${share} % Kapital`
|
||||||
|
}
|
||||||
|
confirmed={rd.confirmed === true}
|
||||||
|
onConfirm={(v) => patch(el.id, { confirmed: v })}
|
||||||
|
defaultOpen={defaultOpen}
|
||||||
|
>
|
||||||
|
<div className="sm:col-span-2">
|
||||||
|
<label className="mb-1 flex items-center text-xs font-medium text-muted">
|
||||||
|
Kapitalanteil
|
||||||
|
<InfoBubble text="0 % = volle Rente, 100 % = volles Kapital, alles dazwischen ist die Kombination. Bewusst eine Quote und kein Frankenbetrag: Verschiebst du das Pensionsalter, ändert sich das Guthaben – die Quote skaliert mit, ein fixer Betrag würde still ein anderes Verhältnis bedeuten." />
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
step={5}
|
||||||
|
value={share}
|
||||||
|
onChange={(e) => patch(el.id, { capitalSharePct: Number(e.target.value) })}
|
||||||
|
className="h-2 flex-1 cursor-pointer appearance-none rounded-full bg-surface-2 accent-[var(--accent)]"
|
||||||
|
/>
|
||||||
|
<span className="w-28 whitespace-nowrap text-right text-sm tabular-nums text-fg">
|
||||||
|
{share} % Kapital
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 flex justify-between text-[11px] text-faint">
|
||||||
|
<span>volle Rente</span>
|
||||||
|
<span>volles Kapital</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<NumberField
|
||||||
|
label="Umwandlungssatz (%)"
|
||||||
|
help="Aus deinem PK-Ausweis. Der gesetzliche Mindestsatz von 6,8 % gilt nur für den obligatorischen Teil – umhüllende Kassen liegen effektiv oft bei 5 bis 5,6 %, bei einer Frühpensionierung tiefer."
|
||||||
|
value={rd.conversionRate ?? 0}
|
||||||
|
step={0.1}
|
||||||
|
min={0}
|
||||||
|
max={20}
|
||||||
|
onChange={(v) => patch(el.id, { conversionRate: v })}
|
||||||
|
/>
|
||||||
|
{share > 0 && (
|
||||||
|
<NumberField
|
||||||
|
label="Kapitalbezugssteuer (%)"
|
||||||
|
help="Pauschalsatz. Die tatsächliche Steuer ist kantonal und progressiv – und alle Kapitalbezüge desselben Jahres werden zusammengezählt, bei Ehepaaren auch die des Partners. Wer staffelt, zahlt weniger."
|
||||||
|
value={rd.capitalTaxRate ?? 0}
|
||||||
|
step={0.5}
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
onChange={(v) => patch(el.id, { capitalTaxRate: v })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{share > 0 && (
|
||||||
|
<>
|
||||||
|
<div className="sm:col-span-2">
|
||||||
|
<label className="flex cursor-pointer items-start gap-2 text-xs text-muted">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={rd.recentBuyIn === true}
|
||||||
|
onChange={(e) => patch(el.id, { recentBuyIn: e.target.checked })}
|
||||||
|
className="mt-0.5"
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
In den letzten {PK_BUYIN_BLOCKING_YEARS} Jahren in die Pensionskasse eingekauft
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
{rd.recentBuyIn && (
|
||||||
|
<p className="mt-1 flex items-start gap-2 rounded-lg border border-attention bg-attention/10 px-3 py-2 text-xs text-attention-fg">
|
||||||
|
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
||||||
|
Ein Kapitalbezug innerhalb von {PK_BUYIN_BLOCKING_YEARS} Jahren nach einem Einkauf lässt den
|
||||||
|
Steuerabzug für diesen Einkauf nachträglich entfallen (Art. 79b Abs. 3 BVG). Das Tool rechnet diesen
|
||||||
|
Effekt nicht – prüfe die Fristen mit deiner Kasse.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<CapitalUse rd={rd} elementId={el.id} patch={patch} targets={targets} plan={plan} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Pillar>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Pillar3aBlock({
|
||||||
|
el,
|
||||||
|
rd,
|
||||||
|
patch,
|
||||||
|
retirementAge,
|
||||||
|
siblings,
|
||||||
|
defaultOpen,
|
||||||
|
}: {
|
||||||
|
el: ElementInput;
|
||||||
|
rd: RetirementDecision;
|
||||||
|
patch: (elementId: string, p: Partial<RetirementDecision>) => void;
|
||||||
|
retirementAge: number;
|
||||||
|
siblings: RetirementDecision[];
|
||||||
|
defaultOpen?: boolean;
|
||||||
|
}) {
|
||||||
|
const age = rd.withdrawalAge ?? retirementAge;
|
||||||
|
const clash = siblings.some((s) => (s.withdrawalAge ?? retirementAge) === age);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pillar
|
||||||
|
title={`Säule 3a · ${el.name}`}
|
||||||
|
subtitle={`Bezug mit ${age}`}
|
||||||
|
confirmed={rd.confirmed === true}
|
||||||
|
onConfirm={(v) => patch(el.id, { confirmed: v })}
|
||||||
|
defaultOpen={defaultOpen}
|
||||||
|
>
|
||||||
|
<NumberField
|
||||||
|
label="Bezugsalter"
|
||||||
|
help={`Frühestens ${PILLAR_3A_MIN_WITHDRAWAL_AGE}, spätestens ${PILLAR_3A_MAX_WITHDRAWAL_AGE} – und nach dem Referenzalter nur, solange du erwerbstätig bleibst. Der Bezug erfolgt an der ersten Phasengrenze bei oder nach diesem Alter.`}
|
||||||
|
value={age}
|
||||||
|
min={PILLAR_3A_MIN_WITHDRAWAL_AGE}
|
||||||
|
max={PILLAR_3A_MAX_WITHDRAWAL_AGE}
|
||||||
|
step={1}
|
||||||
|
onChange={(v) => patch(el.id, { withdrawalAge: Math.round(v) })}
|
||||||
|
/>
|
||||||
|
<NumberField
|
||||||
|
label="Kapitalbezugssteuer (%)"
|
||||||
|
help="Pauschalsatz. Alle Kapitalbezüge desselben Jahres werden zusammengezählt – deshalb lohnt sich das Staffeln über mehrere Jahre und mehrere Konten."
|
||||||
|
value={rd.capitalTaxRate ?? 0}
|
||||||
|
step={0.5}
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
onChange={(v) => patch(el.id, { capitalTaxRate: v })}
|
||||||
|
/>
|
||||||
|
<div className="sm:col-span-2 rounded-lg bg-surface-2 px-3 py-2 text-xs text-muted">
|
||||||
|
Ein 3a-Konto lässt sich bei der Pensionierung nur <strong className="text-fg">ganz</strong> auflösen. Gestaffelt
|
||||||
|
wird über mehrere Konten mit unterschiedlichen Bezugsjahren.
|
||||||
|
{age > AHV_REFERENCE_AGE && (
|
||||||
|
<>
|
||||||
|
{" "}
|
||||||
|
Ein Bezug nach {AHV_REFERENCE_AGE} setzt voraus, dass du weiterhin erwerbstätig bist.
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{clash && (
|
||||||
|
<p className="sm:col-span-2 flex items-start gap-2 text-xs text-attention-fg">
|
||||||
|
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
||||||
|
Ein weiteres 3a-Konto wird im selben Jahr bezogen. Die Beträge werden steuerlich zusammengezählt – ein
|
||||||
|
anderes Bezugsjahr senkt die Progression.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</Pillar>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verwendung des bezogenen Kapitals (Punkt C). Dieselbe Frage wie in der Matrix-Zelle -- hier
|
||||||
|
// nur an dem Ort, an dem man ohnehin über den Bezug nachdenkt.
|
||||||
|
function CapitalUse({
|
||||||
|
rd,
|
||||||
|
elementId,
|
||||||
|
patch,
|
||||||
|
targets,
|
||||||
|
plan,
|
||||||
|
}: {
|
||||||
|
rd: RetirementDecision;
|
||||||
|
elementId: string;
|
||||||
|
patch: (elementId: string, p: Partial<RetirementDecision>) => void;
|
||||||
|
targets: ElementInput[];
|
||||||
|
plan: PlanInput;
|
||||||
|
}) {
|
||||||
|
const amort = Math.max(0, Math.min(100, rd.capitalUseAmortizationPct ?? 0));
|
||||||
|
const invest = Math.max(0, Math.min(100 - amort, rd.capitalUseInvestPct ?? 0));
|
||||||
|
const cash = Math.max(0, 100 - amort - invest);
|
||||||
|
const hasMortgage = plan.elements.some((e) => e.category === "REAL_ESTATE");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="sm:col-span-2 mt-1 border-t border-border pt-3">
|
||||||
|
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-faint">
|
||||||
|
Wohin fliesst das bezogene Kapital?
|
||||||
|
</p>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<NumberField
|
||||||
|
label="… in die Hypothek (%)"
|
||||||
|
help={
|
||||||
|
hasMortgage
|
||||||
|
? "Einmalige Amortisation. Am Restsaldo gekappt – ist die Hypothek kleiner, bleibt der Rest Cash."
|
||||||
|
: "Es gibt keine Immobilie in diesem Szenario – dieser Anteil bliebe wirkungslos auf dem Cash."
|
||||||
|
}
|
||||||
|
value={amort}
|
||||||
|
step={5}
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
onChange={(v) => patch(elementId, { capitalUseAmortizationPct: Math.max(0, Math.min(100, v)) })}
|
||||||
|
/>
|
||||||
|
<NumberField
|
||||||
|
label="… in die Anlage (%)"
|
||||||
|
help="Fliesst als Zusatzeinlage in das gewählte Vermögens-Element und wächst dort weiter."
|
||||||
|
value={invest}
|
||||||
|
step={5}
|
||||||
|
min={0}
|
||||||
|
max={100 - amort}
|
||||||
|
onChange={(v) => patch(elementId, { capitalUseInvestPct: Math.max(0, Math.min(100 - amort, v)) })}
|
||||||
|
/>
|
||||||
|
{invest > 0 && targets.length > 0 && (
|
||||||
|
<div className="sm:col-span-2">
|
||||||
|
<SelectField
|
||||||
|
label="Ziel der Anlage-Quote"
|
||||||
|
value={rd.capitalUseTargetElementId ?? targets[0].id}
|
||||||
|
onChange={(v: string) => patch(elementId, { capitalUseTargetElementId: v })}
|
||||||
|
options={targets.map((t) => ({
|
||||||
|
value: t.id,
|
||||||
|
label: `${t.name} · ${ownerLabel(plan.persons, t.ownerRole)}`,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{invest > 0 && targets.length === 0 && (
|
||||||
|
<p className="sm:col-span-2 flex items-start gap-2 text-xs text-attention-fg">
|
||||||
|
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
||||||
|
Es gibt kein Element «Sonstiges Vermögen», in das die Anlage-Quote fliessen könnte. Der Betrag bliebe auf
|
||||||
|
dem Cash-Konto liegen.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p className="sm:col-span-2 text-xs text-faint">
|
||||||
|
Nicht zugeteilt: <strong className="text-fg">{cash} %</strong> – bleibt auf dem Cash-Konto.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,338 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
// Der Pensionierungs-Bildschirm.
|
||||||
|
//
|
||||||
|
// Bis 0.33 lagen die Entscheide, die inhaltlich EINE Frage sind, in drei weit auseinander
|
||||||
|
// liegenden Matrix-Zellen (AHV, PK, 3a) am Pensions-Übergang. Hier stehen sie beieinander --
|
||||||
|
// in der Reihenfolge, in der man tatsächlich darüber nachdenkt:
|
||||||
|
//
|
||||||
|
// Wann höre ich auf? → Was kommt dann rein? → Was habe ich auf einen Schlag?
|
||||||
|
// → Reicht das? → Was mache ich mit dem Haufen?
|
||||||
|
//
|
||||||
|
// Die Leitzahl ganz oben ist die RENTENLÜCKE. Sie ist keine neue Rechnung, sondern die
|
||||||
|
// Verzehrquote im ersten voll pensionierten Jahr -- es fehlte bisher nur der Name dafür.
|
||||||
|
// Gerechnet wird sie im Rechenkern (`computed.retirement`), damit Bildschirm und PDF-Bericht
|
||||||
|
// nicht auseinanderlaufen können.
|
||||||
|
//
|
||||||
|
// Gestaltungsprinzip: KEIN leeres Formular, sondern ein vollständiger Vorschlag, den man
|
||||||
|
// korrigiert. Sonst müsste man am Anfang Fragen beantworten, die man erst am Ende beantworten
|
||||||
|
// kann -- und ohne Vorgaben wäre der Plan bis dahin gar nicht rechenbar.
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { AlertTriangle } from "lucide-react";
|
||||||
|
import { api } from "@/lib/api-client";
|
||||||
|
import { Button, useToast } from "@/components/ui";
|
||||||
|
import { InfoBubble } from "@/components/InfoBubble";
|
||||||
|
import { NumberField } from "@/components/FormField";
|
||||||
|
import { RetirementAdjuster } from "@/components/RetirementAdjuster";
|
||||||
|
import { RetirementFields } from "@/components/RetirementFields";
|
||||||
|
import { formatChf } from "@/lib/format";
|
||||||
|
import { ownerLabel } from "@/lib/elements";
|
||||||
|
import {
|
||||||
|
AHV_REFERENCE_AGE,
|
||||||
|
MAX_PLANNING_HORIZON_AGE,
|
||||||
|
MIN_PLANNING_HORIZON_AGE,
|
||||||
|
PK_MIN_RETIREMENT_AGE,
|
||||||
|
} from "@/lib/constants";
|
||||||
|
import { withRetirementDefaults, type RetirementDecision } from "@/lib/retirement-decision";
|
||||||
|
import type { PlanComputed, RetirementPersonSummary } from "@/lib/calculations";
|
||||||
|
import type { ElementInput, PersonRole, PlanInput } from "@/lib/types";
|
||||||
|
|
||||||
|
type Draft = Record<string, RetirementDecision>;
|
||||||
|
|
||||||
|
export function RetirementPanel({
|
||||||
|
plan,
|
||||||
|
computed,
|
||||||
|
onSaved,
|
||||||
|
}: {
|
||||||
|
plan: PlanInput;
|
||||||
|
computed: PlanComputed;
|
||||||
|
onSaved: () => void;
|
||||||
|
}) {
|
||||||
|
const toast = useToast();
|
||||||
|
const [draft, setDraft] = useState<Draft>({});
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Effektiver Entscheid je Element: gespeicherter Wert, überlagert vom Entwurf, aufgefüllt
|
||||||
|
// mit den Vorgaben. Genau das rechnet auch der Rechenkern.
|
||||||
|
const effective = (el: ElementInput): RetirementDecision => {
|
||||||
|
const person = plan.persons.find((p) => p.role === el.ownerRole);
|
||||||
|
return withRetirementDefaults(el.category, person?.retirementAge ?? AHV_REFERENCE_AGE, {
|
||||||
|
...(el.retirementDecision ?? {}),
|
||||||
|
...(draft[el.id] ?? {}),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const patch = (elementId: string, p: Partial<RetirementDecision>) =>
|
||||||
|
setDraft((d) => ({ ...d, [elementId]: { ...(d[elementId] ?? {}), ...p } }));
|
||||||
|
|
||||||
|
const dirty = Object.keys(draft).length > 0;
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
for (const [elementId, changes] of Object.entries(draft)) {
|
||||||
|
const el = plan.elements.find((x) => x.id === elementId);
|
||||||
|
if (!el) continue;
|
||||||
|
await api.put(`/api/elements/${elementId}/retirement`, {
|
||||||
|
...(el.retirementDecision ?? {}),
|
||||||
|
...changes,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setDraft({});
|
||||||
|
toast("success", "Pensionierung gespeichert.");
|
||||||
|
onSaved();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "Speichern fehlgeschlagen.");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-5">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-fg">Pensionierung</h2>
|
||||||
|
<p className="mt-1 text-sm text-muted">
|
||||||
|
Alle Entscheide zu AHV, Pensionskasse und Säule 3a an einem Ort. Was du hier festlegst, steht auch in der
|
||||||
|
Matrix am Pensions-Übergang – es ist derselbe Entscheid, nur anders angeschaut.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{plan.persons.map((person) => {
|
||||||
|
const summary = computed.retirement.perPerson.find((s) => s.role === person.role);
|
||||||
|
return (
|
||||||
|
<PersonBlock
|
||||||
|
key={person.role}
|
||||||
|
plan={plan}
|
||||||
|
computed={computed}
|
||||||
|
role={person.role}
|
||||||
|
summary={summary}
|
||||||
|
effective={effective}
|
||||||
|
patch={patch}
|
||||||
|
onSaved={onSaved}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{error && <p className="text-sm text-danger">{error}</p>}
|
||||||
|
{dirty && (
|
||||||
|
<div className="sticky bottom-0 flex items-center gap-3 border-t border-border bg-surface px-1 py-3">
|
||||||
|
<Button disabled={saving} onClick={save}>
|
||||||
|
{saving ? "…" : "Änderungen speichern"}
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" disabled={saving} onClick={() => setDraft({})}>
|
||||||
|
Verwerfen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- ein Block je Person -------------------------------------------------------------------
|
||||||
|
|
||||||
|
function PersonBlock({
|
||||||
|
plan,
|
||||||
|
computed,
|
||||||
|
role,
|
||||||
|
summary,
|
||||||
|
effective,
|
||||||
|
patch,
|
||||||
|
onSaved,
|
||||||
|
}: {
|
||||||
|
plan: PlanInput;
|
||||||
|
computed: PlanComputed;
|
||||||
|
role: PersonRole;
|
||||||
|
summary: RetirementPersonSummary | undefined;
|
||||||
|
effective: (el: ElementInput) => RetirementDecision;
|
||||||
|
patch: (elementId: string, p: Partial<RetirementDecision>) => void;
|
||||||
|
onSaved: () => void;
|
||||||
|
}) {
|
||||||
|
const person = plan.persons.find((p) => p.role === role)!;
|
||||||
|
const label = ownerLabel(plan.persons, role);
|
||||||
|
const own = (cat: string) => plan.elements.filter((e) => e.category === cat && e.ownerRole === role);
|
||||||
|
const ahvEl = own("AHV")[0];
|
||||||
|
const pkEls = own("PENSION_FUND");
|
||||||
|
const a3Els = own("PILLAR_3A");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="rounded-2xl border border-border">
|
||||||
|
<header className="border-b border-border px-4 py-3">
|
||||||
|
<div className="flex flex-wrap items-baseline justify-between gap-2">
|
||||||
|
<h3 className="text-base font-semibold text-fg">
|
||||||
|
{label} · Pensionierung mit {person.retirementAge}
|
||||||
|
</h3>
|
||||||
|
{person.retirementAge < PK_MIN_RETIREMENT_AGE && (
|
||||||
|
<span className="flex items-center gap-1 text-xs text-attention-fg">
|
||||||
|
<AlertTriangle className="h-3.5 w-3.5" />
|
||||||
|
Vor {PK_MIN_RETIREMENT_AGE} lässt kaum eine Pensionskasse eine Pensionierung zu.
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="grid gap-4 px-4 py-4 md:grid-cols-2">
|
||||||
|
<RetirementAdjuster plan={plan} onSaved={onSaved} />
|
||||||
|
<HorizonControl plan={plan} role={role} onSaved={onSaved} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<GapBox plan={plan} computed={computed} summary={summary} />
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2 px-4 pb-4">
|
||||||
|
{[ahvEl, ...pkEls, ...a3Els].filter(Boolean).map((el) => (
|
||||||
|
<RetirementFields key={el!.id} plan={plan} el={el!} rd={effective(el!)} patch={patch} summary={summary} />
|
||||||
|
))}
|
||||||
|
{!ahvEl && pkEls.length === 0 && a3Els.length === 0 && (
|
||||||
|
<p className="rounded-lg border border-dashed border-border bg-surface-2 p-3 text-sm text-muted">
|
||||||
|
Für {label} sind noch keine Vorsorge-Elemente erfasst. Lege in der Matrix AHV, Pensionskasse oder Säule 3a
|
||||||
|
an – die Entscheide dazu erscheinen dann hier.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Leitzahlen ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function GapBox({
|
||||||
|
plan,
|
||||||
|
computed,
|
||||||
|
summary,
|
||||||
|
}: {
|
||||||
|
plan: PlanInput;
|
||||||
|
computed: PlanComputed;
|
||||||
|
summary: RetirementPersonSummary | undefined;
|
||||||
|
}) {
|
||||||
|
const r = computed.retirement;
|
||||||
|
const startYear = plan.startYear ?? null;
|
||||||
|
const horizon = summary?.planningHorizonAge ?? null;
|
||||||
|
const reachesEnd = computed.ruinAge === null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-4 mb-4 rounded-xl border border-border bg-surface-2 p-4">
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div className="flex flex-col gap-1 text-sm">
|
||||||
|
<Line label="AHV-Rente" value={summary?.ahvAnnual ?? 0} note={summary?.ahvDraw} />
|
||||||
|
<Line label="PK-Rente" value={summary?.pkPensionAnnual ?? 0} />
|
||||||
|
<div className="my-1 border-t border-border" />
|
||||||
|
<Line label="Renteneinkommen (Haushalt)" value={r.pensionIncome ?? 0} strong />
|
||||||
|
<Line label="Ausgaben (Haushalt)" value={r.expenses ?? 0} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col justify-center gap-3">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center text-xs uppercase tracking-wide text-faint">
|
||||||
|
Rentenlücke
|
||||||
|
<InfoBubble text="Renteneinkommen minus Ausgaben im ersten Jahr, in dem niemand mehr arbeitet. Ein negativer Wert ist normal – er wird aus dem Vermögen gedeckt. Entscheidend ist, wie lange das trägt." />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`text-2xl font-semibold tabular-nums ${
|
||||||
|
(r.gapAnnual ?? 0) < 0 ? "text-danger" : "text-success"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{r.gapAnnual === null ? "–" : `${formatChf(r.gapAnnual)} / Jahr`}
|
||||||
|
</div>
|
||||||
|
{r.firstRetirementYear !== null && startYear && (
|
||||||
|
<div className="text-xs text-faint">
|
||||||
|
gerechnet für {startYear + r.firstRetirementYear - 1}, das erste voll pensionierte Jahr
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs uppercase tracking-wide text-faint">Vermögen reicht</div>
|
||||||
|
<div className={`text-lg font-semibold ${reachesEnd ? "text-success" : "text-danger"}`}>
|
||||||
|
{reachesEnd
|
||||||
|
? horizon
|
||||||
|
? `bis zum Horizont (Alter ${horizon})`
|
||||||
|
: "über die ganze Planung"
|
||||||
|
: `bis Alter ${computed.ruinAge}`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{summary && summary.capitalAtRetirement > 0 && (
|
||||||
|
<div className="text-xs text-muted">
|
||||||
|
Einmalig verfügbar: <strong className="text-fg">{formatChf(summary.capitalAtRetirement)}</strong> netto
|
||||||
|
aus PK und Säule 3a
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Line({ label, value, note, strong }: { label: string; value: number; note?: string; strong?: boolean }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-baseline justify-between gap-3">
|
||||||
|
<span className={strong ? "font-semibold text-fg" : "text-muted"}>
|
||||||
|
{label}
|
||||||
|
{note && <span className="ml-1 text-xs text-faint">({note})</span>}
|
||||||
|
</span>
|
||||||
|
<span className={`tabular-nums ${strong ? "font-semibold text-fg" : "text-fg"}`}>{formatChf(value)}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Planungshorizont ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
function HorizonControl({ plan, role, onSaved }: { plan: PlanInput; role: PersonRole; onSaved: () => void }) {
|
||||||
|
const person = plan.persons.find((p) => p.role === role)!;
|
||||||
|
const [value, setValue] = useState(person.planningHorizonAge ?? null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const toast = useToast();
|
||||||
|
// Ohne erfassten Horizont: das Alter, bei dem der Plan heute faktisch endet.
|
||||||
|
const implied = person.age + plan.phases.reduce((s, p) => s + p.durationYears, 0);
|
||||||
|
|
||||||
|
async function submit(next: number) {
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await api.post(`/api/scenarios/${plan.id}/horizon`, { role, horizonAge: next });
|
||||||
|
toast("success", `Planungshorizont auf Alter ${next} gesetzt.`);
|
||||||
|
onSaved();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "Nicht möglich.");
|
||||||
|
setValue(person.planningHorizonAge ?? null);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-border p-3">
|
||||||
|
<h4 className="mb-2 flex items-center text-sm font-semibold text-fg">
|
||||||
|
Planungshorizont
|
||||||
|
<InfoBubble text="Bis zu welchem Alter gerechnet wird. Die letzte Lebensphase passt sich an. Ohne diesen Wert ergibt sich das Planende stillschweigend aus der Summe der Phasendauern – zwei Szenarien rechnen dann womöglich unbemerkt verschieden weit und sind nicht vergleichbar." />
|
||||||
|
</h4>
|
||||||
|
<div className="flex items-end gap-2">
|
||||||
|
<div className="flex-1">
|
||||||
|
<NumberField
|
||||||
|
label="Alter"
|
||||||
|
value={value ?? implied}
|
||||||
|
min={MIN_PLANNING_HORIZON_AGE}
|
||||||
|
max={MAX_PLANNING_HORIZON_AGE}
|
||||||
|
step={1}
|
||||||
|
onChange={setValue}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
disabled={busy || value === null || value === person.planningHorizonAge}
|
||||||
|
onClick={() => value !== null && submit(value)}
|
||||||
|
>
|
||||||
|
Setzen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{person.planningHorizonAge === null && (
|
||||||
|
<p className="mt-1 text-xs text-faint">
|
||||||
|
Noch nicht gesetzt – der Plan endet aktuell rechnerisch mit Alter {implied}.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{error && <p className="mt-1 text-xs text-danger">{error}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
+66
-11
@@ -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
@@ -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 ||
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user