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:
+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 ? (
|
||||
<>
|
||||
|
||||
Reference in New Issue
Block a user