Modul-Review 3: Struktur, Dashboard, CSV-Export und Grafiken
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:
2026-07-25 12:16:07 +02:00
parent 96fdd00d5c
commit b42f9b385f
22 changed files with 1199 additions and 289 deletions
+189 -43
View File
@@ -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>
);
}