"use client"; import { useState } from "react"; import { Trash2 } from "lucide-react"; import { AlertTriangle } from "lucide-react"; import { FieldLabel, MoneyField, NumberField, SelectField, TextField } from "@/components/FormField"; import { formatChf } from "@/lib/format"; import { api } from "@/lib/api-client"; 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 } from "@/lib/elements"; import { DEFAULT_CAPITAL_TAX_RATE, DEFAULT_PK_CONVERSION_RATE, DEFAULT_PROPERTY_GAINS_TAX_RATE, PILLAR_3A_MAX_ANNUAL, } 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; } 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; } // 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)}
); } // 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))) })} /> )} ); } export function withTransitionDefaults(category: ElementCategory, isRetirement: boolean, td: TransitionData): TransitionData { const out = { ...td }; if (category === "AHV") { // Ein Speichern der Prüfung markiert sie als erledigt (gleiches Muster wie bei PK/3a). if (isRetirement && out.reviewed === undefined) out.reviewed = true; } else if (category === "REAL_ESTATE" || category === "OTHER_ASSET") { if (out.decision === undefined) out.decision = "HOLD"; } else if (category === "PENSION_FUND") { if (isRetirement) { if (out.payoutMode === undefined) out.payoutMode = "PENSION"; } else if (out.withdrawalMode === undefined) { out.withdrawalMode = "NONE"; } } else if (category === "PILLAR_3A") { if (!isRetirement && out.withdrawalMode === undefined) out.withdrawalMode = "NONE"; } return out; } // --- Cash-Übergang: einmalige Sonderein-/ausgaben --- export function withCashTransitionDefaults(ct: CashTransitionData): CashTransitionData { return ct.mode === undefined ? { ...ct, mode: "NONE" } : { ...ct }; } export function isCashTransitionAnswered(ct: CashTransitionData): boolean { return ct.mode !== undefined; } // Kurzfassung für die Übergangszelle der Cash-Zeile. export function cashTransitionSummary(ct: CashTransitionData): string { const mode = ct.mode; if (mode === undefined) return "?"; const inn = `+${formatChf(num(ct.inflowAmount))}`; const out = `−${formatChf(num(ct.outflowAmount))}`; switch (mode) { case "INFLOW": return inn; case "OUTFLOW": return out; case "BOTH": return `${inn} / ${out}`; default: return "1:1"; } } // 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 })} /> )} ); } // "Beantwortet" = ein konkreter Entscheid liegt vor (kein offenes Fragezeichen). export function isTransitionAnswered(category: ElementCategory, isRetirement: boolean, td: TransitionData): boolean { switch (category) { case "AHV": // Nur bei der Pensionierung ist eine Prüfung der Beitragskarriere nötig. return isRetirement ? td.reviewed === true : true; case "REAL_ESTATE": case "OTHER_ASSET": return td.decision !== undefined; case "PENSION_FUND": return isRetirement ? td.payoutMode !== undefined : td.withdrawalMode !== undefined; case "PILLAR_3A": return isRetirement ? true : td.withdrawalMode !== undefined; default: return true; } } // --- 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; 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 = typeof pd.amount === "number" ? pd.amount : carried ? context.derivedStart : 0; const rate = num(pd.teuerungsausgleich, 0); 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."}

setP({ amount: v })} /> setP({ teuerungsausgleich: v })} /> ); } case "AHV": 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.

); } return ( setP({ gapYears: Math.max(0, Math.min(context.durationYears, Math.round(v))) })} /> ); case "PENSION_FUND": if (!context.ownerWorking) { return (

Die PK-Rente wird aus dem beim Pensions-Übergang gewählten Umwandlungssatz berechnet (siehe Kennzahl). Bei reinem Kapitalbezug erscheint hier "Vollständig bezogen".

); } return ( <> {carried ? ( <> setP({ additionalInvestment: v })} /> ) : ( setP({ currentValue: v })} /> )} setP({ annualContribution: v })} /> setP({ expectedReturn: v })} /> ); case "PILLAR_3A": if (!context.ownerWorking) { return

Die Säule 3a wird beim Pensions-Übergang vollständig bezogen.

; } return ( <> {carried ? ( <> setP({ additionalInvestment: v })} /> ) : ( setP({ currentValue: v })} /> )} setP({ annualContribution: v })} /> setP({ expectedReturn: v })} /> ); 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 : num(pd.mortgage); const hypEnde = Math.max(0, hypStart - num(pd.amortization) * 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 ? ( <> ) : ( <> setP({ purchasePrice: v })} /> setP({ mortgage: v })} /> )} setP({ amortization: v })} /> setP({ interestRate: v })} />
{formatChf(zinsStart)} {formatChf(zinsEnde)}
setP({ valueGrowth: v })} />
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 ? ( <> setP({ additionalInvestment: v })} /> ) : ( setP({ startValue: v })} /> )} setP({ expectedReturn: v })} /> setP({ annualContribution: v })} /> setP({ annualWithdrawal: v })} /> ); case "OTHER_DEBT": return ( <> {carried ? ( ) : ( setP({ startValue: v })} /> )} setP({ annualRepayment: v })} /> ); } } export function ElementTransitionFields({ element, context, td, setT, }: { element: { category: ElementCategory }; context: CellContext; td: TransitionData; setT: (patch: Partial) => void; }) { 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": if (context.isRetirementTransition && context.ahvCareer) { return ; } return (

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

); case "PENSION_FUND": if (context.isRetirementTransition) { const mode = td.payoutMode ?? "PENSION"; return ( <> setT({ payoutMode: v })} options={[ { value: "PENSION", label: "Rente" }, { value: "CAPITAL", label: "Kapitalbezug" }, { value: "COMBI", label: "Kombination" }, ]} /> {(mode === "PENSION" || mode === "COMBI") && ( setT({ conversionRate: v })} /> )} {(mode === "CAPITAL" || mode === "COMBI") && ( setT({ capitalTaxRate: v })} /> )} {mode === "COMBI" && ( setT({ capitalAmount: v })} /> )} ); } return ; case "PILLAR_3A": if (context.isRetirementTransition) { return ( setT({ capitalTaxRate: v })} /> ); } 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 })} /> )} {decision !== "SELL" && context.mortgageEnd > 0 && ( setT({ extraAmortization: v })} /> )} ); } 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": return ( setT({ immediateRepayment: v })} /> ); } } export function ElementDetail({ element, context, phaseData, transitionData, allPhases = [], phaseDataByPhase = {}, onSaved, onDeleteElement, }: 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 { const payload: PhaseData = { ...pd }; // 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 (
{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}

}
); }