Modul-Review 3: Struktur, Dashboard, CSV-Export und Grafiken
Deploy App / deploy (push) Successful in 1m59s
Deploy App / deploy (push) Successful in 1m59s
Versionierung: - startet neu bei 0.1; erst eine gesetzte Hauptversion macht daraus 1.0 - Szenario-Liste zeigt die echte Version (z.B. 0.17) statt "1.x", plus neue Spalte "Phasen" - Migration 20260724120000_version_zero_start (nur der Default) Plan-Dashboard: - Kacheln anklickbar (fuehren in ihren Bereich), neue Kachel "Berichte" - Plan umbenennen ueber Stift-Symbol - Ist-Abweichung nennt das Jahr des juengsten Ist-Datensatzes und ist bei positiver Abweichung gruen statt rot Szenario-Liste: - Klick auf die Zeile oeffnet die Matrix (Matrix-Knopf entfaellt) - je Zeile Kopie (Vorlage frei waehlbar) und Loeschen - laedt nach einer Loeschung neu (zeigte vorher den alten Stand) Seitenleiste: - Szenarien wieder verschachtelt nach Herkunft - Effektive Werte / Analysen / Berichte buendig zum Knoten "Szenarien" CSV-Export (neues Modul lib/csv.ts): - vier Bloecke: Kopf, Lebensphasen, ganze Matrix (Elemente x Phasen inkl. Uebergangs-Entscheide im Klartext), Jahreswerte - mit BOM (Excel-Umlaute), Dateiname transliteriert Umlaute - vorher enthielt die Datei kein einziges finanzielles Element Grafiken: - Szenario-Waehler gilt fuer alle drei Grafiken - BUGFIX Szenario-Vergleich: WealthChart nutzte den Namen als Datenschluessel -> gleichnamige Szenarien ueberschrieben sich (Legende zeigte beide, Chart nur eine). Neu die ID; stille Deckelung auf 4 Serien entfaellt - eigene Legende mit freier Farbwahl je Serie + Erklaerung des Linienstils - Vermoegensaufteilung neu: gestapelte Flaeche ueber die Planjahre + Ring fuer die relative Aufteilung zu einem waehlbaren Zeitpunkt - alle Diagrammfarben aus neuen Theme-Tokens (--chart-1..6, --chart-grid) Doku-Drift bereinigt: Kapitel 2.1, 3.2.2-3.2.7 und 3.10 beschrieben noch den Stand vor V7 (Grundprofil am Szenario, parentPlanId, Scenario.startYear, window.confirm, drei Sidebar-Unterpunkte). Nebenbei: verstuemmelte Hex-Farbe --danger-soft (warm) repariert, deutsche Plural-/Umlautfehler in den Uebersichts-Kacheln. SPEZIFIKATION 0.31. 267 -> 275 Tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,17 +1,54 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { Bar, BarChart, CartesianGrid, Legend, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
|
||||
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";
|
||||
|
||||
export const CHART_PALETTE = ["#4f46e5", "#0ea5e9", "#16a34a", "#d97706", "#dc2626", "#7c3aed"];
|
||||
// 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 je Phase (Beginn und Ende). Eigene Komponente, weil sie
|
||||
// sowohl im Grafiken-Dialog als auch in der Live-Simulation gebraucht wird.
|
||||
export function AllocationChart({ computed, height = 288 }: { computed: PlanComputed; height?: number }) {
|
||||
// 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(() => {
|
||||
@@ -28,50 +65,159 @@ export function AllocationChart({ computed, height = 288 }: { computed: PlanComp
|
||||
return [...info.entries()].filter(([, v]) => v.any).map(([id, v]) => ({ id, name: v.name }));
|
||||
}, [computed]);
|
||||
|
||||
// Je Phase zwei Kategorien auf der x-Achse: Beginn und Ende.
|
||||
const barData = useMemo(
|
||||
() =>
|
||||
computed.phases.flatMap((phase) => {
|
||||
const beginn: Record<string, number | string> = { label: `${phase.name} · Beginn` };
|
||||
const ende: Record<string, number | string> = { label: `${phase.name} · Ende` };
|
||||
for (const el of phase.elements) {
|
||||
if (!ASSET_CATS.includes(el.category)) continue;
|
||||
beginn[el.elementId] = Math.max(0, Math.round(el.startValue));
|
||||
ende[el.elementId] = Math.max(0, Math.round(el.endValue));
|
||||
// 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 [beginn, ende];
|
||||
}),
|
||||
[computed]
|
||||
);
|
||||
}
|
||||
}
|
||||
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="w-full" style={{ height }}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={barData} margin={{ top: 8, right: 16, left: 8, bottom: 8 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
|
||||
<XAxis dataKey="label" tick={{ fontSize: 10 }} interval={0} angle={-30} textAnchor="end" height={70} />
|
||||
<YAxis
|
||||
tick={{ fontSize: 11 }}
|
||||
tickFormatter={(v) => Intl.NumberFormat("de-CH", { notation: "compact" }).format(v)}
|
||||
/>
|
||||
<Tooltip formatter={(v) => (typeof v === "number" ? formatChf(v) : v)} />
|
||||
<Legend wrapperStyle={{ fontSize: 12 }} />
|
||||
{assetEls.map((el, i) => (
|
||||
<Bar
|
||||
key={el.id}
|
||||
dataKey={el.id}
|
||||
name={el.name}
|
||||
stackId="a"
|
||||
fill={CHART_PALETTE[i % CHART_PALETTE.length]}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
))}
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
<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 }}
|
||||
onClick={(e) => {
|
||||
const y = (e as { activePayload?: { payload?: { year?: number } }[] })?.activePayload?.[0]?.payload?.year;
|
||||
if (typeof y === "number") setPickedYear(y);
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user