b42f9b385f
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>
174 lines
6.9 KiB
TypeScript
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>
|
|
);
|
|
}
|