Navigation auf Plan-Ebene und gespeicherte Analysen (Roadmap-Redesign)
Deploy App / deploy (push) Successful in 1m45s
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:
+171
-39
@@ -11,6 +11,7 @@ import {
|
||||
CalendarClock,
|
||||
GitBranch,
|
||||
History,
|
||||
Layers,
|
||||
LayoutDashboard,
|
||||
Menu,
|
||||
PiggyBank,
|
||||
@@ -28,6 +29,8 @@ import { MonteCarloDialog } from "@/components/MonteCarloDialog";
|
||||
import { SensitivityDialog } from "@/components/SensitivityDialog";
|
||||
import { LiveSimDialog } from "@/components/LiveSimDialog";
|
||||
import { ActualsDialog } from "@/components/ActualsDialog";
|
||||
import { PlanDashboardView, ScenarioListView, AnalysesView } from "@/components/PlanViews";
|
||||
import { SavedAnalysisView } from "@/components/SavedAnalysisView";
|
||||
import { buildViews } from "@/lib/dataview";
|
||||
import type { ActualsSetInput, ElementOrigin } from "@/lib/actuals";
|
||||
import { VersionHistoryDialog } from "@/components/VersionHistoryDialog";
|
||||
@@ -99,6 +102,9 @@ function AppShellInner({ username }: { username: string }) {
|
||||
const [showLiveSim, setShowLiveSim] = useState(false);
|
||||
const [showHistory, setShowHistory] = useState(false);
|
||||
const [showActuals, setShowActuals] = useState(false);
|
||||
// Plan-Ebene: Dashboard / Szenarien-Liste / Analysen. Null = kein Plan-View aktiv.
|
||||
const [planNav, setPlanNav] = useState<{ planId: string; tab: "dashboard" | "scenarios" | "analyses" } | null>(null);
|
||||
const [savedAnalysisId, setSavedAnalysisId] = useState<string | null>(null);
|
||||
const [showSystemParams, setShowSystemParams] = useState(false);
|
||||
const [showPlanTraces, setShowPlanTraces] = useState(false);
|
||||
const [showPalette, setShowPalette] = useState(false);
|
||||
@@ -110,6 +116,7 @@ function AppShellInner({ username }: { username: string }) {
|
||||
setShowSpec(true);
|
||||
setShowSystemParams(false);
|
||||
setSelectedScenarioId(null);
|
||||
setPlanNav(null);
|
||||
}
|
||||
|
||||
const loadPlans = useCallback(async () => {
|
||||
@@ -123,7 +130,9 @@ function AppShellInner({ username }: { username: string }) {
|
||||
const loadDetail = useCallback(async (scenarioId: string, silent = false) => {
|
||||
if (!silent) setLoading(true);
|
||||
try {
|
||||
setDetail(await api.get<ScenarioDetail>(`/api/scenarios/${scenarioId}`));
|
||||
const d = await api.get<ScenarioDetail>(`/api/scenarios/${scenarioId}`);
|
||||
setDetail(d);
|
||||
return d;
|
||||
} finally {
|
||||
if (!silent) setLoading(false);
|
||||
}
|
||||
@@ -138,9 +147,9 @@ function AppShellInner({ username }: { username: string }) {
|
||||
if (selectedScenarioId) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- asynchroner Datenabruf
|
||||
loadDetail(selectedScenarioId);
|
||||
} else {
|
||||
setDetail(null);
|
||||
}
|
||||
// Kein `else setDetail(null)`: In der Plan-Ebene (planNav) wird `detail` fürs Basisszenario
|
||||
// geladen, damit die Werkzeuge starten können -- das darf hier nicht weggeräumt werden.
|
||||
}, [selectedScenarioId, loadDetail]);
|
||||
|
||||
// Cmd/Ctrl+K öffnet die Befehls-Palette.
|
||||
@@ -161,11 +170,45 @@ function AppShellInner({ username }: { username: string }) {
|
||||
|
||||
function openScenario(id: string) {
|
||||
setSelectedScenarioId(id);
|
||||
setPlanNav(null);
|
||||
setShowSpec(false);
|
||||
setShowSystemParams(false);
|
||||
setSidebarOpen(false);
|
||||
}
|
||||
|
||||
// Plan-Ebene öffnen (Dashboard / Szenarien / Analysen). Räumt die Szenario- und Wissens-
|
||||
// Ansichten weg -- es kann immer nur eine Hauptansicht aktiv sein.
|
||||
function openPlanTab(planId: string, tab: "dashboard" | "scenarios" | "analyses") {
|
||||
setPlanNav({ planId, tab });
|
||||
setSelectedScenarioId(null);
|
||||
setShowSpec(false);
|
||||
setShowSystemParams(false);
|
||||
setSidebarOpen(false);
|
||||
}
|
||||
|
||||
// Ein Analysewerkzeug aus der Analysen-Ansicht heraus starten. Die Werkzeuge arbeiten auf
|
||||
// einem Szenario -- ohne ausgewähltes nehmen wir das Basisszenario; im Werkzeug selbst
|
||||
// lässt sich Szenario/Version weiterhin umstellen.
|
||||
async function launchTool(planId: string, kind: "CHART" | "MONTE_CARLO" | "SENSITIVITY" | "LIVESIM") {
|
||||
const plan = plans.find((p) => p.id === planId);
|
||||
const baseId = plan?.scenarios.find((s) => s.isBase)?.id ?? plan?.scenarios[0]?.id;
|
||||
if (!baseId) return;
|
||||
if (detail?.meta.id !== baseId) await loadDetail(baseId, true);
|
||||
if (kind === "CHART") setShowCharts(true);
|
||||
else if (kind === "MONTE_CARLO") setShowMonteCarlo(true);
|
||||
else if (kind === "SENSITIVITY") setShowSensitivity(true);
|
||||
else setShowLiveSim(true);
|
||||
}
|
||||
|
||||
// Effektive Werte von der Plan-Ebene aus öffnen (braucht das Basisszenario geladen).
|
||||
async function openActualsForPlan(planId: string) {
|
||||
const plan = plans.find((p) => p.id === planId);
|
||||
const baseId = plan?.scenarios.find((s) => s.isBase)?.id ?? plan?.scenarios[0]?.id;
|
||||
if (!baseId) return;
|
||||
if (detail?.meta.id !== baseId) await loadDetail(baseId, true);
|
||||
setShowActuals(true);
|
||||
}
|
||||
|
||||
async function handleDeletePlan(id: string) {
|
||||
const plan = plans.find((p) => p.id === id);
|
||||
const ok = await confirm({
|
||||
@@ -217,7 +260,9 @@ function AppShellInner({ username }: { username: string }) {
|
||||
}
|
||||
}
|
||||
|
||||
const activePlan = plans.find((p) => p.scenarios.some((s) => s.id === selectedScenarioId)) ?? null;
|
||||
const activePlan =
|
||||
plans.find((p) => p.scenarios.some((s) => s.id === selectedScenarioId)) ??
|
||||
(planNav ? plans.find((p) => p.id === planNav.planId) ?? null : null);
|
||||
|
||||
// Plan-Sicht und Ist-Sicht in einem Zug. Ohne erfasste Ist-Werte bleibt `actual` null und
|
||||
// die Oberflaeche verhaelt sich exakt wie bisher.
|
||||
@@ -231,9 +276,9 @@ function AppShellInner({ username }: { username: string }) {
|
||||
const paletteActions = useMemo<PaletteAction[]>(() => {
|
||||
const base: PaletteAction[] = [
|
||||
{ id: "a-new", label: "Neuen Plan erstellen", hint: "Aktion", run: () => setShowNewPlanChooser(true) },
|
||||
{ id: "a-home", label: "Übersicht öffnen", hint: "Aktion", run: () => { setSelectedScenarioId(null); setShowSpec(false); setShowSystemParams(false); } },
|
||||
{ id: "a-spec", label: "So rechnet FPT (Spezifikation)", hint: "Aktion", run: () => { setShowSpec(true); setSpecAnchor(null); setShowSystemParams(false); setSelectedScenarioId(null); } },
|
||||
{ id: "a-params", label: "Systemparameter", hint: "Aktion", run: () => { setShowSystemParams(true); setShowSpec(false); setSelectedScenarioId(null); } },
|
||||
{ id: "a-home", label: "Übersicht öffnen", hint: "Aktion", run: () => { setSelectedScenarioId(null); setPlanNav(null); setShowSpec(false); setShowSystemParams(false); } },
|
||||
{ id: "a-spec", label: "So rechnet FPT (Spezifikation)", hint: "Aktion", run: () => { setShowSpec(true); setSpecAnchor(null); setShowSystemParams(false); setSelectedScenarioId(null); setPlanNav(null); } },
|
||||
{ id: "a-params", label: "Systemparameter", hint: "Aktion", run: () => { setShowSystemParams(true); setShowSpec(false); setSelectedScenarioId(null); setPlanNav(null); } },
|
||||
];
|
||||
if (detail && selectedScenarioId) {
|
||||
base.unshift(
|
||||
@@ -270,12 +315,13 @@ function AppShellInner({ username }: { username: string }) {
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedScenarioId(null);
|
||||
setPlanNav(null);
|
||||
setShowSpec(false);
|
||||
setShowSystemParams(false);
|
||||
setSidebarOpen(false);
|
||||
}}
|
||||
className={`flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium transition-colors ${
|
||||
selectedScenarioId === null && !showSpec && !showSystemParams ? "bg-accent-soft text-accent-soft-fg" : "text-muted hover:bg-surface-2"
|
||||
selectedScenarioId === null && !planNav && !showSpec && !showSystemParams ? "bg-accent-soft text-accent-soft-fg" : "text-muted hover:bg-surface-2"
|
||||
}`}
|
||||
>
|
||||
<LayoutDashboard className="h-4 w-4" />
|
||||
@@ -295,31 +341,67 @@ function AppShellInner({ username }: { username: string }) {
|
||||
</div>
|
||||
{plans.length === 0 && <p className="px-3 py-2 text-xs text-faint">Noch keine Pläne.</p>}
|
||||
|
||||
{plans.map((p) => (
|
||||
<div key={p.id} className="mb-1">
|
||||
<div className="group flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-semibold text-fg">
|
||||
<FolderKanban className="h-3.5 w-3.5 shrink-0 text-faint" />
|
||||
<span className="min-w-0 flex-1 truncate" title={p.name}>{p.name}</span>
|
||||
{plans.map((p) => {
|
||||
const navHere = planNav?.planId === p.id;
|
||||
const subItem = (tab: "dashboard" | "scenarios" | "analyses", label: string, Icon: typeof FolderKanban) => (
|
||||
<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 ${
|
||||
navHere && planNav?.tab === tab ? "bg-accent-soft text-accent-soft-fg" : "text-muted hover:bg-surface-2"
|
||||
}`}
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5 shrink-0" />
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
return (
|
||||
<div key={p.id} className="mb-1">
|
||||
{/* Plan-Name: Klick öffnet das Plan-Dashboard. */}
|
||||
<div className="group flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-semibold text-fg">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openPlanTab(p.id, "dashboard")}
|
||||
className="flex min-w-0 flex-1 items-center gap-1.5 text-left"
|
||||
title={p.name}
|
||||
>
|
||||
<FolderKanban className={`h-3.5 w-3.5 shrink-0 ${navHere && planNav?.tab === "dashboard" ? "text-accent" : "text-faint"}`} />
|
||||
<span className="min-w-0 flex-1 truncate">{p.name}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Plan löschen"
|
||||
onClick={() => handleDeletePlan(p.id)}
|
||||
className="rounded p-0.5 text-faint opacity-60 transition-opacity hover:bg-danger-soft hover:text-danger group-hover:opacity-100"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Unter-Menüpunkte, leicht eingezogen. */}
|
||||
{subItem("scenarios", "Szenarien", Layers)}
|
||||
{/* Der Szenario-Baum bleibt darunter -- ein Klick führt direkt in die Matrix. */}
|
||||
<ScenarioTree
|
||||
scenarios={p.scenarios}
|
||||
parentId={null}
|
||||
depth={1}
|
||||
selectedId={selectedScenarioId}
|
||||
onSelect={openScenario}
|
||||
onCopy={setCopyFrom}
|
||||
onDelete={handleDeleteScenario}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Plan löschen"
|
||||
onClick={() => handleDeletePlan(p.id)}
|
||||
className="rounded p-0.5 text-faint opacity-60 transition-opacity hover:bg-danger-soft hover:text-danger group-hover:opacity-100"
|
||||
onClick={() => openActualsForPlan(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 text-muted transition-colors hover:bg-surface-2"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
<CalendarClock className="h-3.5 w-3.5 shrink-0" />
|
||||
Effektive Werte
|
||||
</button>
|
||||
{subItem("analyses", "Analysen", BarChart3)}
|
||||
</div>
|
||||
<ScenarioTree
|
||||
scenarios={p.scenarios}
|
||||
parentId={null}
|
||||
depth={0}
|
||||
selectedId={selectedScenarioId}
|
||||
onSelect={openScenario}
|
||||
onCopy={setCopyFrom}
|
||||
onDelete={handleDeleteScenario}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="mt-4 flex flex-col gap-1 border-t border-border pt-3">
|
||||
<span className="px-3 pb-1 text-[11px] font-semibold uppercase tracking-wide text-faint">Wissen</span>
|
||||
@@ -330,6 +412,7 @@ function AppShellInner({ username }: { username: string }) {
|
||||
setSpecAnchor(null);
|
||||
setShowSystemParams(false);
|
||||
setSelectedScenarioId(null);
|
||||
setPlanNav(null);
|
||||
setSidebarOpen(false);
|
||||
}}
|
||||
className={`flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm font-medium transition-colors ${
|
||||
@@ -345,6 +428,7 @@ function AppShellInner({ username }: { username: string }) {
|
||||
setShowSystemParams(true);
|
||||
setShowSpec(false);
|
||||
setSelectedScenarioId(null);
|
||||
setPlanNav(null);
|
||||
setSidebarOpen(false);
|
||||
}}
|
||||
className={`flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm font-medium transition-colors ${
|
||||
@@ -397,9 +481,11 @@ function AppShellInner({ username }: { username: string }) {
|
||||
? "Systemparameter"
|
||||
: showSpec
|
||||
? "So rechnet FPT"
|
||||
: detail
|
||||
? `${detail.meta.planName} · ${detail.meta.name}`
|
||||
: "Übersicht"}
|
||||
: planNav
|
||||
? `${activePlan?.name ?? "Plan"} · ${planNav.tab === "dashboard" ? "Dashboard" : planNav.tab === "scenarios" ? "Szenarien" : "Analysen"}`
|
||||
: selectedScenarioId && detail
|
||||
? `${detail.meta.planName} · ${detail.meta.name}`
|
||||
: "Übersicht"}
|
||||
</h1>
|
||||
<ProfileMenu username={username} />
|
||||
</header>
|
||||
@@ -409,14 +495,39 @@ function AppShellInner({ username }: { username: string }) {
|
||||
|
||||
{showSpec && <SpecView anchor={specAnchor} />}
|
||||
|
||||
{!showSpec && !showSystemParams && loading && <PlanSkeleton />}
|
||||
{!showSpec && !showSystemParams && loading && !planNav && <PlanSkeleton />}
|
||||
|
||||
{!showSpec && !showSystemParams && !loading && selectedScenarioId === null && (
|
||||
{/* Plan-Ebene: Dashboard / Szenarien / Analysen */}
|
||||
{!showSpec && !showSystemParams && planNav?.tab === "dashboard" && (
|
||||
<PlanDashboardView planId={planNav.planId} />
|
||||
)}
|
||||
{!showSpec && !showSystemParams && planNav?.tab === "scenarios" && (
|
||||
<ScenarioListView
|
||||
planId={planNav.planId}
|
||||
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);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{!showSpec && !showSystemParams && planNav?.tab === "analyses" && (
|
||||
<AnalysesView
|
||||
planId={planNav.planId}
|
||||
onLaunch={(kind) => void launchTool(planNav.planId, kind)}
|
||||
onOpenSaved={(id) => setSavedAnalysisId(id)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!showSpec && !showSystemParams && !loading && !planNav && selectedScenarioId === null && (
|
||||
<DashboardHome
|
||||
username={username}
|
||||
plans={plans}
|
||||
creatingDemo={creatingDemo}
|
||||
onSelect={openScenario}
|
||||
onOpenPlan={(id) => openPlanTab(id, "dashboard")}
|
||||
onCreateGuided={() => setShowWizard(true)}
|
||||
onCreateChooser={() => setShowNewPlanChooser(true)}
|
||||
onCreateDemo={handleCreateDemo}
|
||||
@@ -546,6 +657,7 @@ function AppShellInner({ username }: { username: string }) {
|
||||
<ChartsDialog onClose={() => setShowCharts(false)} title={`Grafiken · ${detail.meta.name}`}>
|
||||
<Dashboard
|
||||
plan={detail.plan}
|
||||
planId={detail.meta.planId}
|
||||
computed={detail.computed}
|
||||
siblings={(activePlan?.scenarios ?? []).filter((s) => s.id !== detail.meta.id)}
|
||||
actuals={detail.actuals ?? []}
|
||||
@@ -569,6 +681,7 @@ function AppShellInner({ username }: { username: string }) {
|
||||
{showSensitivity && detail && (
|
||||
<SensitivityDialog
|
||||
plan={detail.plan}
|
||||
planId={detail.meta.planId}
|
||||
actuals={detail.actuals ?? []}
|
||||
origins={detail.elementOrigins ?? []}
|
||||
onClose={() => setShowSensitivity(false)}
|
||||
@@ -584,6 +697,14 @@ function AppShellInner({ username }: { username: string }) {
|
||||
/>
|
||||
)}
|
||||
|
||||
{savedAnalysisId && planNav && (
|
||||
<SavedAnalysisView
|
||||
planId={planNav.planId}
|
||||
analysisId={savedAnalysisId}
|
||||
onClose={() => setSavedAnalysisId(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showActuals && detail && activePlan && (
|
||||
<ActualsDialog
|
||||
planId={detail.meta.planId}
|
||||
@@ -817,7 +938,7 @@ function DashboardHome({
|
||||
username,
|
||||
plans,
|
||||
creatingDemo,
|
||||
onSelect,
|
||||
onOpenPlan,
|
||||
onCreateGuided,
|
||||
onCreateChooser,
|
||||
onCreateDemo,
|
||||
@@ -826,7 +947,7 @@ function DashboardHome({
|
||||
username: string;
|
||||
plans: PlanListItem[];
|
||||
creatingDemo: boolean;
|
||||
onSelect: (scenarioId: string) => void;
|
||||
onOpenPlan: (planId: string) => void;
|
||||
onCreateGuided: () => void;
|
||||
onCreateChooser: () => void;
|
||||
onCreateDemo: () => void;
|
||||
@@ -858,13 +979,15 @@ function DashboardHome({
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{plans.map((p) => {
|
||||
const base = p.scenarios.find((s) => s.isBase) ?? p.scenarios[0];
|
||||
const others = p.scenarios.length - 1;
|
||||
const persons = (p.persons ?? [])
|
||||
.map((pe) => pe.name || (pe.role === "PERSON_A" ? "Person A" : "Person B"))
|
||||
.join(" · ");
|
||||
return (
|
||||
<div
|
||||
key={p.id}
|
||||
className="group relative flex cursor-pointer flex-col gap-2 rounded-xl border border-border bg-surface p-4 shadow-sm transition-shadow hover:shadow-md"
|
||||
onClick={() => base && onSelect(base.id)}
|
||||
onClick={() => onOpenPlan(p.id)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-accent-soft">
|
||||
@@ -873,7 +996,8 @@ function DashboardHome({
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate font-medium text-fg">{p.name}</div>
|
||||
<div className="text-xs text-muted">
|
||||
Basisszenario{others > 0 ? ` + ${others} Variante${others === 1 ? "" : "n"}` : ""}
|
||||
{persons || "Basisszenario"}
|
||||
{others > 0 ? ` · +${others} Variante${others === 1 ? "" : "n"}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@@ -888,6 +1012,14 @@ function DashboardHome({
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 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>
|
||||
{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.analyses > 0 && <span>{p._count.analyses} Analyse{p._count.analyses === 1 ? "" : "n"}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
+152
-60
@@ -1,10 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { BarChart3, Download, LineChart as LineChartIcon } from "lucide-react";
|
||||
import { BarChart3, LineChart as LineChartIcon } from "lucide-react";
|
||||
import { AllocationChart, CHART_PALETTE } from "@/components/AllocationChart";
|
||||
import { useVersionedPlan, VersionBar } from "@/components/VersionPicker";
|
||||
import { AnalysisBar, useAnalysisBasis } from "@/components/AnalysisControls";
|
||||
import { SaveAnalysisButton } from "@/components/SaveAnalysisButton";
|
||||
import type { ActualsSetInput, ElementOrigin } from "@/lib/actuals";
|
||||
import { WealthChart, type TimelineSeries } from "@/components/WealthChart";
|
||||
import { SparquoteChart } from "@/components/SparquoteChart";
|
||||
@@ -18,20 +19,31 @@ interface PlanListItem {
|
||||
name: string;
|
||||
}
|
||||
|
||||
type ChartId = "wealth" | "cashflow" | "allocation";
|
||||
const CHART_OPTIONS: { id: ChartId; label: string }[] = [
|
||||
{ id: "wealth", label: "Vermögensverlauf" },
|
||||
{ id: "cashflow", label: "Einkommen vs. Ausgaben" },
|
||||
{ id: "allocation", label: "Vermögensaufteilung" },
|
||||
];
|
||||
|
||||
export function Dashboard({
|
||||
plan: currentPlan,
|
||||
planId,
|
||||
computed: currentComputed,
|
||||
siblings,
|
||||
actuals = [],
|
||||
origins = [],
|
||||
}: {
|
||||
plan: PlanInput;
|
||||
planId: string;
|
||||
computed: PlanComputed;
|
||||
// Die übrigen Szenarien desselben Plans -- nur die sind sinnvoll vergleichbar.
|
||||
siblings: PlanListItem[];
|
||||
actuals?: ActualsSetInput[];
|
||||
origins?: ElementOrigin[];
|
||||
}) {
|
||||
// Eine Grafik zur Zeit -- man wählt zuerst, welche (Punkt 5 der Analysen-Ansicht).
|
||||
const [chartId, setChartId] = useState<ChartId>("wealth");
|
||||
// Gezeigt wird wahlweise der Arbeitsstand oder eine festgehaltene Version.
|
||||
const { versionId, setVersionId, plan, loading: versionLoading } = useVersionedPlan(currentPlan.id, currentPlan);
|
||||
// Der Snapshot ist ein PlanInput -- das Gerechnete entsteht hier lokal (computePlan ist rein).
|
||||
@@ -92,77 +104,157 @@ export function Dashboard({
|
||||
|
||||
<AnalysisBar basis={basis} />
|
||||
|
||||
{/* Grafikauswahl */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="inline-flex flex-wrap gap-0.5 rounded-lg border border-border bg-surface-2 p-0.5 text-xs">
|
||||
{CHART_OPTIONS.map((o) => (
|
||||
<button
|
||||
key={o.id}
|
||||
type="button"
|
||||
onClick={() => setChartId(o.id)}
|
||||
className={`rounded-md px-2.5 py-1 font-medium transition-colors ${
|
||||
chartId === o.id ? "bg-accent text-accent-fg" : "text-muted hover:text-fg"
|
||||
}`}
|
||||
>
|
||||
{o.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<SaveAnalysisButton
|
||||
planId={planId}
|
||||
type="CHART"
|
||||
scenarioName={currentPlan.name}
|
||||
versionLabel={versionId === "current" ? null : versionId}
|
||||
build={() => buildChartResult(chartId, computed, series, basis)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<StatCard label="Endvermögen (nominal)" value={lastPhase ? lastPhase.endWealthNominal : 0} />
|
||||
<StatCard label="Endvermögen (real, kaufkraftbereinigt)" value={lastPhase ? lastPhase.endWealthReal : 0} />
|
||||
</div>
|
||||
|
||||
<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
|
||||
</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 blasse Linie sind die realen Ausgaben – der Abstand zur nominalen Linie ist der Inflationsanteil.
|
||||
</p>
|
||||
<SparquoteChart computed={computed} />
|
||||
</section>
|
||||
|
||||
<section className="rounded-xl border border-border bg-surface p-4 shadow-sm">
|
||||
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||
<h3 className="flex items-center gap-1.5 text-sm font-semibold text-fg">
|
||||
{chartId === "cashflow" && (
|
||||
<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" />
|
||||
Vermögensverlauf nach Alter
|
||||
Einkommen vs. Ausgaben pro Jahr
|
||||
</h3>
|
||||
{/* Der Export liest immer das Szenario aus der Datenbank -- also den aktuellen
|
||||
Stand, nicht die betrachtete Version. Das wird beschriftet statt verschwiegen. */}
|
||||
<a
|
||||
href={`/api/scenarios/${currentPlan.id}/export`}
|
||||
title={
|
||||
versionId === "current"
|
||||
? undefined
|
||||
: "Der CSV-Export liefert immer den aktuellen Stand, nicht die betrachtete Version."
|
||||
}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-border px-3 py-1.5 text-xs font-medium text-muted hover:bg-surface-2"
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
CSV-Export{versionId === "current" ? "" : " (aktueller Stand)"}
|
||||
</a>
|
||||
</div>
|
||||
{otherPlans.length > 0 && (
|
||||
<div className="mb-3 flex flex-wrap gap-2">
|
||||
<span className="text-xs text-muted">Szenarien vergleichen:</span>
|
||||
{otherPlans.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}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<WealthChart series={series} metric={basis.metric} />
|
||||
</section>
|
||||
<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 blasse Linie sind die realen Ausgaben – der Abstand zur nominalen Linie ist der Inflationsanteil.
|
||||
</p>
|
||||
<SparquoteChart computed={computed} />
|
||||
</section>
|
||||
)}
|
||||
|
||||
<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)
|
||||
</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.
|
||||
</p>
|
||||
<AllocationChart computed={computed} />
|
||||
</section>
|
||||
{chartId === "wealth" && (
|
||||
<section className="rounded-xl border border-border bg-surface p-4 shadow-sm">
|
||||
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||
<h3 className="flex items-center gap-1.5 text-sm font-semibold text-fg">
|
||||
<LineChartIcon className="h-4 w-4 text-accent" />
|
||||
Vermögensverlauf nach Alter
|
||||
</h3>
|
||||
</div>
|
||||
{otherPlans.length > 0 && (
|
||||
<div className="mb-3 flex flex-wrap gap-2">
|
||||
<span className="text-xs text-muted">Szenarien vergleichen:</span>
|
||||
{otherPlans.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}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<WealthChart series={series} metric={basis.metric} />
|
||||
</section>
|
||||
)}
|
||||
|
||||
{chartId === "allocation" && (
|
||||
<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)
|
||||
</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.
|
||||
</p>
|
||||
<AllocationChart computed={computed} />
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Momentaufnahme der gerade gezeigten Grafik als Zahlen (siehe analyses.ts).
|
||||
function buildChartResult(
|
||||
chartId: ChartId,
|
||||
computed: PlanComputed,
|
||||
series: TimelineSeries[],
|
||||
basis: { metric: string; source: string },
|
||||
): { metric?: string; source?: string; summary?: string | null; inputs: Record<string, unknown>; result: Record<string, unknown> } {
|
||||
const label = CHART_OPTIONS.find((o) => o.id === chartId)!.label;
|
||||
const common = {
|
||||
metric: basis.metric,
|
||||
source: basis.source,
|
||||
summary: label,
|
||||
inputs: { params: [{ label: "Grafik", value: label }, { label: "Grösse", value: basis.metric === "real" ? "real" : "nominal" }] },
|
||||
};
|
||||
if (chartId === "wealth") {
|
||||
return {
|
||||
...common,
|
||||
result: {
|
||||
params: [{ label: "Grösse", value: basis.metric === "real" ? "real" : "nominal" }],
|
||||
chart: {
|
||||
kind: "line" as const,
|
||||
xLabel: "Alter",
|
||||
yFormat: "chf" as const,
|
||||
series: series.map((s) => ({
|
||||
label: s.label,
|
||||
dashed: s.dashed,
|
||||
points: s.computed.yearly.map((y) => ({ x: y.age, y: basis.metric === "real" ? y.wealthReal : y.wealthNominal })),
|
||||
})),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
if (chartId === "cashflow") {
|
||||
return {
|
||||
...common,
|
||||
result: {
|
||||
params: common.inputs.params,
|
||||
chart: {
|
||||
kind: "line" as const,
|
||||
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 })) },
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
// Allokation: Endvermögen je Phase als Balken (kompakte, druckbare Zusammenfassung).
|
||||
return {
|
||||
...common,
|
||||
result: {
|
||||
params: common.inputs.params,
|
||||
table: {
|
||||
columns: ["Phase", "Vermögen Ende (nom.)", "real"],
|
||||
rows: computed.phases.map((p) => [p.name, formatChf(p.endWealthNominal), formatChf(p.endWealthReal)]),
|
||||
},
|
||||
chart: {
|
||||
kind: "bar" as const,
|
||||
yFormat: "chf" as const,
|
||||
series: [{ label: "Vermögen Phasenende", points: computed.phases.map((p, i) => ({ x: i + 1, y: p.endWealthNominal })) }],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function StatCard({ label, value, help }: { label: string; value: number; help?: string }) {
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-surface p-4 shadow-sm">
|
||||
|
||||
@@ -7,6 +7,7 @@ import { NumberField, SelectField, MoneyField, RequiredNumberField } from "@/com
|
||||
import { InfoBubble } from "@/components/InfoBubble";
|
||||
import { CURRENT, loadVersionPlan, VersionSelect } from "@/components/VersionPicker";
|
||||
import { AnalysisBar, useAnalysisBasis } from "@/components/AnalysisControls";
|
||||
import { SaveAnalysisButton } from "@/components/SaveAnalysisButton";
|
||||
import type { ActualsSetInput, ElementOrigin } from "@/lib/actuals";
|
||||
import { computePlan } from "@/lib/calculations";
|
||||
import { api } from "@/lib/api-client";
|
||||
@@ -596,21 +597,93 @@ export function MonteCarloDialog({
|
||||
</div>
|
||||
|
||||
{outcomes && fanRes && (
|
||||
<MonteCarloResults
|
||||
outcomes={outcomes}
|
||||
fanRes={fanRes}
|
||||
detPoints={
|
||||
fanRes.length === 1 && loaded[fanRes[0].scenarioId]
|
||||
? detPointsOf(loaded[fanRes[0].scenarioId].plan, loaded[fanRes[0].scenarioId].computed)
|
||||
: []
|
||||
}
|
||||
/>
|
||||
<>
|
||||
<div className="flex justify-end">
|
||||
<SaveAnalysisButton
|
||||
planId={meta.planId}
|
||||
type="MONTE_CARLO"
|
||||
scenarioName={outcomes.length === 1 ? outcomes[0].name : `${outcomes.length} Szenarien`}
|
||||
versionLabel={null}
|
||||
build={() => buildMcResult(outcomes, fanRes, manualTarget, basis)}
|
||||
/>
|
||||
</div>
|
||||
<MonteCarloResults
|
||||
outcomes={outcomes}
|
||||
fanRes={fanRes}
|
||||
detPoints={
|
||||
fanRes.length === 1 && loaded[fanRes[0].scenarioId]
|
||||
? detPointsOf(loaded[fanRes[0].scenarioId].plan, loaded[fanRes[0].scenarioId].computed)
|
||||
: []
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Baut die generische, druckfähige Momentaufnahme des MC-Ergebnisses (siehe analyses.ts).
|
||||
// Bewusst OHNE finalWealthSorted -- das sind zehntausende Zahlen je Lauf.
|
||||
function buildMcResult(
|
||||
outcomes: Outcome[],
|
||||
fanRes: ScenarioMcResult[],
|
||||
target: number,
|
||||
basis: { metric: string; source: string }
|
||||
): { metric?: string; source?: string; scenarioName?: string | null; summary?: string | null; inputs: Record<string, unknown>; result: Record<string, unknown> } {
|
||||
const single = outcomes.length === 1;
|
||||
const table = {
|
||||
columns: ["Szenario", "Planung hist. (Plan)", "Ziel hist. (Plan)", "Ruin", "Median"],
|
||||
rows: outcomes.map((o) => [
|
||||
o.name,
|
||||
`${Math.round(o.f1 * 100)} % (${Math.round(o.f2 * 100)} %)`,
|
||||
`${Math.round(o.f3 * 100)} % (${Math.round(o.f4 * 100)} %)`,
|
||||
`${Math.round(o.ruin * 100)} %`,
|
||||
formatChf(o.medianHist),
|
||||
]),
|
||||
};
|
||||
// Fächer nur beim Einzelszenario; bei mehreren die Median-Linien.
|
||||
const chart = {
|
||||
kind: "line" as const,
|
||||
xLabel: "Alter",
|
||||
yFormat: "chf" as const,
|
||||
series: fanRes.map((r, i) => ({
|
||||
label: r.name,
|
||||
color: PALETTE[i % PALETTE.length],
|
||||
points: r.bands.map((b) => ({ x: b.age, y: b.p50 })),
|
||||
})),
|
||||
};
|
||||
return {
|
||||
metric: basis.metric,
|
||||
source: basis.source,
|
||||
scenarioName: single ? outcomes[0].name : `${outcomes.length} Szenarien`,
|
||||
summary: single ? `Realismus ${Math.round(outcomes[0].f1 * 100)} %` : null,
|
||||
inputs: {
|
||||
params: [
|
||||
{ label: "Zielbetrag", value: formatChf(target) },
|
||||
{ label: "Läufe/Szenario", value: outcomes[0]?.runs.toLocaleString("de-CH") ?? "–" },
|
||||
{ label: "Grösse", value: basis.metric === "real" ? "real" : "nominal" },
|
||||
{ label: "Grundlage", value: basis.source === "ACTUAL" ? "effektiv" : "Plan" },
|
||||
],
|
||||
},
|
||||
result: {
|
||||
params: [
|
||||
{ label: "Zielbetrag", value: formatChf(target) },
|
||||
{ label: "Läufe/Szenario", value: outcomes[0]?.runs.toLocaleString("de-CH") ?? "–" },
|
||||
],
|
||||
metrics: single
|
||||
? [
|
||||
{ label: "Realismus der Planung", value: `${Math.round(outcomes[0].f1 * 100)} %` },
|
||||
{ label: "Ziel erreicht (historisch)", value: `${Math.round(outcomes[0].f3 * 100)} %` },
|
||||
{ label: "Median (historisch)", value: formatChf(outcomes[0].medianHist) },
|
||||
]
|
||||
: [],
|
||||
table,
|
||||
chart,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function ReadOnlySigma({ value }: { value: number }) {
|
||||
return (
|
||||
<div>
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
CreditCard,
|
||||
Download,
|
||||
HelpCircle,
|
||||
Home,
|
||||
Landmark,
|
||||
@@ -490,11 +491,18 @@ export function PlanView({
|
||||
>
|
||||
<Plus className="h-4 w-4" /> Lebensphase
|
||||
</button>
|
||||
{/* CSV-Export der Matrix. Liest immer den aktuellen Stand aus der Datenbank. */}
|
||||
<a
|
||||
href={`/api/scenarios/${plan.id}/export`}
|
||||
className="ml-auto flex items-center gap-1.5 rounded-lg border border-border px-2.5 py-1.5 text-xs font-medium text-muted transition-colors hover:bg-surface-2 hover:text-fg"
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" /> CSV-Export
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowTour(true)}
|
||||
title="Kurze Tour durch die Ansicht"
|
||||
className="ml-auto flex items-center gap-1.5 rounded-lg border border-border px-2.5 py-1.5 text-xs font-medium text-muted transition-colors hover:bg-surface-2 hover:text-fg"
|
||||
className="flex items-center gap-1.5 rounded-lg border border-border px-2.5 py-1.5 text-xs font-medium text-muted transition-colors hover:bg-surface-2 hover:text-fg"
|
||||
>
|
||||
<HelpCircle className="h-3.5 w-3.5" /> Tour
|
||||
</button>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Bookmark, Check } from "lucide-react";
|
||||
import { useToast } from "@/components/ui";
|
||||
import { defaultAnalysisName, saveAnalysis, type AnalysisType, type SaveAnalysisPayload } from "@/lib/analyses";
|
||||
|
||||
// Einheitlicher Speichern-Knopf für die Analysewerkzeuge. `build` wird erst beim Klick
|
||||
// aufgerufen -- so wird das (evtl. grosse) Ergebnis nicht bei jedem Render zusammengebaut,
|
||||
// sondern nur, wenn wirklich gespeichert wird.
|
||||
export function SaveAnalysisButton({
|
||||
planId,
|
||||
type,
|
||||
scenarioName,
|
||||
versionLabel,
|
||||
disabled,
|
||||
build,
|
||||
}: {
|
||||
planId: string;
|
||||
type: AnalysisType;
|
||||
scenarioName: string | null;
|
||||
versionLabel: string | null;
|
||||
disabled?: boolean;
|
||||
build: () => Omit<SaveAnalysisPayload, "name" | "type"> | null;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [name, setName] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [done, setDone] = useState(false);
|
||||
const toast = useToast();
|
||||
|
||||
function start() {
|
||||
setName(defaultAnalysisName(type, scenarioName, versionLabel));
|
||||
setOpen(true);
|
||||
}
|
||||
|
||||
async function save() {
|
||||
const payload = build();
|
||||
if (!payload) {
|
||||
toast("error", "Es gibt noch kein Ergebnis zum Speichern.");
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
await saveAnalysis(planId, { ...payload, name: name.trim() || defaultAnalysisName(type, scenarioName, versionLabel), type });
|
||||
setOpen(false);
|
||||
setDone(true);
|
||||
toast("success", "Analyse gespeichert.");
|
||||
setTimeout(() => setDone(false), 2000);
|
||||
} catch (e) {
|
||||
toast("error", e instanceof Error ? e.message : "Speichern fehlgeschlagen.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (open) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2 rounded-lg border border-accent bg-accent-soft/20 p-2">
|
||||
<input
|
||||
autoFocus
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && save()}
|
||||
className="min-w-0 flex-1 rounded border border-border bg-surface px-2 py-1 text-sm text-fg"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={save}
|
||||
className="rounded-lg bg-accent px-3 py-1 text-xs font-medium text-accent-fg hover:bg-accent-hover disabled:opacity-50"
|
||||
>
|
||||
{busy ? "Speichern…" : "Speichern"}
|
||||
</button>
|
||||
<button type="button" onClick={() => setOpen(false)} className="rounded-lg border border-border px-2 py-1 text-xs text-muted hover:bg-surface-2">
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={start}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-border px-3 py-1.5 text-xs font-medium text-muted transition-colors hover:bg-surface-2 hover:text-fg disabled:opacity-40"
|
||||
>
|
||||
{done ? <Check className="h-3.5 w-3.5 text-success" /> : <Bookmark className="h-3.5 w-3.5" />}
|
||||
{done ? "Gespeichert" : "Analyse speichern"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Bar, BarChart, CartesianGrid, Legend, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
|
||||
import { Eye, X } from "lucide-react";
|
||||
import { api } from "@/lib/api-client";
|
||||
import { formatChf } from "@/lib/format";
|
||||
import { ANALYSIS_TYPE_LABEL, sourceLabel, type AnalysisType, type SavedResult } from "@/lib/analyses";
|
||||
|
||||
interface Full {
|
||||
id: string;
|
||||
name: string;
|
||||
type: AnalysisType;
|
||||
scenarioName: string | null;
|
||||
versionLabel: string | null;
|
||||
metric: string;
|
||||
source: string;
|
||||
createdAt: string;
|
||||
result: SavedResult;
|
||||
}
|
||||
|
||||
// Nur-Lese-Ansicht einer gespeicherten Analyse. Es wird NICHTS neu gerechnet -- die beim
|
||||
// Speichern festgehaltenen Zahlen werden nur gezeichnet.
|
||||
export function SavedAnalysisView({ planId, analysisId, onClose }: { planId: string; analysisId: string; onClose: () => void }) {
|
||||
const [data, setData] = useState<Full | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const d = await api.get<{ analysis: Full }>(`/api/plans/${planId}/analyses/${analysisId}`);
|
||||
if (!cancelled) setData(d.analysis);
|
||||
} catch (e) {
|
||||
if (!cancelled) setError(e instanceof Error ? e.message : "Konnte nicht geladen werden.");
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [planId, analysisId]);
|
||||
|
||||
const r = data?.result;
|
||||
|
||||
return (
|
||||
<div className="ui-fade fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/40 px-4 py-8" onClick={onClose}>
|
||||
<div onClick={(e) => e.stopPropagation()} className="ui-pop flex w-full max-w-4xl flex-col gap-4 rounded-2xl border border-border bg-surface p-6 shadow-xl">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h2 className="flex items-center gap-2 text-base font-semibold text-fg">
|
||||
<Eye className="h-5 w-5 text-accent" />
|
||||
<span className="truncate">{data?.name ?? "Analyse"}</span>
|
||||
</h2>
|
||||
{data && (
|
||||
<p className="mt-0.5 text-xs text-muted">
|
||||
{ANALYSIS_TYPE_LABEL[data.type]} · gespeichert{" "}
|
||||
{new Date(data.createdAt).toLocaleDateString("de-CH", { day: "2-digit", month: "2-digit", year: "numeric" })}
|
||||
{data.scenarioName ? ` · ${data.scenarioName}${data.versionLabel ? ` ${data.versionLabel}` : ""}` : ""} ·{" "}
|
||||
{data.metric === "real" ? "real" : "nominal"} · {sourceLabel(data.source)}
|
||||
<span className="ml-1 rounded bg-surface-2 px-1.5 py-0.5 text-[10px] text-faint">nur Ansicht</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<button type="button" onClick={onClose} aria-label="Schliessen" className="rounded-md p-1 text-faint hover:bg-surface-2">
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-danger">{error}</p>}
|
||||
{!data && !error && <p className="text-sm text-muted">Wird geladen…</p>}
|
||||
|
||||
{r && (
|
||||
<>
|
||||
{r.metrics && r.metrics.length > 0 && (
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||
{r.metrics.map((m, i) => (
|
||||
<div key={i} className="rounded-xl border border-border bg-surface-2 p-3">
|
||||
<div className="text-[10px] font-semibold uppercase tracking-wide text-faint">{m.label}</div>
|
||||
<div className={`mt-0.5 text-lg font-semibold ${m.tone === "danger" ? "text-danger" : m.tone === "success" ? "text-success" : "text-fg"}`}>
|
||||
{m.value}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{r.chart && <SavedChart chart={r.chart} />}
|
||||
|
||||
{r.table && (
|
||||
<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">
|
||||
{r.table.columns.map((c, i) => (
|
||||
<th key={i} className={`px-3 py-2 font-semibold ${i === 0 ? "text-left" : "text-right"}`}>{c}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{r.table.rows.map((row, ri) => (
|
||||
<tr key={ri} className="border-t border-border">
|
||||
{row.map((cell, ci) => (
|
||||
<td key={ci} className={`px-3 py-2 ${ci === 0 ? "text-left font-medium text-fg" : "text-right tabular-nums text-muted"}`}>
|
||||
{cell}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="rounded-xl border border-border bg-surface-2 p-3">
|
||||
<div className="mb-1 text-[10px] font-semibold uppercase tracking-wide text-faint">Parameter</div>
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted">
|
||||
{r.params.map((p, i) => (
|
||||
<span key={i}>
|
||||
{p.label}: <strong className="text-fg">{p.value}</strong>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SavedChart({ chart }: { chart: NonNullable<SavedResult["chart"]> }) {
|
||||
const fmt = (v: number) => (chart.yFormat === "pct" ? `${v} %` : Intl.NumberFormat("de-CH", { notation: "compact" }).format(v));
|
||||
const tip = (v: unknown) => (typeof v !== "number" ? String(v ?? "") : chart.yFormat === "pct" ? `${v} %` : formatChf(v));
|
||||
|
||||
// Punkte aller Serien auf gemeinsame x-Werte bringen.
|
||||
const xs = Array.from(new Set(chart.series.flatMap((s) => s.points.map((p) => p.x)))).sort((a, b) => a - b);
|
||||
const data = xs.map((x) => {
|
||||
const row: Record<string, number | string> = { x };
|
||||
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"];
|
||||
|
||||
return (
|
||||
<div className="h-72 w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
{chart.kind === "bar" ? (
|
||||
<BarChart data={data} margin={{ top: 8, right: 16, left: 8, bottom: 8 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
|
||||
<XAxis dataKey="x" tick={{ fontSize: 11 }} />
|
||||
<YAxis tick={{ fontSize: 11 }} tickFormatter={fmt} />
|
||||
<Tooltip formatter={tip} />
|
||||
<Legend wrapperStyle={{ fontSize: 12 }} />
|
||||
{chart.series.map((s, i) => (
|
||||
<Bar key={s.label} dataKey={s.label} fill={s.color ?? PALETTE[i % PALETTE.length]} isAnimationActive={false} />
|
||||
))}
|
||||
</BarChart>
|
||||
) : (
|
||||
<LineChart data={data} margin={{ top: 8, right: 16, left: 8, bottom: 8 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
|
||||
<XAxis dataKey="x" type="number" domain={["dataMin", "dataMax"]} tick={{ fontSize: 11 }} tickFormatter={(v) => `${v}`} />
|
||||
<YAxis tick={{ fontSize: 11 }} tickFormatter={fmt} />
|
||||
<Tooltip formatter={tip} labelFormatter={(v) => (chart.xLabel ? `${chart.xLabel} ${v}` : `${v}`)} />
|
||||
<Legend wrapperStyle={{ fontSize: 12 }} />
|
||||
{chart.series.map((s, i) => (
|
||||
<Line
|
||||
key={s.label}
|
||||
dataKey={s.label}
|
||||
stroke={s.color ?? PALETTE[i % PALETTE.length]}
|
||||
strokeWidth={2}
|
||||
strokeDasharray={s.dashed ? "5 3" : undefined}
|
||||
dot={false}
|
||||
connectNulls
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
))}
|
||||
</LineChart>
|
||||
)}
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { RequiredNumberField } from "@/components/FormField";
|
||||
import { InfoBubble } from "@/components/InfoBubble";
|
||||
import { useVersionedPlan, VersionBar } from "@/components/VersionPicker";
|
||||
import { AnalysisBar, useAnalysisBasis } from "@/components/AnalysisControls";
|
||||
import { SaveAnalysisButton } from "@/components/SaveAnalysisButton";
|
||||
import type { ActualsSetInput, ElementOrigin } from "@/lib/actuals";
|
||||
import { formatChf } from "@/lib/format";
|
||||
import {
|
||||
@@ -39,11 +40,13 @@ function formatRange(low: number, high: number, unit: DriverDef["unit"]): string
|
||||
|
||||
export function SensitivityDialog({
|
||||
plan: currentPlan,
|
||||
planId,
|
||||
actuals = [],
|
||||
origins = [],
|
||||
onClose,
|
||||
}: {
|
||||
plan: PlanInput;
|
||||
planId: string;
|
||||
actuals?: ActualsSetInput[];
|
||||
origins?: ElementOrigin[];
|
||||
onClose: () => void;
|
||||
@@ -244,12 +247,64 @@ export function SensitivityDialog({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{result && <TornadoResults result={result} metric={metric} chartData={chartData} />}
|
||||
{result && (
|
||||
<>
|
||||
<div className="flex justify-end">
|
||||
<SaveAnalysisButton
|
||||
planId={planId}
|
||||
type="SENSITIVITY"
|
||||
scenarioName={currentPlan.name}
|
||||
versionLabel={versionId === "current" ? null : versionId}
|
||||
build={() => buildSensitivityResult(result, metric, currentPlan.name, basis)}
|
||||
/>
|
||||
</div>
|
||||
<TornadoResults result={result} metric={metric} chartData={chartData} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Generische Momentaufnahme des Tornados (siehe analyses.ts).
|
||||
function buildSensitivityResult(
|
||||
result: TornadoResult,
|
||||
metric: TornadoMetric,
|
||||
scenarioName: string,
|
||||
basis: { source: string }
|
||||
): { metric?: string; source?: string; scenarioName?: string | null; summary?: string | null; inputs: Record<string, unknown>; result: Record<string, unknown> } {
|
||||
return {
|
||||
metric,
|
||||
source: basis.source,
|
||||
scenarioName,
|
||||
summary: result.bars[0] ? `Stärkster Hebel: ${result.bars[0].shortLabel}` : null,
|
||||
inputs: {
|
||||
params: [
|
||||
{ label: "Zielgrösse", value: metric === "real" ? "Endvermögen real" : "Endvermögen nominal" },
|
||||
{ label: "Basiswert", value: formatChf(result.base) },
|
||||
],
|
||||
},
|
||||
result: {
|
||||
params: [{ label: "Basiswert (unverändert)", value: formatChf(result.base) }],
|
||||
table: {
|
||||
columns: ["Treiber", "Bandbreite", "tief", "hoch", "Spannweite"],
|
||||
rows: result.bars.map((b) => [
|
||||
b.shortLabel,
|
||||
`${b.low}…${b.high} ${UNIT_SUFFIX[b.unit]}`,
|
||||
formatChf(b.lowResult),
|
||||
formatChf(b.highResult),
|
||||
formatChf(b.swing),
|
||||
]),
|
||||
},
|
||||
chart: {
|
||||
kind: "bar" as const,
|
||||
yFormat: "chf" as const,
|
||||
series: [{ label: "Spannweite", points: result.bars.map((b, i) => ({ x: i, y: b.swing })) }],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function TornadoResults({
|
||||
result,
|
||||
metric,
|
||||
|
||||
Reference in New Issue
Block a user