"use client"; import { useState } from "react"; import { Trash2 } from "lucide-react"; import { AlertTriangle } from "lucide-react"; import { FieldLabel, InheritableField, MoneyField, NumberField, SelectField, TextField } from "@/components/FormField"; import { formatChf } from "@/lib/format"; import { api } from "@/lib/api-client"; // Die reinen Uebergangs-Regeln liegen in lib/transitions.ts (auch serverseitig nutzbar) und // werden hier nur weitergereicht, damit bestehende Importe unveraendert bleiben. export { withTransitionDefaults, isTransitionAnswered, withCashTransitionDefaults, isCashTransitionAnswered, cashTransitionSummary, } from "@/lib/transitions"; import { buildRateWrites, changedRateFields, scopeQuestion, SCOPE_OPTIONS, type PhaseRef, type RateScope, } from "@/lib/ratefields"; import { ahvAnnualPension, ahvMdje, type AhvCareer } from "@/lib/calculations"; import { CATEGORY_LABELS, num, type InheritableKey } from "@/lib/elements"; import { AHV_REFERENCE_AGE, DEFAULT_CAPITAL_TAX_RATE, DEFAULT_PROPERTY_GAINS_TAX_RATE, PILLAR_3A_MAX_ANNUAL, PILLAR_3A_MAX_SELF_EMPLOYED, } from "@/lib/constants"; import type { CashTransitionData, CashTransitionMode, ElementCategory, PhaseData, TransitionData, } from "@/lib/elements"; export interface CellContext { kind: "phase" | "transition"; phaseId: string; // bei transition: die fromPhaseId ownerWorking: boolean; isConsumption: boolean; durationYears: number; isRetirementTransition: boolean; carriedEndValue: number; // Endwert des Elements in der (Vor-)Phase, für Bezugs-Maxima carried: boolean; // Phase >= 2: Basiswert wird aus der Vorphase fortgeschrieben derivedStart: number; // fortgeschriebener Basiswert (read-only Anzeige) derivedMortgage: number; // nur Immobilie: fortgeschriebene Resthypothek zu Phasenbeginn mortgageEnd: number; // nur Immobilie: Resthypothek am Phasenende (für die Sonderamortisation) // nur Immobilie: modellierter VERKEHRSWERT am Phasenende (Eigenkapital + Resthypothek), // inkl. aufgelaufener Wertsteigerung. Vorbelegung und Vergleichswert für den Verkaufspreis. propertyValueEnd: number; deflatorStart: number; // Kaufkraft-Deflator zu Phasenbeginn (real <-> nominal, erstes Jahr) // Warnhinweis: Anzahl Phasen NACH dieser (Änderungen schreiben sich dorthin fort). laterPhaseCount: number; // AHV-Beitragskarriere des Element-Besitzers (für die Prüfung am Pensions-Übergang). ahvCareer: AhvCareer | null; // Werte, die in dieser Phase gelten würden, wenn das jeweilige Feld leer bleibt // (Roadmap Nr. 44, Punkt A). Leer in der ersten Phase. inheritedValues: Record; // Alter des Element-Besitzers im ersten Jahr dieser Phase. Entscheidet bei der AHV, ob // das Referenzalter in diese Phase fällt (Kap. 4.4.6). ownerAgeStart: number; // Mögliche Ziele der Anlage-Quote beim Kapitalbezug (Punkt C): die in der FOLGEphase noch // aktiven Vermögens-Elemente. investTargets: { id: string; name: string }[]; // Betrag, der aus einem Kapitalbezug in DIESES Element umgeleitet wurde (Punkt C). capitalFromTransfer: 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 { element: { id: string; category: ElementCategory; name: string; ownerRole: string | null }; context: CellContext; phaseData: PhaseData; transitionData: TransitionData; // Alle Phasen des Szenarios und die dort bereits erfassten Werte -- Grundlage für die // Übernahme einer geänderten Rate auf weitere Phasen (siehe SPEZIFIKATION 3.6.11). allPhases?: PhaseRef[]; phaseDataByPhase?: Record; onSaved: () => 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 // fortgeschrieben (Endwert = Startwert der Folgephase) -- eine Änderung hier wirkt also bis // ans Planende durch, was ohne Hinweis leicht übersehen wird. export function CarryWarning({ laterPhaseCount }: { laterPhaseCount: number }) { if (laterPhaseCount <= 0) return null; const phasen = laterPhaseCount === 1 ? "die nachfolgende Lebensphase" : `die ${laterPhaseCount} nachfolgenden Lebensphasen`; return (
Achtung: Diese Lebensphase ist nicht die letzte. Werte werden fortgeschrieben (Endwert = Startwert der Folgephase). Eine Änderung hier wirkt sich auf {phasen} aus und verändert deren Startwerte, Kennzahlen sowie Endvermögen, Cash-Verlauf und Ruinalter.
); } // Read-only Anzeige eines abgeleiteten (fortgeschriebenen) Wertes. function DerivedField({ label, value, help }: { label: string; value: number; help?: string }) { return (
{formatChf(value)}
); } // 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 (
{formatChf(value)} {suffix ? {suffix} : null} {onAction && ( )}
); } // PK/3a-Bezugs-Entscheid im normalen Übergang (Vorbezug): Kein Bezug / Bezug (+ Betrag + // Kapitalbezugssteuer). Der Bezugsbetrag wird brutto dem Kapital entnommen; ins Cash fliesst // der Betrag nach Abzug der Steuer. function WithdrawalDecision({ td, setT, max, label, }: { td: TransitionData; setT: (patch: Partial) => void; max: number; label: string; }) { const mode = td.withdrawalMode ?? "NONE"; const gross = num(td.withdrawal); const taxRate = num(td.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE); return ( <> setT(v === "NONE" ? { withdrawalMode: v, withdrawal: 0 } : { withdrawalMode: v })} options={[ { value: "NONE", label: "Kein Bezug" }, { value: "AMOUNT", label: "Bezug" }, ]} /> {mode === "AMOUNT" && ( <> setT({ withdrawal: v })} /> setT({ capitalTaxRate: v })} /> )} ); } // Vorbelegung expliziter Entscheide, damit ein blosses "Speichern" den sichtbaren Default // (Halten / Kein Bezug / Rente) auch tatsächlich persistiert. // AHV-Prüfung am Pensions-Übergang: geplantes Durchschnittseinkommen aus dem Plan plus -- // nur falls der Plan nicht bis zum Beitragsbeginn (Alter 21) zurückreicht -- die Jahre davor. export function AhvReviewFields({ career, td, setT, }: { career: AhvCareer; td: TransitionData; setT: (patch: Partial) => void; }) { const brauchtVorgeschichte = career.yearsBeforePlan > 0; // Vorbelegung mit dem geplanten Durchschnitt: besser als 0, wenn der Nutzer nichts weiss. const avgBefore = typeof td.avgIncomeBefore === "number" ? td.avgIncomeBefore : Math.round(career.plannedAvgGrossIncome); const gapBefore = Math.max(0, Math.round(num(td.gapYearsBefore))); const mdJE = ahvMdje(career, brauchtVorgeschichte ? avgBefore : 0, gapBefore); const rente = ahvAnnualPension(mdJE, career.gapYearsInPlan + gapBefore); return ( <>

Die AHV-Rente hängt vom massgebenden durchschnittlichen Jahreseinkommen{" "} über die ganze Beitragsdauer (ab Alter 21) ab. Massgebend ist der Bruttolohn; das Tool rechnet die im Plan erfassten Nettöinkommen dafür automatisch hoch. Alle Beträge sind REAL (heutige Kaufkraft) – die AHV wertet vergangene Einkommen auf und indexiert die Schwellen, was sich real weitgehend aufhebt.

{brauchtVorgeschichte && ( <>

Der Plan beginnt erst im Alter {career.yearsBeforePlan + 21}. Die {career.yearsBeforePlan} Beitragsjahre davor kennt das Tool nicht – bitte ergänzen.

setT({ avgIncomeBefore: v })} /> setT({ gapYearsBefore: Math.max(0, Math.min(career.yearsBeforePlan, Math.round(v))) })} /> )} ); } // Eingabefelder für den Cash-Entscheid. Erfassungs-Konventionen bewusst wie bei den // laufenden Flows: Zufluss nominal (wie Einkommen), Kosten real (wie Ausgaben). export function CashTransitionFields({ ct, setC, deflatorEnd, }: { ct: CashTransitionData; setC: (patch: Partial) => void; deflatorEnd: number; // Bestands-Deflator an der Phasengrenze }) { const mode = ct.mode ?? "NONE"; const showIn = mode === "INFLOW" || mode === "BOTH"; const showOut = mode === "OUTFLOW" || mode === "BOTH"; const d = deflatorEnd || 1; const inflowGross = num(ct.inflowAmount); const taxRate = num(ct.inflowTaxRate, 0); const inflowNet = Math.round(inflowGross * (1 - taxRate / 100)); return ( <>
setC( v === "NONE" ? { mode: v, inflowAmount: 0, outflowAmount: 0 } : v === "INFLOW" ? { mode: v, outflowAmount: 0 } : v === "OUTFLOW" ? { mode: v, inflowAmount: 0 } : { mode: v } ) } options={[ { value: "NONE", label: "1:1 übernehmen" }, { value: "INFLOW", label: "Einmaliger Zufluss" }, { value: "OUTFLOW", label: "Einmalige Kosten" }, { value: "BOTH", label: "Zufluss und Kosten" }, ]} />
{showIn && ( <>
Einmaliger Zufluss (z. B. Erbschaft). Wird NOMINAL erfasst – der Betrag, der zu diesem Zeitpunkt tatsächlich aufs Konto kommt.
setC({ inflowLabel: v })} /> setC({ inflowAmount: v })} /> setC({ inflowTaxRate: v })} /> {taxRate > 0 && ( )} )} {showOut && ( <>
Einmalige Kosten (z. B. Poolbau). Werden REAL erfasst – in heutiger Kaufkraft. Die Inflation rechnet daraus automatisch den nominalen Betrag.
setC({ outflowLabel: v })} /> setC({ outflowAmount: v })} /> )} ); } // Verwendung des BEZOGENEN ALTERSKAPITALS (Roadmap Nr. 44, Punkt C). // // Steht am Bezugs-Entscheid des jeweiligen Guthabens (PK bzw. Säule 3a) -- bis 0.32 hing die // Frage am Cash-Übergang, wo sie sich mit zwei Guthaben gar nicht getrennt beantworten liess. // // In PROZENT und nicht in Franken: Verschiebt man das Pensionsalter, ändert sich das bezogene // Kapital. Ein Frankenbetrag müsste dann von Hand nachgezogen werden -- und würde bis dahin // still eine falsche Aufteilung rechnen. Eine Quote skaliert mit. export function CapitalUseFields({ td, setT, netAmount, targets, }: { td: TransitionData; setT: (patch: Partial) => void; // Netto-Auszahlung, auf die sich die Prozente beziehen. netAmount: number; targets: { id: string; name: string }[]; }) { const amort = Math.max(0, Math.min(100, num(td.capitalUseAmortizationPct))); const invest = Math.max(0, Math.min(100 - amort, num(td.capitalUseInvestPct))); const cash = Math.max(0, 100 - amort - invest); return ( <>
Wohin fliesst das bezogene Kapital? Von den{" "} {formatChf(netAmount)} netto teilst du hier in Prozent zu – so bleibt die Aufteilung richtig, wenn sich der Betrag durch ein anderes Pensionsalter ändert. Was nicht zugeteilt ist, bleibt auf dem Cash-Konto.
setT({ capitalUseAmortizationPct: Math.max(0, Math.min(100, v)) })} /> setT({ capitalUseInvestPct: Math.max(0, Math.min(100 - amort, v)) })} /> {invest > 0 && targets.length > 0 && (
setT({ capitalUseTargetElementId: v })} options={targets.map((t) => ({ value: t.id, label: t.name }))} />
)} {invest > 0 && ( )} ); } // --- Wiederverwendbare Feldgruppen (Detail-Panel, Erstell-Popup, Übergangs-Review) --- export function ElementPhaseFields({ element, context, pd, setP, }: { element: { category: ElementCategory }; context: CellContext; pd: PhaseData; setP: (patch: Partial) => void; }) { const carried = context.carried; // Punkt A (Roadmap Nr. 44): Wiederkehr-Parameter werden aus der Vorphase übernommen, // solange das Feld leer bleibt. `inh` baut die Hülle mit dem sichtbaren Häkchen. const inh = ( key: InheritableKey, label: string, render: (value: number, set: (v: number) => void) => React.ReactNode, format: (v: number) => string, help?: string ) => { const own = pd[key]; const fallback = context.inheritedValues[key] ?? 0; const isInheriting = carried && typeof own !== "number"; return ( setP({ [key]: inherit ? undefined : fallback } as Partial)} > {render(typeof own === "number" ? own : fallback, (v) => setP({ [key]: v } as Partial))} ); }; const asPct = (v: number) => `${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) => ( ); // 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) => ( ); // 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) => ( ); switch (element.category) { case "INCOME": case "EXPENSE": { const isIncome = element.category === "INCOME"; // Basiswert (erstes Jahr). Ab Phase 2 mit dem fortgeschriebenen Wert der Vorphase // vorbelegt, aber bewusst änderbar (Teilzeit, Beförderung, Jobwechsel …). 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; // Info-Gegenwert im ersten Jahr: Einkommen -> real; Ausgaben -> nominal. const otherValue = isIncome ? Math.round(baseValue / d) : Math.round(baseValue * d); return ( <>

{isIncome ? "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."}

{carried ? ( // Ab Phase 2 bewusst änderbar: Teilzeit, Beförderung, Jobwechsel, anderer // Lebensstandard -- das sind echte Entscheide dieser Phase. 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»." ) )} {inh( "teuerungsausgleich", isIncome ? "Nominale Lohnerhöhung (%/Jahr)" : "Reale Mehrausgaben (%/Jahr)", (v, set) => ( ), asPct, isIncome ? "Geschätzte jährliche Lohnerhöhung. 0% = Lohn bleibt nominal gleich und verliert real an Kaufkraft." : "Zusätzliche reale Mehrausgaben pro Jahr (z. B. weil man sich mehr gönnt). Die Inflation kommt separat automatisch dazu. 0% = gleicher Lebensstandard." )} ); } case "AHV": { // Seit Roadmap Nr. 44 beginnt die AHV-Rente IMMER mit dem Referenzalter -- unabhängig // davon, wann die Person aufhört zu arbeiten (Kap. 4.4.6). Daraus folgen zwei Fälle, // die es vorher nicht gab und die hier sichtbar gemacht werden müssen: // * Pensionierung VOR 65 -> beitragspflichtig als Nichterwerbstätige(r) bis 65. // * Erwerbstätig ÜBER 65 -> die Rente fliesst trotzdem ab 65. const ageStart = context.ownerAgeStart; const ageEnd = ageStart + context.durationYears - 1; const reachesReference = ageEnd >= AHV_REFERENCE_AGE; const startsBeforeReference = ageStart < AHV_REFERENCE_AGE; if (!context.ownerWorking) { // Sonderfall: bei Planbeginn bereits pensioniert -> es gibt keinen Pensions-Übergang, // an dem die Karriere geprüft werden könnte. Dann hier erfassen (nur erste Phase). if (!carried && context.ahvCareer && context.ahvCareer.planYears === 0) { return ( <>

Diese Person ist bei Planbeginn bereits pensioniert. Die AHV-Rente hängt vom massgebenden durchschnittlichen Jahreseinkommen über die ganze Beitragsdauer ab – bitte hier erfassen (REAL, heutige Kaufkraft).

setP({ avgIncomeBefore: v })} /> setP({ gapYearsBefore: Math.max(0, Math.round(v)) })} /> ); } return ( <>

Die AHV-Rente wird aus der beim Pensions-Übergang geprüften Beitragskarriere berechnet (massgebendes Durchschnittseinkommen und Ausfalljahre). Bei Ehepaaren greift die Plafonierung auf 150% der Maximalrente. Sie fliesst ab Alter {AHV_REFERENCE_AGE} {reachesReference && startsBeforeReference ? ` – also erst im Verlauf dieser Lebensphase (Alter ${ageStart} bis ${ageEnd}).` : "."}

{startsBeforeReference && ( <>

Frühpensionierung: Wer vor Alter {AHV_REFERENCE_AGE} aufhört zu arbeiten, bleibt bis dahin AHV-beitragspflichtig – als Nichterwerbstätige(r). Der Beitrag ist eine laufende Ausgabe und fällt mit {AHV_REFERENCE_AGE} weg, wenn die Rente einsetzt.

setP({ ahvContribution: v })} /> )} ); } return ( <> setP({ gapYears: Math.max(0, Math.min(context.durationYears, Math.round(v))) })} /> {reachesReference && (

Weiterarbeiten über {AHV_REFERENCE_AGE} hinaus: Die AHV-Rente beginnt trotzdem mit {AHV_REFERENCE_AGE} und fliesst neben dem Lohn. Ein Aufschub der Rente wird nicht abgebildet. Erfasse deshalb nur die Ausfalljahre bis zum Referenzalter.

)} ); } case "PENSION_FUND": if (!context.ownerWorking) { // Zustand statt Regel: entweder läuft eine Rente, oder das Guthaben ist bezogen. const hasPension = context.computedValue > 0; return (
{hasPension ? ( <> Rente: {formatChf(context.computedValue)} pro Jahr. 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. ) : ( <> Vollständig bezogen. Das Guthaben wurde beim Übergang in die Pension als Kapital ausbezahlt; wohin es geflossen ist, steht dort im Bezugs-Entscheid. )}
); } return ( <> {carried ? ( <> {capital( "Zusatzeinlage aus Kapital (CHF)", num(pd.additionalInvestment), "Aufstockung aus dem verfügbaren Kapital dieser Phase." )} ) : ( start("currentValue", "Aktueller PK-Wert (CHF)") )} {rate( "annualContribution", "Jährliche Einzahlung (CHF)", "Arbeitnehmer- und Arbeitgeberbeiträge. Stammen aus dem BRUTTOLOHN und schmälern die Sparquote nicht – deshalb stehen sie im Verteil-Dialog in einem eigenen Block." )} {inh( "expectedReturn", "Erwartete Rendite (%/Jahr)", (v, set) => , asPct )} ); case "PILLAR_3A": { if (!context.ownerWorking) { return (
Vollständig bezogen. 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.
); } // «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; return ( <> {carried ? ( <> {capital( "Zusatzeinlage aus Kapital (CHF)", num(pd.additionalInvestment), "Aufstockung aus dem verfügbaren Kapital dieser Phase." )} ) : ( start("currentValue", "Aktueller 3a-Wert (CHF)") )} {rate( "annualContribution", "Jährliche Einzahlung (CHF)", `Maximal CHF ${max3a.toLocaleString("de-CH")} (2026, ${pd.selfEmployed3a ? "Selbstständig ohne PK" : "mit PK"}) und höchstens die Sparquote.` )} {inh( "expectedReturn", "Erwartete Rendite (%/Jahr)", (v, set) => , asPct )} ); } case "REAL_ESTATE": { // Zinsbetrag zu Phasenbeginn und -ende: die Restschuld sinkt mit der Amortisation, // der Zinsbetrag also mit. Am Nullpunkt gekappt (analog zur Berechnung). const hypStart = carried ? 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 zinsEnde = Math.round((hypEnde * num(pd.interestRate)) / 100); const handling = pd.interestHandling ?? "INCLUDED"; return ( <> {carried ? ( <> ) : ( <> {start("purchasePrice", "Kaufpreis (CHF)")} {start("mortgage", "Hypothek (CHF)")} )} {rate( "amortization", "Amortisation (CHF/Jahr)", "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( "interestRate", "Hypothekarzins (%/Jahr)", (v, set) => , asPct, "Zinssatz auf der Restschuld. Der Zinsbetrag sinkt automatisch mit der Amortisation." )}
{formatChf(zinsStart)} {formatChf(zinsEnde)}
{inh( "valueGrowth", "Geschätzte Wertsteigerung (%/Jahr)", (v, set) => , asPct, "Wirkt auf den Wert der LIEGENSCHAFT, nicht auf das Eigenkapital. 1% von 1 Mio sind 10'000 pro Jahr – bei 100'000 Eigenkapital also 10% darauf (Hebel)." )}
setP({ interestHandling: v })} options={[ { value: "INCLUDED", label: "Ja – bereits im Ausgaben-Element berücksichtigt" }, { value: "ADD", label: "Nein – bitte zu den Ausgaben dazuzählen" }, ]} />
); } case "OTHER_ASSET": return ( <> {carried ? ( <> {capital( "Zusatzinvestition aus Kapital (CHF)", num(pd.additionalInvestment), "Neuinvestition aus dem verfügbaren Kapital dieser Phase." )} {/* Aus einem Kapitalbezug (PK/3a) am letzten Übergang umgeleitet -- nicht hier erfasst, sondern dort als Quote entschieden (Punkt C). Kommt der Betrag aus mehreren Bezügen (PK und 3a, ggf. beider Personen), wird er einzeln aufgeschlüsselt: sonst liesse sich nicht pruefen, ob wirklich alles angekommen ist. */} {context.capitalFromTransfer > 0 && (
{formatChf(context.capitalFromTransfer)} {context.capitalFromTransferSources.length > 0 && (
{context.capitalFromTransferSources.map((src) => (
aus {src.name} {formatChf(src.amount)}
))}
)}
)} ) : ( start("startValue", "Startwert (CHF)") )} {inh( "expectedReturn", "Erwartete Rendite (%/Jahr)", (v, set) => , asPct )} {rate( "annualContribution", "Jährlicher Sparbeitrag (CHF)", "Flacher Jahresbetrag, der ins Vermögen fliesst und vom Cash abgezogen wird (geplante Sparrate)." )} {rate( "annualWithdrawal", "Jährliche Bezugsrate (CHF)", "Entnahme aus dem Vermögen (z. B. laufende Renten-Entnahme im Alter). Mindert das Vermögen und fliesst jährlich ins Cash (geplante Verzehrrate)." )} ); case "OTHER_DEBT": return ( <> {carried ? ( ) : ( start("startValue", "Restschuld (CHF)") )} {rate("annualRepayment", "Jährliche Tilgung (CHF)", "Zählt gegen die Sparquote (max. die Sparquote).")} {context.oneOffRepayment > 0 && capital( "Sofort-Tilgung aus Kapital (CHF)", context.oneOffRepayment, "Einmalige Tilgung am Übergang in diese Phase, aus dem verfügbaren Kapital." )} ); } } export function ElementTransitionFields({ element, context, td, setT, retirement, }: { element: { category: ElementCategory }; context: CellContext; td: TransitionData; setT: (patch: Partial) => void; // Der fertig gerenderte Pensionierungs-Baustein (AHV/PK/3a). Wird vom Aufrufer geliefert, // damit dieses Modul nichts über den Pensionierungs-Bildschirm wissen muss. retirement?: React.ReactNode; }) { switch (element.category) { case "INCOME": case "EXPENSE": return (

Für diese Kategorie gibt es im Übergang keine Eingaben. Die Werte werden 1:1 in die nächste Lebensphase übernommen und können dort angepasst werden.

); case "AHV": // Der Bezugs-Entscheid liegt seit 0.34 am Element und wird hier nur ANDERS ANGESCHAUT -- // es ist dieselbe Komponente wie im Pensionierungs-Bildschirm, kein Duplikat. if (context.isRetirementTransition && retirement) { return
{retirement}
; } return (

Die Beitragskarriere wird erst beim Übergang in die Pensionierung geprüft.

); case "PENSION_FUND": { if (context.isRetirementTransition && retirement) { return
{retirement}
; } return ; } case "PILLAR_3A": { if (context.isRetirementTransition && retirement) { return
{retirement}
; } return ; } case "REAL_ESTATE": { const decision = td.decision ?? "HOLD"; const marktwert = Math.round(context.propertyValueEnd); const preis = num(td.salePrice); // Abweichung zwischen erfasstem Verkaufspreis und modelliertem Verkehrswert. Beide // Grössen sind unabhängig erfassbar -- ohne diesen Vergleich könnte man 2 % // Wertsteigerung annehmen und trotzdem zum Kaufpreis verkaufen, ohne es zu merken. const abweichung = preis - marktwert; const abweichungPct = marktwert > 0 ? (abweichung / marktwert) * 100 : 0; const deutlich = marktwert > 0 && Math.abs(abweichungPct) >= 10; return ( <> // Beim Wechsel auf "Verkaufen" den Verkaufspreis mit dem modellierten // Verkehrswert vorbelegen -- aber nur, wenn noch keiner erfasst ist. setT(v === "SELL" && td.salePrice === undefined ? { decision: v, salePrice: marktwert } : { decision: v }) } options={[ { value: "HOLD", label: "Halten" }, { value: "SELL", label: "Verkaufen" }, ]} /> {decision === "SELL" && ( <> setT({ salePrice: v })} /> {marktwert > 0 && (

{Math.abs(abweichung) < 1 ? ( <>Verkaufspreis und modellierter Verkehrswert stimmen überein. ) : ( <> Der Verkaufspreis liegt {formatChf(Math.abs(abweichung))} CHF ( {abweichung > 0 ? "+" : "−"} {Math.abs(Math.round(abweichungPct * 10) / 10)} %) {abweichung > 0 ? "über" : "unter"} dem modellierten Verkehrswert. {deutlich && ( <> {" "} Das ist eine deutliche Abweichung – prüfe, ob sie gewollt ist oder ob die angenommene Wertsteigerung nicht zum erwarteten Verkaufspreis passt. )} )}

)} setT({ saleTaxRate: v })} /> )} {/* Die Sonderamortisation stand bis 0.39 hier. Sie stammt aber aus dem Kapital, das in der FOLGEphase verfügbar ist, und wird dort im Kapital-Dialog zusammen mit allen anderen Verwendungen desselben Topfs entschieden. Zwei Eingaben für denselben Franken -- hier und dort -- gab es damit nicht mehr. */} {decision !== "SELL" && context.mortgageEnd > 0 && num(td.extraAmortization) > 0 && ( )} ); } case "OTHER_ASSET": { const decision = td.decision ?? "HOLD"; return ( <> setT({ decision: v })} options={[ { value: "HOLD", label: "Halten" }, { value: "SELL", label: "Verkaufen (ganz)" }, { value: "PARTIAL", label: "Teilverkauf" }, ]} /> {decision === "PARTIAL" && ( setT({ partialSaleAmount: v })} /> )} ); } case "OTHER_DEBT": // Dito: Die Sofort-Tilgung zehrt vom Kapital der Folgephase und wird dort entschieden. return num(td.immediateRepayment) > 0 ? ( ) : (

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.

); } } export function ElementDetail({ element, context, phaseData, transitionData, allPhases = [], phaseDataByPhase = {}, onSaved, onDeleteElement, compact, }: Props) { const [pd, setPd] = useState({ ...phaseData }); const [td, setTd] = useState({ ...transitionData }); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); // Reichweite einer geänderten Rate. Vorgabe ist das bisherige Verhalten: nur diese Phase. const [scope, setScope] = useState("THIS"); const isTransition = context.kind === "transition"; // Welche Ratenfelder hat der Nutzer verändert? Nur dann wird überhaupt gefragt. const changedRates = isTransition ? [] : changedRateFields(element.category, phaseData, pd); const showScope = changedRates.length > 0 && allPhases.length > 1; async function save() { setSaving(true); setError(null); try { if (isTransition) { await api.put(`/api/elements/${element.id}/transition/${context.phaseId}`, td); } else { // 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 // 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. if ( (element.category === "INCOME" || element.category === "EXPENSE") && context.carried && typeof payload.amount === "number" && payload.amount === Math.round(context.derivedStart) ) { delete payload.amount; } await api.put(`/api/elements/${element.id}/phase/${context.phaseId}`, payload); // Geänderte Raten auf die gewählten weiteren Phasen übertragen. Die bestehenden // Werte der Zielphasen bleiben erhalten -- übernommen wird ausschliesslich die Rate. // Alle Schreibvorgänge fallen in dieselbe Bearbeitungssitzung und ergeben daher // EINE Nebenversion, nicht eine je Phase (siehe 3.8.1). for (const w of buildRateWrites( allPhases, context.phaseId, scope, changedRates, pd, phaseDataByPhase )) { await api.put(`/api/elements/${element.id}/phase/${w.phaseId}`, w.data); } } onSaved(); } catch (e) { setError(e instanceof Error ? e.message : "Speichern fehlgeschlagen."); } finally { setSaving(false); } } function setP(patch: Partial) { setPd((prev) => ({ ...prev, ...patch })); } function setT(patch: Partial) { setTd((prev) => ({ ...prev, ...patch })); } return (
{!compact && (
{CATEGORY_LABELS[element.category]} {isTransition ? " · Übergang" : ""}
{element.name}
)}
{/* Punkt 4: Warnung, dass Änderungen sich in die Folgephasen fortschreiben. */} {!isTransition && } {isTransition ? ( ) : ( )}
{/* Rückfrage zur Reichweite. Erscheint erst, wenn eine Rate tatsächlich verändert wurde -- und bewusst hier statt als Modal beim Tippen: Das Zahlenfeld löst bei jedem Tastendruck aus, ein Dialog erschiene bei «5.2» viermal. */} {showScope && (

{scopeQuestion(changedRates, allPhases.length)}

{SCOPE_OPTIONS.map((o) => ( ))}

{scope === "THIS" ? "Andere Phasen bleiben unverändert." : `Wird beim Speichern in ${ buildRateWrites(allPhases, context.phaseId, scope, changedRates, pd, phaseDataByPhase).length } weitere Phase(n) übertragen. Beträge und Raten dort bleiben erhalten – nur der geänderte Wert wird gesetzt.`}

)} {error &&

{error}

}
); }