0761f6b3e2
Deploy App / deploy (push) Successful in 1m4s
Immobilien-Bugfix (gravierend): - Der Ist-Wizard belegte den Immobilienwert mit dem EIGENKAPITAL vor (ElementYearPoint.value), waehrend Erfassung und Rechenkern den VERKEHRSWERT erwarten (propertyValue). Der Kern setzte die Zahl als Verkehrswert ein und liess die Hypothek stehen -> das Eigenkapital brach im Ist-Jahr um genau die Hypothek ein, meist ins Negative. - Sichtbar als negative Gesamt-Abweichung trotz reiner Lohnerhoehung und als "wegbrechendes" Wohneigentum in der Vermoegensaufteilung. - Feld ist neu als "Verkehrswert + Restschuld" beschriftet; 3 Regressionstests. Ist-Datensaetze bearbeitbar: - Klick auf die Zeile (oder "Bearbeiten") oeffnet den Satz erneut - neuer Endpunkt PUT /api/plans/<id>/actuals/<setId> - beim Bearbeiten ueberschreiben die Planwerte die erfassten Zahlen nicht Ring-Klick in der Vermoegensaufteilung repariert: - Recharts 3 reicht kein activePayload mehr durch (nur activeIndex) -- der Handler feuerte nie, der Ring zeigte immer das Planende Seitenleiste sauber dreistufig: - Ebene 1 Plaene, Ebene 2 die vier Bereiche mit buendigen Symbolen, Ebene 3 nur die Szenarien (verschachtelt nach Herkunft) Szenario-Liste zeigt neben der Version deren Kommentar. SPEZIFIKATION 0.32 (neue Kapitel 3.9.6, 3.9.7). 275 -> 278 Tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
230 lines
8.6 KiB
TypeScript
230 lines
8.6 KiB
TypeScript
"use client";
|
||
|
||
import { useMemo, useState } from "react";
|
||
import {
|
||
Area,
|
||
AreaChart,
|
||
Cell,
|
||
Legend,
|
||
Pie,
|
||
PieChart,
|
||
ReferenceLine,
|
||
ResponsiveContainer,
|
||
Tooltip,
|
||
XAxis,
|
||
YAxis,
|
||
} from "recharts";
|
||
import { formatChf } from "@/lib/format";
|
||
import type { PlanComputed } from "@/lib/calculations";
|
||
|
||
// Diagramm-Palette aus den Theme-Tokens (globals.css) statt fester Hex-Werte -- so passt sie
|
||
// sich Hell/Dunkel/Warm an. Recharts reicht die Werte als SVG-Attribut durch, der Browser
|
||
// löst `var(...)` dort auf.
|
||
export const CHART_PALETTE = [
|
||
"var(--chart-1)",
|
||
"var(--chart-2)",
|
||
"var(--chart-3)",
|
||
"var(--chart-4)",
|
||
"var(--chart-5)",
|
||
"var(--chart-6)",
|
||
];
|
||
|
||
// Einheitliches Aussehen der Recharts-Tooltips (folgt dem Farbschema).
|
||
export const TOOLTIP_STYLE = {
|
||
background: "var(--surface)",
|
||
border: "1px solid var(--border)",
|
||
borderRadius: "0.5rem",
|
||
fontSize: 12,
|
||
color: "var(--fg)",
|
||
} as const;
|
||
|
||
const ASSET_CATS = ["PENSION_FUND", "PILLAR_3A", "REAL_ESTATE", "OTHER_ASSET"];
|
||
|
||
// Gestapelte Vermögensaufteilung.
|
||
//
|
||
// Bis 0.30 waren das gestapelte Balken mit je zwei Säulen pro Phase (Beginn/Ende) und schräg
|
||
// gestellter Beschriftung -- schwer lesbar, sobald es mehr als zwei Phasen gab. Neu:
|
||
// * eine gestapelte FLÄCHE über alle Planjahre (zeigt den Verlauf statt zweier Stichproben),
|
||
// Phasengrenzen als feine senkrechte Linien,
|
||
// * daneben ein RING für ein wählbares Jahr, der die RELATIVE Aufteilung zeigt.
|
||
// Ein Klick in die Fläche wählt das Jahr des Rings.
|
||
export function AllocationChart({ computed, height = 300 }: { computed: PlanComputed; height?: number }) {
|
||
// Asset-Elemente (nach id, damit gleiche Namen nicht kollidieren), die irgendwann einen
|
||
// positiven Wert haben -- in Reihenfolge ihres ersten Auftretens.
|
||
const assetEls = useMemo(() => {
|
||
const info = new Map<string, { name: string; any: boolean }>();
|
||
for (const phase of computed.phases) {
|
||
for (const el of phase.elements) {
|
||
if (!ASSET_CATS.includes(el.category)) continue;
|
||
const cur = info.get(el.elementId) ?? { name: el.name, any: false };
|
||
cur.name = el.name;
|
||
if (el.startValue > 0 || el.endValue > 0) cur.any = true;
|
||
info.set(el.elementId, cur);
|
||
}
|
||
}
|
||
return [...info.entries()].filter(([, v]) => v.any).map(([id, v]) => ({ id, name: v.name }));
|
||
}, [computed]);
|
||
|
||
// Je Planjahr eine Zeile mit dem Wert jedes Elements. Die Jahreswerte liegen bereits in
|
||
// `ElementPhaseComputed.yearly` (seit 0.11) -- hier wird nur umsortiert, nichts gerechnet.
|
||
const areaData = useMemo(() => {
|
||
const byYear = new Map<number, Record<string, number>>();
|
||
const ageOf = new Map<number, number>();
|
||
for (const phase of computed.phases) {
|
||
for (const el of phase.elements) {
|
||
if (!ASSET_CATS.includes(el.category)) continue;
|
||
for (const y of el.yearly) {
|
||
const row = byYear.get(y.year) ?? {};
|
||
row[el.elementId] = Math.max(0, Math.round(y.value));
|
||
byYear.set(y.year, row);
|
||
ageOf.set(y.year, y.age);
|
||
}
|
||
}
|
||
}
|
||
return [...byYear.entries()]
|
||
.sort(([a], [b]) => a - b)
|
||
.map(([year, values]) => ({ year, age: ageOf.get(year) ?? 0, ...values }));
|
||
}, [computed]);
|
||
|
||
// Phasengrenzen (kumulierte Dauer) für die Trennlinien.
|
||
const boundaryAges = useMemo(() => {
|
||
const out: number[] = [];
|
||
let acc = 0;
|
||
for (let i = 0; i < computed.phases.length - 1; i++) {
|
||
acc += computed.phases[i].durationYears;
|
||
const age = areaData.find((r) => r.year === acc)?.age;
|
||
if (typeof age === "number") out.push(age);
|
||
}
|
||
return out;
|
||
}, [computed, areaData]);
|
||
|
||
// Gewähltes Jahr für den Ring -- Vorgabe: das letzte (Endzustand).
|
||
const [pickedYear, setPickedYear] = useState<number | null>(null);
|
||
const ringRow = areaData.find((r) => r.year === pickedYear) ?? areaData[areaData.length - 1];
|
||
|
||
const ringData = useMemo(() => {
|
||
if (!ringRow) return [];
|
||
return assetEls
|
||
.map((el) => ({ name: el.name, value: Number(ringRow[el.id as keyof typeof ringRow] ?? 0) }))
|
||
.filter((d) => d.value > 0);
|
||
}, [assetEls, ringRow]);
|
||
const ringTotal = ringData.reduce((s, d) => s + d.value, 0);
|
||
|
||
if (assetEls.length === 0) {
|
||
return <p className="text-sm text-muted">Dieser Plan enthält keine Vermögenselemente.</p>;
|
||
}
|
||
|
||
return (
|
||
<div className="flex flex-col gap-4 lg:flex-row">
|
||
{/* Verlauf */}
|
||
<div className="min-w-0 flex-1">
|
||
<div className="mb-1 text-xs font-medium text-muted">
|
||
Verlauf über alle Planjahre <span className="text-faint">· Klick wählt das Jahr für den Ring</span>
|
||
</div>
|
||
<div style={{ height }}>
|
||
<ResponsiveContainer width="100%" height="100%">
|
||
<AreaChart
|
||
data={areaData}
|
||
margin={{ top: 8, right: 8, left: 8, bottom: 4 }}
|
||
style={{ cursor: "pointer" }}
|
||
// Recharts 3 reicht im Klick-Parameter NUR den aktiven Index durch -- das in
|
||
// Version 2 übliche `activePayload` gibt es nicht mehr. Über den Index in
|
||
// `areaData` kommt man an das Planjahr.
|
||
onClick={(state) => {
|
||
const raw = state?.activeIndex ?? state?.activeTooltipIndex;
|
||
const idx = typeof raw === "number" ? raw : Number(raw);
|
||
if (!Number.isInteger(idx) || idx < 0 || idx >= areaData.length) return;
|
||
setPickedYear(areaData[idx].year);
|
||
}}
|
||
>
|
||
<XAxis
|
||
dataKey="age"
|
||
type="number"
|
||
domain={["dataMin", "dataMax"]}
|
||
tick={{ fontSize: 11 }}
|
||
tickFormatter={(v) => `${v} J.`}
|
||
stroke="var(--faint)"
|
||
/>
|
||
<YAxis
|
||
tick={{ fontSize: 11 }}
|
||
stroke="var(--faint)"
|
||
tickFormatter={(v) => Intl.NumberFormat("de-CH", { notation: "compact" }).format(v)}
|
||
/>
|
||
<Tooltip
|
||
contentStyle={TOOLTIP_STYLE}
|
||
formatter={(v) => (typeof v === "number" ? formatChf(v) : v)}
|
||
labelFormatter={(v) => `Alter ${v}`}
|
||
/>
|
||
{boundaryAges.map((age) => (
|
||
<ReferenceLine key={age} x={age} stroke="var(--border-strong)" strokeDasharray="3 3" />
|
||
))}
|
||
{/* Markierung des Ring-Jahres. */}
|
||
{ringRow && <ReferenceLine x={ringRow.age} stroke="var(--accent)" strokeWidth={1.5} />}
|
||
{assetEls.map((el, i) => (
|
||
<Area
|
||
key={el.id}
|
||
type="monotone"
|
||
dataKey={el.id}
|
||
name={el.name}
|
||
stackId="a"
|
||
stroke={CHART_PALETTE[i % CHART_PALETTE.length]}
|
||
fill={CHART_PALETTE[i % CHART_PALETTE.length]}
|
||
fillOpacity={0.75}
|
||
isAnimationActive={false}
|
||
/>
|
||
))}
|
||
<Legend wrapperStyle={{ fontSize: 12 }} />
|
||
</AreaChart>
|
||
</ResponsiveContainer>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Relative Aufteilung im gewählten Jahr */}
|
||
<div className="lg:w-72 lg:shrink-0">
|
||
<div className="mb-1 text-xs font-medium text-muted">
|
||
Aufteilung mit {ringRow?.age ?? "–"} Jahren
|
||
{pickedYear !== null && (
|
||
<button
|
||
type="button"
|
||
onClick={() => setPickedYear(null)}
|
||
className="ml-2 text-[11px] font-normal text-accent hover:underline"
|
||
>
|
||
zurücksetzen
|
||
</button>
|
||
)}
|
||
</div>
|
||
<div style={{ height }}>
|
||
<ResponsiveContainer width="100%" height="100%">
|
||
<PieChart>
|
||
<Pie
|
||
data={ringData}
|
||
dataKey="value"
|
||
nameKey="name"
|
||
innerRadius="55%"
|
||
outerRadius="80%"
|
||
paddingAngle={1}
|
||
isAnimationActive={false}
|
||
>
|
||
{ringData.map((d, i) => (
|
||
<Cell key={d.name} fill={CHART_PALETTE[i % CHART_PALETTE.length]} />
|
||
))}
|
||
</Pie>
|
||
<Tooltip
|
||
contentStyle={TOOLTIP_STYLE}
|
||
formatter={(v) =>
|
||
typeof v === "number"
|
||
? `${formatChf(v)} · ${ringTotal > 0 ? Math.round((v / ringTotal) * 100) : 0} %`
|
||
: v
|
||
}
|
||
/>
|
||
</PieChart>
|
||
</ResponsiveContainer>
|
||
</div>
|
||
<div className="-mt-2 text-center text-xs text-muted">
|
||
Total <strong className="text-fg">{formatChf(ringTotal)}</strong>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|