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>
);
}
+53 -13
View File
@@ -117,6 +117,8 @@ function AppShellInner({ username }: { username: string }) {
const [showPalette, setShowPalette] = useState(false);
// Sprungmarke in die SPEZIFIKATION, gesetzt aus einem Rechenweg heraus.
const [specAnchor, setSpecAnchor] = useState<string | null>(null);
// Erhoehen erzwingt ein Neuladen der Szenario-Liste (bleibt bei einer Loeschung montiert).
const [scenarioListKey, setScenarioListKey] = useState(0);
// Tour (seit dem Layout-Umbau hier statt in PlanView -- sie liest die data-tour-Ziele im
// DOM der Szenario-Ansicht). Zwei Auslöser:
@@ -276,6 +278,9 @@ function AppShellInner({ username }: { username: string }) {
return;
}
await loadPlans();
// Die Szenario-Liste bleibt beim Löschen aus der Seitenleiste montiert -- ohne dieses
// Signal zeigte sie den gelöschten Eintrag weiter.
setScenarioListKey((k) => k + 1);
if (selectedScenarioId === s.id) setSelectedScenarioId(null);
}
@@ -384,7 +389,7 @@ function AppShellInner({ username }: { username: string }) {
<button
type="button"
onClick={() => openPlanTab(p.id, tab)}
className={`flex w-full items-center gap-2 rounded-lg py-1.5 pl-8 pr-3 text-left text-xs font-medium transition-colors ${
className={`flex w-full items-center gap-2 rounded-lg py-1.5 pl-3 pr-3 text-left text-xs font-medium transition-colors ${
navHere && planNav?.tab === tab ? "bg-accent-soft text-accent-soft-fg" : "text-muted hover:bg-surface-2"
}`}
>
@@ -452,7 +457,7 @@ function AppShellInner({ username }: { username: string }) {
<button
type="button"
onClick={() => void openActualsTab(p.id)}
className={`flex w-full items-center gap-2 rounded-lg py-1.5 pl-8 pr-3 text-left text-xs font-medium transition-colors ${
className={`flex w-full items-center gap-2 rounded-lg py-1.5 pl-3 pr-3 text-left text-xs font-medium transition-colors ${
navHere && planNav?.tab === "actuals" ? "bg-accent-soft text-accent-soft-fg" : "text-muted hover:bg-surface-2"
}`}
>
@@ -565,18 +570,27 @@ function AppShellInner({ username }: { username: string }) {
{/* Plan-Ebene: Dashboard / Szenarien / Analysen */}
{!showSpec && !showSystemParams && planNav?.tab === "dashboard" && (
<PlanDashboardView planId={planNav.planId} />
<PlanDashboardView
planId={planNav.planId}
onNavigate={(tab) => (tab === "actuals" ? void openActualsTab(planNav.planId) : openPlanTab(planNav.planId, tab))}
onRenamed={() => void loadPlans()}
/>
)}
{!showSpec && !showSystemParams && planNav?.tab === "scenarios" && (
<ScenarioListView
planId={planNav.planId}
reloadKey={scenarioListKey}
onOpenMatrix={openScenario}
onOpenHistory={(sid) => {
void loadDetail(sid, true).then(() => setShowHistory(true));
}}
onNew={() => {
const base = activePlan?.scenarios.find((s) => s.isBase) ?? activePlan?.scenarios[0];
if (base) setCopyFrom({ id: base.id, planId: base.planId, planName: activePlan?.name ?? "", name: base.name, isBase: base.isBase, parentScenarioId: base.parentScenarioId } as ScenarioMeta);
onCopyFrom={(sid) => {
const src = activePlan?.scenarios.find((s) => s.id === sid);
if (src) setCopyFrom(src);
}}
onDelete={(sid) => {
const s = activePlan?.scenarios.find((x) => x.id === sid);
if (s) void handleDeleteScenario(s);
}}
/>
)}
@@ -968,17 +982,43 @@ function ScenarioTree({
onCopy: (s: ScenarioMeta) => void;
onDelete: (s: ScenarioMeta) => void;
}) {
// Flache, gleichmässig eingerückte Liste: Basisszenario zuoberst, danach die Varianten.
// Die Herkunfts-Verschachtelung wird in der Szenario-Liste (Spalte «aus …») gezeigt, nicht
// mehr durch die Einrückung in der Seitenleiste.
const ordered = [...scenarios].sort((a, b) => (a.isBase === b.isBase ? 0 : a.isBase ? -1 : 1));
// Echte Verschachtelung: Ein Szenario wird unter seiner Vorlage eingerückt (Tiefe = Länge
// der Herkunftskette). So ist in der Seitenleiste sofort sichtbar, woraus ein Szenario
// entstanden ist -- die Szenario-Liste zeigt dasselbe zusätzlich als Spalte «aus …».
const byId = new Map(scenarios.map((s) => [s.id, s]));
const depthOf = (s: ScenarioMeta): number => {
let d = 0;
let cur = s.parentScenarioId ? byId.get(s.parentScenarioId) : undefined;
// Deckel gegen eine (theoretisch) zyklische Kette.
while (cur && d < 8) {
d += 1;
cur = cur.parentScenarioId ? byId.get(cur.parentScenarioId) : undefined;
}
return d;
};
// Vorordnung: Basis zuoberst, Kinder direkt unter ihrer Vorlage.
const ordered: ScenarioMeta[] = [];
const visit = (parentId: string | null) => {
for (const s of scenarios) {
const pid = s.isBase ? null : s.parentScenarioId;
if ((pid ?? null) !== parentId) continue;
ordered.push(s);
visit(s.id);
}
};
visit(null);
// Szenarien, deren Vorlage gelöscht wurde, hängen sonst nirgends -- ans Ende.
for (const s of scenarios) if (!ordered.includes(s)) ordered.push(s);
if (ordered.length === 0) return null;
return (
<>
{ordered.map((s) => (
<div
key={s.id}
className={`group flex items-center gap-1.5 rounded-lg py-1.5 pr-1.5 pl-9 text-sm transition-colors ${
style={{ paddingLeft: `${2.25 + depthOf(s) * 0.75}rem` }}
className={`group flex items-center gap-1.5 rounded-lg py-1.5 pr-1.5 text-sm transition-colors ${
selectedId === s.id ? "bg-accent-soft font-medium text-accent-soft-fg" : "text-muted hover:bg-surface-2"
}`}
>
@@ -1093,9 +1133,9 @@ function DashboardHome({
{/* Ein paar Kennzahlen pro Plan direkt in der Übersicht. */}
<div className="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-[11px] text-muted">
<span>{p.scenarios.length} Szenario{p.scenarios.length === 1 ? "" : "s"}</span>
<span>{p.scenarios.length} Szenario{p.scenarios.length === 1 ? "" : "en"}</span>
{p.startYear && <span>ab {p.startYear}</span>}
{p._count && p._count.actuals > 0 && <span>{p._count.actuals} Ist-Datensatz{p._count.actuals === 1 ? "" : "e"}</span>}
{p._count && p._count.actuals > 0 && <span>{p._count.actuals} {p._count.actuals === 1 ? "Ist-Datensatz" : "Ist-Datensätze"}</span>}
{p._count && p._count.analyses > 0 && <span>{p._count.analyses} Analyse{p._count.analyses === 1 ? "" : "n"}</span>}
</div>
</div>
+85 -32
View File
@@ -1,6 +1,6 @@
"use client";
import { useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { BarChart3, LineChart as LineChartIcon } from "lucide-react";
import { AllocationChart, CHART_PALETTE } from "@/components/AllocationChart";
import { useVersionedPlan, VersionBar } from "@/components/VersionPicker";
@@ -27,9 +27,9 @@ const CHART_OPTIONS: { id: ChartId; label: string }[] = [
];
export function Dashboard({
plan: currentPlan,
plan: openedPlan,
planId,
computed: currentComputed,
computed: openedComputed,
siblings,
actuals = [],
origins = [],
@@ -44,18 +44,46 @@ export function Dashboard({
}) {
// Eine Grafik zur Zeit -- man wählt zuerst, welche (Punkt 5 der Analysen-Ansicht).
const [chartId, setChartId] = useState<ChartId>("wealth");
// Welches Szenario die Grafik zeigt. Gilt für ALLE drei Grafiken -- vorher war das fest an
// das geöffnete Szenario gebunden, und nur der Vermögensverlauf konnte weitere dazunehmen.
const allScenarios = useMemo<PlanListItem[]>(
() => [{ id: openedPlan.id, name: openedPlan.name }, ...siblings],
[openedPlan.id, openedPlan.name, siblings]
);
const [primaryId, setPrimaryId] = useState(openedPlan.id);
const [loaded, setLoaded] = useState<Record<string, { plan: PlanInput; computed: PlanComputed }>>({});
useEffect(() => {
if (primaryId === openedPlan.id || loaded[primaryId]) return;
let cancelled = false;
(async () => {
const d = await api.get<{ plan: PlanInput; computed: PlanComputed }>(`/api/scenarios/${primaryId}`);
if (!cancelled) setLoaded((prev) => ({ ...prev, [primaryId]: { plan: d.plan, computed: d.computed } }));
})();
return () => {
cancelled = true;
};
}, [primaryId, openedPlan.id, loaded]);
const basePlan = primaryId === openedPlan.id ? openedPlan : (loaded[primaryId]?.plan ?? openedPlan);
const baseComputed = primaryId === openedPlan.id ? openedComputed : (loaded[primaryId]?.computed ?? openedComputed);
// Gezeigt wird wahlweise der Arbeitsstand oder eine festgehaltene Version.
const { versionId, setVersionId, plan, loading: versionLoading } = useVersionedPlan(currentPlan.id, currentPlan);
const { versionId, setVersionId, plan, loading: versionLoading } = useVersionedPlan(basePlan.id, basePlan);
// Der Snapshot ist ein PlanInput -- das Gerechnete entsteht hier lokal (computePlan ist rein).
const basis = useAnalysisBasis(plan, actuals, origins);
// Version UND Datenquelle bestimmen, was gezeigt wird. Beim Arbeitsstand mit Plandaten
// bleibt es beim bereits vom Server Gerechneten -- kein zweiter Lauf nötig.
const computed = useMemo(
() => (versionId === "current" && basis.source === "PLAN" ? currentComputed : basis.computed),
[versionId, currentComputed, basis.source, basis.computed]
() => (versionId === "current" && basis.source === "PLAN" ? baseComputed : basis.computed),
[versionId, baseComputed, basis.source, basis.computed]
);
const [compareIds, setCompareIds] = useState<string[]>([]);
const [compareData, setCompareData] = useState<Record<string, PlanComputed>>({});
// Vom Nutzer in der Legende gewählte Farben je Serie -- überschreibt die Palette.
const [colors, setColors] = useState<Record<string, string>>({});
async function toggleCompare(id: string) {
if (compareIds.includes(id)) {
@@ -69,38 +97,59 @@ export function Dashboard({
}
}
// Vergleichbar sind alle Szenarien ausser dem gerade gezeigten.
const comparable = allScenarios.filter((s) => s.id !== primaryId);
const series: TimelineSeries[] = useMemo(() => {
const result: TimelineSeries[] = [{ label: plan.name, color: CHART_PALETTE[0], computed }];
const result: TimelineSeries[] = [
{ id: plan.id, label: plan.name, color: colors[plan.id] ?? CHART_PALETTE[0], computed },
];
// Im Ist-Modus die reine Planlinie als gestrichelte Referenz daneben -- ohne Anker sagt
// eine Ist-Linie allein nichts aus.
if (basis.source === "ACTUAL") {
const id = `${plan.id}__plan`;
result.push({
id,
label: `${plan.name} (Plan)`,
color: CHART_PALETTE[0],
color: colors[id] ?? CHART_PALETTE[0],
computed: basis.planComputed,
dashed: true,
});
}
compareIds.forEach((id, i) => {
const c = compareData[id];
const name = siblings.find((p) => p.id === id)?.name ?? id;
if (c) result.push({ label: name, color: CHART_PALETTE[(i + 1) % CHART_PALETTE.length], computed: c });
const name = allScenarios.find((p) => p.id === id)?.name ?? id;
if (c) {
result.push({ id, label: name, color: colors[id] ?? CHART_PALETTE[(i + 1) % CHART_PALETTE.length], computed: c });
}
});
// Vier Serien sind die Grenze der Lesbarkeit -- darüber hilft keine Farbpalette mehr.
return result.slice(0, 4);
}, [plan.name, computed, compareIds, compareData, siblings, basis.source, basis.planComputed]);
return result;
}, [plan.id, plan.name, computed, compareIds, compareData, allScenarios, basis.source, basis.planComputed, colors]);
const otherPlans = siblings;
const lastPhase = computed.phases[computed.phases.length - 1];
const showScenarioPicker = allScenarios.length > 1;
return (
<div className="flex flex-col gap-6">
<VersionBar
scenarioId={currentPlan.id}
versionId={versionId}
onChange={setVersionId}
loading={versionLoading}
/>
{/* Szenario-Auswahl: gilt für alle drei Grafiken. */}
{showScenarioPicker && (
<div className="flex flex-wrap items-center gap-2">
<span className="text-xs font-medium text-muted">Szenario</span>
<select
value={primaryId}
onChange={(e) => setPrimaryId(e.target.value)}
className="rounded-lg border border-border bg-input px-2.5 py-1.5 text-sm text-fg focus:border-accent focus:outline-none"
>
{allScenarios.map((s) => (
<option key={s.id} value={s.id}>
{s.name}
</option>
))}
</select>
</div>
)}
<VersionBar scenarioId={basePlan.id} versionId={versionId} onChange={setVersionId} loading={versionLoading} />
<AnalysisBar basis={basis} />
@@ -123,7 +172,7 @@ export function Dashboard({
<SaveAnalysisButton
planId={planId}
type="CHART"
scenarioName={currentPlan.name}
scenarioName={plan.name}
versionLabel={versionId === "current" ? null : versionId}
build={() => buildChartResult(chartId, computed, series, basis)}
/>
@@ -138,10 +187,10 @@ export function Dashboard({
<section className="rounded-xl border border-border bg-surface p-4 shadow-sm">
<h3 className="mb-1 flex items-center gap-1.5 text-sm font-semibold text-fg">
<LineChartIcon className="h-4 w-4 text-accent" />
Einkommen vs. Ausgaben pro Jahr
Einkommen vs. Ausgaben pro Jahr · {plan.name}
</h3>
<p className="mb-3 text-xs text-muted">
Die Fläche zwischen Einkommen und nominalen Ausgaben ist die Spar- (grün) bzw. Verzehrquote (rot).
Die Fläche zwischen Einkommen und nominalen Ausgaben ist die Spar- bzw. Verzehrquote.
Die blasse Linie sind die realen Ausgaben &ndash; der Abstand zur nominalen Linie ist der Inflationsanteil.
</p>
<SparquoteChart computed={computed} />
@@ -156,10 +205,10 @@ export function Dashboard({
Vermögensverlauf nach Alter
</h3>
</div>
{otherPlans.length > 0 && (
{comparable.length > 0 && (
<div className="mb-3 flex flex-wrap gap-2">
<span className="text-xs text-muted">Szenarien vergleichen:</span>
{otherPlans.map((p) => (
<span className="text-xs text-muted">Weitere Szenarien überlagern:</span>
{comparable.map((p) => (
<label key={p.id} className="flex items-center gap-1 text-xs text-muted">
<input type="checkbox" checked={compareIds.includes(p.id)} onChange={() => toggleCompare(p.id)} />
{p.name}
@@ -167,7 +216,11 @@ export function Dashboard({
))}
</div>
)}
<WealthChart series={series} metric={basis.metric} />
<WealthChart
series={series}
metric={basis.metric}
onColorChange={(id, color) => setColors((prev) => ({ ...prev, [id]: color }))}
/>
</section>
)}
@@ -175,11 +228,11 @@ export function Dashboard({
<section className="rounded-xl border border-border bg-surface p-4 shadow-sm">
<h3 className="mb-1 flex items-center gap-1.5 text-sm font-semibold text-fg">
<BarChart3 className="h-4 w-4 text-accent" />
Vermögensaufteilung pro Phase (Beginn &amp; Ende)
Vermögensaufteilung im Zeitverlauf · {plan.name}
</h3>
<p className="mb-3 text-xs text-muted">
Je Phase links die Aufteilung zu Beginn, rechts am Ende. Das Ende einer Phase entspricht im
Gesamtvolumen dem Beginn der nächsten &ndash; die Aufteilung kann durch Umschichtung abweichen.
Links der Verlauf über alle Planjahre (gestapelt; die gestrichelten Linien sind die Phasengrenzen), rechts
die relative Aufteilung zu einem wählbaren Zeitpunkt.
</p>
<AllocationChart computed={computed} />
</section>
@@ -230,8 +283,8 @@ function buildChartResult(
xLabel: "Jahr",
yFormat: "chf" as const,
series: [
{ label: "Einkommen", color: "#16a34a", points: computed.yearly.map((y) => ({ x: y.year, y: y.income })) },
{ label: "Ausgaben", color: "#dc2626", points: computed.yearly.map((y) => ({ x: y.year, y: y.expenseNominal })) },
{ label: "Einkommen", color: "var(--chart-3)", points: computed.yearly.map((y) => ({ x: y.year, y: y.income })) },
{ label: "Ausgaben", color: "var(--chart-5)", points: computed.yearly.map((y) => ({ x: y.year, y: y.expenseNominal })) },
],
},
},
+4 -4
View File
@@ -43,8 +43,8 @@ const CHARTS: { id: ChartId; label: string; hint: string }[] = [
},
];
const BASE_COLOR = "#9ca3af";
const LIVE_COLOR = "#4f46e5";
const BASE_COLOR = "var(--faint)";
const LIVE_COLOR = "var(--chart-1)";
export function LiveSimDialog({
plan: currentPlan,
@@ -243,8 +243,8 @@ export function LiveSimDialog({
<WealthChart
series={[
// Referenz zuerst, damit die Live-Linie darüber liegt.
{ label: "Dein Plan", color: BASE_COLOR, computed: base.computed },
{ label: "Simuliert", color: LIVE_COLOR, computed: live.computed },
{ id: "base", label: "Dein Plan", color: BASE_COLOR, computed: base.computed },
{ id: "live", label: "Simuliert", color: LIVE_COLOR, computed: live.computed },
]}
/>
)}
+1 -1
View File
@@ -35,7 +35,7 @@ import type { PlanInput, ScenarioMeta } from "@/lib/types";
import type { PlanComputed } from "@/lib/calculations";
// Farben der Szenario-Serien -- wie im Vermögensverlauf, damit die Zuordnung vertraut bleibt.
const PALETTE = ["#4f46e5", "#0ea5e9", "#16a34a", "#d97706", "#dc2626", "#7c3aed"];
const PALETTE = ["var(--chart-1)", "var(--chart-2)", "var(--chart-3)", "var(--chart-4)", "var(--chart-5)", "var(--chart-6)"];
// Ergebnis je Szenario: ZWEI Welten (historische vs. geplante Renditen), aus jeder werden
// ZWEI Schwellen abgelesen (Plan-Endbetrag und Zielbetrag) -- die vier Fälle aus 4.12.7.
+219 -39
View File
@@ -3,16 +3,21 @@
import { useCallback, useEffect, useState } from "react";
import {
BarChart3,
CalendarClock,
Check,
Dices,
Eye,
FileSpreadsheet,
GitBranch,
History,
Layers,
LineChart as LineChartIcon,
Pencil,
Plus,
SlidersHorizontal,
Table2,
Tornado,
Trash2,
X,
} from "lucide-react";
import { Button, useConfirm, useToast } from "@/components/ui";
import { api } from "@/lib/api-client";
@@ -20,6 +25,8 @@ import { formatChf } from "@/lib/format";
import { ANALYSIS_TYPE_LABEL, sourceLabel, type AnalysisType, type SavedAnalysisMeta } from "@/lib/analyses";
import type { PersonRole } from "@/lib/types";
export type PlanTab = "dashboard" | "scenarios" | "actuals" | "analyses" | "reports";
// --- Typen der Dashboard-Antwort ---
interface DashboardResponse {
plan: {
@@ -45,8 +52,9 @@ interface DashboardResponse {
name: string;
isBase: boolean;
parentScenarioId: string | null;
currentMajor: number;
version: string | null;
elementCount: number;
phaseCount: number;
versionCount: number;
endNominal: number;
endReal: number;
@@ -74,21 +82,69 @@ function useDashboard(planId: string, reloadKey: number) {
return { data, error };
}
function Stat({ label, value, hint, tone }: { label: string; value: string; hint?: string; tone?: "danger" }) {
return (
<div className="rounded-xl border border-border bg-surface p-4 shadow-sm">
<div className="text-xs text-muted">{label}</div>
<div className={`mt-1 text-xl font-semibold ${tone === "danger" ? "text-danger" : "text-fg"}`}>{value}</div>
// Anklickbare Kennzahl-Kachel. Ohne `onClick` bleibt sie eine reine Anzeige.
function Stat({
label,
value,
hint,
tone,
icon: Icon,
onClick,
}: {
label: string;
value: string;
hint?: string;
tone?: "danger" | "success";
icon?: typeof Layers;
onClick?: () => void;
}) {
const toneClass = tone === "danger" ? "text-danger" : tone === "success" ? "text-success" : "text-fg";
const body = (
<>
<div className="flex items-center gap-1.5 text-xs text-muted">
{Icon && <Icon className="h-3.5 w-3.5 text-accent" />}
{label}
</div>
<div className={`mt-1 text-2xl font-semibold tabular-nums ${toneClass}`}>{value}</div>
{hint && <div className="mt-0.5 text-[11px] text-faint">{hint}</div>}
</div>
</>
);
if (!onClick) {
return <div className="rounded-2xl border border-border bg-surface p-4 shadow-sm">{body}</div>;
}
return (
<button
type="button"
onClick={onClick}
className="group rounded-2xl border border-border bg-surface p-4 text-left shadow-sm transition-all hover:-translate-y-0.5 hover:border-accent hover:shadow-md"
>
{body}
<div className="mt-1 text-[11px] font-medium text-accent opacity-0 transition-opacity group-hover:opacity-100">
Öffnen
</div>
</button>
);
}
// =========================================================================================
// Plan-Dashboard
// =========================================================================================
export function PlanDashboardView({ planId }: { planId: string }) {
const { data, error } = useDashboard(planId, 0);
export function PlanDashboardView({
planId,
onNavigate,
onRenamed,
}: {
planId: string;
onNavigate: (tab: PlanTab) => void;
onRenamed: () => void;
}) {
const [reloadKey, setReloadKey] = useState(0);
const { data, error } = useDashboard(planId, reloadKey);
const [editing, setEditing] = useState(false);
const [draftName, setDraftName] = useState("");
const [saving, setSaving] = useState(false);
const toast = useToast();
if (error) return <p className="text-sm text-danger">{error}</p>;
if (!data) return <p className="text-sm text-muted">Wird geladen</p>;
@@ -97,43 +153,134 @@ export function PlanDashboardView({ planId }: { planId: string }) {
.map((p) => `${p.name || (p.role === "PERSON_A" ? "Person A" : "Person B")} (${p.age})`)
.join(" · ");
// Jüngster erfasster Ist-Datensatz -- er bestimmt, gegen welchen Stand verglichen wird.
const latestActualYear = base && base.actualYears.length > 0 ? Math.max(...base.actualYears) : null;
const delta = base && base.actualEndNominal !== null ? base.actualEndNominal - base.endNominal : null;
async function saveName() {
const name = draftName.trim();
if (!name) return;
setSaving(true);
try {
await api.patch(`/api/plans/${planId}`, { name });
setEditing(false);
setReloadKey((k) => k + 1);
onRenamed();
toast("success", "Plan umbenannt.");
} catch (e) {
toast("error", e instanceof Error ? e.message : "Umbenennen fehlgeschlagen.");
} finally {
setSaving(false);
}
}
return (
<div className="flex flex-col gap-6">
<div>
<h2 className="text-lg font-semibold text-fg">{plan.name}</h2>
{editing ? (
<div className="flex flex-wrap items-center gap-2">
<input
autoFocus
value={draftName}
onChange={(e) => setDraftName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") void saveName();
if (e.key === "Escape") setEditing(false);
}}
maxLength={120}
className="rounded-lg border border-border bg-input px-3 py-1.5 text-lg font-semibold text-fg focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/25"
/>
<Button size="sm" onClick={saveName} disabled={saving}>
<Check className="h-4 w-4" /> Speichern
</Button>
<Button size="sm" variant="secondary" onClick={() => setEditing(false)}>
<X className="h-4 w-4" />
</Button>
</div>
) : (
<div className="group flex items-center gap-2">
<h2 className="text-lg font-semibold text-fg">{plan.name}</h2>
<button
type="button"
aria-label="Plan umbenennen"
title="Plan umbenennen"
onClick={() => {
setDraftName(plan.name);
setEditing(true);
}}
className="rounded-md p-1 text-faint opacity-60 transition-opacity hover:bg-surface-2 hover:text-fg group-hover:opacity-100"
>
<Pencil className="h-4 w-4" />
</button>
</div>
)}
<p className="text-sm text-muted">
{plan.householdType === "COUPLE" ? "Paar" : "Einzelperson"} · {persons}
{plan.startYear ? ` · Planstart ${plan.startYear}` : ""}
</p>
</div>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
<Stat label="Szenarien" value={String(counts.scenarios)} />
<Stat label="Effektive Werte" value={String(counts.actuals)} hint="erfasste Ist-Datensätze" />
<Stat label="Gespeicherte Analysen" value={String(counts.analyses)} />
<Stat label="Lebensphasen" value={base ? String(base.phaseCount) : ""} hint="laut Basisszenario" />
{/* Anklickbare Kacheln -- jede führt in ihren Bereich. */}
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
<Stat
label="Szenarien"
value={String(counts.scenarios)}
hint="Varianten dieser Planung"
icon={Layers}
onClick={() => onNavigate("scenarios")}
/>
<Stat
label="Effektive Werte"
value={String(counts.actuals)}
hint="erfasste Ist-Datensätze"
icon={CalendarClock}
onClick={() => onNavigate("actuals")}
/>
<Stat
label="Gespeicherte Analysen"
value={String(counts.analyses)}
hint="Grafiken, Simulationen, Tornados"
icon={BarChart3}
onClick={() => onNavigate("analyses")}
/>
<Stat
label="Berichte"
value="PDF"
hint="erzeugen und wieder herunterladen"
icon={FileSpreadsheet}
onClick={() => onNavigate("reports")}
/>
</div>
{base && (
<div className="rounded-xl border border-border bg-surface p-4 shadow-sm">
<h3 className="mb-3 text-sm font-semibold text-fg">
<div className="rounded-2xl border border-border bg-surface p-5 shadow-sm">
<h3 className="mb-3 flex flex-wrap items-baseline gap-2 text-sm font-semibold text-fg">
Basisszenario «{base.name}»
<span className="ml-2 text-[11px] font-normal text-faint">alle Kennzahlen beziehen sich hierauf</span>
<span className="text-[11px] font-normal text-faint">alle Kennzahlen beziehen sich hierauf</span>
</h3>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-4">
<Stat label="Endvermögen (nominal)" value={formatChf(base.endNominal)} />
<Stat label="Endvermögen (real)" value={formatChf(base.endReal)} />
<Stat label="Lebensphasen" value={String(base.phaseCount)} />
{base.ruinAge !== null ? (
<Stat label="Kapital reicht" value={`bis Alter ${base.ruinAge}`} tone="danger" hint="danach aufgebraucht" />
) : (
<Stat label="Kapital reicht" value="bis Planende" />
<Stat label="Kapital reicht" value="bis Planende" tone="success" />
)}
</div>
{base.actualEndNominal !== null && (
<p className="mt-3 rounded-lg border border-accent bg-accent-soft/20 px-3 py-2 text-xs text-accent-soft-fg">
Mit den effektiven Werten liegt das Endvermögen (nominal) bei{" "}
<strong>{formatChf(base.actualEndNominal)}</strong> eine Abweichung von{" "}
<strong>{formatChf(base.actualEndNominal - base.endNominal)}</strong> gegenüber dem Plan.
{base.actualEndNominal !== null && delta !== null && (
<p
className={`mt-4 rounded-xl border px-3 py-2 text-xs ${
delta >= 0 ? "border-success bg-success/10 text-fg" : "border-danger bg-danger-soft text-fg"
}`}
>
Mit den effektiven Werten{latestActualYear ? ` von ${latestActualYear}` : ""} liegt das Endvermögen
(nominal) bei <strong>{formatChf(base.actualEndNominal)}</strong> eine Abweichung von{" "}
<strong className={delta >= 0 ? "text-success" : "text-danger"}>
{delta >= 0 ? "+" : ""}
{formatChf(delta)}
</strong>{" "}
gegenüber dem Plan.
</p>
)}
</div>
@@ -147,28 +294,36 @@ export function PlanDashboardView({ planId }: { planId: string }) {
// =========================================================================================
export function ScenarioListView({
planId,
reloadKey = 0,
onOpenMatrix,
onOpenHistory,
onNew,
onCopyFrom,
onDelete,
}: {
planId: string;
reloadKey?: number;
onOpenMatrix: (scenarioId: string) => void;
onOpenHistory: (scenarioId: string) => void;
onNew: () => void;
// Kopiervorlage ist frei wählbar -- nicht mehr zwingend das Basisszenario.
onCopyFrom: (scenarioId: string) => void;
onDelete: (scenarioId: string) => void;
}) {
const { data, error } = useDashboard(planId, 0);
const { data, error } = useDashboard(planId, reloadKey);
if (error) return <p className="text-sm text-danger">{error}</p>;
if (!data) return <p className="text-sm text-muted">Wird geladen</p>;
const nameById = new Map(data.scenarios.map((s) => [s.id, s.name]));
const baseId = data.scenarios.find((s) => s.isBase)?.id ?? data.scenarios[0]?.id;
return (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-fg">Szenarien</h2>
<Button onClick={onNew}>
<Plus className="h-4 w-4" /> Neues Szenario
</Button>
{baseId && (
<Button onClick={() => onCopyFrom(baseId)}>
<Plus className="h-4 w-4" /> Neues Szenario
</Button>
)}
</div>
<div className="overflow-x-auto rounded-xl border border-border">
@@ -177,6 +332,7 @@ export function ScenarioListView({
<tr className="bg-surface-2 text-xs text-faint">
<th className="px-3 py-2 text-left font-semibold">Szenario</th>
<th className="px-3 py-2 text-right font-semibold">Version</th>
<th className="px-3 py-2 text-right font-semibold">Phasen</th>
<th className="px-3 py-2 text-right font-semibold">Elemente</th>
<th className="px-3 py-2 text-right font-semibold">Endvermögen (nom.)</th>
<th className="px-3 py-2 text-right font-semibold">Kapital reicht</th>
@@ -185,11 +341,20 @@ export function ScenarioListView({
</thead>
<tbody>
{data.scenarios.map((s) => (
<tr key={s.id} className={`border-t border-border ${s.isBase ? "bg-accent-soft/20" : ""}`}>
// Die ganze Zeile öffnet die Matrix -- der frühere «Matrix»-Knopf entfällt.
<tr
key={s.id}
onClick={() => onOpenMatrix(s.id)}
className={`cursor-pointer border-t border-border transition-colors hover:bg-accent-soft/40 ${
s.isBase ? "bg-accent-soft/20" : ""
}`}
>
<td className="px-3 py-2">
<div className="flex items-center gap-2">
<span className="font-medium text-fg">{s.name}</span>
{s.isBase && <span className="rounded bg-accent px-1.5 py-0.5 text-[10px] font-semibold text-accent-fg">Basis</span>}
{s.isBase && (
<span className="rounded bg-accent px-1.5 py-0.5 text-[10px] font-semibold text-accent-fg">Basis</span>
)}
</div>
{s.parentScenarioId && (
<div className="flex items-center gap-1 text-[11px] text-faint">
@@ -197,7 +362,8 @@ export function ScenarioListView({
</div>
)}
</td>
<td className="px-3 py-2 text-right tabular-nums text-muted">{s.currentMajor}.x</td>
<td className="px-3 py-2 text-right tabular-nums text-muted">{s.version ?? ""}</td>
<td className="px-3 py-2 text-right tabular-nums text-muted">{s.phaseCount}</td>
<td className="px-3 py-2 text-right tabular-nums text-muted">{s.elementCount}</td>
<td className="px-3 py-2 text-right tabular-nums text-fg">{formatChf(s.endNominal)}</td>
<td className="px-3 py-2 text-right">
@@ -207,10 +373,12 @@ export function ScenarioListView({
<span className="text-success">Planende</span>
)}
</td>
<td className="px-3 py-2">
{/* Aktionen: stopPropagation, sonst öffnet der Zeilenklick die Matrix. */}
<td className="px-3 py-2" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-end gap-1.5">
<button
type="button"
title="Änderungshistorie"
onClick={() => onOpenHistory(s.id)}
className="flex items-center gap-1 rounded-lg border border-border px-2 py-1 text-[11px] text-muted hover:bg-surface-2 hover:text-fg"
>
@@ -218,11 +386,23 @@ export function ScenarioListView({
</button>
<button
type="button"
onClick={() => onOpenMatrix(s.id)}
title="Neues Szenario aus diesem"
onClick={() => onCopyFrom(s.id)}
className="flex items-center gap-1 rounded-lg border border-border px-2 py-1 text-[11px] text-muted hover:bg-surface-2 hover:text-fg"
>
<Table2 className="h-3.5 w-3.5" /> Matrix
<Plus className="h-3.5 w-3.5" /> Kopie
</button>
{!s.isBase && (
<button
type="button"
aria-label="Szenario löschen"
title="Szenario löschen"
onClick={() => onDelete(s.id)}
className="rounded-lg border border-border px-2 py-1 text-[11px] text-muted hover:border-danger hover:bg-danger-soft hover:text-danger"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
</div>
</td>
</tr>
@@ -394,7 +574,7 @@ function FlipTile({ tile, onClick }: { tile: (typeof TILES)[number]; onClick: ()
onMouseEnter={() => setFlipped(true)}
onMouseLeave={() => setFlipped(false)}
// Touch hat kein Hover -- ein Antippen des Info-Bereichs klappt um, statt gleich zu starten.
className="group relative flex h-40 flex-col items-start justify-between overflow-hidden rounded-2xl border border-border bg-surface p-4 text-left shadow-sm transition-colors hover:border-accent"
className="group relative flex h-40 flex-col items-start justify-between overflow-hidden rounded-2xl border border-border bg-surface p-4 text-left shadow-sm transition-all hover:-translate-y-0.5 hover:border-accent hover:shadow-md"
>
{!flipped ? (
<>
+1 -1
View File
@@ -139,7 +139,7 @@ function SavedChart({ chart }: { chart: NonNullable<SavedResult["chart"]> }) {
for (const s of chart.series) row[s.label] = s.points.find((p) => p.x === x)?.y ?? (null as unknown as number);
return row;
});
const PALETTE = ["#4f46e5", "#0ea5e9", "#16a34a", "#d97706", "#dc2626", "#7c3aed"];
const PALETTE = ["var(--chart-1)", "var(--chart-2)", "var(--chart-3)", "var(--chart-4)", "var(--chart-5)", "var(--chart-6)"];
return (
<div className="h-72 w-full">
+3 -3
View File
@@ -14,9 +14,9 @@ import {
import { formatChf } from "@/lib/format";
import type { PlanComputed } from "@/lib/calculations";
const INCOME_COLOR = "#16a34a";
const EXPENSE_COLOR = "#dc2626";
const REAL_COLOR = "#9ca3af";
const INCOME_COLOR = "var(--chart-3)";
const EXPENSE_COLOR = "var(--chart-5)";
const REAL_COLOR = "var(--faint)";
// Verlauf pro Jahr: Einkommen (nominal, inkl. Renten) vs. nominale Ausgaben; die Fläche
// dazwischen ist die Spar-/Verzehrquote (grün = Sparen, rot = Verzehr). Reale Ausgaben als
+116 -45
View File
@@ -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>
);
}