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
+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 ? (
<>