"use client"; import { useEffect, useMemo, useState } from "react"; import { AlertCircle, ArrowRight, Building2, CheckCircle2, ChevronDown, ChevronRight, CreditCard, Home, Landmark, ListTodo, Pencil, Maximize2, PiggyBank, Table2, Plus, Settings2, ShoppingCart, Tornado, Trash2, TrendingUp, Wallet, } from "lucide-react"; import { Timeline } from "@/components/Timeline"; import { Sparkline } from "@/components/Sparkline"; import { CapitalDistributionDialog, RateDistributionDialog } from "@/components/DistributionDialogs"; import { capitalPot } from "@/lib/distribution"; import { isRetirementTransition, openTransitionCount, decisionsText, type DecisionCounts, transitionInactive as inactiveAtTransition, TRANSITION_CATEGORIES, retirementConfirmed, } from "@/lib/decisions"; import { AHV_REFERENCE_AGE } from "@/lib/constants"; import { RETIREMENT_CATEGORIES, withRetirementDefaults, type RetirementDecision, } from "@/lib/retirement-decision"; import { RetirementFields } from "@/components/RetirementFields"; import { RetirementPanel } from "@/components/RetirementPanel"; import { Button, EmptyState, InspectorShell, Modal, useConfirm, useToast } from "@/components/ui"; import { ElementDetailDialog, PhaseDetailDialog } from "@/components/DetailView"; 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 { RetirementAdjuster } from "@/components/RetirementAdjuster"; import { MoneyField, SelectField, TextField } from "@/components/FormField"; import { api } from "@/lib/api-client"; import { formatChf } from "@/lib/format"; import { DATA_SOURCE_OPTIONS, type DataSource } from "@/lib/dataview"; import { CATEGORY_LABELS, CATEGORY_ORDER, PERSON_ONLY_CATEGORIES, inheritedPhaseValues, num, ownerLabel, type CashTransitionData, type ElementCategory, type PhaseData, type TransitionData, } from "@/lib/elements"; import { computePlan, type PhaseComputed, type PlanComputed } from "@/lib/calculations"; import type { ScenarioDiff } from "@/lib/diff"; 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 Übergangs-Entscheid. AHV ist dabei ein Sonderfall: nur beim // Pensions-Übergang ist die Beitragskarriere zu prüfen (siehe transitionInactive). 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 }; // Alle Einzel-Bearbeitungen laufen über EIN rechtes Inspector-Panel (statt Modals): // die Matrix bleibt sichtbar, ein Klick auf eine andere Zelle wechselt den Inhalt. // Genau ein Panel kann offen sein -- deshalb eine Union statt sechs einzelner Zustände. type Panel = | { kind: "phase"; phaseId: string } | { kind: "elementMeta"; elementId: string } | { kind: "cell"; elementId: string; phaseId: string } | { kind: "transition"; elementId: string; fromPhaseId: string } | { kind: "cashTransition"; fromPhaseId: string } | { kind: "cashInitial" } | { kind: "profile" }; export function PlanView({ plan, computed: planComputed, actualComputed = null, actualYears = [], diff, onChanged, onOpenSpec, onOpenSensitivity, }: { plan: PlanInput; computed: PlanComputed; // Zweiter Rechenlauf mit den effektiven Werten; null, wenn keine erfasst sind. actualComputed?: PlanComputed | null; // Kalenderjahre mit Ist-Satz (Marker auf der Zeitachse). actualYears?: number[]; // Abweichungen gegenüber dem Eltern-Szenario; null im Basisszenario (nichts zu markieren). diff: ScenarioDiff | null; onChanged: () => void; onOpenSpec?: (anchor: string) => void; onOpenSensitivity?: () => void; }) { // Planzahlen oder effektive Zahlen (inkl. Abweichung). Bewusst zwei Möglichkeiten statt // dreier: Plan UND Ist als Rohwerte nebeneinander wären mit nominal/real acht Zahlen je // Zelle (siehe SPEZIFIKATION 9.29). const [dataSource, setDataSource] = useState("PLAN"); const hasActuals = actualComputed !== null; const computed = dataSource === "ACTUAL" && actualComputed ? actualComputed : planComputed; // Planwert derselben Zelle -- Grundlage der Abweichung. In der Plan-Sicht null, dann zeigt // die Zelle gar keine Abweichung an (statt eine von 0 zu behaupten). const showDeviation = dataSource === "ACTUAL" && actualComputed !== null; const planEndOf = (elementId: string | undefined, phaseId: string): number | null => { if (!showDeviation || !elementId) return null; const ph = planComputed.phases.find((p) => p.id === phaseId); const el = ph?.elements.find((e) => e.elementId === elementId); return el ? el.endValue : null; }; const confirmDialog = useConfirm(); const toast = useToast(); // Markierungs-Klassen: geändert = gelb, neu = grün, entfernt = grau. const cellDiff = (elementId: string, phaseId: string) => diff?.phaseCell.has(`${elementId}:${phaseId}`) ? "bg-diff-soft ring-1 ring-inset ring-diff/40" : ""; const transDiff = (elementId: string, fromPhaseId: string) => diff?.transitionCell.has(`${elementId}:${fromPhaseId}`) ? "bg-diff-soft ring-1 ring-inset ring-diff/40" : ""; const rowDiff = (elementId: string) => { const k = diff?.elementRow.get(elementId); return k === "added" ? "bg-diff-added-soft" : k === "changed" ? "bg-diff-soft" : ""; }; const [panel, setPanel] = useState(null); const [collapsedCats, setCollapsedCats] = useState>(new Set()); const [showAdd, setShowAdd] = useState(false); const [addCategory, setAddCategory] = useState(null); const [showAddPhase, setShowAddPhase] = useState(false); const [reviewFromPhaseId, setReviewFromPhaseId] = useState(null); // Offener Verteil-Dialog (Kapital bzw. Spar-/Verzehrquote) -- bewusst ein eigenes Popup, // weil beide mehrere Elemente auf einmal bearbeiten. const [distribute, setDistribute] = useState<{ kind: "capital" | "rates"; phaseId: string } | null>(null); const [valueMode, setValueMode] = useState("nominal"); // Matrix oder Pensionierung. Kein URL-Zustand: Es ist eine Arbeitsansicht, kein Ort. const [view, setView] = useState<"matrix" | "retirement">("matrix"); // Die Tour (Start-Knopf, Auto-Start bei Plan-Erstellung, Rendering) liegt seit dem // Layout-Umbau in AppShell -- sie liest die data-tour-Ziele im DOM dieser Ansicht. // Nur-Lese-Detailansicht (Roadmap Nr. 43). Der Rechenweg wird erst beim Öffnen erzeugt. const [detailFor, setDetailFor] = useState<{ kind: "element"; id: string } | { kind: "phase"; id: string } | null>(null); // Erklärte Berechnung: bewusst NUR wenn eine Detailansicht offen ist. computePlan ist rein // und läuft im Browser -- es braucht dafür weder einen API-Aufruf noch eine grössere // Server-Antwort, und das Ergebnis ist per Konstruktion identisch zum Serverergebnis. const explained = useMemo( () => (detailFor ? computePlan(plan, undefined, { explain: true }) : null), [detailFor, plan] ); useEffect(() => { const stored = typeof window !== "undefined" ? window.localStorage.getItem(VALUE_MODE_KEY) : null; // eslint-disable-next-line react-hooks/set-state-in-effect -- einmalige Übernahme 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); } // isRetirementTransition/transitionInactive/openTransitionCount kommen aus lib/decisions -- // der Bericht zaehlt mit derselben Regel (sonst meldet er andere Zahlen als die Matrix). // Beitragskarriere des Element-Besitzers (nur für 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 (für den Fortschreibungs-Warnhinweis). function laterPhaseCount(phase: PhaseComputed): number { return computed.phases.length - phase.sequenceNumber; } // Phasen in ihrer Reihenfolge -- Grundlage der Vererbung (Roadmap Nr. 44, Punkt A). const orderedPhaseIds = computed.phases.map((p) => p.id); // Alter des Element-Besitzers im ersten Jahr einer Phase. Ohne Personenzuordnung gilt // Person A, weil die Zeitachse ohnehin an ihr hängt. function ownerAgeAtPhaseStart(phase: PhaseComputed, element: ElementInput): number { const role = element.ownerRole && element.ownerRole !== "HOUSEHOLD" ? element.ownerRole : "PERSON_A"; return phase.persons.find((p) => p.role === role)?.startAge ?? 0; } // Baut den Kontext für 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, mortgageEnd: ce?.mortgageEnd ?? 0, // Verkehrswert = Eigenkapital + Resthypothek (beides am Phasenende). propertyValueEnd: (ce?.endValue ?? 0) + (ce?.mortgageEnd ?? 0), deflatorStart: phase.cumulativeInflationStart, laterPhaseCount: laterPhaseCount(phase), ahvCareer: careerFor(element), inheritedValues: inheritedPhaseValues(element.phaseValues, orderedPhaseIds, phase.id), ownerAgeStart: ownerAgeAtPhaseStart(phase, element), investTargets: [], capitalFromTransfer: ce?.capitalFromTransfer ?? 0, capitalFromTransferSources: ce?.capitalFromTransferSources ?? [], }; } 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, mortgageEnd: ce?.mortgageEnd ?? 0, // Verkehrswert = Eigenkapital + Resthypothek (beides am Phasenende). propertyValueEnd: (ce?.endValue ?? 0) + (ce?.mortgageEnd ?? 0), deflatorStart: fromPhase.cumulativeInflationStart, laterPhaseCount: laterPhaseCount(fromPhase), ahvCareer: careerFor(element), inheritedValues: {}, ownerAgeStart: ownerAgeAtPhaseStart(fromPhase, element), // Ziele fuer die Anlage-Quote beim Kapitalbezug (Punkt C). investTargets: investTargetsOf(plan, toPhase), capitalFromTransfer: 0, capitalFromTransferSources: [], }; } // Am Übergang 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-Übergangs. const transitionInactive = (el: ElementInput, fromPhase: PhaseComputed, toPhase?: PhaseComputed) => inactiveAtTransition(computed, el, fromPhase, toPhase); function cashTransitionFor(phaseId: string): CashTransitionData { return plan.phases.find((p) => p.id === phaseId)?.cashTransition ?? {}; } // Anzahl offener (noch nicht getroffener) Übergangs-Entscheide an einer Grenze. // Der Cash-Entscheid (einmalige Sonderein-/ausgaben) zählt mit. const transitionOpenCount = (fromPhase: PhaseComputed, toPhase: PhaseComputed) => openTransitionCount(plan, computed, fromPhase, toPhase); // Ist der Übergangs-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/scenarios/${plan.id}/phases`, payload); setShowAddPhase(false); onChanged(); } // Kurzfassung der Pensionierung fuer die Matrix-Ansicht: die eine Zahl, die zaehlt, plus // der Hinweis auf ungepruefte Vorgaben. Ein Klick fuehrt in den Bildschirm. const retirementSummaryText = (() => { const r = computed.retirement; if (r.gapAnnual === null) return null; const unconfirmed = plan.elements.filter( (e) => RETIREMENT_CATEGORIES.includes(e.category) && !retirementConfirmed(e) ).length; const luecke = r.gapAnnual < 0 ? `Rentenluecke ${formatChf(r.gapAnnual)}/Jahr` : `Ueberschuss ${formatChf(r.gapAnnual)}/Jahr`; return unconfirmed > 0 ? `${luecke} · ${unconfirmed} Vorgaben ungeprueft` : luecke; })(); const hasPhases = computed.phases.length > 0; const firstPhase = computed.phases[0] ?? null; const lastPhase = computed.phases[computed.phases.length - 1] ?? null; return (
setReviewFromPhaseId(fromPhaseId)} onAddElement={(cat) => { setAddCategory(cat); setShowAdd(true); }} onAddPhase={() => setShowAddPhase(true)} onOpenSensitivity={onOpenSensitivity} /> {/* Kompakter Klotz: Grundprofil (25 %) · Zeitachse (50 %) · Kennzahlen (25 %). Auf schmalen Screens stapeln die drei. */}
{/* Grundprofil -- Daten untereinander, «Bearbeiten» oben rechts. */}
Grundprofil{diff?.profileChanged ? " · abweichend" : ""}
{plan.persons.map((p) => ( {personLabel(p.role)}: {p.age} J., Pension {p.retirementAge} ))} Inflation {plan.inflationRateDefault}%
{/* Zeitachse */}
{/* Kennzahlen: Endvermögen nominal + real (+ Ruin, falls vorhanden). */}
Endvermögen
Nominal
{lastPhase ? formatChf(Math.round(lastPhase.endWealthNominal)) : "—"}
Real (heutige Kaufkraft)
{lastPhase ? formatChf(Math.round(lastPhase.endWealthReal)) : "—"}
{computed.ruinAge !== null && (
Kapital reicht nur bis Alter {computed.ruinAge}
)}
{/* Ansicht: Matrix oder Pensionierung. Die Pensionierung steht bewusst gleichrangig NEBEN der Matrix und nicht in ihr: Sie ist keine Zelle, sondern eine eigene Frage -- und die einzige, die man zwischen Szenarien systematisch variiert. */} {hasPhases && (
{(["matrix", "retirement"] as const).map((v) => ( ))}
{view === "matrix" && retirementSummaryText && ( )}
)} {view === "retirement" && ( )} {/* Anzeige-Umschalter (nominal/real, Plan/Ist) -- direkt über der Matrix, weil er nur deren Zahlen steuert. */} {hasPhases && view === "matrix" && (
Anzeige
{(["nominal", "both", "real"] as ValueMode[]).map((m) => ( ))}
real = kaufkraftbereinigt (Planbeginn) {/* Zweite Achse: Datenquelle. Erscheint nur, wenn es überhaupt Ist-Werte gibt -- sonst wäre es ein Umschalter ohne Gegenstück. */} {hasActuals && ( <> Zahlen
{DATA_SOURCE_OPTIONS.map((o) => ( ))}
{dataSource === "ACTUAL" && ( Abweichung gegenüber Plan farbig )} )}
)} {!hasPhases && ( } title="Noch keine Lebensphasen" text="Deine Planung besteht aus Lebensphasen – Abschnitten, in denen deine Finanzen ähnlich laufen (z. B. «bis zur Pension»). Leg die erste an, danach kommen die finanziellen Elemente." > )} {hasPhases && (
{/* Alle Kategoriegruppen auf einen Schlag auf- oder zuklappen. */}
)} {computed.ruinAge !== null && (
Kapital aufgebraucht mit Alter {computed.ruinAge} – das Gesamtvermögen (inkl. Cash) fällt danach unter 0. {onOpenSensitivity && ( )}
)} {/* Matrix: eigener Scrollbereich, damit Phasen-Köpfe (oben) UND Elementnamen (links) beim Scrollen sichtbar bleiben. */} {hasPhases && view === "matrix" && (
{/* Feste Spaltenbreiten: Alle Phasenspalten sind gleich breit -- bei einer einzigen Phase bleibt die Tabelle dadurch schmal, bei vielen wird horizontal gescrollt. Ein w-full haette die Spalten je nach Anzahl unterschiedlich gedehnt. */} {columns.map((col) => col.kind === "phase" ? ( setPanel({ kind: "phase", phaseId: col.phase.id })} onExpand={() => setDetailFor({ kind: "phase", id: col.phase.id })} onDistributeCapital={ col.phase.sequenceNumber > 1 ? () => setDistribute({ kind: "capital", phaseId: col.phase.id }) : null } onDistributeRates={() => setDistribute({ kind: "rates", phaseId: col.phase.id })} onDelete={ col.phase.sequenceNumber === computed.phases.length ? () => void deletePhase(col.phase.id, col.phase.name) : null } active={panel?.kind === "phase" && panel.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" ? ( ) : ( (() => { // Übergangszelle 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 ( {/* Zugeklappt: Summe der Elementwerte je Phase -- sonst verschwindet die Information beim Zuklappen. Aufgeklappt bleibt die Zeile leer, weil man die Einzelwerte darunter sieht. */} {collapsed ? columns.map((col) => col.kind === "phase" ? ( ) : ( {!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 ( ); })} ))} ); })} {/* Geisterzeilen: in der Vorlage vorhanden, in diesem Szenario gelöscht. */} {(diff?.removedElements ?? []).map((r) => ( ))} {plan.elements.length === 0 && (diff?.removedElements.length ?? 0) === 0 && ( )}
Finanzielle Elemente
Cash verfügbares Kapital setPanel({ kind: "cashInitial" }) : 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"} ${ isFirst && diff?.cashInitialChanged ? "bg-diff-soft ring-1 ring-inset ring-diff/40" : "" }`} > {valStr(col.phase.cashStart, col.phase.cumulativeInflationStart, valueMode)}{" "} {" "} {valStr(col.phase.cashEnd, col.phase.cumulativeInflationEnd, valueMode)} setPanel({ kind: "cashTransition", fromPhaseId: 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-attention font-semibold text-attention-fg" : diff?.cashTransitionCell.has(col.fromPhase.id) ? "bg-diff-soft text-diff ring-1 ring-inset ring-diff/40" : "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]} {(() => { const rows = els .map((e) => computedElement(col.phase.id, e.id)) .filter((ce): ce is NonNullable => !!ce && ce.status === "ACTIVE"); if (rows.length === 0) return ""; const start = rows.reduce((sum, ce) => sum + ce.startValue, 0); const end = rows.reduce((sum, ce) => sum + ce.endValue, 0); return ( {valStr(start, col.phase.cumulativeInflationStart, valueMode)}{" "} {" "} {valStr(end, col.phase.cumulativeInflationEnd, valueMode)} ); })()} ) ) : }
{el.name} {diff?.elementRow.get(el.id) === "added" && ( neu )} {/* Aktionen zum Element selbst (phasenunabhängig): umbenennen, löschen, Detailansicht -- bewusst IMMER sichtbar, damit sie auch ohne Maus (Touch) erreichbar sind. */}
{el.ownerRole && el.ownerRole !== "HOUSEHOLD" && (
{personLabel(el.ownerRole)}
)} {/* Trend über alle Planjahre -- die per-Jahr-Werte existieren seit 0.11. */} ph.elements.find((e) => e.elementId === el.id)?.yearly?.map((y) => y.value) ?? [] )} className="mt-0.5" />
setPanel({ kind: "cell", elementId: el.id, phaseId: col.phase.id })} title={cellDiff(el.id, col.phase.id) ? "Weicht von der Vorlage ab" : undefined} className={`cursor-pointer border-b border-r border-border px-2 py-1.5 text-center text-xs ${ ce?.locked ? "text-faint" : "text-fg" } ${cellDiff(el.id, col.phase.id)}`} > {phaseCellContent(ce, col.phase, valueMode, planEndOf(ce?.elementId, col.phase.id))} canTransition && setPanel({ kind: "transition", elementId: el.id, fromPhaseId: col.fromPhase.id }) } title={transDiff(el.id, col.fromPhase.id) ? "Weicht von der Vorlage ab" : undefined} className={`border-b border-r border-border px-2 py-1.5 text-center text-[11px] ${ canTransition ? "cursor-pointer" : "text-faint" } ${ // Offene Entscheide tragen die Attention-Farbe (getrennt vom // Akzent: "hier fehlt was" ist nicht "hier kannst du klicken"). open ? "bg-attention font-semibold text-attention-fg" : transDiff(el.id, col.fromPhase.id) ? "bg-diff-soft text-diff ring-1 ring-inset ring-diff/40" : canTransition ? "bg-accent-soft/40 text-accent" : "" }`} > {canTransition ? transitionSummary(el, col.fromPhase, col.toPhase) : locked ? "–" : "→"}
{r.name}
{CATEGORY_LABELS[r.category]} · entfernt
In diesem Szenario entfernt
Noch keine finanziellen Elemente. Füge oben dein erstes Element hinzu.
)} {/* Inspector-Panel: alle Einzel-Bearbeitungen. Der key erzwingt beim Wechsel der Auswahl einen Neuaufbau, damit kein Formular-Zustand der vorherigen Zelle übrig bleibt (gleiches Muster wie zuvor bei den Dialogen). */} {panel && renderPanel()} {showAdd && firstPhase && ( { setShowAdd(false); setAddCategory(null); }} onCreated={() => { setShowAdd(false); setAddCategory(null); toast("success", "Element angelegt."); onChanged(); }} /> )} {showAddPhase && ( setShowAddPhase(false)} onCreate={handleAddPhase} /> )} {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(); }} /> ); })()} {detailFor?.kind === "element" && explained && (() => { const el = plan.elements.find((e) => e.id === detailFor.id); if (!el) return null; return ( setDetailFor(null)} onOpenSpec={onOpenSpec} /> ); })()} {detailFor?.kind === "phase" && explained && (() => { const ph = explained.phases.find((p) => p.id === detailFor.id); if (!ph) return null; return ( setDetailFor(null)} onOpenSpec={onOpenSpec} /> ); })()} {distribute?.kind === "capital" && ( setDistribute(null)} onSaved={() => { setDistribute(null); toast("success", "Kapital verteilt."); onChanged(); }} /> )} {distribute?.kind === "rates" && ( setDistribute(null)} onSaved={() => { setDistribute(null); toast("success", "Raten gespeichert."); onChanged(); }} /> )}
); // Nächste Phasen-Kappung (für das Phase-Popup). // Element loeschen -- neu auch direkt aus der Zeile heraus. async function deleteElement(elementId: string, elementName: string) { const ok = await confirmDialog({ title: "Element löschen?", message: `«${elementName}» wird aus ALLEN Lebensphasen entfernt, samt Werten und Übergangs-Entscheiden.`, confirmLabel: "Endgültig löschen", danger: true, }); if (!ok) return; try { await api.delete(`/api/elements/${elementId}`); setPanel(null); onChanged(); toast("success", "Element gelöscht."); } catch (e) { toast("error", e instanceof Error ? e.message : "Löschen fehlgeschlagen."); } } async function deletePhase(phaseId: string, phaseName: string) { const ok = await confirmDialog({ title: "Lebensphase löschen?", message: `«${phaseName}» wird mit allen erfassten Werten und Übergangs-Entscheiden entfernt.`, confirmLabel: "Endgültig löschen", danger: true, }); if (!ok) return; try { await api.delete(`/api/phases/${phaseId}`); setPanel(null); onChanged(); toast("success", "Lebensphase gelöscht."); } catch (e) { toast("error", e instanceof Error ? e.message : "Löschen fehlgeschlagen."); } } 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; } // Inhalt des Inspector-Panels je nach Auswahl. function renderPanel() { if (!panel) return null; const close = () => setPanel(null); const closeAndRefresh = () => { setPanel(null); onChanged(); }; switch (panel.kind) { case "elementMeta": { const el = plan.elements.find((e) => e.id === panel.elementId); if (!el) return null; return ( deleteElement(el.id, el.name)} /> ); } case "phase": { const phase = computed.phases.find((p) => p.id === panel.phaseId); const phaseInput = plan.phases.find((p) => p.id === panel.phaseId); if (!phase || !phaseInput) return null; return ( ); } case "cell": { const element = plan.elements.find((e) => e.id === panel.elementId); const phase = computed.phases.find((p) => p.id === panel.phaseId); if (!element || !phase) return null; return ( ({ id: p.id, sequenceNumber: p.sequenceNumber }))} phaseDataByPhase={element.phaseValues} transitionData={{}} onSaved={closeAndRefresh} onDeleteElement={() => { setPanel(null); deleteElement(element.id, element.name); }} /> ); } case "transition": { const element = plan.elements.find((e) => e.id === panel.elementId); const fromPhase = computed.phases.find((p) => p.id === panel.fromPhaseId); if (!element || !fromPhase) return null; const toPhase = computed.phases[computed.phases.findIndex((p) => p.id === fromPhase.id) + 1]; return ( ); } case "cashTransition": { const fromPhase = computed.phases.find((p) => p.id === panel.fromPhaseId); if (!fromPhase) return null; const toPhase = computed.phases[computed.phases.findIndex((p) => p.id === fromPhase.id) + 1]; return ( ); } case "cashInitial": return ; case "profile": return ; } } function transitionSummary(el: ElementInput, fromPhase: PhaseComputed, toPhase: PhaseComputed): string { const td = el.transitionValues[fromPhase.id] ?? {}; switch (el.category) { case "AHV": return td.reviewed === true ? "Geprüft" : "?"; case "OTHER_ASSET": return td.decision === "SELL" ? "Verkauf" : td.decision === "PARTIAL" ? `Teilverkauf −${formatChf(num(td.partialSaleAmount))}` : td.decision === "HOLD" ? "Halten" : "?"; case "REAL_ESTATE": if (td.decision === "SELL") return "Verkauf"; if (td.decision === undefined) return "?"; return num(td.extraAmortization) > 0 ? `Sonderamort. −${formatChf(num(td.extraAmortization))}` : "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 für 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)); } // Reiner Text (fuer Titel/Tooltips und einzeilige Faelle). 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); } // Im Modus "Beide" steht der Realwert in Klammern in einer EIGENEN Zeile unter dem nominalen // Wert -- nebeneinander wird die Zeile zu lang und die Spalten unnoetig breit. Der Pfeil // wiederholt sich auf der zweiten Zeile, damit der Zeitbezug Start -> Ende erhalten bleibt. function ValuePair({ start, end, deflatorStart, deflatorEnd, mode, devEnd, }: { start: number; end: number; deflatorStart: number; deflatorEnd: number; mode: ValueMode; // Abweichung des ENDwerts gegenüber dem Plan (nur in der Ist-Sicht gesetzt). Bewusst nur // ein Wert statt Start und Ende: Die Zelle soll nicht zur zweiten Tabelle werden. devEnd?: number | null; }) { const arrow = ; return ( {mode === "real" ? formatChf(realOf(start, deflatorStart)) : formatChf(start)} {arrow}{" "} {mode === "real" ? formatChf(realOf(end, deflatorEnd)) : formatChf(end)} {mode === "both" && ( ({formatChf(realOf(start, deflatorStart))}) {arrow} ({formatChf(realOf(end, deflatorEnd))}) )} ); } // Die Abweichung trägt als einziges Element Farbe -- würde man die Beträge selbst einfärben, // entstünde ein Ampelteppich, in dem nichts mehr heraussticht. function Deviation({ value, deflator = 1 }: { value?: number | null; deflator?: number }) { if (value == null || Math.round(value / (deflator || 1)) === 0) return null; const v = Math.round(value / (deflator || 1)); return ( 0 ? "text-success" : "text-danger"}`}> {v > 0 ? "▲ +" : "▼ −"} {formatChf(Math.abs(v))} ); } // Einzelwert, gleiche Konvention. function ValueSingle({ value, deflator, mode }: { value: number; deflator: number; mode: ValueMode }) { return ( {mode === "real" ? formatChf(realOf(value, deflator)) : formatChf(value)} {mode === "both" && ( ({formatChf(realOf(value, deflator))}) )} ); } function phaseCellContent( ce: ReturnType | undefined, phase: PhaseComputed, mode: ValueMode, // Endwert desselben Elements in derselben Phase laut PLAN -- null in der Plan-Sicht. planEnd?: number | null ): 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; const devEnd = planEnd == null ? null : ce.endValue - planEnd; if (START_END_CATEGORIES.includes(ce.category) && (ce.startValue !== 0 || ce.endValue !== 0)) { return ( ); } if ((ce.category === "AHV" || ce.category === "PENSION_FUND") && ce.startValue !== 0) { return ( Rente {mode === "real" ? formatChf(realOf(ce.startValue, dS)) : formatChf(ce.startValue)} {mode === "both" && ( ({formatChf(realOf(ce.startValue, dS))}) )} ); } return ce.summary || "–"; } function PhaseHeader({ phase, personLabel, mode, diffKind, onClick, onExpand, onDistributeCapital, onDistributeRates, onDelete, active, }: { phase: PhaseComputed; personLabel: (role: string) => string; mode: ValueMode; diffKind: "changed" | "added" | "removed" | null; onClick: () => void; onExpand: () => void; // null = Phase 1: dort gibt es kein verteilbares Übergangs-Kapital (additionalInvestment // wird in der ersten Phase von der Berechnung ignoriert, dort zählt der Startwert). onDistributeCapital: (() => void) | null; onDistributeRates: () => void; // null = nicht die letzte Phase (nur die letzte lässt sich löschen). onDelete: (() => void) | null; active: boolean; }) { const quotaLabel = phase.isConsumption ? "Verzehrquote" : "Sparquote"; const pot = capitalPot(phase); const dS = phase.cumulativeInflationStart; const dE = phase.cumulativeInflationEnd; // Bestandswerte (Cash, Vermögen) const dF = phase.flowDeflatorEnd; // Flow-Werte (Einkommen, Ausgaben, Quote) return (
{phase.name} {diffKind === "added" && ( neu )} {phase.cashNegative ? ( ) : ( )} {/* Aktionen zur Phase. Der Kopf selbst öffnet ebenfalls das Bearbeiten-Panel; die Knöpfe müssen das Event deshalb stoppen, sonst gingen zwei Dinge gleichzeitig auf. Bewusst IMMER sichtbar (Touch hat kein Hover). */} {onDelete && ( )}
{phase.type === "ERWERB" ? "Erwerb" : phase.type === "PENSION" ? "Pension" : "Misch"} {phase.durationYears} J.
{phase.persons.map((p) => (
{personLabel(p.role)} {p.startAge} → {p.endAge}
))}
{/* Verfügbares Kapital -- die Grösse, die man für die Planung DIESER Phase braucht. Nur zeigen, wenn überhaupt etwas da ist. */} {onDistributeCapital && pot.total > 0 && (
Verfügbares Kapital
{pot.allocatedInvestments + pot.allocatedRepayments > 0 && (
davon verteilt
)}
Rest auf Cash
)} {/* Spar- bzw. Verzehrquote */}
{quotaLabel}
Vermögen
{(phase.oneOffInflow > 0 || phase.oneOffOutflow > 0) && (
phase.oneOffInflow ? "text-danger" : "text-success"}`}> {phase.oneOffInflow > 0 ? `+ ${phase.oneOffInflowLabel ?? "Zufluss"}` : ""} {phase.oneOffInflow > 0 && phase.oneOffOutflow > 0 ? " · " : ""} {phase.oneOffOutflow > 0 ? `− ${phase.oneOffOutflowLabel ?? "Kosten"}` : ""}
)} {/* Alles Weitere (Einkommen, Ausgaben, Raten im Detail, Kapitalinvestitionen) steht in der Detailansicht -- erreichbar über das Expand-Icon oben. */}
); } function TransitionHeader({ counts, onClick }: { counts: DecisionCounts; onClick: () => void }) { // Eine ungeprüfte Vorgabe ist kein vergessenes Eingabefeld -- sie wird deshalb eigens // benannt und in gedeckterem Ton gezeigt (SPEZIFIKATION 3.5.3). const done = counts.open === 0 && counts.unconfirmed === 0; const onlyDefaults = counts.open === 0 && counts.unconfirmed > 0; return (
Übergang
{done ? (
geprüft
) : (
{counts.open > 0 && (
{counts.open} offen
)} {counts.unconfirmed > 0 && (
{counts.unconfirmed} Vorgabe{counts.unconfirmed === 1 ? "" : "n"}
)}
)} ); } function FragmentRows({ children }: { children: React.ReactNode }) { return <>{children}; } // --- "Nächste Schritte": abgeleitete Checkliste ------------------------------------------ // Führt neue (und wiederkehrende) Nutzer zum jeweils nächsten sinnvollen Schritt, statt // sie vor der Matrix raten zu lassen. Alles wird aus den vorhandenen Daten abgeleitet -- // kein eigener Zustand, kein API-Aufruf, verschwindet von selbst, wenn nichts offen ist. function NextSteps({ plan, computed, columns, openCountFor, onReview, onAddElement, onAddPhase, onOpenSensitivity, }: { plan: PlanInput; computed: PlanComputed; columns: Column[]; openCountFor: (fromPhase: PhaseComputed, toPhase: PhaseComputed) => DecisionCounts; onReview: (fromPhaseId: string) => void; onAddElement: (category: ElementCategory) => void; onAddPhase: () => void; onOpenSensitivity?: () => void; }) { if (computed.phases.length === 0) return null; const items: { key: string; text: string; action: string; run: () => void }[] = []; // Offene Übergangs-Entscheide (inkl. Cash) -- der wichtigste Handlungsbedarf. let openTotal = 0; let firstOpenPhaseId: string | null = null; for (const col of columns) { if (col.kind !== "transition") continue; const n = openCountFor(col.fromPhase, col.toPhase); openTotal += n.open; if (n.open > 0 && firstOpenPhaseId === null) firstOpenPhaseId = col.fromPhase.id; } if (openTotal > 0 && firstOpenPhaseId) { const target = firstOpenPhaseId; items.push({ key: "transitions", text: `${openTotal} Übergangs-Entscheid${openTotal === 1 ? "" : "e"} offen – z. B. ob eine Immobilie verkauft wird.`, action: "Jetzt durchgehen", run: () => onReview(target), }); } if (!plan.elements.some((e) => e.category === "INCOME")) { items.push({ key: "income", text: "Noch kein Einkommen erfasst – ohne Einkommen gibt es keine Sparquote und keine AHV-Rente.", action: "Einkommen erfassen", run: () => onAddElement("INCOME"), }); } if (!plan.elements.some((e) => e.category === "EXPENSE")) { items.push({ key: "expense", text: "Noch keine Ausgaben erfasst – sie sind erfahrungsgemäss der stärkste Hebel der ganzen Planung.", action: "Ausgaben erfassen", run: () => onAddElement("EXPENSE"), }); } // Plan endet vor der Pensionierung: die spannendste Phase fehlt noch. const totalYears = computed.phases.reduce((s, p) => s + p.durationYears, 0); if (plan.persons.some((p) => p.age + totalYears < p.retirementAge)) { items.push({ key: "pension-phase", text: "Dein Plan endet vor der Pensionierung – die entscheidende Phase fehlt noch.", action: "Phase anlegen", run: onAddPhase, }); } if (computed.ruinAge !== null && onOpenSensitivity) { items.push({ key: "ruin", text: `Das Kapital reicht nicht bis ans Planende (Ruin mit ${computed.ruinAge}). Finde heraus, welche Annahme am meisten bewegt.`, action: "Einflussfaktoren", run: onOpenSensitivity, }); } if (items.length === 0) return null; return (
Nächste Schritte
    {items.map((it) => (
  • {it.text}
  • ))}
); } // --- Dialog: neues finanzielles Element mit Direkteingabe der Phase-1-Werte --- function AddElementDialog({ plan, firstPhase, initialCategory, onClose, onCreated, }: { plan: PlanInput; firstPhase: PhaseComputed; initialCategory?: ElementCategory | null; onClose: () => void; onCreated: () => void; }) { const [category, setCategory] = useState(initialCategory ?? "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 für 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, mortgageEnd: 0, propertyValueEnd: 0, deflatorStart: firstPhase.cumulativeInflationStart, laterPhaseCount: 0, // beim Anlegen bewusst kein Warnhinweis ahvCareer: null, // Beim Anlegen gibt es keine Vorphase, aus der etwas zu erben waere. inheritedValues: {}, ownerAgeStart: firstPhase.persons.find((p) => p.role === (owner ?? "PERSON_A"))?.startAge ?? 0, investTargets: [], capitalFromTransfer: 0, capitalFromTransferSources: [], }; async function create() { setSaving(true); setError(null); try { const { element } = await api.post<{ element: { id: string } }>(`/api/scenarios/${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" />
); } // Ziele für die Anlage-Quote der Kapitalverwendung (Roadmap Nr. 44, Punkt C): alle // Vermögens-Elemente, die in der Folgephase noch aktiv sind. // Der Name allein genuegt hier nicht: Zwei Personen nennen ihr Wertschriftendepot beide "ETF", // und im Dropdown waehlt man dann blind das falsche -- was erst Phasen spaeter auffaellt. function investTargetsOf(plan: PlanInput, toPhase: PhaseComputed | undefined): { id: string; name: string }[] { return (toPhase?.elements ?? []) .filter((e) => e.category === "OTHER_ASSET" && e.status === "ACTIVE") .map((e) => { const owner = plan.elements.find((x) => x.id === e.elementId)?.ownerRole ?? null; return { id: e.elementId, name: `${e.name} · ${ownerLabel(plan.persons, owner)}` }; }); } // --- Panel: Szenario-Profil (Grundprofil bearbeiten) --- function ProfilePanel({ plan, onClose, onSaved }: { plan: PlanInput; onClose: () => void; onSaved: () => void }) { const [draft, setDraft] = useState({ householdType: plan.householdType, inflationRateDefault: plan.inflationRateDefault, startYear: plan.startYear ?? new Date().getFullYear(), 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/scenarios/${plan.id}`, draft); onSaved(); } catch (e) { setError(e instanceof Error ? e.message : "Speichern fehlgeschlagen."); } finally { setSaving(false); } } return (
0} /> {error &&

{error}

}
{plan.phases.length > 0 && (

Pensionsalter anpassen

{/* Wirkt SOFORT (eigener Endpunkt), weil dabei Phasendauern verschoben werden -- das lässt sich nicht sinnvoll mit dem Profil-Formular zusammen abspeichern. */}
)}
); } // --- Dialog: geführter Übergang --- // Pensionsalter des Element-Besitzers -- Bezugspunkt fuer die Vorgaben des Entscheids. function retirementAgeOf(plan: PlanInput, el: ElementInput): number { return plan.persons.find((p) => p.role === el.ownerRole)?.retirementAge ?? AHV_REFERENCE_AGE; } function TransitionReviewDialog({ plan, computed, fromPhase, toPhase, elements, initialCash, buildContext, isRetirement, onClose, onSaved, }: { plan: PlanInput; computed: PlanComputed; 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)); // Pensionierungs-Entscheide: eigener Entwurf, weil sie an einem anderen Ort liegen als die // Übergangswerte -- und beim Speichern über einen eigenen Endpunkt gehen. const [rds, setRds] = useState>({}); const effectiveRd = (el: ElementInput) => withRetirementDefaults(el.category, retirementAgeOf(plan, el), { ...(el.retirementDecision ?? {}), ...(rds[el.id] ?? {}), }); 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] ?? {}); } // Pensionierungs-Entscheide liegen am ELEMENT und haben deshalb einen eigenen Endpunkt. for (const [elementId, changes] of Object.entries(rds)) { const el = plan.elements.find((x) => x.id === elementId); await api.put(`/api/elements/${elementId}/retirement`, { ...(el?.retirementDecision ?? {}), ...changes }); } onSaved(); } catch (err) { setError(err instanceof Error ? err.message : "Speichern fehlgeschlagen."); } finally { setSaving(false); } } return (

Geh die Positionen durch und triff 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 Übergang. */}
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önntest du 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 } }))} retirement={ setRds((prev) => ({ ...prev, [id]: { ...prev[id], ...patch } }))} summary={computed.retirement.perPerson.find((x) => x.role === el.ownerRole)} /> } />
); })}
{error &&

{error}

}
); } // --- Panel: Cash-Übergang (einmalige Sonderein-/ausgaben) --- function CashTransitionPanel({ 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 (
setCt((prev) => ({ ...prev, ...patch }))} deflatorEnd={fromPhase.cumulativeInflationEnd} />
{error &&

{error}

}
); } // --- Panel: Cash-Anfangswert (erste Lebensphase) --- function CashInitialPanel({ 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/scenarios/${plan.id}`, { initialCash: value }); onSaved(); } catch (e) { setError(e instanceof Error ? e.message : "Speichern fehlgeschlagen."); } finally { setSaving(false); } } return (
{error &&

{error}

}
); } // --- Panel: einzelner Übergangs-Entscheid (per Klick auf eine Übergangszelle) --- function TransitionCellPanel({ plan, computed, element, fromPhase, toPhase, context, onClose, onSaved, }: { plan: PlanInput; computed: PlanComputed; 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 [rd, setRd] = useState({}); const effectiveRd = (el: ElementInput) => withRetirementDefaults(el.category, retirementAgeOf(plan, el), { ...(el.retirementDecision ?? {}), ...rd, }); 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); if (Object.keys(rd).length > 0) { await api.put(`/api/elements/${element.id}/retirement`, { ...(element.retirementDecision ?? {}), ...rd, }); } onSaved(); } catch (e) { setError(e instanceof Error ? e.message : "Speichern fehlgeschlagen."); } finally { setSaving(false); } } return (
setTd((prev) => ({ ...prev, ...patch }))} retirement={ ) => setRd((prev) => ({ ...prev, ...patch })) } summary={computed.retirement.perPerson.find((x) => x.role === element.ownerRole)} /> } />
{error &&

{error}

}
); } // --- gemeinsame Bausteine --- // Erstell-Flows und der geführte Übergang bleiben Modals (ui.Modal: ESC, Fokus-Falle, // Animation); Einzel-Bearbeitungen laufen über das Inspector-Panel. function DialogShell({ title, onClose, children, wide, }: { title: string; onClose: () => void; children: React.ReactNode; wide?: boolean; }) { return ( {children} ); } function DialogActions({ saving, onConfirm, onClose, confirmLabel, }: { saving: boolean; onConfirm: () => void; onClose: () => void; confirmLabel: string; }) { return (
); } // Aktionsleiste im Inspector-Panel. function PanelActions({ saving, onConfirm, onClose, confirmLabel, }: { saving: boolean; onConfirm: () => void; onClose: () => void; confirmLabel: string; }) { return (
); } // --- Panel: Element umbenennen und zuordnen ---------------------------------------------- // Beides sind Eigenschaften des ELEMENTS, nicht einer Phase -- deshalb ein eigenes Panel, // erreichbar über das Stift-Symbol in der Element-Zeile. function ElementMetaPanel({ element, plan, personLabel, onSaved, onDelete, }: { element: ElementInput; plan: PlanInput; personLabel: (role: string) => string; onSaved: () => void; onDelete: () => void; }) { const [name, setName] = useState(element.name); const [ownerRole, setOwnerRole] = useState(element.ownerRole ?? "HOUSEHOLD"); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); const needsPerson = PERSON_ONLY_CATEGORIES.includes(element.category); const isCouple = plan.householdType === "COUPLE"; const options = needsPerson ? isCouple ? [ { value: "PERSON_A", label: personLabel("PERSON_A") }, { value: "PERSON_B", label: personLabel("PERSON_B") }, ] : [{ value: "PERSON_A", label: personLabel("PERSON_A") }] : isCouple ? [ { value: "HOUSEHOLD", label: "Gemeinsam" }, { value: "PERSON_A", label: personLabel("PERSON_A") }, { value: "PERSON_B", label: personLabel("PERSON_B") }, ] : [ { value: "HOUSEHOLD", label: "Gemeinsam" }, { value: "PERSON_A", label: personLabel("PERSON_A") }, ]; async function save() { setSaving(true); setError(null); try { await api.patch(`/api/elements/${element.id}`, { name: name.trim() || element.name, ownerRole }); onSaved(); } catch (e) { setError(e instanceof Error ? e.message : "Speichern fehlgeschlagen."); } finally { setSaving(false); } } return (
{CATEGORY_LABELS[element.category]}

Die Werte je Lebensphase bearbeitest du direkt in den Zellen der Matrix.

{error &&

{error}

}
); }