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:
+116
-45
@@ -1,19 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
CartesianGrid,
|
||||
Legend,
|
||||
Line,
|
||||
LineChart,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
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;
|
||||
@@ -36,15 +33,39 @@ function pointsFor(computed: PlanComputed) {
|
||||
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>;
|
||||
}
|
||||
@@ -56,47 +77,97 @@ export function WealthChart({
|
||||
const row: Record<string, number | null> = { age };
|
||||
for (const s of withPoints) {
|
||||
const pt = s.points.find((p) => p.age === age);
|
||||
row[s.label] = pt ? (metric === "real" ? pt.real : pt.nominal) : null;
|
||||
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="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" className="stroke-border" />
|
||||
<XAxis
|
||||
dataKey="age"
|
||||
type="number"
|
||||
domain={["dataMin", "dataMax"]}
|
||||
tick={{ fontSize: 11 }}
|
||||
tickFormatter={(v) => `${v} J.`}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 11 }}
|
||||
tickFormatter={(v) => Intl.NumberFormat("de-CH", { notation: "compact" }).format(v)}
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(v) => (typeof v === "number" ? formatChf(v) : v)}
|
||||
labelFormatter={(v) => `Alter ${v}`}
|
||||
/>
|
||||
<Legend wrapperStyle={{ fontSize: 12 }} />
|
||||
{withPoints.map((s) => (
|
||||
<Line
|
||||
key={s.label}
|
||||
type="monotone"
|
||||
dataKey={s.label}
|
||||
stroke={s.color}
|
||||
strokeWidth={2}
|
||||
strokeDasharray={s.dashed ? "5 3" : undefined}
|
||||
dot={false}
|
||||
connectNulls
|
||||
isAnimationActive={false}
|
||||
<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.`}
|
||||
/>
|
||||
))}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user