Detailansichten, Wasserfaelle und vollstaendige Rechenweg-Offenlegung
Deploy App / deploy (push) Successful in 57s

Roadmap Nr. 43 (Detailansichten) und Nr. 41 (Berechnungslogiken offenlegen).

Systemparameter-Ansicht:
- SYSTEM_PARAMETERS in constants.ts: Wert, Bedeutung, Herleitung, Quelle,
  Stand -- aus derselben Datei, aus der gerechnet wird

Detailansichten (nur lesen, per Expand-Icon):
- Element: Verlauf ueber ALLE Planjahre (neu ElementPhaseComputed.yearly),
  bei Immobilien Verkehrswert/Restschuld/Eigenkapital getrennt
- Phase: Vermoegensaufteilung + zwei Wasserfaelle

Zwei getrennte Wasserfaelle (WealthBridge / CashBridge):
- Sparraten, Amortisationen und Investitionen sind UMBUCHUNGEN und erscheinen
  nur im Cash-Wasserfall -- als Vermoegensabgang gezeichnet wuerden sie einen
  Verlust vortaeuschen, den es nicht gibt
- PK-Beitraege dagegen sind ein echter Vermoegenszugang (belasten kein Cash),
  Verrentung ein echter Abgang (Kapital verlaesst die Bilanz)
- residual als Kontrollgroesse fuer die Vollstaendigkeit der Zerlegung

Rechenweg-Protokoll, vollstaendige Abdeckung:
- computePlan(plan, sample?, { explain }) protokolliert die Schritte, die es
  ohnehin ausfuehrt -- die Erklaerung IST die Rechnung, statt einer zweiten
  Formel-Implementierung im UI, die still abdriften koennte
- standardmaessig aus (Monte Carlo bleibt unberuehrt)
- Arithmetik nicht umgestellt: Zwischengroessen werden als Differenz
  abgeleitet, damit die 43 Golden Tests bitgleich bleiben
- jeder Trace verlinkt in die SPEZIFIKATION; ein Test prueft gegen die echte
  Datei, dass alle Verweise eine existierende Ueberschrift treffen

SPEZIFIKATION auf 0.11: neue Kapitel 3.6.7, 3.6.8, 4.14, 9.20, 9.21.
12 Tests ergaenzt (80 -> 92). Keine DB-Aenderung.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-18 17:12:55 +02:00
parent 1836cad7f3
commit 4791dccf93
9 changed files with 2066 additions and 33 deletions
+511
View File
@@ -0,0 +1,511 @@
"use client";
import { useMemo, useState } from "react";
import {
Bar,
BarChart,
CartesianGrid,
Cell,
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 ---------------------------------------------------------------------------
// Recharts kennt keinen Wasserfall: Er entsteht aus zwei gestapelten Balken -- einem
// unsichtbaren Sockel und dem sichtbaren Delta darueber.
interface WaterfallItem {
label: string;
value: number;
total?: boolean; // Zwischen-/Endsumme: startet bei 0 statt beim laufenden Saldo
}
function waterfallData(items: WaterfallItem[]) {
let running = 0;
return items.map((it) => {
if (it.total) {
running = it.value;
return { label: it.label, base: 0, delta: Math.abs(it.value), value: it.value, kind: "total" as const };
}
const start = running;
running += it.value;
return {
label: it.label,
base: Math.min(start, running),
delta: Math.abs(it.value),
value: it.value,
kind: (it.value >= 0 ? "pos" : "neg") as "pos" | "neg",
};
});
}
const WF_COLOR = { total: "var(--accent)", pos: "#16a34a", neg: "#dc2626" };
function Waterfall({ items, height = 300 }: { items: WaterfallItem[]; height?: number }) {
const data = useMemo(() => waterfallData(items), [items]);
if (data.length === 0) return null;
return (
<div className="w-full" style={{ height }}>
<ResponsiveContainer width="100%" height="100%">
<BarChart data={data} margin={{ top: 8, right: 16, left: 8, bottom: 60 }}>
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
<XAxis dataKey="label" tick={{ fontSize: 10 }} interval={0} angle={-32} textAnchor="end" height={70} />
<YAxis
tick={{ fontSize: 11 }}
tickFormatter={(v) => Intl.NumberFormat("de-CH", { notation: "compact" }).format(v)}
/>
<Tooltip
cursor={{ fill: "var(--surface-2)" }}
formatter={(_v, _n, p) => [formatChf(p?.payload?.value ?? 0), p?.payload?.label ?? ""]}
labelFormatter={() => ""}
/>
<Bar dataKey="base" stackId="w" fill="transparent" isAnimationActive={false} />
<Bar dataKey="delta" stackId="w" isAnimationActive={false} radius={[2, 2, 0, 0]}>
{data.map((d, i) => (
<Cell key={i} fill={WF_COLOR[d.kind]} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
);
}
function bridgeItems(w: WealthBridge, isFirst: boolean): WaterfallItem[] {
const items: WaterfallItem[] = [];
if (!isFirst) {
items.push({ label: "Vermögen Ende Vorphase", value: w.openingWealth, total: true });
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 verrentet", value: -w.pensionConversion });
if (w.saleGainLoss) items.push({ label: "Verkaufsdifferenz", value: w.saleGainLoss });
}
items.push({ label: "Vermögen Phasenbeginn", value: w.startWealth, total: true });
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 });
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 });
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 (
<tr className="border-t border-border align-top">
<td className="px-3 py-1.5 text-fg">{step.label}</td>
<td className="px-3 py-1.5 text-[11px] text-muted">
{step.formula && <div className="font-mono">{step.formula}</div>}
{step.substituted && unit !== "" && <div className="font-mono text-faint">{step.substituted}</div>}
{step.note && <div className="mt-0.5 italic text-faint">{step.note}</div>}
</td>
<td className="whitespace-nowrap px-3 py-1.5 text-right font-medium text-fg">{value}</td>
</tr>
);
}
export function TraceBlock({ trace, onOpenSpec }: { trace: Trace; onOpenSpec?: (anchor: string) => void }) {
return (
<div className="rounded-xl border border-border">
<div className="flex items-center justify-between gap-2 border-b border-border bg-surface-2 px-3 py-2">
<span className="text-xs font-semibold text-fg">{trace.title}</span>
{trace.specAnchor && onOpenSpec && (
<button
type="button"
onClick={() => onOpenSpec(trace.specAnchor!)}
className="flex shrink-0 items-center gap-1 rounded-md border border-border px-2 py-0.5 text-[11px] text-muted hover:bg-surface"
>
<BookOpen className="h-3 w-3" /> in der Spezifikation
</button>
)}
</div>
<table className="w-full border-collapse text-sm">
<tbody>
{trace.steps.map((s, i) => (
<TraceStepRow key={i} step={s} />
))}
</tbody>
</table>
</div>
);
}
// --- 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 (
<div className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/40 px-4 py-8" onClick={onClose}>
<div
onClick={(e) => e.stopPropagation()}
className="flex w-full max-w-4xl flex-col gap-4 rounded-2xl border border-border bg-surface p-6 shadow-xl"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h2 className="truncate text-base font-semibold text-fg">{title}</h2>
{subtitle && <p className="text-xs text-muted">{subtitle}</p>}
</div>
<button type="button" onClick={onClose} aria-label="Schliessen" className="rounded-md p-1 text-faint hover:bg-surface-2">
<X className="h-4 w-4" />
</button>
</div>
<div className="inline-flex w-fit rounded-lg border border-border bg-surface p-0.5 text-xs">
{tabs.map((t) => (
<button
key={t.key}
type="button"
onClick={() => setActive(t.key)}
className={`rounded-md px-3 py-1 font-medium ${
current?.key === t.key ? "bg-accent text-accent-fg" : "text-muted hover:bg-surface-2"
}`}
>
{t.label}
</button>
))}
</div>
<div className="flex flex-col gap-4">{current?.content}</div>
<p className="text-[11px] text-faint">Nur-Lese-Ansicht. Werte werden hier nicht verändert.</p>
</div>
</div>
);
}
// --- 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 ueber 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";
const verlauf = (
<>
{points.length === 0 ? (
<p className="text-sm text-muted">Für dieses Element gibt es in diesem Plan keinen Verlauf.</p>
) : (
<div className="h-80 w-full">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={points} margin={{ top: 8, right: 16, left: 8, bottom: 8 }}>
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
<XAxis dataKey="age" type="number" domain={["dataMin", "dataMax"]} tick={{ fontSize: 11 }} tickFormatter={(v) => `${v} J.`} />
<YAxis tick={{ fontSize: 11 }} tickFormatter={(v) => Intl.NumberFormat("de-CH", { notation: "compact" }).format(v)} />
<Tooltip formatter={(v, n) => [typeof v === "number" ? formatChf(v) : v, n]} labelFormatter={(v) => `Alter ${v}`} />
<Legend wrapperStyle={{ fontSize: 12 }} />
<Line dataKey="value" name={valueLabel} stroke="var(--accent)" strokeWidth={2} dot={false} isAnimationActive={false} />
{MULTI_SERIES.includes(category) && (
<>
<Line dataKey="propertyValue" name="Verkehrswert" stroke="#0ea5e9" strokeWidth={1.5} dot={false} isAnimationActive={false} />
<Line dataKey="mortgage" name="Restschuld" stroke="#dc2626" strokeWidth={1.5} strokeDasharray="5 3" dot={false} isAnimationActive={false} />
</>
)}
</LineChart>
</ResponsiveContainer>
</div>
)}
<div className="overflow-x-auto rounded-xl border border-border">
<table className="w-full border-collapse text-sm">
<thead>
<tr className="bg-surface-2 text-xs text-faint">
<th className="px-3 py-2 text-left font-semibold">Lebensphase</th>
<th className="px-3 py-2 text-right font-semibold">Beginn</th>
<th className="px-3 py-2 text-right font-semibold">Ende</th>
<th className="px-3 py-2 text-left font-semibold">Hinweis</th>
</tr>
</thead>
<tbody>
{perPhase.map(({ phase, ec }) => (
<tr key={phase.id} className="border-t border-border">
<td className="px-3 py-2 text-fg">{phase.name}</td>
<td className="px-3 py-2 text-right text-muted">{formatChf(ec!.startValue)}</td>
<td className="px-3 py-2 text-right text-muted">{formatChf(ec!.endValue)}</td>
<td className="px-3 py-2 text-[11px] text-faint">{ec!.note ?? ""}</td>
</tr>
))}
</tbody>
</table>
</div>
</>
);
const rechenweg = (
<>
{perPhase.every(({ ec }) => !ec!.trace && !ec!.transitionTrace) && (
<p className="text-sm text-muted">Für dieses Element gibt es keinen eigenen Rechenweg.</p>
)}
{perPhase.map(({ phase, ec }) => (
<div key={phase.id} className="flex flex-col gap-2">
{(ec!.trace || ec!.transitionTrace) && (
<div className="text-xs font-semibold uppercase tracking-wide text-faint">{phase.name}</div>
)}
{ec!.trace && <TraceBlock trace={ec!.trace} onOpenSpec={onOpenSpec} />}
{ec!.transitionTrace && <TraceBlock trace={ec!.transitionTrace} onOpenSpec={onOpenSpec} />}
</div>
))}
</>
);
return (
<DetailShell
title={name}
subtitle={CATEGORY_LABELS[category]}
onClose={onClose}
tabs={[
{ key: "verlauf", label: "Verlauf", content: verlauf },
{ key: "rechenweg", label: "Rechenweg", content: rechenweg },
]}
/>
);
}
// --- Phasen-Detailansicht ------------------------------------------------------------------
const ASSET_CATS: ElementCategory[] = ["PENSION_FUND", "PILLAR_3A", "REAL_ESTATE", "OTHER_ASSET"];
const ALLOC_PALETTE = ["#4f46e5", "#0ea5e9", "#16a34a", "#d97706", "#dc2626", "#7c3aed"];
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 uebersicht = (
<>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<Kpi label="Typ" text={phase.type === "ERWERB" ? "Erwerb" : phase.type === "PENSION" ? "Pension" : "Misch"} />
<Kpi label="Dauer" text={`${phase.durationYears} Jahre`} />
<Kpi label="Vermögen Beginn" text={formatChf(phase.startWealthNominal)} />
<Kpi label="Vermögen Ende" text={formatChf(phase.endWealthNominal)} />
</div>
<section>
<h3 className="mb-1 text-xs font-semibold text-fg">Vermögensaufteilung</h3>
<p className="mb-2 text-[11px] text-muted">Zusammensetzung des Anlagevermögens zu Beginn und am Ende dieser Phase.</p>
{allocEls.length === 0 ? (
<p className="text-sm text-muted">In dieser Phase gibt es kein Anlagevermögen.</p>
) : (
<div className="h-56 w-full">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={alloc} margin={{ top: 8, right: 16, left: 8, bottom: 8 }}>
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
<XAxis dataKey="label" tick={{ fontSize: 11 }} />
<YAxis tick={{ fontSize: 11 }} tickFormatter={(v) => Intl.NumberFormat("de-CH", { notation: "compact" }).format(v)} />
<Tooltip formatter={(v) => (typeof v === "number" ? formatChf(v) : v)} />
<Legend wrapperStyle={{ fontSize: 12 }} />
{allocEls.map((el, i) => (
<Bar key={el.elementId} dataKey={el.elementId} name={el.name} stackId="a" fill={ALLOC_PALETTE[i % ALLOC_PALETTE.length]} isAnimationActive={false} />
))}
</BarChart>
</ResponsiveContainer>
</div>
)}
</section>
</>
);
const wasserfall = (
<>
<section>
<h3 className="mb-1 text-xs font-semibold text-fg">Woher kommt die Vermögensänderung?</h3>
<p className="mb-2 text-[11px] text-muted">
Nur <strong>echte</strong> Zu- und Abgänge. Sparraten, Amortisationen und Zusatzinvestitionen erscheinen hier
bewusst <strong>nicht</strong>: 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.
</p>
<Waterfall items={bridgeItems(phase.wealthBridge, isFirst)} />
{Math.abs(phase.wealthBridge.residual) > 2 && (
<p className="text-[11px] text-faint">
Rundungsdifferenz: {formatChf(phase.wealthBridge.residual)}
</p>
)}
</section>
<section>
<h3 className="mb-1 text-xs font-semibold text-fg">Wohin ist das Cash geflossen?</h3>
<p className="mb-2 text-[11px] text-muted">
Hier erscheinen die Umbuchungen: Sparraten, Amortisationen und Investitionen verlassen das Cash-Konto, auch
wenn sie das Vermögen nicht mindern.
</p>
<Waterfall items={cashItems(phase.cashBridge, isFirst)} />
{Math.abs(phase.cashBridge.residual) > 2 && (
<p className="text-[11px] text-faint">Rundungsdifferenz: {formatChf(phase.cashBridge.residual)}</p>
)}
</section>
</>
);
const rechenweg = (
<>
{(phase.traces ?? []).length === 0 && <p className="text-sm text-muted">Kein Rechenweg verfügbar.</p>}
{(phase.traces ?? []).map((t, i) => (
<TraceBlock key={i} trace={t} onOpenSpec={onOpenSpec} />
))}
</>
);
return (
<DetailShell
title={phase.name}
subtitle={`Lebensphase ${phase.sequenceNumber} · ${phase.durationYears} Jahre`}
onClose={onClose}
tabs={[
{ key: "uebersicht", label: "Übersicht", content: uebersicht },
{ key: "wasserfall", label: "Wasserfall", content: wasserfall },
{ key: "rechenweg", label: "Rechenweg", content: rechenweg },
]}
/>
);
}
function Kpi({ label, text }: { label: string; text: string }) {
return (
<div className="rounded-lg border border-border bg-surface-2 px-3 py-2">
<div className="text-[11px] text-muted">{label}</div>
<div className="text-sm font-semibold text-fg">{text}</div>
</div>
);
}
// --- Plan-weite Rechenwege (Deflatoren, AHV-Karriere, Ruinalter) ---------------------------
export function PlanTraceDialog({
computed,
onClose,
onOpenSpec,
}: {
computed: PlanComputed;
onClose: () => void;
onOpenSpec?: (anchor: string) => void;
}) {
return (
<DetailShell
title="Rechenwege dieses Szenarios"
subtitle="Plan-weite Grössen, die in alle Phasen hineinwirken"
onClose={onClose}
tabs={[
{
key: "plan",
label: "Plan-Ebene",
content: (
<>
{(computed.traces ?? []).map((t, i) => (
<TraceBlock key={i} trace={t} onOpenSpec={onOpenSpec} />
))}
</>
),
},
]}
/>
);
}