"use client"; import { useMemo, useState } from "react"; import { Bar, BarChart, CartesianGrid, Legend, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis, } from "recharts"; import { BookOpen, X } from "lucide-react"; import { formatChf } from "@/lib/format"; import { CATEGORY_LABELS } from "@/lib/elements"; import type { ElementCategory } from "@/lib/elements"; import type { CashBridge, ElementYearPoint, PhaseComputed, PlanComputed, Trace, TraceStep, WealthBridge, } from "@/lib/calculations"; // --- Wasserfall --------------------------------------------------------------------------- // Bewusst NICHT mit Recharts, sondern als eigene HTML/CSS-Darstellung. Ein Wasserfall lebt // von drei Dingen, die Recharts hier nicht hergibt: Verbindungslinien zwischen den Balken // (ohne sie sieht man nicht, dass jeder Balken dort ansetzt, wo der vorherige aufhört), // Wertbeschriftung an jedem Balken, und eine klare optische Trennung von Zwischenständen // und Veränderungen. // // Liegend statt stehend: Die Beschriftungen sind lang ("Wertsteigerung Immobilie"), stehend // müssten sie gedreht werden. Liegend ist es ausserdem konsistent zum Tornado. interface WaterfallItem { label: string; value: number; total?: boolean; // Zwischen-/Endsumme: absoluter Stand statt Veränderung section?: string; // optionale Abschnitts-Überschrift VOR diesem Eintrag } interface WaterfallRow { label: string; section?: string; from: number; to: number; value: number; running: number; // Stand NACH diesem Schritt kind: "total" | "pos" | "neg"; } function waterfallRows(items: WaterfallItem[]): WaterfallRow[] { let running = 0; return items.map((it) => { if (it.total) { running = it.value; return { label: it.label, section: it.section, from: 0, to: it.value, value: it.value, running, kind: "total" as const }; } const from = running; running += it.value; return { label: it.label, section: it.section, from, to: running, value: it.value, running, kind: (it.value >= 0 ? "pos" : "neg") as "pos" | "neg", }; }); } const WF_FILL = { total: "var(--accent)", pos: "#16a34a", neg: "#dc2626" }; const ROW_H = 34; const BAR_H = 20; function Waterfall({ items }: { items: WaterfallItem[] }) { const rows = useMemo(() => waterfallRows(items), [items]); if (rows.length === 0) return null; const lo = Math.min(0, ...rows.map((r) => Math.min(r.from, r.to))); const hi = Math.max(0, ...rows.map((r) => Math.max(r.from, r.to))); const span = hi - lo || 1; const pos = (v: number) => ((v - lo) / span) * 100; return (
{rows.map((r, i) => { const left = pos(Math.min(r.from, r.to)); const width = Math.max(0.4, Math.abs(pos(r.to) - pos(r.from))); const isLast = i === rows.length - 1; return (
{r.section && (
{r.section}
)}
{r.label}
{/* Nulllinie */}
{/* Balken */}
{/* Verbindungslinie zum nächsten Balken: auf dem Stand NACH diesem Schritt */} {!isLast && (
)}
{r.kind === "total" ? formatChf(r.value) : `${r.value >= 0 ? "+" : "−"}${formatChf(Math.abs(r.value))}`}
); })}
{/* Zahlen mit laufendem Zwischenstand -- bei stark unterschiedlichen Grössenordnungen ist die Tabelle der Grafik überlegen. */}
Zahlen mit Zwischenstand anzeigen
{rows.map((r, i) => ( ))}
Schritt Betrag Zwischenstand
{r.label} {r.kind === "total" ? "—" : `${r.value >= 0 ? "+" : "−"}${formatChf(Math.abs(r.value))}`} {formatChf(r.running)}
); } // Kontrollgrösse: Ist die Zerlegung vollständig, muss die Differenz zwischen Endwert und // der Summe der Schritte 0 sein. Sichtbar machen statt verstecken -- ein Wasserfall, der // nicht aufgeht, ist ein Fehler und kein Schönheitsproblem. function ResidualNote({ residual }: { residual: number }) { if (Math.abs(residual) <= 2) return null; return (

Die Zerlegung geht nicht auf. Nicht zugeordnete Differenz: {formatChf(residual)}. Bitte melden – das ist ein Fehler in der Berechnung, nicht in der Darstellung.

); } function bridgeItems(w: WealthBridge, isFirst: boolean): WaterfallItem[] { const items: WaterfallItem[] = []; if (!isFirst) { items.push({ label: "Vermögen Ende Vorphase", value: w.openingWealth, total: true, section: "Am Übergang in diese Phase" }); if (w.oneOffInflow) items.push({ label: "Einmaliger Zufluss", value: w.oneOffInflow }); if (w.oneOffOutflow) items.push({ label: "Einmalige Kosten", value: -w.oneOffOutflow }); if (w.transitionTax) items.push({ label: "Steuern am Übergang", value: -w.transitionTax }); if (w.pensionConversion) items.push({ label: "PK in Rente umgewandelt", value: -w.pensionConversion }); if (w.saleGainLoss) items.push({ label: "Verkaufsdifferenz", value: w.saleGainLoss }); } items.push({ label: "Vermögen Phasenbeginn", value: w.startWealth, total: true, section: isFirst ? undefined : "Innerhalb der Phase", }); if (w.quotaTotal) items.push({ label: "Spar-/Verzehrquote", value: w.quotaTotal }); if (w.investmentReturn) items.push({ label: "Kapitalerträge", value: w.investmentReturn }); if (w.propertyAppreciation) items.push({ label: "Wertsteigerung Immobilie", value: w.propertyAppreciation }); if (w.pensionFundContribution) items.push({ label: "PK-Beiträge", value: w.pensionFundContribution }); items.push({ label: "Vermögen Phasenende", value: w.endWealth, total: true }); return items; } function cashItems(c: CashBridge, isFirst: boolean): WaterfallItem[] { const items: WaterfallItem[] = []; items.push({ label: isFirst ? "Cash-Anfangswert" : "Cash Ende Vorphase", value: c.openingCash, total: true, section: isFirst ? undefined : "Am Übergang in diese Phase", }); if (c.capitalInflow) items.push({ label: "Kapitalzufluss", value: c.capitalInflow }); if (c.oneOffInflow) items.push({ label: "Einmaliger Zufluss", value: c.oneOffInflow }); if (c.immediateRepay) items.push({ label: "Sofort-Tilgung", value: -c.immediateRepay }); if (c.oneOffOutflow) items.push({ label: "Einmalige Kosten", value: -c.oneOffOutflow }); if (c.investments) items.push({ label: "Investitionen", value: -c.investments }); items.push({ label: "Cash Phasenbeginn", value: c.cashStart, total: true, section: "Innerhalb der Phase" }); if (c.quotaTotal) items.push({ label: "Spar-/Verzehrquote", value: c.quotaTotal }); if (c.savingRates) items.push({ label: "Sparraten", value: -c.savingRates }); if (c.debtRates) items.push({ label: "Amort./Tilgung", value: -c.debtRates }); if (c.withdrawals) items.push({ label: "Bezugsraten", value: c.withdrawals }); items.push({ label: "Cash Phasenende", value: c.cashEnd, total: true }); return items; } // --- Rechenweg ---------------------------------------------------------------------------- function TraceStepRow({ step }: { step: TraceStep }) { const unit = step.unit ?? "CHF"; const value = unit === "" ? step.substituted ?? "" : unit === "%" ? `${step.result} %` : unit === "Jahre" ? `${step.result}` : formatChf(step.result); return ( {step.label} {step.formula &&
{step.formula}
} {step.substituted && unit !== "" &&
{step.substituted}
} {step.note &&
{step.note}
} {value} ); } export function TraceBlock({ trace, onOpenSpec }: { trace: Trace; onOpenSpec?: (anchor: string) => void }) { return (
{trace.title} {trace.specAnchor && onOpenSpec && ( )}
{trace.steps.map((s, i) => ( ))}
); } // --- Gemeinsame Dialog-Huelle mit Reitern -------------------------------------------------- function DetailShell({ title, subtitle, tabs, onClose, }: { title: string; subtitle?: string; tabs: { key: string; label: string; content: React.ReactNode }[]; onClose: () => void; }) { const [active, setActive] = useState(tabs[0]?.key); const current = tabs.find((t) => t.key === active) ?? tabs[0]; return (
e.stopPropagation()} className="flex w-full max-w-4xl flex-col gap-4 rounded-2xl border border-border bg-surface p-6 shadow-xl" >

{title}

{subtitle &&

{subtitle}

}
{tabs.map((t) => ( ))}
{current?.content}

Nur-Lese-Ansicht. Werte werden hier nicht verändert.

); } // --- Element-Detailansicht ---------------------------------------------------------------- const MULTI_SERIES: ElementCategory[] = ["REAL_ESTATE"]; export function ElementDetailDialog({ elementId, name, category, computed, onClose, onOpenSpec, }: { elementId: string; name: string; category: ElementCategory; computed: PlanComputed; onClose: () => void; onOpenSpec?: (anchor: string) => void; }) { // Verlauf über ALLE Phasen zusammensetzen. const points = useMemo(() => { const out: ElementYearPoint[] = []; for (const ph of computed.phases) { const ec = ph.elements.find((e) => e.elementId === elementId); if (ec) out.push(...ec.yearly); } return out; }, [computed, elementId]); const perPhase = useMemo( () => computed.phases .map((ph) => ({ phase: ph, ec: ph.elements.find((e) => e.elementId === elementId) })) .filter((x) => x.ec), [computed, elementId] ); const isFlow = category === "INCOME" || category === "EXPENSE"; const valueLabel = isFlow ? category === "INCOME" ? "Einkommen (nominal)" : "Ausgaben (nominal)" : category === "OTHER_DEBT" ? "Beitrag zum Vermögen" : category === "REAL_ESTATE" ? "Eigenkapital" : "Wert"; // Zweite Achse nur, wenn dieses Element überhaupt eine Rate trägt (AHV und Schulden nicht). const rateLabel = category === "REAL_ESTATE" ? "Wertsteigerung" : isFlow ? category === "INCOME" ? "Lohnentwicklung" : "Reale Mehrausgaben" : "Erwartete Rendite"; const hasRate = points.some((p) => typeof p.rate === "number"); const verlauf = ( <> {points.length === 0 ? (

Für dieses Element gibt es in diesem Plan keinen Verlauf.

) : (
`${v} J.`} /> Intl.NumberFormat("de-CH", { notation: "compact" }).format(v)} /> {hasRate && ( `${v} %`} // Etwas Luft nach oben und unten, damit eine konstante Rate nicht als // Linie direkt auf der Achse klebt. domain={([min, max]: readonly [number, number]) => [Math.min(0, min - 1), max + 1]} /> )} typeof v !== "number" ? [v, n] : n === rateLabel ? [`${v} %`, n] : [formatChf(v), n] } labelFormatter={(v) => `Alter ${v}`} /> {MULTI_SERIES.includes(category) && ( <> )} {hasRate && ( // Stufenlinie, nicht interpoliert: Die Rate ist innerhalb einer Phase // konstant und springt an der Phasengrenze. Eine weiche Kurve würde einen // gleitenden Übergang suggerieren, den die Berechnung nicht macht. )}
)}
{perPhase.map(({ phase, ec }) => ( ))}
Lebensphase Beginn Ende Hinweis
{phase.name} {formatChf(ec!.startValue)} {formatChf(ec!.endValue)} {ec!.note ?? ""}
); const rechenweg = ( <> {perPhase.every(({ ec }) => !ec!.trace && !ec!.transitionTrace) && (

Für dieses Element gibt es keinen eigenen Rechenweg.

)} {perPhase.map(({ phase, ec }) => (
{(ec!.trace || ec!.transitionTrace) && (
{phase.name}
)} {ec!.trace && } {ec!.transitionTrace && }
))} ); return ( ); } // --- Phasen-Detailansicht ------------------------------------------------------------------ const ASSET_CATS: ElementCategory[] = ["PENSION_FUND", "PILLAR_3A", "REAL_ESTATE", "OTHER_ASSET"]; const ALLOC_PALETTE = ["#4f46e5", "#0ea5e9", "#16a34a", "#d97706", "#dc2626", "#7c3äd"]; export function PhaseDetailDialog({ phase, isFirst, onClose, onOpenSpec, }: { phase: PhaseComputed; isFirst: boolean; onClose: () => void; onOpenSpec?: (anchor: string) => void; }) { const alloc = useMemo(() => { const rows = phase.elements.filter((e) => ASSET_CATS.includes(e.category) && (e.startValue > 0 || e.endValue > 0)); return [ { label: "Beginn", ...Object.fromEntries(rows.map((r) => [r.elementId, Math.max(0, r.startValue)])) }, { label: "Ende", ...Object.fromEntries(rows.map((r) => [r.elementId, Math.max(0, r.endValue)])) }, ]; }, [phase]); const allocEls = useMemo( () => phase.elements.filter((e) => ASSET_CATS.includes(e.category) && (e.startValue > 0 || e.endValue > 0)), [phase] ); const übersicht = ( <>

Vermögensaufteilung

Zusammensetzung des Anlagevermögens zu Beginn und am Ende dieser Phase.

{allocEls.length === 0 ? (

In dieser Phase gibt es kein Anlagevermögen.

) : (
Intl.NumberFormat("de-CH", { notation: "compact" }).format(v)} /> (typeof v === "number" ? formatChf(v) : v)} /> {allocEls.map((el, i) => ( ))}
)}
); const wasserfall = ( <>

Woher kommt die Vermögensänderung?

Nur echte Zu- und Abgänge. Sparraten, Amortisationen und Zusatzinvestitionen erscheinen hier bewusst nicht: Sie verschieben Geld vom Cash in einen Vermögenswert, ohne das Vermögen zu verändern – als Balken gezeichnet würden sie einen Verlust vortäuschen, den es nicht gibt.

Wohin ist das Cash geflossen?

Hier erscheinen die Umbuchungen: Sparraten, Amortisationen und Investitionen verlassen das Cash-Konto, auch wenn sie das Vermögen nicht mindern.

); const rechenweg = ( <> {(phase.traces ?? []).length === 0 &&

Kein Rechenweg verfügbar.

} {(phase.traces ?? []).map((t, i) => ( ))} ); return ( ); } function Kpi({ label, text }: { label: string; text: string }) { return (
{label}
{text}
); } // --- Plan-weite Rechenwege (Deflatoren, AHV-Karriere, Ruinalter) --------------------------- export function PlanTraceDialog({ computed, onClose, onOpenSpec, }: { computed: PlanComputed; onClose: () => void; onOpenSpec?: (anchor: string) => void; }) { return ( {(computed.traces ?? []).map((t, i) => ( ))} ), }, ]} /> ); }