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 });