Files
FPT/src/components/WealthChart.tsx
T
admGitAICDS b42f9b385f
Deploy App / deploy (push) Successful in 1m59s
Modul-Review 3: Struktur, Dashboard, CSV-Export und Grafiken
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>
2026-07-25 12:16:07 +02:00

174 lines
6.9 KiB
TypeScript

"use client";
import { useEffect, useState } from "react";
import { CartesianGrid, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
import { TOOLTIP_STYLE } from "@/components/AllocationChart";
import { formatChf } from "@/lib/format";
import type { PlanComputed } from "@/lib/calculations";
export interface TimelineSeries {
// Eindeutiger Schlüssel der Serie. WICHTIG: Bis 0.30 diente der ANZEIGENAME als Datenschlüssel
// -- zwei Szenarien mit gleichem Namen überschrieben sich dadurch gegenseitig (die Legende
// zeigte beide, der Chart nur eine). Deshalb ein eigener, garantiert eindeutiger Schlüssel.
id: string;
label: string;
color: string;
computed: PlanComputed;
// Plandaten werden gestrichelt gezeichnet, effektive Daten durchgezogen. Das Stilbudget
// geht bewusst an Plan/Ist statt an nominal/real (siehe SPEZIFIKATION 9.29).
dashed?: boolean;
}
// Datenpunkte je Serie: JEDES Planjahr (nicht nur die Phasengrenzen), verortet auf dem Alter
// der Referenzperson (Person A). Der erste Punkt ist das Startvermögen vor Jahr 1.
function pointsFor(computed: PlanComputed) {
const phases = computed.phases;
if (phases.length === 0) return [] as { age: number; nominal: number; real: number }[];
const startAge = phases[0].persons[0]?.startAge ?? 0;
const start = Math.round(phases[0].startWealthNominal);
const pts = [{ age: startAge, nominal: start, real: start }];
for (const y of computed.yearly) {
pts.push({ age: y.age, nominal: y.wealthNominal, real: y.wealthReal });
}
return pts;
}
// `var(--chart-1)` in einen konkreten Hex-Wert auflösen -- das Farbwahl-Feld (input[type=color])
// braucht einen echten Wert. Läuft nur im Browser und nur, wenn eine Variable übergeben wurde.
function resolveColor(color: string): string {
if (!color.startsWith("var(")) return color;
if (typeof window === "undefined") return "#888888";
const name = color.slice(4, -1).trim();
const v = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
return v || "#888888";
}
// Liniendiagramm: Gesamtvermögen (nominal + real) über das Alter. Unterstützt mehrere
// überlagerte Pläne für den Szenario-Vergleich.
export function WealthChart({
series,
metric = "nominal",
onColorChange,
}: {
series: TimelineSeries[];
metric?: "nominal" | "real";
// Optional: erlaubt das Umfärben einer Serie über die Legende.
onColorChange?: (id: string, color: string) => void;
}) {
// Aufgelöste Farben für die Farbwahl-Felder; nach dem ersten Rendern (und bei Theme-Wechsel).
const [resolved, setResolved] = useState<Record<string, string>>({});
const colorKey = series.map((s) => `${s.id}:${s.color}`).join("|");
useEffect(() => {
const next: Record<string, string> = {};
for (const s of series) next[s.id] = resolveColor(s.color);
// eslint-disable-next-line react-hooks/set-state-in-effect -- Farbauflösung braucht das DOM
setResolved(next);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [colorKey]);
if (series.length === 0 || series[0].computed.phases.length === 0) {
return <p className="text-sm text-muted">Noch keine Phasen vorhanden.</p>;
}
const withPoints = series.map((s) => ({ ...s, points: pointsFor(s.computed) }));
const ages = Array.from(new Set(withPoints.flatMap((s) => s.points.map((p) => p.age)))).sort((a, b) => a - b);
const data = ages.map((age) => {
const row: Record<string, number | null> = { age };
for (const s of withPoints) {
const pt = s.points.find((p) => p.age === age);
row[s.id] = pt ? (metric === "real" ? pt.real : pt.nominal) : null;
}
return row;
});
const nameById = new Map(series.map((s) => [s.id, s.label]));
const hasDashed = series.some((s) => s.dashed);
return (
<div className="w-full">
<div className="h-80 w-full">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={data} margin={{ top: 8, right: 16, left: 8, bottom: 8 }}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--chart-grid)" />
<XAxis
dataKey="age"
type="number"
domain={["dataMin", "dataMax"]}
tick={{ fontSize: 11 }}
stroke="var(--faint)"
tickFormatter={(v) => `${v} J.`}
/>
<YAxis
tick={{ fontSize: 11 }}
stroke="var(--faint)"
tickFormatter={(v) => Intl.NumberFormat("de-CH", { notation: "compact" }).format(v)}
/>
<Tooltip
contentStyle={TOOLTIP_STYLE}
formatter={(v, key) => [typeof v === "number" ? formatChf(v) : v, nameById.get(String(key)) ?? key]}
labelFormatter={(v) => `Alter ${v}`}
/>
{withPoints.map((s) => (
<Line
key={s.id}
type="monotone"
dataKey={s.id}
name={s.label}
stroke={s.color}
strokeWidth={2}
strokeDasharray={s.dashed ? "5 3" : undefined}
dot={false}
connectNulls
isAnimationActive={false}
/>
))}
</LineChart>
</ResponsiveContainer>
</div>
{/* Eigene Legende statt der von Recharts: Sie trägt die Farbwahl und erklärt den
Linienstil -- ohne das wäre unklar, wofür die gestrichelte Linie steht. */}
<div className="mt-2 flex flex-wrap items-center gap-x-4 gap-y-1.5">
{series.map((s) => (
<span key={s.id} className="flex items-center gap-1.5 text-xs text-muted">
{onColorChange ? (
<label className="relative flex cursor-pointer items-center" title="Farbe wählen">
<span
className="block h-3 w-6 rounded-sm"
style={{
background: s.dashed
? `repeating-linear-gradient(90deg, ${s.color} 0 5px, transparent 5px 8px)`
: s.color,
}}
/>
<input
type="color"
value={resolved[s.id] ?? "#888888"}
onChange={(e) => onColorChange(s.id, e.target.value)}
className="absolute inset-0 h-full w-full cursor-pointer opacity-0"
/>
</label>
) : (
<span
className="block h-3 w-6 rounded-sm"
style={{
background: s.dashed
? `repeating-linear-gradient(90deg, ${s.color} 0 5px, transparent 5px 8px)`
: s.color,
}}
/>
)}
{s.label}
</span>
))}
{hasDashed && (
<span className="text-[11px] text-faint">
gestrichelt = reine Planwerte · durchgezogen = mit effektiven Werten
</span>
)}
</div>
</div>
);
}