Files
FPT/src/components/AllocationChart.tsx
T
admGitAICDS 0761f6b3e2
Deploy App / deploy (push) Successful in 1m4s
Effektive Werte: Immobilien-Bugfix, Bearbeiten; Sidebar-Ebenen, Ring-Klick
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>
2026-07-25 12:52:23 +02:00

230 lines
8.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
);
}