0953880a80
Deploy App / deploy (push) Successful in 1m10s
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>
246 lines
9.0 KiB
TypeScript
246 lines
9.0 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,
|
||
// 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>
|
||
);
|
||
}
|