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:
@@ -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
@@ -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>
|
||||
|
||||
@@ -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 – 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 & 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 – 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 })) },
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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 },
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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
@@ -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 ? (
|
||||
<>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
@@ -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