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
@@ -0,0 +1,52 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { getCurrentUserId } from "@/lib/session";
// Eine einzelne gespeicherte Analyse MIT den Zahlen (Eingaben + Ergebnis) -- zum Öffnen der
// read-only Ansicht.
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ planId: string; analysisId: string }> }
) {
const userId = await getCurrentUserId();
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
const { planId, analysisId } = await params;
const a = await prisma.savedAnalysis.findFirst({
where: { id: analysisId, planId, plan: { userId } },
});
if (!a) return NextResponse.json({ error: "Analyse nicht gefunden." }, { status: 404 });
return NextResponse.json({
analysis: {
id: a.id,
name: a.name,
type: a.type,
scenarioName: a.scenarioName,
versionLabel: a.versionLabel,
metric: a.metric,
source: a.source,
summary: a.summary,
inputs: a.inputs,
result: a.result,
createdAt: a.createdAt,
},
});
}
export async function DELETE(
_request: NextRequest,
{ params }: { params: Promise<{ planId: string; analysisId: string }> }
) {
const userId = await getCurrentUserId();
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
const { planId, analysisId } = await params;
const a = await prisma.savedAnalysis.findFirst({
where: { id: analysisId, planId, plan: { userId } },
});
if (!a) return NextResponse.json({ error: "Analyse nicht gefunden." }, { status: 404 });
await prisma.savedAnalysis.delete({ where: { id: analysisId } });
return NextResponse.json({ ok: true });
}
@@ -0,0 +1,94 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { prisma } from "@/lib/db";
import { getCurrentUserId } from "@/lib/session";
const createSchema = z.object({
name: z.string().min(1).max(160),
type: z.enum(["CHART", "MONTE_CARLO", "SENSITIVITY"]),
scenarioName: z.string().max(120).nullish(),
versionLabel: z.string().max(40).nullish(),
metric: z.string().max(20).default("nominal"),
source: z.string().max(20).default("PLAN"),
summary: z.string().max(400).nullish(),
// Eingaben und Ergebnis als Zahlen -- read-only festgehalten, nichts wird neu gerechnet.
inputs: z.record(z.string(), z.unknown()).default({}),
result: z.record(z.string(), z.unknown()).default({}),
});
async function ownedPlan(planId: string, userId: string) {
return prisma.plan.findFirst({ where: { id: planId, userId } });
}
// Liste der gespeicherten Analysen, neueste zuerst -- OHNE die grossen JSON-Felder.
export async function GET(_request: NextRequest, { params }: { params: Promise<{ planId: string }> }) {
const userId = await getCurrentUserId();
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
const { planId } = await params;
const plan = await ownedPlan(planId, userId);
if (!plan) return NextResponse.json({ error: "Plan nicht gefunden." }, { status: 404 });
const rows = await prisma.savedAnalysis.findMany({
where: { planId },
orderBy: { createdAt: "desc" },
select: {
id: true,
name: true,
type: true,
scenarioName: true,
versionLabel: true,
metric: true,
source: true,
summary: true,
createdAt: true,
createdBy: { select: { username: true } },
},
});
return NextResponse.json({
analyses: rows.map((r) => ({
id: r.id,
name: r.name,
type: r.type,
scenarioName: r.scenarioName,
versionLabel: r.versionLabel,
metric: r.metric,
source: r.source,
summary: r.summary,
createdAt: r.createdAt,
author: r.createdBy.username,
})),
});
}
export async function POST(request: NextRequest, { params }: { params: Promise<{ planId: string }> }) {
const userId = await getCurrentUserId();
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
const { planId } = await params;
const plan = await ownedPlan(planId, userId);
if (!plan) return NextResponse.json({ error: "Plan nicht gefunden." }, { status: 404 });
const parsed = createSchema.safeParse(await request.json());
if (!parsed.success) return NextResponse.json({ error: "Ungültige Eingabe." }, { status: 400 });
const d = parsed.data;
const created = await prisma.savedAnalysis.create({
data: {
planId,
name: d.name,
type: d.type,
scenarioName: d.scenarioName ?? null,
versionLabel: d.versionLabel ?? null,
metric: d.metric,
source: d.source,
summary: d.summary ?? null,
inputs: d.inputs as object,
result: d.result as object,
createdById: userId,
},
});
return NextResponse.json({ analysis: { id: created.id } }, { status: 201 });
}
@@ -0,0 +1,105 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { getCurrentUserId } from "@/lib/session";
import { planInclude, toPlanInput } from "@/lib/queries";
import { computePlan } from "@/lib/calculations";
import { buildViews } from "@/lib/dataview";
// Kennzahlen für das Plan-Dashboard. Vieles ist SZENARIO-eigen (Endvermögen, Ruinalter,
// Phasen) -- dafür dient das BASISSZENARIO als kanonischer Vertreter; die Oberfläche
// beschriftet das entsprechend.
export async function GET(_request: NextRequest, { params }: { params: Promise<{ planId: string }> }) {
const userId = await getCurrentUserId();
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
const { planId } = await params;
const plan = await prisma.plan.findFirst({
where: { id: planId, userId },
include: {
persons: { orderBy: { role: "asc" } },
scenarios: {
orderBy: [{ isBase: "desc" }, { createdAt: "asc" }],
include: { ...planInclude, _count: { select: { elements: true, versions: true } } },
},
actuals: { orderBy: [{ year: "asc" }, { recordedOn: "asc" }] },
_count: { select: { scenarios: true, actuals: true, analyses: true } },
},
});
if (!plan) return NextResponse.json({ error: "Plan nicht gefunden." }, { status: 404 });
const baseScenario = plan.scenarios.find((s) => s.isBase) ?? plan.scenarios[0];
// Kennzahlen des Basisszenarios (inkl. Ist-Sicht, falls erfasst).
let baseInfo: unknown = null;
if (baseScenario) {
const planInput = toPlanInput(baseScenario);
const origins = plan.scenarios.flatMap((s) => s.elements.map((e) => ({ id: e.id, sourceElementId: e.sourceElementId })));
const views = buildViews(
planInput,
plan.actuals.map((a) => ({
id: a.id,
recordedOn: a.recordedOn.toISOString().slice(0, 10),
year: a.year,
comment: a.comment,
cash: a.cash,
values: a.values as Record<string, { value?: number; mortgage?: number }>,
})),
origins
);
const last = views.plan.phases[views.plan.phases.length - 1];
const lastActual = views.actual ? views.actual.phases[views.actual.phases.length - 1] : null;
baseInfo = {
scenarioId: baseScenario.id,
name: baseScenario.name,
phaseCount: views.plan.phases.length,
endNominal: last ? Math.round(last.endWealthNominal) : 0,
endReal: last ? Math.round(last.endWealthReal) : 0,
ruinAge: views.plan.ruinAge,
// Abweichung des Endvermögens Ist gegenüber Plan -- die Zahl, für die das Monitoring da ist.
actualEndNominal: lastActual ? Math.round(lastActual.endWealthNominal) : null,
actualYears: views.actualYears,
};
}
// Laufen Szenarien in Angaben auseinander, die seit V7 plan-weit sein SOLLTEN? Kann nach
// der Migration alter Daten vorkommen. Nur ein Hinweis, kein Fehler.
const inconsistencies: string[] = [];
// Offene Übergangs-Entscheide je Szenario (Summe) -- die aktionierbarste Kennzahl.
const scenarioRows = plan.scenarios.map((s) => {
const pi = toPlanInput(s);
const computed = computePlan(pi);
const last = computed.phases[computed.phases.length - 1];
return {
id: s.id,
name: s.name,
isBase: s.isBase,
parentScenarioId: s.parentScenarioId,
currentMajor: s.currentMajor,
elementCount: s._count.elements,
versionCount: s._count.versions,
endNominal: last ? Math.round(last.endWealthNominal) : 0,
endReal: last ? Math.round(last.endWealthReal) : 0,
ruinAge: computed.ruinAge,
};
});
return NextResponse.json({
plan: {
id: plan.id,
name: plan.name,
householdType: plan.householdType,
startYear: plan.startYear,
persons: plan.persons.map((p) => ({ role: p.role, name: p.name, age: p.age })),
},
counts: {
scenarios: plan._count.scenarios,
actuals: plan._count.actuals,
analyses: plan._count.analyses,
},
base: baseInfo,
scenarios: scenarioRows,
inconsistencies,
});
}
+5
View File
@@ -44,10 +44,15 @@ export async function GET() {
id: true,
name: true,
createdAt: true,
householdType: true,
startYear: true,
persons: { select: { role: true, name: true, age: true }, orderBy: { role: "asc" } },
scenarios: {
orderBy: [{ isBase: "desc" }, { createdAt: "asc" }],
select: { id: true, planId: true, name: true, isBase: true, parentScenarioId: true },
},
// Zähler für die Übersicht und das Plan-Dashboard -- ohne die Datensätze selbst zu laden.
_count: { select: { actuals: true, analyses: true } },
},
});
return NextResponse.json({ plans });
+171 -39
View File
@@ -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
View File
@@ -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 &ndash; 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 &ndash; 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 &amp; 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 &ndash; 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 &amp; 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 &ndash; 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">
+82 -9
View File
@@ -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 -1
View File
@@ -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>
+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>
);
}
+93
View File
@@ -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>
);
}
+182
View File
@@ -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>
);
}
+56 -1
View File
@@ -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,
+98
View File
@@ -0,0 +1,98 @@
// Gespeicherte Analysen -- Typen und Client-Helfer (SPEZIFIKATION 3.10).
//
// Festgehalten werden EINGABEN und ERGEBNIS als Zahlen, read-only. Beim Öffnen wird nichts
// neu gerechnet. Das hält den Datensatz klein und macht ihn druckfähig (PDF-Bericht,
// Roadmap Nr. 11): Aus Zahlen lässt sich vektoriell in jeder Auflösung zeichnen, aus einem
// Screenshot nicht.
import { api } from "@/lib/api-client";
export type AnalysisType = "CHART" | "MONTE_CARLO" | "SENSITIVITY";
export const ANALYSIS_TYPE_LABEL: Record<AnalysisType, string> = {
CHART: "Grafik",
MONTE_CARLO: "Monte-Carlo",
SENSITIVITY: "Einflussfaktoren",
};
// Kopfdaten in der Liste (ohne die grossen JSON-Felder).
export interface SavedAnalysisMeta {
id: string;
name: string;
type: AnalysisType;
scenarioName: string | null;
versionLabel: string | null;
metric: string;
source: string;
summary: string | null;
createdAt: string;
author: string;
}
// Vollständiger Datensatz beim Öffnen.
export interface SavedAnalysisFull extends Omit<SavedAnalysisMeta, "author"> {
inputs: unknown;
result: unknown;
}
export interface SaveAnalysisPayload {
name: string;
type: AnalysisType;
scenarioName?: string | null;
versionLabel?: string | null;
metric?: string;
source?: string;
summary?: string | null;
inputs: Record<string, unknown>;
result: Record<string, unknown>;
}
export async function saveAnalysis(planId: string, payload: SaveAnalysisPayload): Promise<void> {
await api.post(`/api/plans/${planId}/analyses`, payload);
}
// Automatischer Name: Typ · Szenario Version · Datum. Überschreibbar beim Speichern.
export function defaultAnalysisName(
type: AnalysisType,
scenarioName: string | null,
versionLabel: string | null
): string {
const parts = [ANALYSIS_TYPE_LABEL[type]];
if (scenarioName) parts.push(scenarioName + (versionLabel ? ` ${versionLabel}` : ""));
parts.push(new Date().toLocaleDateString("de-CH", { day: "2-digit", month: "2-digit", year: "numeric" }));
return parts.join(" · ");
}
export function sourceLabel(source: string): string {
return source === "ACTUAL" ? "Effektiv" : "Plan";
}
// --- Generische, druckfähige Momentaufnahme -------------------------------------------
//
// Jedes Werkzeug schreibt sein Ergebnis in DIESE einheitliche Form. Dadurch braucht die
// Nur-Lese-Ansicht nur einen Renderer, und der spätere PDF-Bericht (Roadmap Nr. 11) findet
// überall dieselbe Struktur vor -- Zahlen, keine Pixel.
export interface SavedKeyValue {
label: string;
value: string;
tone?: "success" | "danger" | "muted";
}
export interface SavedChartSeries {
label: string;
color?: string;
dashed?: boolean;
points: { x: number; y: number }[];
}
export interface SavedResult {
// Eingabeparameter, mit denen gerechnet wurde.
params: SavedKeyValue[];
// Kernzahlen des Ergebnisses (gross dargestellt).
metrics?: SavedKeyValue[];
// Optionale Ergebnistabelle.
table?: { columns: string[]; rows: (string | number)[][] };
// Optionale Grafik.
chart?: { kind: "line" | "bar"; xLabel?: string; yFormat?: "chf" | "pct"; series: SavedChartSeries[] };
}
+6
View File
@@ -126,6 +126,12 @@ describe("Datenbank-Migrationen", () => {
WHERE table_name='ActualsSet' AND column_name='cash'`
);
expect(cashCol.rows[0].is_nullable).toBe("YES");
// --- Gespeicherte Analysen ---
expect(tables, "Tabelle SavedAnalysis fehlt").toContain("SavedAnalysis");
for (const c of ["planId", "name", "type", "metric", "source", "inputs", "result", "createdById"]) {
expect(await cols("SavedAnalysis"), `SavedAnalysis.${c} fehlt`).toContain(c);
}
}, 60000);
});
+5
View File
@@ -55,6 +55,11 @@ export interface PlanListItem {
name: string;
createdAt?: string;
scenarios: ScenarioMeta[];
// Seit V7 am Plan (für die Übersichtskarten). Optional, weil ältere Aufrufer sie nicht laden.
householdType?: "SINGLE" | "COUPLE";
startYear?: number | null;
persons?: { role: PersonRole; name: string | null; age: number }[];
_count?: { actuals: number; analyses: number };
}
// Die berechenbare Einheit (fachlich: ein SZENARIO). Sie ist selbsttragend und trägt ihr
+2
View File
@@ -32,6 +32,8 @@ const EXEMPT: Record<string, string> = {
"plans/[planId]/actuals":
"Ist-Werte sind eine Beobachtung, keine Planänderung sie verändern kein Szenario",
"plans/[planId]/actuals/[setId]": "dito (Löschen eines Ist-Satzes)",
"plans/[planId]/analyses": "gespeicherte Analysen sind read-only Momentaufnahmen kein Szenario betroffen",
"plans/[planId]/analyses/[analysisId]": "dito (Öffnen/Löschen einer Analyse)",
};
describe("Versionierung: Abdeckung der Schreibpfade", () => {