Compare commits

...

19 Commits

Author SHA1 Message Date
admGitAICDS a3c86fc559 Zeitachse als Zeitstrahl: Beschriftung alternierend, Startjahr markant, aktive Phase hervorgehoben
Deploy App / deploy (push) Successful in 1m11s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 22:20:12 +02:00
admGitAICDS 5842a84b20 Menue schiebt statt zu ueberlappen, Karte schmaler mit Seitenpfeilen, Zeitachse neu, Cash inline
Deploy App / deploy (push) Successful in 1m9s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 22:07:44 +02:00
admGitAICDS ff862ed030 Menueleiste als eine Komponente in zwei Breiten, Kartenansicht wieder erreichbar
Deploy App / deploy (push) Successful in 1m15s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 21:47:28 +02:00
admGitAICDS f41c3614b5 Kartenansicht je Lebensphase, Icon-Schiene links, Steuerung rechts
Deploy App / deploy (push) Successful in 1m10s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 21:29:42 +02:00
admGitAICDS 113616cc1f Memo 2026081701: Kapitalverwendung mit waehlbarem Tilgungsziel, AHV automatisch, Farb- und Textkorrekturen
Deploy App / deploy (push) Successful in 1m18s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 20:04:45 +02:00
admGitAICDS f9d7ca07fd Stammdaten gewinnen in Phase 1, Bestaetigung je Zeile statt pauschal
Deploy App / deploy (push) Successful in 1m10s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 21:41:07 +02:00
admGitAICDS ce987b9978 Ein Wert, ein Ort: Startwerte und Raten read-only, Verteil-Dialoge als einziger Eingabeort
Deploy App / deploy (push) Successful in 1m13s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 21:17:39 +02:00
admGitAICDS 5ff9bc0709 Bestandsaufnahme: Anlegen schliesst den Dialog nicht mehr
Deploy App / deploy (push) Successful in 1m12s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 20:17:34 +02:00
admGitAICDS f22b0a3f27 Uebersicht der offenen Punkte statt Assistent, Bestaetigung je Phasenzelle
Deploy App / deploy (push) Successful in 1m55s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 15:02:15 +02:00
admGitAICDS 6ff144d7e1 Assistent auf zwei Schritte, Pensionsalter fix 65, Horizont abgeleitet
Deploy App / deploy (push) Successful in 1m57s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 12:18:56 +02:00
admGitAICDS 2f6b788bd4 Bestandsaufnahme: Kategorienamen, Sammel-Speichern, Matrix-Spalte Start
Deploy App / deploy (push) Successful in 1m39s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 11:37:16 +02:00
admGitAICDS 42d5585969 Assistent als Herzstueck: Tests und Spezifikation 0.36
Deploy App / deploy (push) Successful in 2m2s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 22:33:46 +02:00
admGitAICDS c440063a93 Matrix-Steuerung eingebettet, Tour als Demo-Popup
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 22:28:56 +02:00
admGitAICDS 79fb2b9ca6 Szenario-Screen mit vier Kacheln, FPT-Assistent, Onboarding auf einen Knopf
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 22:23:13 +02:00
admGitAICDS 2f762175d2 Fundament: Element-Stammdaten, Fixpunkte, Horizont in Jahren, PK-Bezugsalter
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 22:06:04 +02:00
admGitAICDS cc61a120ee Assistent: Ueberblicksschritt Pensionierung mit gerechneter Vorschau
Deploy App / deploy (push) Successful in 1m6s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 14:19:31 +02:00
admGitAICDS 0f656f4070 Pensionierung als Eigenschaft der Person: Treiber, Tests, Spezifikation 0.35
Deploy App / deploy (push) Successful in 1m57s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 13:52:40 +02:00
admGitAICDS 1865db5de7 Pensionierungs-Bildschirm, Ampel mit drei Zustaenden, Planungshorizont
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 13:37:08 +02:00
admGitAICDS 9907fda6f4 Pensionierung: Entscheid ans Element, AHV-Vorbezug/Aufschub, PK-Kapitalquote
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 13:18:38 +02:00
47 changed files with 7510 additions and 2628 deletions
+874 -142
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,18 @@
-- Pensionierung wird eine Eigenschaft der PERSON statt der Zeitachse (SPEZIFIKATION 3.13).
--
-- (1) `FinancialElement.retirementDecision` haelt den Bezugs-Entscheid von AHV, Pensionskasse
-- und Saeule 3a. Bisher lag er in `ElementTransitionValue` -- also am Schluessel
-- Element x Phasen-ID. Verschob sich das Pensionsalter, wanderte die Phasengrenze, und der
-- Entscheid musste verlustbehaftet von Grenze zu Grenze gerettet werden. Ohne Phasen-ID im
-- Schluessel ueberlebt er jede Zeitachsen-Aenderung.
--
-- (2) `Person.planningHorizonAge` macht das Planende explizit. Bisher ergab es sich
-- stillschweigend als Summe der Phasendauern -- zwei Szenarien konnten dadurch unbemerkt
-- verschieden weit rechnen und waren nicht vergleichbar. NULL bedeutet weiterhin
-- "aus den Phasendauern ableiten", der Wert ist also rueckwaertskompatibel.
--
-- Bewusst OHNE Datenmigration: Alte Entscheide in `ElementTransitionValue` bleiben liegen und
-- werden vom Rechenkern ignoriert. Die betroffenen Elemente erscheinen dadurch als
-- "Vorgabe ungeprueft" -- genau der Zustand, den das neue Drei-Zustands-Modell dafuer kennt.
ALTER TABLE "FinancialElement" ADD COLUMN "retirementDecision" JSONB;
ALTER TABLE "Person" ADD COLUMN "planningHorizonAge" INTEGER;
@@ -0,0 +1,19 @@
-- Umbau 0.36: Bestandsaufnahme vor der Zeitachse, Assistent als Herzstueck.
--
-- (1) `FinancialElement.baseData` haelt den Bestand bei PLANBEGINN und die Ausgangs-Annahmen.
-- Bisher lagen diese Werte in `ElementPhaseValue` der ersten Phase. Das war schon immer
-- schief -- ein Startwert ist nicht "phase-1-spezifisch", sondern der Stand am Anfang --
-- und es machte den ersten Schritt des Assistenten unmoeglich: Eine Bestandsaufnahme
-- braucht noch keine Lebensphasen. Zugleich ist es die Wurzel der Feld-Vererbung.
--
-- (2) `Scenario.planningHorizonYears` fuehrt den Horizont in JAHREN statt als Endalter je
-- Person. Eine Zahl statt zweier, die bei einem Paar auseinanderlaufen koennten; die
-- Endalter werden abgeleitet. Ersetzt `Person.planningHorizonAge` aus 0.35.
--
-- (3) `Scenario.assistantProgress` haelt die sieben Haken des FPT-Assistenten.
--
-- Bewusst OHNE Datenmigration (Plaene wurden vorgaengig geloescht).
ALTER TABLE "FinancialElement" ADD COLUMN "baseData" JSONB;
ALTER TABLE "Scenario" ADD COLUMN "planningHorizonYears" INTEGER;
ALTER TABLE "Scenario" ADD COLUMN "assistantProgress" JSONB;
ALTER TABLE "Person" DROP COLUMN IF EXISTS "planningHorizonAge";
@@ -0,0 +1,8 @@
-- Der Planungshorizont wird ABGELEITET statt erfasst (SPEZIFIKATION 3.14.5).
--
-- Dritter und letzter Anlauf: 0.35 fuehrte ihn als Endalter je Person, 0.36 als Jahreszahl am
-- Szenario -- beide Male eine zweite Wahrheit ueber dieselbe Sache. Wer die Lebensphasen
-- einzeln plant, hat den Horizont damit bereits bestimmt; ihn zusaetzlich zu speichern hiess
-- nur, zwei Zahlen synchron halten zu muessen. Er ist jetzt schlicht die Summe der
-- Phasendauern.
ALTER TABLE "Scenario" DROP COLUMN IF EXISTS "planningHorizonYears";
@@ -0,0 +1,18 @@
-- Der FPT-Assistent wird durch eine Uebersicht der OFFENEN PUNKTE ersetzt (SPEZIFIKATION 3.14).
--
-- Der Unterschied ist grundsaetzlich: Der Assistent war ein ABLAUF ("tu dies, dann das") und
-- funktionierte nur beim ersten Aufsetzen. Die Uebersicht ist ein ZUSTAND ("das ist noch
-- offen") und traegt bei jedem Plan, in jeder Reihenfolge, auch beim zwanzigsten Szenario.
--
-- (1) `Phase.ratesConfirmed` haelt fest, ob die Spar-/Verzehrquote dieser Phase einmal bewusst
-- verteilt wurde. Was man nicht verteilt, sammelt sich still als Cash an -- rechnerisch
-- richtig, aber selten die Absicht. Der Haken unterscheidet "bewusst so gelassen" von
-- "noch nie angeschaut".
--
-- (2) `Scenario.assistantProgress` entfaellt mit dem Assistenten. Der Fortschritt wird nicht
-- mehr erklaert, sondern ABGELEITET -- aus dem, was tatsaechlich noch offen ist.
--
-- Die Bestaetigung der Phasenwerte je Element liegt als `confirmed` im vorhandenen JSON von
-- `ElementPhaseValue` und braucht deshalb keine Spalte.
ALTER TABLE "Phase" ADD COLUMN "ratesConfirmed" BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE "Scenario" DROP COLUMN IF EXISTS "assistantProgress";
+21
View File
@@ -76,6 +76,8 @@ model Person {
scenarioId String scenarioId String
scenario Scenario @relation(fields: [scenarioId], references: [id], onDelete: Cascade) scenario Scenario @relation(fields: [scenarioId], references: [id], onDelete: Cascade)
role PersonRole role PersonRole
// Alter, in dem die Person die Erwerbstaetigkeit aufgibt. Wird bei der Plan-Anlage nicht
// mehr abgefragt (Default 65) -- die Pensionsplanung im Assistenten legt es fest.
retirementAge Int retirementAge Int
@@unique([scenarioId, role]) @@unique([scenarioId, role])
@@ -220,6 +222,8 @@ model Scenario {
inflationRateDefault Float inflationRateDefault Float
initialCash Float @default(0) initialCash Float @default(0)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -278,6 +282,11 @@ model Phase {
// und nicht in ElementTransitionValue, weil Cash kein FinancialElement ist. // und nicht in ElementTransitionValue, weil Cash kein FinancialElement ist.
cashTransition Json? cashTransition Json?
// Wurde die Spar-/Verzehrquote dieser Phase einmal bewusst verteilt? Was man nicht verteilt,
// sammelt sich still als Cash an -- rechnerisch richtig, aber selten die Absicht. Der Haken
// unterscheidet "bewusst so gelassen" von "noch nie angeschaut".
ratesConfirmed Boolean @default(false)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -301,6 +310,18 @@ model FinancialElement {
// Gegenstueck im Eltern-Szenario (lose Referenz, kein FK) -- Grundlage des Diffs. // Gegenstueck im Eltern-Szenario (lose Referenz, kein FK) -- Grundlage des Diffs.
sourceElementId String? sourceElementId String?
// Pensionierungs-Entscheid (AHV/PK/3a). Bewusst OHNE Phasen-ID im Schluessel: So ueberlebt
// er jede Verschiebung der Zeitachse. Siehe src/lib/retirement-decision.ts.
retirementDecision Json?
// Stammdaten des Elements: der Bestand bei PLANBEGINN und die Ausgangs-Annahmen (Rendite,
// Wertsteigerung, Zins). Zwei Gruende dafuer, dass das nicht in Phase 1 liegt:
// 1. Ein Startwert ist nicht "phase-1-spezifisch", sondern schlicht der Stand am Anfang.
// 2. Elemente lassen sich damit erfassen, BEVOR es Lebensphasen gibt -- der erste Schritt
// des Assistenten ist eine Bestandsaufnahme und braucht noch keine Zeitachse.
// Zugleich die Wurzel der Feld-Vererbung: Phase 1 erbt von hier.
baseData Json?
phaseValues ElementPhaseValue[] phaseValues ElementPhaseValue[]
transitionValues ElementTransitionValue[] transitionValues ElementTransitionValue[]
} }
@@ -0,0 +1,35 @@
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 { phaseDataSchema } from "@/lib/elements";
// Stammdaten eines Elements: der Bestand bei PLANBEGINN und die Ausgangs-Annahmen.
//
// Bewusst ohne Phasenbezug. Zwei Gründe: Ein Startwert ist nicht «phase-1-spezifisch»,
// sondern schlicht der Stand am Anfang -- und Elemente lassen sich damit erfassen, BEVOR es
// Lebensphasen gibt. Genau das braucht der erste Schritt des Assistenten: eine
// Bestandsaufnahme, die noch keine Zeitachse voraussetzt.
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 });
const parsed = phaseDataSchema.safeParse(await request.json());
if (!parsed.success) return NextResponse.json({ error: "Ungültige Eingabe." }, { status: 400 });
await prisma.financialElement.update({
where: { id: elementId },
data: { baseData: parsed.data },
});
await touchScenario(element.scenarioId, userId);
return NextResponse.json({ ok: true });
}
@@ -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 });
}
+3
View File
@@ -10,6 +10,8 @@ import { planDurationChange } from "@/lib/phaseplan";
const updatePhaseSchema = z.object({ const updatePhaseSchema = z.object({
name: z.string().min(1).max(120).optional(), name: z.string().min(1).max(120).optional(),
durationYears: z.number().int().min(1).max(80).optional(), durationYears: z.number().int().min(1).max(80).optional(),
// Die Spar-/Verzehrquote dieser Phase wurde bewusst verteilt (SPEZIFIKATION 3.14).
ratesConfirmed: z.boolean().optional(),
}); });
export async function PUT( export async function PUT(
@@ -58,6 +60,7 @@ export async function PUT(
data: { data: {
name: parsed.data.name ?? undefined, name: parsed.data.name ?? undefined,
durationYears: duration ?? undefined, durationYears: duration ?? undefined,
ratesConfirmed: parsed.data.ratesConfirmed ?? undefined,
}, },
}); });
if (neighbour) { if (neighbour) {
@@ -64,6 +64,10 @@ 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,
baseData: el.baseData ?? undefined,
sourceElementId: el.id, sourceElementId: el.id,
}, },
}); });
File diff suppressed because it is too large Load Diff
+67 -8
View File
@@ -21,6 +21,7 @@ import { CarryWarning } from "@/components/ElementDetail";
import { api } from "@/lib/api-client"; import { api } from "@/lib/api-client";
import { formatChf } from "@/lib/format"; import { formatChf } from "@/lib/format";
import { computePlan } from "@/lib/calculations"; import { computePlan } from "@/lib/calculations";
import { PILLAR_3A_MAX_ANNUAL, PILLAR_3A_MAX_SELF_EMPLOYED } from "@/lib/constants";
import { import {
CATEGORY_LABELS, CATEGORY_LABELS,
inheritedPhaseValues, inheritedPhaseValues,
@@ -368,8 +369,14 @@ interface RateTarget {
owner: string; owner: string;
field: "annualContribution" | "annualWithdrawal" | "amortization" | "annualRepayment"; field: "annualContribution" | "annualWithdrawal" | "amortization" | "annualRepayment";
label: string; label: string;
direction: "out" | "in"; // "out"/"in" = fliesst aus der bzw. in die Quote. "gross" = weder noch: der PK-Beitrag
// stammt aus dem BRUTTOLOHN und berührt das Cash-Konto nicht. Er steht seit 0.40 trotzdem
// hier, weil er sonst nirgends mehr erfassbar wäre -- aber in einem eigenen Block, damit
// niemand ihn für einen Teil der Quote hält.
direction: "out" | "in" | "gross";
hint: string; hint: string;
// Obergrenze (Säule 3a). Ohne sie liesse sich ein steuerlich unzulässiger Betrag erfassen.
max?: number;
} }
export function RateDistributionDialog({ export function RateDistributionDialog({
@@ -387,6 +394,13 @@ export function RateDistributionDialog({
}) { }) {
const phase = computed.phases.find((p) => p.id === phaseId)!; const phase = computed.phases.find((p) => p.id === phaseId)!;
// «Grosse Säule 3a» je Element: bestimmt die Obergrenze der Einzahlung. Der Schalter steht
// seit 0.40 hier statt in der Phasenzelle -- getrennt von dem Feld, dessen Grenze er
// festlegt, war er eine Einstellung ohne sichtbare Wirkung.
const [flags, setFlags] = useState<Record<string, boolean>>(() =>
Object.fromEntries(plan.elements.map((e) => [e.id, !!e.phaseValues[phaseId]?.selfEmployed3a]))
);
const targets = useMemo<RateTarget[]>(() => { const targets = useMemo<RateTarget[]>(() => {
const out: RateTarget[] = []; const out: RateTarget[] = [];
for (const e of plan.elements) { for (const e of plan.elements) {
@@ -396,13 +410,23 @@ export function RateDistributionDialog({
// Die Zeile hier trotzdem anzubieten hiess, den Entscheid aus dem Übergang zu // Die Zeile hier trotzdem anzubieten hiess, den Entscheid aus dem Übergang zu
// widersprechen -- inklusive eines geerbten Betrags, der wirkungslos weiterläuft. // widersprechen -- inklusive eines geerbten Betrags, der wirkungslos weiterläuft.
if (ce.acceptsCapital === false) continue; if (ce.acceptsCapital === false) continue;
// Die PK ist bewusst NICHT dabei: Ihr Beitrag stammt aus dem Bruttolohn und belastet if (e.category === "PENSION_FUND") {
// das Cash-Konto nicht -- er lässt sich also gar nicht aus der Quote verteilen. // Der PK-Beitrag ist NICHT Teil der Quote (er stammt aus dem Bruttolohn). Er steht in
if (e.category === "PILLAR_3A") { // einem eigenen Block -- aber er steht hier, weil dies seit 0.40 der einzige Ort ist,
// an dem jährliche Beträge entstehen. Ohne ihn wäre er unerreichbar und fiele still
// auf 0, obwohl er das Endvermögen um sechsstellige Beträge bewegt. Eine bereits
// bezogene PK ist oben über `acceptsCapital` schon ausgeschieden.
out.push({
elementId: e.id, name: e.name, category: e.category, owner: ownerLabel(plan.persons, e.ownerRole), field: "annualContribution",
label: "Jährliche Einzahlung", direction: "gross",
hint: "Arbeitnehmer- und Arbeitgeberbeiträge aus dem Bruttolohn. Sie sind im Nettolohn bereits abgezogen und schmälern die Sparquote deshalb NICHT.",
});
} else if (e.category === "PILLAR_3A") {
out.push({ out.push({
elementId: e.id, name: e.name, category: e.category, owner: ownerLabel(plan.persons, e.ownerRole), field: "annualContribution", elementId: e.id, name: e.name, category: e.category, owner: ownerLabel(plan.persons, e.ownerRole), field: "annualContribution",
label: "Jährliche Einzahlung", direction: "out", label: "Jährliche Einzahlung", direction: "out",
hint: "Fliesst jährlich vom Cash in die Säule 3a.", hint: "Fliesst jährlich vom Cash in die Säule 3a.",
max: flags[e.id] ? PILLAR_3A_MAX_SELF_EMPLOYED : PILLAR_3A_MAX_ANNUAL,
}); });
} else if (e.category === "OTHER_ASSET") { } else if (e.category === "OTHER_ASSET") {
out.push({ out.push({
@@ -431,7 +455,7 @@ export function RateDistributionDialog({
} }
return out; return out;
// eslint-disable-next-line react-hooks/exhaustive-deps -- phase folgt phaseId // eslint-disable-next-line react-hooks/exhaustive-deps -- phase folgt phaseId
}, [plan.elements, phaseId]); }, [plan.elements, phaseId, flags]);
const key = (t: RateTarget) => `${t.elementId}:${t.field}`; const key = (t: RateTarget) => `${t.elementId}:${t.field}`;
@@ -479,11 +503,15 @@ export function RateDistributionDialog({
const e = plan.elements.find((x) => x.id === t.elementId)!; const e = plan.elements.find((x) => x.id === t.elementId)!;
const merged = byElement.get(t.elementId) ?? { ...(e.phaseValues[phaseId] ?? {}) }; const merged = byElement.get(t.elementId) ?? { ...(e.phaseValues[phaseId] ?? {}) };
(merged as Record<string, number>)[t.field] = draft[key(t)] ?? 0; (merged as Record<string, number>)[t.field] = draft[key(t)] ?? 0;
if (t.category === "PILLAR_3A") merged.selfEmployed3a = !!flags[t.elementId];
byElement.set(t.elementId, merged); byElement.set(t.elementId, merged);
} }
for (const [elementId, data] of byElement) { for (const [elementId, data] of byElement) {
await api.put(`/api/elements/${elementId}/phase/${phaseId}`, data); await api.put(`/api/elements/${elementId}/phase/${phaseId}`, data);
} }
// Die Verteilung zählt eigens als offener Punkt: Man kann jede Zelle angeschaut und die
// Quote trotzdem nie verteilt haben -- dann bliebe alles still auf dem Cash-Konto.
await api.put(`/api/phases/${phaseId}`, { ratesConfirmed: true });
onSaved(); onSaved();
} catch (e) { } catch (e) {
setError(e instanceof Error ? e.message : "Speichern fehlgeschlagen."); setError(e instanceof Error ? e.message : "Speichern fehlgeschlagen.");
@@ -532,9 +560,9 @@ export function RateDistributionDialog({
</p> </p>
) : ( ) : (
// Bewusst nach RICHTUNG gruppiert: erst alles, was das Cash verlässt, dann alles, was // Bewusst nach RICHTUNG gruppiert: erst alles, was das Cash verlässt, dann alles, was
// hineinfliesst. Gemischt war die Liste schwer zu lesen. // hineinfliesst, zuletzt der Bruttolohn-Block. Gemischt war die Liste schwer zu lesen.
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
{(["out", "in"] as const).map((dir) => { {(["out", "in", "gross"] as const).map((dir) => {
const rows = targets.filter((t) => t.direction === dir); const rows = targets.filter((t) => t.direction === dir);
if (rows.length === 0) return null; if (rows.length === 0) return null;
return ( return (
@@ -544,8 +572,19 @@ export function RateDistributionDialog({
dir === "in" ? "text-success" : "text-muted" dir === "in" ? "text-success" : "text-muted"
}`} }`}
> >
{dir === "in" ? "Ins Cash (Entnahmen)" : "Vom Cash (Einzahlungen und Tilgungen)"} {dir === "in"
? "Ins Cash (Entnahmen)"
: dir === "gross"
? "Aus dem Bruttolohn (ausserhalb der Quote)"
: "Vom Cash (Einzahlungen und Tilgungen)"}
</div> </div>
{dir === "gross" && (
<p className="rounded-lg bg-surface-2 px-3 py-2 text-[11px] leading-relaxed text-muted">
Diese Beiträge sind im Nettolohn <strong className="text-fg">bereits abgezogen</strong>. Sie
schmälern die Sparquote nicht und tauchen in der Bilanz unten deshalb nicht auf sie stehen
hier, weil sie das Vorsorgekapital gleichwohl jährlich erhöhen.
</p>
)}
{rows.map((t) => ( {rows.map((t) => (
<div key={key(t)} className="rounded-xl border border-border p-3"> <div key={key(t)} className="rounded-xl border border-border p-3">
<div className="mb-2 flex flex-wrap items-center gap-2 text-sm"> <div className="mb-2 flex flex-wrap items-center gap-2 text-sm">
@@ -556,9 +595,29 @@ export function RateDistributionDialog({
<span className="rounded bg-surface-2 px-1.5 py-0.5 text-[10px] text-muted">{t.owner}</span> <span className="rounded bg-surface-2 px-1.5 py-0.5 text-[10px] text-muted">{t.owner}</span>
<InfoBubble text={t.hint} /> <InfoBubble text={t.hint} />
</div> </div>
{/* Das Häkchen steht ÜBER dem Betrag, weil es dessen Obergrenze bestimmt. */}
{t.category === "PILLAR_3A" && (
<label className="mb-2 flex cursor-pointer items-center gap-2 text-xs text-muted">
<input
type="checkbox"
checked={!!flags[t.elementId]}
onChange={(e) => setFlags((prev) => ({ ...prev, [t.elementId]: e.target.checked }))}
/>
Selbstständig ohne Pensionskasse («grosse Säule 3a»)
<InfoBubble
text={`Selbstständigerwerbende ohne PK dürfen bis 20 % des Nettoerwerbseinkommens einzahlen, gedeckelt auf ca. ${PILLAR_3A_MAX_SELF_EMPLOYED.toLocaleString("de-CH")} CHF statt der ${PILLAR_3A_MAX_ANNUAL.toLocaleString("de-CH")} CHF mit PK.`}
/>
</label>
)}
<MoneyField <MoneyField
label={`${t.label} (CHF/Jahr)`} label={`${t.label} (CHF/Jahr)`}
help={
t.max !== undefined
? `Höchstens CHF ${t.max.toLocaleString("de-CH")} (2026) und höchstens die Sparquote.`
: undefined
}
value={draft[key(t)] ?? 0} value={draft[key(t)] ?? 0}
max={t.max}
onChange={(v) => setDraft((prev) => ({ ...prev, [key(t)]: v }))} onChange={(v) => setDraft((prev) => ({ ...prev, [key(t)]: v }))}
/> />
</div> </div>
+239 -179
View File
@@ -4,7 +4,6 @@ import { useState } from "react";
import { Trash2 } from "lucide-react"; import { Trash2 } from "lucide-react";
import { AlertTriangle } from "lucide-react"; import { AlertTriangle } from "lucide-react";
import { FieldLabel, InheritableField, MoneyField, NumberField, SelectField, TextField } from "@/components/FormField"; import { FieldLabel, InheritableField, MoneyField, NumberField, SelectField, TextField } from "@/components/FormField";
import { InfoBubble } from "@/components/InfoBubble";
import { formatChf } from "@/lib/format"; import { formatChf } from "@/lib/format";
import { api } from "@/lib/api-client"; import { api } from "@/lib/api-client";
// Die reinen Uebergangs-Regeln liegen in lib/transitions.ts (auch serverseitig nutzbar) und // Die reinen Uebergangs-Regeln liegen in lib/transitions.ts (auch serverseitig nutzbar) und
@@ -29,7 +28,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,
@@ -74,6 +72,25 @@ export interface CellContext {
// Betrag, der aus einem Kapitalbezug in DIESES Element umgeleitet wurde (Punkt C). // Betrag, der aus einem Kapitalbezug in DIESES Element umgeleitet wurde (Punkt C).
capitalFromTransfer: number; capitalFromTransfer: number;
capitalFromTransferSources: { name: string; amount: number }[]; capitalFromTransferSources: { name: string; amount: number }[];
// Stammdaten des Elements. In der ERSTEN Phase ist der Startwert genau das -- er wird dort
// nur noch ANGEZEIGT, denn sonst entstünde derselbe Betrag an zwei Orten (Kap. 3.14.3).
baseData: PhaseData;
// Der TATSÄCHLICH gerechnete Stand dieser Zelle. In einer Phase, in der die Person schon
// pensioniert ist, stand hier bis 0.40 eine Regelbeschreibung im Futur («wird berechnet»,
// «wird bezogen»), obwohl der Bezug längst geschehen war. Was die Zelle braucht, ist der
// Zustand, nicht die Regel.
computedSummary: string;
computedNote: string | null;
computedValue: number;
// Einmalige Kapitalverwendungen, die am Übergang IN diese Phase beschlossen wurden. Sie
// liegen technisch an der Vorphase, gehören aber sichtbar hierher -- zusammen mit der
// Zusatzeinlage, die aus demselben Topf stammt.
oneOffAmortization: number;
oneOffRepayment: number;
// Absprünge. Ein Wert entsteht an genau EINEM Ort; angezeigt wird er überall, wo er wirkt.
onEditBase?: () => void;
onDistributeRates?: () => void;
onDistributeCapital?: () => void;
} }
interface Props { interface Props {
@@ -87,6 +104,9 @@ interface Props {
phaseDataByPhase?: Record<string, PhaseData>; phaseDataByPhase?: Record<string, PhaseData>;
onSaved: () => void; onSaved: () => void;
onDeleteElement: () => void; onDeleteElement: () => void;
// In der Kartenansicht steht Name und Kategorie schon im Zeilenkopf -- dann waere die
// eigene Ueberschrift eine Wiederholung, und der Loeschen-Knopf sitzt dort im Menue.
compact?: boolean;
} }
// Warnhinweis beim Bearbeiten einer Phase, der noch Phasen folgen. Seit V3 werden Werte live // Warnhinweis beim Bearbeiten einer Phase, der noch Phasen folgen. Seit V3 werden Werte live
@@ -119,6 +139,51 @@ function DerivedField({ label, value, help }: { label: string; value: number; he
); );
} }
// Read-only Anzeige mit Absprung an den Ort, an dem der Wert ENTSTEHT.
//
// Seit 0.40 hat jede Zahl genau einen Entstehungsort: Bestände in den Stammdaten, jährliche
// Raten im Verteil-Dialog der Quote, einmalige Kapitalverwendungen im Kapital-Dialog. In der
// Zelle stehen sie trotzdem -- nur eben unveränderlich. Zwei Eingabefelder für dieselbe Zahl
// sind der Fehler, den dieser Umbau beseitigt: Man tippt in das eine und wundert sich, warum
// das andere gewinnt.
function LinkedField({
label,
value,
help,
actionLabel,
onAction,
suffix,
}: {
label: string;
value: number;
help?: string;
actionLabel: string;
onAction?: () => void;
// Zusatz hinter dem Betrag, z. B. "/Jahr".
suffix?: string;
}) {
return (
<div>
<FieldLabel label={label} help={help} />
<div className="flex w-full items-center justify-between gap-2 rounded-lg border border-dashed border-border bg-surface-2 px-2.5 py-1.5 text-sm text-muted">
<span className="tabular-nums">
{formatChf(value)}
{suffix ? <span className="text-faint"> {suffix}</span> : null}
</span>
{onAction && (
<button
type="button"
onClick={onAction}
className="shrink-0 whitespace-nowrap rounded border border-accent px-1.5 py-0.5 text-[11px] font-semibold text-accent transition-colors hover:bg-accent hover:text-accent-fg"
>
{actionLabel}
</button>
)}
</div>
</div>
);
}
// PK/3a-Bezugs-Entscheid im normalen Übergang (Vorbezug): Kein Bezug / Bezug (+ Betrag + // PK/3a-Bezugs-Entscheid im normalen Übergang (Vorbezug): Kein Bezug / Bezug (+ Betrag +
// Kapitalbezugssteuer). Der Bezugsbetrag wird brutto dem Kapital entnommen; ins Cash fliesst // Kapitalbezugssteuer). Der Bezugsbetrag wird brutto dem Kapital entnommen; ins Cash fliesst
// der Betrag nach Abzug der Steuer. // der Betrag nach Abzug der Steuer.
@@ -485,7 +550,51 @@ export function ElementPhaseFields({
); );
}; };
const asPct = (v: number) => `${v} %`; const asPct = (v: number) => `${v} %`;
const asChf = (v: number) => formatChf(v);
// Der Startwert der ERSTEN Phase ist der Stammdatenwert -- hier nur angezeigt, geändert
// wird er in der Spalte «Start». Bis 0.39 stand hier ein Eingabefeld, das
// `phaseValues[phase1]` schrieb und die Stammdaten still überschattete (der Rechenkern
// legt sie als `{...baseData, ...phaseValues}` übereinander): dieselbe Zahl an zwei Orten.
// Dieselbe Vorrangregel wie im Rechenkern (`firstPhaseValues`): Wo die Stammdaten einen Wert
// tragen, gewinnen SIE -- ein Phasenwert darf sie nicht überschatten. Der Rückfall auf den
// Phasenwert bleibt nur, solange die Stammdaten leer sind.
const bd = context.baseData;
const start = (key: "amount" | "currentValue" | "startValue" | "purchasePrice" | "mortgage", label: string, help?: string) => (
<LinkedField
label={label}
help={help}
value={Math.round(typeof bd[key] === "number" ? (bd[key] as number) : num(pd[key]))}
actionLabel="Startwert bearbeiten"
onAction={context.onEditBase}
/>
);
// Jährliche Raten. Sie entstehen ausschliesslich im Verteil-Dialog der Spar- bzw.
// Verzehrquote: Nur dort sieht man, wie viel überhaupt zu verteilen ist, und dass die
// Summe aufgeht. Ein zweites Eingabefeld je Element hätte man auch über die Quote hinaus
// füllen können, ohne dass es jemand merkt.
const rate = (key: InheritableKey, label: string, help?: string) => (
<LinkedField
label={label}
help={help}
value={Math.round(num(pd[key], num(context.inheritedValues[key])))}
suffix="/Jahr"
actionLabel="Quote verteilen"
onAction={context.onDistributeRates}
/>
);
// Einmalige Verwendung des in dieser Phase verfügbaren Kapitals -- alle aus demselben Topf,
// deshalb auch alle im selben Dialog.
const capital = (label: string, value: number, help?: string) => (
<LinkedField
label={label}
help={help}
value={Math.round(value)}
actionLabel="Kapital verteilen"
onAction={context.onDistributeCapital}
/>
);
switch (element.category) { switch (element.category) {
case "INCOME": case "INCOME":
@@ -493,7 +602,13 @@ export function ElementPhaseFields({
const isIncome = element.category === "INCOME"; const isIncome = element.category === "INCOME";
// Basiswert (erstes Jahr). Ab Phase 2 mit dem fortgeschriebenen Wert der Vorphase // Basiswert (erstes Jahr). Ab Phase 2 mit dem fortgeschriebenen Wert der Vorphase
// vorbelegt, aber bewusst änderbar (Teilzeit, Beförderung, Jobwechsel …). // vorbelegt, aber bewusst änderbar (Teilzeit, Beförderung, Jobwechsel …).
const baseValue = typeof pd.amount === "number" ? pd.amount : carried ? context.derivedStart : 0; const baseValue = carried
? typeof pd.amount === "number"
? pd.amount
: context.derivedStart
: typeof bd.amount === "number"
? bd.amount
: num(pd.amount);
const d = context.deflatorStart || 1; const d = context.deflatorStart || 1;
// Info-Gegenwert im ersten Jahr: Einkommen -> real; Ausgaben -> nominal. // Info-Gegenwert im ersten Jahr: Einkommen -> real; Ausgaben -> nominal.
const otherValue = isIncome ? Math.round(baseValue / d) : Math.round(baseValue * d); const otherValue = isIncome ? Math.round(baseValue / d) : Math.round(baseValue * d);
@@ -504,12 +619,22 @@ export function ElementPhaseFields({
? "Bitte das NETTO-Einkommen erfassen: der Betrag, der nach allen Lohnabzügen (AHV/ALV, Pensionskasse, NBU) tatsächlich aufs Konto kommt nicht der Bruttolohn. Erfasst wird NOMINAL; der reale Wert (heutige Kaufkraft) erscheint nur zur Info. Für die AHV-Rente rechnet das Tool intern auf den Bruttolohn zurück." ? "Bitte das NETTO-Einkommen erfassen: der Betrag, der nach allen Lohnabzügen (AHV/ALV, Pensionskasse, NBU) tatsächlich aufs Konto kommt nicht der Bruttolohn. Erfasst wird NOMINAL; der reale Wert (heutige Kaufkraft) erscheint nur zur Info. Für die AHV-Rente rechnet das Tool intern auf den Bruttolohn zurück."
: "Ausgaben werden REAL erfasst (in heutiger Kaufkraft). Die Inflation (plan-weit) rechnet daraus automatisch die nominalen Ausgaben nur zur Info."} : "Ausgaben werden REAL erfasst (in heutiger Kaufkraft). Die Inflation (plan-weit) rechnet daraus automatisch die nominalen Ausgaben nur zur Info."}
</p> </p>
{carried ? (
// Ab Phase 2 bewusst änderbar: Teilzeit, Beförderung, Jobwechsel, anderer
// Lebensstandard -- das sind echte Entscheide dieser Phase.
<MoneyField <MoneyField
label={isIncome ? "Jahreseinkommen NETTO, nominal (erstes Jahr)" : "Jahresausgaben REAL (erstes Jahr, heutige Kaufkraft)"} label={isIncome ? "Jahreseinkommen NETTO, nominal (erstes Jahr)" : "Jahresausgaben REAL (erstes Jahr, heutige Kaufkraft)"}
help={carried ? "Vorbelegt mit dem fortgeschriebenen Wert der Vorphase; hier bewusst änderbar." : undefined} help="Vorbelegt mit dem fortgeschriebenen Wert der Vorphase; hier bewusst änderbar."
value={baseValue} value={baseValue}
onChange={(v) => setP({ amount: v })} onChange={(v) => setP({ amount: v })}
/> />
) : (
start(
"amount",
isIncome ? "Jahreseinkommen NETTO, nominal (erstes Jahr)" : "Jahresausgaben REAL (erstes Jahr, heutige Kaufkraft)",
"In der ersten Lebensphase ist das der Wert aus der Bestandsaufnahme geändert wird er in der Spalte «Start»."
)
)}
<DerivedField <DerivedField
label={isIncome ? "≈ real (heutige Kaufkraft)" : "≈ nominal (in diesem Jahr)"} label={isIncome ? "≈ real (heutige Kaufkraft)" : "≈ nominal (in diesem Jahr)"}
value={otherValue} value={otherValue}
@@ -635,11 +760,23 @@ export function ElementPhaseFields({
} }
case "PENSION_FUND": case "PENSION_FUND":
if (!context.ownerWorking) { if (!context.ownerWorking) {
// Zustand statt Regel: entweder läuft eine Rente, oder das Guthaben ist bezogen.
const hasPension = context.computedValue > 0;
return ( return (
<p className="col-span-2 text-sm text-muted"> <div className="col-span-2 rounded-lg bg-surface-2 px-3 py-2 text-sm text-muted">
Die PK-Rente wird aus dem beim Pensions-Übergang gewählten Umwandlungssatz berechnet (siehe {hasPension ? (
Kennzahl). Bei reinem Kapitalbezug erscheint hier &quot;Vollständig bezogen&quot;. <>
</p> <strong className="text-fg">Rente: {formatChf(context.computedValue)} pro Jahr.</strong> Das
Guthaben wurde beim Übergang in die Pension verrentet hier gibt es nichts mehr einzustellen.
Den Umwandlungssatz und den Kapitalanteil änderst du in der Übergangsspalte davor.
</>
) : (
<>
<strong className="text-fg">Vollständig bezogen.</strong> Das Guthaben wurde beim Übergang in die
Pension als Kapital ausbezahlt; wohin es geflossen ist, steht dort im Bezugs-Entscheid.
</>
)}
</div>
); );
} }
return ( return (
@@ -647,23 +784,19 @@ export function ElementPhaseFields({
{carried ? ( {carried ? (
<> <>
<DerivedField label="Startwert (fortgeschrieben)" value={context.derivedStart} help="Endwert der Vorphase, fortgeschrieben." /> <DerivedField label="Startwert (fortgeschrieben)" value={context.derivedStart} help="Endwert der Vorphase, fortgeschrieben." />
<MoneyField {capital(
label="Zusatzeinlage aus Kapital (CHF)" "Zusatzeinlage aus Kapital (CHF)",
help="Aufstockung aus dem verfügbaren Kapital dieser Phase." num(pd.additionalInvestment),
value={num(pd.additionalInvestment)} "Aufstockung aus dem verfügbaren Kapital dieser Phase."
)}
onChange={(v) => setP({ additionalInvestment: v })}
/>
</> </>
) : ( ) : (
<MoneyField label="Aktueller PK-Wert (CHF)" value={num(pd.currentValue)} onChange={(v) => setP({ currentValue: v })} /> start("currentValue", "Aktueller PK-Wert (CHF)")
)} )}
{inh( {rate(
"annualContribution", "annualContribution",
"Jährliche Einzahlung (CHF)", "Jährliche Einzahlung (CHF)",
(v, set) => <MoneyField label="Jährliche Einzahlung (CHF)" value={v} onChange={set} />, "Arbeitnehmer- und Arbeitgeberbeiträge. Stammen aus dem BRUTTOLOHN und schmälern die Sparquote nicht deshalb stehen sie im Verteil-Dialog in einem eigenen Block."
asChf,
"Arbeitnehmer- und Arbeitgeberbeiträge. Fliesst NICHT in die Sparquote ein (bereits in den Ausgaben berücksichtigt)."
)} )}
{inh( {inh(
"expectedReturn", "expectedReturn",
@@ -675,43 +808,36 @@ export function ElementPhaseFields({
); );
case "PILLAR_3A": { case "PILLAR_3A": {
if (!context.ownerWorking) { if (!context.ownerWorking) {
return <p className="col-span-2 text-sm text-muted">Die Säule 3a wird beim Pensions-Übergang vollständig bezogen.</p>; return (
<div className="col-span-2 rounded-lg bg-surface-2 px-3 py-2 text-sm text-muted">
<strong className="text-fg">Vollständig bezogen.</strong> Ein 3a-Konto lässt sich nur ganz auflösen
das ist beim Übergang geschehen. Bezugsalter, Steuersatz und die Verwendung des Kapitals stehen in der
Übergangsspalte davor.
</div>
);
} }
// «Grosse Säule 3a» für Selbstständige ohne PK: höhere Obergrenze (Roadmap-Feedback C7c). // «Grosse Säule 3a» für Selbstständige ohne PK: höhere Obergrenze (Roadmap-Feedback C7c).
// Der Schalter steht seit 0.40 im Verteil-Dialog, weil er dort die Obergrenze der
// Einzahlung bestimmt -- getrennt von ihr wäre er eine Einstellung ohne sichtbare Wirkung.
const max3a = pd.selfEmployed3a ? PILLAR_3A_MAX_SELF_EMPLOYED : PILLAR_3A_MAX_ANNUAL; const max3a = pd.selfEmployed3a ? PILLAR_3A_MAX_SELF_EMPLOYED : PILLAR_3A_MAX_ANNUAL;
return ( return (
<> <>
{carried ? ( {carried ? (
<> <>
<DerivedField label="Startwert (fortgeschrieben)" value={context.derivedStart} help="Endwert der Vorphase, fortgeschrieben." /> <DerivedField label="Startwert (fortgeschrieben)" value={context.derivedStart} help="Endwert der Vorphase, fortgeschrieben." />
<MoneyField {capital(
label="Zusatzeinlage aus Kapital (CHF)" "Zusatzeinlage aus Kapital (CHF)",
help="Aufstockung aus dem verfügbaren Kapital dieser Phase." num(pd.additionalInvestment),
value={num(pd.additionalInvestment)} "Aufstockung aus dem verfügbaren Kapital dieser Phase."
)}
onChange={(v) => setP({ additionalInvestment: v })}
/>
</> </>
) : ( ) : (
<MoneyField label="Aktueller 3a-Wert (CHF)" value={num(pd.currentValue)} onChange={(v) => setP({ currentValue: v })} /> start("currentValue", "Aktueller 3a-Wert (CHF)")
)} )}
<label className="col-span-2 flex cursor-pointer items-center gap-2 text-xs text-muted"> {rate(
<input
type="checkbox"
checked={!!pd.selfEmployed3a}
onChange={(e) => setP({ selfEmployed3a: e.target.checked })}
/>
Selbstständig ohne Pensionskasse («grosse Säule 3a»)
<InfoBubble text={`Selbstständigerwerbende ohne PK dürfen bis 20 % des Nettoerwerbseinkommens einzahlen, gedeckelt auf ca. ${PILLAR_3A_MAX_SELF_EMPLOYED.toLocaleString("de-CH")} CHF statt der ${PILLAR_3A_MAX_ANNUAL.toLocaleString("de-CH")} CHF mit PK.`} />
</label>
{inh(
"annualContribution", "annualContribution",
"Jährliche Einzahlung (CHF)", "Jährliche Einzahlung (CHF)",
(v, set) => ( `Maximal CHF ${max3a.toLocaleString("de-CH")} (2026, ${pd.selfEmployed3a ? "Selbstständig ohne PK" : "mit PK"}) und höchstens die Sparquote.`
<MoneyField label="Jährliche Einzahlung (CHF)" value={v} max={max3a} onChange={set} />
),
asChf,
`Maximal CHF ${max3a.toLocaleString("de-CH")} (2026, ${pd.selfEmployed3a ? "Selbstständig ohne PK" : "mit PK"}) und höchstens die Sparquote. Wird von der Sparquote abgezogen.`
)} )}
{inh( {inh(
"expectedReturn", "expectedReturn",
@@ -725,8 +851,13 @@ export function ElementPhaseFields({
case "REAL_ESTATE": { case "REAL_ESTATE": {
// Zinsbetrag zu Phasenbeginn und -ende: die Restschuld sinkt mit der Amortisation, // Zinsbetrag zu Phasenbeginn und -ende: die Restschuld sinkt mit der Amortisation,
// der Zinsbetrag also mit. Am Nullpunkt gekappt (analog zur Berechnung). // der Zinsbetrag also mit. Am Nullpunkt gekappt (analog zur Berechnung).
const hypStart = carried ? context.derivedMortgage : num(pd.mortgage); const hypStart = carried
const hypEnde = Math.max(0, hypStart - num(pd.amortization) * context.durationYears); ? context.derivedMortgage
: typeof bd.mortgage === "number"
? bd.mortgage
: num(pd.mortgage);
const amortEff = num(pd.amortization, num(context.inheritedValues.amortization));
const hypEnde = Math.max(0, hypStart - amortEff * context.durationYears);
const zinsStart = Math.round((hypStart * num(pd.interestRate)) / 100); const zinsStart = Math.round((hypStart * num(pd.interestRate)) / 100);
const zinsEnde = Math.round((hypEnde * num(pd.interestRate)) / 100); const zinsEnde = Math.round((hypEnde * num(pd.interestRate)) / 100);
const handling = pd.interestHandling ?? "INCLUDED"; const handling = pd.interestHandling ?? "INCLUDED";
@@ -743,17 +874,21 @@ export function ElementPhaseFields({
</> </>
) : ( ) : (
<> <>
<MoneyField label="Kaufpreis (CHF)" value={num(pd.purchasePrice)} onChange={(v) => setP({ purchasePrice: v })} /> {start("purchasePrice", "Kaufpreis (CHF)")}
<MoneyField label="Hypothek (CHF)" value={num(pd.mortgage)} onChange={(v) => setP({ mortgage: v })} /> {start("mortgage", "Hypothek (CHF)")}
</> </>
)} )}
{inh( {rate(
"amortization", "amortization",
"Amortisation (CHF/Jahr)", "Amortisation (CHF/Jahr)",
(v, set) => <MoneyField label="Amortisation (CHF/Jahr)" value={v} onChange={set} />,
asChf,
"Jährliche Reduktion der Hypothek. Zählt gegen die Sparquote und endet, sobald die Hypothek abbezahlt ist." "Jährliche Reduktion der Hypothek. Zählt gegen die Sparquote und endet, sobald die Hypothek abbezahlt ist."
)} )}
{context.oneOffAmortization > 0 &&
capital(
"Sonderamortisation aus Kapital (CHF)",
context.oneOffAmortization,
"Einmaltilgung am Übergang in diese Phase, aus dem verfügbaren Kapital."
)}
{inh( {inh(
"interestRate", "interestRate",
"Hypothekarzins (%/Jahr)", "Hypothekarzins (%/Jahr)",
@@ -798,12 +933,11 @@ export function ElementPhaseFields({
{carried ? ( {carried ? (
<> <>
<DerivedField label="Startwert (fortgeschrieben)" value={context.derivedStart} help="Endwert der Vorphase, fortgeschrieben." /> <DerivedField label="Startwert (fortgeschrieben)" value={context.derivedStart} help="Endwert der Vorphase, fortgeschrieben." />
<MoneyField {capital(
label="Zusatzinvestition aus Kapital (CHF)" "Zusatzinvestition aus Kapital (CHF)",
help="Neuinvestition aus dem verfügbaren Kapital dieser Phase." num(pd.additionalInvestment),
value={num(pd.additionalInvestment)} "Neuinvestition aus dem verfügbaren Kapital dieser Phase."
onChange={(v) => setP({ additionalInvestment: v })} )}
/>
{/* Aus einem Kapitalbezug (PK/3a) am letzten Übergang umgeleitet -- nicht hier {/* Aus einem Kapitalbezug (PK/3a) am letzten Übergang umgeleitet -- nicht hier
erfasst, sondern dort als Quote entschieden (Punkt C). Kommt der Betrag aus erfasst, sondern dort als Quote entschieden (Punkt C). Kommt der Betrag aus
mehreren Bezügen (PK und 3a, ggf. beider Personen), wird er einzeln mehreren Bezügen (PK und 3a, ggf. beider Personen), wird er einzeln
@@ -832,7 +966,7 @@ export function ElementPhaseFields({
)} )}
</> </>
) : ( ) : (
<MoneyField label="Startwert (CHF)" value={num(pd.startValue)} onChange={(v) => setP({ startValue: v })} /> start("startValue", "Startwert (CHF)")
)} )}
{inh( {inh(
"expectedReturn", "expectedReturn",
@@ -840,18 +974,14 @@ export function ElementPhaseFields({
(v, set) => <NumberField label="Erwartete Rendite (%/Jahr)" step={0.1} value={v} onChange={set} />, (v, set) => <NumberField label="Erwartete Rendite (%/Jahr)" step={0.1} value={v} onChange={set} />,
asPct asPct
)} )}
{inh( {rate(
"annualContribution", "annualContribution",
"Jährlicher Sparbeitrag (CHF)", "Jährlicher Sparbeitrag (CHF)",
(v, set) => <MoneyField label="Jährlicher Sparbeitrag (CHF)" value={v} onChange={set} />,
asChf,
"Flacher Jahresbetrag, der ins Vermögen fliesst und vom Cash abgezogen wird (geplante Sparrate)." "Flacher Jahresbetrag, der ins Vermögen fliesst und vom Cash abgezogen wird (geplante Sparrate)."
)} )}
{inh( {rate(
"annualWithdrawal", "annualWithdrawal",
"Jährliche Bezugsrate (CHF)", "Jährliche Bezugsrate (CHF)",
(v, set) => <MoneyField label="Jährliche Bezugsrate (CHF)" value={v} onChange={set} />,
asChf,
"Entnahme aus dem Vermögen (z. B. laufende Renten-Entnahme im Alter). Mindert das Vermögen und fliesst jährlich ins Cash (geplante Verzehrrate)." "Entnahme aus dem Vermögen (z. B. laufende Renten-Entnahme im Alter). Mindert das Vermögen und fliesst jährlich ins Cash (geplante Verzehrrate)."
)} )}
</> </>
@@ -862,14 +992,14 @@ export function ElementPhaseFields({
{carried ? ( {carried ? (
<DerivedField label="Restschuld (fortgeschrieben)" value={Math.abs(context.derivedStart)} help="Fortgeschriebene Restschuld aus der Vorphase." /> <DerivedField label="Restschuld (fortgeschrieben)" value={Math.abs(context.derivedStart)} help="Fortgeschriebene Restschuld aus der Vorphase." />
) : ( ) : (
<MoneyField label="Restschuld (CHF)" value={num(pd.startValue)} onChange={(v) => setP({ startValue: v })} /> start("startValue", "Restschuld (CHF)")
)} )}
{inh( {rate("annualRepayment", "Jährliche Tilgung (CHF)", "Zählt gegen die Sparquote (max. die Sparquote).")}
"annualRepayment", {context.oneOffRepayment > 0 &&
"Jährliche Tilgung (CHF)", capital(
(v, set) => <MoneyField label="Jährliche Tilgung (CHF)" value={v} onChange={set} />, "Sofort-Tilgung aus Kapital (CHF)",
asChf, context.oneOffRepayment,
"Zählt gegen die Sparquote (max. die Sparquote)." "Einmalige Tilgung am Übergang in diese Phase, aus dem verfügbaren Kapital."
)} )}
</> </>
); );
@@ -881,11 +1011,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 +1031,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 +1042,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)" />;
} }
@@ -1072,13 +1119,15 @@ export function ElementTransitionFields({
<NumberField label="Grundstückgewinnsteuer (%)" step={1} value={num(td.saleTaxRate, DEFAULT_PROPERTY_GAINS_TAX_RATE)} onChange={(v) => setT({ saleTaxRate: v })} /> <NumberField label="Grundstückgewinnsteuer (%)" step={1} value={num(td.saleTaxRate, DEFAULT_PROPERTY_GAINS_TAX_RATE)} onChange={(v) => setT({ saleTaxRate: v })} />
</> </>
)} )}
{decision !== "SELL" && context.mortgageEnd > 0 && ( {/* Die Sonderamortisation stand bis 0.39 hier. Sie stammt aber aus dem Kapital, das
<MoneyField in der FOLGEphase verfügbar ist, und wird dort im Kapital-Dialog zusammen mit
label="Sonderamortisation (CHF)" allen anderen Verwendungen desselben Topfs entschieden. Zwei Eingaben für
help={`Einmaltilgung der Hypothek aus dem Cash der nächsten Phase (z. B. aus einem 3a-Bezug). Senkt Restschuld und künftige Zinsen. Maximal ${formatChf(context.mortgageEnd)} (Resthypothek am Phasenende).`} denselben Franken -- hier und dort -- gab es damit nicht mehr. */}
{decision !== "SELL" && context.mortgageEnd > 0 && num(td.extraAmortization) > 0 && (
<DerivedField
label="Sonderamortisation aus Kapital (CHF)"
value={num(td.extraAmortization)} value={num(td.extraAmortization)}
max={context.mortgageEnd} help="Einmaltilgung aus dem Kapital der nächsten Phase. Geändert wird sie dort unter «Kapital verteilen»."
onChange={(v) => setT({ extraAmortization: v })}
/> />
)} )}
</> </>
@@ -1111,13 +1160,18 @@ export function ElementTransitionFields({
); );
} }
case "OTHER_DEBT": case "OTHER_DEBT":
return ( // Dito: Die Sofort-Tilgung zehrt vom Kapital der Folgephase und wird dort entschieden.
<MoneyField return num(td.immediateRepayment) > 0 ? (
label="Sofortige Tilgung (CHF)" <DerivedField
help="Wird sofort getilgt und vom verfügbaren Kapital der nächsten Phase abgezogen." label="Sofort-Tilgung aus Kapital (CHF)"
value={num(td.immediateRepayment)} value={num(td.immediateRepayment)}
onChange={(v) => setT({ immediateRepayment: v })} help="Wird am Übergang getilgt, aus dem Kapital der nächsten Phase. Geändert wird sie dort unter «Kapital verteilen»."
/> />
) : (
<p className="col-span-2 text-sm text-muted">
Am Übergang ist hier nichts zu entscheiden. Eine einmalige Tilgung aus dem verfügbaren Kapital
nimmst du in der nächsten Lebensphase unter «Kapital verteilen» vor.
</p>
); );
} }
} }
@@ -1131,6 +1185,7 @@ export function ElementDetail({
phaseDataByPhase = {}, phaseDataByPhase = {},
onSaved, onSaved,
onDeleteElement, onDeleteElement,
compact,
}: Props) { }: Props) {
const [pd, setPd] = useState<PhaseData>({ ...phaseData }); const [pd, setPd] = useState<PhaseData>({ ...phaseData });
const [td, setTd] = useState<TransitionData>({ ...transitionData }); const [td, setTd] = useState<TransitionData>({ ...transitionData });
@@ -1152,7 +1207,10 @@ export function ElementDetail({
if (isTransition) { if (isTransition) {
await api.put(`/api/elements/${element.id}/transition/${context.phaseId}`, td); await api.put(`/api/elements/${element.id}/transition/${context.phaseId}`, td);
} else { } else {
const payload: PhaseData = { ...pd }; // Speichern heisst zugleich BESTAETIGEN: Wer die Zelle geoeffnet und gespeichert hat,
// hat hingeschaut. Der Haken steht neben den Werten und friert nichts ein -- leere
// Felder erben weiterhin live aus der Vorphase (SPEZIFIKATION 3.12.4).
const payload: PhaseData = { ...pd, confirmed: true };
// Einkommen/Ausgaben ab Phase 2: entspricht der Basiswert dem fortgeschriebenen Wert // Einkommen/Ausgaben ab Phase 2: entspricht der Basiswert dem fortgeschriebenen Wert
// der Vorphase, KEINEN Override speichern -> Wert bleibt live vererbt (Änderungen in // der Vorphase, KEINEN Override speichern -> Wert bleibt live vererbt (Änderungen in
// früheren Phasen wirken sich weiter aus). Nur ein bewusst abweichender Wert wird fix. // früheren Phasen wirken sich weiter aus). Nur ein bewusst abweichender Wert wird fix.
@@ -1198,6 +1256,7 @@ export function ElementDetail({
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
{!compact && (
<div className="flex items-start justify-between gap-2"> <div className="flex items-start justify-between gap-2">
<div> <div>
<div className="text-xs font-semibold uppercase tracking-wide text-accent-soft-fg"> <div className="text-xs font-semibold uppercase tracking-wide text-accent-soft-fg">
@@ -1214,6 +1273,7 @@ export function ElementDetail({
<Trash2 className="h-3.5 w-3.5" /> Element löschen <Trash2 className="h-3.5 w-3.5" /> Element löschen
</button> </button>
</div> </div>
)}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{/* Punkt 4: Warnung, dass Änderungen sich in die Folgephasen fortschreiben. */} {/* Punkt 4: Warnung, dass Änderungen sich in die Folgephasen fortschreiben. */}
+627
View File
@@ -0,0 +1,627 @@
"use client";
// Die Bestandsaufnahme: alles erfassen, was heute da ist.
//
// Der einzige Sammel-Dialog, der geblieben ist. Er arbeitet ausschliesslich über die
// bestehenden Endpunkte -- was er schreibt, hätte man auch einzeln über die Matrix erfassen
// können; er nimmt einem nur das Suchen ab.
//
// Erfasst werden NUR Tatsachen: Kontostand, Guthaben, Kaufpreis, Restschuld. Renditen,
// Lohnentwicklung und Teuerung stehen bewusst nicht hier -- sie sind Annahmen, und eine
// Annahme gilt immer nur für einen Zeitraum. Sie werden in den Phasenzellen der Matrix
// gesetzt und dort bestätigt (SPEZIFIKATION 3.14).
import { useState } from "react";
import { Check, Plus, Trash2 } from "lucide-react";
import { api } from "@/lib/api-client";
import { Button, Modal, useConfirm } from "@/components/ui";
import { MoneyField, NumberField, SelectField, TextField } from "@/components/FormField";
import { formatChf } from "@/lib/format";
import { CATEGORY_LABELS, ownerLabel, num, type PhaseData } from "@/lib/elements";
import type { PlanInput } from "@/lib/types";
type Scope = "HOUSEHOLD" | "PERSON_A" | "PERSON_B";
interface Kind {
key: string;
category: string;
// Beschriftung des Knopfs. Bewusst der KATEGORIENAME und keine erfundene Umgangssprache:
// Die Matrix, die Rechenwege und der PDF-Bericht sprechen dieselbe Sprache -- wer hier eine
// andere Vokabel lernt, sucht sie spaeter vergeblich. Die Alltagssprache steht in `hint`.
label: string;
// Vorschlag fuer den Namen des angelegten Elements. Hier darf es konkret werden: Die
// Kategorie ist "Ausgaben", die Zeile heisst "Lebenshaltung".
defaultName: string;
hint: string;
personOnly?: boolean;
// SCHRITT 1 -- der Bestand von heute. Nur Zahlen, die man nachschlagen kann: Kontostand,
// Guthaben, Kaufpreis, Restschuld.
baseFields: (v: PhaseData, set: (p: Partial<PhaseData>) => void) => React.ReactNode;
// SCHRITT 2 -- die Annahmen fuer EINE Lebensphase. Alles, was pro Jahr geschieht und sich
// von Phase zu Phase aendern darf: Renditen, Lohnentwicklung, Wertsteigerung, Zins.
//
// Die Trennung ist der Kern: Ein Guthaben ist eine Tatsache, eine Rendite eine Annahme --
// und Annahmen gelten immer nur fuer einen Zeitraum. Bis 0.37 standen beide zusammen in der
// Bestandsaufnahme, wodurch eine Annahme wie eine Tatsache aussah.
phaseFields?: (v: PhaseData, set: (p: Partial<PhaseData>) => void) => React.ReactNode;
}
const KINDS: Kind[] = [
{
key: "income",
category: "INCOME",
label: "Einkommen",
defaultName: "Lohn",
hint: "Dein Nettolohn das, was auf dem Konto ankommt.",
personOnly: true,
baseFields: (v, set) => (
<MoneyField
label="Nettolohn pro Jahr"
help="Was tatsächlich aufs Konto kommt, nach allen Abzügen. FPT rechnet für die AHV intern auf den Bruttolohn hoch."
value={num(v.amount)}
onChange={(x) => set({ amount: x })}
/>
),
phaseFields: (v, set) => (
<NumberField
label="Jährliche Lohnerhöhung (%)"
help="Nominal, also inklusive Teuerung. 1 % ist ein vorsichtiger Ausgangswert."
step={0.5}
value={num(v.teuerungsausgleich, 1)}
onChange={(x) => set({ teuerungsausgleich: x })}
/>
),
},
{
key: "expense",
category: "EXPENSE",
label: "Ausgaben",
defaultName: "Lebenshaltung",
hint: "Alles, was du im Jahr ausgibst Wohnen, Essen, Versicherungen, Freizeit.",
baseFields: (v, set) => (
<MoneyField
label="Ausgaben pro Jahr"
help="In heutiger Kaufkraft. Die Teuerung rechnet FPT selbst dazu."
value={num(v.amount)}
onChange={(x) => set({ amount: x })}
/>
),
phaseFields: (v, set) => (
<NumberField
label="Jährliche Teuerung (%)"
help="Leer lassen heisst: die Inflationsannahme des Szenarios. Eine eigene Zahl nur, wenn du für diese Ausgaben etwas anderes erwartest."
step={0.25}
value={num(v.teuerungsausgleich)}
onChange={(x) => set({ teuerungsausgleich: x })}
/>
),
},
{
key: "pk",
category: "PENSION_FUND",
label: "Pensionskasse",
defaultName: "Pensionskasse",
hint: "Dein Altersguthaben laut PK-Ausweis.",
personOnly: true,
baseFields: (v, set) => (
<MoneyField
label="Altersguthaben heute"
help="Steht auf deinem Vorsorgeausweis."
value={num(v.currentValue)}
onChange={(x) => set({ currentValue: x })}
/>
),
phaseFields: (v, set) => (
<>
<NumberField
label="Verzinsung (%/Jahr)"
help="Der BVG-Mindestzins liegt aktuell tief; viele Kassen verzinsen etwas darüber."
step={0.25}
value={num(v.expectedReturn, 1.25)}
onChange={(x) => set({ expectedReturn: x })}
/>
{/* Der PK-Beitrag steht hier und nicht bei der Sparquote: Er stammt aus dem
Bruttolohn und belastet das Cash-Konto nicht -- aus der Quote liesse er sich
gar nicht verteilen (SPEZIFIKATION 4.6.3). */}
<MoneyField
label="Jährlicher Beitrag (Arbeitnehmer + Arbeitgeber)"
help="Die Altersgutschrift laut Vorsorgeausweis. Sie kommt aus dem Bruttolohn und schmälert deine Sparquote deshalb nicht."
value={num(v.annualContribution)}
onChange={(x) => set({ annualContribution: x })}
/>
</>
),
},
{
key: "p3a",
category: "PILLAR_3A",
label: "Säule 3a",
defaultName: "Säule 3a",
hint: "Ein Konto oder Depot der gebundenen Vorsorge. Mehrere Konten legst du einzeln an.",
personOnly: true,
baseFields: (v, set) => (
<MoneyField label="Guthaben heute" value={num(v.currentValue)} onChange={(x) => set({ currentValue: x })} />
),
phaseFields: (v, set) => (
<NumberField
label="Erwartete Rendite (%/Jahr)"
help="Auf einem Konto praktisch 0, in einer Wertschriftenlösung deutlich mehr."
step={0.5}
value={num(v.expectedReturn, 2)}
onChange={(x) => set({ expectedReturn: x })}
/>
),
},
{
key: "etf",
category: "OTHER_ASSET",
label: "Sonstiges Vermögen",
defaultName: "Wertschriften",
hint: "Depot, ETF, Sparkonto alles freie Vermögen ausser dem Cash-Konto.",
baseFields: (v, set) => (
<MoneyField label="Wert heute" value={num(v.startValue)} onChange={(x) => set({ startValue: x })} />
),
phaseFields: (v, set) => (
<NumberField
label="Erwartete Rendite (%/Jahr)"
help="Langfristiger Durchschnitt. Für ein gemischtes Portfolio sind 3 bis 5 % ein üblicher Ansatz."
step={0.5}
value={num(v.expectedReturn, 4)}
onChange={(x) => set({ expectedReturn: x })}
/>
),
},
{
key: "re",
category: "REAL_ESTATE",
label: "Immobilie",
defaultName: "Wohneigentum",
hint: "Haus oder Wohnung samt Hypothek.",
baseFields: (v, set) => (
<>
<MoneyField
label="Kaufpreis"
help="Der ursprüngliche Kaufpreis Grundlage der Grundstückgewinnsteuer bei einem späteren Verkauf."
value={num(v.purchasePrice)}
onChange={(x) => set({ purchasePrice: x })}
/>
<MoneyField label="Hypothek heute" value={num(v.mortgage)} onChange={(x) => set({ mortgage: x })} />
</>
),
phaseFields: (v, set) => (
<>
<NumberField
label="Hypothekarzins (%)"
step={0.25}
value={num(v.interestRate, 2)}
onChange={(x) => set({ interestRate: x })}
/>
<NumberField
label="Wertsteigerung (%/Jahr)"
help="Auf den Wert der Liegenschaft, nicht auf dein Eigenkapital dort wirkt sie gehebelt."
step={0.25}
value={num(v.valueGrowth, 1)}
onChange={(x) => set({ valueGrowth: x })}
/>
<div className="sm:col-span-2">
<SelectField
label="Sind die Hypothekarzinsen in deinen Ausgaben schon enthalten?"
help="Wenn ja, rechnet FPT sie nicht noch einmal dazu sonst zählten sie doppelt."
value={v.interestHandling ?? "INCLUDED"}
onChange={(x: "INCLUDED" | "ADD") => set({ interestHandling: x })}
options={[
{ value: "INCLUDED", label: "Ja, sind in der Lebenshaltung enthalten" },
{ value: "ADD", label: "Nein, bitte zusätzlich rechnen" },
]}
/>
</div>
</>
),
},
{
key: "debt",
category: "OTHER_DEBT",
label: "Sonstige Schulden",
defaultName: "Schulden",
hint: "Kredite ausserhalb der Hypothek.",
baseFields: (v, set) => (
<MoneyField label="Restschuld heute" value={num(v.startValue)} onChange={(x) => set({ startValue: x })} />
),
},
];
// Der Entwurf des GANZEN Schritts, nicht der einzelnen Karte.
//
// Zwei Gruende dafuer, dass er hier oben liegt und nicht in der Karte: Beim Wechsel des
// Reiters verschwinden die Karten des vorigen Bereichs vom Bildschirm -- mit ihrem eigenen
// Zustand waeren alle getippten Zahlen still weg. Und die Summe "Vermoegen heute" laesst sich
// nur berechnen, wenn alle Entwuerfe an EINEM Ort liegen.
export interface InventoryDraft {
base: Record<string, PhaseData>;
names: Record<string, string>;
// Cash ist kein Element, sondern eine Eigenschaft des Szenarios (`Scenario.initialCash`) --
// deshalb kein Plus-Knopf, sondern ein festes Feld. Es fehlte bis 0.39 ganz: Die Summe
// «Vermögen heute» rechnete es bereits mit, erfassen konnte man es hier aber nirgends.
// `null` = unberührt (dann wird beim Speichern auch nichts geschrieben).
cash: number | null;
}
export function emptyInventoryDraft(): InventoryDraft {
return { base: {}, names: {}, cash: null };
}
// Bestand eines Elements aus Stammdaten -- dieselbe Rechnung wie in der Kachel «Kennzahlen»,
// hier nur auf dem Entwurf statt auf dem gespeicherten Stand.
function wealthOf(category: string, d: PhaseData): number {
if (category === "OTHER_ASSET") return num(d.startValue);
if (category === "PENSION_FUND" || category === "PILLAR_3A") return num(d.currentValue);
if (category === "REAL_ESTATE") return num(d.purchasePrice) - num(d.mortgage);
if (category === "OTHER_DEBT") return -num(d.startValue);
return 0;
}
// Die Stammdaten-Felder einer Kategorie. Eigene Komponente, weil sie an ZWEI Orten
// erscheinen: im Assistenten-Schritt 1 (als Liste) und in der Matrix-Spalte «Start» (einzeln).
// Zwei Implementierungen derselben Felder liefen garantiert auseinander.
export function BaseFields({
category,
values,
set,
}: {
category: string;
values: PhaseData;
set: (patch: Partial<PhaseData>) => void;
}) {
const kind = KINDS.find((k) => k.category === category);
return <>{kind?.baseFields(values, set)}</>;
}
export function InventoryFields({
plan,
draft,
setDraft,
onChanged,
}: {
plan: PlanInput;
draft: InventoryDraft;
setDraft: (fn: (d: InventoryDraft) => InventoryDraft) => void;
onChanged: () => void;
}) {
const scopes: Scope[] =
plan.householdType === "COUPLE" ? ["HOUSEHOLD", "PERSON_A", "PERSON_B"] : ["PERSON_A"];
const [page, setPage] = useState(0);
const scope = scopes[page];
const cash = draft.cash ?? Math.round(plan.initialCash || 0);
// Live-Summe aus dem ENTWURF: Sie soll sich beim Tippen bewegen, nicht erst beim Speichern.
const today = plan.elements.reduce(
(sum, e) => sum + wealthOf(e.category, draft.base[e.id] ?? e.baseData ?? {}),
cash
);
return (
<div className="flex flex-col gap-4">
{/* Cash steht ausserhalb der Reiter: Es ist kein Element und gehört keiner Person --
es ist der Topf, aus dem alles fliesst und in den alles zurückfliesst. */}
<div className="rounded-xl border border-border bg-surface-2 p-3">
<MoneyField
label="Cash heute (Konto- und Sparguthaben)"
help="Alles, was sofort verfügbar ist. Aus diesem Topf zahlt FPT die Ausgaben; was du sparst und nicht zuteilst, bleibt hier liegen."
value={cash}
onChange={(x) => setDraft((d) => ({ ...d, cash: x }))}
/>
</div>
{scopes.length > 1 && (
<div className="flex gap-1 rounded-lg border border-border bg-surface-2 p-1 text-xs">
{scopes.map((sc, i) => (
<button
key={sc}
type="button"
onClick={() => setPage(i)}
className={`flex-1 rounded-md px-2 py-1 font-medium ${
page === i ? "bg-accent text-accent-fg" : "text-muted hover:bg-surface"
}`}
>
{sc === "HOUSEHOLD" ? "Gemeinsam" : ownerLabel(plan.persons, sc)}
</button>
))}
</div>
)}
<ScopeElements plan={plan} scope={scope} draft={draft} setDraft={setDraft} onChanged={onChanged} />
{/* Die einzige Zahl, die schon vor jeder Zeitplanung etwas aussagt -- und die erste
Rueckmeldung auf die Bestandsaufnahme. */}
<div className="sticky bottom-0 flex items-baseline justify-between gap-3 border-t border-border bg-surface px-1 pt-3">
<span className="text-xs font-semibold uppercase tracking-wide text-faint">Vermögen heute</span>
<span className="text-lg font-semibold tabular-nums text-fg">{formatChf(today)}</span>
</div>
</div>
);
}
function ScopeElements({
plan,
scope,
draft,
setDraft,
onChanged,
}: {
plan: PlanInput;
scope: Scope;
draft: InventoryDraft;
setDraft: (fn: (d: InventoryDraft) => InventoryDraft) => void;
onChanged: () => void;
}) {
const [busy, setBusy] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const kinds = KINDS.filter((k) => (scope === "HOUSEHOLD" ? !k.personOnly : true));
const mine = plan.elements.filter((e) => (e.ownerRole ?? "HOUSEHOLD") === scope);
// Das Element entsteht SOFORT -- es braucht eine Id, an der die Werte haengen. Nur die
// Zahlen werden bis zum Abschluss des Schritts zurueckgehalten.
async function add(kind: Kind) {
setBusy(kind.key);
setError(null);
try {
const suffix =
plan.householdType === "COUPLE" && scope !== "HOUSEHOLD" ? ` ${ownerLabel(plan.persons, scope)}` : "";
await api.post(`/api/scenarios/${plan.id}/elements`, {
category: kind.category,
name: `${kind.defaultName}${suffix}`,
ownerRole: scope,
});
onChanged();
} catch (e) {
setError(e instanceof Error ? e.message : "Anlegen fehlgeschlagen.");
} finally {
setBusy(null);
}
}
return (
<div className="flex flex-col gap-3">
{/* Der Knopf traegt den Kategorienamen, die Erklaerung steht darunter -- so lernt man
beim ersten Kontakt die Vokabel, die auch die Matrix verwendet. */}
<div className="grid gap-1.5 sm:grid-cols-2 lg:grid-cols-3">
{kinds.map((k) => (
<button
key={k.key}
type="button"
disabled={busy === k.key}
onClick={() => add(k)}
className="flex items-start gap-1.5 rounded-lg border border-dashed border-border px-2.5 py-1.5 text-left transition-colors hover:border-accent hover:bg-accent-soft/20"
>
<Plus className="mt-0.5 h-3.5 w-3.5 shrink-0 text-accent" />
<span className="min-w-0">
<span className="block text-xs font-semibold text-fg">{k.label}</span>
<span className="block text-[11px] leading-snug text-muted">{k.hint}</span>
</span>
</button>
))}
</div>
{mine.length === 0 ? (
<p className="rounded-lg border border-dashed border-border bg-surface-2 p-3 text-sm text-muted">
Noch nichts erfasst. Wähle oben, was zutrifft du kannst jederzeit weitere Positionen ergänzen.
</p>
) : (
<div className="flex flex-col gap-2">
{mine.map((el) => (
<BaseDataCard
key={el.id}
plan={plan}
elementId={el.id}
draft={draft}
setDraft={setDraft}
onChanged={onChanged}
/>
))}
</div>
)}
{error && <p className="text-sm text-danger">{error}</p>}
</div>
);
}
function BaseDataCard({
plan,
elementId,
draft,
setDraft,
onChanged,
}: {
plan: PlanInput;
elementId: string;
draft: InventoryDraft;
setDraft: (fn: (d: InventoryDraft) => InventoryDraft) => void;
onChanged: () => void;
}) {
const el = plan.elements.find((x) => x.id === elementId)!;
const kind = KINDS.find((k) => k.category === el.category);
const values = draft.base[el.id] ?? el.baseData ?? {};
const name = draft.names[el.id] ?? el.name;
const set = (patch: Partial<PhaseData>) =>
setDraft((d) => ({ ...d, base: { ...d.base, [el.id]: { ...(d.base[el.id] ?? el.baseData ?? {}), ...patch } } }));
async function remove() {
await api.delete(`/api/elements/${el.id}`);
setDraft((d) => {
const base = { ...d.base };
const names = { ...d.names };
delete base[el.id];
delete names[el.id];
return { ...d, base, names };
});
onChanged();
}
return (
<div className="rounded-xl border border-border p-3">
<div className="mb-2 flex items-center gap-2">
<span className="text-xs font-semibold uppercase tracking-wide text-faint">
{CATEGORY_LABELS[el.category]}
</span>
<button
type="button"
onClick={remove}
className="ml-auto rounded p-1 text-faint hover:bg-danger-soft hover:text-danger"
aria-label="Entfernen"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div className="sm:col-span-2">
<TextField
label="Bezeichnung"
value={name}
onChange={(v) => setDraft((d) => ({ ...d, names: { ...d.names, [el.id]: v } }))}
/>
</div>
{kind ? (
kind.baseFields(values, set)
) : (
// Die AHV ist die einzige Kategorie ohne Bestand: Es gibt kein Guthaben zum
// Nachschlagen, nur eine Beitragskarriere. Eine leere Karte sähe nach einem Fehler
// aus -- deshalb steht hier, warum nichts zu tun ist.
<p className="sm:col-span-2 rounded-lg bg-surface-2 px-3 py-2 text-xs text-muted">
Hier gibt es nichts zu erfassen. Die AHV-Rente entsteht aus deiner Beitragskarriere FPT leitet sie
aus den Einkommen dieses Plans ab. Beim Übergang in die Pension kannst du sie prüfen und um
Beitragslücken vor Planbeginn ergänzen.
</p>
)}
</div>
</div>
);
}
// Schreibt den gesammelten Entwurf. Wird vom Schritt-Dialog beim Abschliessen aufgerufen --
// es gibt bewusst keinen Speichern-Knopf je Karte mehr.
export async function saveInventoryDraft(plan: PlanInput, draft: InventoryDraft): Promise<void> {
for (const el of plan.elements) {
const nextName = draft.names[el.id]?.trim();
if (nextName && nextName !== el.name) {
await api.patch(`/api/elements/${el.id}`, { name: nextName });
}
const base = draft.base[el.id];
if (base) await api.put(`/api/elements/${el.id}/base`, base);
}
// Cash hängt am Szenario, nicht an einem Element -- eigener Endpunkt.
if (draft.cash !== null && draft.cash !== plan.initialCash) {
await api.patch(`/api/scenarios/${plan.id}`, { initialCash: draft.cash });
}
// Die AHV entsteht am Schluss von selbst -- je Person eine.
//
// Sie hat als einzige Kategorie KEINEN Bestand: Es gibt kein Guthaben, das man nachschlagen
// könnte, nur eine Beitragskarriere. Ein Plus-Knopf mit einer leeren Karte darunter wäre
// deshalb sinnlos gewesen. Weglassen ist aber keine Option: In der Schweiz hat jede Person
// eine AHV, und ohne das Element rechnet der Plan ab der Pensionierung mit einer Rente von 0.
//
// IDEMPOTENT: Wer die Bestandsaufnahme später korrigiert und erneut abschliesst, bekommt
// kein zweites Element -- geprüft wird je Person, nicht global.
for (const person of plan.persons) {
const exists = plan.elements.some((e) => e.category === "AHV" && e.ownerRole === person.role);
if (exists) continue;
const name = person.name?.trim() ? `AHV ${person.name.trim()}` : "AHV";
await api.post(`/api/scenarios/${plan.id}/elements`, {
category: "AHV",
name,
ownerRole: person.role,
});
}
}
// --- Der Dialog drumherum -------------------------------------------------------------------
//
// Gespeichert wird EINMAL, beim Abschliessen. Ein Speichern-Knopf je Element hätte bedeutet,
// dass ein Reiter-Wechsel die getippten Zahlen verwirft -- deshalb liegt der Entwurf hier
// oben und nicht in den einzelnen Karten.
export function InventoryDialog({
plan,
onClose,
onChanged,
onSaved,
}: {
plan: PlanInput;
onClose: () => void;
// Der Plan hat sich geaendert (Element angelegt oder geloescht) -- neu laden, aber den
// Dialog OFFEN lassen. Getrennt von onSaved, denn das schliesst ihn.
onChanged: () => void;
onSaved: () => void;
}) {
const [draft, setDraft] = useState<InventoryDraft>(emptyInventoryDraft);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const confirm = useConfirm();
const dirty =
Object.keys(draft.base).length > 0 ||
Object.keys(draft.names).length > 0 ||
(draft.cash !== null && draft.cash !== plan.initialCash);
async function closeGuarded() {
if (!dirty) return onClose();
const ok = await confirm({
title: "Eingaben verwerfen?",
message:
"Du hast Angaben erfasst, die noch nicht gespeichert sind. Schliesst du jetzt, gehen sie verloren. " +
"Mit «Bestandsaufnahme abschliessen» werden sie übernommen.",
confirmLabel: "Verwerfen",
danger: true,
});
if (ok) onClose();
}
async function save() {
setSaving(true);
setError(null);
try {
await saveInventoryDraft(plan, draft);
setDraft(emptyInventoryDraft());
onSaved();
} catch (e) {
setError(e instanceof Error ? e.message : "Speichern fehlgeschlagen.");
setSaving(false);
}
}
return (
<Modal
title="Bestandsaufnahme"
subtitle="Alles, was du heute hast Einkommen, Ausgaben, Guthaben, Immobilien, Schulden"
onClose={closeGuarded}
xwide
>
<div className="flex flex-col gap-4">
<div className="rounded-xl border border-border bg-surface-2 p-3 text-xs leading-relaxed text-muted">
Hier stehen nur Zahlen, die du <strong className="text-fg">nachschlagen</strong> kannst. Renditen,
Lohnentwicklung und Teuerung kommen später sie sind Annahmen, und eine Annahme gilt immer nur für
einen bestimmten Zeitraum. Du triffst sie in den Lebensphasen.
</div>
<InventoryFields
plan={plan}
draft={draft}
setDraft={(fn) => setDraft((d) => fn(d))}
onChanged={onChanged}
/>
{error && <p className="text-sm text-danger">{error}</p>}
<div className="flex items-center justify-between border-t border-border pt-3">
<Button variant="ghost" onClick={closeGuarded}>
Abbrechen
</Button>
<Button disabled={saving} onClick={save}>
<Check className="h-4 w-4" /> {saving ? "Wird gespeichert…" : "Bestandsaufnahme abschliessen"}
</Button>
</div>
</div>
</Modal>
);
}
+732
View File
@@ -0,0 +1,732 @@
"use client";
// Die Kartenansicht: eine Karte je Lebensphase (SPEZIFIKATION 3.15).
//
// Sie loest das Grundproblem der Matrix, nicht dessen Symptome. Die Matrix stellt Phasen
// NEBENEINANDER -- gut zum Vergleichen, schlecht zum Arbeiten: Ab der vierten Phase scrollt
// man horizontal, und das rechte Bearbeitungs-Panel legte sich ausgerechnet ueber die Spalte,
// zu der man gerade gescrollt hatte. Eine Karte zeigt genau eine Phase, ueber die ganze
// Breite, und getippt wird in der Karte selbst. Horizontales Scrollen gibt es nicht mehr.
//
// Die Karte erzaehlt die Phase als ZEITABLAUF in drei Abschnitten:
// 1. Eintritt -- was beim Wechsel in diese Phase geschah (Uebergang aus der Vorphase).
// Bei der ersten Phase stattdessen der Bestand aus der Bestandsaufnahme.
// 2. Laufend -- was in diesen Jahren gilt: die Elemente mit ihren Annahmen.
// 3. Ergebnis -- was am Ende dasteht.
//
// Der Uebergang gehoert bewusst zur FOLGEphase und nicht ans Ende der Vorphase: Das dabei
// frei werdende Kapital wird in der Folgephase verteilt (seit 0.41 auch in der Anzeige, Kap.
// 3.12.5). Der Preis ist, dass man den Hausverkauf gedanklich am Ende von Phase N trifft, ihn
// aber auf Karte N+1 findet -- bewusst in Kauf genommen, weil dort die Folgen stehen.
//
// Die Matrix bleibt als VERGLEICHSANSICHT bestehen (Diff, Plan/Ist, Verlauf ueber alle
// Phasen) -- dasselbe Datenmaterial, zwei Sichten.
import { useEffect, useState } from "react";
import {
AlertTriangle,
ChevronDown,
ChevronLeft,
ChevronRight,
Coins,
Maximize2,
Pencil,
PiggyBank,
Plus,
Trash2,
Wallet,
} from "lucide-react";
import { Button } from "@/components/ui";
import {
CashTransitionFields,
ElementDetail,
cashTransitionSummary,
isCashTransitionAnswered,
withCashTransitionDefaults,
type CellContext,
} from "@/components/ElementDetail";
import { api } from "@/lib/api-client";
import { CATEGORY_LABELS, ownerLabel, type CashTransitionData, type PhaseData } from "@/lib/elements";
import { capitalPot } from "@/lib/distribution";
import { formatChf } from "@/lib/format";
import { unconfirmedCells } from "@/lib/review";
import type { PhaseComputed, PlanComputed, ElementPhaseComputed } from "@/lib/calculations";
import type { ElementInput, PlanInput } from "@/lib/types";
import type { PhaseRef } from "@/lib/ratefields";
export type ValueMode = "nominal" | "both" | "real";
// Alles, was die Karte an ihre Umgebung zurueckmeldet. Bewusst ein flaches Buendel statt
// vieler Einzel-Props: Die Karte ist eine Ansicht, keine Datenhaltung.
export interface CardActions {
onEditPhase: (phaseId: string) => void;
onDeletePhase: (phaseId: string, name: string) => void;
onAddPhase: () => void;
onAddElement: () => void;
onDeleteElement: (elementId: string, name: string) => void;
onDistributeRates: (phaseId: string) => void;
onDistributeCapital: (phaseId: string) => void;
onCashTransition: (fromPhaseId: string) => void;
onOpenPhaseDetail: (phaseId: string) => void;
onChanged: () => void;
}
interface Props {
plan: PlanInput;
computed: PlanComputed;
mode: ValueMode;
activePhaseId: string | null;
onSelectPhase: (phaseId: string) => void;
phaseContext: (phase: PhaseComputed, el: ElementInput) => CellContext;
transitionContext: (fromPhase: PhaseComputed, toPhase: PhaseComputed | undefined, el: ElementInput) => CellContext;
// Elemente, die am Uebergang NACH einer Phase ueberhaupt etwas zu entscheiden haben.
transitionElements: (fromPhase: PhaseComputed, toPhase: PhaseComputed | undefined) => ElementInput[];
cashTransitionFor: (phaseId: string) => CashTransitionData;
actions: CardActions;
}
const real = (v: number, d: number) => Math.round(v / (d || 1));
function val(v: number, deflator: number, mode: ValueMode): string {
if (mode === "real") return formatChf(real(v, deflator));
if (mode === "both") return `${formatChf(v)} (${formatChf(real(v, deflator))})`;
return formatChf(v);
}
export function PhaseCards({
plan,
computed,
mode,
activePhaseId,
onSelectPhase,
phaseContext,
transitionContext,
transitionElements,
cashTransitionFor,
actions,
}: Props) {
const phases = computed.phases;
const index = Math.max(0, phases.findIndex((p) => p.id === activePhaseId));
const phase = phases[index];
// Pfeiltasten. Der dritte Weg neben Zeitachse und den Knoepfen unten -- wer eine Phase nach
// der anderen durchgeht, will die Hand nicht an der Maus haben. Eingabefelder duerfen davon
// nicht betroffen sein, sonst springt die Karte beim Bearbeiten eines Betrags weg.
useEffect(() => {
function onKey(e: KeyboardEvent) {
if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;
const t = e.target as HTMLElement | null;
if (t && (t.tagName === "INPUT" || t.tagName === "SELECT" || t.tagName === "TEXTAREA" || t.isContentEditable)) return;
if (e.metaKey || e.ctrlKey || e.altKey) return;
const next = e.key === "ArrowLeft" ? index - 1 : index + 1;
if (next < 0 || next >= phases.length) return;
e.preventDefault();
onSelectPhase(phases[next].id);
}
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [index, phases, onSelectPhase]);
if (!phase) return null;
return (
<div className="flex flex-col gap-3">
{/* Die Karte laeuft NICHT ueber die volle Breite. Eine Zeile aus 1300 px liest sich
schlecht -- das Auge verliert beim Zeilenwechsel den Anschluss, und Beschriftung und
Wert driften so weit auseinander, dass man sie nicht mehr zusammenbringt. Links und
rechts bleibt Luft, und die traegt die beiden grossen Pfeile. */}
<div className="flex items-stretch justify-center gap-3">
<SideArrow
dir="left"
label={phases[index - 1]?.name}
disabled={index === 0}
onClick={() => index > 0 && onSelectPhase(phases[index - 1].id)}
/>
<div className="min-w-0 flex-1" style={{ maxWidth: "46rem" }}>
<PhaseCard
key={phase.id}
plan={plan}
computed={computed}
phase={phase}
previous={phases[index - 1]}
mode={mode}
phaseContext={phaseContext}
transitionContext={transitionContext}
transitionElements={transitionElements}
cashTransitionFor={cashTransitionFor}
actions={actions}
/>
</div>
<SideArrow
dir="right"
label={phases[index + 1]?.name}
disabled={index === phases.length - 1}
onClick={() => index < phases.length - 1 && onSelectPhase(phases[index + 1].id)}
/>
</div>
<div className="flex items-center justify-center gap-2 text-xs text-muted">
<button
type="button"
disabled={index === 0}
onClick={() => onSelectPhase(phases[index - 1].id)}
className="flex items-center gap-1 rounded-lg border border-border px-2.5 py-1.5 transition-colors hover:bg-surface-2 disabled:opacity-40"
>
<ChevronLeft className="h-4 w-4" />
{phases[index - 1]?.name ?? "Anfang"}
</button>
<span className="px-2 tabular-nums">
Phase {index + 1} von {phases.length}
</span>
{index === phases.length - 1 ? (
<button
type="button"
onClick={actions.onAddPhase}
className="flex items-center gap-1 rounded-lg border border-dashed border-accent px-2.5 py-1.5 font-medium text-accent transition-colors hover:bg-accent hover:text-accent-fg"
>
<Plus className="h-4 w-4" /> Lebensphase
</button>
) : (
<button
type="button"
onClick={() => onSelectPhase(phases[index + 1].id)}
className="flex items-center gap-1 rounded-lg border border-border px-2.5 py-1.5 transition-colors hover:bg-surface-2"
>
{phases[index + 1].name}
<ChevronRight className="h-4 w-4" />
</button>
)}
</div>
</div>
);
}
// --- Die Karte -----------------------------------------------------------------------------
function PhaseCard({
plan,
computed,
phase,
previous,
mode,
phaseContext,
transitionContext,
transitionElements,
cashTransitionFor,
actions,
}: {
plan: PlanInput;
computed: PlanComputed;
phase: PhaseComputed;
previous: PhaseComputed | undefined;
mode: ValueMode;
phaseContext: (phase: PhaseComputed, el: ElementInput) => CellContext;
transitionContext: (fromPhase: PhaseComputed, toPhase: PhaseComputed | undefined, el: ElementInput) => CellContext;
transitionElements: (fromPhase: PhaseComputed, toPhase: PhaseComputed | undefined) => ElementInput[];
cashTransitionFor: (phaseId: string) => CashTransitionData;
actions: CardActions;
}) {
// Welche Zeilen sind aufgeklappt? Beim Wechsel der Karte faengt es wieder bei zu an --
// die Karte wird ueber `key` neu montiert.
const [open, setOpen] = useState<Record<string, boolean>>({});
const rows = phase.elements.filter((e) => e.status !== "SOLD" || e.startValue !== 0);
const openCount = unconfirmedCells(plan, computed, phase.id).length;
const ratesOpen = plan.phases.find((p) => p.id === phase.id)?.ratesConfirmed !== true;
const pot = capitalPot(phase);
const capitalOpen = pot.total > 0 && pot.allocatedInvestments + pot.allocatedRepayments === 0;
const transEls = previous ? transitionElements(previous, phase) : [];
const ct = previous ? cashTransitionFor(previous.id) : null;
const cashOpen = ct ? !isCashTransitionAnswered(ct) : false;
// Ein Knopf fuer alle Zeilen der Karte. Bei acht Elementen ist das der Unterschied zwischen
// «einmal durchsehen» und acht Klicks.
const allKeys = [...transEls.map((e) => `t:${e.id}`), ...rows.map((e) => `p:${e.elementId}`)];
const anyOpen = allKeys.some((k) => open[k]);
const toggleAll = () =>
setOpen(anyOpen ? {} : Object.fromEntries(allKeys.map((k) => [k, true])));
const dS = phase.cumulativeInflationStart;
const dE = phase.cumulativeInflationEnd;
const years =
plan.startYear != null
? (() => {
const before = computed.phases
.slice(0, computed.phases.findIndex((p) => p.id === phase.id))
.reduce((s, p) => s + p.durationYears, 0);
return `${plan.startYear + before}${plan.startYear + before + phase.durationYears - 1}`;
})()
: null;
return (
<article className="ui-fade overflow-hidden rounded-2xl border border-border bg-surface shadow-sm">
{/* Kopf: ruhig und gross. Er beantwortet «wo bin ich» in einem Blick. */}
<header className="flex flex-wrap items-start gap-3 px-5 py-4">
<div className="min-w-0">
<h2 className="truncate text-lg font-semibold text-fg">{phase.name}</h2>
<p className="mt-0.5 text-xs text-muted">
{years ? `${years} · ` : ""}
{phase.durationYears} {phase.durationYears === 1 ? "Jahr" : "Jahre"}
{phase.persons.length > 0 ? " · " : ""}
{phase.persons
.map((p) => `${ownerLabel(plan.persons, p.role)} ${p.startAge}${p.endAge}`)
.join(", ")}
</p>
</div>
<div className="ml-auto flex flex-wrap items-center gap-1.5">
{openCount + (ratesOpen ? 1 : 0) + (cashOpen ? 1 : 0) > 0 && (
<span className="flex items-center gap-1 rounded-full bg-attention-soft px-2 py-0.5 text-[11px] font-semibold text-attention-soft-fg">
<AlertTriangle className="h-3 w-3" />
{openCount + (ratesOpen ? 1 : 0) + (cashOpen ? 1 : 0)} offen
</span>
)}
{allKeys.length > 0 && (
<button
type="button"
onClick={toggleAll}
className="rounded-lg border border-border px-2 py-1 text-[11px] font-medium text-muted transition-colors hover:bg-surface-2 hover:text-fg"
>
{anyOpen ? "Alle zuklappen" : "Alle aufklappen"}
</button>
)}
<IconBtn label="Bezeichnung und Dauer ändern" onClick={() => actions.onEditPhase(phase.id)}>
<Pencil className="h-4 w-4" />
</IconBtn>
<IconBtn label="Detailansicht" onClick={() => actions.onOpenPhaseDetail(phase.id)}>
<Maximize2 className="h-4 w-4" />
</IconBtn>
{phase.sequenceNumber === computed.phases.length && (
<IconBtn label="Lebensphase löschen" danger onClick={() => actions.onDeletePhase(phase.id, phase.name)}>
<Trash2 className="h-4 w-4" />
</IconBtn>
)}
</div>
</header>
{/* 1 · EINTRITT */}
<Section
step={1}
title={previous ? "Eintritt" : "Start"}
hint={
previous
? `Was beim Wechsel aus «${previous.name}» geschah`
: "Der Bestand bei Planbeginn erfasst in der Bestandsaufnahme"
}
>
{!previous ? (
<p className="px-1 py-2 text-xs text-muted">
Diese Phase beginnt mit dem heutigen Stand. Startwerte änderst du in der
Bestandsaufnahme oder über die Zeile selbst.
</p>
) : (
<>
{ct && (
// Cash verhielt sich bis 0.42.1 als einziger Eintritts-Punkt anders: Ein Klick
// oeffnete das rechte Panel statt aufzuklappen. Auf einer Karte, die genau davon
// wegwollte, war das ein Fremdkoerper.
<Row
open={!!open["cash"]}
onToggle={() => setOpen((pr) => ({ ...pr, cash: !pr.cash }))}
icon={<Wallet className="h-4 w-4" />}
name="Cash"
meta="einmalige Sonderein-/ausgaben"
attention={cashOpen}
right={<span className="tabular-nums">{cashTransitionSummary(ct)}</span>}
>
<CashTransitionEditor
fromPhaseId={previous.id}
initial={ct}
deflatorEnd={previous.cumulativeInflationEnd}
onSaved={actions.onChanged}
/>
</Row>
)}
{transEls.length === 0 && !cashOpen && (
<p className="px-1 py-2 text-xs text-muted">An diesem Übergang gibt es nichts zu entscheiden.</p>
)}
{transEls.map((el) => {
const key = `t:${el.id}`;
const ctx = transitionContext(previous, phase, el);
return (
<Row
key={key}
open={!!open[key]}
onToggle={() => setOpen((p) => ({ ...p, [key]: !p[key] }))}
icon={<Coins className="h-4 w-4" />}
name={el.name}
meta={`${CATEGORY_LABELS[el.category]} · ${ownerLabel(plan.persons, el.ownerRole)}`}
right={null}
>
<ElementDetail
compact
element={el}
context={ctx}
phaseData={el.phaseValues[phase.id] ?? {}}
transitionData={el.transitionValues[previous.id] ?? {}}
onSaved={actions.onChanged}
onDeleteElement={() => actions.onDeleteElement(el.id, el.name)}
/>
</Row>
);
})}
<div className="pt-1">
<QuotaButton
label="Kapital verteilen"
open={capitalOpen}
hidden={!pot.total && !pot.allocatedInvestments && !pot.allocatedRepayments}
sub={`${formatChf(pot.total)} verfügbar · ${formatChf(pot.rest)} bleibt Cash`}
onClick={() => actions.onDistributeCapital(phase.id)}
/>
</div>
</>
)}
</Section>
{/* 2 · LAUFEND */}
<Section
step={2}
title="Laufend"
hint={`Was in diesen ${phase.durationYears} Jahren gilt`}
action={
<button
type="button"
onClick={actions.onAddElement}
className="flex items-center gap-1 rounded-lg border border-border px-2 py-1 text-[11px] font-medium text-muted transition-colors hover:bg-surface-2 hover:text-fg"
>
<Plus className="h-3.5 w-3.5" /> Element
</button>
}
>
{rows.length === 0 && (
<p className="px-1 py-2 text-xs text-muted">Noch keine Elemente erfasst.</p>
)}
{rows.map((ce) => {
const el = plan.elements.find((x) => x.id === ce.elementId);
if (!el) return null;
const key = `p:${el.id}`;
const isOpen = unconfirmedCells(plan, computed, phase.id).includes(el.id);
return (
<Row
key={key}
open={!!open[key]}
onToggle={() => setOpen((p) => ({ ...p, [key]: !p[key] }))}
icon={<PiggyBank className="h-4 w-4" />}
name={el.name}
meta={`${CATEGORY_LABELS[el.category]} · ${ownerLabel(plan.persons, el.ownerRole)}`}
attention={isOpen}
right={<CellValue ce={ce} dS={dS} dE={dE} mode={mode} />}
>
<ElementDetail
compact
element={el}
context={phaseContext(phase, el)}
phaseData={el.phaseValues[phase.id] ?? {}}
transitionData={{}}
allPhases={plan.phases.map<PhaseRef>((p) => ({ id: p.id, name: p.name, sequenceNumber: p.sequenceNumber }))}
phaseDataByPhase={Object.fromEntries(
plan.phases.map((p) => [p.id, (el.phaseValues[p.id] ?? {}) as PhaseData])
)}
onSaved={actions.onChanged}
onDeleteElement={() => actions.onDeleteElement(el.id, el.name)}
/>
</Row>
);
})}
<div className="pt-1">
<QuotaButton
label={phase.isConsumption ? "Bezug verteilen" : "Sparquote verteilen"}
open={ratesOpen}
sub={`${phase.isConsumption ? "Verzehrquote" : "Sparquote"} ${val(phase.quotaStart, dS, mode)}${val(
phase.quotaEnd,
phase.flowDeflatorEnd,
mode
)}`}
onClick={() => actions.onDistributeRates(phase.id)}
/>
</div>
</Section>
{/* 3 · ERGEBNIS */}
<Section step={3} title="Ergebnis" hint="Stand am Ende dieser Phase" tone="result">
<div className="grid gap-x-6 gap-y-1.5 sm:grid-cols-2">
<Figure label="Vermögen" from={val(phase.startWealthNominal, dS, mode)} to={val(phase.endWealthNominal, dE, mode)} strong />
<Figure
label="Cash"
from={val(phase.cashStart, dS, mode)}
to={val(phase.cashEnd, dE, mode)}
danger={phase.cashNegative}
/>
</div>
{phase.cashNegative && (
<p className="mt-2 flex items-start gap-2 text-xs text-danger">
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
Das Cash-Konto fällt in dieser Phase unter 0 eine Liquiditätslücke.
</p>
)}
</Section>
</article>
);
}
// --- Bausteine -----------------------------------------------------------------------------
function IconBtn({
label,
onClick,
danger,
children,
}: {
label: string;
onClick: () => void;
danger?: boolean;
children: React.ReactNode;
}) {
return (
<button
type="button"
aria-label={label}
title={label}
onClick={onClick}
className={`rounded-lg p-1.5 text-faint transition-colors ${
danger ? "hover:bg-danger-soft hover:text-danger" : "hover:bg-surface-2 hover:text-fg"
}`}
>
{children}
</button>
);
}
// Ein Akt der Karte. Die drei sind bewusst deutlich voneinander abgesetzt: ein getoenter
// Kopfstreifen ueber die volle Breite, eine Nummer im Kreis, darunter der Inhalt. Beim ersten
// Anlauf trennte sie nur eine Haarlinie -- man las die Karte als eine lange Liste statt als
// drei Schritte.
function Section({
step,
title,
hint,
action,
tone = "plain",
children,
}: {
step: number;
title: string;
hint: string;
action?: React.ReactNode;
// "plain" = weisser Inhalt, "result" = durchgehend getoent (der Schlusspunkt).
tone?: "plain" | "result";
children: React.ReactNode;
}) {
return (
<section className={`border-t-2 border-border ${tone === "result" ? "bg-surface-2" : ""}`}>
<div className={`flex items-center gap-2 px-5 py-2 ${tone === "result" ? "" : "bg-surface-2/70"}`}>
<span
className={`flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-[10px] font-semibold ${
tone === "result" ? "bg-accent text-accent-fg" : "bg-surface text-muted ring-1 ring-border"
}`}
>
{step}
</span>
<span className="text-xs font-semibold uppercase tracking-wide text-fg">{title}</span>
<span className="min-w-0 truncate text-[11px] text-faint">{hint}</span>
{action && <span className="ml-auto shrink-0">{action}</span>}
</div>
<div className="px-5 py-2.5">{children}</div>
</section>
);
}
// Der grosse Pfeil neben der Karte. Er sitzt in der Luft, die durch die begrenzte Kartenbreite
// ohnehin entsteht -- ein Bedienelement, das keinen zusaetzlichen Platz kostet.
function SideArrow({
dir,
label,
disabled,
onClick,
}: {
dir: "left" | "right";
label?: string;
disabled: boolean;
onClick: () => void;
}) {
return (
<button
type="button"
disabled={disabled}
onClick={onClick}
aria-label={dir === "left" ? `Zurück zu ${label ?? "vorheriger Phase"}` : `Weiter zu ${label ?? "nächster Phase"}`}
title={label}
className="hidden w-12 shrink-0 items-center justify-center rounded-2xl border border-transparent text-faint transition-colors hover:border-border hover:bg-surface hover:text-accent disabled:pointer-events-none disabled:opacity-0 lg:flex"
>
{dir === "left" ? <ChevronLeft className="h-7 w-7" /> : <ChevronRight className="h-7 w-7" />}
</button>
);
}
// Eine aufklappbare Zeile. Zugeklappt ist sie eine Zusammenfassung, aufgeklappt der ganze
// Editor -- und zwar IN der Karte. Kein Panel legt sich mehr ueber das, was man bearbeitet.
function Row({
open,
onToggle,
icon,
name,
meta,
right,
attention,
children,
}: {
open: boolean;
onToggle: () => void;
icon: React.ReactNode;
name: string;
meta: string;
right: React.ReactNode;
attention?: boolean;
children: React.ReactNode;
}) {
return (
<div className={`border-b border-border last:border-b-0 ${open ? "bg-surface-2/40" : ""}`}>
<button
type="button"
onClick={onToggle}
aria-expanded={open}
className="flex w-full items-center gap-2.5 py-2 text-left transition-colors hover:bg-surface-2/60"
>
<span className={`h-1.5 w-1.5 shrink-0 rounded-full ${attention ? "bg-attention" : "bg-success"}`} />
<span className={attention ? "text-attention" : "text-faint"}>{icon}</span>
<span className="min-w-0">
<span className="block truncate text-sm text-fg">{name}</span>
<span className="block truncate text-[11px] text-faint">{meta}</span>
</span>
<span className="ml-auto shrink-0 text-xs text-muted">{right}</span>
<ChevronDown className={`h-4 w-4 shrink-0 text-faint transition-transform ${open ? "rotate-180" : ""}`} />
</button>
{open && <div className="pb-4 pl-6 pr-1">{children}</div>}
</div>
);
}
// Der Cash-Uebergang in der Karte. Dieselben Felder wie im Panel (`CashTransitionFields`),
// nur mit eigenem Speichern -- eine zweite Implementierung der Felder waere garantiert
// auseinandergelaufen.
function CashTransitionEditor({
fromPhaseId,
initial,
deflatorEnd,
onSaved,
}: {
fromPhaseId: string;
initial: CashTransitionData;
deflatorEnd: number;
onSaved: () => void;
}) {
// Vorbelegen, damit ein blosses Speichern den sichtbaren Default (1:1) festhaelt.
const [ct, setCt] = useState<CashTransitionData>(() => withCashTransitionDefaults(initial));
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
async function save() {
setSaving(true);
setError(null);
try {
await api.put(`/api/phases/${fromPhaseId}/cash-transition`, ct);
onSaved();
} catch (e) {
setError(e instanceof Error ? e.message : "Speichern fehlgeschlagen.");
} finally {
setSaving(false);
}
}
return (
<div className="flex flex-col gap-3">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<CashTransitionFields ct={ct} setC={(patch) => setCt((prev) => ({ ...prev, ...patch }))} deflatorEnd={deflatorEnd} />
</div>
{error && <p className="text-sm text-danger">{error}</p>}
<div>
<Button disabled={saving} onClick={save}>
{saving ? "Speichern…" : "Speichern"}
</Button>
</div>
</div>
);
}
function CellValue({
ce,
dS,
dE,
mode,
}: {
ce: ElementPhaseComputed;
dS: number;
dE: number;
mode: ValueMode;
}) {
if (ce.summary) return <span className="tabular-nums">{ce.summary}</span>;
if (ce.note) return <span>{ce.note}</span>;
return (
<span className="whitespace-nowrap tabular-nums">
{val(ce.startValue, dS, mode)} <span className="text-faint"></span> {val(ce.endValue, dE, mode)}
</span>
);
}
function Figure({
label,
from,
to,
strong,
danger,
}: {
label: string;
from: string;
to: string;
strong?: boolean;
danger?: boolean;
}) {
return (
<div className="flex items-baseline justify-between gap-3 border-b border-border/60 py-1 last:border-b-0">
<span className="text-xs text-muted">{label}</span>
<span
className={`whitespace-nowrap tabular-nums ${strong ? "text-sm font-semibold" : "text-xs"} ${
danger ? "text-danger" : "text-fg"
}`}
>
{from} <span className="text-faint"></span> {to}
</span>
</div>
);
}
// Der Weg in einen Verteil-Dialog. Gruen mit Haken, sobald erledigt -- dieselbe Sprache wie
// im Phasenkopf der Matrix (SPEZIFIKATION 3.6.9).
function QuotaButton({
label,
sub,
open,
hidden,
onClick,
}: {
label: string;
sub: string;
open: boolean;
hidden?: boolean;
onClick: () => void;
}) {
if (hidden) return null;
return (
<Button variant={open ? "primary" : "ghost"} onClick={onClick} className="w-full justify-between">
<span className="flex flex-col items-start">
<span>
{label}
{open ? "" : " ✓"}
</span>
<span className="text-[11px] font-normal opacity-80">{sub}</span>
</span>
<ChevronRight className="h-4 w-4" />
</Button>
);
}
+19 -31
View File
@@ -1,6 +1,7 @@
"use client"; "use client";
import { FieldLabel, NumberField, SelectField, TextField } from "@/components/FormField"; import { NumberField, SelectField, TextField } from "@/components/FormField";
import { AHV_REFERENCE_AGE } from "@/lib/constants";
import type { HouseholdType, PersonRole } from "@/lib/types"; import type { HouseholdType, PersonRole } from "@/lib/types";
export interface ProfileDraft { export interface ProfileDraft {
@@ -15,23 +16,28 @@ export function emptyProfileDraft(): ProfileDraft {
householdType: "SINGLE", householdType: "SINGLE",
inflationRateDefault: 1.5, inflationRateDefault: 1.5,
startYear: new Date().getFullYear(), startYear: new Date().getFullYear(),
persons: [{ role: "PERSON_A", name: "", age: 35, retirementAge: 65 }], // Das Pensionsalter wird beim Anlegen nicht gefragt: Es ist der erste Entscheid der
// Pensionsplanung. Bis dahin gilt das Referenzalter.
persons: [{ role: "PERSON_A", name: "", age: 35, retirementAge: AHV_REFERENCE_AGE }],
}; };
} }
// Gemeinsame Formularfelder für das Grundprofil eines Plans (Haushaltsform, Personen, // Gemeinsame Formularfelder für das Grundprofil eines Plans (Haushaltsform, Personen,
// Inflation). Wird beim Plan-Erstellen und in den Plan-Einstellungen verwendet. // Startjahr, Inflation). Wird beim Plan-Erstellen und in den Grundeinstellungen verwendet.
//
// Das PENSIONSALTER fehlt hier seit 0.36 bewusst. Es ist kein Stammdatum, sondern der erste
// Entscheid der Pensionsplanung -- und es erzeugt eine Phasengrenze. Es wird deshalb dort
// gesetzt («Pensionsplanung» je Person) und hier höchstens angezeigt.
export function PlanProfileFields({ export function PlanProfileFields({
draft, draft,
onChange, onChange,
// Sobald Lebensphasen bestehen, liegt jede Pensionierung auf einer Phasengrenze. Ein frei // Blendet den Hinweis aus, dass diese Angaben plan-weit gelten -- beim Anlegen eines Plans
// änderbares Alter würde diese Grenze zerreissen -- deshalb übernimmt dort die eigene // gibt es noch keine anderen Szenarien, die betroffen sein könnten.
// Bedienung «Pensionsalter anpassen» (Roadmap Nr. 44), und das Feld ist nur noch Anzeige. hideScopeNote = false,
lockRetirement = false,
}: { }: {
draft: ProfileDraft; draft: ProfileDraft;
onChange: (next: ProfileDraft) => void; onChange: (next: ProfileDraft) => void;
lockRetirement?: boolean; hideScopeNote?: boolean;
}) { }) {
function setType(type: HouseholdType) { function setType(type: HouseholdType) {
if (type === "SINGLE") { if (type === "SINGLE") {
@@ -39,7 +45,7 @@ export function PlanProfileFields({
} else { } else {
const persons = const persons =
draft.persons.length < 2 draft.persons.length < 2
? [...draft.persons, { role: "PERSON_B" as PersonRole, name: "", age: 35, retirementAge: 65 }] ? [...draft.persons, { role: "PERSON_B" as PersonRole, name: "", age: 35, retirementAge: AHV_REFERENCE_AGE }]
: draft.persons; : draft.persons;
onChange({ ...draft, householdType: type, persons }); onChange({ ...draft, householdType: type, persons });
} }
@@ -57,11 +63,12 @@ export function PlanProfileFields({
{/* Seit V7 beschreiben Haushaltsform, Personen und Startjahr den PLAN und gelten für {/* Seit V7 beschreiben Haushaltsform, Personen und Startjahr den PLAN und gelten für
ALLE Szenarien. Das muss sichtbar sein -- sonst ändert man beim Bearbeiten eines ALLE Szenarien. Das muss sichtbar sein -- sonst ändert man beim Bearbeiten eines
Nebenszenarios unbemerkt auch alle anderen. */} Nebenszenarios unbemerkt auch alle anderen. */}
{!hideScopeNote && (
<p className="rounded-lg border border-attention bg-attention-soft px-3 py-2 text-xs text-attention-soft-fg"> <p className="rounded-lg border border-attention bg-attention-soft px-3 py-2 text-xs text-attention-soft-fg">
Haushaltsform, Personen und Planstart gelten für <strong>alle Szenarien</strong> dieses Haushaltsform, Personen und Planstart gelten für <strong>alle Szenarien</strong> dieses
Plans. Unterscheiden sie sich, ist es ein anderer Plan. Szenario-eigen sind nur das{" "} Plans. Unterscheiden sie sich, ist es ein anderer Plan.
<strong>Pensionsalter</strong> und die <strong>Inflation</strong>.
</p> </p>
)}
<SelectField <SelectField
label="Haushaltsform (plan-weit)" label="Haushaltsform (plan-weit)"
@@ -94,26 +101,7 @@ export function PlanProfileFields({
max={120} max={120}
onChange={(v) => updatePerson(index, { age: Math.round(v) })} onChange={(v) => updatePerson(index, { age: Math.round(v) })}
/> />
{lockRetirement ? ( {/* Das Pensionsalter steht bewusst NICHT hier -- siehe Kommentar oben. */}
<div>
<FieldLabel
label="Pensionierungsalter"
help="Liegt immer auf einer Phasengrenze und wird deshalb unten unter «Pensionsalter anpassen» verschoben -- dabei wandern die angrenzenden Phasendauern mit."
/>
<div className="mt-1 rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-muted">
{person.retirementAge} Jahre
</div>
</div>
) : (
<NumberField
label="Pensionierungsalter (nur dieses Szenario)"
help="Steuert die Ableitung des Phasentyps (Erwerb/Pension). Als einzige Personenangabe szenario-eigen -- das ist der Kern jedes Früh-/Spätpensionierungs-Szenarios."
value={person.retirementAge}
min={30}
max={100}
onChange={(v) => updatePerson(index, { retirementAge: Math.round(v) })}
/>
)}
</div> </div>
))} ))}
+1236 -343
View File
File diff suppressed because it is too large Load Diff
-974
View File
@@ -1,974 +0,0 @@
"use client";
// Geführter Plan-Assistent (Roadmap Nr. 10): "Schritt für Schritt statt leerer Matrix".
// Der Wizard fragt in Alltagssprache und erzeugt am Ende Plan, Phasen und Elemente über
// die BESTEHENDEN API-Endpunkte -- reine Orchestrierung, keine Änderung an Berechnung
// oder Datenmodell.
//
// Schritt 2 (Lebensphasen) ist an den fixen Pensionierungszeitpunkten ausgerichtet: Die
// Lebenslinie zerfällt in Abschnitte (Erwerb / Misch / Pension, siehe lib/phaseplan). In den
// FESTEN Abschnitten (durch eine Pensionierung begrenzt) verteilt der Nutzer beliebig viele
// Phasen, deren Dauer exakt aufgehen muss; der letzte Pensions-Abschnitt ist offen.
//
// Reihenfolge beim Anlegen (wichtig): erst Phase 1, dann Elemente samt Phase-1-Werten, dann
// die Folgephasen -- so belegt die Phasen-Route die Folgephasen korrekt vor.
//
// Ehrliche Grenze: Bricht die Sequenz mittendrin ab (Netzfehler), existiert ein Teil-Plan
// (siehe SPEZIFIKATION 9.23).
import { useMemo, useRef, useState } from "react";
import { ArrowLeft, ArrowRight, CalendarRange, Check, Coins, Flag, PiggyBank, Plus, Sparkles, Trash2, User, Wallet } from "lucide-react";
import { Modal, Button } from "@/components/ui";
import { MoneyField, NumberField, TextField } from "@/components/FormField";
import { InfoBubble } from "@/components/InfoBubble";
import { PlanProfileFields, emptyProfileDraft, type ProfileDraft } from "@/components/PlanProfileFields";
import { api } from "@/lib/api-client";
import { planSegments, defaultOpenDuration, type PlanSegment, type SegmentType } from "@/lib/phaseplan";
import { PILLAR_3A_MAX_ANNUAL, PILLAR_3A_MAX_SELF_EMPLOYED } from "@/lib/constants";
import type { PhaseData } from "@/lib/elements";
interface PhaseDraft {
name: string;
durationYears: number;
}
const STEP_TITLES = [
"Über dich",
"Deine Lebensphasen",
"Einkommen & Ausgaben",
"Vorsorge & Vermögen",
"Sparen & Verteilen",
"Fertig",
];
// Die fünf «Tu-was»-Schritte für den Willkommens-Screen und die persistente Schritt-Leiste.
// «Fertig» (Zusammenfassung) ist der Abschluss und trägt keine eigene Nummer.
const WIZARD_OVERVIEW: { icon: React.ComponentType<{ className?: string }>; title: string; desc: string }[] = [
{ icon: User, title: "Über dich", desc: "Haushalt, Alter und Pensionsalter." },
{ icon: CalendarRange, title: "Lebensphasen", desc: "Die Abschnitte deiner Planung bis zur Pension und danach." },
{ icon: Wallet, title: "Einkommen & Ausgaben", desc: "Was reinkommt, was rausgeht, was auf dem Konto liegt." },
{ icon: PiggyBank, title: "Vorsorge & Vermögen", desc: "Was du heute besitzt PK, 3a, Wertschriften, Wohneigentum." },
{ icon: Coins, title: "Sparen & Verteilen", desc: "Deine Sparquote auf die Sparziele aufteilen." },
];
const SEGMENT_META: Record<SegmentType, { label: string; phase: string; def: string; color: string; soft: string }> = {
ERWERB: {
label: "Erwerb",
phase: "Erwerbsphase",
def: "Alle arbeiten noch Einkommen fliesst, Vorsorge wächst.",
color: "bg-accent/70 text-accent-fg",
soft: "border-accent",
},
MIXED: {
label: "Misch",
phase: "Übergangsphase",
def: "Eine Person ist pensioniert, die andere arbeitet noch.",
color: "bg-accent/40 text-accent-fg",
soft: "border-accent",
},
PENSION: {
label: "Pension",
phase: "Pensionsphase",
def: "Alle sind pensioniert gelebt wird aus Renten und Vermögen.",
color: "bg-accent-soft text-accent-soft-fg",
soft: "border-accent",
},
};
function personsSignature(draft: ProfileDraft): string {
return draft.householdType + "|" + draft.persons.map((p) => `${p.age}/${p.retirementAge}`).join(",");
}
// Eine Phase je Abschnitt als Startpunkt: feste Länge in den begrenzten Abschnitten, eine
// sinnvolle Vorgabe im offenen Pensions-Abschnitt.
function defaultSegPhases(segments: PlanSegment[], persons: { age: number }[]): PhaseDraft[][] {
const typeCount: Record<SegmentType, number> = { ERWERB: 0, MIXED: 0, PENSION: 0 };
return segments.map((seg) => {
typeCount[seg.type] += 1;
const dur = seg.fixedYears ?? defaultOpenDuration(persons, seg.fromYear);
return [{ name: SEGMENT_META[seg.type].phase, durationYears: Math.max(1, dur) }];
});
}
// --- Vermögens-/Vorsorge-Erfassung je Eigentümer-Bereich ---------------------------------
type Scope = "SELF" | "HOUSEHOLD" | "PERSON_A" | "PERSON_B";
type Category = "pk" | "p3a" | "etf" | "re" | "debt";
interface ScopeAssets {
pk: { on: boolean; value: number; contribution: number; ret: number };
// selfEmployed: «grosse Säule 3a» ohne PK -> höhere Obergrenze (Roadmap-Feedback C7c).
p3a: { on: boolean; value: number; contribution: number; selfEmployed: boolean; ret: number };
etf: { on: boolean; value: number; contribution: number; ret: number };
// growth = Wertsteigerung %/Jahr; interestIncluded = Zinsen bereits in den Ausgaben.
re: { on: boolean; price: number; mortgage: number; amortization: number; interest: number; growth: number; interestIncluded: boolean };
debt: { on: boolean; value: number; repayment: number };
}
function emptyScope(): ScopeAssets {
return {
pk: { on: false, value: 0, contribution: 0, ret: 1.5 },
p3a: { on: false, value: 0, contribution: 0, selfEmployed: false, ret: 2 },
etf: { on: false, value: 0, contribution: 0, ret: 5 },
re: { on: false, price: 0, mortgage: 0, amortization: 0, interest: 1.5, growth: 1, interestIncluded: true },
debt: { on: false, value: 0, repayment: 0 },
};
}
export function PlanWizard({
onCreated,
onClose,
}: {
onCreated: (scenarioId: string) => void;
onClose: () => void;
}) {
const [started, setStarted] = useState(false);
const [step, setStep] = useState(0);
const [planName, setPlanName] = useState("Meine Planung");
const [profile, setProfile] = useState<ProfileDraft>(emptyProfileDraft);
// Abschnitte der Lebenslinie und die je Abschnitt geplanten Phasen (parallel indexiert).
const segments = useMemo(() => planSegments(profile.persons), [profile.persons]);
const [segPhases, setSegPhases] = useState<PhaseDraft[][]>([]);
const segSig = useRef<string>("");
// Einkommen bewusst PRO PERSON: In Paar-Plänen zählt "Gemeinsam" nicht für die AHV-Rente
// (SPEZIFIKATION 9.9) -- der Assistent räumt diese Falle von Anfang an aus.
const [incomes, setIncomes] = useState<Record<string, number>>({ PERSON_A: 0 });
const [expenses, setExpenses] = useState(0);
const [initialCash, setInitialCash] = useState(0);
const [assets, setAssets] = useState<Record<Scope, ScopeAssets>>({
SELF: emptyScope(),
HOUSEHOLD: emptyScope(),
PERSON_A: emptyScope(),
PERSON_B: emptyScope(),
});
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const isCouple = profile.householdType === "COUPLE";
const personLabel = (role: string) => {
const p = profile.persons.find((x) => x.role === role);
return p?.name?.trim() || (role === "PERSON_A" ? "Person A" : "Person B");
};
function setScope(scope: Scope, cat: Category, patch: Record<string, number | boolean>) {
setAssets((prev) => ({ ...prev, [scope]: { ...prev[scope], [cat]: { ...prev[scope][cat], ...patch } } }));
}
function goNext() {
if (step === 0) {
// Abschnitte neu ableiten; Phasen nur zurücksetzen, wenn sich das Profil strukturell
// geändert hat (sonst blieben Bearbeitungen des Nutzers erhalten).
const sig = personsSignature(profile);
if (sig !== segSig.current || segPhases.length === 0) {
setSegPhases(defaultSegPhases(segments, profile.persons));
segSig.current = sig;
}
const inc: Record<string, number> = {};
for (const p of profile.persons) inc[p.role] = incomes[p.role] ?? 0;
setIncomes(inc);
}
setStep((s) => Math.min(s + 1, STEP_TITLES.length - 1));
}
// --- Validierung Schritt 1 ---
const segSums = segPhases.map((phs) => phs.reduce((s, p) => s + Math.max(0, p.durationYears), 0));
const step1Valid =
segPhases.length === segments.length &&
segments.every((seg, i) => {
const phs = segPhases[i] ?? [];
if (phs.length === 0) return false;
if (phs.some((p) => p.durationYears < 1)) return false;
return seg.fixedYears === null ? segSums[i] >= 1 : segSums[i] === seg.fixedYears;
});
// Flache, geordnete Phasenliste (für Vorschau, Timeline und Anlegen).
const flatPhases: PhaseDraft[] = segPhases.flat();
function updatePhase(segIdx: number, phaseIdx: number, patch: Partial<PhaseDraft>) {
setSegPhases((prev) =>
prev.map((phs, i) => (i === segIdx ? phs.map((p, j) => (j === phaseIdx ? { ...p, ...patch } : p)) : phs))
);
}
function addPhase(segIdx: number) {
const seg = segments[segIdx];
const rest = seg.fixedYears === null ? 5 : Math.max(0, seg.fixedYears - segSums[segIdx]);
setSegPhases((prev) =>
prev.map((phs, i) =>
i === segIdx
? [...phs, { name: `${SEGMENT_META[seg.type].phase} ${phs.length + 1}`, durationYears: Math.max(1, rest) }]
: phs
)
);
}
function removePhase(segIdx: number, phaseIdx: number) {
setSegPhases((prev) => prev.map((phs, i) => (i === segIdx ? phs.filter((_, j) => j !== phaseIdx) : phs)));
}
async function create() {
setSaving(true);
setError(null);
try {
const { scenario } = await api.post<{ plan: { id: string }; scenario: { id: string } }>("/api/plans", {
name: planName.trim() || "Meine Planung",
householdType: profile.householdType,
inflationRateDefault: profile.inflationRateDefault,
startYear: profile.startYear,
persons: profile.persons.map((p) => ({ role: p.role, name: p.name.trim() || null, age: p.age, retirementAge: p.retirementAge })),
});
const sid = scenario.id;
if (initialCash > 0) await api.patch(`/api/scenarios/${sid}`, { initialCash });
const first = await api.post<{ phase: { id: string } }>(`/api/scenarios/${sid}/phases`, {
name: flatPhases[0].name,
durationYears: flatPhases[0].durationYears,
});
const firstPhaseId = first.phase.id;
async function addEl(category: string, name: string, ownerRole: string, values?: PhaseData) {
const { element } = await api.post<{ element: { id: string } }>(`/api/scenarios/${sid}/elements`, {
category,
name,
ownerRole,
});
if (values) await api.put(`/api/elements/${element.id}/phase/${firstPhaseId}`, values);
}
for (const p of profile.persons) {
const amount = incomes[p.role] ?? 0;
// 1 % nominale Lohnerhöhung als dezenter Default -- 0 % liesse den Lohn real verlieren.
if (amount > 0) await addEl("INCOME", `Lohn ${personLabel(p.role)}`, p.role, { amount, teuerungsausgleich: 1 });
}
if (expenses > 0) await addEl("EXPENSE", "Lebenshaltung", "HOUSEHOLD", { amount: expenses, teuerungsausgleich: 0 });
for (const p of profile.persons) {
await addEl("AHV", isCouple ? `AHV ${personLabel(p.role)}` : "AHV", p.role, { gapYears: 0 });
}
// Vermögen/Vorsorge je Eigentümer-Bereich anlegen.
for (const scope of renderedScopes) {
const a = assets[scope.id];
const suffix = scope.nameSuffix; // z. B. " Anna" oder " (gemeinsam)"
if (scope.showVorsorge) {
if (a.pk.on) {
await addEl("PENSION_FUND", `Pensionskasse${suffix}`, scope.vorsorgeRole!, {
currentValue: a.pk.value,
annualContribution: a.pk.contribution,
expectedReturn: a.pk.ret,
});
}
if (a.p3a.on) {
await addEl("PILLAR_3A", `Säule 3a${suffix}`, scope.vorsorgeRole!, {
currentValue: a.p3a.value,
annualContribution: Math.min(a.p3a.contribution, max3aFor(scope.id)),
expectedReturn: a.p3a.ret,
selfEmployed3a: a.p3a.selfEmployed,
});
}
}
if (a.etf.on) {
await addEl("OTHER_ASSET", `Wertschriften${suffix}`, scope.otherRole, {
startValue: a.etf.value,
annualContribution: a.etf.contribution,
expectedReturn: a.etf.ret,
});
}
if (a.re.on) {
await addEl("REAL_ESTATE", `Wohneigentum${suffix}`, scope.otherRole, {
purchasePrice: a.re.price,
mortgage: a.re.mortgage,
amortization: a.re.amortization,
interestRate: a.re.interest,
valueGrowth: a.re.growth,
interestHandling: a.re.interestIncluded ? "INCLUDED" : "ADD",
});
}
if (a.debt.on) {
await addEl("OTHER_DEBT", `Schulden${suffix}`, scope.otherRole, {
startValue: a.debt.value,
annualRepayment: a.debt.repayment,
});
}
}
for (const ph of flatPhases.slice(1)) {
await api.post(`/api/scenarios/${sid}/phases`, { name: ph.name, durationYears: ph.durationYears });
}
onCreated(sid);
} catch (e) {
setError(
(e instanceof Error ? e.message : "Erstellen fehlgeschlagen.") +
" Falls der Plan teilweise angelegt wurde, findest du ihn in der Seitenleiste und kannst dort normal weiterarbeiten."
);
setSaving(false);
}
}
// Welche Eigentümer-Bereiche in Schritt 4 gezeigt werden.
const renderedScopes: {
id: Scope;
label: string;
nameSuffix: string;
showVorsorge: boolean;
vorsorgeRole: string | null;
otherRole: string;
cats: Category[];
}[] = isCouple
? [
{ id: "HOUSEHOLD", label: "Gemeinsam", nameSuffix: " (gemeinsam)", showVorsorge: false, vorsorgeRole: null, otherRole: "HOUSEHOLD", cats: ["etf", "re", "debt"] },
{ id: "PERSON_A", label: personLabel("PERSON_A"), nameSuffix: ` ${personLabel("PERSON_A")}`, showVorsorge: true, vorsorgeRole: "PERSON_A", otherRole: "PERSON_A", cats: ["pk", "p3a", "etf", "re", "debt"] },
{ id: "PERSON_B", label: personLabel("PERSON_B"), nameSuffix: ` ${personLabel("PERSON_B")}`, showVorsorge: true, vorsorgeRole: "PERSON_B", otherRole: "PERSON_B", cats: ["pk", "p3a", "etf", "re", "debt"] },
]
: [
{ id: "SELF", label: "Deine Angaben", nameSuffix: "", showVorsorge: true, vorsorgeRole: "PERSON_A", otherRole: "HOUSEHOLD", cats: ["pk", "p3a", "etf", "re", "debt"] },
];
const enabledAssetCount = renderedScopes.reduce((n, sc) => {
const a = assets[sc.id];
return n + sc.cats.filter((c) => a[c].on).length;
}, 0);
// --- Sparquote (Schritt "Sparen & Verteilen") ---
// Die Sparquote entsteht aus Nettoeinkommen minus Ausgaben. PK-Beiträge kommen VOM
// Bruttolohn (vor dem Netto) und reduzieren sie deshalb NICHT -- genau wie im Rechenkern.
const totalIncome = profile.persons.reduce((s, p) => s + (incomes[p.role] ?? 0), 0);
// Hypothekarzinsen, die NICHT bereits in den Ausgaben stecken, werden hier den Ausgaben
// zugerechnet -- so wie sie der Rechenkern bei "interestHandling: ADD" behandelt. Sonst
// zeigte die Vorschau eine zu hohe Sparquote.
const addedInterest = renderedScopes.reduce((sum, sc) => {
const re = assets[sc.id].re;
return sum + (re.on && !re.interestIncluded ? Math.round((re.mortgage * re.interest) / 100) : 0);
}, 0);
const effectiveExpenses = expenses + addedInterest;
const sparBase = totalIncome - effectiveExpenses;
const allocatedFromQuote = renderedScopes.reduce((sum, sc) => {
const a = assets[sc.id];
return (
sum +
(a.p3a.on ? a.p3a.contribution : 0) +
(a.etf.on ? a.etf.contribution : 0) +
(a.re.on ? a.re.amortization : 0) +
(a.debt.on ? a.debt.repayment : 0)
);
}, 0);
const restToCash = sparBase - allocatedFromQuote;
const anyPk = renderedScopes.some((sc) => assets[sc.id].pk.on);
const anyQuoteTarget = renderedScopes.some((sc) => {
const a = assets[sc.id];
return a.p3a.on || a.etf.on || a.re.on || a.debt.on;
});
const scopeTag = (sc: { label: string }) => (isCouple ? ` · ${sc.label}` : "");
const max3aFor = (sc: Scope) => (assets[sc].p3a.selfEmployed ? PILLAR_3A_MAX_SELF_EMPLOYED : PILLAR_3A_MAX_ANNUAL);
// Willkommens-Screen vor dem ersten Schritt: das Gesamtbild der fünf Schritte, damit der
// Nutzer von Anfang an weiss, was auf ihn zukommt (Roadmap-Feedback).
if (!started) {
return (
<Modal title="Plan erstellen geführt" subtitle="In 5 Schritten zu deinem Finanzplan" onClose={onClose} xwide>
<div className="flex flex-col gap-4">
<p className="text-sm text-muted">
Erstelle deinen Finanzplan in fünf einfachen Schritten das dauert nur ein paar Minuten, und alles lässt
sich später jederzeit ändern.
</p>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
{WIZARD_OVERVIEW.map((s, i) => (
<div key={i} className="flex items-start gap-3 rounded-xl border border-border bg-surface-2 p-3">
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-accent-soft text-accent-soft-fg">
<s.icon className="h-4 w-4" />
</span>
<div className="min-w-0">
<div className="text-sm font-semibold text-fg">
{i + 1}. {s.title}
</div>
<p className="text-xs text-muted">{s.desc}</p>
</div>
</div>
))}
</div>
<div className="flex items-center justify-between border-t border-border pt-3">
<Button variant="ghost" size="sm" onClick={onClose}>
Abbrechen
</Button>
<Button onClick={() => setStarted(true)}>
Los gehts <ArrowRight className="h-4 w-4" />
</Button>
</div>
</div>
</Modal>
);
}
return (
<Modal
title="Plan erstellen geführt"
subtitle={step < 5 ? `Schritt ${step + 1} von 5: ${STEP_TITLES[step]}` : "Zusammenfassung"}
onClose={onClose}
xwide
>
<div className="flex gap-5">
{/* Persistente Schritt-Leiste (Desktop): der Nutzer sieht immer, wo er steht und was folgt. */}
<nav className="hidden w-48 shrink-0 flex-col gap-1 sm:flex">
{WIZARD_OVERVIEW.map((s, i) => {
const state = step > i ? "done" : step === i ? "current" : "todo";
return (
<div
key={i}
className={`flex items-start gap-2 rounded-lg px-2 py-1.5 text-xs ${
state === "current" ? "bg-accent-soft text-accent-soft-fg" : "text-muted"
}`}
>
<span
className={`mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-[10px] font-bold ${
state === "done"
? "bg-accent text-accent-fg"
: state === "current"
? "border border-accent-soft-fg text-accent-soft-fg"
: "border border-border text-faint"
}`}
>
{state === "done" ? <Check className="h-3 w-3" /> : i + 1}
</span>
<div className="min-w-0">
<div className={`font-semibold ${state === "todo" ? "text-faint" : ""}`}>{s.title}</div>
{state === "current" && <p className="mt-0.5 leading-snug">{s.desc}</p>}
</div>
</div>
);
})}
<div className="mt-1 flex items-center gap-2 rounded-lg px-2 py-1.5 text-xs">
<span
className={`flex h-5 w-5 shrink-0 items-center justify-center rounded-full ${
step === 5 ? "bg-accent text-accent-fg" : "border border-border text-faint"
}`}
>
<Flag className="h-3 w-3" />
</span>
<span className={step === 5 ? "font-semibold text-fg" : "text-faint"}>Fertig</span>
</div>
</nav>
<div className="flex min-w-0 flex-1 flex-col gap-4">
{/* Fortschritt (Mobile, wo die Leiste ausgeblendet ist). */}
<div className="flex gap-1 sm:hidden">
{WIZARD_OVERVIEW.map((_, i) => (
<div key={i} className={`h-1 flex-1 rounded-full ${i <= step ? "bg-accent" : "bg-surface-2"}`} />
))}
</div>
{step === 0 && (
<div className="flex flex-col gap-4">
<p className="text-sm text-muted">
Ein paar Angaben zu dir daraus baut das Tool dein Grundgerüst. Alles lässt sich später ändern.
</p>
<TextField label="Name des Plans" value={planName} onChange={setPlanName} placeholder="z. B. Meine Planung" />
<PlanProfileFields draft={profile} onChange={setProfile} />
</div>
)}
{step === 1 && (
<div className="flex flex-col gap-4">
<p className="text-sm text-muted">
Deine Planung besteht aus <strong className="text-fg">Lebensphasen</strong>. Das Pensionsalter setzt feste
Grenzen auf der Lebenslinie dazwischen kannst du beliebig viele Phasen einplanen.
</p>
<WizardTimeline segments={segments} segPhases={segPhases} profile={profile} />
{segments.map((seg, segIdx) => {
const meta = SEGMENT_META[seg.type];
const sum = segSums[segIdx] ?? 0;
const fixed = seg.fixedYears;
const ok = fixed === null ? sum >= 1 : sum === fixed;
return (
<div key={segIdx} className={`rounded-xl border-l-4 border border-border ${meta.soft} bg-surface-2 p-3`}>
<div className="mb-1 flex flex-wrap items-center gap-2">
<span className="text-sm font-semibold text-fg">
Teil {segIdx + 1}: {meta.label}
</span>
{fixed === null ? (
<span className="rounded bg-surface px-1.5 py-0.5 text-[10px] font-medium text-muted">
Dauer frei · aktuell {sum} J.
</span>
) : (
<span
className={`rounded px-1.5 py-0.5 text-[10px] font-semibold ${
ok ? "bg-success/15 text-success" : "bg-danger-soft text-danger"
}`}
>
Summe {sum} / {fixed} Jahre{ok ? " ✓" : ""}
</span>
)}
</div>
<p className="mb-2 text-xs text-muted">{meta.def}</p>
<div className="flex flex-col gap-2">
{(segPhases[segIdx] ?? []).map((ph, phaseIdx) => (
<div key={phaseIdx} className="grid grid-cols-1 gap-2 sm:grid-cols-[1fr_9rem_auto] sm:items-end">
<TextField
label={phaseIdx === 0 ? "Bezeichnung" : ""}
value={ph.name}
onChange={(v) => updatePhase(segIdx, phaseIdx, { name: v })}
/>
<NumberField
label={phaseIdx === 0 ? "Dauer (Jahre)" : ""}
value={ph.durationYears}
min={1}
max={80}
onChange={(v) => updatePhase(segIdx, phaseIdx, { durationYears: Math.max(1, Math.round(v)) })}
/>
<button
type="button"
aria-label="Phase entfernen"
disabled={(segPhases[segIdx]?.length ?? 0) <= 1}
onClick={() => removePhase(segIdx, phaseIdx)}
className="mb-0.5 flex h-8 w-8 items-center justify-center rounded-lg border border-border text-faint transition-colors hover:border-danger hover:bg-danger-soft hover:text-danger disabled:opacity-30"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
))}
</div>
<button
type="button"
onClick={() => addPhase(segIdx)}
className="mt-2 inline-flex items-center gap-1 rounded-lg border border-dashed border-accent px-2.5 py-1 text-xs font-medium text-accent transition-colors hover:bg-accent-soft"
>
<Plus className="h-3.5 w-3.5" /> Phase hinzufügen
</button>
{fixed !== null && !ok && (
<p className="mt-2 text-[11px] text-danger">
Dieser Teil ist durch die Pensionierung auf genau {fixed} Jahre festgelegt. Die Phasendauern müssen
zusammen {fixed} ergeben (aktuell {sum}).
</p>
)}
</div>
);
})}
</div>
)}
{step === 2 && (
<div className="flex flex-col gap-3">
<p className="text-sm text-muted">Die wichtigsten Zahlen deiner Planung grob geschätzt reicht für den Anfang.</p>
{profile.persons.map((p) => {
const nm = profile.persons.find((x) => x.role === p.role)?.name?.trim();
return (
<MoneyField
key={p.role}
label={
isCouple
? `Was verdient ${personLabel(p.role)} netto pro Jahr? (CHF)`
: `Was verdienst du${nm ? ` (${nm})` : ""} netto pro Jahr? (CHF)`
}
help="Der Betrag, der nach allen Lohnabzügen tatsächlich aufs Konto kommt nicht der Bruttolohn. Für die AHV-Rente rechnet das Tool intern hoch."
value={incomes[p.role] ?? 0}
onChange={(v) => setIncomes((prev) => ({ ...prev, [p.role]: v }))}
/>
);
})}
<MoneyField
label={isCouple ? "Was gebt ihr heute pro Jahr aus? (CHF)" : "Was gibst du heute pro Jahr aus? (CHF)"}
help="Alle Lebenshaltungskosten in heutiger Kaufkraft Miete/Wohnen, Essen, Mobilität, Versicherungen, Steuern, Freizeit. Die Inflation rechnet das Tool automatisch dazu."
value={expenses}
onChange={setExpenses}
/>
<MoneyField
label={isCouple ? "Wie viel liegt auf euren Konten? (CHF)" : "Wie viel liegt auf deinem Konto? (CHF)"}
help="Euer heutiger Kontostand (Cash). Er ist der Startwert des Ausgleichskontos."
value={initialCash}
onChange={setInitialCash}
/>
</div>
)}
{step === 3 && (
<div className="flex flex-col gap-4">
<p className="text-sm text-muted">
{isCouple ? "Was besitzt ihr?" : "Was besitzt du?"} Hake an, was zutrifft nur das Angehakte fragt nach Details.
Hier geht es nur um die <strong className="text-fg">heutigen Werte</strong>; die jährlichen Einzahlungen und
Amortisationen verteilst du im nächsten Schritt aus deiner Sparquote.
{isCouple && " Pensionskasse und Säule 3a sind immer persönlich; Wertschriften, Wohneigentum und Schulden könnt ihr gemeinsam oder je Person erfassen."}
</p>
{renderedScopes.map((sc) => (
<div key={sc.id} className="rounded-xl border border-border p-3">
{isCouple && (
<div className="mb-2 text-xs font-semibold uppercase tracking-wide text-faint">{sc.label}</div>
)}
<div className="flex flex-col gap-2">
{sc.cats.includes("pk") && (
<WizardToggle
label="Pensionskasse"
help="PK-Guthaben laut Vorsorgeausweis. Die jährliche Einzahlung erfasst du im nächsten Schritt."
checked={assets[sc.id].pk.on}
onChange={(v) => setScope(sc.id, "pk", { on: v })}
>
<MoneyField label="Aktuelles Guthaben (CHF)" value={assets[sc.id].pk.value} onChange={(v) => setScope(sc.id, "pk", { value: v })} />
<NumberField label="Erwartete Rendite (%/Jahr)" step={0.1} value={assets[sc.id].pk.ret} onChange={(v) => setScope(sc.id, "pk", { ret: v })} />
</WizardToggle>
)}
{sc.cats.includes("p3a") && (
<WizardToggle
label="Säule 3a"
help="3a-Guthaben laut Bescheinigung. Die jährliche Einzahlung erfasst du im nächsten Schritt."
checked={assets[sc.id].p3a.on}
onChange={(v) => setScope(sc.id, "p3a", { on: v })}
>
<MoneyField label="Aktuelles Guthaben (CHF)" value={assets[sc.id].p3a.value} onChange={(v) => setScope(sc.id, "p3a", { value: v })} />
<NumberField label="Erwartete Rendite (%/Jahr)" step={0.1} value={assets[sc.id].p3a.ret} onChange={(v) => setScope(sc.id, "p3a", { ret: v })} />
</WizardToggle>
)}
{sc.cats.includes("etf") && (
<WizardToggle
label="Wertschriften / ETF / Sparen"
help="Depots, Fonds, Sparkonten alles, was Rendite bringt und nicht Vorsorge ist. Den jährlichen Sparbeitrag erfasst du im nächsten Schritt."
checked={assets[sc.id].etf.on}
onChange={(v) => setScope(sc.id, "etf", { on: v })}
>
<MoneyField label="Aktueller Wert (CHF)" value={assets[sc.id].etf.value} onChange={(v) => setScope(sc.id, "etf", { value: v })} />
<NumberField label="Erwartete Rendite (%/Jahr)" step={0.1} value={assets[sc.id].etf.ret} onChange={(v) => setScope(sc.id, "etf", { ret: v })} />
</WizardToggle>
)}
{sc.cats.includes("re") && (
<WizardToggle
label="Wohneigentum"
help="Kaufpreis und Hypothek laut Vertrag. Die jährliche Amortisation erfasst du im nächsten Schritt."
checked={assets[sc.id].re.on}
onChange={(v) => setScope(sc.id, "re", { on: v })}
>
<MoneyField label="Kaufpreis (CHF)" value={assets[sc.id].re.price} onChange={(v) => setScope(sc.id, "re", { price: v })} />
<MoneyField label="Hypothek (CHF)" value={assets[sc.id].re.mortgage} onChange={(v) => setScope(sc.id, "re", { mortgage: v })} />
<NumberField label="Hypothekarzins (%)" step={0.1} value={assets[sc.id].re.interest} onChange={(v) => setScope(sc.id, "re", { interest: v })} />
<NumberField label="Wertsteigerung (%/Jahr)" step={0.1} value={assets[sc.id].re.growth} onChange={(v) => setScope(sc.id, "re", { growth: v })} />
<label className="col-span-full flex cursor-pointer items-center gap-2 text-xs text-muted">
<input
type="checkbox"
checked={assets[sc.id].re.interestIncluded}
onChange={(e) => setScope(sc.id, "re", { interestIncluded: e.target.checked })}
/>
Hypothekarzinsen sind bereits in den Ausgaben enthalten
<InfoBubble text="Damit die Zinsen nicht doppelt zählen. Angehakt (Standard): Du hast sie oben schon in den Jahresausgaben berücksichtigt. Nicht angehakt: Das Tool rechnet sie zusätzlich dazu." />
</label>
</WizardToggle>
)}
{sc.cats.includes("debt") && (
<WizardToggle
label="Schulden / Kredite"
help="Privatkredite, Autoleasing-Restwert und Ähnliches ohne die Hypothek (die gehört zum Wohneigentum). Die jährliche Rückzahlung erfasst du im nächsten Schritt."
checked={assets[sc.id].debt.on}
onChange={(v) => setScope(sc.id, "debt", { on: v })}
>
<MoneyField label="Restschuld (CHF)" value={assets[sc.id].debt.value} onChange={(v) => setScope(sc.id, "debt", { value: v })} />
</WizardToggle>
)}
</div>
</div>
))}
<p className="text-[11px] text-faint">
Die AHV wird automatisch für {isCouple ? "beide Personen" : "dich"} angelegt die Rente berechnet das Tool
aus dem Einkommen.
</p>
</div>
)}
{step === 4 && (
<div className="flex flex-col gap-4">
{/* Sparquote-Karte: das Kernmodell des Tools zum Anfassen. */}
<div className={`rounded-xl border p-4 ${sparBase >= 0 ? "border-accent bg-accent-soft/30" : "border-danger bg-danger-soft"}`}>
<div className="text-xs font-semibold uppercase tracking-wide text-faint">Deine jährliche Sparquote</div>
<div className="mt-1 flex flex-wrap items-baseline gap-x-2 gap-y-1 text-sm">
<span className="text-muted">{totalIncome.toLocaleString("de-CH")} CHF Einkommen</span>
<span className="text-faint"></span>
<span className="text-muted">{effectiveExpenses.toLocaleString("de-CH")} CHF Ausgaben</span>
<span className="text-faint">=</span>
<span className={`text-lg font-bold ${sparBase >= 0 ? "text-fg" : "text-danger"}`}>
{sparBase.toLocaleString("de-CH")} CHF / Jahr
</span>
</div>
{addedInterest > 0 && (
<p className="mt-1 text-[11px] text-faint">
Enthält {addedInterest.toLocaleString("de-CH")} CHF Hypothekarzinsen, die du im vorigen Schritt als «noch
nicht in den Ausgaben» markiert hast.
</p>
)}
<p className="mt-1 text-xs text-muted">
{sparBase >= 0
? "Dieser Betrag bleibt dir jährlich zum Sparen, Amortisieren oder Anlegen. Verteile ihn unten was übrig bleibt, wächst auf dem Cash-Konto."
: "Du gibst mehr aus, als du einnimmst die Differenz wird laufend aus deinem Vermögen bzw. Cash gedeckt. Einzahlungen unten sind dann nur sinnvoll, wenn genügend Cash vorhanden ist."}
</p>
</div>
{/* PK-Einzahlung: bewusst SEPARAT, weil sie vom Bruttolohn kommt und die Sparquote
nicht schmälert -- exakt wie der Rechenkern es behandelt. */}
{anyPk && (
<div className="rounded-xl border border-border p-3">
<div className="mb-1 text-xs font-semibold uppercase tracking-wide text-faint">
Pensionskassen-Einzahlung
</div>
<p className="mb-2 text-xs text-muted">
Wird direkt vom <strong className="text-fg">Bruttolohn</strong> bezahlt also <strong className="text-fg">bevor</strong> dein
Nettoeinkommen entsteht. Sie schmälert deine Sparquote deshalb <strong className="text-fg">nicht</strong>.
</p>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{renderedScopes.filter((sc) => assets[sc.id].pk.on).map((sc) => (
<MoneyField
key={sc.id}
label={`PK-Einzahlung pro Jahr (CHF)${scopeTag(sc)}`}
value={assets[sc.id].pk.contribution}
onChange={(v) => setScope(sc.id, "pk", { contribution: v })}
/>
))}
</div>
</div>
)}
{/* "Noch zu verteilen" prominent -- direkt unter der PK, ueber der Verteilung. */}
{anyQuoteTarget && (
<div
className={`flex items-center justify-between rounded-xl border px-4 py-2.5 text-sm ${
restToCash < 0 ? "border-danger bg-danger-soft" : "border-border bg-surface-2"
}`}
>
<span className="font-medium text-fg">Noch nicht verteilt (bleibt auf dem Cash-Konto)</span>
<span className={`text-base font-bold ${restToCash < 0 ? "text-danger" : "text-fg"}`}>
{restToCash.toLocaleString("de-CH")} CHF / Jahr
</span>
</div>
)}
{anyQuoteTarget && restToCash < 0 && (
<p className="-mt-2 text-[11px] text-danger">
Du verteilst mehr, als deine Sparquote hergibt die Differenz muss aus dem Cash-Bestand kommen und kann ihn
ins Minus ziehen.
</p>
)}
{/* Verteilung der Sparquote -- gruppiert nach Eigentuemer-Bereich (Gemeinsam/A/B). */}
{anyQuoteTarget ? (
<div className="flex flex-col gap-3">
{renderedScopes
.filter((sc) => {
const a = assets[sc.id];
return a.p3a.on || a.etf.on || a.re.on || a.debt.on;
})
.map((sc) => (
<div key={sc.id} className="rounded-xl border border-border p-3">
<div className="mb-2 text-xs font-semibold uppercase tracking-wide text-faint">
{isCouple ? sc.label : "Wohin fliesst deine Sparquote?"}
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{assets[sc.id].p3a.on && (
<div className="flex flex-col gap-1.5 sm:col-span-2">
<label className="flex cursor-pointer items-center gap-2 text-xs text-muted">
<input
type="checkbox"
checked={assets[sc.id].p3a.selfEmployed}
onChange={(e) => setScope(sc.id, "p3a", { selfEmployed: e.target.checked })}
/>
Selbstständig ohne Pensionskasse («grosse Säule 3a»)
<InfoBubble text={`Selbstständigerwerbende ohne PK dürfen bis 20 % des Nettoerwerbseinkommens einzahlen, gedeckelt auf ca. ${PILLAR_3A_MAX_SELF_EMPLOYED.toLocaleString("de-CH")} CHF statt der ${PILLAR_3A_MAX_ANNUAL.toLocaleString("de-CH")} CHF mit PK.`} />
</label>
<div className="sm:max-w-[calc(50%-0.375rem)]">
<MoneyField
label="Säule 3a: Einzahlung / Jahr (CHF)"
help={`Maximal ${max3aFor(sc.id).toLocaleString("de-CH")} CHF${assets[sc.id].p3a.selfEmployed ? " (Selbstständig ohne PK)" : " (mit PK)"}.`}
value={assets[sc.id].p3a.contribution}
max={max3aFor(sc.id)}
onChange={(v) => setScope(sc.id, "p3a", { contribution: v })}
/>
</div>
</div>
)}
{assets[sc.id].etf.on && (
<MoneyField
label="Wertschriften: Sparbeitrag / Jahr (CHF)"
value={assets[sc.id].etf.contribution}
onChange={(v) => setScope(sc.id, "etf", { contribution: v })}
/>
)}
{assets[sc.id].re.on && (
<MoneyField
label="Wohneigentum: Amortisation / Jahr (CHF)"
value={assets[sc.id].re.amortization}
onChange={(v) => setScope(sc.id, "re", { amortization: v })}
/>
)}
{assets[sc.id].debt.on && (
<MoneyField
label="Schulden: Rückzahlung / Jahr (CHF)"
value={assets[sc.id].debt.repayment}
onChange={(v) => setScope(sc.id, "debt", { repayment: v })}
/>
)}
</div>
</div>
))}
</div>
) : (
<p className="text-sm text-muted">
Du hast im vorigen Schritt keine Spar-/Vorsorgeposten angehakt, in die deine Sparquote fliessen könnte. Der
ganze Betrag wächst dann auf dem Cash-Konto. Zurück im vorigen Schritt kannst du z. B. Wertschriften oder die
Säule 3a ergänzen.
</p>
)}
</div>
)}
{step === 5 && (
<div className="flex flex-col gap-3">
<p className="text-sm text-muted">Das wird angelegt:</p>
<ul className="flex flex-col gap-1.5 rounded-xl border border-border bg-surface-2 p-4 text-sm text-fg">
<li>📋 Plan «{planName.trim() || "Meine Planung"}» mit Basisszenario</li>
<li>📅 {flatPhases.map((p) => `${p.name} (${p.durationYears} J.)`).join(" → ")}</li>
<li>
💰 {profile.persons.filter((p) => (incomes[p.role] ?? 0) > 0).length > 0 ? "Einkommen, " : ""}
{expenses > 0 ? "Ausgaben, " : ""}AHV
{enabledAssetCount > 0 ? ` + ${enabledAssetCount} Vermögens-/Vorsorgeposten` : ""}
</li>
</ul>
<p className="text-xs text-muted">
Danach zeigt dir eine kurze Tour die Matrix und die offenen{" "}
<strong className="text-fg">Übergangs-Entscheide</strong> (z. B. was bei der Pensionierung mit der PK
passiert) gehst du direkt in der Ansicht durch.
</p>
</div>
)}
{error && <p className="text-sm text-danger">{error}</p>}
<div className="flex items-center justify-between border-t border-border pt-3">
<Button variant="ghost" size="sm" onClick={onClose}>
Abbrechen
</Button>
<div className="flex gap-2">
{/* Zurück führt in Schritt 0 auf den Willkommens-Screen. */}
<Button variant="secondary" onClick={() => (step > 0 ? setStep(step - 1) : setStarted(false))} disabled={saving}>
<ArrowLeft className="h-4 w-4" /> Zurück
</Button>
{step < STEP_TITLES.length - 1 ? (
<Button onClick={goNext} disabled={step === 1 && !step1Valid}>
Weiter <ArrowRight className="h-4 w-4" />
</Button>
) : (
<Button onClick={create} disabled={saving}>
<Sparkles className="h-4 w-4" /> {saving ? "Wird angelegt…" : "Plan erstellen"}
</Button>
)}
</div>
</div>
</div>
</div>
</Modal>
);
}
// Zeitachse: proportionaler Balken mit nummerierten Phasen; Pensionierungs-Fixpunkte als
// Flaggen darüber; die Beschriftung steht als nummerierte Liste UNTER dem Balken, damit auch
// kurze Phasen sauber lesbar sind.
function WizardTimeline({
segments,
segPhases,
profile,
}: {
segments: PlanSegment[];
segPhases: PhaseDraft[][];
profile: ProfileDraft;
}) {
const flat = segments.flatMap((seg, segIdx) =>
(segPhases[segIdx] ?? []).map((ph) => ({ ...ph, type: seg.type }))
);
const total = flat.reduce((s, p) => s + Math.max(1, p.durationYears), 0);
if (total <= 0 || flat.length === 0) return null;
const minAge = Math.min(...profile.persons.map((p) => p.age));
const markers = profile.persons
.map((p) => ({ label: p.name?.trim() || (p.role === "PERSON_A" ? "A" : "B"), year: Math.max(0, p.retirementAge - p.age), age: p.retirementAge, role: p.role }))
.filter((m) => m.year > 0 && m.year < total);
const durs = flat.map((ph) => Math.max(1, ph.durationYears));
const numbered = flat.map((ph, i) => {
const from = durs.slice(0, i).reduce((s, d) => s + d, 0);
return { ...ph, index: i + 1, from, to: from + durs[i] };
});
return (
<div className="rounded-xl border border-border bg-surface p-3">
<div className="relative pt-5">
{/* Pensionierungs-Fixpunkte */}
{markers.map((m) => (
<div
key={m.role}
className="absolute top-0 flex -translate-x-1/2 flex-col items-center"
style={{ left: `${(m.year / total) * 100}%` }}
title={`${m.label}: Pensionierung mit ${m.age}`}
>
<span className="flex items-center gap-0.5 whitespace-nowrap text-[10px] font-medium text-accent">
<Flag className="h-3 w-3" fill="var(--accent)" /> {m.label} · {m.age}
</span>
<div className="h-2 w-px bg-accent" />
</div>
))}
{/* Balken mit nummerierten Segmenten */}
<div className="flex w-full overflow-hidden rounded-lg border border-border">
{numbered.map((ph, i) => (
<div
key={i}
style={{ width: `${(Math.max(1, ph.durationYears) / total) * 100}%` }}
title={`${ph.name} · ${ph.durationYears} J.`}
className={`flex items-center justify-center py-1.5 text-[10px] font-bold ${SEGMENT_META[ph.type].color} ${i > 0 ? "border-l border-border/50" : ""}`}
>
{ph.index}
</div>
))}
</div>
{/* Alters-Achse */}
<div className="mt-0.5 flex justify-between text-[10px] text-faint">
<span>{minAge} J.</span>
<span>{minAge + total} J.</span>
</div>
</div>
{/* Beschriftung unter dem Balken */}
<div className="mt-2 flex flex-wrap gap-x-3 gap-y-1 text-[11px]">
{numbered.map((ph, i) => (
<span key={i} className="inline-flex items-center gap-1 whitespace-nowrap text-muted">
<span className="flex h-4 w-4 items-center justify-center rounded-full bg-surface-2 text-[9px] font-bold text-fg">
{ph.index}
</span>
{ph.name} · {ph.durationYears} J.
</span>
))}
</div>
</div>
);
}
function WizardToggle({
label,
help,
checked,
onChange,
children,
}: {
label: string;
help: string;
checked: boolean;
onChange: (v: boolean) => void;
children: React.ReactNode;
}) {
return (
<div className={`rounded-xl border p-3 transition-colors ${checked ? "border-accent bg-accent-soft/30" : "border-border bg-surface-2"}`}>
<label className="flex cursor-pointer items-center gap-2 text-sm font-medium text-fg">
<input type="checkbox" checked={checked} onChange={(e) => onChange(e.target.checked)} />
{label}
<InfoBubble text={help} />
</label>
{checked && <div className="mt-3 grid grid-cols-1 gap-3 sm:grid-cols-2">{children}</div>}
</div>
);
}
+540
View File
@@ -0,0 +1,540 @@
"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
plan={plan}
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>
{/* Dieselbe Vokabel wie bei den Phasenzellen (SPEZIFIKATION 3.14.2): «angeschaut».
Es ist derselbe Gedanke -- dort auf den Werten der Vorphase, hier auf einer Vorgabe
des Tools -- und zwei Wörter für eine Sache haben nur verwirrt. */}
{confirmed ? (
<span className="flex items-center gap-1 whitespace-nowrap text-[11px] font-semibold text-success">
<Check className="h-3.5 w-3.5" /> angeschaut
</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 rounded-lg bg-surface-2 px-3 py-2 text-xs text-muted">
<input
type="checkbox"
checked={confirmed}
onChange={(e) => onConfirm(e.target.checked)}
className="mt-0.5"
/>
<span>
<strong className="text-fg">Angeschaut.</strong> FPT muss hier etwas rechnen, auch bevor du entschieden
hast deshalb gibt es eine Vorgabe (volle Rente, Bezug im Pensionsjahr). Der Haken hält fest, dass du
sie gesehen und für richtig befunden hast. <strong className="text-fg">Er ändert keine Zahl.</strong>{" "}
Ohne ihn rechnet FPT genauso weiter, weist die Vorgabe aber als «ungeprüft» aus, 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;
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) })}
// KEIN appearance-none: Damit zeichnet der Browser die Schiene nicht mehr selbst,
// und accent-color greift nicht -- uebrig blieb der blosse Knopf. Im warmen Schema
// war die Schiene dadurch unsichtbar.
// Gleiche Schreibweise wie der Regler der Live-Simulation, der immer funktioniert
// hat: keine erzwungene Hoehe, kein appearance-none.
className="flex-1 cursor-pointer 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>
{/* Bei 100 % Kapital wird nichts verrentet -- dann ist der Satz gegenstandslos, genau wie
die Kapitalbezugssteuer bei 0 % Kapital. */}
{share < 100 && (
<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 && (
// attention-fg ist WEISS -- gedacht fuer vollflaechigen Attention-Grund. Auf
// 10 % Deckkraft war der Text im warmen und im hellen Schema unlesbar.
<p className="mt-1 flex items-start gap-2 rounded-lg border border-attention bg-attention-soft px-3 py-2 text-xs text-attention-soft-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} plan={plan} />
</>
)}
</Pillar>
);
}
export function Pillar3aBlock({
plan,
el,
rd,
patch,
retirementAge,
siblings,
defaultOpen,
}: {
plan: PlanInput;
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-soft-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>
)}
{/* Die Frage stellt sich hier sogar zwingender als bei der PK: Ein 3a-Konto wird IMMER
vollständig bezogen, es fliesst also in jedem Fall ein grosser Betrag. Bis 0.40 gab
es die Maske nur bei der Pensionskasse -- das 3a-Kapital landete stumm auf dem Cash. */}
<CapitalUse rd={rd} elementId={el.id} patch={patch} plan={plan} />
</Pillar>
);
}
// Eine Zeile der Kapitalverwendung: Quote plus, sobald sie > 0 ist, das Ziel. Eigene Komponente
// auf Modulebene -- innerhalb von CapitalUse deklariert wuerde sie bei jedem Tastendruck neu
// erzeugt und verloere ihren Zustand (der React Compiler weist das zu Recht zurueck).
function CapitalUseRow({
title,
help,
value,
max,
onChange,
targets,
targetValue,
onTarget,
targetLabel,
missing,
label,
}: {
title: string;
help: string;
value: number;
max: number;
onChange: (v: number) => void;
targets: ElementInput[];
targetValue?: string;
onTarget: (id: string) => void;
targetLabel: string;
missing: string;
label: (e: ElementInput) => string;
}) {
return (
<div className="rounded-lg border border-border p-3">
<div className="grid gap-3 sm:grid-cols-2">
<NumberField label={title} help={help} value={value} step={5} min={0} max={max} onChange={onChange} />
{value > 0 && targets.length > 0 && (
<SelectField
label={targetLabel}
value={targetValue ?? targets[0].id}
onChange={(v: string) => onTarget(v)}
options={targets.map((t) => ({ value: t.id, label: label(t) }))}
/>
)}
{value > 0 && targets.length === 0 && (
<p className="flex items-start gap-2 self-end text-xs text-attention-soft-fg">
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
{missing}
</p>
)}
</div>
</div>
);
}
// 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.
//
// Drei gleichrangige Zeilen: Schuldentilgung, Investition, Cash. Cash ist rechnerisch der
// REST, wird aber wie eine Wahl dargestellt und nicht wie eine Fussnote -- «ich lasse es
// liegen und entscheide später» ist ein legitimer Entscheid. Alle drei frei eintippbar zu
// machen hiesse, dass beim Tippen still eine der anderen Zahlen wandert; das überrascht mehr,
// als es hilft.
function CapitalUse({
rd,
elementId,
patch,
plan,
}: {
rd: RetirementDecision;
elementId: string;
patch: (elementId: string, p: Partial<RetirementDecision>) => void;
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);
// Tilgungs-Ziele: Hypotheken UND sonstige Schulden. Wer einen Konsumkredit zu 6 % neben
// einer Hypothek zu 1,5 % trägt, tilgt zuerst den Kredit.
const debtTargets = plan.elements.filter((e) => e.category === "REAL_ESTATE" || e.category === "OTHER_DEBT");
const investTargets = plan.elements.filter((e) => e.category === "OTHER_ASSET");
const label = (e: ElementInput) => `${e.name} · ${ownerLabel(plan.persons, e.ownerRole)}`;
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="flex flex-col gap-2">
<CapitalUseRow
label={label}
title="Schuldentilgung (%)"
help="Einmalige Tilgung. Am Restsaldo gekappt ist die Schuld kleiner als der Anteil, bleibt der Rest Cash."
value={amort}
max={100}
onChange={(v) => patch(elementId, { capitalUseAmortizationPct: Math.max(0, Math.min(100, v)) })}
targets={debtTargets}
targetValue={rd.capitalUseDebtTargetElementId}
onTarget={(v) => patch(elementId, { capitalUseDebtTargetElementId: v })}
targetLabel="Welche Schuld?"
missing="Es gibt weder Immobilie noch Schuld in diesem Szenario der Anteil bliebe auf dem Cash-Konto."
/>
<CapitalUseRow
label={label}
title="Investition (%)"
help="Fliesst als Zusatzeinlage in das gewählte Vermögens-Element und wächst dort weiter."
value={invest}
max={100 - amort}
onChange={(v) => patch(elementId, { capitalUseInvestPct: Math.max(0, Math.min(100 - amort, v)) })}
targets={investTargets}
targetValue={rd.capitalUseTargetElementId}
onTarget={(v) => patch(elementId, { capitalUseTargetElementId: v })}
targetLabel="Wohin investieren?"
missing="Es gibt kein Element «Sonstiges Vermögen» der Anteil bliebe auf dem Cash-Konto."
/>
{/* Cash ist der Rest, aber als vollwertige Zeile dargestellt. */}
<div className="rounded-lg border border-dashed border-border bg-surface-2 p-3">
<div className="flex items-baseline justify-between gap-3">
<span className="text-xs font-medium text-muted">Cash (%)</span>
<span className="text-sm font-semibold tabular-nums text-fg">{cash} %</span>
</div>
<p className="mt-1 text-[11px] text-faint">
Was du nicht zuteilst, bleibt auf dem Cash-Konto unverzinst, aber jederzeit verfügbar.
</p>
</div>
</div>
</div>
);
}
+114
View File
@@ -0,0 +1,114 @@
"use client";
// Die Übersicht der offenen Punkte -- oben rechts, wo bis 0.38 der Assistent stand.
//
// Sie listet keine Schritte auf, sondern zeigt einen ZUSTAND: Was ist in diesem Plan noch
// nicht entschieden oder nicht bestätigt? Der Unterschied fällt auf, sobald man einen
// bestehenden Plan öffnet -- der Assistent bot dort Schritte an, die längst erledigt waren.
//
// Zwei Sonderzustände ganz am Anfang: Ohne Elemente und ohne Lebensphasen gibt es
// naturgemäss nichts Offenes, obwohl der Plan leer ist. Eine "0" wäre dort eine Lüge; deshalb
// steht dort eine Aufforderung statt einer Zahl.
import { CheckCircle2, ClipboardList, Sparkles } from "lucide-react";
import type { PlanReview } from "@/lib/review";
export function ReviewTile({
review,
onOpenInventory,
onAddPhase,
onJump,
}: {
review: PlanReview;
onOpenInventory: () => void;
onAddPhase: () => void;
onJump: (group: { id: string; kind: "phase" | "transition" }) => void;
}) {
const done = review.stage === "DONE";
const starting = review.stage === "NO_ELEMENTS" || review.stage === "NO_PHASES";
return (
<div
data-tour="assistant"
// KEIN h-full: In der rechten Leiste ist die Kachel das einzige Element mit Inhalt --
// mit h-full zog sie sich ueber den ganzen Bildschirm, obwohl ein einziger Eintrag
// darin stand.
className={`flex flex-col rounded-xl border px-3 py-2.5 shadow-sm transition-colors ${
done ? "border-success bg-success-soft" : "border-attention bg-attention-soft"
}`}
>
<div className="mb-2 flex items-center gap-2">
{done ? (
<CheckCircle2 className="h-4 w-4 text-success" />
) : (
<ClipboardList className="h-4 w-4 text-attention" />
)}
<span className="text-xs font-semibold uppercase tracking-wide text-fg">Offene Punkte</span>
{!starting && (
<span className={`ml-auto text-[11px] font-semibold ${done ? "text-success" : "text-muted"}`}>
{done ? "keine" : review.total}
</span>
)}
</div>
{review.stage === "NO_ELEMENTS" && (
<div className="flex min-h-0 flex-1 flex-col gap-2">
<p className="text-[11px] leading-snug text-muted">
Dein Plan ist noch leer. Erfasse zuerst, was du heute hast danach planst du deine erste Lebensphase.
</p>
<button
type="button"
onClick={onOpenInventory}
className="mt-auto flex items-center justify-center gap-1.5 rounded-lg bg-accent px-2 py-1.5 text-[11px] font-semibold text-accent-fg hover:opacity-90"
>
<Sparkles className="h-3.5 w-3.5" /> Bestandsaufnahme
</button>
</div>
)}
{review.stage === "NO_PHASES" && (
<div className="flex min-h-0 flex-1 flex-col gap-2">
<p className="text-[11px] leading-snug text-muted">
Deine Bestandsaufnahme steht. Jetzt fehlt die erste Lebensphase erst dann gibt es etwas zu
entscheiden.
</p>
<button
type="button"
onClick={onAddPhase}
className="mt-auto flex items-center justify-center gap-1.5 rounded-lg bg-accent px-2 py-1.5 text-[11px] font-semibold text-accent-fg hover:opacity-90"
>
Erste Lebensphase anlegen
</button>
</div>
)}
{review.stage === "DONE" && (
<div className="flex min-h-0 flex-1 flex-col justify-center gap-1">
<p className="text-sm font-semibold text-success">Alles entschieden</p>
<p className="text-[11px] leading-snug text-muted">
In allen Lebensphasen und Übergängen ist nichts mehr offen. Änderst du etwas, erscheint es hier wieder.
</p>
</div>
)}
{review.stage === "OPEN" && (
<div className="flex min-h-0 flex-1 flex-col gap-1 overflow-auto">
{review.groups.map((g) => (
<button
key={`${g.kind}-${g.id}`}
type="button"
onClick={() => onJump(g)}
className="rounded-lg px-1.5 py-1 text-left transition-colors hover:bg-surface/60"
>
<span className="flex items-baseline gap-1.5">
<span className="min-w-0 flex-1 truncate text-xs font-semibold text-fg">{g.title}</span>
<span className="shrink-0 text-[11px] font-semibold text-attention">{g.open}</span>
</span>
<span className="block truncate text-[11px] text-muted">{g.reasons.join(" · ")}</span>
</button>
))}
</div>
)}
</div>
);
}
+2 -2
View File
@@ -200,14 +200,14 @@ export function SensitivityDialog({
label="tief" label="tief"
value={dr.low} value={dr.low}
suffix={suffix} suffix={suffix}
step={d.unit === "delta_years" ? 1 : 0.1} step={d.unit === "delta_years" || d.unit === "delta_months" ? 1 : 0.1}
onChange={(v) => setDraft(d.id, { low: v })} onChange={(v) => setDraft(d.id, { low: v })}
/> />
<RequiredNumberField <RequiredNumberField
label="hoch" label="hoch"
value={dr.high} value={dr.high}
suffix={suffix} suffix={suffix}
step={d.unit === "delta_years" ? 1 : 0.1} step={d.unit === "delta_years" || d.unit === "delta_months" ? 1 : 0.1}
onChange={(v) => setDraft(d.id, { high: v })} onChange={(v) => setDraft(d.id, { high: v })}
/> />
</div> </div>
+159 -104
View File
@@ -11,15 +11,24 @@ interface PersonAxis {
color: string; color: string;
} }
// Horizontale Zeitachse: die Lebensphasen als aneinandergereihte Segmente (Breite = Dauer), // Die Zeitachse: ein durchgehender Balken, die Lebensphasen als Segmente, Breite nach Dauer.
// darunter Alter und -- sofern ein Planstart gesetzt ist -- die Kalenderjahre. Pensionsalter //
// je Person und ein allfälliges Ruinalter sind als Marker eingezeichnet. // Seit 0.42 ist sie zugleich der NAVIGATOR der Kartenansicht -- ein Klick springt zur Phase.
// Zeit laeuft von links nach rechts; das versteht man ohne Erklaerung.
//
// 0.42.3 gibt ihr die Form, die man von Zeitstrahlen kennt: Die Beschriftungen stehen
// ABWECHSELND ober- und unterhalb des Balkens, mit Dreieck und Verbindungslinie. Das ist nicht
// Zierde, sondern loest ein Platzproblem: Nebeneinander gesetzte Beschriftungen kollidieren,
// sobald eine Phase kurz ist. Abwechselnd hat jede den doppelten Raum -- Nachbarn auf
// derselben Seite liegen zwei Phasen auseinander.
export function Timeline({ export function Timeline({
phases, phases,
persons, persons,
ruinAge, ruinAge,
startYear, startYear,
actualYears = [], actualYears = [],
activePhaseId,
onSelectPhase,
}: { }: {
phases: PhaseComputed[]; phases: PhaseComputed[];
persons: PersonAxis[]; persons: PersonAxis[];
@@ -28,143 +37,189 @@ export function Timeline({
// Kalenderjahre, für die effektive Werte erfasst sind (aufsteigend). Der jüngste Satz // Kalenderjahre, für die effektive Werte erfasst sind (aufsteigend). Der jüngste Satz
// wird hervorgehoben, ältere bleiben blass -- sie sind überholt, aber nicht bedeutungslos. // wird hervorgehoben, ältere bleiben blass -- sie sind überholt, aber nicht bedeutungslos.
actualYears?: number[]; actualYears?: number[];
activePhaseId?: string | null;
onSelectPhase?: (phaseId: string) => void;
}) { }) {
if (phases.length === 0 || persons.length === 0) return null; if (phases.length === 0 || persons.length === 0) return null;
const totalYears = phases.reduce((s, p) => s + p.durationYears, 0); const totalYears = phases.reduce((s, p) => s + p.durationYears, 0);
const minAge = Math.min(...persons.map((p) => p.currentAge)); const minAge = Math.min(...persons.map((p) => p.currentAge));
const maxAge = minAge + totalYears; const maxAge = minAge + totalYears;
const span = Math.max(1, maxAge - minAge); const span = Math.max(1, totalYears);
const pct = (age: number) => `${(Math.max(0, Math.min(span, age - minAge)) / span) * 100}%`; // Position eines Alters auf dem Balken, in Prozent seiner Länge.
const pctOfAge = (age: number) => (Math.max(0, Math.min(span, age - minAge)) / span) * 100;
const yearOf = (yearsFromStart: number) => (startYear ? startYear + yearsFromStart : null); const yearOf = (yearsFromStart: number) => (startYear ? startYear + yearsFromStart : null);
// Segmente: Start-/Endjahr (relativ zum Planbeginn) je Phase -- ohne Mutation während
// des Renderns, deshalb die kumulierte Summe der vorangehenden Dauern.
const segments = phases.map((p, i) => { const segments = phases.map((p, i) => {
const from = phases.slice(0, i).reduce((s, x) => s + x.durationYears, 0); const from = phases.slice(0, i).reduce((s, x) => s + x.durationYears, 0);
return { phase: p, from, to: from + p.durationYears }; return { phase: p, from, to: from + p.durationYears, index: i };
}); });
return ( const clickable = !!onSelectPhase;
<div className="rounded-xl border border-border bg-surface p-4 shadow-sm"> const BAR = "var(--fg)";
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<h3 className="text-sm font-semibold text-fg">Zeitachse</h3>
<div className="flex gap-3 text-xs text-muted">
{persons.map((p) => (
<span key={p.role} className="flex items-center gap-1">
<span className="inline-block h-2 w-2 rounded-full" style={{ backgroundColor: p.color }} />
{p.label} (heute {p.currentAge})
</span>
))}
</div>
</div>
<div className="relative pt-6"> // Ein Beschriftungsblock. Oben steht er auf dem Kopf (Karte, Linie, Dreieck), unten in
{/* Pensionsmarker je Person */} // umgekehrter Reihenfolge -- so zeigt das Dreieck immer zum Balken hin.
{persons.map((p, i) => const labelFor = (s: (typeof segments)[number], above: boolean) => {
p.retirementAge > minAge && p.retirementAge < maxAge ? ( const active = activePhaseId === s.phase.id;
<div const from = yearOf(s.from);
key={p.role} const to = yearOf(s.to);
className="absolute top-0 flex -translate-x-1/2 flex-col items-center" const tri = (
style={{ left: pct(p.retirementAge) }} <span
title={`${p.label}: Pensionierung mit ${p.retirementAge}${ aria-hidden="true"
yearOf(p.retirementAge - minAge) ? ` (${yearOf(p.retirementAge - minAge)})` : "" className="h-0 w-0 border-x-[7px] border-x-transparent"
style={
above
? { borderBottomWidth: 9, borderBottomStyle: "solid", borderBottomColor: active ? "var(--accent)" : BAR }
: { borderTopWidth: 9, borderTopStyle: "solid", borderTopColor: active ? "var(--accent)" : BAR }
}
/>
);
const line = <span className="w-px flex-1" style={{ backgroundColor: active ? "var(--accent)" : "var(--border-strong)" }} />;
const card = (
<span
className={`max-w-[9.5rem] rounded-xl border px-2.5 py-1 text-center transition-colors ${
active
? "border-accent bg-accent-soft text-accent-soft-fg shadow-sm"
: "border-border bg-surface text-muted group-hover/tl:border-accent group-hover/tl:text-accent-soft-fg"
}`} }`}
> >
<Flag className="h-3.5 w-3.5" style={{ color: p.color }} fill={p.color} /> <span className={`block whitespace-nowrap text-[13px] font-semibold ${active ? "" : "text-fg"}`}>
<span className="whitespace-nowrap text-[10px] font-medium" style={{ color: p.color }}> {from ? (s.phase.durationYears === 1 ? `${from}` : `${from}${to! - 1}`) : `Phase ${s.index + 1}`}
{p.retirementAge} </span>
<span className="block truncate text-[11px]">{s.phase.name}</span>
<span className="block text-[10px] text-faint">
{s.phase.durationYears} {s.phase.durationYears === 1 ? "Jahr" : "Jahre"}
</span>
</span>
);
return (
<button
key={s.phase.id}
type="button"
disabled={!clickable}
onClick={() => onSelectPhase?.(s.phase.id)}
aria-current={active ? "true" : undefined}
title={`${s.phase.name} · ${s.phase.durationYears} Jahre`}
className={`group/tl absolute bottom-0 top-0 flex -translate-x-1/2 flex-col items-center ${
clickable ? "cursor-pointer" : "cursor-default"
}`}
style={{ left: `${((s.from + s.phase.durationYears / 2) / span) * 100}%` }}
>
{above ? (
<>
{card}
{line}
{tri}
</>
) : (
<>
{tri}
{line}
{card}
</>
)}
</button>
);
};
return (
<div className="flex items-stretch gap-3">
{/* Das Startjahr steht markant am linken Ende -- es ist der Nullpunkt, auf den sich alle
realen Werte und die ganze Achse beziehen. */}
<div className="flex shrink-0 flex-col justify-center pb-1 text-right">
<span className="text-xl font-semibold leading-none text-fg">{startYear ?? minAge}</span>
<span className="mt-0.5 text-[10px] uppercase tracking-wide text-faint">
{startYear ? "Start" : "Jahre"}
</span> </span>
<div className="mt-0.5 h-3 w-px" style={{ backgroundColor: p.color, marginTop: i * 2 }} />
</div> </div>
<div className="relative min-w-0 flex-1">
{/* Beschriftungen oberhalb (Phase 1, 3, 5 …) */}
<div className="relative h-[68px]">{segments.filter((s) => s.index % 2 === 0).map((s) => labelFor(s, true))}</div>
{/* Der Balken. Die Segmente teilen sich die Breite streng nach Dauer -- eine Phase von
fünf Jahren ist halb so breit wie eine von zehn. */}
<div className="relative flex h-9 gap-[3px] overflow-hidden rounded-full">
{segments.map((s) => {
const active = activePhaseId === s.phase.id;
return (
<button
key={s.phase.id}
type="button"
disabled={!clickable}
onClick={() => onSelectPhase?.(s.phase.id)}
aria-label={`${s.phase.name}, ${s.phase.durationYears} Jahre`}
title={`${s.phase.name} · ${s.phase.durationYears} Jahre`}
className={`min-w-0 transition-all ${clickable ? "cursor-pointer hover:brightness-110" : ""}`}
style={{
width: `${(s.phase.durationYears / span) * 100}%`,
backgroundColor: active ? "var(--accent)" : BAR,
opacity: active ? 1 : 0.85,
}}
/>
);
})}
{/* Marker auf dem Balken: Pensionierung je Person, Ruinalter, erfasste Ist-Jahre.
Sie liegen AUF dem Balken statt darüber -- oberhalb kollidierten sie mit den
Beschriftungen. */}
{persons.map((p) =>
p.retirementAge > minAge && p.retirementAge < maxAge ? (
<span
key={p.role}
className="pointer-events-none absolute inset-y-1 w-0.5 -translate-x-1/2 rounded-full"
style={{ left: `${pctOfAge(p.retirementAge)}%`, backgroundColor: p.color }}
title={`${p.label}: Pensionierung mit ${p.retirementAge}`}
/>
) : null ) : null
)} )}
{/* Ruin-Marker (Person A) */}
{ruinAge != null && ruinAge > minAge && ruinAge <= maxAge && ( {ruinAge != null && ruinAge > minAge && ruinAge <= maxAge && (
<div <span
className="absolute top-0 flex -translate-x-1/2 flex-col items-center" className="pointer-events-none absolute inset-y-0 w-1 -translate-x-1/2"
style={{ left: pct(ruinAge) }} style={{ left: `${pctOfAge(ruinAge)}%`, backgroundColor: "var(--danger)" }}
title={`Kapital aufgebraucht mit Alter ${ruinAge}`} title={`Kapital aufgebraucht mit Alter ${ruinAge}`}
> />
<span className="text-[10px] font-semibold" style={{ color: "var(--danger)" }}>
Ruin {ruinAge}
</span>
<div className="mt-0.5 h-4 w-px" style={{ backgroundColor: "var(--danger)" }} />
</div>
)} )}
{/* Marker für erfasste effektive Werte. Nur mit bekanntem Planstartjahr platzierbar --
ohne Kalenderbezug gäbe es keine Position auf der Achse. */}
{startYear && {startYear &&
actualYears.map((y) => { actualYears.map((y) => {
const age = minAge + (y - startYear); const age = minAge + (y - startYear);
if (age < minAge || age > maxAge) return null; if (age < minAge || age > maxAge) return null;
const isLatest = y === actualYears[actualYears.length - 1]; const isLatest = y === actualYears[actualYears.length - 1];
return ( return (
<div
key={y}
className="absolute top-0 flex -translate-x-1/2 flex-col items-center"
style={{ left: pct(age), opacity: isLatest ? 1 : 0.35 }}
title={
isLatest
? `Effektive Werte erfasst für ${y} (aktuellster Stand)`
: `Effektive Werte erfasst für ${y} (überholt)`
}
>
<CalendarCheck
className="h-3.5 w-3.5"
style={{ color: isLatest ? "var(--attention)" : "var(--muted)" }}
/>
<span <span
className="whitespace-nowrap text-[10px] font-medium" key={y}
style={{ color: isLatest ? "var(--attention)" : "var(--muted)" }} className="pointer-events-none absolute top-1/2 h-2 w-2 -translate-x-1/2 -translate-y-1/2 rounded-full ring-2"
> style={{
{y} left: `${pctOfAge(age)}%`,
</span> backgroundColor: isLatest ? "var(--attention)" : "var(--faint)",
<div // eslint-disable-next-line @typescript-eslint/no-explicit-any
className="mt-0.5 h-3 w-px" ["--tw-ring-color" as any]: "var(--surface)",
style={{ backgroundColor: isLatest ? "var(--attention)" : "var(--muted)" }} }}
title={isLatest ? `Effektive Werte für ${y} (aktuell)` : `Effektive Werte für ${y} (überholt)`}
/> />
</div>
);
})}
{/* Phasen-Segmente: Breite proportional zur Dauer, Einfärbung nach Phasentyp. */}
<div className="flex w-full overflow-hidden rounded-lg border border-border">
{segments.map((s, i) => {
const soft = s.phase.type === "PENSION" || s.phase.type === "MIXED";
return (
<div
key={s.phase.id}
style={{ width: `${(s.phase.durationYears / span) * 100}%` }}
title={`${s.phase.name} · ${s.phase.durationYears} Jahre`}
className={`flex min-w-0 flex-col justify-center px-2 py-1.5 ${
i > 0 ? "border-l border-border" : ""
} ${s.phase.type === "PENSION" ? "bg-accent-soft" : s.phase.type === "MIXED" ? "bg-accent/25" : "bg-accent/70"}`}
>
<span className={`truncate text-[10px] font-semibold ${soft ? "text-accent-soft-fg" : "text-accent-fg"}`}>
{s.phase.name}
</span>
<span className={`truncate text-[9px] ${soft ? "text-accent-soft-fg" : "text-accent-fg"} opacity-80`}>
{s.phase.durationYears} J.
{yearOf(s.from) ? ` · ${yearOf(s.from)}${yearOf(s.to)}` : ""}
</span>
</div>
); );
})} })}
</div> </div>
{/* Alters- und Jahres-Beschriftung */} {/* Beschriftungen unterhalb (Phase 2, 4, 6 …) */}
<div className="mt-1 flex justify-between text-[11px] text-muted"> <div className="relative h-[68px]">{segments.filter((s) => s.index % 2 === 1).map((s) => labelFor(s, false))}</div>
<span>
{minAge} J.{yearOf(0) ? ` · ${yearOf(0)}` : ""}
</span>
<span>
{maxAge} J.{yearOf(totalYears) ? ` · ${yearOf(totalYears)}` : ""}
</span>
</div> </div>
{/* Rechts das Planende und, falls vorhanden, die Marker-Erklärung in Kurzform. */}
<div className="flex shrink-0 flex-col justify-center pb-1">
<span className="text-sm font-semibold leading-none text-muted">
{startYear ? startYear + totalYears - 1 : maxAge}
</span>
<span className="mt-0.5 flex items-center gap-1 text-[10px] uppercase tracking-wide text-faint">
Ende
{persons.some((p) => p.retirementAge > minAge && p.retirementAge < maxAge) && (
<Flag className="h-2.5 w-2.5" aria-label="Pensionierung markiert" />
)}
{actualYears.length > 0 && <CalendarCheck className="h-2.5 w-2.5" aria-label="Effektive Werte markiert" />}
</span>
</div> </div>
</div> </div>
); );
+282 -152
View File
@@ -1,211 +1,341 @@
"use client"; "use client";
// Interaktive Kurz-Tour über die Planansicht (Teil von Roadmap Nr. 10). // Die Tour: eine kurze Demo statt eines Spotlights.
// //
// Spotlight ohne Engine-Bibliothek: Der abgedunkelte Bereich entsteht aus VIER fixed-Flächen // Bis 0.35 legte sich die Tour über den ECHTEN Bildschirm und hob Bereiche hervor. Das hatte
// um die Bounding-Box des Ziels herum (oben/unten/links/rechts). Das lässt ein «Loch» am Ziel // zwei Nachteile, die sich nicht beheben liessen: Auf einem frisch angelegten, leeren Plan gab
// frei und funktioniert -- anders als ein box-shadow-Trick -- unabhängig von z-index (sticky // es kaum etwas hervorzuheben -- gerade dann, wenn die Tour am nötigsten ist. Und das
// Matrix-Köpfe) und overflow (Matrix-Scrollbereich). Ziele, die es im aktuellen Plan nicht // Ausschneiden der Hervorhebung kämpfte dauernd mit Scroll-Containern und Z-Ebenen.
// gibt oder die unsichtbar sind, werden übersprungen. //
// Neu zeigt die Tour einen NACHGEBAUTEN Bildschirm mit erfundenen Zahlen. Er ist bewusst
// keine echte Ansicht, sondern eine Illustration: gerade so detailliert, dass man den Aufbau
// erkennt, und so einfach, dass sie sich in einem Popup erklären lässt. Der Preis ist, dass
// die Attrappe bei UI-Änderungen nachgeführt werden muss -- dafür funktioniert die Tour ab
// der ersten Sekunde und unabhängig davon, was im Plan schon steht.
import { useEffect, useMemo, useState } from "react"; import { useState } from "react";
import { Lightbulb, X } from "lucide-react"; import { ArrowLeft, ArrowRight, Sparkles, X } from "lucide-react";
import { Button } from "@/components/ui"; import { Button } from "@/components/ui";
export const TOUR_DONE_KEY = "fpt-tour-done"; export const TOUR_DONE_KEY = "fpt-tour-done";
type Focus = "none" | "settings" | "figures" | "actions" | "assistant" | "timeline" | "matrix" | "sidebar";
interface TourStep { interface TourStep {
target: string; // data-tour="…"
title: string; title: string;
text: string; text: string;
focus: Focus;
} }
const STEPS: TourStep[] = [ const STEPS: TourStep[] = [
{ {
target: "profil", title: "Willkommen bei FPT",
title: "Dein Grundprofil", text: "In zwei Minuten siehst du, wie das Tool aufgebaut ist. Alles, was hier steht, sind erfundene Beispielzahlen dein eigener Plan ist noch leer.",
text: "Alter, Pensionsalter und Inflationsannahme die Basis dieses Szenarios. Über «Bearbeiten» änderst du sie jederzeit.", focus: "none",
},
{
title: "Oben stellst du ein, unten siehst du das Ergebnis",
text: "Der Bildschirm hat zwei Hälften. Oben die Steuerung: Grundeinstellungen, Kennzahlen, Schnellaktionen und der Assistent. Unten die Matrix mit allen Zahlen.",
focus: "none",
},
{
title: "Die Grundeinstellungen",
text: "Wer plant, ab wann und wie lange. Alter, Startjahr und Inflation gelten für den ganzen Plan; Planungshorizont und Pensionsalter gehören zum Szenario. Je Person führt ein Knopf in die Pensionsplanung.",
focus: "settings",
},
{
title: "Die Kennzahlen",
text: "Was unter dem Strich herauskommt: dein Vermögen heute, das Endvermögen nominal und real und ob das Kapital über die ganze Planung reicht.",
focus: "figures",
},
{
title: "Der FPT-Assistent",
text: "Dein Wegweiser. Sieben Schritte führen von der Bestandsaufnahme bis zum fertigen Plan. Jeder Schritt erklärt zuerst, worum es geht, und bietet danach das passende Werkzeug an. Wann ein Schritt erledigt ist, entscheidest du.",
focus: "assistant",
}, },
{ {
target: "timeline",
title: "Die Zeitachse", title: "Die Zeitachse",
text: "Deine Lebensphasen im Zeitverlauf: Die Breite zeigt die Dauer, die Fähnchen die Pensionierungen und falls es dazu kommt, den Punkt, an dem das Kapital ausgeht. Die ganze Planung denkt in diesen Phasen.", text: "Deine Planung im Überblick: Lebensphasen, die Zeitpunkte der Pensionierung und, falls das Geld nicht reicht, das Jahr, in dem es knapp wird.",
focus: "timeline",
}, },
{ {
target: "endwealth", title: "Die Matrix",
title: "Dein Endvermögen", text: "Das Herz des Tools. Jede Zeile ist ein finanzielles Element, jede Spalte eine Lebensphase. Die schmalen Spalten dazwischen sind die Übergänge dort entscheidest du, was beim Wechsel geschieht.",
text: "Das Ergebnis auf einen Blick: das Vermögen am Ende der Planung nominal und real (in heutiger Kaufkraft). Reicht das Kapital nicht bis zum Schluss, steht hier stattdessen das Alter, in dem es ausgeht.", focus: "matrix",
}, },
{ {
target: "matrix", title: "Pläne und Szenarien",
title: "Die Matrix das Herzstück", text: "Links verwaltest du alles. Ein Szenario ist eine Variante deines Plans etwa mit anderem Pensionsalter. Du kannst beliebig viele anlegen und vergleichen.",
text: "Zeilen sind deine Finanzen, Spalten deine Lebensphasen. Klick auf eine Zelle: rechts öffnet sich das Bearbeiten-Panel, die Matrix bleibt sichtbar.", focus: "sidebar",
}, },
{ {
target: "transition", title: "Jetzt bist du dran",
title: "Übergänge", text: "Starte mit dem FPT-Assistenten oben rechts. Schritt 1 ist die Bestandsaufnahme: alles, was du heute besitzt. Von dort führt dich der Assistent weiter.",
text: "Zwischen zwei Phasen triffst du einmalige Entscheide verkaufen, beziehen, verrenten. Orange heisst: noch offen. Klick auf den Spaltenkopf führt dich durch alle Entscheide.", focus: "assistant",
},
{
target: "cash",
title: "Das Cash-Konto",
text: "Dein Ausgleichskonto: Was übrig bleibt, landet hier was fehlt, macht es rot. Negativ = Liquiditätslücke.",
},
{
target: "toolbar",
title: "Aktionen zum Szenario",
text: "Hier oben: die Änderungshistorie ansehen, ein neues Szenario aus diesem ableiten, die Rechenwege einsehen, die Matrix als CSV exportieren und diese Tour jederzeit neu starten.",
},
{
target: "menu",
title: "Analysen & Berichte",
text: "Links im Menü öffnest du die grossen Werkzeuge: unter «Analysen» die Grafiken, die Live-Simulation, Monte-Carlo und die Einflussfaktoren; unter «Berichte» erzeugst du ein PDF. «Effektive Werte» hält deine echten Zahlen gegen den Plan.",
}, },
]; ];
interface Box {
top: number;
left: number;
width: number;
height: number;
}
export function Tour({ onClose }: { onClose: () => void }) { export function Tour({ onClose }: { onClose: () => void }) {
// Nur Schritte, deren Ziel im DOM existiert UND sichtbar ist (z. B. die Menü-Leiste ist auf
// schmalen Screens ausgeblendet -- getClientRects() ist dann leer).
const steps = useMemo(
() =>
STEPS.filter((s) => {
const el = document.querySelector(`[data-tour="${s.target}"]`);
return el !== null && el.getClientRects().length > 0;
}),
[]
);
const [index, setIndex] = useState(0); const [index, setIndex] = useState(0);
const [rect, setRect] = useState<Box | null>(null); const step = STEPS[index];
const step = steps[index]; const last = index === STEPS.length - 1;
useEffect(() => {
if (!step) return;
const el = document.querySelector(`[data-tour="${step.target}"]`);
if (!el) return;
el.scrollIntoView({ block: "center", behavior: "smooth" });
// Die Bounding-Box jedes Frame nachführen (folgt dem sanften Scrollen und Resize), aber
// den State nur bei tatsächlicher Änderung neu setzen.
let raf = 0;
const update = () => {
const r = el.getBoundingClientRect();
setRect((prev) =>
prev && prev.top === r.top && prev.left === r.left && prev.width === r.width && prev.height === r.height
? prev
: { top: r.top, left: r.left, width: r.width, height: r.height }
);
raf = requestAnimationFrame(update);
};
update();
return () => cancelAnimationFrame(raf);
}, [step]);
if (!step || !rect) return null;
const pad = 6;
const hole: Box = {
top: Math.max(0, rect.top - pad),
left: Math.max(0, rect.left - pad),
width: rect.width + pad * 2,
height: rect.height + pad * 2,
};
const cardAtBottom = rect.top + rect.height / 2 < window.innerHeight * 0.5;
function finish() { function finish() {
try { try {
localStorage.setItem(TOUR_DONE_KEY, "1"); localStorage.setItem(TOUR_DONE_KEY, "1");
} catch { } catch {
/* localStorage nicht verfügbar -> Tour erscheint beim nächsten Mal erneut */ // Privater Modus o. Ä. -- die Tour lässt sich dann eben erneut öffnen.
} }
onClose(); onClose();
} }
// Vier Abdunkel-Flächen um das Loch. pointer-events-none, damit nichts blockiert wird.
const dim = "fixed z-[45] bg-black/60 pointer-events-none";
const belowTop = hole.top + hole.height;
const rightOfLeft = hole.left + hole.width;
return ( return (
<> <div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/60 p-3 sm:p-6">
<div className={dim} style={{ top: 0, left: 0, width: "100vw", height: hole.top }} /> <div className="flex h-full max-h-[900px] w-full max-w-6xl flex-col overflow-hidden rounded-2xl border border-border bg-surface shadow-2xl">
<div className={dim} style={{ top: belowTop, left: 0, width: "100vw", height: `calc(100vh - ${belowTop}px)` }} /> <header className="flex items-center gap-3 border-b border-border px-4 py-3">
<div className={dim} style={{ top: hole.top, left: 0, width: hole.left, height: hole.height }} /> <Sparkles className="h-4 w-4 shrink-0 text-accent" />
<div <div className="min-w-0 flex-1">
className={dim} <h2 className="truncate text-sm font-semibold text-fg">{step.title}</h2>
style={{ top: hole.top, left: rightOfLeft, width: `calc(100vw - ${rightOfLeft}px)`, height: hole.height }} <p className="text-[11px] text-muted">
/> Schritt {index + 1} von {STEPS.length}
</p>
{/* Pulsierender Rahmen ums Ziel. */}
<div
className="tour-ring pointer-events-none fixed z-[46] rounded-xl"
style={{ top: hole.top, left: hole.left, width: hole.width, height: hole.height }}
/>
{/* Karte -- springt auf die dem Ziel gegenüberliegende Bildschirmhälfte. */}
<div
key={index}
className={`ui-pop fixed left-1/2 z-[50] w-[32rem] max-w-[calc(100vw-2rem)] -translate-x-1/2 rounded-2xl border-2 border-accent bg-surface p-5 shadow-2xl ${
cardAtBottom ? "bottom-8" : "top-8"
}`}
>
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2.5">
<span className="flex h-9 w-9 items-center justify-center rounded-xl bg-accent text-accent-fg shadow-sm">
<Lightbulb className="h-5 w-5" />
</span>
<div>
<div className="text-base font-semibold text-fg">{step.title}</div>
<div className="text-[11px] font-medium uppercase tracking-wide text-accent">
Schritt {index + 1} von {steps.length}
</div>
</div>
</div> </div>
<button <button
type="button" type="button"
onClick={finish} onClick={finish}
aria-label="Tour beenden" aria-label="Tour schliessen"
className="rounded-md p-1 text-faint hover:bg-surface-2 hover:text-fg" className="rounded-lg p-1.5 text-faint hover:bg-surface-2 hover:text-fg"
> >
<X className="h-4 w-4" /> <X className="h-4 w-4" />
</button> </button>
</header>
{/* Die Attrappe. Rein illustrativ -- nichts darin ist klickbar. */}
<div className="min-h-0 flex-1 overflow-auto bg-surface-2/40 p-4">
<MockScreen focus={step.focus} />
</div> </div>
<p className="mt-3 text-sm leading-relaxed text-muted">{step.text}</p>
<div className="mt-4 flex items-center justify-between gap-2"> <footer className="border-t border-border px-4 py-3">
<button type="button" onClick={finish} className="text-xs font-medium text-faint hover:text-muted"> <p className="mb-3 text-sm leading-relaxed text-fg">{step.text}</p>
Überspringen <div className="flex items-center gap-2">
</button> <div className="flex flex-1 gap-1">
<div className="flex items-center gap-3"> {STEPS.map((_, i) => (
<div className="flex gap-1"> <div
{steps.map((_, i) => ( key={i}
<span key={i} className={`h-1.5 w-1.5 rounded-full ${i === index ? "bg-accent" : "bg-border-strong"}`} /> className={`h-1 flex-1 rounded-full transition-colors ${i <= index ? "bg-accent" : "bg-surface-2"}`}
/>
))} ))}
</div> </div>
<div className="flex gap-2"> <Button variant="ghost" size="sm" disabled={index === 0} onClick={() => setIndex((i) => i - 1)}>
{index > 0 && ( <ArrowLeft className="h-4 w-4" /> Zurück
<Button variant="secondary" size="sm" onClick={() => setIndex(index - 1)}>
Zurück
</Button> </Button>
)} {last ? (
{index < steps.length - 1 ? ( <Button size="sm" onClick={finish}>
<Button size="sm" onClick={() => setIndex(index + 1)}> <Sparkles className="h-4 w-4" /> Mit dem Assistenten starten
Weiter
</Button> </Button>
) : ( ) : (
<Button size="sm" onClick={finish}> <Button size="sm" onClick={() => setIndex((i) => i + 1)}>
Fertig Weiter <ArrowRight className="h-4 w-4" />
</Button> </Button>
)} )}
</div> </div>
</footer>
</div> </div>
</div> </div>
</div> );
</> }
// --- Die Attrappe ---------------------------------------------------------------------------
//
// Feste, erfundene Zahlen. Sie müssen weder stimmen noch zueinander passen -- ihr einziger
// Zweck ist, den Aufbau greifbar zu machen.
function MockScreen({ focus }: { focus: Focus }) {
const dim = (area: Focus) => (focus === "none" || focus === area ? "opacity-100" : "opacity-20");
const ring = (area: Focus) => (focus === area ? "ring-2 ring-accent" : "");
return (
<div className="flex gap-3 text-[10px]">
{/* Seitenleiste */}
<div
className={`hidden w-36 shrink-0 flex-col gap-1 rounded-lg border border-border bg-surface p-2 transition-opacity sm:flex ${dim("sidebar")} ${ring("sidebar")}`}
>
<div className="mb-1 font-semibold uppercase tracking-wide text-faint">Meine Pläne</div>
<div className="rounded bg-accent-soft px-1.5 py-1 font-medium text-accent-soft-fg">Familie Muster</div>
<div className="pl-2 text-muted">Szenarien</div>
<div className="rounded bg-surface-2 px-1.5 py-0.5 pl-3 text-fg">Basis</div>
<div className="px-1.5 py-0.5 pl-3 text-muted">Pension mit 62</div>
<div className="pl-2 text-muted">Effektive Werte</div>
<div className="pl-2 text-muted">Analysen</div>
<div className="pl-2 text-muted">Berichte</div>
</div>
<div className="flex min-w-0 flex-1 flex-col gap-3">
{/* Oberer Bereich */}
<div className="rounded-lg border border-border bg-surface-2/60 p-2">
<div className="grid grid-cols-2 gap-2 lg:grid-cols-4">
<MockTile title="Grundeinstellungen" cls={`${dim("settings")} ${ring("settings")}`}>
<div>Anna · 42 J.</div>
<div>Beat · 45 J.</div>
<div className="text-faint">Start 2026 · Inflation 1,5 %</div>
<div className="mt-1 border-t border-border pt-1">Horizont 40 Jahre · bis 2065</div>
<div className="flex items-center gap-1">
Pension 65 · Ende 82
<span className="ml-auto rounded border border-border px-1 text-[9px]">Pensionsplanung</span>
</div>
</MockTile>
<MockTile title="Kennzahlen" cls={`${dim("figures")} ${ring("figures")}`}>
<MockFigure label="Vermögen heute" value="412'000" />
<MockFigure label="Endvermögen nominal" value="1'284'000" />
<MockFigure label="Endvermögen real" value="702'000" />
<div className="mt-1 rounded bg-success-soft px-1.5 py-0.5 text-success">
Kapital reicht über die ganze Planung
</div>
</MockTile>
<MockTile title="Schnellaktionen" cls={`${dim("actions")} ${ring("actions")}`}>
{["Neues Szenario", "Tour", "Änderungshistorie", "Rechenwege", "CSV-Export"].map((a) => (
<div key={a} className="rounded border border-border px-1.5 py-0.5 text-muted">
{a}
</div>
))}
</MockTile>
<div
className={`flex flex-col gap-1 rounded-lg border border-attention bg-attention-soft px-2 py-1.5 transition-opacity ${dim("assistant")} ${ring("assistant")}`}
>
<div className="flex items-center gap-1">
<Sparkles className="h-3 w-3 text-attention-fg" />
<span className="font-semibold uppercase tracking-wide text-fg">FPT-Assistent</span>
<span className="ml-auto font-semibold text-muted">2 / 7</span>
</div>
{[
["3. Lebensphasen", "4 Lebensphasen"],
["4. Erwerbsjahre planen", ""],
["5. Pensionierung planen", ""],
].map(([t, sub]) => (
<div key={t} className="flex items-start gap-1">
<span className="mt-0.5 h-2.5 w-2.5 shrink-0 rounded-sm border border-border bg-surface" />
<span className="min-w-0">
<span className="block truncate font-semibold text-fg">{t}</span>
{sub && <span className="block truncate text-muted">{sub}</span>}
</span>
</div>
))}
{["1. Bestandsaufnahme", "2. Eckdaten"].map((t) => (
<div key={t} className="flex items-start gap-1 opacity-50">
<span className="mt-0.5 flex h-2.5 w-2.5 shrink-0 items-center justify-center rounded-sm bg-success text-[7px] text-white">
</span>
<span className="truncate text-muted line-through">{t}</span>
</div>
))}
</div>
</div>
{/* Zeitachse */}
<div
className={`mt-2 rounded-lg border border-border bg-surface p-2 transition-opacity ${dim("timeline")} ${ring("timeline")}`}
>
<div className="mb-1 flex justify-between text-faint">
<span>2026</span>
<span>2065</span>
</div>
<div className="flex h-4 overflow-hidden rounded">
<div className="flex w-[45%] items-center justify-center bg-accent/70 text-[8px] text-white">
Erwerbsjahre
</div>
<div className="flex w-[15%] items-center justify-center bg-attention/70 text-[8px] text-white">
Übergangszeit
</div>
<div className="flex w-[40%] items-center justify-center bg-success/60 text-[8px] text-white">
Ruhestand
</div>
</div>
<div className="mt-1 flex justify-between text-faint">
<span>Anna 42 · Beat 45</span>
<span>Pension 65</span>
<span>Anna 82 · Beat 85</span>
</div>
</div>
</div>
{/* Matrix */}
<div
className={`overflow-hidden rounded-lg border border-border bg-surface transition-opacity ${dim("matrix")} ${ring("matrix")}`}
>
<table className="w-full border-collapse">
<thead>
<tr className="bg-surface-2">
<th className="border-b border-r border-border px-2 py-1.5 text-left align-top">
<div className="font-semibold uppercase tracking-wide text-faint">Matrix</div>
<div className="mt-1 flex gap-1">
<span className="rounded border border-dashed border-border px-1 text-[9px] font-normal text-muted">
+ Element
</span>
<span className="rounded border border-dashed border-border px-1 text-[9px] font-normal text-muted">
+ Phase
</span>
</div>
</th>
{["Erwerbsjahre", "Übergang", "Ruhestand I", "Übergang", "Ruhestand II"].map((h, i) => (
<th
key={i}
className={`border-b border-r border-border px-2 py-1.5 text-center font-medium ${
h === "Übergang" ? "bg-attention text-attention-fg" : "text-fg"
}`}
>
{h}
{h === "Übergang" && <div className="text-[8px] font-normal">2 offen</div>}
</th>
))}
</tr>
</thead>
<tbody>
{[
["Lohn Anna", "96'000", "→", "—", "→", "—"],
["Lebenshaltung", "78'000", "→", "72'000", "→", "68'000"],
["Pensionskasse Anna", "310'000", "40 % Kapital", "Rente 18'400", "→", "18'400"],
["Säule 3a Anna", "84'000", "Bezug mit 64", "—", "→", "—"],
["Wertschriften", "120'000", "→", "486'000", "→", "310'000"],
].map((row, i) => (
<tr key={i} className={i % 2 ? "bg-surface-2/40" : ""}>
<td className="border-b border-r border-border px-2 py-1 text-fg">{row[0]}</td>
{row.slice(1).map((c, j) => (
<td
key={j}
className={`border-b border-r border-border px-2 py-1 text-center tabular-nums ${
j % 2 === 1 ? "bg-attention-soft text-attention-soft-fg" : "text-muted"
}`}
>
{c}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
);
}
function MockTile({ title, cls, children }: { title: string; cls: string; children: React.ReactNode }) {
return (
<div className={`flex flex-col gap-0.5 rounded-lg border border-border bg-surface px-2 py-1.5 transition-opacity ${cls}`}>
<div className="mb-0.5 font-semibold uppercase tracking-wide text-faint">{title}</div>
{children}
</div>
);
}
function MockFigure({ label, value }: { label: string; value: string }) {
return (
<div className="flex justify-between gap-1">
<span className="truncate text-muted">{label}</span>
<span className="shrink-0 font-semibold tabular-nums text-fg">{value}</span>
</div>
); );
} }
+71
View File
@@ -0,0 +1,71 @@
// «Ein Wert, ein Ort» -- durchgesetzt im Rechenkern, nicht nur in der Oberfläche
// (SPEZIFIKATION 3.14.3).
//
// Der Bestand bei Planbeginn gehört in die Stammdaten. In der ersten Phase stand dafür bis
// 0.39 zusätzlich ein Eingabefeld; eine dort gespeicherte Zahl überschattete die Stammdaten
// still. Seit 0.40 ist das Feld read-only -- womit so ein Altwert unerreichbar WÄRE, wenn er
// weiterhin gewönne. Deshalb entscheidet die Regel der Rechenkern.
import { describe, it, expect } from "vitest";
import { computePlan, firstPhaseValues } from "@/lib/calculations";
import type { PlanInput } from "@/lib/types";
function plan(elements: PlanInput["elements"]): PlanInput {
return {
id: "plan",
name: "T",
householdType: "SINGLE",
inflationRateDefault: 0,
initialCash: 0,
startYear: 2026,
persons: [{ id: "A", role: "PERSON_A", name: null, age: 40, retirementAge: 65 }],
phases: [{ id: "p1", sequenceNumber: 1, name: "P1", durationYears: 10, cashTransition: { mode: "NONE" } }],
elements,
};
}
function haus(phaseValues: Record<string, Record<string, unknown>>) {
return [
{
id: "e1",
category: "REAL_ESTATE" as never,
name: "Haus",
ownerRole: "HOUSEHOLD" as never,
orderIndex: 1,
baseData: { purchasePrice: 800000, mortgage: 500000 } as Record<string, number>,
phaseValues: phaseValues as never,
transitionValues: {},
},
];
}
describe("Bestandsfelder in der ersten Phase", () => {
it("nimmt den Bestand aus den Stammdaten", () => {
const c = computePlan(plan(haus({})));
expect(c.phases[0].elements[0].startValue).toBe(300000);
});
it("lässt sich von einem Phasenwert NICHT überschatten", () => {
// Genau der Fall aus der Testrunde: In 0.39 schrieb das Speichern der Zelle eine 0 --
// seither stand das Haus mit Startwert 0 da, ohne dass man es noch korrigieren konnte.
const c = computePlan(plan(haus({ p1: { confirmed: true, purchasePrice: 0, mortgage: 0 } })));
expect(c.phases[0].elements[0].startValue).toBe(300000);
});
it("fällt auf den Phasenwert zurück, solange die Stammdaten leer sind", () => {
// Ohne diesen Rückfall wären Pläne aus der Zeit vor den Stammdaten schlagartig auf 0.
const els = haus({ p1: { purchasePrice: 600000, mortgage: 400000 } });
els[0].baseData = {};
expect(computePlan(plan(els)).phases[0].elements[0].startValue).toBe(200000);
});
it("lässt die ANNAHMEN in der ersten Phase überschreibbar", () => {
// Eine Rendite gilt für einen Zeitraum, kein Anfangsbestand -- sie darf je Phase abweichen.
const merged = firstPhaseValues(
{ startValue: 100000, expectedReturn: 3 },
{ expectedReturn: 7, startValue: 0 }
);
expect(merged.expectedReturn).toBe(7);
expect(merged.startValue).toBe(100000);
});
});
+32 -12
View File
@@ -1,6 +1,7 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { computePlan } from "@/lib/calculations"; import { computePlan } from "@/lib/calculations";
import type { CashTransitionData, ElementCategory, PhaseData, TransitionData } from "@/lib/elements"; import type { CashTransitionData, ElementCategory, PhaseData, TransitionData } from "@/lib/elements";
import type { RetirementDecision } from "@/lib/retirement-decision";
import type { PlanInput } from "@/lib/types"; import type { PlanInput } from "@/lib/types";
// Die zentrale Invariante beider Wasserfall-Brücken: `residual` ist die Differenz zwischen // Die zentrale Invariante beider Wasserfall-Brücken: `residual` ist die Differenz zwischen
@@ -16,9 +17,20 @@ function el(
category: ElementCategory, category: ElementCategory,
ownerRole: string | null, ownerRole: string | null,
phaseValues: Record<string, PhaseData>, phaseValues: Record<string, PhaseData>,
transitionValues: Record<string, TransitionData> = {} transitionValues: Record<string, TransitionData> = {},
// Pensionierungs-Entscheid: haengt seit 0.34 am Element, nicht an einer Phasengrenze.
retirementDecision: RetirementDecision = {}
) { ) {
return { id: nid(), category, name: category, ownerRole: ownerRole as never, orderIndex: idc, phaseValues, transitionValues }; return {
id: nid(),
category,
name: category,
ownerRole: ownerRole as never,
orderIndex: idc,
phaseValues,
transitionValues,
retirementDecision,
};
} }
function plan(opts: { function plan(opts: {
@@ -84,13 +96,15 @@ const konstellationen: { name: string; build: () => PlanInput }[] = [
"PENSION_FUND", "PENSION_FUND",
"PERSON_A", "PERSON_A",
{ p1: { currentValue: 600000, annualContribution: 20000, expectedReturn: 2 } }, { p1: { currentValue: 600000, annualContribution: 20000, expectedReturn: 2 } },
{ p1: { payoutMode: "PENSION", conversionRate: 6 } } {},
{ capitalSharePct: 0, conversionRate: 6 }
), ),
el( el(
"PILLAR_3A", "PILLAR_3A",
"PERSON_A", "PERSON_A",
{ p1: { currentValue: 120000, annualContribution: 7000, expectedReturn: 3 } }, { p1: { currentValue: 120000, annualContribution: 7000, expectedReturn: 3 } },
{ p1: { capitalTaxRate: 8 } } {},
{ capitalTaxRate: 8 }
), ),
el("OTHER_ASSET", "HOUSEHOLD", { el("OTHER_ASSET", "HOUSEHOLD", {
p1: { startValue: 300000, expectedReturn: 4 }, p1: { startValue: 300000, expectedReturn: 4 },
@@ -116,7 +130,8 @@ const konstellationen: { name: string; build: () => PlanInput }[] = [
"PENSION_FUND", "PENSION_FUND",
"PERSON_A", "PERSON_A",
{ p1: { currentValue: 500000, annualContribution: 18000, expectedReturn: 2 } }, { p1: { currentValue: 500000, annualContribution: 18000, expectedReturn: 2 } },
{ p1: { payoutMode: "CAPITAL", capitalTaxRate: 8 } } {},
{ capitalSharePct: 100, capitalTaxRate: 8 }
), ),
], ],
}), }),
@@ -240,7 +255,8 @@ const konstellationen: { name: string; build: () => PlanInput }[] = [
"PENSION_FUND", "PENSION_FUND",
"PERSON_A", "PERSON_A",
{ p1: { currentValue: 600000, annualContribution: 20000, expectedReturn: 2 }, p2: {} }, { p1: { currentValue: 600000, annualContribution: 20000, expectedReturn: 2 }, p2: {} },
{ p1: { payoutMode: "CAPITAL", capitalTaxRate: 5, capitalUseAmortizationPct: 20, capitalUseInvestPct: 70 } } {},
{ capitalSharePct: 100, capitalTaxRate: 5, capitalUseAmortizationPct: 20, capitalUseInvestPct: 70 }
), ),
el( el(
"REAL_ESTATE", "REAL_ESTATE",
@@ -317,7 +333,7 @@ describe("Kapitalverwendung am Pensions-Uebergang (Roadmap Nr. 44, Punkt C)", ()
...raw, ...raw,
elements: raw.elements.map((e) => elements: raw.elements.map((e) =>
e.category === "PENSION_FUND" e.category === "PENSION_FUND"
? { ...e, transitionValues: { p1: { payoutMode: "CAPITAL", capitalTaxRate: 5 } } } ? { ...e, retirementDecision: { capitalSharePct: 100, capitalTaxRate: 5 } }
: e : e
), ),
} as PlanInput); } as PlanInput);
@@ -372,7 +388,8 @@ describe("Kapitalverwendung: Sichtbarkeit am Ziel-Element (0.33)", () => {
"PENSION_FUND", "PENSION_FUND",
"PERSON_A", "PERSON_A",
{ p1: { currentValue: 400000, expectedReturn: 0 }, p2: {} }, { p1: { currentValue: 400000, expectedReturn: 0 }, p2: {} },
{ p1: { payoutMode: "CAPITAL", capitalTaxRate: 0, capitalUseInvestPct: 100 } } {},
{ capitalSharePct: 100, capitalTaxRate: 0, capitalUseInvestPct: 100 }
), ),
el("OTHER_ASSET", "HOUSEHOLD", { p1: { startValue: 50000, expectedReturn: 0 }, p2: {} }), el("OTHER_ASSET", "HOUSEHOLD", { p1: { startValue: 50000, expectedReturn: 0 }, p2: {} }),
], ],
@@ -414,7 +431,8 @@ describe("Kapitalverwendung: Sichtbarkeit am Ziel-Element (0.33)", () => {
"PILLAR_3A", "PILLAR_3A",
"PERSON_A", "PERSON_A",
{ p1: { currentValue: 100000, expectedReturn: 0 }, p2: {} }, { p1: { currentValue: 100000, expectedReturn: 0 }, p2: {} },
{ p1: { capitalTaxRate: 0, capitalUseInvestPct: 50 } } {},
{ capitalTaxRate: 0, capitalUseInvestPct: 50 }
), ),
], ],
} as PlanInput; } as PlanInput;
@@ -444,13 +462,15 @@ describe("Kapitalverwendung: Herkunft und ruhende Guthaben (0.34)", () => {
"PENSION_FUND", "PENSION_FUND",
"PERSON_A", "PERSON_A",
{ p1: { currentValue: 300000, expectedReturn: 0 }, p2: {} }, { p1: { currentValue: 300000, expectedReturn: 0 }, p2: {} },
{ p1: { payoutMode: "CAPITAL", capitalTaxRate: 0, capitalUseInvestPct: 100 } } {},
{ capitalSharePct: 100, capitalTaxRate: 0, capitalUseInvestPct: 100 }
), ),
el( el(
"PILLAR_3A", "PILLAR_3A",
"PERSON_A", "PERSON_A",
{ p1: { currentValue: 200000, expectedReturn: 0 }, p2: {} }, { p1: { currentValue: 200000, expectedReturn: 0 }, p2: {} },
{ p1: { capitalTaxRate: 0, capitalUseInvestPct: 100 } } {},
{ capitalTaxRate: 0, capitalUseInvestPct: 100 }
), ),
el("OTHER_ASSET", "HOUSEHOLD", { p1: { startValue: 0, expectedReturn: 0 }, p2: {} }), el("OTHER_ASSET", "HOUSEHOLD", { p1: { startValue: 0, expectedReturn: 0 }, p2: {} }),
], ],
@@ -503,6 +523,6 @@ describe("Kapitalverwendung: Herkunft und ruhende Guthaben (0.34)", () => {
: e : e
), ),
}; };
expect(computePlan(mitRate).phases[1].endWealth).toBe(computePlan(base).phases[1].endWealth); expect(computePlan(mitRate).phases[1].endWealthNominal).toBe(computePlan(base).phases[1].endWealthNominal);
}); });
}); });
+20 -9
View File
@@ -1,6 +1,7 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { ahvMonthlyFullPension, computePlan } from "@/lib/calculations"; import { ahvMonthlyFullPension, computePlan } from "@/lib/calculations";
import type { CashTransitionData, ElementCategory, PhaseData, TransitionData } from "@/lib/elements"; import type { CashTransitionData, ElementCategory, PhaseData, TransitionData } from "@/lib/elements";
import type { RetirementDecision } from "@/lib/retirement-decision";
import type { PlanInput } from "@/lib/types"; import type { PlanInput } from "@/lib/types";
// --- kleine Bau-Helfer --- // --- kleine Bau-Helfer ---
@@ -11,9 +12,20 @@ function el(
category: ElementCategory, category: ElementCategory,
ownerRole: string | null, ownerRole: string | null,
phaseValues: Record<string, PhaseData>, phaseValues: Record<string, PhaseData>,
transitionValues: Record<string, TransitionData> = {} transitionValues: Record<string, TransitionData> = {},
// Pensionierungs-Entscheid: hängt seit 0.34 am Element, nicht an einer Phasengrenze.
retirementDecision: RetirementDecision = {}
) { ) {
return { id: nid(), category, name: category, ownerRole: ownerRole as never, orderIndex: idc, phaseValues, transitionValues }; return {
id: nid(),
category,
name: category,
ownerRole: ownerRole as never,
orderIndex: idc,
phaseValues,
transitionValues,
retirementDecision,
};
} }
function plan(opts: { function plan(opts: {
@@ -261,12 +273,11 @@ describe("AHV einkommensabhängig", () => {
"AHV", "AHV",
"PERSON_A", "PERSON_A",
{ p1: { gapYears: opts.gapYearsInPlan ?? 0 }, p2: {} }, { p1: { gapYears: opts.gapYearsInPlan ?? 0 }, p2: {} },
{},
{ {
p1: { confirmed: opts.reviewed ?? true,
reviewed: opts.reviewed ?? true,
avgIncomeBefore: opts.avgIncomeBefore ?? 0, avgIncomeBefore: opts.avgIncomeBefore ?? 0,
gapYearsBefore: opts.gapYearsBefore ?? 0, gapYearsBefore: opts.gapYearsBefore ?? 0,
},
} }
), ),
], ],
@@ -356,13 +367,13 @@ describe("AHV einkommensabhängig", () => {
expect(rente).toBe(Math.round(ahvMonthlyFullPension(80000 * BRUTTO) * 13)); expect(rente).toBe(Math.round(ahvMonthlyFullPension(80000 * BRUTTO) * 13));
}); });
it("bereits bei Planbeginn pensioniert: Karriere kommt aus der Phasenzelle", () => { it("bereits bei Planbeginn pensioniert: Karriere kommt aus dem Pensionierungs-Entscheid", () => {
const p = plan({ const p = plan({
age: 66, age: 66,
retirementAge: 65, retirementAge: 65,
inflation: 0, inflation: 0,
phases: [{ id: "p1", durationYears: 10 }], phases: [{ id: "p1", durationYears: 10 }],
elements: [el("AHV", "PERSON_A", { p1: { avgIncomeBefore: 60000, gapYearsBefore: 0 } })], elements: [el("AHV", "PERSON_A", { p1: {} }, {}, { avgIncomeBefore: 60000, gapYearsBefore: 0 })],
}); });
const rente = computePlan(p).phases[0].elements.find((e) => e.category === "AHV")!.startValue; const rente = computePlan(p).phases[0].elements.find((e) => e.category === "AHV")!.startValue;
expect(rente).toBe(Math.round(ahvMonthlyFullPension(60000) * 13)); expect(rente).toBe(Math.round(ahvMonthlyFullPension(60000) * 13));
@@ -377,8 +388,8 @@ describe("AHV einkommensabhängig", () => {
], ],
phases: [{ id: "p1", sequenceNumber: 1, name: "p1", durationYears: 5, cashTransition: {} }], phases: [{ id: "p1", sequenceNumber: 1, name: "p1", durationYears: 5, cashTransition: {} }],
elements: [ elements: [
el("AHV", "PERSON_A", { p1: { avgIncomeBefore: 100000 } }), el("AHV", "PERSON_A", { p1: {} }, {}, { avgIncomeBefore: 100000 }),
el("AHV", "PERSON_B", { p1: { avgIncomeBefore: 100000 } }), el("AHV", "PERSON_B", { p1: {} }, {}, { avgIncomeBefore: 100000 }),
], ],
}; };
const ph = computePlan(p).phases[0]; const ph = computePlan(p).phases[0];
+365 -110
View File
@@ -10,11 +10,24 @@ import {
DEFAULT_CAPITAL_TAX_RATE, DEFAULT_CAPITAL_TAX_RATE,
DEFAULT_PK_CONVERSION_RATE, DEFAULT_PK_CONVERSION_RATE,
DEFAULT_PROPERTY_GAINS_TAX_RATE, DEFAULT_PROPERTY_GAINS_TAX_RATE,
PILLAR_3A_MAX_WITHDRAWAL_AGE,
PILLAR_3A_MIN_WITHDRAWAL_AGE,
PK_MAX_RETIREMENT_AGE,
PK_MIN_RETIREMENT_AGE,
} from "@/lib/constants"; } from "@/lib/constants";
import { num } from "@/lib/elements"; import { num } from "@/lib/elements";
import { actualsForYear, rebaseFlow, type ResolvedActuals } from "@/lib/actuals"; import { actualsForYear, rebaseFlow, type ResolvedActuals } from "@/lib/actuals";
import type { ElementCategory, TransitionData } from "@/lib/elements"; import { planFixpoints, type Fixpoint, type FixpointPerson } from "@/lib/phaseplan";
import type { PersonRole, PlanInput } from "@/lib/types"; import {
ahvDrawLabel,
ahvFactor,
ahvShiftMonths,
ahvStartAge,
withRetirementDefaults,
type RetirementDecision,
} from "@/lib/retirement-decision";
import type { ElementCategory, PhaseData } from "@/lib/elements";
import type { ElementInput, PersonRole, PlanInput } from "@/lib/types";
export type PhaseType = "ERWERB" | "PENSION" | "MIXED"; export type PhaseType = "ERWERB" | "PENSION" | "MIXED";
export type ElementStatus = "ACTIVE" | "SOLD" | "SETTLED"; export type ElementStatus = "ACTIVE" | "SOLD" | "SETTLED";
@@ -215,10 +228,37 @@ export interface YearPoint {
cash: number; cash: number;
} }
// Kennzahlen des Pensionierungs-Bildschirms. Bewusst im Rechenkern und nicht im UI: Es sind
// die beiden Zahlen, an denen die ganze Planung haengt -- eine Nebenrechnung in der
// Komponente wuerde im PDF-Bericht anders ausfallen.
export interface RetirementPersonSummary {
personId: string;
role: PersonRole;
retirementAge: number;
planningHorizonAge: number | null;
ahvAnnual: number; // inkl. Kuerzung bei Vorbezug bzw. Zuschlag bei Aufschub
ahvFromAge: number; // individuelles Rentenalter, nicht zwingend das Referenzalter
ahvDraw: string; // lesbare Kurzfassung ("Referenzalter", "Vorbezug 2 J. (-13.6 %)")
pkPensionAnnual: number;
capitalAtRetirement: number; // PK-Kapital + Saeule 3a, NETTO nach Kapitalbezugssteuer
}
export interface RetirementOverview {
// Planjahr, ab dem NIEMAND mehr erwerbstaetig ist. null, solange jemand arbeitet.
firstRetirementYear: number | null;
pensionIncome: number | null;
expenses: number | null;
// Renteneinkommen minus Ausgaben im ersten voll pensionierten Jahr. Negativ = Luecke, die
// aus dem Vermoegen zu decken ist.
gapAnnual: number | null;
perPerson: RetirementPersonSummary[];
}
export interface PlanComputed { export interface PlanComputed {
phases: PhaseComputed[]; phases: PhaseComputed[];
yearly: YearPoint[]; yearly: YearPoint[];
nachlass: number; nachlass: number;
retirement: RetirementOverview;
ruinAge: number | null; // Alter (Person A), in dem das Gesamtvermögen (inkl. Cash) erstmals < 0 fällt ruinAge: number | null; // Alter (Person A), in dem das Gesamtvermögen (inkl. Cash) erstmals < 0 fällt
ahvCareer: AhvCareer[]; // Beitragskarriere je Person (für die AHV-Prüfung am Übergang) ahvCareer: AhvCareer[]; // Beitragskarriere je Person (für die AHV-Prüfung am Übergang)
traces?: Trace[]; // plan-weite Rechenwege: Deflatoren, AHV-Karriere, Ruinalter (nur mit explain) traces?: Trace[]; // plan-weite Rechenwege: Deflatoren, AHV-Karriere, Ruinalter (nur mit explain)
@@ -280,16 +320,49 @@ export function ahvYearsBeforePlan(ageAtPlanStart: number): number {
return Math.max(0, ageAtPlanStart - AHV_CONTRIBUTION_START_AGE); return Math.max(0, ageAtPlanStart - AHV_CONTRIBUTION_START_AGE);
} }
// Maximale Dauer einer neuen Phase bis zum nächsten Pensionsereignis (null = unbegrenzt). // Alle Fixpunkte eines Plans als Personen-Sicht für `phaseplan`. Bündelt an EINER Stelle,
// welche Ereignisse eine Phasengrenze erzwingen -- Erwerbsende plus die drei Bezugsbeginne.
export function fixpointPersonsOf(plan: PlanInput): FixpointPerson[] {
return plan.persons.map((p) => {
const own = (cat: ElementCategory) =>
plan.elements.filter((e) => e.category === cat && e.ownerRole === p.role);
const ahvEl = own("AHV")[0];
const pkEl = own("PENSION_FUND")[0];
const rdAhv = ahvEl ? withRetirementDefaults("AHV", p.retirementAge, ahvEl.retirementDecision) : null;
const rdPk = pkEl ? withRetirementDefaults("PENSION_FUND", p.retirementAge, pkEl.retirementDecision) : null;
return {
role: p.role,
name: p.name,
age: p.age,
retirementAge: p.retirementAge,
ahvStartAge: rdAhv ? Math.round(ahvStartAge(rdAhv)) : undefined,
pkWithdrawalAge: rdPk?.pkWithdrawalAge,
pillar3aAges: own("PILLAR_3A")
.map((e) => withRetirementDefaults("PILLAR_3A", p.retirementAge, e.retirementDecision).withdrawalAge)
.filter((x): x is number => typeof x === "number"),
};
});
}
// Maximale Dauer einer neuen Phase bis zum nächsten FIXPUNKT (null = unbegrenzt).
//
// Bis 0.35 zählte nur das Erwerbsende. Seit auch die Bezugsbeginne von AHV, PK und 3a eigene
// Zeitpunkte haben, muss die Kappung sie mitzählen: Fiele ein Bezug mitten in eine Phase,
// würde er auf die nächste Grenze rutschen -- unter Umständen Jahre später.
export function maxPhaseDuration( export function maxPhaseDuration(
persons: { role: PersonRole; age: number; retirementAge: number }[], persons: { role: PersonRole; age: number; retirementAge: number }[],
yearsBefore: number yearsBefore: number,
extraFixpoints: Fixpoint[] = []
): number | null { ): number | null {
const caps: number[] = []; const caps: number[] = [];
for (const p of persons) { for (const p of persons) {
const startAge = p.age + yearsBefore; const startAge = p.age + yearsBefore;
if (startAge < p.retirementAge) caps.push(p.retirementAge - startAge); if (startAge < p.retirementAge) caps.push(p.retirementAge - startAge);
} }
for (const f of extraFixpoints) {
const d = f.year - yearsBefore;
if (d > 0) caps.push(d);
}
return caps.length > 0 ? Math.min(...caps) : null; return caps.length > 0 ? Math.min(...caps) : null;
} }
@@ -353,6 +426,11 @@ function sourceLabelOf(
return `${e.name} (${who})`; return `${e.name} (${who})`;
} }
// Alter innerhalb eines gesetzlichen Fensters halten.
function clampAge(v: number, lo: number, hi: number): number {
return Math.max(lo, Math.min(hi, Math.round(v)));
}
function personByRole<T extends { role: PersonRole }>(persons: T[], role: string): T | null { function personByRole<T extends { role: PersonRole }>(persons: T[], role: string): T | null {
return persons.find((p) => p.role === role) ?? null; return persons.find((p) => p.role === role) ?? null;
} }
@@ -377,6 +455,32 @@ function st(
): TraceStep { ): TraceStep {
return { label, formula, substituted, result, unit, note }; return { label, formula, substituted, result, unit, note };
} }
// Bestandsfelder: der Stand bei PLANBEGINN. Sie gehoeren ausschliesslich in die Stammdaten
// (SPEZIFIKATION 3.14.3, «Ein Wert, ein Ort») -- ein Startwert ist nicht «phase-1-spezifisch»,
// sondern schlicht der Stand am Anfang.
const BASE_ONLY_KEYS = ["amount", "currentValue", "startValue", "purchasePrice", "mortgage"] as const;
// Die wirksamen Werte der ERSTEN Phase: Stammdaten als Wurzel, Phasenwerte darueber.
//
// Mit einer Ausnahme, und die ist der Grund, warum es diese Funktion gibt: Bei den
// Bestandsfeldern gewinnen die STAMMDATEN, wo sie einen Wert tragen. Ein blosses
// `{...bd, ...raw}` liess einen Phasenwert die Stammdaten ueberschatten -- und weil das Feld
// in Phase 1 seit 0.40 read-only ist, waere so ein Wert danach unerreichbar: Eine in einer
// aelteren Version versehentlich gespeicherte 0 haette den Kaufpreis fuer immer verdeckt.
// Die Regel «ein Wert, ein Ort» steht damit im Rechenkern und nicht nur in der Oberflaeche.
//
// Der Rueckfall auf den Phasenwert bleibt fuer den Fall, dass die Stammdaten (noch) leer sind.
// Die ANNAHMEN (Rendite, Zins, Wertsteigerung, Teuerung) bleiben in Phase 1 unveraendert
// ueberschreibbar -- sie gelten fuer einen Zeitraum, nicht fuer den Anfangsbestand.
export function firstPhaseValues(bd: PhaseData, raw: PhaseData): PhaseData {
const merged: PhaseData = { ...bd, ...raw };
for (const key of BASE_ONLY_KEYS) {
const root = bd[key];
if (typeof root === "number") (merged as Record<string, unknown>)[key] = root;
}
return merged;
}
function pct(v: number): string { function pct(v: number): string {
return `${Math.round(v * 1000) / 1000} %`; return `${Math.round(v * 1000) / 1000} %`;
} }
@@ -405,11 +509,32 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
// AHV-Beitragskarriere je Person: reales Einkommen x Beitragsjahre, und Beitragsjahre. // AHV-Beitragskarriere je Person: reales Einkommen x Beitragsjahre, und Beitragsjahre.
const ahvIncomeAccum = new Map<string, number>(); const ahvIncomeAccum = new Map<string, number>();
const ahvYearsAccum = new Map<string, number>(); const ahvYearsAccum = new Map<string, number>();
// Karriere VOR Planbeginn -- aus der Prüfung am Pensions-Übergang bzw. (für bereits // Karriere VOR Planbeginn und der Bezugs-Entscheid, je Person. Beides steht seit 0.34 im
// Pensionierte) aus der Phasenzelle der ersten Phase. // Pensionierungs-Entscheid des AHV-Elements -- vorher lag es an ZWEI Orten (Übergangszelle
const ahvBeforeByPerson = new Map<string, { avg: number; gap: number }>(); // bzw., für bei Planbeginn bereits Pensionierte, Phasenzelle der ersten Phase), was zwei
// Codepfade für dieselbe Frage bedeutete.
// avg === null heisst NICHT ERFASST -- dann gilt der geplante Durchschnitt als Schaetzung
// (siehe unten). Ein Fallback auf 0 wuerde die Rente still und massiv zu tief rechnen.
const ahvBeforeByPerson = new Map<string, { avg: number | null; gap: number }>();
const ahvDrawByPerson = new Map<string, RetirementDecision>();
for (const e of plan.elements) {
if (e.category !== "AHV" || !e.ownerRole) continue;
const owner = personByRole(persons, e.ownerRole);
if (!owner) continue;
const rd = withRetirementDefaults("AHV", retirementAge.get(owner.id) ?? AHV_REFERENCE_AGE, e.retirementDecision);
ahvDrawByPerson.set(owner.id, rd);
ahvBeforeByPerson.set(owner.id, {
avg: typeof rd.avgIncomeBefore === "number" ? rd.avgIncomeBefore : null,
gap: Math.max(0, Math.round(num(rd.gapYearsBefore))),
});
}
const carries = new Map<string, Carry>(); const carries = new Map<string, Carry>();
for (const e of plan.elements) carries.set(e.id, emptyCarry()); for (const e of plan.elements) carries.set(e.id, emptyCarry());
// Fuer die Pensionierungs-Uebersicht: die zuletzt gerechnete AHV-Rente je Person und das
// netto bezogene Alterskapital (PK + 3a). Beides faellt in der Phasen- bzw. Uebergangs-
// schleife ohnehin an -- gesammelt wird es hier, damit es am Ende zur Verfuegung steht.
const ahvFinalByPerson = new Map<string, number>();
const capitalNetByPerson = new Map<string, number>();
const result: PhaseComputed[] = []; const result: PhaseComputed[] = [];
const yearly: YearPoint[] = []; const yearly: YearPoint[] = [];
@@ -456,7 +581,9 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
const capsFromWorking = personInfos const capsFromWorking = personInfos
.filter((p) => p.working) .filter((p) => p.working)
.map((p) => retirementAge.get(p.personId)! - p.startAge) .map((p) => retirementAge.get(p.personId)! - p.startAge)
.filter((d) => d > 0); .filter((d) => d > 0)
// Dazu jeder noch bevorstehende Bezugsbeginn: Auch er erzwingt eine Phasengrenze.
.concat(planFixpoints(fixpointPersonsOf(plan)).map((f) => f.year - yearsBefore).filter((d) => d > 0));
const maxDurationYears = capsFromWorking.length > 0 ? Math.min(...capsFromWorking) : null; const maxDurationYears = capsFromWorking.length > 0 ? Math.min(...capsFromWorking) : null;
const workingByPerson = new Map(personInfos.map((p) => [p.personId, p.working])); const workingByPerson = new Map(personInfos.map((p) => [p.personId, p.working]));
@@ -471,20 +598,6 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
gapYearsByPerson.set(owner.id, (gapYearsByPerson.get(owner.id) ?? 0) + gy); gapYearsByPerson.set(owner.id, (gapYearsByPerson.get(owner.id) ?? 0) + gy);
} }
// Bereits bei Planbeginn pensioniert: es gibt keinen Pensions-Übergang, an dem die
// Beitragskarriere geprüft werden könnte -- die Werte liegen dann in der Phasenzelle.
for (const e of plan.elements) {
if (e.category !== "AHV" || !e.ownerRole) continue;
const owner = personByRole(persons, e.ownerRole);
if (!owner || workingByPerson.get(owner.id)) continue;
if (ahvBeforeByPerson.has(owner.id)) continue; // aus dem Übergang bereits gesetzt
const pd = e.phaseValues[phase.id] ?? {};
ahvBeforeByPerson.set(owner.id, {
avg: num(pd.avgIncomeBefore),
gap: Math.max(0, Math.round(num(pd.gapYearsBefore))),
});
}
// AHV-Renten: Vollrente zum mdJE, gekürzt um die Ausfalljahre. // AHV-Renten: Vollrente zum mdJE, gekürzt um die Ausfalljahre.
// //
// Seit Roadmap Nr. 44 wird die Rente für JEDE Person mit AHV-Element gerechnet, nicht nur // Seit Roadmap Nr. 44 wird die Rente für JEDE Person mit AHV-Element gerechnet, nicht nur
@@ -496,9 +609,12 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
if (e.category !== "AHV" || !e.ownerRole) continue; if (e.category !== "AHV" || !e.ownerRole) continue;
const owner = personByRole(persons, e.ownerRole); const owner = personByRole(persons, e.ownerRole);
if (!owner) continue; if (!owner) continue;
const before = ahvBeforeByPerson.get(owner.id) ?? { avg: 0, gap: 0 }; const before = ahvBeforeByPerson.get(owner.id) ?? { avg: null, gap: 0 };
const career = buildCareer(owner, ahvIncomeAccum, ahvYearsAccum, gapYearsByPerson); const career = buildCareer(owner, ahvIncomeAccum, ahvYearsAccum, gapYearsByPerson);
const mdJE = ahvMdje(career, before.avg, before.gap); // Ohne erfassten Wert gilt der geplante Durchschnitt als Schaetzung fuer die Jahre vor
// Planbeginn -- exakt der Wert, den das UI vorbelegt. Beide Groessen sind BRUTTO.
const avgBefore = before.avg ?? career.plannedAvgGrossIncome;
const mdJE = ahvMdje(career, avgBefore, before.gap);
const totalGap = (gapYearsByPerson.get(owner.id) ?? 0) + before.gap; const totalGap = (gapYearsByPerson.get(owner.id) ?? 0) + before.gap;
ahvUncapped.set(owner.id, ahvAnnualPension(mdJE, totalGap)); ahvUncapped.set(owner.id, ahvAnnualPension(mdJE, totalGap));
} }
@@ -508,6 +624,13 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
const cap = AHV_MAX_ANNUAL_SINGLE * AHV_COUPLE_CAP_FACTOR; const cap = AHV_MAX_ANNUAL_SINGLE * AHV_COUPLE_CAP_FACTOR;
if (sum > cap && sum > 0) for (const [pid, v] of ahvUncapped) ahvFinal.set(pid, Math.round(v * (cap / sum))); if (sum > cap && sum > 0) for (const [pid, v] of ahvUncapped) ahvFinal.set(pid, Math.round(v * (cap / sum)));
} }
// Vorbezug kürzt, Aufschub erhöht -- und zwar NACH dem Ehepaar-Plafond: Der Plafond gilt
// für die ordentlichen Renten, die individuelle Kürzung setzt darauf auf.
for (const [pid, v] of [...ahvFinal]) {
const f = ahvFactor(ahvDrawByPerson.get(pid) ?? {});
if (f !== 1) ahvFinal.set(pid, Math.round(v * f));
}
for (const [pid, v] of ahvFinal) ahvFinalByPerson.set(pid, v);
// --- Element-Laufzeitzustände aufbauen --- // --- Element-Laufzeitzustände aufbauen ---
const orderedElements = [...plan.elements].sort((a, b) => a.orderIndex - b.orderIndex); const orderedElements = [...plan.elements].sort((a, b) => a.orderIndex - b.orderIndex);
@@ -522,6 +645,10 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
const ahvItems: { const ahvItems: {
ownerId: string; ownerId: string;
ownerStartAge: number; ownerStartAge: number;
// Alter, ab dem die Rente DIESER Person fliesst. Bis 0.33 war das fix das
// Referenzalter; mit Vorbezug/Aufschub ist es individuell. Für das Jahresraster
// gerundet -- die Monatsgenauigkeit steckt im Faktor, nicht im Auszahlungszeitpunkt.
renteFromAge: number;
rente: number; rente: number;
beitrag: number; beitrag: number;
working: boolean; working: boolean;
@@ -560,7 +687,12 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
for (const e of orderedElements) { for (const e of orderedElements) {
const carry = carries.get(e.id)!; const carry = carries.get(e.id)!;
const pd = e.phaseValues[phase.id] ?? {}; // Stammdaten (Bestand bei Planbeginn, Ausgangs-Annahmen) und Phasenwerte. In der ERSTEN
// Phase gilt der Phasenwert, wo einer erfasst ist, sonst die Stammdaten -- damit ist
// Phase 1 nichts Besonderes mehr, sondern erbt schlicht von der Wurzel.
const bd = e.baseData ?? {};
const raw = e.phaseValues[phase.id] ?? {};
const pd: PhaseData = isFirstPhase ? firstPhaseValues(bd, raw) : raw;
const owner = e.ownerRole && e.ownerRole !== "HOUSEHOLD" ? personByRole(persons, e.ownerRole) : null; const owner = e.ownerRole && e.ownerRole !== "HOUSEHOLD" ? personByRole(persons, e.ownerRole) : null;
const ownerWorking = owner ? workingByPerson.get(owner.id) ?? false : anyWorking; const ownerWorking = owner ? workingByPerson.get(owner.id) ?? false : anyWorking;
@@ -570,7 +702,14 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
// verwendete Wert wird mitgeführt, damit die Kette über mehrere Phasen trägt. // verwendete Wert wird mitgeführt, damit die Kette über mehrere Phasen trägt.
const inherited = (key: string, fallback = 0): number => { const inherited = (key: string, fallback = 0): number => {
const own = (pd as Record<string, unknown>)[key]; const own = (pd as Record<string, unknown>)[key];
const v = typeof own === "number" ? own : carry.rates[key] ?? fallback; // Reihenfolge: eigener Wert -> aus der Vorphase geerbt -> Stammdaten -> Fallback.
// Die Stammdaten sind damit die WURZEL der Kette; vor 0.36 hatte Phase 1 nichts, von
// dem sie hätte erben können, und fiel auf 0.
const root = (bd as Record<string, unknown>)[key];
const v =
typeof own === "number"
? own
: carry.rates[key] ?? (typeof root === "number" ? root : fallback);
carry.rates[key] = v; carry.rates[key] = v;
return v; return v;
}; };
@@ -661,29 +800,46 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
if (owner) { if (owner) {
const rente = ahvFinal.get(owner.id) ?? 0; const rente = ahvFinal.get(owner.id) ?? 0;
const beitrag = Math.round(num(pd.ahvContribution)); const beitrag = Math.round(num(pd.ahvContribution));
const rd = ahvDrawByPerson.get(owner.id) ?? {};
// Rentenbeginn individuell (Vorbezug/Aufschub), Beitragspflicht dagegen IMMER bis
// zum Referenzalter: Wer die Rente vorbezieht, ist damit nicht von den Beiträgen
// als Nichterwerbstätige(r) befreit -- die beiden Alter sind zu trennen.
const renteFromAge = Math.round(ahvStartAge(rd));
const ageStart = owner.age + yearsBefore; // Alter im ersten Jahr der Phase const ageStart = owner.age + yearsBefore; // Alter im ersten Jahr der Phase
const ageEnd = ageStart + duration - 1; // Alter im letzten Jahr der Phase const ageEnd = ageStart + duration - 1; // Alter im letzten Jahr der Phase
ahvItems.push({ ownerId: owner.id, ownerStartAge: ageStart, rente, beitrag, working: ownerWorking, ec }); ahvItems.push({
ownerId: owner.id,
ownerStartAge: ageStart,
renteFromAge,
rente,
beitrag,
working: ownerWorking,
ec,
});
const reachesRef = ageEnd >= AHV_REFERENCE_AGE; const reachesRente = ageEnd >= renteFromAge;
const startsRetired = ageStart >= AHV_REFERENCE_AGE; const startsWithRente = ageStart >= renteFromAge;
ec.startValue = startsRetired ? rente : 0; const owesContribution = !ownerWorking && ageStart < AHV_REFERENCE_AGE;
ec.endValue = reachesRef ? rente : 0; ec.startValue = startsWithRente ? rente : 0;
ec.endValue = reachesRente ? rente : 0;
if (startsRetired) { if (startsWithRente) {
ec.summary = `Rente ${fmt(rente)}`; ec.summary = `Rente ${fmt(rente)}`;
} else if (reachesRef && !ownerWorking) { } else if (reachesRente && owesContribution) {
ec.summary = `Beitrag ${fmt(beitrag)} → Rente ${fmt(rente)}`; ec.summary = `Beitrag ${fmt(beitrag)} → Rente ${fmt(rente)}`;
ec.note = `Rente ab Alter ${AHV_REFERENCE_AGE}; bis dahin Beitrag als Nichterwerbstätige(r).`; ec.note = `Rente ab Alter ${renteFromAge}; bis Alter ${AHV_REFERENCE_AGE} Beitrag als Nichterwerbstätige(r).`;
} else if (reachesRef) { } else if (reachesRente) {
ec.summary = `Rente ab ${AHV_REFERENCE_AGE} ${fmt(rente)}`; ec.summary = `Rente ab ${renteFromAge} ${fmt(rente)}`;
} else if (!ownerWorking) { } else if (owesContribution) {
ec.summary = `Beitrag ${fmt(beitrag)}`; ec.summary = `Beitrag ${fmt(beitrag)}`;
ec.note = `Frühpensioniert: beitragspflichtig bis Alter ${AHV_REFERENCE_AGE}.`; ec.note = `Frühpensioniert: beitragspflichtig bis Alter ${AHV_REFERENCE_AGE}.`;
} else { } else {
const gap = Math.max(0, Math.round(num(pd.gapYears))); const gap = Math.max(0, Math.round(num(pd.gapYears)));
ec.summary = gap > 0 ? `${gap} Ausfalljahre` : "Keine Ausfalljahre"; ec.summary = gap > 0 ? `${gap} Ausfalljahre` : "Keine Ausfalljahre";
} }
if (ahvShiftMonths(rd) !== 0) {
ec.note = [ec.note, `AHV: ${ahvDrawLabel(rd)}.`].filter(Boolean).join(" ");
}
} }
break; break;
} }
@@ -847,15 +1003,17 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
interestNominal += re.mortgage * (re.interestRate / 100); interestNominal += re.mortgage * (re.interestRate / 100);
} }
// AHV jahresweise: Die Rente fliesst ab dem Referenzalter -- auch wenn die Person noch // AHV jahresweise: Die Rente fliesst ab dem individuellen Rentenalter -- auch wenn die
// arbeitet. Vorher zahlt eine bereits pensionierte Person Beiträge, die wie eine // Person noch arbeitet. Unabhängig davon zahlt eine nicht (mehr) erwerbstätige Person
// Ausgabe auf die Quote schlagen (Kap. 4.4.6). // bis zum REFERENZALTER Beiträge, die wie eine Ausgabe auf die Quote schlagen
// (Kap. 4.4.6). Beide Bedingungen können gleichzeitig gelten: Wer mit 62 aufhört und
// die Rente ab 63 vorbezieht, bezieht ab 63 und zahlt bis 65.
let ahvIncome = 0; let ahvIncome = 0;
let ahvCost = 0; let ahvCost = 0;
for (const a of ahvItems) { for (const a of ahvItems) {
const ageThisYear = a.ownerStartAge + t - 1; // Alter zu Jahresbeginn const ageThisYear = a.ownerStartAge + t - 1; // Alter zu Jahresbeginn
if (ageThisYear >= AHV_REFERENCE_AGE) ahvIncome += a.rente; if (ageThisYear >= a.renteFromAge) ahvIncome += a.rente;
else if (!a.working) ahvCost += a.beitrag; if (!a.working && ageThisYear < AHV_REFERENCE_AGE) ahvCost += a.beitrag;
} }
incomeFlow += ahvIncome; incomeFlow += ahvIncome;
@@ -1020,8 +1178,9 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
// also als Belastung sichtbar), ab dem Referenzalter die Rente. // also als Belastung sichtbar), ab dem Referenzalter die Rente.
for (const a of ahvItems) { for (const a of ahvItems) {
const ageThisYear = a.ownerStartAge + t - 1; // Alter zu Jahresbeginn const ageThisYear = a.ownerStartAge + t - 1; // Alter zu Jahresbeginn
const value = ageThisYear >= AHV_REFERENCE_AGE ? a.rente : a.working ? 0 : -a.beitrag; const rente = ageThisYear >= a.renteFromAge ? a.rente : 0;
if (a.ec.yearly.length < t) a.ec.yearly.push({ year: yr, age, value }); const beitrag = !a.working && ageThisYear < AHV_REFERENCE_AGE ? a.beitrag : 0;
if (a.ec.yearly.length < t) a.ec.yearly.push({ year: yr, age, value: rente - beitrag });
} }
// Verrentete PK läuft nominal fix durch die Phase. // Verrentete PK läuft nominal fix durch die Phase.
for (const ec of ecById.values()) { for (const ec of ecById.values()) {
@@ -1229,9 +1388,10 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
if (e.category === "AHV") { if (e.category === "AHV") {
const owner = e.ownerRole ? personByRole(persons, e.ownerRole) : null; const owner = e.ownerRole ? personByRole(persons, e.ownerRole) : null;
const gap = owner ? gapYearsByPerson.get(owner.id) ?? 0 : 0; const gap = owner ? gapYearsByPerson.get(owner.id) ?? 0 : 0;
const before = owner ? ahvBeforeByPerson.get(owner.id) ?? { avg: 0, gap: 0 } : { avg: 0, gap: 0 }; const before = owner ? ahvBeforeByPerson.get(owner.id) ?? { avg: null, gap: 0 } : { avg: null, gap: 0 };
const career = owner ? buildCareer(owner, ahvIncomeAccum, ahvYearsAccum, gapYearsByPerson) : null; const career = owner ? buildCareer(owner, ahvIncomeAccum, ahvYearsAccum, gapYearsByPerson) : null;
const mdJE = career ? ahvMdje(career, before.avg, before.gap) : 0; const avgBefore = before.avg ?? career?.plannedAvgGrossIncome ?? 0;
const mdJE = career ? ahvMdje(career, avgBefore, before.gap) : 0;
ec.trace = { ec.trace = {
title: `AHV-Rente «${ec.name}»`, title: `AHV-Rente «${ec.name}»`,
specAnchor: "44-ahv-rente", specAnchor: "44-ahv-rente",
@@ -1456,7 +1616,38 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
let txImmediateRepay = 0; let txImmediateRepay = 0;
// Kapitalbezüge, deren Verwendung je Element geregelt ist (Punkt C). Gesammelt WÄHREND // Kapitalbezüge, deren Verwendung je Element geregelt ist (Punkt C). Gesammelt WÄHREND
// der Übergangs-Schleife, angewendet danach -- die Zielelemente werden erst dort bekannt. // der Übergangs-Schleife, angewendet danach -- die Zielelemente werden erst dort bekannt.
const capitalUses: { net: number; td: TransitionData; sourceName: string }[] = []; const capitalUses: { net: number; td: RetirementDecision; sourceName: string }[] = [];
// Zieht dieses 3a-Konto an DIESER Phasengrenze? Ein Konto lässt sich nur ganz auflösen,
// und innerhalb einer Phase kennt das Modell kein Einzelereignis -- gezogen wird deshalb
// an der ERSTEN Grenze bei oder nach dem Wunschalter. Liegt der Wunsch hinter dem
// Planende, greift die letzte Grenze, damit das Guthaben nicht unbezogen liegen bleibt.
// Zieht dieses Vorsorge-Guthaben an DIESER Phasengrenze? Gilt gleichermassen für die
// Pensionskasse und die Säule 3a: Beide haben seit 0.36 ein eigenes Bezugsalter, und
// innerhalb einer Phase kennt das Modell kein Einzelereignis. Gezogen wird an der ERSTEN
// Grenze bei oder nach dem Wunschalter; liegt der Wunsch hinter dem Planende, greift die
// letzte Grenze, damit kein Guthaben unbezogen liegen bleibt.
const drawsHere = (
el: ElementInput,
ownerPerson: { id: string; age: number } | null,
ageAtBoundary: number
): boolean => {
// Beide Kategorien sind personengebunden -- ohne Besitzer gibt es kein Bezugsalter.
if (!ownerPerson) return false;
const ra = retirementAge.get(ownerPerson.id) ?? AHV_REFERENCE_AGE;
const rd = withRetirementDefaults(el.category, ra, el.retirementDecision);
const wish =
el.category === "PENSION_FUND"
? clampAge(num(rd.pkWithdrawalAge, ra), PK_MIN_RETIREMENT_AGE, PK_MAX_RETIREMENT_AGE)
: clampAge(
num(rd.withdrawalAge, ageAtBoundary),
PILLAR_3A_MIN_WITHDRAWAL_AGE,
PILLAR_3A_MAX_WITHDRAWAL_AGE
);
const prevBoundaryAge = ownerPerson.age + yearsBefore;
if (prevBoundaryAge >= wish) return false; // an einer früheren Grenze bereits gezogen
return ageAtBoundary >= wish || !nextPhase;
};
// Echte Vermögensänderungen an dieser Grenze (für die Brücke der Folgephase). // Echte Vermögensänderungen an dieser Grenze (für die Brücke der Folgephase).
// Verkäufe, Bezüge und Tilgungen sind für sich Umbuchungen -- vermögenswirksam sind // Verkäufe, Bezüge und Tilgungen sind für sich Umbuchungen -- vermögenswirksam sind
// nur die Steuer, die Verrentung (Kapital verlässt die Bilanz) und die Differenz // nur die Steuer, die Verrentung (Kapital verlässt die Bilanz) und die Differenz
@@ -1493,9 +1684,12 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
const ec = ecById.get(e.id)!; const ec = ecById.get(e.id)!;
const td = e.transitionValues[phase.id] ?? {}; const td = e.transitionValues[phase.id] ?? {};
const owner = e.ownerRole && e.ownerRole !== "HOUSEHOLD" ? personByRole(persons, e.ownerRole) : null; const owner = e.ownerRole && e.ownerRole !== "HOUSEHOLD" ? personByRole(persons, e.ownerRole) : null;
const ownerRetiresNext = // Pensionsalter des Besitzers und sein Alter AN dieser Phasengrenze -- Bezugspunkt für
!!owner && !!nextPhase && workingByPerson.get(owner.id) === true && // die Vorgaben des Pensionierungs-Entscheids und für die Bezugsalter von PK und 3a.
retiresInPhase(owner.id, persons, retirementAge, yearsBefore + duration); // Ob ein Guthaben HIER gezogen wird, entscheidet seit 0.36 `drawsHere` anhand des
// jeweiligen Bezugsalters, nicht mehr das Erwerbsende.
const ownerRetirementAge = owner ? retirementAge.get(owner.id) ?? AHV_REFERENCE_AGE : AHV_REFERENCE_AGE;
const ownerAgeAtBoundary = owner ? owner.age + yearsBefore + duration : 0;
// Einkommen/Ausgaben: Basiswert wurde bereits im Element-Setup fortgeschrieben. // Einkommen/Ausgaben: Basiswert wurde bereits im Element-Setup fortgeschrieben.
if (e.category === "INCOME" || e.category === "EXPENSE") { if (e.category === "INCOME" || e.category === "EXPENSE") {
@@ -1503,18 +1697,9 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
continue; continue;
} }
// AHV: beim Pensions-Übergang die geprüften Karriere-Werte vor Planbeginn übernehmen. // AHV: Der Bezugs-Entscheid hängt seit 0.34 am Element, nicht am Übergang -- hier ist
// deshalb nichts mehr zu tun.
if (e.category === "AHV") { if (e.category === "AHV") {
if (ownerRetiresNext && owner) {
const career = buildCareer(owner, ahvIncomeAccum, ahvYearsAccum, gapYearsByPerson);
ahvBeforeByPerson.set(owner.id, {
// Ohne erfassten Wert gilt der geplante Durchschnitt als Schätzung für die Jahre
// vor Planbeginn -- exakt der Wert, den der Prüf-Dialog vorbelegt. Ein Fallback auf
// 0 würde die Rente still und massiv zu tief rechnen. Beide Werte sind BRUTTO.
avg: num(td.avgIncomeBefore, career.plannedAvgGrossIncome),
gap: Math.max(0, Math.round(num(td.gapYearsBefore))),
});
}
carry.hasCarry = true; carry.hasCarry = true;
continue; continue;
} }
@@ -1529,30 +1714,27 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
switch (e.category) { switch (e.category) {
case "PENSION_FUND": { case "PENSION_FUND": {
if (ownerRetiresNext) { if (drawsHere(e, owner, ownerAgeAtBoundary)) {
// EINE Quote statt PENSION/CAPITAL/COMBI plus Frankenbetrag: 0 % = volle Rente,
// 100 % = volles Kapital, alles dazwischen ist die Kombination. Als Quote, weil
// sich das Guthaben mit dem Pensionsalter ändert -- ein fixer Betrag würde beim
// Verschieben des Alters still ein anderes Verhältnis bedeuten.
const value = ec.endValue; const value = ec.endValue;
const mode = td.payoutMode ?? "PENSION"; const rd = withRetirementDefaults("PENSION_FUND", ownerRetirementAge, e.retirementDecision);
if (mode === "CAPITAL") { const sharePct = Math.max(0, Math.min(100, num(rd.capitalSharePct)));
const net = Math.round(value * (1 - num(td.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE) / 100)); const capital = Math.round((value * sharePct) / 100);
txInflow += net; const rest = value - capital;
txTax += value - net; if (capital > 0) {
capitalUses.push({ net, td, sourceName: sourceLabelOf(e, persons) }); const net = Math.round(capital * (1 - num(rd.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE) / 100));
carry.value = 0;
carry.pkPensionAnnual = 0;
} else if (mode === "PENSION") {
carry.pkPensionAnnual = Math.round((value * num(td.conversionRate, DEFAULT_PK_CONVERSION_RATE)) / 100);
txPensionConversion += value;
carry.value = 0;
} else {
const capital = Math.min(value, Math.round(num(td.capitalAmount)));
const net = Math.round(capital * (1 - num(td.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE) / 100));
txInflow += net; txInflow += net;
txTax += capital - net; txTax += capital - net;
capitalUses.push({ net, td, sourceName: sourceLabelOf(e, persons) }); capitalUses.push({ net, td: rd, sourceName: sourceLabelOf(e, persons) });
carry.pkPensionAnnual = Math.round(((value - capital) * num(td.conversionRate, DEFAULT_PK_CONVERSION_RATE)) / 100); if (owner) capitalNetByPerson.set(owner.id, (capitalNetByPerson.get(owner.id) ?? 0) + net);
txPensionConversion += value - capital;
carry.value = 0;
} }
carry.pkPensionAnnual =
rest > 0 ? Math.round((rest * num(rd.conversionRate, DEFAULT_PK_CONVERSION_RATE)) / 100) : 0;
txPensionConversion += rest;
carry.value = 0;
} else { } else {
// Vorbezug (z. B. Wohneigentum/Selbstständigkeit): ebenfalls kapitalbezugssteuerpflichtig. // Vorbezug (z. B. Wohneigentum/Selbstständigkeit): ebenfalls kapitalbezugssteuerpflichtig.
// Das Kapital wird brutto entnommen, netto (nach Steuer) fliesst es ins Cash. // Das Kapital wird brutto entnommen, netto (nach Steuer) fliesst es ins Cash.
@@ -1565,11 +1747,17 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
break; break;
} }
case "PILLAR_3A": { case "PILLAR_3A": {
if (ownerRetiresNext) { // Der Bezug hängt seit 0.34 am gewählten Alter, nicht mehr starr am Pensions-
const net = Math.round(ec.endValue * (1 - num(td.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE) / 100)); // Übergang: Ein 3a-Konto lässt sich nur GANZ auflösen, und alle Bezüge desselben
// Jahres werden steuerlich zusammengezählt -- gestaffelt wird deshalb über Konten
// und Jahre. Gezogen wird an der ersten Phasengrenze bei oder nach dem Wunschalter.
if (drawsHere(e, owner, ownerAgeAtBoundary)) {
const rd = withRetirementDefaults("PILLAR_3A", ownerRetirementAge, e.retirementDecision);
const net = Math.round(ec.endValue * (1 - num(rd.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE) / 100));
txInflow += net; txInflow += net;
txTax += ec.endValue - net; txTax += ec.endValue - net;
capitalUses.push({ net, td, sourceName: sourceLabelOf(e, persons) }); capitalUses.push({ net, td: rd, sourceName: sourceLabelOf(e, persons) });
if (owner) capitalNetByPerson.set(owner.id, (capitalNetByPerson.get(owner.id) ?? 0) + net);
carry.value = 0; carry.value = 0;
} else { } else {
const withdrawal = Math.min(ec.endValue, Math.round(num(td.withdrawal))); const withdrawal = Math.min(ec.endValue, Math.round(num(td.withdrawal)));
@@ -1644,17 +1832,22 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
if (e.category === "PENSION_FUND" || e.category === "PILLAR_3A") { if (e.category === "PENSION_FUND" || e.category === "PILLAR_3A") {
anchor = e.category === "PENSION_FUND" ? "491-pension_fund" : "492-pillar_3a"; anchor = e.category === "PENSION_FUND" ? "491-pension_fund" : "492-pillar_3a";
const mode = ownerRetiresNext ? td.payoutMode ?? "PENSION" : td.withdrawalMode ?? "NONE"; // Der Bezugs-Entscheid liegt seit 0.34 am Element; der ZEITPUNKT hat seit 0.36 ein
title = `Übergang «${ec.name}» ${ownerRetiresNext ? "Pensionierung" : "Vorbezug"}`; // eigenes Alter und muss nicht mehr mit dem Erwerbsende zusammenfallen.
if (ownerRetiresNext && e.category === "PENSION_FUND") { const drawnHere = drawsHere(e, owner, ownerAgeAtBoundary);
steps.push(st("Gewählte Bezugsart", 0, undefined, mode === "CAPITAL" ? "Kapitalbezug" : mode === "COMBI" ? "Kombination" : "Rente", undefined, "")); const rd = withRetirementDefaults(e.category, ownerRetirementAge, e.retirementDecision);
if (mode === "PENSION" || mode === "COMBI") { title = `Übergang «${ec.name}» ${drawnHere ? "Bezug" : "Vorbezug"}`;
steps.push(st("Umwandlungssatz", num(td.conversionRate, DEFAULT_PK_CONVERSION_RATE), undefined, undefined, undefined, "%")); if (drawnHere && e.category === "PENSION_FUND") {
const share = Math.max(0, Math.min(100, num(rd.capitalSharePct)));
steps.push(st("Anteil Kapitalbezug", share, undefined, undefined, "0 % = volle Rente, 100 % = volles Kapital.", "%"));
if (share < 100) {
steps.push(st("Umwandlungssatz", num(rd.conversionRate, DEFAULT_PK_CONVERSION_RATE), undefined, undefined, undefined, "%"));
steps.push(st("Jährliche Rente", carry.pkPensionAnnual, "verrentetes Kapital × Umwandlungssatz", undefined, "Das verrentete Kapital verlässt die Vermögensbilanz und erscheint fortan als Renteneinkommen.")); steps.push(st("Jährliche Rente", carry.pkPensionAnnual, "verrentetes Kapital × Umwandlungssatz", undefined, "Das verrentete Kapital verlässt die Vermögensbilanz und erscheint fortan als Renteneinkommen."));
} }
} }
if (taxHere > 0) { if (taxHere > 0) {
steps.push(st("Kapitalbezugssteuer", num(td.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE), undefined, undefined, "Pauschalsatz die tatsächliche Steuer ist kantonal und progressiv.", "%")); const satz = drawnHere ? num(rd.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE) : num(td.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE);
steps.push(st("Kapitalbezugssteuer", satz, undefined, undefined, "Pauschalsatz die tatsächliche Steuer ist kantonal und progressiv.", "%"));
steps.push(st("Steuerbetrag", -taxHere)); steps.push(st("Steuerbetrag", -taxHere));
} }
if (inflowHere !== 0) steps.push(st("Netto ins Cash", inflowHere, "Bruttobezug Kapitalbezugssteuer")); if (inflowHere !== 0) steps.push(st("Netto ins Cash", inflowHere, "Bruttobezug Kapitalbezugssteuer"));
@@ -1725,16 +1918,33 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
const investPct = Math.max(0, Math.min(100 - amortPct, num(use.td.capitalUseInvestPct))); const investPct = Math.max(0, Math.min(100 - amortPct, num(use.td.capitalUseInvestPct)));
if (use.net <= 0 || (amortPct === 0 && investPct === 0)) continue; if (use.net <= 0 || (amortPct === 0 && investPct === 0)) continue;
// Schuldentilgung. Das Ziel ist seit 0.41 waehlbar und darf eine Immobilie ODER eine
// sonstige Schuld sein: Wer einen Konsumkredit zu 6 % neben einer Hypothek zu 1,5 %
// traegt, tilgt zuerst den Kredit -- vorher floss der Betrag stur in die erstbeste
// Immobilie. Ohne Wahl bleibt es beim bisherigen Verhalten (alle Immobilien der Reihe
// nach), damit bestehende Plaene unveraendert rechnen.
let amortBudget = Math.round((use.net * amortPct) / 100); let amortBudget = Math.round((use.net * amortPct) / 100);
for (const e of orderedElements) { const chosenDebt = orderedElements.find((e) => e.id === use.td.capitalUseDebtTargetElementId);
const debtOrder = chosenDebt ? [chosenDebt, ...orderedElements.filter((e) => e !== chosenDebt)] : orderedElements;
for (const e of debtOrder) {
if (amortBudget <= 0) break; if (amortBudget <= 0) break;
if (e.category !== "REAL_ESTATE") continue;
const c = carries.get(e.id)!; const c = carries.get(e.id)!;
if (c.status !== "ACTIVE" || c.mortgage <= 0) continue; if (c.status !== "ACTIVE") continue;
if (e.category === "REAL_ESTATE") {
if (c.mortgage <= 0) continue;
const pay = Math.min(amortBudget, c.mortgage); const pay = Math.min(amortBudget, c.mortgage);
c.mortgage -= pay; c.mortgage -= pay;
amortBudget -= pay; amortBudget -= pay;
txImmediateRepay += pay; txImmediateRepay += pay;
} else if (e.category === "OTHER_DEBT") {
// Nur das AUSDRUECKLICH gewaehlte Schuld-Element -- sonst wuerden bestehende Plaene,
// die bisher nur Hypotheken kannten, ploetzlich anders rechnen.
if (e !== chosenDebt || c.owed <= 0) continue;
const pay = Math.min(amortBudget, c.owed);
c.owed -= pay;
amortBudget -= pay;
txImmediateRepay += pay;
}
} }
const investBudget = Math.round((use.net * investPct) / 100); const investBudget = Math.round((use.net * investPct) / 100);
@@ -1794,6 +2004,59 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
const nachlass = result.length > 0 ? result[result.length - 1].endWealthNominal : 0; const nachlass = result.length > 0 ? result[result.length - 1].endWealthNominal : 0;
const ahvCareer = persons.map((p) => buildCareer(p, ahvIncomeAccum, ahvYearsAccum, gapYearsByPerson)); const ahvCareer = persons.map((p) => buildCareer(p, ahvIncomeAccum, ahvYearsAccum, gapYearsByPerson));
// --- Pensionierung: die Kennzahlen des gleichnamigen Bildschirms -------------------------
//
// Bewusst HIER und nicht im UI: Rentenlücke und Reichweite sind die beiden Zahlen, an denen
// die ganze Planung hängt. Eine Nebenrechnung in der Komponente hätte dieselbe Driftgefahr
// wie bei den Rechenwegen (Kap. 4.14.3) -- und würde im PDF-Bericht anders ausfallen.
//
// Die Rentenlücke ist KEINE neue Grösse: Sie ist die Verzehrquote im ersten Jahr, in dem
// niemand mehr arbeitet -- Renteneinkommen minus Ausgaben. Genau das rechnet die
// Jahresschleife ohnehin, es fehlte nur der Name dafür.
const retirement = ((): RetirementOverview => {
// Startjahr je Phase (1-basiert) -- PhaseComputed fuehrt nur die Dauer.
const startYearOf = new Map<string, number>();
let acc = 1;
for (const r of result) {
startYearOf.set(r.id, acc);
acc += r.durationYears;
}
const firstRetiredPhase = result.find((p) => p.persons.every((x) => !x.working));
const perPerson = persons.map((p) => {
// Die erste Phase, in der DIESE Person nicht mehr arbeitet -- dort stehen ihre Renten.
const ph = result.find((r) => r.persons.find((x) => x.role === p.role && !x.working));
const own = (cat: ElementCategory) =>
ph?.elements.find((e) => e.category === cat && e.ownerRole === p.role);
const rd = ahvDrawByPerson.get(p.id) ?? {};
return {
personId: p.id,
role: p.role,
retirementAge: retirementAge.get(p.id) ?? AHV_REFERENCE_AGE,
// Alter am Planende -- abgeleitet aus der Summe der Lebensphasen.
planningHorizonAge: totalYears > 0 ? p.age + totalYears : null,
ahvAnnual: Math.round(ahvFinalByPerson.get(p.id) ?? 0),
ahvFromAge: Math.round(ahvStartAge(rd)),
ahvDraw: ahvDrawLabel(rd),
pkPensionAnnual: Math.round(own("PENSION_FUND")?.startValue ?? 0),
// Einmalig verfügbares Alterskapital: netto, nach Kapitalbezugssteuer.
capitalAtRetirement: Math.round(capitalNetByPerson.get(p.id) ?? 0),
};
});
if (!firstRetiredPhase) {
return { firstRetirementYear: null, pensionIncome: null, expenses: null, gapAnnual: null, perPerson };
}
// Erstes Jahr dieser Phase: Einkommen und Ausgaben stehen im Jahrespunkt.
const firstYear = startYearOf.get(firstRetiredPhase.id) ?? 1;
const yp = yearly.find((y) => y.year === firstYear);
return {
firstRetirementYear: firstYear,
pensionIncome: yp ? Math.round(yp.income) : null,
expenses: yp ? Math.round(yp.expenseNominal) : null,
gapAnnual: yp ? Math.round(yp.income - yp.expenseNominal) : null,
perPerson,
};
})();
// --- Plan-weite Rechenwege --- // --- Plan-weite Rechenwege ---
const planTraces: Trace[] = []; const planTraces: Trace[] = [];
if (explain) { if (explain) {
@@ -1810,8 +2073,9 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
}); });
for (const career of ahvCareer) { for (const career of ahvCareer) {
const before = ahvBeforeByPerson.get(career.personId) ?? { avg: 0, gap: 0 }; const before = ahvBeforeByPerson.get(career.personId) ?? { avg: null, gap: 0 };
const mdJE = ahvMdje(career, before.avg, before.gap); const avgBefore = before.avg ?? career.plannedAvgGrossIncome;
const mdJE = ahvMdje(career, avgBefore, before.gap);
planTraces.push({ planTraces.push({
title: `AHV-Beitragskarriere ${career.role === "PERSON_A" ? "Person A" : "Person B"}`, title: `AHV-Beitragskarriere ${career.role === "PERSON_A" ? "Person A" : "Person B"}`,
specAnchor: "442-beitragskarriere-und-mdje", specAnchor: "442-beitragskarriere-und-mdje",
@@ -1820,8 +2084,10 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
st("Durchschnittliches Bruttöinkommen im Plan (real)", Math.round(career.plannedAvgGrossIncome), `Nettolohn × ${AHV_GROSS_FROM_NET_FACTOR}`, undefined, st("Durchschnittliches Bruttöinkommen im Plan (real)", Math.round(career.plannedAvgGrossIncome), `Nettolohn × ${AHV_GROSS_FROM_NET_FACTOR}`, undefined,
"Das Tool erfasst netto, die AHV bemisst sich am Brutto. Der Faktor 1.12 ist ein Karriere-Durchschnitt und keine exakte Einzelfall-Umrechnung."), "Das Tool erfasst netto, die AHV bemisst sich am Brutto. Der Faktor 1.12 ist ein Karriere-Durchschnitt und keine exakte Einzelfall-Umrechnung."),
st("Beitragsjahre vor Planbeginn", Math.max(0, career.yearsBeforePlan - before.gap), "Alter bei Planbeginn 21 Ausfalljahre davor", undefined, undefined, "Jahre"), st("Beitragsjahre vor Planbeginn", Math.max(0, career.yearsBeforePlan - before.gap), "Alter bei Planbeginn 21 Ausfalljahre davor", undefined, undefined, "Jahre"),
st("Durchschnittseinkommen vor Planbeginn (real, brutto)", Math.round(before.avg), undefined, undefined, st("Durchschnittseinkommen vor Planbeginn (real, brutto)", Math.round(avgBefore), undefined, undefined,
before.avg === 0 && career.yearsBeforePlan > 0 ? "Noch nicht erfasst am Pensions-Übergang zu prüfen." : "Aus der AHV-Rentenvorausberechnung übernommen."), before.avg === null && career.yearsBeforePlan > 0
? "Nicht erfasst geschätzt aus dem geplanten Durchschnitt. Im Pensionierungs-Bildschirm zu prüfen."
: "Aus der AHV-Rentenvorausberechnung übernommen."),
st("Massgebendes durchschnittliches Jahreseinkommen", Math.round(mdJE), st("Massgebendes durchschnittliches Jahreseinkommen", Math.round(mdJE),
"(Einkommen davor × Jahre davor + Einkommen im Plan × Jahre im Plan) / Total Jahre", undefined, "(Einkommen davor × Jahre davor + Einkommen im Plan × Jahre im Plan) / Total Jahre", undefined,
"REAL gerechnet: die echte AHV wertet vergangene Einkommen auf UND indexiert die Schwellen beides hebt sich real weitgehend auf."), "REAL gerechnet: die echte AHV wertet vergangene Einkommen auf UND indexiert die Schwellen beides hebt sich real weitgehend auf."),
@@ -1841,7 +2107,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
}); });
} }
return { phases: result, yearly, nachlass, ruinAge, ahvCareer, traces: explain ? planTraces : undefined }; return { phases: result, yearly, nachlass, retirement, ruinAge, ahvCareer, traces: explain ? planTraces : undefined };
} }
// Durchschnittliches REALES Jahreseinkommen über eine Phase. Nominal wächst der Flow mit // Durchschnittliches REALES Jahreseinkommen über eine Phase. Nominal wächst der Flow mit
@@ -1870,15 +2136,4 @@ function buildCareer(
}; };
} }
function retiresInPhase(
personId: string,
persons: { id: string; role: PersonRole; age: number }[],
retirementAge: Map<string, number>,
yearsBeforeNext: number
): boolean {
const p = persons.find((x) => x.id === personId);
if (!p) return false;
return p.age + yearsBeforeNext >= retirementAge.get(personId)!;
}
// Der CSV-Export liegt seit 0.31 in lib/csv.ts (vollstaendige Matrix statt Phasen-Summary). // Der CSV-Export liegt seit 0.31 in lib/csv.ts (vollstaendige Matrix statt Phasen-Summary).
+92
View File
@@ -0,0 +1,92 @@
// Verwendung des bezogenen Alterskapitals: das Tilgungs-Ziel ist wählbar (0.41).
//
// Bis 0.40 floss die Tilgungs-Quote stur in die erstbeste Immobilie. Wer neben einer Hypothek
// zu 1,5 % einen Konsumkredit zu 6 % trägt, will aber zuerst den Kredit los -- und hatte keine
// Möglichkeit, das zu sagen.
import { describe, it, expect } from "vitest";
import { computePlan } from "@/lib/calculations";
import type { PlanInput } from "@/lib/types";
function plan(debtTarget?: string): PlanInput {
return {
id: "plan",
name: "T",
householdType: "SINGLE",
inflationRateDefault: 0,
initialCash: 0,
startYear: 2026,
persons: [{ id: "A", role: "PERSON_A", name: null, age: 64, retirementAge: 65 }],
phases: [
{ id: "p1", sequenceNumber: 1, name: "Erwerb", durationYears: 1, cashTransition: { mode: "NONE" } },
{ id: "p2", sequenceNumber: 2, name: "Pension", durationYears: 5, cashTransition: { mode: "NONE" } },
],
elements: [
{
id: "pk",
category: "PENSION_FUND" as never,
name: "PK",
ownerRole: "PERSON_A" as never,
orderIndex: 1,
baseData: { currentValue: 400000, expectedReturn: 0 },
phaseValues: { p1: {}, p2: {} },
transitionValues: {},
// Volles Kapital, keine Steuer, alles in die Tilgung: 400'000 stehen zur Verfügung.
retirementDecision: {
capitalSharePct: 100,
capitalTaxRate: 0,
conversionRate: 6,
capitalUseAmortizationPct: 100,
...(debtTarget ? { capitalUseDebtTargetElementId: debtTarget } : {}),
} as never,
},
{
id: "haus",
category: "REAL_ESTATE" as never,
name: "Haus",
ownerRole: "HOUSEHOLD" as never,
orderIndex: 2,
baseData: { purchasePrice: 900000, mortgage: 300000, interestRate: 1.5, valueGrowth: 0 },
phaseValues: { p1: {}, p2: {} },
transitionValues: { p1: { decision: "HOLD" as never } },
},
{
id: "kredit",
category: "OTHER_DEBT" as never,
name: "Konsumkredit",
ownerRole: "HOUSEHOLD" as never,
orderIndex: 3,
baseData: { startValue: 100000 },
phaseValues: { p1: {}, p2: {} },
transitionValues: {},
},
],
};
}
const restOf = (p: PlanInput, id: string) => {
const c = computePlan(p);
const ph = c.phases[1];
const ce = ph.elements.find((e) => e.elementId === id)!;
return id === "haus" ? ce.mortgageStart : Math.abs(ce.startValue);
};
describe("Schuldentilgung aus dem Alterskapital", () => {
it("tilgt ohne Wahl weiterhin die Hypothek", () => {
// Bestandsschutz: Pläne aus der Zeit vor der Zielwahl müssen unverändert rechnen.
const p = plan();
expect(restOf(p, "haus")).toBe(0);
expect(restOf(p, "kredit")).toBe(100000);
});
it("tilgt den gewählten Kredit zuerst", () => {
const p = plan("kredit");
expect(restOf(p, "kredit")).toBe(0);
});
it("lässt eine nicht gewählte Schuld unberührt, wenn die Hypothek gewählt ist", () => {
const p = plan("haus");
expect(restOf(p, "haus")).toBe(0);
expect(restOf(p, "kredit")).toBe(100000);
});
});
+55
View File
@@ -23,6 +23,61 @@ export const AHV_REFERENCE_AGE = 65;
// Abgeleitet, damit eine Anpassung von R0 nicht an zwei Stellen nachgezogen werden muss. // Abgeleitet, damit eine Anpassung von R0 nicht an zwei Stellen nachgezogen werden muss.
export const AHV_MAX_ANNUAL_SINGLE = 2 * AHV_MIN_MONTHLY_FULL * AHV_PENSION_MONTHS; export const AHV_MAX_ANNUAL_SINGLE = 2 * AHV_MIN_MONTHLY_FULL * AHV_PENSION_MONTHS;
// --- Flexibler Rentenbezug (AHV 21) --------------------------------------------------------
//
// Vorbezug: fruehestens 3 Jahre vor dem Referenzalter, seit AHV 21 in MONATSschritten.
// Die Kuerzung ist lebenslang und betraegt 6.8 % pro vorbezogenem Jahr; Monate anteilig.
// Die seit 1.1.2025 tieferen, einkommensabhaengigen Saetze fuer Frauen der
// Uebergangsgeneration (Jahrgaenge 1961-1969) sind BEWUSST nicht abgebildet -- sie laufen
// aus, und ihre Nachbildung erforderte eine zweite, jahrgangsabhaengige Rentenformel.
// Quelle: Merkblatt 3.04 "Flexibler Rentenbezug", Stand 1.1.2026.
export const AHV_EARLY_MAX_MONTHS = 36;
export const AHV_EARLY_REDUCTION_PER_YEAR = 6.8;
// Aufschub: 1 bis 5 Jahre. Der Zuschlag ist NICHT linear -- er steigt ueberproportional,
// weil sich die Bezugsdauer verkuerzt. Amtliche Stuetzwerte je volles Jahr; fuer Monate
// dazwischen wird linear interpoliert (die amtliche Tabelle ist monatsgenau, die Abweichung
// liegt im Zehntelprozent-Bereich und damit weit unter der Unschaerfe der Rentenschaetzung).
export const AHV_DEFER_MIN_MONTHS = 12;
export const AHV_DEFER_MAX_MONTHS = 60;
export const AHV_DEFER_BONUS_BY_YEAR = [0, 5.2, 10.8, 17.1, 24.0, 31.5];
// Teilbezug/Teilaufschub: zwischen 20 und 80 Prozent der Rente.
export const AHV_PARTIAL_MIN_PCT = 20;
export const AHV_PARTIAL_MAX_PCT = 80;
// --- Saeule 3a: Bezugsfenster ---------------------------------------------------------------
//
// Fruehestens 5 Jahre vor dem Referenzalter (Art. 3 BVV3). Spaetestens beim Referenzalter --
// aufschiebbar bis 70, solange eine Erwerbstaetigkeit besteht. Ein Konto laesst sich bei der
// Pensionierung nur GANZ aufloesen; gestaffelt wird ueber mehrere Konten.
export const PILLAR_3A_MIN_WITHDRAWAL_AGE = AHV_REFERENCE_AGE - 5;
export const PILLAR_3A_MAX_WITHDRAWAL_AGE = AHV_REFERENCE_AGE + 5;
// --- Pensionskasse: Bezugsfenster -----------------------------------------------------------
//
// Gesetzliches Mindestalter fuer die vorzeitige Pensionierung (Art. 1i BVV2). Viele Reglemente
// setzen erst bei 60 an -- deshalb Hinweis statt Sperre.
export const PK_MIN_RETIREMENT_AGE = 58;
export const PK_MAX_RETIREMENT_AGE = AHV_REFERENCE_AGE + 5;
// Sperrfrist nach einem Einkauf, innerhalb derer ein Kapitalbezug den Steuerabzug
// nachtraeglich entfallen laesst (Art. 79b Abs. 3 BVG). Reines Hinweis-Mass: Das Tool kennt
// keine Einkaeufe und kann die Frist deshalb nicht selbst pruefen.
export const PK_BUYIN_BLOCKING_YEARS = 3;
// --- Planungshorizont -----------------------------------------------------------------------
//
// Bis zu welchem Alter gerechnet wird. Bisher ergab sich das Planende stillschweigend aus der
// Summe der Phasendauern -- zwei Szenarien konnten dadurch unbemerkt verschieden weit rechnen
// und waren nicht vergleichbar. Der Default liegt bewusst ueber der Lebenserwartung: Eine zu
// kurze Planung sieht tragfaehig aus, obwohl das Geld nur nicht lange genug reichen muss.
// Gefuehrt wird die Laufzeit in JAHREN und nicht als Endalter je Person: eine Zahl statt
// zweier, die bei einem Paar auseinanderlaufen koennten. Die Endalter sind die Ableitung.
export const DEFAULT_PLANNING_HORIZON_YEARS = 35;
export const MIN_PLANNING_HORIZON_YEARS = 1;
export const MAX_PLANNING_HORIZON_YEARS = 70;
// Umrechnung Netto- -> Bruttolohn für die AHV. Das Tool erfasst das Einkommen NETTO (so // Umrechnung Netto- -> Bruttolohn für die AHV. Das Tool erfasst das Einkommen NETTO (so
// denkt der Nutzer, und so stimmt der Cash-Fluss), die AHV bemisst sich aber am BRUTTOlohn. // denkt der Nutzer, und so stimmt der Cash-Fluss), die AHV bemisst sich aber am BRUTTOlohn.
// //
+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;
} }
-96
View File
@@ -1,96 +0,0 @@
// Beispielplan mit einem Klick (Teil von Roadmap Nr. 10). Erzeugt einen realistischen,
// vollständig ausgefuellten Plan über die BESTEHENDEN API-Endpunkte -- reine Orchestrierung,
// kein neuer Endpunkt, keine Änderung an der Berechnung.
//
// Didaktischer Entscheid: Die Übergangs-Entscheide bleiben absichtlich OFFEN. Der neue
// Nutzer sieht dadurch die Ampel ("N offen") in Aktion und lernt das wichtigste Konzept des
// Tools am Beispiel, statt es erklärt zu bekommen.
//
// WICHTIG zur Reihenfolge: erst Phase 1, dann die Elemente samt Phase-1-Werten, dann die
// Folgephase. Die Phasen-Route belegt beim Anlegen einer Phase die editierbaren Felder aus
// der Vorphase vor -- existieren die Elemente noch nicht, bleibt die Folgephase leer
// (Renditen/Beiträge wären dort 0).
import { api } from "@/lib/api-client";
import type { PhaseData } from "@/lib/elements";
async function addElement(
scenarioId: string,
firstPhaseId: string,
category: string,
name: string,
ownerRole: string,
values?: PhaseData
): Promise<void> {
const { element } = await api.post<{ element: { id: string } }>(
`/api/scenarios/${scenarioId}/elements`,
{ category, name, ownerRole }
);
if (values && Object.keys(values).length > 0) {
await api.put(`/api/elements/${element.id}/phase/${firstPhaseId}`, values);
}
}
// Liefert die Szenario-Id des angelegten Beispielplans.
export async function createDemoPlan(): Promise<string> {
const currentYear = new Date().getFullYear();
const { scenario } = await api.post<{ plan: { id: string }; scenario: { id: string } }>(
"/api/plans",
{
name: "Beispiel: Alex Muster",
householdType: "SINGLE",
inflationRateDefault: 1.5,
startYear: currentYear,
persons: [{ role: "PERSON_A", name: "Alex", age: 45, retirementAge: 65 }],
}
);
const sid = scenario.id;
await api.patch(`/api/scenarios/${sid}`, { initialCash: 40000 });
// Phase 1: Erwerbsphase bis zur Pension (die Route kappt die Dauer ohnehin am
// Pensionsereignis; 20 Jahre = 45 -> 65).
const p1 = await api.post<{ phase: { id: string } }>(`/api/scenarios/${sid}/phases`, {
name: "Erwerbsphase",
durationYears: 20,
});
const firstPhaseId = p1.phase.id;
await addElement(sid, firstPhaseId, "INCOME", "Lohn Alex", "PERSON_A", {
amount: 110000,
teuerungsausgleich: 1,
});
await addElement(sid, firstPhaseId, "EXPENSE", "Lebenshaltung", "HOUSEHOLD", {
amount: 78000,
teuerungsausgleich: 0,
});
await addElement(sid, firstPhaseId, "AHV", "AHV Alex", "PERSON_A", { gapYears: 0 });
await addElement(sid, firstPhaseId, "PENSION_FUND", "Pensionskasse", "PERSON_A", {
currentValue: 380000,
annualContribution: 14000,
expectedReturn: 1.5,
});
await addElement(sid, firstPhaseId, "PILLAR_3A", "Säule 3a", "PERSON_A", {
currentValue: 75000,
annualContribution: 7000,
expectedReturn: 2,
});
await addElement(sid, firstPhaseId, "OTHER_ASSET", "ETF-Depot", "HOUSEHOLD", {
startValue: 120000,
annualContribution: 12000,
expectedReturn: 5,
});
await addElement(sid, firstPhaseId, "REAL_ESTATE", "Eigenheim", "HOUSEHOLD", {
purchasePrice: 900000,
mortgage: 600000,
amortization: 12000,
interestRate: 1.5,
valueGrowth: 1,
});
// Pensionsphase 65 -> 90. Die Route belegt die editierbaren Felder aus Phase 1 vor.
await api.post(`/api/scenarios/${sid}/phases`, { name: "Pensionsphase", durationYears: 25 });
return sid;
}
+9 -1
View File
@@ -104,7 +104,15 @@ 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 ?? {}) ||
!sameData(el.baseData ?? {}, src.baseData ?? {})
) {
d.elementRow.set(el.id, "changed"); d.elementRow.set(el.id, "changed");
} }
+4
View File
@@ -50,6 +50,9 @@ export const CATEGORY_ORDER: ElementCategory[] = [
// Defaults, das UI zeigt je nach Kontext nur die relevanten Felder. // Defaults, das UI zeigt je nach Kontext nur die relevanten Felder.
export interface PhaseData { export interface PhaseData {
// Hat jemand die Annahmen dieser Zelle einmal angeschaut? Steht NEBEN den Werten und
// kopiert nichts -- eine bestaetigte Phase erbt weiterhin live von der Vorphase.
confirmed?: boolean;
// INCOME / EXPENSE // INCOME / EXPENSE
amount?: number; amount?: number;
// INCOME / EXPENSE: jährlicher Teuerungsausgleich (%). Indexiert den Flow über die // INCOME / EXPENSE: jährlicher Teuerungsausgleich (%). Indexiert den Flow über die
@@ -173,6 +176,7 @@ export const cashTransitionSchema = z
export const phaseDataSchema = z export const phaseDataSchema = z
.object({ .object({
confirmed: z.boolean().optional(),
amount: nonNeg.optional(), amount: nonNeg.optional(),
teuerungsausgleich: z.number().min(-20).max(50).optional(), teuerungsausgleich: z.number().min(-20).max(50).optional(),
gapYears: z.number().int().min(0).optional(), gapYears: z.number().int().min(0).optional(),
+24 -2
View File
@@ -48,6 +48,17 @@ export interface SliderDef {
// anders -- ein Regler vergleicht keine Treiber gegeneinander, er zeigt ein einzelnes // anders -- ein Regler vergleicht keine Treiber gegeneinander, er zeigt ein einzelnes
// absolutes Ergebnis. Ein Standardbereich erfindet also keine Aussage, er macht den Regler // absolutes Ergebnis. Ein Standardbereich erfindet also keine Aussage, er macht den Regler
// überhaupt erst bedienbar. Beide Enden bleiben editierbar. Siehe 9.27. // überhaupt erst bedienbar. Beide Enden bleiben editierbar. Siehe 9.27.
// Der geplante PK-Kapitalanteil. Bei mehreren PK-Elementen der Durchschnitt -- der Regler
// setzt ohnehin einen gemeinsamen Wert, und ein Regler, der beim Oeffnen schon vom Plan
// abweicht, waere irrefuehrend.
function plannedCapitalShare(plan: PlanInput): number {
const shares = plan.elements
.filter((e) => e.category === "PENSION_FUND")
.map((e) => e.retirementDecision?.capitalSharePct ?? 0);
if (shares.length === 0) return 0;
return Math.round(shares.reduce((a, b) => a + b, 0) / shares.length);
}
const RANGES: Record<DriverId, { min: number; max: number; step: number; neutral: number }> = { const RANGES: Record<DriverId, { min: number; max: number; step: number; neutral: number }> = {
// Absolute Werte: der neutrale Punkt ist der Planwert selbst und wird zur Laufzeit gesetzt. // Absolute Werte: der neutrale Punkt ist der Planwert selbst und wird zur Laufzeit gesetzt.
inflation: { min: 0, max: 4, step: 0.1, neutral: 0 }, inflation: { min: 0, max: 4, step: 0.1, neutral: 0 },
@@ -58,6 +69,11 @@ const RANGES: Record<DriverId, { min: number; max: number; step: number; neutral
expenses: { min: -20, max: 30, step: 1, neutral: 0 }, expenses: { min: -20, max: 30, step: 1, neutral: 0 },
income: { min: -30, max: 20, step: 1, neutral: 0 }, income: { min: -30, max: 20, step: 1, neutral: 0 },
lifespan: { min: -5, max: 10, step: 1, neutral: 0 }, lifespan: { min: -5, max: 10, step: 1, neutral: 0 },
// Absolutwert in Prozent: 0 = volle Rente, 100 = volles Kapital. Neutral wird zur Laufzeit
// auf den geplanten Anteil gesetzt.
pkCapitalShare: { min: 0, max: 100, step: 5, neutral: 0 },
// Monate gegenueber dem Referenzalter: negativ = Vorbezug, positiv = Aufschub.
ahvDrawMonths: { min: -36, max: 60, step: 1, neutral: 0 },
// Der wirkliche Spielraum hängt an den Phasendauern und wird in `buildSliders` gesetzt. // Der wirkliche Spielraum hängt an den Phasendauern und wird in `buildSliders` gesetzt.
retirementA: { min: -3, max: 3, step: 1, neutral: 0 }, retirementA: { min: -3, max: 3, step: 1, neutral: 0 },
retirementB: { min: -3, max: 3, step: 1, neutral: 0 }, retirementB: { min: -3, max: 3, step: 1, neutral: 0 },
@@ -92,8 +108,14 @@ export function buildSliders(plan: PlanInput, expandReturns: boolean): SliderDef
min: hard ? Math.max(r.min, hard.min) : r.min, min: hard ? Math.max(r.min, hard.min) : r.min,
max: hard ? Math.min(r.max, hard.max) : r.max, max: hard ? Math.min(r.max, hard.max) : r.max,
step: r.step, step: r.step,
// Die Inflation ist der einzige absolute Treiber: neutral ist der Wert aus dem Plan. // Absolute Treiber: neutral ist der Planwert selbst, sonst stünde der Regler beim
neutral: def.id === "inflation" ? plan.inflationRateDefault : 0, // Öffnen an einer Stelle, die den Plan bereits verändert.
neutral:
def.id === "inflation"
? plan.inflationRateDefault
: def.id === "pkCapitalShare"
? plannedCapitalShare(plan)
: 0,
}); });
} }
if (expandReturns) { if (expandReturns) {
+22 -1
View File
@@ -68,12 +68,33 @@ describe("Datenbank-Migrationen", () => {
expect(scenCols).not.toContain("startYear"); expect(scenCols).not.toContain("startYear");
expect(scenCols).not.toContain("userId"); // Eigentümer hängt am Plan expect(scenCols).not.toContain("userId"); // Eigentümer hängt am Plan
// Person trägt nur noch das Pensionsalter -- Name und Alter beschreiben den Haushalt. // Person trägt nur noch das Pensionsalter -- Name und Alter beschreiben den Haushalt und
// liegen am Plan, der Planungshorizont liegt seit 0.36 als JAHRESZAHL am Szenario.
const personCols = await cols("Person"); const personCols = await cols("Person");
expect(personCols).toContain("retirementAge"); expect(personCols).toContain("retirementAge");
expect(personCols).not.toContain("planningHorizonAge");
expect(personCols).not.toContain("name"); expect(personCols).not.toContain("name");
expect(personCols).not.toContain("age"); expect(personCols).not.toContain("age");
// Der Assistenten-Fortschritt ist mit dem Assistenten entfallen (0.39): Was offen ist,
// wird abgeleitet und nicht gespeichert.
expect(await cols("Scenario")).not.toContain("assistantProgress");
// Die Spar-/Verzehrquote je Phase zaehlt eigens als offener Punkt.
expect(await cols("Phase")).toContain("ratesConfirmed");
// Der Pensionierungs-Entscheid hängt am ELEMENT und bewusst an keiner Phase (0.34) --
// nur so überlebt er eine Verschiebung der Zeitachse. Die Stammdaten (0.36) ebenso: Sie
// beschreiben den Bestand bei Planbeginn und existieren, bevor es Phasen gibt.
expect(await cols("FinancialElement")).toContain("retirementDecision");
expect(await cols("FinancialElement")).toContain("baseData");
// Er muss NULL zulassen: Ohne erfassten Entscheid gelten die Vorgaben.
const rd = await db.query<{ is_nullable: string }>(
`SELECT is_nullable FROM information_schema.columns
WHERE table_name='FinancialElement' AND column_name='retirementDecision'`
);
expect(rd.rows[0]?.is_nullable).toBe("YES");
// Ein Plan ohne Haushaltsform wäre nicht rechenbar. // Ein Plan ohne Haushaltsform wäre nicht rechenbar.
const hh = await db.query<{ is_nullable: string }>( const hh = await db.query<{ is_nullable: string }>(
`SELECT is_nullable FROM information_schema.columns `SELECT is_nullable FROM information_schema.columns
+72 -6
View File
@@ -1,10 +1,15 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { planSegments, defaultOpenDuration, planDurationChange } from "@/lib/phaseplan"; import { planFixpoints, planSegments, defaultOpenDuration, planDurationChange } from "@/lib/phaseplan";
// Seit 0.36 tragen Abschnitte zusaetzlich `endedBy` (Beschriftung). Die Struktur-Tests
// projizieren deshalb auf die drei Kernfelder.
const shape = (segs: ReturnType<typeof planSegments>) =>
segs.map((s) => ({ type: s.type, fromYear: s.fromYear, fixedYears: s.fixedYears }));
describe("planSegments", () => { describe("planSegments", () => {
it("Einzelperson: genau zwei Teile (Erwerb fix, Pension offen)", () => { it("Einzelperson: genau zwei Teile (Erwerb fix, Pension offen)", () => {
const segs = planSegments([{ age: 45, retirementAge: 65 }]); const segs = planSegments([{ age: 45, retirementAge: 65 }]);
expect(segs).toEqual([ expect(shape(segs)).toEqual([
{ type: "ERWERB", fromYear: 0, fixedYears: 20 }, { type: "ERWERB", fromYear: 0, fixedYears: 20 },
{ type: "PENSION", fromYear: 20, fixedYears: null }, { type: "PENSION", fromYear: 20, fixedYears: null },
]); ]);
@@ -25,7 +30,7 @@ describe("planSegments", () => {
{ age: 45, retirementAge: 65 }, // Ret in 20 J. { age: 45, retirementAge: 65 }, // Ret in 20 J.
{ age: 50, retirementAge: 62 }, // Ret in 12 J. { age: 50, retirementAge: 62 }, // Ret in 12 J.
]); ]);
expect(segs).toEqual([ expect(shape(segs)).toEqual([
{ type: "ERWERB", fromYear: 0, fixedYears: 12 }, { type: "ERWERB", fromYear: 0, fixedYears: 12 },
{ type: "MIXED", fromYear: 12, fixedYears: 8 }, { type: "MIXED", fromYear: 12, fixedYears: 8 },
{ type: "PENSION", fromYear: 20, fixedYears: null }, { type: "PENSION", fromYear: 20, fixedYears: null },
@@ -37,7 +42,7 @@ describe("planSegments", () => {
{ age: 67, retirementAge: 65 }, // bereits pensioniert -> Ret in 0 J. { age: 67, retirementAge: 65 }, // bereits pensioniert -> Ret in 0 J.
{ age: 60, retirementAge: 65 }, // Ret in 5 J. { age: 60, retirementAge: 65 }, // Ret in 5 J.
]); ]);
expect(segs).toEqual([ expect(shape(segs)).toEqual([
{ type: "MIXED", fromYear: 0, fixedYears: 5 }, { type: "MIXED", fromYear: 0, fixedYears: 5 },
{ type: "PENSION", fromYear: 5, fixedYears: null }, { type: "PENSION", fromYear: 5, fixedYears: null },
]); ]);
@@ -48,11 +53,11 @@ describe("planSegments", () => {
{ age: 70, retirementAge: 65 }, { age: 70, retirementAge: 65 },
{ age: 68, retirementAge: 64 }, { age: 68, retirementAge: 64 },
]); ]);
expect(segs).toEqual([{ type: "PENSION", fromYear: 0, fixedYears: null }]); expect(shape(segs)).toEqual([{ type: "PENSION", fromYear: 0, fixedYears: null }]);
}); });
it("Einzelperson bereits pensioniert: nur Pension", () => { it("Einzelperson bereits pensioniert: nur Pension", () => {
expect(planSegments([{ age: 66, retirementAge: 65 }])).toEqual([ expect(shape(planSegments([{ age: 66, retirementAge: 65 }]))).toEqual([
{ type: "PENSION", fromYear: 0, fixedYears: null }, { type: "PENSION", fromYear: 0, fixedYears: null },
]); ]);
}); });
@@ -127,3 +132,64 @@ describe("planDurationChange", () => {
expect(after).toBe(before); expect(after).toBe(before);
}); });
}); });
describe("planFixpoints (0.36)", () => {
// Bis 0.35 gab es genau einen Fixpunkt pro Person: das Erwerbsende. Seit die Bezugsbeginne
// von AHV, PK und 3a eigene Zeitpunkte haben, erzwingt jeder davon eine Phasengrenze --
// sonst fiele ein Bezug mitten in eine Phase und rutschte auf die nächste Grenze.
const person = {
role: "PERSON_A",
name: "Anna",
age: 60,
retirementAge: 62,
ahvStartAge: 63,
pkWithdrawalAge: 62,
pillar3aAges: [64, 65],
};
it("sammelt jeden Bezugsbeginn als eigenen Zeitpunkt", () => {
const f = planFixpoints([person]);
// Erwerbsende 62 und PK 62 fallen auf dasselbe Jahr -> zwei Eintraege, EIN Jahr.
expect(f.map((x) => x.year)).toEqual([2, 2, 3, 4, 5]);
expect(new Set(f.map((x) => x.kind))).toEqual(
new Set(["RETIREMENT", "PENSION_FUND", "AHV", "PILLAR_3A"])
);
});
it("erzeugt je distinktem Jahr genau eine Abschnittsgrenze", () => {
const segs = planSegments([person]);
// Grenzen bei 2, 3, 4, 5 -> fuenf Abschnitte (der letzte offen).
expect(segs.map((x) => x.fromYear)).toEqual([0, 2, 3, 4, 5]);
expect(segs[segs.length - 1].fixedYears).toBeNull();
});
it("beschriftet jede Grenze mit den Ereignissen, die sie ausloesen", () => {
const segs = planSegments([person]);
// Der erste Abschnitt endet mit Erwerbsende UND PK-Bezug -- beide im selben Jahr.
expect(segs[0].endedBy).toHaveLength(2);
expect(segs[0].endedBy.every((f) => f.label.includes("Anna"))).toBe(true);
});
it("ignoriert Ereignisse, die bei Planbeginn schon vorbei sind", () => {
const f = planFixpoints([{ role: "PERSON_A", age: 67, retirementAge: 65, ahvStartAge: 65 }]);
expect(f).toHaveLength(0);
});
it("faellt ohne Bezugsalter auf das alte Verhalten zurueck", () => {
const segs = planSegments([{ role: "PERSON_A", age: 45, retirementAge: 65 }]);
expect(shape(segs)).toEqual([
{ type: "ERWERB", fromYear: 0, fixedYears: 20 },
{ type: "PENSION", fromYear: 20, fixedYears: null },
]);
});
});
describe("defaultOpenDuration mit Planungshorizont", () => {
it("leitet die Dauer aus dem Horizont ab, wenn er erfasst ist", () => {
expect(defaultOpenDuration([{ age: 45 }], 20, 35)).toBe(15);
});
it("faellt ohne Horizont auf die Schaetzung bis Alter 90 zurueck", () => {
expect(defaultOpenDuration([{ age: 45 }], 20)).toBe(25);
});
});
+88 -14
View File
@@ -11,40 +11,114 @@
export type SegmentType = "ERWERB" | "MIXED" | "PENSION"; export type SegmentType = "ERWERB" | "MIXED" | "PENSION";
// Ein Zeitpunkt, an dem ZWINGEND eine neue Lebensphase beginnen muss.
//
// Bis 0.35 gab es genau einen pro Person: das Erwerbsende. Seit 0.36 sind es bis zu vier --
// jeder Beginn eines Renten- oder Kapitalbezugs ist einer. Der Grund ist derselbe wie beim
// Erwerbsende: Die Rechnung leitet Erwerbsstatus und Bezuege am PHASENBEGINN ab. Faellt ein
// Bezug mitten in eine Phase, waere der halbe Phasenertrag falsch.
export type FixpointKind = "RETIREMENT" | "AHV" | "PENSION_FUND" | "PILLAR_3A";
export interface Fixpoint {
// Jahre ab Planbeginn (1-basiert wie die Phasengrenzen: Jahr 5 = Ende des 5. Planjahres).
year: number;
age: number;
role: string;
kind: FixpointKind;
label: string;
}
export interface PlanSegment { export interface PlanSegment {
type: SegmentType; type: SegmentType;
fromYear: number; // Jahre ab Planbeginn (Beginn des Abschnitts) fromYear: number; // Jahre ab Planbeginn (Beginn des Abschnitts)
// Feste Länge in Jahren (durch den nächsten Pensionierungspunkt bestimmt) ODER null für den // Feste Länge in Jahren (durch den nächsten Fixpunkt bestimmt) ODER null für den letzten,
// letzten, OFFENEN Abschnitt (Pension bis Lebensende -- Dauer frei wählbar). // OFFENEN Abschnitt (Dauer frei wählbar bzw. durch den Planungshorizont bestimmt).
fixedYears: number | null; fixedYears: number | null;
// Welche Ereignisse den Abschnitt beenden -- für die Beschriftung der Zeitachse.
endedBy: Fixpoint[];
} }
// Ein Person-Erwerbsstatus wechselt genau einmal: im Jahr `retirementAge - age` (auf 0 gekappt, export interface FixpointPerson {
// falls bei Planbeginn bereits pensioniert). Die distinkten dieser Zeitpunkte sind die // Optional, damit Aufrufer, die nur Abschnitte brauchen (Zeitachse), nichts erfinden muessen.
// Abschnittsgrenzen; der Abschnitt nach dem letzten Zeitpunkt ist offen. role?: string;
export function planSegments(persons: { age: number; retirementAge: number }[]): PlanSegment[] { name?: string | null;
if (persons.length === 0) return [{ type: "PENSION", fromYear: 0, fixedYears: null }]; age: number;
retirementAge: number;
// Bezugsalter aus dem Pensionierungs-Entscheid. Fehlen sie, gilt das Erwerbsende.
ahvStartAge?: number;
pkWithdrawalAge?: number;
pillar3aAges?: number[];
}
const KIND_LABEL: Record<FixpointKind, string> = {
RETIREMENT: "Erwerbsende",
AHV: "AHV-Rente",
PENSION_FUND: "Pensionskasse",
PILLAR_3A: "Säule 3a",
};
// Alle Fixpunkte, sortiert und ohne Duplikate im selben Jahr (mehrere Ereignisse im gleichen
// Jahr brauchen nur EINE Phasengrenze -- sie wird dann mehrfach beschriftet).
export function planFixpoints(persons: FixpointPerson[]): Fixpoint[] {
const out: Fixpoint[] = [];
const add = (person: FixpointPerson, age: number | undefined, kind: FixpointKind) => {
if (typeof age !== "number") return;
const year = Math.round(age - person.age);
// Ereignisse vor oder bei Planbeginn sind keine Grenze -- sie sind bereits Geschichte.
if (year <= 0) return;
out.push({
year,
age: Math.round(age),
role: person.role ?? "PERSON_A",
kind,
label: `${KIND_LABEL[kind]}${person.name ? " " + person.name : ""} (${Math.round(age)})`,
});
};
for (const p of persons) {
add(p, p.retirementAge, "RETIREMENT");
add(p, p.ahvStartAge, "AHV");
add(p, p.pkWithdrawalAge, "PENSION_FUND");
for (const a of p.pillar3aAges ?? []) add(p, a, "PILLAR_3A");
}
return out.sort((a, b) => a.year - b.year || a.kind.localeCompare(b.kind));
}
// Die distinkten Fixpunkt-Jahre sind die Abschnittsgrenzen; der Abschnitt nach dem letzten
// ist offen.
export function planSegments(persons: FixpointPerson[]): PlanSegment[] {
if (persons.length === 0) return [{ type: "PENSION", fromYear: 0, fixedYears: null, endedBy: [] }];
const fixpoints = planFixpoints(persons);
const retYears = persons.map((p) => Math.max(0, p.retirementAge - p.age)); const retYears = persons.map((p) => Math.max(0, p.retirementAge - p.age));
const events = [...new Set(retYears.filter((r) => r > 0))].sort((a, b) => a - b); const years = [...new Set(fixpoints.map((f) => f.year))].sort((a, b) => a - b);
const boundaries = [0, ...events]; const boundaries = [0, ...years];
const segments: PlanSegment[] = []; const segments: PlanSegment[] = [];
for (let i = 0; i < boundaries.length; i++) { for (let i = 0; i < boundaries.length; i++) {
const from = boundaries[i]; const from = boundaries[i];
const next = i + 1 < boundaries.length ? boundaries[i + 1] : null; const next = i + 1 < boundaries.length ? boundaries[i + 1] : null;
// Eine Person arbeitet in einem Abschnitt, der bei Jahr `from` beginnt, genau dann, wenn // Eine Person arbeitet in einem Abschnitt, der bei Jahr `from` beginnt, genau dann, wenn
// ihr Pensionierungszeitpunkt echt später liegt. // ihr Erwerbsende echt später liegt.
const working = retYears.filter((r) => r > from).length; const working = retYears.filter((r) => r > from).length;
const type: SegmentType = working === persons.length ? "ERWERB" : working > 0 ? "MIXED" : "PENSION"; const type: SegmentType = working === persons.length ? "ERWERB" : working > 0 ? "MIXED" : "PENSION";
segments.push({ type, fromYear: from, fixedYears: next === null ? null : next - from }); segments.push({
type,
fromYear: from,
fixedYears: next === null ? null : next - from,
endedBy: next === null ? [] : fixpoints.filter((f) => f.year === next),
});
} }
return segments; return segments;
} }
// Vorgeschlagene Dauer des offenen Pensions-Abschnitts: bis das älteste Haushaltsmitglied // Vorgeschlagene Dauer des offenen Abschnitts. Mit erfasstem Planungshorizont ergibt sie sich
// ungefähr 90 ist, mindestens aber 5 Jahre. // daraus; ohne ihn bis etwa Alter 90, mindestens aber 5 Jahre.
export function defaultOpenDuration(persons: { age: number }[], fromYear: number): number { export function defaultOpenDuration(
persons: { age: number }[],
fromYear: number,
horizonYears?: number | null
): number {
if (typeof horizonYears === "number" && horizonYears > fromYear) return horizonYears - fromYear;
const oldestNow = persons.length > 0 ? Math.max(...persons.map((p) => p.age)) : 65; const oldestNow = persons.length > 0 ? Math.max(...persons.map((p) => p.age)) : 65;
return Math.max(5, 90 - (oldestNow + fromYear)); return Math.max(5, 90 - (oldestNow + fromYear));
} }
+10
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 : {};
@@ -61,6 +68,7 @@ export function toPlanInput(plan: PlanWithRelations): PlanInput {
name: phase.name, name: phase.name,
durationYears: phase.durationYears, durationYears: phase.durationYears,
cashTransition: parseCashTransition(phase.cashTransition), cashTransition: parseCashTransition(phase.cashTransition),
ratesConfirmed: phase.ratesConfirmed,
sourcePhaseId: phase.sourcePhaseId, sourcePhaseId: phase.sourcePhaseId,
})), })),
elements: plan.elements.map((e) => { elements: plan.elements.map((e) => {
@@ -76,6 +84,8 @@ export function toPlanInput(plan: PlanWithRelations): PlanInput {
orderIndex: e.orderIndex, orderIndex: e.orderIndex,
phaseValues, phaseValues,
transitionValues, transitionValues,
retirementDecision: parseRetirementDecision(e.retirementDecision),
baseData: parsePhaseData(e.baseData),
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,
}; };
} }
+338
View File
@@ -0,0 +1,338 @@
// Der Pensionierungs-Entscheid (0.34).
//
// Zwei Dinge werden hier festgenagelt: die gesetzlichen Faktoren des flexiblen AHV-Bezugs
// (die sind nachschlagbar und muessen exakt stimmen) und die Eigenschaft, um derentwillen
// der ganze Umbau stattfand -- dass ein Entscheid eine Verschiebung der Zeitachse ueberlebt.
import { describe, it, expect } from "vitest";
import { computePlan } from "@/lib/calculations";
import {
ahvDrawLabel,
ahvFactor,
ahvStartAge,
withRetirementDefaults,
type RetirementDecision,
} from "@/lib/retirement-decision";
import { shiftRetirement } from "@/lib/retirement";
import { decisionsText, totalOpenDecisions } from "@/lib/decisions";
import { AHV_REFERENCE_AGE } from "@/lib/constants";
import type { CashTransitionData, ElementCategory, PhaseData, TransitionData } from "@/lib/elements";
import type { PlanInput } from "@/lib/types";
let idc = 0;
const nid = () => `r${idc++}`;
function el(
category: ElementCategory,
ownerRole: string | null,
phaseValues: Record<string, PhaseData>,
transitionValues: Record<string, TransitionData> = {},
retirementDecision: RetirementDecision = {}
) {
return {
id: nid(),
category,
name: category,
ownerRole: ownerRole as never,
orderIndex: idc,
phaseValues,
transitionValues,
retirementDecision,
};
}
function plan(opts: {
age: number;
retirementAge: number;
phases: { id: string; durationYears: number; cashTransition?: CashTransitionData }[];
elements: ReturnType<typeof el>[];
horizon?: number;
}): PlanInput {
return {
id: "plan",
name: "T",
householdType: "SINGLE",
inflationRateDefault: 0,
initialCash: 0,
persons: [
{ id: "A", role: "PERSON_A", name: null, age: opts.age, retirementAge: opts.retirementAge },
],
phases: opts.phases.map((p, i) => ({
id: p.id,
sequenceNumber: i + 1,
name: p.id,
durationYears: p.durationYears,
cashTransition: p.cashTransition ?? {},
})),
elements: opts.elements,
};
}
describe("AHV: flexibler Rentenbezug", () => {
it("kürzt den Vorbezug mit 6,8 % pro Jahr", () => {
expect(ahvFactor({ ahvDraw: "EARLY", ahvMonths: 12 })).toBeCloseTo(0.932, 5);
expect(ahvFactor({ ahvDraw: "EARLY", ahvMonths: 24 })).toBeCloseTo(0.864, 5);
// Monatsgenau, nicht nur in ganzen Jahren (seit AHV 21).
expect(ahvFactor({ ahvDraw: "EARLY", ahvMonths: 6 })).toBeCloseTo(0.966, 5);
});
it("trifft die amtlichen Zuschlagssätze beim Aufschub", () => {
const bonus = (jahre: number) =>
Math.round((ahvFactor({ ahvDraw: "DEFERRED", ahvMonths: jahre * 12 }) - 1) * 1000) / 10;
expect(bonus(1)).toBe(5.2);
expect(bonus(2)).toBe(10.8);
expect(bonus(3)).toBe(17.1);
expect(bonus(4)).toBe(24.0);
expect(bonus(5)).toBe(31.5);
});
it("lässt den Referenzbezug unverändert und kappt am gesetzlichen Fenster", () => {
expect(ahvFactor({})).toBe(1);
expect(ahvFactor({ ahvDraw: "REFERENCE", ahvMonths: 99 })).toBe(1);
// Vorbezug höchstens 3 Jahre, Aufschub höchstens 5.
expect(ahvFactor({ ahvDraw: "EARLY", ahvMonths: 999 })).toBeCloseTo(ahvFactor({ ahvDraw: "EARLY", ahvMonths: 36 }), 9);
expect(ahvFactor({ ahvDraw: "DEFERRED", ahvMonths: 999 })).toBeCloseTo(1.315, 5);
});
it("verschiebt den Rentenbeginn entsprechend", () => {
expect(ahvStartAge({})).toBe(AHV_REFERENCE_AGE);
expect(ahvStartAge({ ahvDraw: "EARLY", ahvMonths: 24 })).toBe(AHV_REFERENCE_AGE - 2);
expect(ahvStartAge({ ahvDraw: "DEFERRED", ahvMonths: 36 })).toBe(AHV_REFERENCE_AGE + 3);
});
it("wirkt beim Teilbezug nur auf den bezogenen Anteil", () => {
// 50 % zwei Jahre vorbezogen (-13,6 %), der Rest ungekürzt -> halbe Wirkung.
const voll = ahvFactor({ ahvDraw: "EARLY", ahvMonths: 24, ahvSharePct: 100 });
const halb = ahvFactor({ ahvDraw: "EARLY", ahvMonths: 24, ahvSharePct: 50 });
expect(halb).toBeCloseTo(1 - (1 - voll) / 2, 9);
});
it("beschriftet den Entscheid lesbar", () => {
expect(ahvDrawLabel({})).toBe("Referenzalter");
expect(ahvDrawLabel({ ahvDraw: "EARLY", ahvMonths: 24 })).toContain("Vorbezug");
expect(ahvDrawLabel({ ahvDraw: "EARLY", ahvMonths: 24 })).toContain("-13.6");
expect(ahvDrawLabel({ ahvDraw: "DEFERRED", ahvMonths: 60 })).toContain("+31.5");
});
});
describe("AHV im Rechenkern: Rentenbeginn und Beitragspflicht sind zwei Alter", () => {
function p(rd: RetirementDecision, retirementAge = 62) {
return plan({
age: 60,
retirementAge,
phases: [
{ id: "p1", durationYears: retirementAge - 60 },
{ id: "p2", durationYears: 15 },
],
elements: [
el("INCOME", "PERSON_A", { p1: { amount: 100000, teuerungsausgleich: 0 }, p2: {} }),
el("EXPENSE", "HOUSEHOLD", { p1: { amount: 50000 }, p2: { amount: 50000 } }),
el("AHV", "PERSON_A", { p1: {}, p2: { ahvContribution: 1000 } }, {}, { avgIncomeBefore: 90000, ...rd }),
],
});
}
const ahvYearly = (pl: PlanInput) => {
const c = computePlan(pl);
const ec = c.phases[1].elements.find((e) => e.category === "AHV")!;
return ec.yearly;
};
// Hinweis zur Konvention: `age` im Verlauf ist das Alter am JAHRESENDE, die Rentenprüfung
// nutzt das Alter am Jahresanfang. Deshalb wird hier nicht auf absolute Alter geprüft,
// sondern auf das erste Jahr mit Rente -- das ist die Aussage, um die es geht.
const erstesRentenjahr = (pl: PlanInput) => ahvYearly(pl).find((x) => x.value > 0)?.age ?? null;
it("zahlt die Rente erst ab dem Referenzalter, wenn nicht vorbezogen wird", () => {
const y = ahvYearly(p({}));
expect(y[0].value).toBeLessThan(0); // vor dem Referenzalter nur der Beitrag
expect(erstesRentenjahr(p({}))).not.toBeNull();
});
it("zieht den Rentenbeginn beim Vorbezug um genau die Vorbezugsdauer vor", () => {
const ohne = erstesRentenjahr(p({}))!;
const zweiJahre = erstesRentenjahr(p({ ahvDraw: "EARLY", ahvMonths: 24 }))!;
expect(ohne - zweiJahre).toBe(2);
});
it("schiebt ihn beim Aufschub entsprechend nach hinten", () => {
const ohne = erstesRentenjahr(p({}))!;
const spaeter = erstesRentenjahr(p({ ahvDraw: "DEFERRED", ahvMonths: 36 }))!;
expect(spaeter - ohne).toBe(3);
});
it("beendet die Beitragspflicht unabhängig davon erst mit dem Referenzalter", () => {
// Der fachliche Kern: Wer mit 62 aufhört und ab 63 vorbezieht, bezieht ab 63 UND zahlt
// bis 65 weiter. Im Überlappungsjahr ist der Wert deshalb um den Beitrag gemindert.
const y = ahvYearly(p({ ahvDraw: "EARLY", ahvMonths: 24 }));
const mitRente = y.filter((x) => x.value > 0).map((x) => x.value);
const kleinster = Math.min(...mitRente);
const groesster = Math.max(...mitRente);
expect(groesster - kleinster).toBe(1000);
});
it("kürzt die Rente dauerhaft um den Vorbezugsfaktor", () => {
const ohne = computePlan(p({})).retirement.perPerson[0].ahvAnnual;
const mit = computePlan(p({ ahvDraw: "EARLY", ahvMonths: 24 })).retirement.perPerson[0].ahvAnnual;
expect(mit).toBe(Math.round(ohne * 0.864));
});
it("erhöht sie beim Aufschub", () => {
const ohne = computePlan(p({})).retirement.perPerson[0].ahvAnnual;
const mit = computePlan(p({ ahvDraw: "DEFERRED", ahvMonths: 36 })).retirement.perPerson[0].ahvAnnual;
expect(mit).toBe(Math.round(ohne * 1.171));
});
});
describe("Pensionskasse: eine Quote statt Modus und Frankenbetrag", () => {
function p(sharePct: number) {
return plan({
age: 62,
retirementAge: 65,
phases: [
{ id: "p1", durationYears: 3 },
{ id: "p2", durationYears: 10 },
],
elements: [
el("INCOME", "PERSON_A", { p1: { amount: 100000, teuerungsausgleich: 0 }, p2: {} }),
el("EXPENSE", "HOUSEHOLD", { p1: { amount: 50000 }, p2: { amount: 50000 } }),
el(
"PENSION_FUND",
"PERSON_A",
{ p1: { currentValue: 500000, expectedReturn: 0 }, p2: {} },
{},
{ capitalSharePct: sharePct, conversionRate: 6, capitalTaxRate: 0 }
),
],
});
}
const pk = (share: number) => computePlan(p(share)).retirement.perPerson[0];
it("0 % ist die volle Rente", () => {
expect(pk(0).pkPensionAnnual).toBe(30000); // 500'000 x 6 %
expect(pk(0).capitalAtRetirement).toBe(0);
});
it("100 % ist der volle Kapitalbezug", () => {
expect(pk(100).pkPensionAnnual).toBe(0);
expect(pk(100).capitalAtRetirement).toBe(500000);
});
it("dazwischen wird sauber geteilt", () => {
expect(pk(40).capitalAtRetirement).toBe(200000);
expect(pk(40).pkPensionAnnual).toBe(18000); // 300'000 x 6 %
});
it("skaliert mit dem Pensionsalter -- genau darum eine Quote und kein Betrag", () => {
// Ein Jahr laenger arbeiten heisst mehr Guthaben; die Aufteilung 40/60 bleibt.
const base = p(40);
const laenger: PlanInput = {
...base,
persons: base.persons.map((x) => ({ ...x, retirementAge: 66 })),
phases: base.phases.map((ph, i) => (i === 0 ? { ...ph, durationYears: 4 } : { ...ph, durationYears: 9 })),
};
const r = computePlan(laenger).retirement.perPerson[0];
// Ohne Rendite waechst das Guthaben hier nicht -- entscheidend ist, dass das VERHAELTNIS
// gleich bleibt, nicht der Betrag.
expect(r.capitalAtRetirement / (r.capitalAtRetirement + (r.pkPensionAnnual / 6) * 100)).toBeCloseTo(0.4, 6);
});
});
describe("Der Entscheid überlebt eine Verschiebung der Zeitachse", () => {
// Der eigentliche Grund fuer den ganzen Umbau: Bis 0.33 hing der Entscheid an
// transitionValues[phaseId]. Verschob man das Pensionsalter, wanderte die Phasengrenze --
// und der Entscheid musste verlustbehaftet mitgezogen werden.
it("bleibt nach shiftRetirement unverändert am Element", () => {
const base = plan({
age: 60,
retirementAge: 65,
phases: [
{ id: "p1", durationYears: 5 },
{ id: "p2", durationYears: 15 },
],
elements: [
el("INCOME", "PERSON_A", { p1: { amount: 100000 }, p2: {} }),
el("EXPENSE", "HOUSEHOLD", { p1: { amount: 60000 }, p2: { amount: 60000 } }),
el(
"PENSION_FUND",
"PERSON_A",
{ p1: { currentValue: 400000, expectedReturn: 0 }, p2: {} },
{},
{ capitalSharePct: 75, capitalTaxRate: 5, confirmed: true }
),
],
});
const shifted = shiftRetirement(base, "PERSON_A", -2);
expect(shifted).not.toBeNull();
const pkVorher = base.elements.find((e) => e.category === "PENSION_FUND")!.retirementDecision;
const pkNachher = shifted!.plan.elements.find((e) => e.category === "PENSION_FUND")!.retirementDecision;
expect(pkNachher).toEqual(pkVorher);
expect(pkNachher?.capitalSharePct).toBe(75);
expect(pkNachher?.confirmed).toBe(true);
});
});
describe("Vorgaben: der Plan ist ab der ersten Sekunde rechenbar", () => {
it("setzt sinnvolle Vorgaben je Kategorie", () => {
expect(withRetirementDefaults("AHV", 65, {}).ahvDraw).toBe("REFERENCE");
// Volle Rente ist die Regel -- ein Kapitalbezug ist der begruendungspflichtige Fall.
expect(withRetirementDefaults("PENSION_FUND", 65, {}).capitalSharePct).toBe(0);
expect(withRetirementDefaults("PILLAR_3A", 63, {}).withdrawalAge).toBe(63);
});
it("hält die 3a im gesetzlichen Bezugsfenster, auch bei früher Pensionierung", () => {
// Wer mit 58 aufhoert, kann die Saeule 3a trotzdem erst mit 60 beziehen.
expect(withRetirementDefaults("PILLAR_3A", 58, {}).withdrawalAge).toBe(60);
expect(withRetirementDefaults("PILLAR_3A", 75, {}).withdrawalAge).toBe(70);
});
it("überschreibt einen erfassten Wert nicht", () => {
expect(withRetirementDefaults("PENSION_FUND", 65, { capitalSharePct: 100 }).capitalSharePct).toBe(100);
});
});
describe("Ampel: drei Zustände statt zwei", () => {
// Bis 0.33 kannte das Modell nur "offen" oder "beantwortet". Eine durchgängige Vorgabe wäre
// damit als beantwortet durchgegangen -- und "volle Rente statt Kapitalbezug" hätte das
// Ergebnis massiv verändert, ohne dass es je jemandem aufgefallen wäre.
function p(confirmed: boolean) {
return plan({
age: 62,
retirementAge: 65,
phases: [
{ id: "p1", durationYears: 3, cashTransition: { mode: "NONE" } },
{ id: "p2", durationYears: 10 },
],
elements: [
el("INCOME", "PERSON_A", { p1: { amount: 100000 }, p2: {} }),
el("EXPENSE", "HOUSEHOLD", { p1: { amount: 50000 }, p2: { amount: 50000 } }),
el(
"PENSION_FUND",
"PERSON_A",
{ p1: { currentValue: 400000 }, p2: {} },
{},
{ capitalSharePct: 0, confirmed }
),
el("PILLAR_3A", "PERSON_A", { p1: { currentValue: 100000 }, p2: {} }, {}, { confirmed }),
],
});
}
it("zählt eine ungeprüfte Vorgabe eigens -- nicht als offenen Entscheid", () => {
const c = totalOpenDecisions(p(false), computePlan(p(false)));
expect(c.unconfirmed).toBe(2); // PK und 3a
expect(c.open).toBe(0); // der Cash-Übergang ist beantwortet
});
it("verschwindet, sobald bestätigt wurde", () => {
const c = totalOpenDecisions(p(true), computePlan(p(true)));
expect(c.unconfirmed).toBe(0);
expect(c.open).toBe(0);
});
it("benennt beides getrennt", () => {
expect(decisionsText({ open: 2, unconfirmed: 3 })).toBe("2 offene Entscheide · 3 Vorgaben ungeprüft");
expect(decisionsText({ open: 1, unconfirmed: 0 })).toBe("1 offener Entscheid");
expect(decisionsText({ open: 0, unconfirmed: 1 })).toBe("1 Vorgabe ungeprüft");
expect(decisionsText({ open: 0, unconfirmed: 0 })).toBe("");
});
});
+246
View File
@@ -0,0 +1,246 @@
// Der Pensionierungs-Entscheid je Vorsorge-Element.
//
// WARUM EIN EIGENER SPEICHERORT UND NICHT `transitionValues`?
//
// Bis 0.34 hing jeder dieser Entscheide an `transitionValues[phaseId]` -- also am Schluessel
// Element x Phasen-ID. Das war der Grund fuer eine ganze Reihe von Aergernissen: Verschob man
// das Pensionsalter, wanderte die Phasengrenze, und die Entscheide mussten ueber
// `mergeTransition` von Grenze zu Grenze gerettet werden -- verlustbehaftet. Ein Szenario nur
// wegen eines anderen Pensionsalters aufzusetzen hiess, alle Entscheide erneut zu treffen.
// Und der Ziel-Solver (Roadmap Nr. 21) haette bei jedem Kandidatenalter die Phasenstruktur
// umbauen und die Entscheide neu einsammeln muessen.
//
// Ohne Phasen-ID im Schluessel ueberlebt der Entscheid JEDE Zeitachsen-Aenderung. Die
// Pensionierung ist eine Eigenschaft der PERSON, nicht der Zeitachse -- die Zeitachse ist die
// Folge davon.
//
// Was hier NICHT hineingehoert: Vorbezuege (PK/3a vor der Pensionierung), Verkaeufe,
// Tilgungen, der Cash-Uebergang. Das sind echte Ereignisse an einer bestimmten Phasengrenze
// und bleiben in `transitionValues`.
import { z } from "zod";
import {
AHV_DEFER_BONUS_BY_YEAR,
AHV_DEFER_MAX_MONTHS,
AHV_EARLY_MAX_MONTHS,
AHV_EARLY_REDUCTION_PER_YEAR,
AHV_PARTIAL_MIN_PCT,
AHV_REFERENCE_AGE,
DEFAULT_CAPITAL_TAX_RATE,
DEFAULT_PK_CONVERSION_RATE,
PILLAR_3A_MAX_WITHDRAWAL_AGE,
PILLAR_3A_MIN_WITHDRAWAL_AGE,
PK_MAX_RETIREMENT_AGE,
PK_MIN_RETIREMENT_AGE,
} from "@/lib/constants";
import type { ElementCategory } from "@/lib/elements";
export type AhvDraw = "EARLY" | "REFERENCE" | "DEFERRED";
export interface RetirementDecision {
// Hat die Person hingeschaut? Fehlt das Haekchen, rechnet das Tool mit einer Vorgabe --
// ein dritter Zustand neben "unbeantwortet" und "beantwortet" (SPEZIFIKATION 3.5.3).
confirmed?: boolean;
// --- AHV -------------------------------------------------------------------------------
ahvDraw?: AhvDraw;
// Monate des Vorbezugs bzw. des Aufschubs. Nur bei EARLY/DEFERRED von Bedeutung.
ahvMonths?: number;
// Teilbezug/Teilaufschub: Anteil der Rente, der vorbezogen bzw. aufgeschoben wird.
ahvSharePct?: number;
// Beitragskarriere vor Planbeginn. Keine Entscheidung, sondern Datenqualitaet -- deshalb
// im UI abgesetzt dargestellt. avgIncomeBefore ist REAL (heutige Kaufkraft) und BRUTTO.
avgIncomeBefore?: number;
gapYearsBefore?: number;
// --- Pensionskasse ---------------------------------------------------------------------
// Anteil des Altersguthabens, der als Kapital bezogen wird. EIN Regler statt des frueheren
// Modus PENSION/CAPITAL/COMBI plus Frankenbetrag: 0 = volle Rente, 100 = volles Kapital,
// alles dazwischen ist die Kombination. Als Quote und nicht in Franken, weil sich das
// Guthaben mit dem Pensionsalter aendert -- ein fixer Betrag wuerde still falsch.
capitalSharePct?: number;
conversionRate?: number;
// Alter, in dem das PK-Guthaben bezogen bzw. verrentet wird. Bis 0.35 war das starr an das
// Erwerbsende gekoppelt -- wer mit 62 aufhoert, aber die PK erst mit 65 beziehen will,
// konnte das nicht abbilden. Reglementarisch fruehestens 58, Aufschub bis 70 nur bei
// fortgesetzter Erwerbstaetigkeit.
pkWithdrawalAge?: number;
// Reines Hinweis-Flag: Ein Kapitalbezug innerhalb von drei Jahren nach einem Einkauf laesst
// den Steuerabzug nachtraeglich entfallen (Art. 79b Abs. 3 BVG). Das Tool kennt keine
// Einkaeufe und kann das nicht selbst pruefen -- deshalb die Frage statt einer Automatik.
recentBuyIn?: boolean;
// --- Saeule 3a -------------------------------------------------------------------------
// Alter, in dem dieses Konto aufgeloest wird. Ein 3a-Konto laesst sich bei der
// Pensionierung nur GANZ aufloesen -- gestaffelt wird ueber mehrere Konten, und genau
// dafuer gibt es dieses Feld. Der Bezug erfolgt an der Phasengrenze bei oder nach diesem
// Alter (siehe `effectiveWithdrawalAge`).
withdrawalAge?: number;
// --- gemeinsam: Kapitalbezug und seine Verwendung ---------------------------------------
capitalTaxRate?: number;
// Quote in die SCHULDENTILGUNG. Ziel ist seit 0.41 waehlbar: eine Hypothek (Immobilie)
// ODER eine sonstige Schuld -- vorher floss der Betrag stets in die erstbeste Immobilie,
// was bei einem teuren Konsumkredit daneben die falsche Wahl war.
capitalUseAmortizationPct?: number;
capitalUseDebtTargetElementId?: string;
capitalUseInvestPct?: number;
capitalUseTargetElementId?: string;
}
const pct = z.number().min(0).max(100);
export const retirementDecisionSchema = z
.object({
confirmed: z.boolean().optional(),
ahvDraw: z.enum(["EARLY", "REFERENCE", "DEFERRED"]).optional(),
ahvMonths: z.number().int().min(0).max(AHV_DEFER_MAX_MONTHS).optional(),
ahvSharePct: z.number().min(AHV_PARTIAL_MIN_PCT).max(100).optional(),
avgIncomeBefore: z.number().min(0).optional(),
gapYearsBefore: z.number().int().min(0).max(50).optional(),
capitalSharePct: pct.optional(),
conversionRate: z.number().min(0).max(20).optional(),
pkWithdrawalAge: z.number().int().min(PK_MIN_RETIREMENT_AGE).max(PK_MAX_RETIREMENT_AGE).optional(),
recentBuyIn: z.boolean().optional(),
withdrawalAge: z
.number()
.int()
.min(PILLAR_3A_MIN_WITHDRAWAL_AGE)
.max(PILLAR_3A_MAX_WITHDRAWAL_AGE)
.optional(),
capitalTaxRate: z.number().min(0).max(100).optional(),
capitalUseAmortizationPct: pct.optional(),
capitalUseDebtTargetElementId: z.string().max(60).optional(),
capitalUseInvestPct: pct.optional(),
capitalUseTargetElementId: z.string().max(60).optional(),
})
.strict();
// Kategorien, die ueberhaupt einen Pensionierungs-Entscheid kennen.
export const RETIREMENT_CATEGORIES: ElementCategory[] = ["AHV", "PENSION_FUND", "PILLAR_3A"];
// --- Vorgaben -------------------------------------------------------------------------------
//
// Zentrales Gestaltungsprinzip: KEIN leeres Formular, sondern ein vollstaendiger Vorschlag,
// den man korrigiert. Nur so ist der Plan ab der ersten Sekunde rechenbar -- und nur so muss
// niemand am Anfang Fragen beantworten, die er erst am Ende beantworten kann.
export function withRetirementDefaults(
category: ElementCategory,
retirementAge: number,
rd: RetirementDecision | null | undefined
): RetirementDecision {
const d: RetirementDecision = { ...(rd ?? {}) };
if (category === "AHV") {
d.ahvDraw ??= "REFERENCE";
d.ahvSharePct ??= 100;
} else if (category === "PENSION_FUND") {
// Volle Rente. Das ist die Vorgabe, weil sie die Regel ist -- ein Kapitalbezug ist der
// begruendungspflichtige Fall, nicht umgekehrt.
d.capitalSharePct ??= 0;
d.conversionRate ??= DEFAULT_PK_CONVERSION_RATE;
d.capitalTaxRate ??= DEFAULT_CAPITAL_TAX_RATE;
// Vorgabe: mit dem Erwerbsende, im gesetzlichen Fenster gehalten.
d.pkWithdrawalAge ??= clamp(retirementAge, PK_MIN_RETIREMENT_AGE, PK_MAX_RETIREMENT_AGE);
} else if (category === "PILLAR_3A") {
// Im Pensionierungsjahr, aber nie ausserhalb des gesetzlichen Fensters: Wer mit 58
// aufhoert, kann die 3a trotzdem erst mit 60 beziehen.
d.withdrawalAge ??= clamp(
retirementAge,
PILLAR_3A_MIN_WITHDRAWAL_AGE,
PILLAR_3A_MAX_WITHDRAWAL_AGE
);
d.capitalTaxRate ??= DEFAULT_CAPITAL_TAX_RATE;
}
return d;
}
function clamp(v: number, lo: number, hi: number): number {
return Math.max(lo, Math.min(hi, v));
}
// --- AHV: Rentenbeginn und Faktor -----------------------------------------------------------
// Alter, ab dem die Rente tatsaechlich fliesst. Bis 0.33 war das immer das Referenzalter --
// wer mit 62 aufhoerte, bekam die ungekuerzte Rente drei Jahre spaeter, wer bis 68 arbeitete,
// verschenkte den Zuschlag. Beides war schlicht falsch.
export function ahvStartAge(rd: RetirementDecision): number {
const months = ahvShiftMonths(rd);
return AHV_REFERENCE_AGE + months / 12;
}
// Verschiebung in Monaten gegenueber dem Referenzalter: negativ = Vorbezug, positiv = Aufschub.
export function ahvShiftMonths(rd: RetirementDecision): number {
const raw = Math.round(Math.max(0, rd.ahvMonths ?? 0));
if (rd.ahvDraw === "EARLY") return -Math.min(raw, AHV_EARLY_MAX_MONTHS);
if (rd.ahvDraw === "DEFERRED") return Math.min(raw, AHV_DEFER_MAX_MONTHS);
return 0;
}
// Faktor auf die Rente. 1 = unveraendert, < 1 = gekuerzt (Vorbezug), > 1 = erhoeht (Aufschub).
//
// Beim Teilbezug wirkt die Kuerzung bzw. der Zuschlag NUR auf den vorbezogenen/aufgeschobenen
// Anteil -- der Rest laeuft ungekuerzt ab dem Referenzalter. Das Tool bildet das vereinfacht
// ab, indem es den gewichteten Mischfaktor bildet: Der Zeitversatz des Restanteils wird nicht
// eigens modelliert, weil das eine zweite Rentenlinie mit eigenem Startjahr erforderte.
export function ahvFactor(rd: RetirementDecision): number {
const months = ahvShiftMonths(rd);
if (months === 0) return 1;
const share = clamp(rd.ahvSharePct ?? 100, AHV_PARTIAL_MIN_PCT, 100) / 100;
const adjust = months < 0 ? earlyFactor(-months) : deferFactor(months);
return share * adjust + (1 - share);
}
function earlyFactor(months: number): number {
// Linear in den Monaten -- die Kuerzung ist gesetzlich ein Jahressatz, monatlich anteilig.
return 1 - (months / 12) * (AHV_EARLY_REDUCTION_PER_YEAR / 100);
}
function deferFactor(months: number): number {
// Die amtlichen Stuetzwerte gelten je volles Jahr; dazwischen wird linear interpoliert.
const years = months / 12;
const lo = Math.floor(years);
const hi = Math.min(AHV_DEFER_BONUS_BY_YEAR.length - 1, lo + 1);
const a = AHV_DEFER_BONUS_BY_YEAR[Math.min(lo, AHV_DEFER_BONUS_BY_YEAR.length - 1)];
const b = AHV_DEFER_BONUS_BY_YEAR[hi];
const bonus = a + (b - a) * (years - lo);
return 1 + bonus / 100;
}
// Lesbare Kurzfassung fuer Matrix-Zelle und Zusammenfassung.
export function ahvDrawLabel(rd: RetirementDecision): string {
const months = ahvShiftMonths(rd);
if (months === 0) return "Referenzalter";
const abs = Math.abs(months);
const years = Math.floor(abs / 12);
const rest = abs % 12;
const dauer = [years > 0 ? `${years} J.` : "", rest > 0 ? `${rest} Mt.` : ""]
.filter(Boolean)
.join(" ");
const delta = Math.round((ahvFactor(rd) - 1) * 1000) / 10;
return `${months < 0 ? "Vorbezug" : "Aufschub"} ${dauer} (${delta > 0 ? "+" : ""}${delta} %)`;
}
// --- Saeule 3a: das Bezugsalter --------------------------------------------------------------
// Der Bezug erfolgt an einer PHASENGRENZE -- innerhalb einer Phase kennt das Modell kein
// Einzelereignis. Gewaehlt wird deshalb die erste Grenze bei oder nach dem gewuenschten Alter.
// Wer exakt staffeln will, setzt eine Phasengrenze; das ist in FPT ohnehin die Art, wie man
// Zeitpunkte modelliert.
export function effectiveWithdrawalAge(
rd: RetirementDecision,
retirementAge: number,
boundaryAges: number[]
): number {
const wish = clamp(
Math.round(rd.withdrawalAge ?? retirementAge),
PILLAR_3A_MIN_WITHDRAWAL_AGE,
PILLAR_3A_MAX_WITHDRAWAL_AGE
);
const hit = boundaryAges.find((a) => a >= wish);
// Ohne passende Grenze (Wunsch liegt hinter dem Planende) gilt die letzte verfuegbare.
return hit ?? boundaryAges[boundaryAges.length - 1] ?? wish;
}
+23
View File
@@ -47,6 +47,29 @@ export function sortedPhases(plan: PlanInput) {
return [...plan.phases].sort((a, b) => a.sequenceNumber - b.sequenceNumber); return [...plan.phases].sort((a, b) => a.sequenceNumber - b.sequenceNumber);
} }
// --- Planungshorizont -----------------------------------------------------------------------
//
// Bis 0.35 ergab sich das Planende stillschweigend als Summe der Phasendauern. Zwei Szenarien
// konnten dadurch unbemerkt verschieden weit rechnen -- und waren dann nicht vergleichbar,
// obwohl genau das ihr Zweck ist.
//
// Seit 0.38 wird der Horizont ABGELEITET: Er ist die Summe der Lebensphasen. Zwei Anlaeufe
// davor -- Endalter je Person (0.35), dann eine eigene Jahreszahl am Szenario (0.36) -- waren
// beide eine zweite Wahrheit ueber dieselbe Sache. Wer die Phasen einzeln plant, hat den
// Horizont damit bereits bestimmt; ihn zusaetzlich zu erfassen hiess nur, zwei Zahlen
// synchron halten zu muessen.
export function planEndYear(plan: PlanInput): number | null {
const total = plan.phases.reduce((n, p) => n + Math.max(0, p.durationYears), 0);
return total > 0 ? total : null;
}
// Alter, das eine Person am Ende der Planung erreicht. Read-only-Anzeige neben dem Horizont.
export function endAgeOf(plan: PlanInput, role: PersonRole): number | null {
const years = planEndYear(plan);
const person = plan.persons.find((p) => p.role === role);
return years !== null && person ? person.age + years : null;
}
// Analysiert für JEDE Person, ob und wie weit sich ihr Pensionsalter verschieben lässt. // Analysiert für JEDE Person, ob und wie weit sich ihr Pensionsalter verschieben lässt.
export function retirementBoundaries(plan: PlanInput): RetirementBoundary[] { export function retirementBoundaries(plan: PlanInput): RetirementBoundary[] {
const phases = sortedPhases(plan); const phases = sortedPhases(plan);
+154
View File
@@ -0,0 +1,154 @@
// Die Übersicht der offenen Punkte (0.39).
//
// Sie ersetzt den FPT-Assistenten. Getestet wird die Regel dahinter: Wann gilt etwas als
// offen, wann als erledigt -- und die beiden Sonderzustände am Anfang, in denen eine Zahl
// eine Lüge wäre.
import { describe, it, expect } from "vitest";
import { computePlan } from "@/lib/calculations";
import { reviewPlan, unconfirmedCells, isCellConfirmed } from "@/lib/review";
import type { PhaseData } from "@/lib/elements";
import type { PlanInput } from "@/lib/types";
function plan(over: Partial<PlanInput> = {}): PlanInput {
return {
id: "plan",
name: "T",
householdType: "SINGLE",
inflationRateDefault: 0,
initialCash: 0,
startYear: 2026,
persons: [{ id: "A", role: "PERSON_A", name: null, age: 40, retirementAge: 65 }],
phases: [],
elements: [],
...over,
};
}
function phase(id: string, seq: number, years: number, ratesConfirmed = true) {
return { id, sequenceNumber: seq, name: id, durationYears: years, cashTransition: { mode: "NONE" as const }, ratesConfirmed };
}
let n = 0;
function el(category: string, phaseValues: Record<string, PhaseData> = {}) {
return {
id: `e${n++}`,
category: category as never,
name: category,
ownerRole: "HOUSEHOLD" as never,
orderIndex: n,
phaseValues,
transitionValues: {},
baseData: {},
};
}
const ok = { confirmed: true };
describe("Die beiden Startzustände", () => {
// Ohne Elemente und ohne Phasen gibt es naturgemäss nichts Offenes -- der Plan ist trotzdem
// leer. Eine «0» wäre dort eine Lüge, deshalb eigene Zustände.
it("meldet einen Plan ohne Elemente als NO_ELEMENTS, nicht als fertig", () => {
const p = plan();
const r = reviewPlan(p, computePlan(p));
expect(r.stage).toBe("NO_ELEMENTS");
expect(r.total).toBe(0);
});
it("meldet Elemente ohne Lebensphasen als NO_PHASES", () => {
const p = plan({ elements: [el("OTHER_ASSET")] });
expect(reviewPlan(p, computePlan(p)).stage).toBe("NO_PHASES");
});
});
describe("Offene Punkte je Phase", () => {
it("zählt jede unbestätigte Zelle", () => {
const p = plan({
phases: [phase("p1", 1, 10)],
elements: [el("INCOME", { p1: {} }), el("EXPENSE", { p1: {} }), el("OTHER_ASSET", { p1: ok })],
});
const c = computePlan(p);
expect(unconfirmedCells(p, c, "p1")).toHaveLength(2);
const r = reviewPlan(p, c);
expect(r.stage).toBe("OPEN");
expect(r.total).toBe(2);
expect(r.groups[0].reasons[0]).toContain("2 Annahmen");
});
it("zählt die nicht verteilte Sparquote eigens", () => {
// Man kann jede Zelle angeschaut und die Quote trotzdem nie verteilt haben -- dann bliebe
// alles still auf dem Cash-Konto.
const p = plan({
phases: [phase("p1", 1, 10, false)],
elements: [el("INCOME", { p1: ok })],
});
const r = reviewPlan(p, computePlan(p));
expect(r.total).toBe(1);
expect(r.groups[0].reasons[0]).toContain("Sparquote nicht verteilt");
});
it("ist fertig, wenn alles bestätigt und verteilt ist", () => {
const p = plan({
phases: [phase("p1", 1, 10)],
elements: [el("INCOME", { p1: ok }), el("EXPENSE", { p1: ok })],
});
const r = reviewPlan(p, computePlan(p));
expect(r.stage).toBe("DONE");
expect(r.total).toBe(0);
expect(r.groups).toHaveLength(0);
});
it("verlangt keine Bestätigung für eine Zelle ohne Annahmen", () => {
// Seit 0.40 stehen Bestände in den Stammdaten und Raten im Verteil-Dialog. Eine
// «Sonstige Schuld» trägt in der Phasenzelle damit gar nichts mehr -- eine Bestätigung
// wäre eine Frage ohne Gegenstand.
const p = plan({
phases: [phase("p1", 1, 10)],
elements: [el("OTHER_DEBT", { p1: {} })],
});
const c = computePlan(p);
expect(unconfirmedCells(p, c, "p1")).toHaveLength(0);
expect(reviewPlan(p, c).stage).toBe("DONE");
});
it("verlangt keine Bestätigung für ein verkauftes Element", () => {
// Ein verkauftes Haus trägt in der Folgephase keine Annahmen mehr.
const p = plan({
phases: [phase("p1", 1, 5), phase("p2", 2, 5)],
elements: [
el("INCOME", { p1: ok, p2: ok }),
{
...el("REAL_ESTATE", { p1: { ...ok, purchasePrice: 800000, mortgage: 500000 }, p2: {} }),
transitionValues: { p1: { decision: "SELL" as const, salePrice: 900000 } },
},
],
});
const c = computePlan(p);
// In p2 ist die Immobilie verkauft -- nur das Einkommen zählt, und das ist bestätigt.
expect(unconfirmedCells(p, c, "p2")).toHaveLength(0);
});
});
describe("Bestätigen friert nichts ein", () => {
// Der wichtigste Punkt: Der Haken steht NEBEN den Werten. Eine bestätigte Phase erbt
// weiterhin live aus der Vorphase -- sonst wäre die Feld-Vererbung hinfällig.
it("lässt die Vererbung unberührt", () => {
const p = plan({
phases: [phase("p1", 1, 10), phase("p2", 2, 10)],
elements: [
{
...el("OTHER_ASSET"),
baseData: { startValue: 100000, expectedReturn: 5 },
// p2 ist bestätigt, hat aber KEINEN eigenen Renditewert -- sie muss erben.
phaseValues: { p1: ok, p2: ok },
},
],
});
const c = computePlan(p);
const p2 = c.phases[1].elements.find((e) => e.category === "OTHER_ASSET")!;
// 100'000 über 20 Jahre zu 5 % -- die Rendite gilt in beiden Phasen. Auf den Franken
// genau geht es nicht: Der Rechenkern rundet an jeder Phasengrenze.
expect(p2.endValue).toBeCloseTo(100000 * Math.pow(1.05, 20), -1);
expect(isCellConfirmed(p, "p2", p.elements[0].id)).toBe(true);
});
});
+131
View File
@@ -0,0 +1,131 @@
// Was ist in diesem Plan noch offen?
//
// Ersetzt den FPT-Assistenten. Der Unterschied ist nicht die Groesse, sondern der Charakter:
// Der Assistent war ein ABLAUF ("tu dies, dann das") und funktionierte nur beim ersten
// Aufsetzen -- wer einen bestehenden Plan oeffnete, bekam Schritte angeboten, die laengst
// erledigt waren. Diese Uebersicht ist ein ZUSTAND. Sie leitet ab, was tatsaechlich fehlt,
// und traegt damit bei jedem Plan, in jeder Reihenfolge, auch beim zwanzigsten Szenario.
//
// Der Mechanismus dahinter ist derselbe, den die Uebergaenge seit 0.35 haben, jetzt auf
// PHASENWERTE ausgeweitet: Eine neue Lebensphase uebernimmt alle Werte der Vorphase, aber sie
// gelten als unbestaetigt, bis jemand hingeschaut hat. Vorbelegen ja, stillschweigend
// uebernehmen nein.
//
// WICHTIG: Bestaetigen heisst "ich habe hingeschaut", NICHT "festnageln". Der Haken steht
// neben den Werten, er kopiert sie nicht in die Phase -- sonst waere jede bestaetigte Phase
// eingefroren und die Feld-Vererbung (SPEZIFIKATION 3.12.4) waere hinfaellig.
import { openTransitionCount, type DecisionCounts } from "@/lib/decisions";
import type { PlanComputed } from "@/lib/calculations";
import type { PlanInput } from "@/lib/types";
// Wo steht der Plan insgesamt? Die ersten beiden Zustaende sind KEINE Zaehlung offener
// Punkte -- ohne Elemente und ohne Phasen gibt es naturgemaess nichts Offenes, der Plan ist
// aber trotzdem leer. Diese beiden Faelle brauchen eine Aufforderung, keine Statistik.
export type ReviewStage = "NO_ELEMENTS" | "NO_PHASES" | "OPEN" | "DONE";
export interface ReviewGroup {
// Sprungziel: Phasen-Id bzw. die Von-Phase eines Uebergangs.
id: string;
kind: "phase" | "transition";
title: string;
open: number;
// Kurze Klartexte, was genau fehlt.
reasons: string[];
}
export interface PlanReview {
stage: ReviewStage;
total: number;
groups: ReviewGroup[];
}
// Kategorien, die in einer Phasenzelle ueberhaupt eine Annahme tragen. Seit 0.40 entstehen
// Bestaende in den Stammdaten und jaehrliche Raten im Verteil-Dialog; in der Zelle bleiben nur
// noch die Annahmen -- und die hat nicht jede Kategorie. Eine "Sonstige Schuld" traegt keine:
// Restschuld und Tilgung stehen anderswo. Eine Bestaetigung fuer eine leere Zelle zu
// verlangen, waere eine Frage ohne Gegenstand.
const CATEGORIES_WITH_ASSUMPTIONS = new Set([
"INCOME", // Lohnentwicklung
"EXPENSE", // Teuerung
"PENSION_FUND", // Verzinsung
"PILLAR_3A", // Rendite
"OTHER_ASSET", // Rendite
"REAL_ESTATE", // Hypothekarzins, Wertsteigerung, Zins-in-Ausgaben
"AHV", // Beitragsluecken, Beitrag als Nichterwerbstaetige(r)
]);
// Braucht dieses Element in dieser Phase eine Bestaetigung? Es muss in der Phase AKTIV sein
// (ein verkauftes Haus traegt keine Annahmen mehr) UND ueberhaupt eine Annahme tragen.
export function needsConfirmation(
computed: PlanComputed,
phaseId: string,
elementId: string
): boolean {
const ph = computed.phases.find((p) => p.id === phaseId);
const ce = ph?.elements.find((e) => e.elementId === elementId);
return !!ce && ce.status === "ACTIVE" && CATEGORIES_WITH_ASSUMPTIONS.has(ce.category);
}
// Ist der Phasenwert dieses Elements bestaetigt?
export function isCellConfirmed(plan: PlanInput, phaseId: string, elementId: string): boolean {
return plan.elements.find((e) => e.id === elementId)?.phaseValues[phaseId]?.confirmed === true;
}
// Unbestaetigte Zellen einer Phase.
export function unconfirmedCells(plan: PlanInput, computed: PlanComputed, phaseId: string): string[] {
return plan.elements
.filter((e) => needsConfirmation(computed, phaseId, e.id) && !isCellConfirmed(plan, phaseId, e.id))
.map((e) => e.id);
}
export function reviewPlan(plan: PlanInput, computed: PlanComputed): PlanReview {
if (plan.elements.length === 0) return { stage: "NO_ELEMENTS", total: 0, groups: [] };
if (computed.phases.length === 0) return { stage: "NO_PHASES", total: 0, groups: [] };
const groups: ReviewGroup[] = [];
computed.phases.forEach((ph, i) => {
const reasons: string[] = [];
let open = 0;
// 1. Annahmen je Element -- Renditen, Lohnentwicklung, Teuerung, Zins.
const cells = unconfirmedCells(plan, computed, ph.id);
if (cells.length > 0) {
open += cells.length;
reasons.push(`${cells.length} ${cells.length === 1 ? "Annahme" : "Annahmen"} nicht bestätigt`);
}
// 2. Die Spar- bzw. Verzehrquote. Zaehlt eigens: Man kann jede Zelle angeschaut haben und
// die Verteilung trotzdem nie getroffen haben -- dann bleibt alles still auf dem Cash.
const phase = plan.phases.find((p) => p.id === ph.id);
if (phase && phase.ratesConfirmed !== true) {
open += 1;
reasons.push(ph.isConsumption ? "Bezüge nicht verteilt" : "Sparquote nicht verteilt");
}
if (open > 0) groups.push({ id: ph.id, kind: "phase", title: ph.name, open, reasons });
// 3. Der Uebergang NACH dieser Phase.
const next = computed.phases[i + 1];
if (!next) return;
const counts: DecisionCounts = openTransitionCount(plan, computed, ph, next);
const tOpen = counts.open + counts.unconfirmed;
if (tOpen > 0) {
const r: string[] = [];
if (counts.open > 0) r.push(`${counts.open} ${counts.open === 1 ? "Entscheid" : "Entscheide"} offen`);
if (counts.unconfirmed > 0)
r.push(`${counts.unconfirmed} ${counts.unconfirmed === 1 ? "Vorgabe" : "Vorgaben"} ungeprüft`);
groups.push({
id: ph.id,
kind: "transition",
title: `Übergang nach ${next.name}`,
open: tOpen,
reasons: r,
});
}
});
const total = groups.reduce((n, g) => n + g.open, 0);
return { stage: total === 0 ? "DONE" : "OPEN", total, groups };
}
+62 -2
View File
@@ -19,6 +19,7 @@ import { num } from "@/lib/elements";
import type { ElementCategory, PhaseData } from "@/lib/elements"; import type { ElementCategory, PhaseData } from "@/lib/elements";
import type { ElementInput, PersonRole, PlanInput } from "@/lib/types"; import type { ElementInput, PersonRole, PlanInput } from "@/lib/types";
import type { ResolvedActuals } from "@/lib/actuals"; import type { ResolvedActuals } from "@/lib/actuals";
import type { RetirementDecision } from "@/lib/retirement-decision";
import { retirementBoundaries, shiftRetirement } from "@/lib/retirement"; import { retirementBoundaries, shiftRetirement } from "@/lib/retirement";
export type DriverId = export type DriverId =
@@ -30,7 +31,9 @@ export type DriverId =
| "lifespan" | "lifespan"
| "propertyGrowth" | "propertyGrowth"
| "retirementA" | "retirementA"
| "retirementB"; | "retirementB"
| "pkCapitalShare"
| "ahvDrawMonths";
// Die Einheit bestimmt, WAS der eingegebene Wert bedeutet -- das ist je Treiber verschieden // Die Einheit bestimmt, WAS der eingegebene Wert bedeutet -- das ist je Treiber verschieden
// und lässt sich nicht vereinheitlichen, ohne fachlich falsch zu werden: // und lässt sich nicht vereinheitlichen, ohne fachlich falsch zu werden:
@@ -39,7 +42,7 @@ export type DriverId =
// absoluter Wert würde die PK auf ETF-Rendite plätten) // absoluter Wert würde die PK auf ETF-Rendite plätten)
// rel_pct relative Abweichung in Prozent (die Elemente haben je eigene Beträge) // rel_pct relative Abweichung in Prozent (die Elemente haben je eigene Beträge)
// delta_years Verschiebung in Jahren // delta_years Verschiebung in Jahren
export type DriverUnit = "abs_pct" | "delta_pp" | "rel_pct" | "delta_years"; export type DriverUnit = "abs_pct" | "delta_pp" | "rel_pct" | "delta_years" | "delta_months";
export interface DriverDef { export interface DriverDef {
id: DriverId; id: DriverId;
@@ -150,6 +153,28 @@ export const DRIVERS: DriverDef[] = [
"Verschiebt das Pensionsalter der Person B. Sinnvolle Bandbreite: 3 bis +3 Jahre. Der Spielraum endet dort, wo eine angrenzende Lebensphase verschwinden würde darüber hinaus eingegebene Jahre werden auf das Mögliche gekürzt.", "Verschiebt das Pensionsalter der Person B. Sinnvolle Bandbreite: 3 bis +3 Jahre. Der Spielraum endet dort, wo eine angrenzende Lebensphase verschwinden würde darüber hinaus eingegebene Jahre werden auf das Mögliche gekürzt.",
applies: (p) => retirementDriverApplies(p, "PERSON_B"), applies: (p) => retirementDriverApplies(p, "PERSON_B"),
}, },
// Pensionierungs-Entscheide als Treiber (0.34). Sie waren bisher gar nicht abbildbar: Der
// Bezugs-Entscheid hing an einer Phasengrenze, und der Tornado verschiebt Grenzen -- die
// Entscheide wären mitgewandert oder verloren gegangen. Seit sie am Element haengen, laesst
// sich beides sauber trennen.
{
id: "pkCapitalShare",
label: "PK: Anteil Kapitalbezug",
shortLabel: "PK Kapitalanteil",
unit: "abs_pct",
help:
"Absoluter Anteil des PK-Guthabens, der als Kapital bezogen wird (0 % = volle Rente, 100 % = volles Kapital). Sinnvolle Bandbreite: 0 bis 100 %. Die Rente ist ein sicherer, lebenslanger Fluss, das Kapital ist marktabhängig und vererbbar das ist der eigentliche Zielkonflikt dieser Frage.",
applies: (p) => hasCategory(p, ["PENSION_FUND"]),
},
{
id: "ahvDrawMonths",
label: "AHV: Vorbezug / Aufschub (Monate)",
shortLabel: "AHV-Bezug",
unit: "delta_months",
help:
"Verschiebung des AHV-Rentenbeginns in MONATEN gegenüber dem Referenzalter: negativ = Vorbezug (kürzt 6,8 % pro Jahr, lebenslang), positiv = Aufschub (erhöht bis +31,5 % nach 5 Jahren). Sinnvolle Bandbreite: 36 bis +60 Monate.",
applies: (p) => hasCategory(p, ["AHV"]),
},
{ {
id: "propertyGrowth", id: "propertyGrowth",
label: "Wertsteigerung der Immobilie", label: "Wertsteigerung der Immobilie",
@@ -167,6 +192,7 @@ export const UNIT_SUFFIX: Record<DriverUnit, string> = {
delta_pp: "pp", delta_pp: "pp",
rel_pct: "%", rel_pct: "%",
delta_years: "J.", delta_years: "J.",
delta_months: "Mt.",
}; };
export function driverById(id: DriverId): DriverDef { export function driverById(id: DriverId): DriverDef {
@@ -196,6 +222,22 @@ function mapElements(
}; };
} }
// Wie `mapElements`, nur auf dem Pensionierungs-Entscheid statt auf den Phasenwerten.
function mapRetirement(
plan: PlanInput,
categories: ElementCategory[],
fn: (rd: RetirementDecision) => Partial<RetirementDecision>
): PlanInput {
return {
...plan,
elements: plan.elements.map((e) =>
categories.includes(e.category)
? { ...e, retirementDecision: { ...(e.retirementDecision ?? {}), ...fn(e.retirementDecision ?? {}) } }
: e
),
};
}
export function applyDriver(plan: PlanInput, id: DriverId, value: number): PlanInput { export function applyDriver(plan: PlanInput, id: DriverId, value: number): PlanInput {
switch (id) { switch (id) {
case "inflation": case "inflation":
@@ -215,6 +257,24 @@ export function applyDriver(plan: PlanInput, id: DriverId, value: number): PlanI
valueGrowth: num(pd.valueGrowth) + value, valueGrowth: num(pd.valueGrowth) + value,
})); }));
case "pkCapitalShare":
// Absolutwert, keine Verschiebung: Die Frage lautet "wie viel Kapital?", nicht
// "wie viel mehr als geplant?".
return mapRetirement(plan, ["PENSION_FUND"], () => ({
capitalSharePct: Math.max(0, Math.min(100, value)),
}));
case "ahvDrawMonths": {
// Ein einziger Regler für beide Richtungen: negativ = Vorbezug, positiv = Aufschub.
// Zwei getrennte Treiber hätten sich gegenseitig ausgeschlossen und den Tornado mit
// einer Zeile belastet, die je nach Vorzeichen der anderen wirkungslos ist.
const months = Math.round(value);
return mapRetirement(plan, ["AHV"], () => ({
ahvDraw: months === 0 ? "REFERENCE" : months < 0 ? "EARLY" : "DEFERRED",
ahvMonths: Math.abs(months),
}));
}
case "expenses": case "expenses":
case "income": { case "income": {
// Relative Skalierung des Basisbetrags. Bewusst nur dort, wo `amount` gesetzt ist: // Relative Skalierung des Basisbetrags. Bewusst nur dort, wo `amount` gesetzt ist:
+9
View File
@@ -4,6 +4,7 @@
// V3-Rework: Das Grundprofil (Haushaltsform, Personen, Inflation) liegt neu direkt am Plan. // V3-Rework: Das Grundprofil (Haushaltsform, Personen, Inflation) liegt neu direkt am Plan.
import type { CashTransitionData, ElementCategory, OwnerRole, PhaseData, TransitionData } from "@/lib/elements"; import type { CashTransitionData, ElementCategory, OwnerRole, PhaseData, TransitionData } from "@/lib/elements";
import type { RetirementDecision } from "@/lib/retirement-decision";
export type HouseholdType = "SINGLE" | "COUPLE"; export type HouseholdType = "SINGLE" | "COUPLE";
export type PersonRole = "PERSON_A" | "PERSON_B"; export type PersonRole = "PERSON_A" | "PERSON_B";
@@ -23,6 +24,8 @@ export interface PhaseInput {
durationYears: number; durationYears: number;
// Cash-Entscheid beim Übergang NACH dieser Phase (einmalige Sonderein-/ausgaben). // Cash-Entscheid beim Übergang NACH dieser Phase (einmalige Sonderein-/ausgaben).
cashTransition: CashTransitionData; cashTransition: CashTransitionData;
// Wurde die Spar-/Verzehrquote dieser Phase einmal bewusst verteilt?
ratesConfirmed?: boolean;
// Gegenstück im Eltern-Szenario (Diff-Grundlage); null im Basisszenario. // Gegenstück im Eltern-Szenario (Diff-Grundlage); null im Basisszenario.
sourcePhaseId?: string | null; sourcePhaseId?: string | null;
} }
@@ -36,6 +39,12 @@ export interface ElementInput {
// Werte je Phase (Key = phaseId) bzw. je Übergang (Key = fromPhaseId). // Werte je Phase (Key = phaseId) bzw. je Übergang (Key = fromPhaseId).
phaseValues: Record<string, PhaseData>; phaseValues: Record<string, PhaseData>;
transitionValues: Record<string, TransitionData>; transitionValues: Record<string, TransitionData>;
// Pensionierungs-Entscheid (nur AHV, PENSION_FUND, PILLAR_3A). Bewusst OHNE Phasenbezug --
// er gilt für die Pensionierung des Besitzers, wo immer die gerade liegt.
retirementDecision?: RetirementDecision | null;
// Stammdaten: Bestand bei Planbeginn und Ausgangs-Annahmen. Existieren, BEVOR es Phasen
// gibt, und sind zugleich die Wurzel der Feld-Vererbung (Phase 1 erbt von hier).
baseData?: PhaseData | null;
// Gegenstück im Eltern-Szenario (Diff-Grundlage); null im Basisszenario. // Gegenstück im Eltern-Szenario (Diff-Grundlage); null im Basisszenario.
sourceElementId?: string | null; sourceElementId?: string | null;
} }
+7 -1
View File
@@ -177,7 +177,7 @@ 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 } },
@@ -227,6 +227,12 @@ 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,
// Stammdaten gehoeren zum Inhalt -- ohne sie verloere eine Wiederherstellung alle
// Bestaende bei Planbeginn.
baseData: (el.baseData ?? undefined) as Prisma.InputJsonValue | undefined,
sourceElementId: el.sourceElementId ?? null, sourceElementId: el.sourceElementId ?? null,
}; };
}; };