Navigation auf Plan-Ebene und gespeicherte Analysen (Roadmap-Redesign)
Deploy App / deploy (push) Successful in 1m45s

Sidebar zweistufig: pro Plan die Unterpunkte Szenarien / Effektive Werte /
Analysen; Klick auf Plan-Name oeffnet ein Plan-Dashboard.

Plan-Dashboard: Kennzahlen, gerechnete Werte ausdruecklich "laut
Basisszenario", Ist-Abweichung falls erfasst.

Szenario-Liste: Version, Elementzahl, Endvermoegen, Ruinalter + Aktionen
Historie und Matrix. Baum in der Sidebar bleibt.

Analysen: vier umklappende Kacheln (auch per Antippen). Grafiken oeffnen
neu mit Auswahl EINER Grafik. Szenario-Vergleich zu den Grafiken,
CSV-Export auf die Matrix.

Gespeicherte Analysen: Grafik/MC/Einflussfaktoren als ZAHLEN einfrieren
(read-only, nichts wird neu gerechnet) -- druckfaehig fuer den spaeteren
PDF-Bericht, ohne finalWealthSorted. Einheitliche generische Ergebnisform.

Neue Tabelle SavedAnalysis, Endpunkte /analyses und /dashboard, Module
analyses.ts, Komponenten PlanViews/SavedAnalysisView/SaveAnalysisButton.
Kein Eingriff in den Rechenkern. Spezifikation 0.24 (3.10 und 9.30 neu).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-20 22:21:02 +02:00
parent ce5f83823f
commit fb70781e5b
19 changed files with 1720 additions and 113 deletions
+418
View File
@@ -0,0 +1,418 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import {
BarChart3,
Dices,
Eye,
GitBranch,
History,
LineChart as LineChartIcon,
Plus,
SlidersHorizontal,
Table2,
Tornado,
Trash2,
} from "lucide-react";
import { Button, useConfirm, useToast } from "@/components/ui";
import { api } from "@/lib/api-client";
import { formatChf } from "@/lib/format";
import { ANALYSIS_TYPE_LABEL, sourceLabel, type AnalysisType, type SavedAnalysisMeta } from "@/lib/analyses";
import type { PersonRole } from "@/lib/types";
// --- Typen der Dashboard-Antwort ---
interface DashboardResponse {
plan: {
id: string;
name: string;
householdType: "SINGLE" | "COUPLE";
startYear: number | null;
persons: { role: PersonRole; name: string | null; age: number }[];
};
counts: { scenarios: number; actuals: number; analyses: number };
base: {
scenarioId: string;
name: string;
phaseCount: number;
endNominal: number;
endReal: number;
ruinAge: number | null;
actualEndNominal: number | null;
actualYears: number[];
} | null;
scenarios: {
id: string;
name: string;
isBase: boolean;
parentScenarioId: string | null;
currentMajor: number;
elementCount: number;
versionCount: number;
endNominal: number;
endReal: number;
ruinAge: number | null;
}[];
}
function useDashboard(planId: string, reloadKey: number) {
const [data, setData] = useState<DashboardResponse | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const d = await api.get<DashboardResponse>(`/api/plans/${planId}/dashboard`);
if (!cancelled) setData(d);
} catch (e) {
if (!cancelled) setError(e instanceof Error ? e.message : "Konnte nicht geladen werden.");
}
})();
return () => {
cancelled = true;
};
}, [planId, reloadKey]);
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>
{hint && <div className="mt-0.5 text-[11px] text-faint">{hint}</div>}
</div>
);
}
// =========================================================================================
// Plan-Dashboard
// =========================================================================================
export function PlanDashboardView({ planId }: { planId: string }) {
const { data, error } = useDashboard(planId, 0);
if (error) return <p className="text-sm text-danger">{error}</p>;
if (!data) return <p className="text-sm text-muted">Wird geladen</p>;
const { plan, counts, base } = data;
const persons = plan.persons
.map((p) => `${p.name || (p.role === "PERSON_A" ? "Person A" : "Person B")} (${p.age})`)
.join(" · ");
return (
<div className="flex flex-col gap-6">
<div>
<h2 className="text-lg font-semibold text-fg">{plan.name}</h2>
<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" />
</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">
Basisszenario «{base.name}»
<span className="ml-2 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">
<Stat label="Endvermögen (nominal)" value={formatChf(base.endNominal)} />
<Stat label="Endvermögen (real)" value={formatChf(base.endReal)} />
{base.ruinAge !== null ? (
<Stat label="Kapital reicht" value={`bis Alter ${base.ruinAge}`} tone="danger" hint="danach aufgebraucht" />
) : (
<Stat label="Kapital reicht" value="bis Planende" />
)}
</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.
</p>
)}
</div>
)}
</div>
);
}
// =========================================================================================
// Szenario-Liste
// =========================================================================================
export function ScenarioListView({
planId,
onOpenMatrix,
onOpenHistory,
onNew,
}: {
planId: string;
onOpenMatrix: (scenarioId: string) => void;
onOpenHistory: (scenarioId: string) => void;
onNew: () => void;
}) {
const { data, error } = useDashboard(planId, 0);
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]));
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>
</div>
<div className="overflow-x-auto rounded-xl border border-border">
<table className="w-full border-collapse text-sm">
<thead>
<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">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>
<th className="px-3 py-2 text-right font-semibold">Aktionen</th>
</tr>
</thead>
<tbody>
{data.scenarios.map((s) => (
<tr key={s.id} className={`border-t border-border ${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>}
</div>
{s.parentScenarioId && (
<div className="flex items-center gap-1 text-[11px] text-faint">
<GitBranch className="h-3 w-3" /> aus {nameById.get(s.parentScenarioId) ?? "…"}
</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.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">
{s.ruinAge !== null ? (
<span className="text-danger">Alter {s.ruinAge}</span>
) : (
<span className="text-success">Planende</span>
)}
</td>
<td className="px-3 py-2">
<div className="flex items-center justify-end gap-1.5">
<button
type="button"
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"
>
<History className="h-3.5 w-3.5" /> Historie
</button>
<button
type="button"
onClick={() => onOpenMatrix(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
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
// =========================================================================================
// Analysen: vier Kacheln + Liste der gespeicherten
// =========================================================================================
export type ToolKind = "CHART" | "MONTE_CARLO" | "SENSITIVITY" | "LIVESIM";
const TILES: { kind: ToolKind; title: string; icon: typeof BarChart3; blurb: string }[] = [
{
kind: "CHART",
title: "Grafiken",
icon: LineChartIcon,
blurb:
"Vermögensverlauf, Einkommen vs. Ausgaben oder Vermögensaufteilung wähle eine Grafik und ob nominal/real und Plan/effektiv gezeigt werden.",
},
{
kind: "LIVESIM",
title: "Live-Simulation",
icon: SlidersHorizontal,
blurb: "Dreh an Reglern (Rendite, Ausgaben, Inflation …) und sieh die Wirkung sofort ohne etwas zu speichern.",
},
{
kind: "MONTE_CARLO",
title: "Monte-Carlo",
icon: Dices,
blurb: "Tausende Zufallspfade zeigen, wie wahrscheinlich deine Planung hält und dein Ziel erreicht wird.",
},
{
kind: "SENSITIVITY",
title: "Einflussfaktoren",
icon: Tornado,
blurb: "Welche deiner Annahmen entscheidet überhaupt über das Ergebnis? Ein Tornado ordnet sie nach Wirkung.",
},
];
function typeIcon(t: AnalysisType) {
return t === "MONTE_CARLO" ? Dices : t === "SENSITIVITY" ? Tornado : BarChart3;
}
export function AnalysesView({
planId,
onLaunch,
onOpenSaved,
}: {
planId: string;
onLaunch: (kind: ToolKind) => void;
onOpenSaved: (id: string) => void;
}) {
const [saved, setSaved] = useState<SavedAnalysisMeta[] | null>(null);
const [error, setError] = useState<string | null>(null);
const confirm = useConfirm();
const toast = useToast();
const load = useCallback(async () => {
try {
const d = await api.get<{ analyses: SavedAnalysisMeta[] }>(`/api/plans/${planId}/analyses`);
setSaved(d.analyses);
} catch (e) {
setError(e instanceof Error ? e.message : "Konnte nicht geladen werden.");
}
}, [planId]);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const d = await api.get<{ analyses: SavedAnalysisMeta[] }>(`/api/plans/${planId}/analyses`);
if (!cancelled) setSaved(d.analyses);
} catch (e) {
if (!cancelled) setError(e instanceof Error ? e.message : "Konnte nicht geladen werden.");
}
})();
return () => {
cancelled = true;
};
}, [planId]);
async function remove(a: SavedAnalysisMeta) {
const ok = await confirm({ title: "Analyse löschen?", message: `«${a.name}» wird entfernt.`, confirmLabel: "Löschen", danger: true });
if (!ok) return;
try {
await api.delete(`/api/plans/${planId}/analyses/${a.id}`);
await load();
toast("success", "Analyse gelöscht.");
} catch (e) {
toast("error", e instanceof Error ? e.message : "Löschen fehlgeschlagen.");
}
}
const dt = (iso: string) =>
new Date(iso).toLocaleDateString("de-CH", { day: "2-digit", month: "2-digit", year: "numeric" });
return (
<div className="flex flex-col gap-6">
<h2 className="text-lg font-semibold text-fg">Analysen</h2>
{/* Vier Kacheln, die beim Darüberfahren (oder Antippen) umklappen. */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
{TILES.map((t) => (
<FlipTile key={t.kind} tile={t} onClick={() => onLaunch(t.kind)} />
))}
</div>
<div>
<h3 className="mb-2 text-sm font-semibold text-fg">Gespeicherte Analysen</h3>
{error && <p className="text-xs text-danger">{error}</p>}
{!saved && !error && <p className="text-xs text-muted">Wird geladen</p>}
{saved && saved.length === 0 && (
<p className="rounded-lg border border-dashed border-border bg-surface-2 p-4 text-xs text-muted">
Noch nichts gespeichert. In jedem Werkzeug oben kannst du ein Ergebnis festhalten es erscheint dann hier.
</p>
)}
{saved && saved.length > 0 && (
<div className="flex flex-col gap-2">
{saved.map((a) => {
const Icon = typeIcon(a.type);
return (
<div key={a.id} className="flex flex-wrap items-center gap-3 rounded-xl border border-border bg-surface-2 p-3">
<Icon className="h-4 w-4 shrink-0 text-accent" />
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-fg">{a.name}</div>
<div className="text-[11px] text-muted">
{ANALYSIS_TYPE_LABEL[a.type]} · {dt(a.createdAt)}
{a.scenarioName ? ` · ${a.scenarioName}${a.versionLabel ? ` ${a.versionLabel}` : ""}` : ""} ·{" "}
{a.metric === "real" ? "real" : "nominal"} · {sourceLabel(a.source)}
{a.summary ? ` · ${a.summary}` : ""}
</div>
</div>
<button
type="button"
onClick={() => onOpenSaved(a.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"
>
<Eye className="h-3.5 w-3.5" /> Anzeigen
</button>
<button
type="button"
onClick={() => remove(a)}
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>
);
})}
</div>
)}
</div>
</div>
);
}
function FlipTile({ tile, onClick }: { tile: (typeof TILES)[number]; onClick: () => void }) {
const [flipped, setFlipped] = useState(false);
const Icon = tile.icon;
return (
<button
type="button"
onClick={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"
>
{!flipped ? (
<>
<Icon className="h-8 w-8 text-accent" />
<div>
<div className="text-sm font-semibold text-fg">{tile.title}</div>
<div className="text-[11px] text-faint">Zum Starten klicken</div>
</div>
</>
) : (
<div className="flex h-full flex-col">
<div className="mb-1 flex items-center gap-1.5 text-sm font-semibold text-accent">
<Icon className="h-4 w-4" /> {tile.title}
</div>
<p className="text-[11px] leading-relaxed text-muted">{tile.blurb}</p>
<span className="mt-auto text-[11px] font-medium text-accent">Starten </span>
</div>
)}
</button>
);
}