Lesbare Wasserfaelle, Verkaufspreis-Abgleich, Erklaerung wirkungsloser Treiber
Deploy App / deploy (push) Successful in 59s
Deploy App / deploy (push) Successful in 59s
Die Wasserfall-Zahlen waren korrekt (residual exakt 0), die Darstellung nicht lesbar. Neu als eigene liegende HTML/CSS-Darstellung statt Recharts: - Verbindungslinien zwischen den Balken (ohne sie zerfaellt der Wasserfall in unverbundene Rechtecke) - Wertbeschriftung an jedem Schritt - Zwischenstand und Veraenderung optisch unterschieden - Abschnitte "Am Uebergang" / "Innerhalb der Phase" - aufklappbare Tabelle mit laufendem Zwischenstand - Nullposten werden nicht gezeichnet - Restposten neu als Fehlermeldung statt beilaeufiger Rundungsnotiz Verkaufspreis einer Immobilie wird beim Wechsel auf "Verkaufen" mit dem modellierten Verkehrswert vorbelegt (nur wenn noch keiner erfasst ist); Verkehrswert und Abweichung werden ausgewiesen, ab 10 % rot abgesetzt. Die beiden Groessen bleiben bewusst entkoppelt -- ein Verkauf unter Verkehrswert ist ein realer Fall. Tornado erklaert Nullbalken statt sie stumm zu zeigen. Wichtigster Fall: Wird die Immobilie vor Planende verkauft, ist die Wertsteigerung nachweislich wirkungslos, weil der Erloes am erfassten Verkaufspreis haengt und nicht am Verkehrswert. 11 Tests ergaenzt (92 -> 103), darunter residual === 0 ueber sieben Plankonstellationen. SPEZIFIKATION auf 0.12, neue Kapitel 3.5.8, 4.13.5, 4.14.2.1, 9.22. Keine Aenderung an der Berechnung. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+166
-48
@@ -5,7 +5,6 @@ import {
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
Cell,
|
||||
Legend,
|
||||
Line,
|
||||
LineChart,
|
||||
@@ -29,77 +28,197 @@ import type {
|
||||
} from "@/lib/calculations";
|
||||
|
||||
// --- Wasserfall ---------------------------------------------------------------------------
|
||||
// Recharts kennt keinen Wasserfall: Er entsteht aus zwei gestapelten Balken -- einem
|
||||
// unsichtbaren Sockel und dem sichtbaren Delta darueber.
|
||||
// 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 aufhoert),
|
||||
// Wertbeschriftung an jedem Balken, und eine klare optische Trennung von Zwischenstaenden
|
||||
// und Veraenderungen.
|
||||
//
|
||||
// Liegend statt stehend: Die Beschriftungen sind lang ("Wertsteigerung Immobilie"), stehend
|
||||
// muessten sie gedreht werden. Liegend ist es ausserdem konsistent zum Tornado.
|
||||
|
||||
interface WaterfallItem {
|
||||
label: string;
|
||||
value: number;
|
||||
total?: boolean; // Zwischen-/Endsumme: startet bei 0 statt beim laufenden Saldo
|
||||
total?: boolean; // Zwischen-/Endsumme: absoluter Stand statt Veraenderung
|
||||
section?: string; // optionale Abschnitts-Ueberschrift VOR diesem Eintrag
|
||||
}
|
||||
|
||||
function waterfallData(items: WaterfallItem[]) {
|
||||
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, base: 0, delta: Math.abs(it.value), value: it.value, kind: "total" as const };
|
||||
return { label: it.label, section: it.section, from: 0, to: it.value, value: it.value, running, kind: "total" as const };
|
||||
}
|
||||
const start = running;
|
||||
const from = running;
|
||||
running += it.value;
|
||||
return {
|
||||
label: it.label,
|
||||
base: Math.min(start, running),
|
||||
delta: Math.abs(it.value),
|
||||
section: it.section,
|
||||
from,
|
||||
to: running,
|
||||
value: it.value,
|
||||
running,
|
||||
kind: (it.value >= 0 ? "pos" : "neg") as "pos" | "neg",
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const WF_COLOR = { total: "var(--accent)", pos: "#16a34a", neg: "#dc2626" };
|
||||
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;
|
||||
|
||||
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>
|
||||
<div className="overflow-hidden rounded-xl border border-border">
|
||||
{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 (
|
||||
<div key={`${r.label}-${i}`}>
|
||||
{r.section && (
|
||||
<div className="border-b border-border bg-surface-2 px-3 py-1 text-[10px] font-semibold uppercase tracking-wide text-faint">
|
||||
{r.section}
|
||||
</div>
|
||||
)}
|
||||
<div className={`flex items-stretch ${r.kind === "total" ? "bg-surface-2" : ""}`}>
|
||||
<div
|
||||
className={`w-44 shrink-0 border-r border-border px-3 py-2 text-[11px] leading-tight ${
|
||||
r.kind === "total" ? "font-semibold text-fg" : "text-muted"
|
||||
}`}
|
||||
>
|
||||
{r.label}
|
||||
</div>
|
||||
<div className="relative min-w-0 flex-1" style={{ height: ROW_H }}>
|
||||
{/* Nulllinie */}
|
||||
<div
|
||||
className="absolute top-0 h-full border-l border-dashed border-border"
|
||||
style={{ left: `${pos(0)}%` }}
|
||||
/>
|
||||
{/* Balken */}
|
||||
<div
|
||||
className="absolute rounded-sm"
|
||||
style={{
|
||||
left: `${left}%`,
|
||||
width: `${width}%`,
|
||||
top: (ROW_H - BAR_H) / 2,
|
||||
height: BAR_H,
|
||||
backgroundColor: WF_FILL[r.kind],
|
||||
opacity: r.kind === "total" ? 0.85 : 1,
|
||||
}}
|
||||
title={`${r.label}: ${formatChf(r.value)}`}
|
||||
/>
|
||||
{/* Verbindungslinie zum naechsten Balken: auf dem Stand NACH diesem Schritt */}
|
||||
{!isLast && (
|
||||
<div
|
||||
className="absolute border-l border-dotted border-faint"
|
||||
style={{
|
||||
left: `${pos(r.to)}%`,
|
||||
top: (ROW_H + BAR_H) / 2,
|
||||
height: (ROW_H - BAR_H) / 2,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={`w-28 shrink-0 border-l border-border px-3 py-2 text-right text-[11px] tabular-nums ${
|
||||
r.kind === "total" ? "font-semibold text-fg" : r.kind === "neg" ? "text-danger" : "text-success"
|
||||
}`}
|
||||
>
|
||||
{r.kind === "total" ? formatChf(r.value) : `${r.value >= 0 ? "+" : "−"}${formatChf(Math.abs(r.value))}`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Zahlen mit laufendem Zwischenstand -- bei stark unterschiedlichen Groessenordnungen
|
||||
ist die Tabelle der Grafik ueberlegen. */}
|
||||
<details className="mt-2">
|
||||
<summary className="cursor-pointer text-[11px] text-muted hover:text-fg">Zahlen mit Zwischenstand anzeigen</summary>
|
||||
<div className="mt-2 overflow-x-auto rounded-lg border border-border">
|
||||
<table className="w-full border-collapse text-xs">
|
||||
<thead>
|
||||
<tr className="bg-surface-2 text-[10px] uppercase tracking-wide text-faint">
|
||||
<th className="px-3 py-1.5 text-left font-semibold">Schritt</th>
|
||||
<th className="px-3 py-1.5 text-right font-semibold">Betrag</th>
|
||||
<th className="px-3 py-1.5 text-right font-semibold">Zwischenstand</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r, i) => (
|
||||
<tr key={i} className={`border-t border-border ${r.kind === "total" ? "bg-surface-2 font-semibold" : ""}`}>
|
||||
<td className="px-3 py-1.5 text-fg">{r.label}</td>
|
||||
<td
|
||||
className={`px-3 py-1.5 text-right tabular-nums ${
|
||||
r.kind === "total" ? "text-faint" : r.kind === "neg" ? "text-danger" : "text-success"
|
||||
}`}
|
||||
>
|
||||
{r.kind === "total" ? "—" : `${r.value >= 0 ? "+" : "−"}${formatChf(Math.abs(r.value))}`}
|
||||
</td>
|
||||
<td className="px-3 py-1.5 text-right tabular-nums text-fg">{formatChf(r.running)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Kontrollgroesse: Ist die Zerlegung vollstaendig, 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 Schoenheitsproblem.
|
||||
function ResidualNote({ residual }: { residual: number }) {
|
||||
if (Math.abs(residual) <= 2) return null;
|
||||
return (
|
||||
<p className="mt-2 rounded-lg border border-danger bg-danger-soft px-3 py-2 text-[11px] text-danger">
|
||||
<strong>Die Zerlegung geht nicht auf.</strong> Nicht zugeordnete Differenz: {formatChf(residual)}. Bitte melden –
|
||||
das ist ein Fehler in der Berechnung, nicht in der Darstellung.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
function bridgeItems(w: WealthBridge, isFirst: boolean): WaterfallItem[] {
|
||||
const items: WaterfallItem[] = [];
|
||||
if (!isFirst) {
|
||||
items.push({ label: "Vermögen Ende Vorphase", value: w.openingWealth, total: true });
|
||||
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 verrentet", value: -w.pensionConversion });
|
||||
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 });
|
||||
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 });
|
||||
@@ -110,13 +229,18 @@ function bridgeItems(w: WealthBridge, isFirst: boolean): WaterfallItem[] {
|
||||
|
||||
function cashItems(c: CashBridge, isFirst: boolean): WaterfallItem[] {
|
||||
const items: WaterfallItem[] = [];
|
||||
items.push({ label: isFirst ? "Cash-Anfangswert" : "Cash Ende Vorphase", value: c.openingCash, total: true });
|
||||
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 });
|
||||
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 });
|
||||
@@ -424,11 +548,7 @@ export function PhaseDetailDialog({
|
||||
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>
|
||||
)}
|
||||
<ResidualNote residual={phase.wealthBridge.residual} />
|
||||
</section>
|
||||
|
||||
<section>
|
||||
@@ -438,9 +558,7 @@ export function PhaseDetailDialog({
|
||||
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>
|
||||
)}
|
||||
<ResidualNote residual={phase.cashBridge.residual} />
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user