Szenario-Hierarchie: Plan als Behaelter, Diff-Markierung (V6)
Deploy App / deploy (push) Successful in 1m43s
Deploy App / deploy (push) Successful in 1m43s
Groesste Umstrukturierung bisher. Der PLAN ist neu ein schlanker Behaelter ohne Finanzdaten; die berechenbare Einheit ist das SZENARIO. Modell: - Jeder Plan bekommt beim Anlegen automatisch ein Basisszenario (isBase). - Das Grundprofil liegt am Szenario, nicht am Plan -- nur so sind Szenarien mit abweichendem PENSIONSALTER moeglich (Fruehpensionierung), das in Person steckt. - Neue Szenarien sind vollstaendige Kopien eines BELIEBIGEN Szenarios und haengen als Baum darunter (parentScenarioId); die Seitenleiste rueckt sie ein. - Kopierte Phasen/Elemente tragen Herkunfts-Verweise (sourcePhaseId, sourceElementId). Ueber den Namen zu matchen waere fragil gewesen. Abweichungs-Markierung (Diff gegen das Eltern-Szenario, live): - geaendert = gelb, neu = gruen + Badge, entfernt = graue Geisterzeile. - Markiert: Phasen-/Uebergangszellen, Element-Zeilen, Phasenkoepfe, Cash-Anfangswert, Cash-Uebergaenge, Grundprofil. Zaehler ueber der Matrix. - Eigene Theme-Tokens fuer Hell/Dunkel/Warm -- ein fester Gelbwert waere im Dunkelschema unbrauchbar. Charts vergleichen neu die Geschwister-Szenarien statt fremder Plaene. Datenmodell/Migration: - Neue Tabelle Plan; bisheriger Plan -> Scenario (IDs erhalten, damit alle Kind-Fremdschluessel gueltig bleiben); planId -> scenarioId in Person/Phase/ FinancialElement. Bestehende Szenarien werden per rekursivem CTE demselben Behaelter zugeordnet, auch mehrfach verschachtelte. - Migration VOR dem Deploy gegen echtes PostgreSQL verifiziert (PGlite, in-process), inkl. verschachtelter Szenarien und Cascade. Der Test ist als migrations.test.ts committet und sichert kuenftige Migrationen ab. API neu unter /api/scenarios/*. 10 neue Tests (48 -> 58). Spezifikation auf v0.8. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -16,7 +16,7 @@ export async function PUT(
|
||||
const element = await getOwnedElement(elementId, userId);
|
||||
if (!element) return NextResponse.json({ error: "Element nicht gefunden." }, { status: 404 });
|
||||
|
||||
const phase = await prisma.phase.findFirst({ where: { id: phaseId, planId: element.planId } });
|
||||
const phase = await prisma.phase.findFirst({ where: { id: phaseId, scenarioId: element.scenarioId } });
|
||||
if (!phase) return NextResponse.json({ error: "Phase nicht gefunden." }, { status: 404 });
|
||||
|
||||
const body = await request.json();
|
||||
|
||||
@@ -16,7 +16,7 @@ export async function PUT(
|
||||
const element = await getOwnedElement(elementId, userId);
|
||||
if (!element) return NextResponse.json({ error: "Element nicht gefunden." }, { status: 404 });
|
||||
|
||||
const phase = await prisma.phase.findFirst({ where: { id: fromPhaseId, planId: element.planId } });
|
||||
const phase = await prisma.phase.findFirst({ where: { id: fromPhaseId, scenarioId: element.scenarioId } });
|
||||
if (!phase) return NextResponse.json({ error: "Phase nicht gefunden." }, { status: 404 });
|
||||
|
||||
const body = await request.json();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getOwnedPhase, getOwnedPlan, toPlanInput } from "@/lib/queries";
|
||||
import { getOwnedPhase, getOwnedScenario, toPlanInput } from "@/lib/queries";
|
||||
import { getCurrentUserId } from "@/lib/session";
|
||||
import { maxPhaseDuration } from "@/lib/calculations";
|
||||
|
||||
@@ -28,9 +28,9 @@ export async function PUT(
|
||||
let duration = parsed.data.durationYears;
|
||||
if (duration != null) {
|
||||
// Dauer ans naechste Pensionsereignis kappen (Jahre vor dieser Phase).
|
||||
const plan = await getOwnedPlan(existing.planId, userId);
|
||||
if (plan) {
|
||||
const planInput = toPlanInput(plan);
|
||||
const scenario = await getOwnedScenario(existing.scenarioId, userId);
|
||||
if (scenario) {
|
||||
const planInput = toPlanInput(scenario);
|
||||
const yearsBefore = planInput.phases
|
||||
.filter((p) => p.sequenceNumber < existing.sequenceNumber)
|
||||
.reduce((s, p) => s + p.durationYears, 0);
|
||||
@@ -63,7 +63,7 @@ export async function DELETE(
|
||||
if (!phase) return NextResponse.json({ error: "Phase nicht gefunden." }, { status: 404 });
|
||||
|
||||
const later = await prisma.phase.findFirst({
|
||||
where: { planId: phase.planId, sequenceNumber: { gt: phase.sequenceNumber } },
|
||||
where: { scenarioId: phase.scenarioId, sequenceNumber: { gt: phase.sequenceNumber } },
|
||||
});
|
||||
if (later) {
|
||||
return NextResponse.json({ error: "Nur die letzte Phase kann geloescht werden." }, { status: 400 });
|
||||
|
||||
@@ -1,41 +1,12 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { toPlanInput, getOwnedPlan } from "@/lib/queries";
|
||||
import { getCurrentUserId } from "@/lib/session";
|
||||
import { computePlan } from "@/lib/calculations";
|
||||
import { planProfileSchema, validatePersonsForType } from "@/app/api/plans/route";
|
||||
|
||||
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;
|
||||
// Der Plan ist nur noch der Behaelter: er traegt den Namen; die Finanzdaten liegen im Szenario.
|
||||
const patchSchema = z.object({ name: z.string().min(1).max(120) });
|
||||
|
||||
const plan = await getOwnedPlan(planId, userId);
|
||||
if (!plan) return NextResponse.json({ error: "Plan nicht gefunden." }, { status: 404 });
|
||||
|
||||
const planInput = toPlanInput(plan);
|
||||
const computed = computePlan(planInput);
|
||||
|
||||
return NextResponse.json({ plan: planInput, computed });
|
||||
}
|
||||
|
||||
// Name/Cash-Anfangswert aendern ODER das ganze Plan-Profil (Haushaltsform/Personen/Inflation).
|
||||
const patchSchema = z.union([
|
||||
planProfileSchema.extend({ name: z.string().min(1).max(120).optional() }),
|
||||
z.object({
|
||||
name: z.string().min(1).max(120).optional(),
|
||||
initialCash: z.number().min(0).max(1_000_000_000).optional(),
|
||||
}),
|
||||
]);
|
||||
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ planId: string }> }
|
||||
) {
|
||||
export async function PATCH(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;
|
||||
@@ -43,50 +14,22 @@ export async function PATCH(
|
||||
const plan = await prisma.plan.findFirst({ where: { id: planId, userId } });
|
||||
if (!plan) return NextResponse.json({ error: "Plan nicht gefunden." }, { status: 404 });
|
||||
|
||||
const body = await request.json();
|
||||
const parsed = patchSchema.safeParse(body);
|
||||
const parsed = patchSchema.safeParse(await request.json());
|
||||
if (!parsed.success) return NextResponse.json({ error: "Ungueltige Eingabe." }, { status: 400 });
|
||||
|
||||
const data = parsed.data;
|
||||
const hasProfile = "householdType" in data;
|
||||
|
||||
if (hasProfile) {
|
||||
const error = validatePersonsForType(data);
|
||||
if (error) return NextResponse.json({ error }, { status: 400 });
|
||||
const updated = await prisma.$transaction(async (tx) => {
|
||||
await tx.person.deleteMany({ where: { planId: plan.id } });
|
||||
return tx.plan.update({
|
||||
where: { id: plan.id },
|
||||
data: {
|
||||
name: data.name ?? undefined,
|
||||
householdType: data.householdType,
|
||||
inflationRateDefault: data.inflationRateDefault,
|
||||
persons: { create: data.persons },
|
||||
},
|
||||
});
|
||||
});
|
||||
return NextResponse.json({ plan: { id: updated.id, name: updated.name } });
|
||||
}
|
||||
|
||||
const updated = await prisma.plan.update({
|
||||
where: { id: plan.id },
|
||||
data: {
|
||||
name: "name" in data ? data.name : undefined,
|
||||
initialCash: "initialCash" in data && data.initialCash != null ? Math.round(data.initialCash) : undefined,
|
||||
},
|
||||
});
|
||||
const updated = await prisma.plan.update({ where: { id: plan.id }, data: { name: parsed.data.name } });
|
||||
return NextResponse.json({ plan: { id: updated.id, name: updated.name } });
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ planId: string }> }
|
||||
) {
|
||||
// Loescht den Plan inkl. aller Szenarien (Cascade).
|
||||
export async function DELETE(_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 } });
|
||||
if (!plan) return NextResponse.json({ error: "Plan nicht gefunden." }, { status: 404 });
|
||||
|
||||
await prisma.plan.delete({ where: { id: plan.id } });
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getOwnedPlan } from "@/lib/queries";
|
||||
import { getCurrentUserId } from "@/lib/session";
|
||||
|
||||
const scenarioSchema = z.object({
|
||||
name: z.string().min(1).max(120),
|
||||
branchFromPhaseId: z.string().min(1),
|
||||
});
|
||||
|
||||
// Erstellt ein Szenario als Deep-Copy eines Plans bis zur Verzweigungsphase (inkl.).
|
||||
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 body = await request.json();
|
||||
const parsed = scenarioSchema.safeParse(body);
|
||||
if (!parsed.success) return NextResponse.json({ error: "Ungueltige Eingabe." }, { status: 400 });
|
||||
|
||||
const source = await getOwnedPlan(planId, userId);
|
||||
if (!source) return NextResponse.json({ error: "Ursprungsplan nicht gefunden." }, { status: 404 });
|
||||
|
||||
const branchPhase = source.phases.find((p) => p.id === parsed.data.branchFromPhaseId);
|
||||
if (!branchPhase) return NextResponse.json({ error: "Verzweigungsphase nicht gefunden." }, { status: 404 });
|
||||
|
||||
const copiedPhases = source.phases
|
||||
.filter((p) => p.sequenceNumber <= branchPhase.sequenceNumber)
|
||||
.sort((a, b) => a.sequenceNumber - b.sequenceNumber);
|
||||
const copiedPhaseIds = new Set(copiedPhases.map((p) => p.id));
|
||||
|
||||
const newPlanId = await prisma.$transaction(async (tx) => {
|
||||
const newPlan = await tx.plan.create({
|
||||
data: {
|
||||
userId,
|
||||
name: parsed.data.name,
|
||||
householdType: source.householdType,
|
||||
inflationRateDefault: source.inflationRateDefault,
|
||||
initialCash: source.initialCash,
|
||||
parentPlanId: source.id,
|
||||
persons: {
|
||||
create: source.persons.map((p) => ({ role: p.role, name: p.name, age: p.age, retirementAge: p.retirementAge })),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Phasen kopieren (alte -> neue Id).
|
||||
const phaseIdMap = new Map<string, string>();
|
||||
let lastNewPhaseId = "";
|
||||
for (const phase of copiedPhases) {
|
||||
const created = await tx.phase.create({
|
||||
data: {
|
||||
planId: newPlan.id,
|
||||
sequenceNumber: phase.sequenceNumber,
|
||||
name: phase.name,
|
||||
durationYears: phase.durationYears,
|
||||
// Cash-Entscheid (einmalige Sonderein-/ausgaben) mitkopieren.
|
||||
cashTransition: phase.cashTransition ?? undefined,
|
||||
},
|
||||
});
|
||||
phaseIdMap.set(phase.id, created.id);
|
||||
lastNewPhaseId = created.id;
|
||||
}
|
||||
|
||||
// Elemente + deren Phasen-/Uebergangswerte kopieren.
|
||||
for (const el of source.elements) {
|
||||
const newEl = await tx.financialElement.create({
|
||||
data: {
|
||||
planId: newPlan.id,
|
||||
category: el.category,
|
||||
name: el.name,
|
||||
ownerRole: el.ownerRole,
|
||||
orderIndex: el.orderIndex,
|
||||
},
|
||||
});
|
||||
for (const pv of el.phaseValues) {
|
||||
const newPhaseId = phaseIdMap.get(pv.phaseId);
|
||||
if (!newPhaseId) continue;
|
||||
await tx.elementPhaseValue.create({
|
||||
data: { elementId: newEl.id, phaseId: newPhaseId, data: pv.data as object },
|
||||
});
|
||||
}
|
||||
for (const tv of el.transitionValues) {
|
||||
if (!copiedPhaseIds.has(tv.fromPhaseId)) continue;
|
||||
const newFromId = phaseIdMap.get(tv.fromPhaseId);
|
||||
if (!newFromId) continue;
|
||||
await tx.elementTransitionValue.create({
|
||||
data: { elementId: newEl.id, fromPhaseId: newFromId, data: tv.data as object },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await tx.plan.update({ where: { id: newPlan.id }, data: { branchFromPhaseId: lastNewPhaseId } });
|
||||
return newPlan.id;
|
||||
});
|
||||
|
||||
return NextResponse.json({ planId: newPlanId }, { status: 201 });
|
||||
}
|
||||
+28
-24
@@ -10,18 +10,17 @@ const personSchema = z.object({
|
||||
retirementAge: z.number().int().min(30).max(100),
|
||||
});
|
||||
|
||||
// Ein Plan traegt sein eigenes Grundprofil (Haushaltsform, Personen, Inflation).
|
||||
export const planProfileSchema = z.object({
|
||||
// Das Grundprofil liegt am SZENARIO (nicht am Plan) -- dadurch kann ein Szenario z. B. ein
|
||||
// anderes Pensionsalter tragen als das Basisszenario (Frühpensionierungs-Szenario).
|
||||
export const scenarioProfileSchema = z.object({
|
||||
householdType: z.enum(["SINGLE", "COUPLE"]),
|
||||
inflationRateDefault: z.number().min(-20).max(50),
|
||||
persons: z.array(personSchema).min(1).max(2),
|
||||
});
|
||||
|
||||
const createPlanSchema = z
|
||||
.object({ name: z.string().min(1).max(120) })
|
||||
.and(planProfileSchema);
|
||||
const createPlanSchema = z.object({ name: z.string().min(1).max(120) }).and(scenarioProfileSchema);
|
||||
|
||||
export function validatePersonsForType(data: z.infer<typeof planProfileSchema>): string | null {
|
||||
export function validatePersonsForType(data: z.infer<typeof scenarioProfileSchema>): string | null {
|
||||
if (data.householdType === "SINGLE" && data.persons.length !== 1) {
|
||||
return "Einzelperson-Plan benoetigt genau eine Person.";
|
||||
}
|
||||
@@ -31,40 +30,35 @@ export function validatePersonsForType(data: z.infer<typeof planProfileSchema>):
|
||||
return null;
|
||||
}
|
||||
|
||||
// Liste der Plaene mit ihrem Szenario-Baum (nur Kopfdaten).
|
||||
export async function GET() {
|
||||
const userId = await getCurrentUserId();
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
|
||||
}
|
||||
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
|
||||
|
||||
const plans = await prisma.plan.findMany({
|
||||
where: { userId },
|
||||
orderBy: { createdAt: "asc" },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
parentPlanId: true,
|
||||
branchFromPhaseId: true,
|
||||
createdAt: true,
|
||||
phases: {
|
||||
select: { id: true, name: true, sequenceNumber: true },
|
||||
orderBy: { sequenceNumber: "asc" },
|
||||
scenarios: {
|
||||
orderBy: [{ isBase: "desc" }, { createdAt: "asc" }],
|
||||
select: { id: true, planId: true, name: true, isBase: true, parentScenarioId: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
return NextResponse.json({ plans });
|
||||
}
|
||||
|
||||
// Legt einen Plan an -- zusammen mit seinem Basisszenario, das das Grundprofil traegt.
|
||||
export async function POST(request: NextRequest) {
|
||||
const userId = await getCurrentUserId();
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
|
||||
}
|
||||
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
|
||||
|
||||
const body = await request.json();
|
||||
const parsed = createPlanSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
|
||||
}
|
||||
if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
|
||||
const error = validatePersonsForType(parsed.data);
|
||||
if (error) return NextResponse.json({ error }, { status: 400 });
|
||||
|
||||
@@ -72,11 +66,21 @@ export async function POST(request: NextRequest) {
|
||||
data: {
|
||||
userId,
|
||||
name: parsed.data.name,
|
||||
householdType: parsed.data.householdType,
|
||||
inflationRateDefault: parsed.data.inflationRateDefault,
|
||||
persons: { create: parsed.data.persons },
|
||||
scenarios: {
|
||||
create: {
|
||||
name: "Basisszenario",
|
||||
isBase: true,
|
||||
householdType: parsed.data.householdType,
|
||||
inflationRateDefault: parsed.data.inflationRateDefault,
|
||||
persons: { create: parsed.data.persons },
|
||||
},
|
||||
},
|
||||
},
|
||||
include: { scenarios: true },
|
||||
});
|
||||
|
||||
return NextResponse.json({ plan: { id: plan.id } }, { status: 201 });
|
||||
return NextResponse.json(
|
||||
{ plan: { id: plan.id }, scenario: { id: plan.scenarios[0].id } },
|
||||
{ status: 201 }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getOwnedScenario } from "@/lib/queries";
|
||||
import { getCurrentUserId } from "@/lib/session";
|
||||
|
||||
const copySchema = z.object({ name: z.string().min(1).max(120) });
|
||||
|
||||
// Erstellt ein neues Szenario als vollstaendige Kopie eines bestehenden. Das Original wird
|
||||
// zum Elternteil -- damit haengt der Baum in der Seitenleiste und der Diff hat seine Basis.
|
||||
// Jede kopierte Phase/jedes kopierte Element traegt einen Herkunfts-Verweis auf sein
|
||||
// Gegenstueck im Original; darauf beruht die Abweichungs-Markierung im UI.
|
||||
export async function POST(request: NextRequest, { params }: { params: Promise<{ scenarioId: string }> }) {
|
||||
const userId = await getCurrentUserId();
|
||||
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
|
||||
const { scenarioId } = await params;
|
||||
|
||||
const parsed = copySchema.safeParse(await request.json());
|
||||
if (!parsed.success) return NextResponse.json({ error: "Ungueltige Eingabe." }, { status: 400 });
|
||||
|
||||
const source = await getOwnedScenario(scenarioId, userId);
|
||||
if (!source) return NextResponse.json({ error: "Ursprungs-Szenario nicht gefunden." }, { status: 404 });
|
||||
|
||||
const newId = await prisma.$transaction(async (tx) => {
|
||||
const created = await tx.scenario.create({
|
||||
data: {
|
||||
planId: source.planId,
|
||||
name: parsed.data.name,
|
||||
isBase: false,
|
||||
parentScenarioId: source.id,
|
||||
householdType: source.householdType,
|
||||
inflationRateDefault: source.inflationRateDefault,
|
||||
initialCash: source.initialCash,
|
||||
persons: {
|
||||
create: source.persons.map((p) => ({
|
||||
role: p.role,
|
||||
name: p.name,
|
||||
age: p.age,
|
||||
retirementAge: p.retirementAge,
|
||||
})),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Phasen kopieren (alte -> neue Id merken, fuer die Werte-Zuordnung).
|
||||
const phaseIdMap = new Map<string, string>();
|
||||
for (const phase of source.phases) {
|
||||
const p = await tx.phase.create({
|
||||
data: {
|
||||
scenarioId: created.id,
|
||||
sequenceNumber: phase.sequenceNumber,
|
||||
name: phase.name,
|
||||
durationYears: phase.durationYears,
|
||||
cashTransition: phase.cashTransition ?? undefined,
|
||||
sourcePhaseId: phase.id,
|
||||
},
|
||||
});
|
||||
phaseIdMap.set(phase.id, p.id);
|
||||
}
|
||||
|
||||
// Elemente inkl. Phasen- und Uebergangswerten kopieren.
|
||||
for (const el of source.elements) {
|
||||
const newEl = await tx.financialElement.create({
|
||||
data: {
|
||||
scenarioId: created.id,
|
||||
category: el.category,
|
||||
name: el.name,
|
||||
ownerRole: el.ownerRole,
|
||||
orderIndex: el.orderIndex,
|
||||
sourceElementId: el.id,
|
||||
},
|
||||
});
|
||||
for (const pv of el.phaseValues) {
|
||||
const phaseId = phaseIdMap.get(pv.phaseId);
|
||||
if (!phaseId) continue;
|
||||
await tx.elementPhaseValue.create({
|
||||
data: { elementId: newEl.id, phaseId, data: pv.data as object },
|
||||
});
|
||||
}
|
||||
for (const tv of el.transitionValues) {
|
||||
const fromPhaseId = phaseIdMap.get(tv.fromPhaseId);
|
||||
if (!fromPhaseId) continue;
|
||||
await tx.elementTransitionValue.create({
|
||||
data: { elementId: newEl.id, fromPhaseId, data: tv.data as object },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return created.id;
|
||||
});
|
||||
|
||||
return NextResponse.json({ scenarioId: newId }, { status: 201 });
|
||||
}
|
||||
+8
-8
@@ -1,7 +1,7 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getOwnedPlan } from "@/lib/queries";
|
||||
import { getOwnedScenario } from "@/lib/queries";
|
||||
import { getCurrentUserId } from "@/lib/session";
|
||||
import { PERSON_ONLY_CATEGORIES } from "@/lib/elements";
|
||||
|
||||
@@ -20,17 +20,17 @@ const createSchema = z.object({
|
||||
ownerRole: z.enum(["PERSON_A", "PERSON_B", "HOUSEHOLD"]).nullable().optional(),
|
||||
});
|
||||
|
||||
// Legt ein neues finanzielles Element (plan-weit) an. Personen-Pflicht je Kategorie.
|
||||
// Legt ein neues finanzielles Element (szenario-weit) an. Personen-Pflicht je Kategorie.
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ planId: string }> }
|
||||
{ params }: { params: Promise<{ scenarioId: string }> }
|
||||
) {
|
||||
const userId = await getCurrentUserId();
|
||||
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
|
||||
const { planId } = await params;
|
||||
const { scenarioId } = await params;
|
||||
|
||||
const plan = await getOwnedPlan(planId, userId);
|
||||
if (!plan) return NextResponse.json({ error: "Plan nicht gefunden." }, { status: 404 });
|
||||
const scenario = await getOwnedScenario(scenarioId, userId);
|
||||
if (!scenario) return NextResponse.json({ error: "Szenario nicht gefunden." }, { status: 404 });
|
||||
|
||||
const body = await request.json();
|
||||
const parsed = createSchema.safeParse(body);
|
||||
@@ -51,13 +51,13 @@ export async function POST(
|
||||
}
|
||||
|
||||
const maxOrder = await prisma.financialElement.aggregate({
|
||||
where: { planId: plan.id },
|
||||
where: { scenarioId: scenario.id },
|
||||
_max: { orderIndex: true },
|
||||
});
|
||||
|
||||
const element = await prisma.financialElement.create({
|
||||
data: {
|
||||
planId: plan.id,
|
||||
scenarioId: scenario.id,
|
||||
category,
|
||||
name,
|
||||
ownerRole,
|
||||
+8
-8
@@ -1,31 +1,31 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { toPlanInput, getOwnedPlan } from "@/lib/queries";
|
||||
import { toPlanInput, getOwnedScenario } from "@/lib/queries";
|
||||
import { getCurrentUserId } from "@/lib/session";
|
||||
import { computePlan, planToCsv } from "@/lib/calculations";
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ planId: string }> }
|
||||
{ params }: { params: Promise<{ scenarioId: string }> }
|
||||
) {
|
||||
const userId = await getCurrentUserId();
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
|
||||
}
|
||||
const { planId } = await params;
|
||||
const { scenarioId } = await params;
|
||||
|
||||
const plan = await getOwnedPlan(planId, userId);
|
||||
if (!plan) {
|
||||
return NextResponse.json({ error: "Plan nicht gefunden." }, { status: 404 });
|
||||
const scenario = await getOwnedScenario(scenarioId, userId);
|
||||
if (!scenario) {
|
||||
return NextResponse.json({ error: "Szenario nicht gefunden." }, { status: 404 });
|
||||
}
|
||||
|
||||
const planInput = toPlanInput(plan);
|
||||
const planInput = toPlanInput(scenario);
|
||||
const computed = computePlan(planInput);
|
||||
const csv = planToCsv(planInput, computed);
|
||||
|
||||
return new NextResponse(csv, {
|
||||
headers: {
|
||||
"Content-Type": "text/csv; charset=utf-8",
|
||||
"Content-Disposition": `attachment; filename="${plan.name.replace(/[^a-z0-9]+/gi, "_")}.csv"`,
|
||||
"Content-Disposition": `attachment; filename="${scenario.name.replace(/[^a-z0-9]+/gi, "_")}.csv"`,
|
||||
},
|
||||
});
|
||||
}
|
||||
+7
-7
@@ -1,7 +1,7 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getOwnedPlan, toPlanInput } from "@/lib/queries";
|
||||
import { getOwnedScenario, toPlanInput } from "@/lib/queries";
|
||||
import { getCurrentUserId } from "@/lib/session";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import { computePlan, maxPhaseDuration } from "@/lib/calculations";
|
||||
@@ -17,20 +17,20 @@ const createPhaseSchema = z.object({
|
||||
// vorbelegt; die Startwerte werden in der Berechnung live aus der Vorphase fortgeschrieben.
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ planId: string }> }
|
||||
{ params }: { params: Promise<{ scenarioId: string }> }
|
||||
) {
|
||||
const userId = await getCurrentUserId();
|
||||
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
|
||||
const { planId } = await params;
|
||||
const { scenarioId } = await params;
|
||||
|
||||
const plan = await getOwnedPlan(planId, userId);
|
||||
if (!plan) return NextResponse.json({ error: "Plan nicht gefunden." }, { status: 404 });
|
||||
const scenario = await getOwnedScenario(scenarioId, userId);
|
||||
if (!scenario) return NextResponse.json({ error: "Szenario nicht gefunden." }, { status: 404 });
|
||||
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const parsed = createPhaseSchema.safeParse(body);
|
||||
if (!parsed.success) return NextResponse.json({ error: "Ungueltige Eingabe." }, { status: 400 });
|
||||
|
||||
const planInput = toPlanInput(plan);
|
||||
const planInput = toPlanInput(scenario);
|
||||
const yearsBefore = planInput.phases.reduce((s, p) => s + p.durationYears, 0);
|
||||
const cap = maxPhaseDuration(planInput.persons, yearsBefore);
|
||||
|
||||
@@ -54,7 +54,7 @@ export async function POST(
|
||||
const phase = await prisma.$transaction(async (tx) => {
|
||||
const created = await tx.phase.create({
|
||||
data: {
|
||||
planId: plan.id,
|
||||
scenarioId: scenario.id,
|
||||
sequenceNumber: nextSequence,
|
||||
name: defaultName,
|
||||
durationYears: duration,
|
||||
@@ -0,0 +1,110 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { toPlanInput, getOwnedScenario, getOwnedScenarioWithMeta } from "@/lib/queries";
|
||||
import { getCurrentUserId } from "@/lib/session";
|
||||
import { computePlan } from "@/lib/calculations";
|
||||
import { scenarioProfileSchema, validatePersonsForType } from "@/app/api/plans/route";
|
||||
|
||||
// Liefert das Szenario samt Berechnung -- und zusaetzlich das ELTERN-Szenario als
|
||||
// Vergleichsbasis, damit das UI die Abweichungen markieren kann (Basisszenario: null).
|
||||
export async function GET(_request: NextRequest, { params }: { params: Promise<{ scenarioId: string }> }) {
|
||||
const userId = await getCurrentUserId();
|
||||
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
|
||||
const { scenarioId } = await params;
|
||||
|
||||
const scenario = await getOwnedScenarioWithMeta(scenarioId, userId);
|
||||
if (!scenario) return NextResponse.json({ error: "Szenario nicht gefunden." }, { status: 404 });
|
||||
|
||||
const planInput = toPlanInput(scenario);
|
||||
const computed = computePlan(planInput);
|
||||
|
||||
let base: ReturnType<typeof toPlanInput> | null = null;
|
||||
if (scenario.parentScenarioId) {
|
||||
const parent = await getOwnedScenario(scenario.parentScenarioId, userId);
|
||||
if (parent) base = toPlanInput(parent);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
plan: planInput,
|
||||
computed,
|
||||
base,
|
||||
meta: {
|
||||
id: scenario.id,
|
||||
planId: scenario.planId,
|
||||
planName: scenario.plan.name,
|
||||
name: scenario.name,
|
||||
isBase: scenario.isBase,
|
||||
parentScenarioId: scenario.parentScenarioId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Name/Cash-Anfangswert ODER das ganze Grundprofil (Haushaltsform/Personen/Inflation).
|
||||
const patchSchema = z.union([
|
||||
scenarioProfileSchema.extend({ name: z.string().min(1).max(120).optional() }),
|
||||
z.object({
|
||||
name: z.string().min(1).max(120).optional(),
|
||||
initialCash: z.number().min(0).max(1_000_000_000).optional(),
|
||||
}),
|
||||
]);
|
||||
|
||||
export async function PATCH(request: NextRequest, { params }: { params: Promise<{ scenarioId: string }> }) {
|
||||
const userId = await getCurrentUserId();
|
||||
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
|
||||
const { scenarioId } = await params;
|
||||
|
||||
const scenario = await prisma.scenario.findFirst({ where: { id: scenarioId, plan: { userId } } });
|
||||
if (!scenario) return NextResponse.json({ error: "Szenario nicht gefunden." }, { status: 404 });
|
||||
|
||||
const parsed = patchSchema.safeParse(await request.json());
|
||||
if (!parsed.success) return NextResponse.json({ error: "Ungueltige Eingabe." }, { status: 400 });
|
||||
|
||||
const data = parsed.data;
|
||||
if ("householdType" in data) {
|
||||
const error = validatePersonsForType(data);
|
||||
if (error) return NextResponse.json({ error }, { status: 400 });
|
||||
const updated = await prisma.$transaction(async (tx) => {
|
||||
await tx.person.deleteMany({ where: { scenarioId: scenario.id } });
|
||||
return tx.scenario.update({
|
||||
where: { id: scenario.id },
|
||||
data: {
|
||||
name: data.name ?? undefined,
|
||||
householdType: data.householdType,
|
||||
inflationRateDefault: data.inflationRateDefault,
|
||||
persons: { create: data.persons },
|
||||
},
|
||||
});
|
||||
});
|
||||
return NextResponse.json({ scenario: { id: updated.id, name: updated.name } });
|
||||
}
|
||||
|
||||
const updated = await prisma.scenario.update({
|
||||
where: { id: scenario.id },
|
||||
data: {
|
||||
name: "name" in data ? data.name : undefined,
|
||||
initialCash:
|
||||
"initialCash" in data && data.initialCash != null ? Math.round(data.initialCash) : undefined,
|
||||
},
|
||||
});
|
||||
return NextResponse.json({ scenario: { id: updated.id, name: updated.name } });
|
||||
}
|
||||
|
||||
// Loescht ein Szenario. Das Basisszenario kann nicht geloescht werden (dafuer den Plan loeschen).
|
||||
export async function DELETE(_request: NextRequest, { params }: { params: Promise<{ scenarioId: string }> }) {
|
||||
const userId = await getCurrentUserId();
|
||||
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
|
||||
const { scenarioId } = await params;
|
||||
|
||||
const scenario = await prisma.scenario.findFirst({ where: { id: scenarioId, plan: { userId } } });
|
||||
if (!scenario) return NextResponse.json({ error: "Szenario nicht gefunden." }, { status: 404 });
|
||||
if (scenario.isBase) {
|
||||
return NextResponse.json(
|
||||
{ error: "Das Basisszenario kann nicht geloescht werden. Loeschen Sie stattdessen den Plan." },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
await prisma.scenario.delete({ where: { id: scenario.id } });
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
Reference in New Issue
Block a user