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:
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user