"use client"; import { useEffect, useMemo, useState } from "react"; import { AlertCircle, Building2, CheckCircle2, ChevronDown, ChevronRight, CreditCard, Home, Landmark, PiggyBank, Plus, Settings2, ShoppingCart, TrendingUp, Wallet, X, } from "lucide-react"; import { Timeline } from "@/components/Timeline"; import { CashTransitionFields, ElementDetail, ElementPhaseFields, ElementTransitionFields, cashTransitionSummary, isCashTransitionAnswered, isTransitionAnswered, withCashTransitionDefaults, withTransitionDefaults, type CellContext, } from "@/components/ElementDetail"; import { PhaseDetail } from "@/components/PhaseDetail"; import { PlanProfileFields, type ProfileDraft } from "@/components/PlanProfileFields"; import { MoneyField } from "@/components/FormField"; import { api } from "@/lib/api-client"; import { formatChf } from "@/lib/format"; import { CATEGORY_LABELS, CATEGORY_ORDER, PERSON_ONLY_CATEGORIES, num, type CashTransitionData, type ElementCategory, type PhaseData, type TransitionData, } from "@/lib/elements"; import type { PhaseComputed, PlanComputed } from "@/lib/calculations"; import type { ElementInput, PlanInput } from "@/lib/types"; const CATEGORY_ICON: Record = { INCOME: , EXPENSE: , AHV: , PENSION_FUND: , PILLAR_3A: , REAL_ESTATE: , OTHER_ASSET: , OTHER_DEBT: , }; // Kategorien mit einem Uebergangs-Entscheid. AHV ist dabei ein Sonderfall: nur beim // Pensions-Uebergang ist die Beitragskarriere zu pruefen (siehe transitionInactive). const TRANSITION_CATEGORIES: ElementCategory[] = [ "AHV", "PENSION_FUND", "PILLAR_3A", "REAL_ESTATE", "OTHER_ASSET", "OTHER_DEBT", ]; const VALUE_CATEGORIES: ElementCategory[] = [ "PENSION_FUND", "PILLAR_3A", "REAL_ESTATE", "OTHER_ASSET", "OTHER_DEBT", ]; type Column = | { kind: "phase"; phase: PhaseComputed } | { kind: "transition"; fromPhase: PhaseComputed; toPhase: PhaseComputed }; type Selection = | { type: "phase"; phaseId: string }; export function PlanView({ plan, computed, onChanged, }: { plan: PlanInput; computed: PlanComputed; onChanged: () => void; }) { const [selected, setSelected] = useState(null); const [collapsedCats, setCollapsedCats] = useState>(new Set()); const [showAdd, setShowAdd] = useState(false); const [showAddPhase, setShowAddPhase] = useState(false); const [showSettings, setShowSettings] = useState(false); const [reviewFromPhaseId, setReviewFromPhaseId] = useState(null); const [editTransition, setEditTransition] = useState<{ elementId: string; fromPhaseId: string } | null>(null); const [editPhaseCell, setEditPhaseCell] = useState<{ elementId: string; phaseId: string } | null>(null); const [showCashInit, setShowCashInit] = useState(false); // fromPhaseId des Cash-Uebergangs, der gerade bearbeitet wird. const [editCashTransition, setEditCashTransition] = useState(null); const [valueMode, setValueMode] = useState("nominal"); useEffect(() => { const stored = typeof window !== "undefined" ? window.localStorage.getItem(VALUE_MODE_KEY) : null; // eslint-disable-next-line react-hooks/set-state-in-effect -- einmalige Uebernahme der gespeicherten Wahl if (stored === "nominal" || stored === "both" || stored === "real") setValueMode(stored); }, []); function changeValueMode(m: ValueMode) { setValueMode(m); if (typeof window !== "undefined") window.localStorage.setItem(VALUE_MODE_KEY, m); } const columns = useMemo(() => { const cols: Column[] = []; computed.phases.forEach((p, i) => { cols.push({ kind: "phase", phase: p }); if (i < computed.phases.length - 1) { cols.push({ kind: "transition", fromPhase: p, toPhase: computed.phases[i + 1] }); } }); return cols; }, [computed.phases]); // Anzeigename einer Person: eigener Name, sonst Fallback "Person A"/"Person B". function personLabel(role: string): string { const p = plan.persons.find((x) => x.role === role); if (p?.name && p.name.trim()) return p.name.trim(); return role === "PERSON_A" ? "Person A" : "Person B"; } const personAxes = plan.persons.map((p) => ({ role: p.role, label: personLabel(p.role), currentAge: p.age, retirementAge: p.retirementAge, color: p.role === "PERSON_A" ? "var(--person-a)" : "var(--person-b)", })); const elementsByCategory = useMemo(() => { const map = new Map(); for (const cat of CATEGORY_ORDER) map.set(cat, []); for (const e of [...plan.elements].sort((a, b) => a.orderIndex - b.orderIndex)) { map.get(e.category)!.push(e); } return map; }, [plan.elements]); function computedElement(phaseId: string, elementId: string) { return computed.phases.find((p) => p.id === phaseId)?.elements.find((e) => e.elementId === elementId); } function isRetirementTransition(element: ElementInput, fromPhase: PhaseComputed, toPhase: PhaseComputed): boolean { if (!element.ownerRole || element.ownerRole === "HOUSEHOLD") return false; const before = fromPhase.persons.find((p) => p.role === element.ownerRole); const after = toPhase.persons.find((p) => p.role === element.ownerRole); return !!before?.working && !!after && !after.working; } // Beitragskarriere des Element-Besitzers (nur fuer AHV relevant). function careerFor(element: ElementInput) { if (element.category !== "AHV" || !element.ownerRole || element.ownerRole === "HOUSEHOLD") return null; const person = plan.persons.find((p) => p.role === element.ownerRole); return computed.ahvCareer.find((c) => c.personId === person?.id) ?? null; } // Anzahl Phasen NACH dieser (fuer den Fortschreibungs-Warnhinweis). function laterPhaseCount(phase: PhaseComputed): number { return computed.phases.length - phase.sequenceNumber; } // Baut den Kontext fuer eine Phasenzelle. function buildPhaseContext(phase: PhaseComputed, element: ElementInput): CellContext { const ce = computedElement(phase.id, element.id); const ownerWorking = element.ownerRole && element.ownerRole !== "HOUSEHOLD" ? phase.persons.find((p) => p.role === element.ownerRole)?.working ?? false : phase.type !== "PENSION"; return { kind: "phase", phaseId: phase.id, ownerWorking, isConsumption: phase.isConsumption, durationYears: phase.durationYears, isRetirementTransition: false, carriedEndValue: ce?.endValue ?? 0, carried: ce?.carried ?? false, derivedStart: ce?.baseValue ?? 0, derivedMortgage: ce?.mortgageStart ?? 0, deflatorStart: phase.cumulativeInflationStart, laterPhaseCount: laterPhaseCount(phase), ahvCareer: careerFor(element), }; } function buildTransitionContext(fromPhase: PhaseComputed, toPhase: PhaseComputed | undefined, element: ElementInput): CellContext { const ce = computedElement(fromPhase.id, element.id); return { kind: "transition", phaseId: fromPhase.id, ownerWorking: true, isConsumption: fromPhase.isConsumption, durationYears: fromPhase.durationYears, isRetirementTransition: toPhase ? isRetirementTransition(element, fromPhase, toPhase) : false, carriedEndValue: ce?.endValue ?? 0, carried: ce?.carried ?? false, derivedStart: 0, derivedMortgage: 0, deflatorStart: fromPhase.cumulativeInflationStart, laterPhaseCount: laterPhaseCount(fromPhase), ahvCareer: careerFor(element), }; } // Am Uebergang nichts (mehr) zu tun: verkauft/getilgt ODER PK/3a nach der Pensionierung // (Besitzer ist zu Beginn der Von-Phase bereits pensioniert -> bereits bezogen/verrentet) // ODER AHV ausserhalb des Pensions-Uebergangs. function transitionInactive(el: ElementInput, fromPhase: PhaseComputed, toPhase?: PhaseComputed): boolean { const ce = computedElement(fromPhase.id, el.id); if (ce && ce.status !== "ACTIVE") return true; if (el.category === "AHV") { return !(toPhase && isRetirementTransition(el, fromPhase, toPhase)); } if (el.category === "PENSION_FUND" || el.category === "PILLAR_3A") { if (el.ownerRole && el.ownerRole !== "HOUSEHOLD") { const owner = fromPhase.persons.find((p) => p.role === el.ownerRole); if (owner && !owner.working) return true; } } return false; } function cashTransitionFor(phaseId: string): CashTransitionData { return plan.phases.find((p) => p.id === phaseId)?.cashTransition ?? {}; } // Anzahl offener (noch nicht getroffener) Uebergangs-Entscheide an einer Grenze. // Der Cash-Entscheid (einmalige Sonderein-/ausgaben) zaehlt mit. function transitionOpenCount(fromPhase: PhaseComputed, toPhase: PhaseComputed): number { let n = isCashTransitionAnswered(cashTransitionFor(fromPhase.id)) ? 0 : 1; for (const el of plan.elements) { if (!TRANSITION_CATEGORIES.includes(el.category)) continue; if (transitionInactive(el, fromPhase, toPhase)) continue; const td = el.transitionValues[fromPhase.id] ?? {}; const retire = toPhase ? isRetirementTransition(el, fromPhase, toPhase) : false; if (!isTransitionAnswered(el.category, retire, td)) n++; } return n; } // Ist der Uebergangs-Entscheid dieses Elements noch offen? function transitionUnanswered(el: ElementInput, fromPhase: PhaseComputed, toPhase: PhaseComputed): boolean { if (transitionInactive(el, fromPhase, toPhase)) return false; const retire = isRetirementTransition(el, fromPhase, toPhase); return !isTransitionAnswered(el.category, retire, el.transitionValues[fromPhase.id] ?? {}); } function transitionElements(fromPhase: PhaseComputed, toPhase?: PhaseComputed): ElementInput[] { return plan.elements .filter((el) => TRANSITION_CATEGORIES.includes(el.category)) .filter((el) => !transitionInactive(el, fromPhase, toPhase)) .sort((a, b) => a.orderIndex - b.orderIndex); } async function handleAddPhase(payload: { name?: string; durationYears?: number }) { await api.post(`/api/plans/${plan.id}/phases`, payload); setShowAddPhase(false); onChanged(); } const hasPhases = computed.phases.length > 0; const firstPhase = computed.phases[0] ?? null; return (
Anzeige
{(["nominal", "both", "real"] as ValueMode[]).map((m) => ( ))}
real = kaufkraftbereinigt (Planbeginn)
{/* Plan-Profil */}
Grundprofil (Plan) {plan.persons.map((p) => ( {personLabel(p.role)}: {p.age} J., Pension {p.retirementAge} ))} Inflation {plan.inflationRateDefault}%
{!hasPhases && (

Dieser Plan hat noch keine Lebensphasen.

)} {hasPhases && (
)} {computed.ruinAge !== null && (
Kapital aufgebraucht mit Alter {computed.ruinAge} – das Gesamtvermoegen (inkl. Cash) faellt danach unter 0.
)} {/* Matrix */} {hasPhases && (
{columns.map((col) => col.kind === "phase" ? ( setSelected({ type: "phase", phaseId: col.phase.id })} active={selected?.type === "phase" && selected.phaseId === col.phase.id} /> ) : ( setReviewFromPhaseId(col.fromPhase.id)} /> ) )} {/* Cash-Zeile (systemseitig, immer sichtbar, read-only). */} {columns.map((col) => { const isFirst = col.kind === "phase" && col.phase.sequenceNumber === 1; return col.kind === "phase" ? ( ) : ( (() => { // Uebergangszelle der Cash-Zeile: einmalige Sonderein-/ausgaben. const ct = cashTransitionFor(col.fromPhase.id); const open = !isCashTransitionAnswered(ct); return ( ); })() ); })} {CATEGORY_ORDER.map((cat) => { const els = elementsByCategory.get(cat)!; if (els.length === 0) return null; const collapsed = collapsedCats.has(cat); return ( {!collapsed && els.map((el) => ( {columns.map((col) => { if (col.kind === "phase") { const ce = computedElement(col.phase.id, el.id); return ( ); } const locked = TRANSITION_CATEGORIES.includes(el.category) && transitionInactive(el, col.fromPhase, col.toPhase); const canTransition = TRANSITION_CATEGORIES.includes(el.category) && !locked; const open = canTransition && transitionUnanswered(el, col.fromPhase, col.toPhase); return ( ); })} ))} ); })} {plan.elements.length === 0 && ( )}
Finanzielle Elemente
Cash verfuegbares Kapital setShowCashInit(true) : undefined} title={isFirst ? "Cash-Anfangswert bearbeiten" : undefined} className={`border-b border-r border-border px-2 py-1.5 text-center text-xs ${ isFirst ? "cursor-pointer hover:bg-accent-soft" : "" } ${col.phase.cashNegative ? "font-semibold text-danger" : "text-fg"}`} > {valStr(col.phase.cashStart, col.phase.cumulativeInflationStart, valueMode)}{" "} {" "} {valStr(col.phase.cashEnd, col.phase.cumulativeInflationEnd, valueMode)} setEditCashTransition(col.fromPhase.id)} title="Einmalige Sonderein-/ausgaben" className={`cursor-pointer border-b border-r border-border px-2 py-1.5 text-center text-[11px] ${ open ? "bg-accent font-semibold text-accent-fg" : "bg-accent-soft/40 text-accent" }`} > {cashTransitionSummary(ct)}
setCollapsedCats((prev) => { const next = new Set(prev); if (next.has(cat)) next.delete(cat); else next.add(cat); return next; }) } > {collapsed ? : } {CATEGORY_ICON[cat]} {CATEGORY_LABELS[cat]}
{el.name}
{el.ownerRole && el.ownerRole !== "HOUSEHOLD" && (
{personLabel(el.ownerRole)}
)}
setEditPhaseCell({ elementId: el.id, phaseId: col.phase.id })} className={`cursor-pointer border-b border-r border-border px-2 py-1.5 text-center text-xs ${ ce?.locked ? "text-faint" : "text-fg" }`} > {phaseCellContent(ce, col.phase, valueMode)} canTransition && setEditTransition({ elementId: el.id, fromPhaseId: col.fromPhase.id }) } className={`border-b border-r border-border px-2 py-1.5 text-center text-[11px] ${ canTransition ? "cursor-pointer" : "text-faint" } ${open ? "bg-accent font-semibold text-accent-fg" : canTransition ? "bg-accent-soft/40 text-accent" : ""}`} > {canTransition ? transitionSummary(el, col.fromPhase, col.toPhase) : locked ? "–" : "→"}
Noch keine finanziellen Elemente. Fuegen Sie oben Ihr erstes Element hinzu.
)} {/* Detail-Panel. Der key erzwingt beim Wechsel von Zelle/Phase einen Neuaufbau, damit der lokale Formular-Zustand nicht vom vorher geoeffneten Element uebrig bleibt. */} {selected && (
{renderDetail()}
)} {showAdd && firstPhase && ( setShowAdd(false)} onCreated={() => { setShowAdd(false); onChanged(); }} /> )} {showAddPhase && ( setShowAddPhase(false)} onCreate={handleAddPhase} /> )} {showSettings && ( setShowSettings(false)} onSaved={() => { setShowSettings(false); onChanged(); }} /> )} {showCashInit && ( setShowCashInit(false)} onSaved={() => { setShowCashInit(false); onChanged(); }} /> )} {editCashTransition && (() => { const fromPhase = computed.phases.find((p) => p.id === editCashTransition); if (!fromPhase) return null; const toIndex = computed.phases.findIndex((p) => p.id === fromPhase.id) + 1; const toPhase = computed.phases[toIndex]; return ( setEditCashTransition(null)} onSaved={() => { setEditCashTransition(null); onChanged(); }} /> ); })()} {reviewFromPhaseId && (() => { const fromPhase = computed.phases.find((p) => p.id === reviewFromPhaseId); if (!fromPhase) return null; const toIndex = computed.phases.findIndex((p) => p.id === fromPhase.id) + 1; const toPhase = computed.phases[toIndex]; const els = transitionElements(fromPhase, toPhase); return ( buildTransitionContext(fromPhase, toPhase, el)} isRetirement={(el) => (toPhase ? isRetirementTransition(el, fromPhase, toPhase) : false)} onClose={() => setReviewFromPhaseId(null)} onSaved={() => { setReviewFromPhaseId(null); onChanged(); }} /> ); })()} {editTransition && (() => { const element = plan.elements.find((e) => e.id === editTransition.elementId); const fromPhase = computed.phases.find((p) => p.id === editTransition.fromPhaseId); if (!element || !fromPhase) return null; const toIndex = computed.phases.findIndex((p) => p.id === fromPhase.id) + 1; const toPhase = computed.phases[toIndex]; const context = buildTransitionContext(fromPhase, toPhase, element); return ( setEditTransition(null)} onSaved={() => { setEditTransition(null); onChanged(); }} /> ); })()} {editPhaseCell && (() => { const element = plan.elements.find((e) => e.id === editPhaseCell.elementId); const phase = computed.phases.find((p) => p.id === editPhaseCell.phaseId); if (!element || !phase) return null; const context = buildPhaseContext(phase, element); return ( setEditPhaseCell(null)} onSaved={() => { setEditPhaseCell(null); onChanged(); }} onDeleted={() => { setEditPhaseCell(null); deleteElement(element.id); }} /> ); })()}
); // Naechste Phasen-Kappung (fuer das Phase-Popup). function nextPhaseCap(): number | null { // Simpel aus den Personen ableiten (Jahre nach Planbeginn = Summe der Dauern). const yearsBefore = plan.phases.reduce((s, p) => s + p.durationYears, 0); const caps = plan.persons .map((p) => p.retirementAge - (p.age + yearsBefore)) .filter((d) => d > 0); return caps.length > 0 ? Math.min(...caps) : null; } function renderDetail() { if (!selected) return null; const phase = computed.phases.find((p) => p.id === selected.phaseId); const phaseInput = plan.phases.find((p) => p.id === selected.phaseId); if (!phase || !phaseInput) return null; const isLast = phase.sequenceNumber === computed.phases.length; return ( { setSelected(null); onChanged(); }} /> ); } async function deleteElement(id: string) { if (!confirm("Dieses Element wirklich loeschen (aus allen Phasen)?")) return; await api.delete(`/api/elements/${id}`); setSelected(null); onChanged(); } function transitionSummary(el: ElementInput, fromPhase: PhaseComputed, toPhase: PhaseComputed): string { const td = el.transitionValues[fromPhase.id] ?? {}; switch (el.category) { case "AHV": return td.reviewed === true ? "Geprueft" : "?"; case "REAL_ESTATE": case "OTHER_ASSET": return td.decision === "SELL" ? "Verkauf" : td.decision === "HOLD" ? "Halten" : "?"; case "PENSION_FUND": if (isRetirementTransition(el, fromPhase, toPhase)) { return td.payoutMode === "CAPITAL" ? "Kapital" : td.payoutMode === "COMBI" ? "Kombi" : td.payoutMode === "PENSION" ? "Rente" : "?"; } if (td.withdrawalMode === undefined) return "?"; return td.withdrawalMode === "AMOUNT" && num(td.withdrawal) > 0 ? `−${formatChf(num(td.withdrawal))}` : "Kein Bezug"; case "PILLAR_3A": if (isRetirementTransition(el, fromPhase, toPhase)) return "Bezug"; if (td.withdrawalMode === undefined) return "?"; return td.withdrawalMode === "AMOUNT" && num(td.withdrawal) > 0 ? `−${formatChf(num(td.withdrawal))}` : "Kein Bezug"; case "OTHER_DEBT": return num(td.immediateRepayment) > 0 ? "Tilgung" : "→"; default: return "→"; } } } // Zellinhalt: Start- UND Zielwert fuer wertbehaftete Elemente, sonst die Kennzahl. const START_END_CATEGORIES: ElementCategory[] = [...VALUE_CATEGORIES, "INCOME", "EXPENSE"]; type ValueMode = "nominal" | "both" | "real"; const VALUE_MODE_KEY = "fpt-value-mode"; // Realwert = nominal / kumulierte Inflation (Kaufkraft zum Planbeginn). function realOf(nominal: number, deflator: number): number { return Math.round(nominal / (deflator || 1)); } function valStr(nominal: number, deflator: number, mode: ValueMode): string { if (mode === "real") return formatChf(realOf(nominal, deflator)); if (mode === "both") return `${formatChf(nominal)} (${formatChf(realOf(nominal, deflator))})`; return formatChf(nominal); } function phaseCellContent( ce: ReturnType | undefined, phase: PhaseComputed, mode: ValueMode ): React.ReactNode { if (!ce) return "–"; if (ce.note) return ce.note; const isFlow = ce.category === "INCOME" || ce.category === "EXPENSE"; const dS = phase.cumulativeInflationStart; const dE = isFlow ? phase.flowDeflatorEnd : phase.cumulativeInflationEnd; if (START_END_CATEGORIES.includes(ce.category) && (ce.startValue !== 0 || ce.endValue !== 0)) { return ( {valStr(ce.startValue, dS, mode)} {valStr(ce.endValue, dE, mode)} ); } if ((ce.category === "AHV" || ce.category === "PENSION_FUND") && ce.startValue !== 0) { return `Rente ${valStr(ce.startValue, dS, mode)}`; } return ce.summary || "–"; } function PhaseHeader({ phase, personLabel, mode, onClick, active, }: { phase: PhaseComputed; personLabel: (role: string) => string; mode: ValueMode; onClick: () => void; active: boolean; }) { const quotaLabel = phase.isConsumption ? "Verzehr" : "Quote"; const dS = phase.cumulativeInflationStart; const dE = phase.cumulativeInflationEnd; // Bestandswerte (Cash, Vermoegen) const dF = phase.flowDeflatorEnd; // Flow-Werte (Einkommen, Ausgaben, Quote) return (
{phase.name} {phase.cashNegative ? ( ) : ( )}
{phase.type === "ERWERB" ? "Erwerb" : phase.type === "PENSION" ? "Pension" : "Misch"} {phase.durationYears} J.
{phase.persons.map((p) => (
{personLabel(p.role)} {p.startAge} → {p.endAge}
))}
Einkommen {valStr(phase.incomeStart, dS, mode)} → {valStr(phase.incomeEnd, dF, mode)}
Ausgaben {valStr(phase.expenseStart, dS, mode)} → {valStr(phase.expenseEnd, dF, mode)}
{quotaLabel} {valStr(phase.quotaStart, dS, mode)} → {valStr(phase.quotaEnd, dF, mode)}
Geplante Sparrate {valStr(phase.plannedSaveRate, dS, mode)}
Geplante Verzehrrate {valStr(phase.plannedWithdrawRate, dS, mode)}
{(phase.capitalInflow > 0 || phase.capitalInvest > 0) && ( <>
{phase.capitalInflow > 0 &&
Kapitalzufluss {valStr(phase.capitalInflow, dS, mode)}
} {phase.capitalInvest > 0 &&
Kapitalinvestitionen {valStr(phase.capitalInvest, dS, mode)}
} )} {(phase.oneOffInflow > 0 || phase.oneOffOutflow > 0) && ( <>
{phase.oneOffInflow > 0 && (
+ {phase.oneOffInflowLabel ?? "Einmaliger Zufluss"} {valStr(phase.oneOffInflow, dS, mode)}
)} {phase.oneOffOutflow > 0 && (
− {phase.oneOffOutflowLabel ?? "Einmalige Kosten"} {valStr(phase.oneOffOutflow, dS, mode)}
)} )}
Vermoegen {valStr(phase.startWealthNominal, dS, mode)} → {valStr(phase.endWealthNominal, dE, mode)}
); } function TransitionHeader({ openCount, onClick }: { openCount: number; onClick: () => void }) { const done = openCount === 0; return (
Uebergang
{done ? (
geprueft
) : (
{openCount} offen
)} ); } function FragmentRows({ children }: { children: React.ReactNode }) { return <>{children}; } // --- Dialog: neues finanzielles Element mit Direkteingabe der Phase-1-Werte --- function AddElementDialog({ plan, firstPhase, onClose, onCreated, }: { plan: PlanInput; firstPhase: PhaseComputed; onClose: () => void; onCreated: () => void; }) { const [category, setCategory] = useState("INCOME"); const [name, setName] = useState(""); const [ownerRole, setOwnerRole] = useState("PERSON_A"); const [pd, setPd] = useState({}); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); const needsPerson = PERSON_ONLY_CATEGORIES.includes(category); const isCouple = plan.householdType === "COUPLE"; const pLabel = (role: string) => { const p = plan.persons.find((x) => x.role === role); return p?.name && p.name.trim() ? p.name.trim() : role === "PERSON_A" ? "Person A" : "Person B"; }; const ownerOptions = needsPerson ? isCouple ? [ { value: "PERSON_A", label: pLabel("PERSON_A") }, { value: "PERSON_B", label: pLabel("PERSON_B") }, ] : [{ value: "PERSON_A", label: pLabel("PERSON_A") }] : isCouple ? [ { value: "HOUSEHOLD", label: "Gemeinsam" }, { value: "PERSON_A", label: pLabel("PERSON_A") }, { value: "PERSON_B", label: pLabel("PERSON_B") }, ] : [ { value: "HOUSEHOLD", label: "Gemeinsam" }, { value: "PERSON_A", label: pLabel("PERSON_A") }, ]; // Kontext fuer die Phase-1-Felder des neuen Elements. const owner = needsPerson || ownerRole !== "HOUSEHOLD" ? ownerRole : null; const ownerWorking = owner && owner !== "HOUSEHOLD" ? firstPhase.persons.find((p) => p.role === owner)?.working ?? false : firstPhase.type !== "PENSION"; const context: CellContext = { kind: "phase", phaseId: firstPhase.id, ownerWorking, isConsumption: firstPhase.isConsumption, durationYears: firstPhase.durationYears, isRetirementTransition: false, carriedEndValue: 0, carried: false, derivedStart: 0, derivedMortgage: 0, deflatorStart: firstPhase.cumulativeInflationStart, laterPhaseCount: 0, // beim Anlegen bewusst kein Warnhinweis ahvCareer: null, }; async function create() { setSaving(true); setError(null); try { const { element } = await api.post<{ element: { id: string } }>(`/api/plans/${plan.id}/elements`, { category, name: name.trim() || CATEGORY_LABELS[category], ownerRole, }); // Ist-Zustand direkt in Phase 1 speichern (sofern Felder ausgefuellt). if (Object.keys(pd).length > 0) { await api.put(`/api/elements/${element.id}/phase/${firstPhase.id}`, pd); } onCreated(); } catch (e) { setError(e instanceof Error ? e.message : "Erstellen fehlgeschlagen."); } finally { setSaving(false); } } return (
setName(e.target.value)} placeholder={CATEGORY_LABELS[category]} className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-fg focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/25" />
Werte (erste Lebensphase)
setPd((prev) => ({ ...prev, ...patch }))} />
{error &&

{error}

}
); } // --- Dialog: neue Lebensphase --- function AddPhaseDialog({ maxDurationYears, onClose, onCreate, }: { maxDurationYears: number | null; onClose: () => void; onCreate: (payload: { name?: string; durationYears?: number }) => void; }) { const cap = maxDurationYears; const [name, setName] = useState(""); const [durationYears, setDurationYears] = useState(cap ?? 10); const [saving, setSaving] = useState(false); return (
setName(e.target.value)} placeholder="automatisch (Erwerb/Pension)" className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-fg focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/25" />
e.currentTarget.select()} onChange={(e) => { const v = e.target.valueAsNumber || 1; setDurationYears(cap != null ? Math.min(v, cap) : v); }} className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-fg focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/25" />
{ setSaving(true); onCreate({ name: name.trim() || undefined, durationYears }); }} onClose={onClose} confirmLabel="Erstellen" />
); } // --- Dialog: Plan-Einstellungen (Grundprofil bearbeiten) --- function PlanSettingsDialog({ plan, onClose, onSaved }: { plan: PlanInput; onClose: () => void; onSaved: () => void }) { const [draft, setDraft] = useState({ householdType: plan.householdType, inflationRateDefault: plan.inflationRateDefault, persons: plan.persons.map((p) => ({ role: p.role, name: p.name ?? "", age: p.age, retirementAge: p.retirementAge })), }); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); async function save() { setSaving(true); setError(null); try { await api.patch(`/api/plans/${plan.id}`, draft); onSaved(); } catch (e) { setError(e instanceof Error ? e.message : "Speichern fehlgeschlagen."); } finally { setSaving(false); } } return ( {error &&

{error}

}
); } // --- Dialog: geführter Übergang --- function TransitionReviewDialog({ fromPhase, toPhase, elements, initialCash, buildContext, isRetirement, onClose, onSaved, }: { fromPhase: PhaseComputed; toPhase: PhaseComputed | undefined; elements: ElementInput[]; initialCash: CashTransitionData; buildContext: (el: ElementInput) => CellContext; isRetirement: (el: ElementInput) => boolean; onClose: () => void; onSaved: () => void; }) { const [tds, setTds] = useState>(() => Object.fromEntries( elements.map((e) => [e.id, withTransitionDefaults(e.category, isRetirement(e), e.transitionValues[fromPhase.id] ?? {})]) ) ); const [ct, setCt] = useState(() => withCashTransitionDefaults(initialCash)); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); async function saveAll() { setSaving(true); setError(null); try { await api.put(`/api/phases/${fromPhase.id}/cash-transition`, ct); for (const e of elements) { await api.put(`/api/elements/${e.id}/transition/${fromPhase.id}`, tds[e.id] ?? {}); } onSaved(); } catch (err) { setError(err instanceof Error ? err.message : "Speichern fehlgeschlagen."); } finally { setSaving(false); } } return (

Gehen Sie die Positionen durch und treffen Sie je Element den Übergangs-Entscheid (Halten, Verkaufen, Bezug). Danach werden gehaltene Werte automatisch in die nächste Phase fortgeschrieben.

{/* Cash zuerst: einmalige Sonderein-/ausgaben betreffen jeden Uebergang. */}
Cash Einmalige Sonderein-/ausgaben

Einmalige Ereignisse wie Erbschaft, Autokauf oder Poolbau werden hier direkt dem Cash-Konto gutgeschrieben bzw. belastet.

setCt((prev) => ({ ...prev, ...patch }))} deflatorEnd={fromPhase.cumulativeInflationEnd} />
{elements.length === 0 && (

An diesem Übergang gibt es sonst keine zu entscheidenden Positionen.

)} {elements.map((el) => { const ctx = buildContext(el); const retire = isRetirement(el); const hint = el.category === "AHV" && retire ? "Pensionierung: Beitragskarriere prüfen – die Rente hängt vom Durchschnittseinkommen ab." : (el.category === "PENSION_FUND" || el.category === "PILLAR_3A") && !retire ? "Hier könnten Sie optional Kapital beziehen." : el.category === "PENSION_FUND" && retire ? "Pensionierung: Bezugsart wählen (Rente / Kapital / Kombination)." : el.category === "PILLAR_3A" && retire ? "Wird bei Pensionierung vollständig bezogen." : null; return (
{CATEGORY_ICON[el.category]} {el.name} {CATEGORY_LABELS[el.category]}
{hint &&

{hint}

}
setTds((prev) => ({ ...prev, [el.id]: { ...prev[el.id], ...patch } }))} />
); })}
{error &&

{error}

}
); } // --- Dialog: Cash-Uebergang (einmalige Sonderein-/ausgaben) --- function CashTransitionDialog({ fromPhase, toPhase, initial, onClose, onSaved, }: { fromPhase: PhaseComputed; toPhase: PhaseComputed | undefined; initial: CashTransitionData; onClose: () => void; onSaved: () => void; }) { // Vorbelegung, damit ein blosses "Speichern" den sichtbaren Default (1:1) persistiert. const [ct, setCt] = useState(() => withCashTransitionDefaults(initial)); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); async function save() { setSaving(true); setError(null); try { await api.put(`/api/phases/${fromPhase.id}/cash-transition`, ct); onSaved(); } catch (e) { setError(e instanceof Error ? e.message : "Speichern fehlgeschlagen."); } finally { setSaving(false); } } return (
Einmalige Sonderein-/ausgaben · {fromPhase.name} → {toPhase?.name ?? "Ende"}
setCt((prev) => ({ ...prev, ...patch }))} deflatorEnd={fromPhase.cumulativeInflationEnd} />
{error &&

{error}

}
); } // --- Dialog: Cash-Anfangswert (erste Lebensphase) --- function CashInitialDialog({ plan, onClose, onSaved }: { plan: PlanInput; onClose: () => void; onSaved: () => void }) { const [value, setValue] = useState(plan.initialCash); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); async function save() { setSaving(true); setError(null); try { await api.patch(`/api/plans/${plan.id}`, { initialCash: value }); onSaved(); } catch (e) { setError(e instanceof Error ? e.message : "Speichern fehlgeschlagen."); } finally { setSaving(false); } } return (

Startbestand des Cash-Kontos zu Beginn der ersten Lebensphase.

{error &&

{error}

}
); } // --- Dialog: Element-Werte einer Lebensphase (per Klick auf eine Phasenzelle) --- function PhaseCellDialog({ element, phase, context, phaseData, onClose, onSaved, onDeleted, }: { element: ElementInput; phase: PhaseComputed; context: CellContext; phaseData: PhaseData; onClose: () => void; onSaved: () => void; onDeleted: () => void; }) { return ( ); } // --- Dialog: einzelner Übergangs-Entscheid (per Klick auf eine Übergangszelle) --- function TransitionCellDialog({ element, fromPhase, toPhase, context, onClose, onSaved, }: { element: ElementInput; fromPhase: PhaseComputed; toPhase: PhaseComputed | undefined; context: CellContext; onClose: () => void; onSaved: () => void; }) { const [td, setTd] = useState(() => withTransitionDefaults(element.category, context.isRetirementTransition, element.transitionValues[fromPhase.id] ?? {}) ); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); async function save() { setSaving(true); setError(null); try { await api.put(`/api/elements/${element.id}/transition/${fromPhase.id}`, td); onSaved(); } catch (e) { setError(e instanceof Error ? e.message : "Speichern fehlgeschlagen."); } finally { setSaving(false); } } return (
{CATEGORY_LABELS[element.category]} · {fromPhase.name} → {toPhase?.name ?? "Ende"}
setTd((prev) => ({ ...prev, ...patch }))} />
{error &&

{error}

}
); } // --- gemeinsame Dialog-Bausteine --- function DialogShell({ title, onClose, children, wide, }: { title: string; onClose: () => void; children: React.ReactNode; wide?: boolean; }) { return (
e.stopPropagation()} className={`flex w-full ${wide ? "max-w-2xl" : "max-w-md"} flex-col gap-3 rounded-2xl border border-border bg-surface p-6 shadow-xl`} >

{title}

{children}
); } function DialogActions({ saving, onConfirm, onClose, confirmLabel, }: { saving: boolean; onConfirm: () => void; onClose: () => void; confirmLabel: string; }) { return (
); }