Files
FPT/src/components/AllocationChart.tsx
T
admGitAICDS 0953880a80
Deploy App / deploy (push) Successful in 1m10s
Modul-Review 4: Matrix -- Kapitalverwendung, Phasendauer, Bedienung
Kapitalverwendung (Punkt C) am richtigen Ort:
- Prozent-Aufteilung des bezogenen Alterskapitals wandert vom Cash-Uebergang
  zum Bezugs-Entscheid der PK (nur bei Kapitalbezug) bzw. der Saeule 3a --
  mit zwei Guthaben liess sie sich vorher gar nicht getrennt beantworten
- Dialoge fuehren neu brutto -> Steuersatz -> netto -> Verteilung
- BUGFIX: Der zugeteilte Betrag erhoehte still den internen Bestand, deshalb
  zeigten Ziel-Element und "Kapital verteilen" eine 0. Er laeuft jetzt ueber
  Carry.capitalIn als Zusatzeinlage der Folgephase und ist ueberall sichtbar
- Saeule 3a ist am Pensions-Uebergang neu ein offener Entscheid

Phasendauer (gemeldeter Fehler):
- Die Folgephase gleicht eine geaenderte Dauer aus; Gesamtdauer bleibt gleich
- Vorher kappte das Tool nur die bearbeitete Phase -> Phase 2 ueberspannte
  danach die Pensionierung und die Invariante aus Punkt 44 kippte
- Rueckfrage vorher, Blockade wenn die Folgephase unter 1 Jahr fiele
- neue reine Funktion planDurationChange

Bedienung:
- Element-Zeile und Phasenkopf: Stift (umbenennen, beim Element inkl.
  Zuordnung), Papierkorb, Expand -- alle immer sichtbar
- PATCH /api/elements/<id> nimmt neu auch ownerRole
- Hilfetexte via Portal (wurden in scrollenden Dialogen abgeschnitten)
- Verteil-Dialoge: Zuordnung je Zeile, nach vom/ins Cash gruppiert,
  Vorbelegung mit dem EFFEKTIVEN Wert inkl. Vererbung (zeigte vorher 0)
- Matrix: gleiche Spaltenbreiten + horizontales Scrollen, "Alle auf-/
  zuklappen", Kategorie-Summe in der zugeklappten Zeile
- Phasen-Detailansicht nutzt die neue Aufteilungs-Grafik
- Uebersicht: "Leer starten" auch im leeren Zustand

Nebenbei: dritte verstuemmelte Hex-Farbe (#7c3aed) repariert, Phasen-Panel
nutzt den eigenen Bestaetigungs-Dialog statt window.confirm; mehrere veraltete
Referenzen und die buildCarryData-Tabelle in der Spez nachgezogen.

SPEZIFIKATION 0.33. 278 -> 288 Tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 23:09:03 +02:00

246 lines
9.0 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,
// Auf eine einzelne Lebensphase einschraenken (Detailansicht der Phase). Ohne Angabe wird
// der ganze Plan gezeigt.
onlyPhaseId,
}: {
computed: PlanComputed;
height?: number;
onlyPhaseId?: string;
}) {
const phases = useMemo(
() => (onlyPhaseId ? computed.phases.filter((p) => p.id === onlyPhaseId) : computed.phases),
[computed, onlyPhaseId]
);
// 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 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 }));
}, [phases]);
// 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 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 }));
}, [phases]);
// Phasengrenzen (kumulierte Dauer) für die Trennlinien. Bei einer einzelnen Phase gibt es keine.
const boundaryAges = useMemo(() => {
if (onlyPhaseId) return [];
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, onlyPhaseId]);
// 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">
{onlyPhaseId ? "Verlauf in dieser Lebensphase" : "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>
);
}