Szenario-Hierarchie: Plan als Behaelter, Diff-Markierung (V6)
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:
2026-07-18 09:51:44 +02:00
parent 71137f7cea
commit a5d4868c58
25 changed files with 1336 additions and 486 deletions
@@ -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();
+5 -5
View File
@@ -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 });
+9 -66
View File
@@ -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
View File
@@ -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 });
}
@@ -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,
@@ -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"`,
},
});
}
@@ -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,
+110
View File
@@ -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 });
}
+30
View File
@@ -26,6 +26,12 @@
--danger-soft: #fef2f2;
--success: #059669;
--person-a: #4f46e5;
--diff: #b45309;
--diff-soft: #fef3c7;
--diff-added: #15803d;
--diff-added-soft: #dcfce7;
--diff-removed: #71717a;
--diff-removed-soft: #f4f4f5;
--person-b: #0ea5e9;
}
@@ -48,6 +54,12 @@
--danger-soft: rgba(220, 38, 38, 0.16);
--success: #34d399;
--person-a: #818cf8;
--diff: #fbbf24;
--diff-soft: rgba(251, 191, 36, 0.16);
--diff-added: #4ade80;
--diff-added-soft: rgba(74, 222, 128, 0.14);
--diff-removed: #a1a1aa;
--diff-removed-soft: rgba(161, 161, 170, 0.12);
--person-b: #38bdf8;
}
@@ -71,6 +83,12 @@
--danger-soft: #fbeae7;
--success: #2e9e7b;
--person-a: #e8663c;
--diff: #b45309;
--diff-soft: #fbeccb;
--diff-added: #2e7d52;
--diff-added-soft: #dcf0e2;
--diff-removed: #8a7f72;
--diff-removed-soft: #f0e8dd;
--person-b: #f2a93b;
}
@@ -95,6 +113,12 @@
--danger-soft: rgba(220, 38, 38, 0.16);
--success: #34d399;
--person-a: #818cf8;
--diff: #fbbf24;
--diff-soft: rgba(251, 191, 36, 0.16);
--diff-added: #4ade80;
--diff-added-soft: rgba(74, 222, 128, 0.14);
--diff-removed: #a1a1aa;
--diff-removed-soft: rgba(161, 161, 170, 0.12);
--person-b: #38bdf8;
}
}
@@ -117,6 +141,12 @@
--color-danger: var(--danger);
--color-danger-soft: var(--danger-soft);
--color-success: var(--success);
--color-diff: var(--diff);
--color-diff-soft: var(--diff-soft);
--color-diff-added: var(--diff-added);
--color-diff-added-soft: var(--diff-added-soft);
--color-diff-removed: var(--diff-removed);
--color-diff-removed-soft: var(--diff-removed-soft);
--color-person-a: var(--person-a);
--color-person-b: var(--person-b);
--font-sans: var(--font-geist-sans);
+262 -143
View File
@@ -2,8 +2,10 @@
import { useCallback, useEffect, useState } from "react";
import {
Copy,
FileText,
FolderKanban,
GitBranch,
LayoutDashboard,
Menu,
PiggyBank,
@@ -17,42 +19,39 @@ import { SpecView } from "@/components/SpecView";
import { ProfileMenu } from "@/components/ProfileMenu";
import { PlanProfileFields, emptyProfileDraft, type ProfileDraft } from "@/components/PlanProfileFields";
import { api } from "@/lib/api-client";
import type { PlanInput } from "@/lib/types";
import { computeScenarioDiff } from "@/lib/diff";
import type { PlanInput, PlanListItem, ScenarioMeta } from "@/lib/types";
import type { PlanComputed } from "@/lib/calculations";
interface PlanListItem {
id: string;
name: string;
parentPlanId: string | null;
branchFromPhaseId: string | null;
phases: { id: string; name: string; sequenceNumber: number }[];
interface ScenarioDetail {
plan: PlanInput; // das Szenario selbst (berechenbare Einheit)
computed: PlanComputed;
base: PlanInput | null; // Eltern-Szenario als Vergleichsbasis
meta: ScenarioMeta & { planName: string };
}
export function AppShell({ username }: { username: string }) {
const [plans, setPlans] = useState<PlanListItem[]>([]);
const [selectedPlanId, setSelectedPlanId] = useState<string | null>(null);
const [detail, setDetail] = useState<{ plan: PlanInput; computed: PlanComputed } | null>(null);
const [selectedScenarioId, setSelectedScenarioId] = useState<string | null>(null);
const [detail, setDetail] = useState<ScenarioDetail | null>(null);
const [loading, setLoading] = useState(true);
const [sidebarOpen, setSidebarOpen] = useState(false);
const [showNewPlan, setShowNewPlan] = useState(false);
const [showScenario, setShowScenario] = useState(false);
// Die Spezifikation ist eine eigene Ansicht neben Uebersicht und Plan (schliessen sich aus).
const [copyFrom, setCopyFrom] = useState<ScenarioMeta | null>(null);
const [showSpec, setShowSpec] = useState(false);
const loadPlans = useCallback(async (preferId?: string) => {
const loadPlans = useCallback(async () => {
const data = await api.get<{ plans: PlanListItem[] }>("/api/plans");
setPlans(data.plans);
if (preferId) setSelectedPlanId(preferId);
return data.plans;
}, []);
// silent = Hintergrund-Refresh ohne Loading-Umschaltung: die PlanView bleibt montiert,
// damit die Scrollposition (z. B. nach dem Schliessen eines Popups) erhalten bleibt.
const loadDetail = useCallback(async (planId: string, silent = false) => {
const loadDetail = useCallback(async (scenarioId: string, silent = false) => {
if (!silent) setLoading(true);
try {
const data = await api.get<{ plan: PlanInput; computed: PlanComputed }>(`/api/plans/${planId}`);
setDetail(data);
setDetail(await api.get<ScenarioDetail>(`/api/scenarios/${scenarioId}`));
} finally {
if (!silent) setLoading(false);
}
@@ -64,26 +63,46 @@ export function AppShell({ username }: { username: string }) {
}, [loadPlans]);
useEffect(() => {
if (selectedPlanId) {
if (selectedScenarioId) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- asynchroner Datenabruf
loadDetail(selectedPlanId);
loadDetail(selectedScenarioId);
} else {
setDetail(null);
}
}, [selectedPlanId, loadDetail]);
}, [selectedScenarioId, loadDetail]);
function refreshCurrent() {
if (selectedPlanId) loadDetail(selectedPlanId, true);
if (selectedScenarioId) loadDetail(selectedScenarioId, true);
}
function openScenario(id: string) {
setSelectedScenarioId(id);
setShowSpec(false);
setSidebarOpen(false);
}
async function handleDeletePlan(id: string) {
if (!confirm("Diesen Plan wirklich loeschen?")) return;
if (!confirm("Diesen Plan mit ALLEN Szenarien wirklich loeschen?")) return;
await api.delete(`/api/plans/${id}`);
await loadPlans();
if (selectedPlanId === id) setSelectedPlanId(null);
const rest = await loadPlans();
if (!rest.some((p) => p.scenarios.some((s) => s.id === selectedScenarioId))) {
setSelectedScenarioId(null);
}
}
const activePlan = plans.find((p) => p.id === selectedPlanId) ?? null;
async function handleDeleteScenario(s: ScenarioMeta) {
if (!confirm(`Szenario "${s.name}" wirklich loeschen?`)) return;
try {
await api.delete(`/api/scenarios/${s.id}`);
} catch (e) {
alert(e instanceof Error ? e.message : "Loeschen fehlgeschlagen.");
return;
}
await loadPlans();
if (selectedScenarioId === s.id) setSelectedScenarioId(null);
}
const activePlan = plans.find((p) => p.scenarios.some((s) => s.id === selectedScenarioId)) ?? null;
const sidebar = (
<div className="flex h-full flex-col">
@@ -98,14 +117,12 @@ export function AppShell({ username }: { username: string }) {
<button
type="button"
onClick={() => {
setSelectedPlanId(null);
setSelectedScenarioId(null);
setShowSpec(false);
setSidebarOpen(false);
}}
className={`flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium ${
selectedPlanId === null && !showSpec
? "bg-accent-soft text-accent-soft-fg"
: "text-muted hover:bg-surface-2"
selectedScenarioId === null && !showSpec ? "bg-accent-soft text-accent-soft-fg" : "text-muted hover:bg-surface-2"
}`}
>
<LayoutDashboard className="h-4 w-4" />
@@ -124,25 +141,31 @@ export function AppShell({ username }: { username: string }) {
</button>
</div>
{plans.length === 0 && <p className="px-3 py-2 text-xs text-faint">Noch keine Plaene.</p>}
{plans.map((p) => (
<button
key={p.id}
type="button"
onClick={() => {
setSelectedPlanId(p.id);
setShowSpec(false);
setSidebarOpen(false);
}}
className={`flex items-center gap-2 rounded-lg px-3 py-2 text-left text-sm ${
selectedPlanId === p.id && !showSpec
? "bg-accent-soft font-medium text-accent-soft-fg"
: "text-muted hover:bg-surface-2"
}`}
>
<FolderKanban className="h-4 w-4 shrink-0" />
<span className="min-w-0 flex-1 truncate">{p.name}</span>
<span className="text-[11px] text-faint">{p.phases.length}</span>
</button>
<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>
<button
type="button"
aria-label="Plan loeschen"
onClick={() => handleDeletePlan(p.id)}
className="rounded p-0.5 text-faint opacity-0 hover:bg-danger-soft hover:text-danger group-hover:opacity-100"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
<ScenarioTree
scenarios={p.scenarios}
parentId={null}
depth={0}
selectedId={selectedScenarioId}
onSelect={openScenario}
onCopy={setCopyFrom}
onDelete={handleDeleteScenario}
/>
</div>
))}
<div className="mt-4 border-t border-border pt-3">
@@ -150,7 +173,7 @@ export function AppShell({ username }: { username: string }) {
type="button"
onClick={() => {
setShowSpec(true);
setSelectedPlanId(null);
setSelectedScenarioId(null);
setSidebarOpen(false);
}}
className={`flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm font-medium ${
@@ -165,16 +188,16 @@ export function AppShell({ username }: { username: string }) {
</div>
);
const diff = detail ? computeScenarioDiff(detail.plan, detail.base) : null;
return (
<div className="flex min-h-screen w-full">
{/* Sidebar Desktop */}
<aside className="hidden w-60 shrink-0 border-r border-border bg-surface lg:block">{sidebar}</aside>
<aside className="hidden w-64 shrink-0 border-r border-border bg-surface lg:block">{sidebar}</aside>
{/* Sidebar Mobile (Overlay) */}
{sidebarOpen && (
<div className="fixed inset-0 z-40 lg:hidden">
<div className="absolute inset-0 bg-black/40" onClick={() => setSidebarOpen(false)} />
<aside className="absolute left-0 top-0 h-full w-64 border-r border-border bg-surface shadow-xl">
<aside className="absolute left-0 top-0 h-full w-72 border-r border-border bg-surface shadow-xl">
<button
type="button"
onClick={() => setSidebarOpen(false)}
@@ -188,7 +211,6 @@ export function AppShell({ username }: { username: string }) {
</div>
)}
{/* Hauptbereich */}
<div className="flex min-w-0 flex-1 flex-col">
<header className="flex items-center gap-3 border-b border-border bg-surface px-4 py-3">
<button
@@ -200,7 +222,11 @@ export function AppShell({ username }: { username: string }) {
<Menu className="h-4 w-4" />
</button>
<h1 className="min-w-0 flex-1 truncate text-base font-semibold text-fg">
{showSpec ? "Spezifikation" : activePlan ? activePlan.name : "Uebersicht"}
{showSpec
? "Spezifikation"
: detail
? `${detail.meta.planName} · ${detail.meta.name}`
: "Uebersicht"}
</h1>
<ProfileMenu username={username} />
</header>
@@ -210,80 +236,169 @@ export function AppShell({ username }: { username: string }) {
{!showSpec && loading && <p className="text-sm text-muted">Laedt</p>}
{!showSpec && !loading && selectedPlanId === null && (
{!showSpec && !loading && selectedScenarioId === null && (
<DashboardHome
username={username}
plans={plans}
onSelect={(id) => {
setSelectedPlanId(id);
setShowSpec(false);
}}
onSelect={openScenario}
onCreate={() => setShowNewPlan(true)}
onDelete={handleDeletePlan}
/>
)}
{!showSpec && !loading && detail && selectedPlanId && (
{!showSpec && !loading && detail && selectedScenarioId && (
<div className="flex flex-col gap-6">
<div className="flex flex-wrap items-center gap-2">
{detail.plan.phases.length > 0 && (
<button
type="button"
onClick={() => setShowScenario(true)}
className="flex items-center gap-1.5 rounded-lg border border-border px-3 py-1.5 text-sm font-medium text-muted hover:bg-surface-2"
>
<Plus className="h-4 w-4" />
Szenario
</button>
)}
<button
type="button"
onClick={() => handleDeletePlan(selectedPlanId)}
className="flex items-center gap-1.5 rounded-lg border border-border px-3 py-1.5 text-sm font-medium text-muted hover:border-danger hover:bg-danger-soft hover:text-danger"
onClick={() => setCopyFrom(detail.meta)}
className="flex items-center gap-1.5 rounded-lg border border-border px-3 py-1.5 text-sm font-medium text-muted hover:bg-surface-2"
>
<Trash2 className="h-4 w-4" />
Plan loeschen
<Copy className="h-4 w-4" />
Neues Szenario aus diesem
</button>
{!detail.meta.isBase && (
<button
type="button"
onClick={() => handleDeleteScenario(detail.meta)}
className="flex items-center gap-1.5 rounded-lg border border-border px-3 py-1.5 text-sm font-medium text-muted hover:border-danger hover:bg-danger-soft hover:text-danger"
>
<Trash2 className="h-4 w-4" />
Szenario loeschen
</button>
)}
{diff && detail.base && (
<span className="ml-auto flex items-center gap-1.5 rounded-lg border border-diff bg-diff-soft px-3 py-1.5 text-xs font-medium text-diff">
<GitBranch className="h-3.5 w-3.5" />
{diff.total === 0
? "Unveraendert gegenueber der Vorlage"
: `${diff.total} Abweichung${diff.total === 1 ? "" : "en"} gegenueber der Vorlage`}
</span>
)}
</div>
<PlanView plan={detail.plan} computed={detail.computed} onChanged={refreshCurrent} />
<PlanView plan={detail.plan} computed={detail.computed} diff={diff} onChanged={refreshCurrent} />
{detail.plan.phases.length > 0 && <Dashboard plan={detail.plan} computed={detail.computed} allPlans={plans} />}
{detail.plan.phases.length > 0 && (
<Dashboard
plan={detail.plan}
computed={detail.computed}
siblings={(activePlan?.scenarios ?? []).filter((s) => s.id !== detail.meta.id)}
/>
)}
</div>
)}
</main>
</div>
{/* Dialoge */}
{showNewPlan && (
<PlanDialog
onCreate={async (name, profile) => {
const { plan } = await api.post<{ plan: { id: string } }>("/api/plans", { name, ...profile });
const { scenario } = await api.post<{ plan: { id: string }; scenario: { id: string } }>(
"/api/plans",
{ name, ...profile }
);
setShowNewPlan(false);
await loadPlans(plan.id);
await loadPlans();
openScenario(scenario.id);
}}
onClose={() => setShowNewPlan(false)}
/>
)}
{showScenario && detail && selectedPlanId && (
<ScenarioDialog
phases={detail.plan.phases}
onCreate={async (name, branchFromPhaseId) => {
const { planId } = await api.post<{ planId: string }>(`/api/plans/${selectedPlanId}/scenario`, {
name,
branchFromPhaseId,
});
setShowScenario(false);
await loadPlans(planId);
{copyFrom && (
<CopyScenarioDialog
source={copyFrom}
onClose={() => setCopyFrom(null)}
onCreate={async (name) => {
const { scenarioId } = await api.post<{ scenarioId: string }>(
`/api/scenarios/${copyFrom.id}/copy`,
{ name }
);
setCopyFrom(null);
await loadPlans();
openScenario(scenarioId);
}}
onClose={() => setShowScenario(false)}
/>
)}
</div>
);
}
// Startansicht: Begruessung + Plan-Kacheln.
// Rekursiver Szenario-Baum: Kinder werden eingerueckt, damit Sub-Szenarien sichtbar sind.
function ScenarioTree({
scenarios,
parentId,
depth,
selectedId,
onSelect,
onCopy,
onDelete,
}: {
scenarios: ScenarioMeta[];
parentId: string | null;
depth: number;
selectedId: string | null;
onSelect: (id: string) => void;
onCopy: (s: ScenarioMeta) => void;
onDelete: (s: ScenarioMeta) => void;
}) {
const level = scenarios.filter((s) => (s.parentScenarioId ?? null) === parentId);
if (level.length === 0) return null;
return (
<>
{level.map((s) => (
<div key={s.id}>
<div
className={`group flex items-center gap-1.5 rounded-lg py-1.5 pr-1.5 text-sm ${
selectedId === s.id ? "bg-accent-soft font-medium text-accent-soft-fg" : "text-muted hover:bg-surface-2"
}`}
style={{ paddingLeft: `${12 + depth * 14}px` }}
>
<button type="button" onClick={() => onSelect(s.id)} className="flex min-w-0 flex-1 items-center gap-1.5 text-left">
{s.isBase ? (
<PiggyBank className="h-3.5 w-3.5 shrink-0" />
) : (
<GitBranch className="h-3.5 w-3.5 shrink-0" />
)}
<span className="min-w-0 flex-1 truncate" title={s.name}>{s.name}</span>
</button>
<button
type="button"
aria-label="Kopie erstellen"
title="Neues Szenario aus diesem"
onClick={() => onCopy(s)}
className="rounded p-0.5 text-faint opacity-0 hover:bg-accent-soft hover:text-accent group-hover:opacity-100"
>
<Copy className="h-3.5 w-3.5" />
</button>
{!s.isBase && (
<button
type="button"
aria-label="Szenario loeschen"
onClick={() => onDelete(s)}
className="rounded p-0.5 text-faint opacity-0 hover:bg-danger-soft hover:text-danger group-hover:opacity-100"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
</div>
<ScenarioTree
scenarios={scenarios}
parentId={s.id}
depth={depth + 1}
selectedId={selectedId}
onSelect={onSelect}
onCopy={onCopy}
onDelete={onDelete}
/>
</div>
))}
</>
);
}
// Startansicht: Begruessung + Plan-Kacheln (Klick oeffnet das Basisszenario).
function DashboardHome({
username,
plans,
@@ -293,7 +408,7 @@ function DashboardHome({
}: {
username: string;
plans: PlanListItem[];
onSelect: (id: string) => void;
onSelect: (scenarioId: string) => void;
onCreate: () => void;
onDelete: (id: string) => void;
}) {
@@ -302,42 +417,46 @@ function DashboardHome({
<div>
<h2 className="text-xl font-semibold text-fg">Willkommen, {username}</h2>
<p className="mt-1 text-sm text-muted">
Waehlen Sie einen Plan oder erstellen Sie einen neuen, um Ihre finanzielle Zukunft zu planen.
Waehlen Sie einen Plan oder erstellen Sie einen neuen. Jeder Plan enthaelt ein Basisszenario
und beliebig viele Varianten davon.
</p>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
{plans.map((p) => (
<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={() => onSelect(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">
<FolderKanban className="h-5 w-5 text-accent-soft-fg" />
</div>
<div className="min-w-0 flex-1">
<div className="truncate font-medium text-fg">{p.name}</div>
<div className="text-xs text-muted">
{p.phases.length} {p.phases.length === 1 ? "Phase" : "Phasen"}
{p.parentPlanId ? " · Szenario" : ""}
{plans.map((p) => {
const base = p.scenarios.find((s) => s.isBase) ?? p.scenarios[0];
const others = p.scenarios.length - 1;
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)}
>
<div className="flex items-center gap-2">
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-accent-soft">
<FolderKanban className="h-5 w-5 text-accent-soft-fg" />
</div>
<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"}` : ""}
</div>
</div>
<button
type="button"
aria-label="Plan loeschen"
onClick={(e) => {
e.stopPropagation();
onDelete(p.id);
}}
className="rounded-md p-1.5 text-faint opacity-0 transition-opacity hover:bg-danger-soft hover:text-danger group-hover:opacity-100"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
<button
type="button"
aria-label="Plan loeschen"
onClick={(e) => {
e.stopPropagation();
onDelete(p.id);
}}
className="rounded-md p-1.5 text-faint opacity-0 transition-opacity hover:bg-danger-soft hover:text-danger group-hover:opacity-100"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
))}
);
})}
<button
type="button"
@@ -359,7 +478,7 @@ function PlanDialog({
onCreate: (name: string, profile: ProfileDraft) => void;
onClose: () => void;
}) {
const [name, setName] = useState("Basisplan");
const [name, setName] = useState("Meine Planung");
const [draft, setDraft] = useState<ProfileDraft>(emptyProfileDraft);
const [saving, setSaving] = useState(false);
@@ -370,6 +489,10 @@ function PlanDialog({
className="flex w-full max-w-md flex-col gap-4 rounded-2xl border border-border bg-surface p-6 shadow-xl"
>
<h2 className="text-base font-semibold text-fg">Neuen Plan erstellen</h2>
<p className="text-xs text-muted">
Es wird automatisch ein <strong className="text-fg">Basisszenario</strong> angelegt. Weitere
Szenarien entstehen spaeter als Kopien davon.
</p>
<div>
<label className="mb-1 block text-xs font-medium text-muted">Name des Plans</label>
<input
@@ -406,24 +529,28 @@ function PlanDialog({
);
}
function ScenarioDialog({
phases,
function CopyScenarioDialog({
source,
onCreate,
onClose,
}: {
phases: { id: string; name: string }[];
onCreate: (name: string, branchFromPhaseId: string) => void;
source: ScenarioMeta;
onCreate: (name: string) => void;
onClose: () => void;
}) {
const [name, setName] = useState("Neues Szenario");
const [branchFromPhaseId, setBranchFromPhaseId] = useState(phases[phases.length - 1]?.id ?? "");
const [name, setName] = useState(`${source.name} Variante`);
const [saving, setSaving] = useState(false);
return (
<div className="fixed inset-0 z-40 flex items-center justify-center bg-black/40 px-4" onClick={onClose}>
<div
onClick={(e) => e.stopPropagation()}
className="flex w-full max-w-sm flex-col gap-3 rounded-2xl border border-border bg-surface p-6 shadow-xl"
>
<h2 className="text-base font-semibold text-fg">Szenario erstellen</h2>
<h2 className="text-base font-semibold text-fg">Neues Szenario</h2>
<p className="text-xs text-muted">
Vollstaendige Kopie von <strong className="text-fg">{source.name}</strong>. Aenderungen darin
werden anschliessend farblich hervorgehoben.
</p>
<input
autoFocus
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-fg focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/25"
@@ -431,25 +558,17 @@ function ScenarioDialog({
onChange={(e) => setName(e.target.value)}
placeholder="Name des Szenarios"
/>
<label className="text-xs text-muted">Verzweigen ab Phase</label>
<select
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-fg focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/25"
value={branchFromPhaseId}
onChange={(e) => setBranchFromPhaseId(e.target.value)}
>
{phases.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
<div className="flex gap-2">
<button
type="button"
onClick={() => onCreate(name, branchFromPhaseId)}
className="rounded-lg bg-accent px-4 py-2 text-sm font-medium text-accent-fg shadow-sm hover:bg-accent-hover"
disabled={saving}
onClick={() => {
setSaving(true);
onCreate(name.trim() || "Szenario");
}}
className="rounded-lg bg-accent px-4 py-2 text-sm font-medium text-accent-fg shadow-sm hover:bg-accent-hover disabled:opacity-50"
>
Erstellen
{saving ? "..." : "Erstellen"}
</button>
<button
type="button"
+9 -8
View File
@@ -20,11 +20,12 @@ interface PlanListItem {
export function Dashboard({
plan,
computed,
allPlans,
siblings,
}: {
plan: PlanInput;
computed: PlanComputed;
allPlans: PlanListItem[];
// Die uebrigen Szenarien desselben Plans -- nur die sind sinnvoll vergleichbar.
siblings: PlanListItem[];
}) {
const [compareIds, setCompareIds] = useState<string[]>([]);
const [compareData, setCompareData] = useState<Record<string, PlanComputed>>({});
@@ -36,7 +37,7 @@ export function Dashboard({
}
setCompareIds((prev) => [...prev, id]);
if (!compareData[id]) {
const data = await api.get<{ computed: PlanComputed }>(`/api/plans/${id}`);
const data = await api.get<{ computed: PlanComputed }>(`/api/scenarios/${id}`);
setCompareData((prev) => ({ ...prev, [id]: data.computed }));
}
}
@@ -45,11 +46,11 @@ export function Dashboard({
const result: TimelineSeries[] = [{ label: plan.name, color: PALETTE[0], computed }];
compareIds.forEach((id, i) => {
const c = compareData[id];
const name = allPlans.find((p) => p.id === id)?.name ?? id;
const name = siblings.find((p) => p.id === id)?.name ?? id;
if (c) result.push({ label: name, color: PALETTE[(i + 1) % PALETTE.length], computed: c });
});
return result;
}, [plan.name, computed, compareIds, compareData, allPlans]);
}, [plan.name, computed, compareIds, compareData, siblings]);
const ASSET_CATS = ["PENSION_FUND", "PILLAR_3A", "REAL_ESTATE", "OTHER_ASSET"];
@@ -87,7 +88,7 @@ export function Dashboard({
[computed]
);
const otherPlans = allPlans.filter((p) => p.id !== plan.id);
const otherPlans = siblings;
const lastPhase = computed.phases[computed.phases.length - 1];
return (
@@ -117,7 +118,7 @@ export function Dashboard({
Vermoegensverlauf nach Alter
</h3>
<a
href={`/api/plans/${plan.id}/export`}
href={`/api/scenarios/${plan.id}/export`}
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" />
@@ -126,7 +127,7 @@ export function Dashboard({
</div>
{otherPlans.length > 0 && (
<div className="mb-3 flex flex-wrap gap-2">
<span className="text-xs text-muted">Vergleichen mit:</span>
<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
+85 -14
View File
@@ -49,6 +49,7 @@ import {
type TransitionData,
} from "@/lib/elements";
import type { PhaseComputed, PlanComputed } from "@/lib/calculations";
import type { ScenarioDiff } from "@/lib/diff";
import type { ElementInput, PlanInput } from "@/lib/types";
const CATEGORY_ICON: Record<ElementCategory, React.ReactNode> = {
@@ -91,12 +92,28 @@ type Selection =
export function PlanView({
plan,
computed,
diff,
onChanged,
}: {
plan: PlanInput;
computed: PlanComputed;
// Abweichungen gegenueber dem Eltern-Szenario; null im Basisszenario (nichts zu markieren).
diff: ScenarioDiff | null;
onChanged: () => void;
}) {
// Markierungs-Klassen: geaendert = gelb, neu = gruen, entfernt = grau.
const cellDiff = (elementId: string, phaseId: string) =>
diff?.phaseCell.has(`${elementId}:${phaseId}`) ? "bg-diff-soft ring-1 ring-inset ring-diff/40" : "";
const transDiff = (elementId: string, fromPhaseId: string) =>
diff?.transitionCell.has(`${elementId}:${fromPhaseId}`) ? "bg-diff-soft ring-1 ring-inset ring-diff/40" : "";
const rowDiff = (elementId: string) => {
const k = diff?.elementRow.get(elementId);
return k === "added"
? "bg-diff-added-soft"
: k === "changed"
? "bg-diff-soft"
: "";
};
const [selected, setSelected] = useState<Selection | null>(null);
const [collapsedCats, setCollapsedCats] = useState<Set<ElementCategory>>(new Set());
const [showAdd, setShowAdd] = useState(false);
@@ -275,7 +292,7 @@ export function PlanView({
}
async function handleAddPhase(payload: { name?: string; durationYears?: number }) {
await api.post(`/api/plans/${plan.id}/phases`, payload);
await api.post(`/api/scenarios/${plan.id}/phases`, payload);
setShowAddPhase(false);
onChanged();
}
@@ -307,8 +324,14 @@ export function PlanView({
<Timeline phases={computed.phases} persons={personAxes} ruinAge={computed.ruinAge} />
{/* Plan-Profil */}
<div className="flex flex-wrap items-center gap-3 rounded-xl border border-border bg-surface px-4 py-3 text-sm shadow-sm">
<span className="text-xs font-semibold uppercase tracking-wide text-faint">Grundprofil (Plan)</span>
<div
className={`flex flex-wrap items-center gap-3 rounded-xl border px-4 py-3 text-sm shadow-sm ${
diff?.profileChanged ? "border-diff bg-diff-soft" : "border-border bg-surface"
}`}
>
<span className="text-xs font-semibold uppercase tracking-wide text-faint">
Grundprofil (Szenario){diff?.profileChanged ? " · abweichend" : ""}
</span>
{plan.persons.map((p) => (
<span key={p.role} className="text-xs text-muted">
{personLabel(p.role)}: {p.age} J., Pension {p.retirementAge}
@@ -385,6 +408,7 @@ export function PlanView({
phase={col.phase}
personLabel={personLabel}
mode={valueMode}
diffKind={diff?.phaseHeader.get(col.phase.id) ?? null}
onClick={() => setSelected({ type: "phase", phaseId: col.phase.id })}
active={selected?.type === "phase" && selected.phaseId === col.phase.id}
/>
@@ -416,7 +440,9 @@ export function PlanView({
title={isFirst ? "Cash-Anfangswert bearbeiten" : undefined}
className={`border-b border-r border-border px-2 py-1.5 text-center text-xs ${
isFirst ? "cursor-pointer hover:bg-accent-soft" : ""
} ${col.phase.cashNegative ? "font-semibold text-danger" : "text-fg"}`}
} ${col.phase.cashNegative ? "font-semibold text-danger" : "text-fg"} ${
isFirst && diff?.cashInitialChanged ? "bg-diff-soft ring-1 ring-inset ring-diff/40" : ""
}`}
>
<span className="whitespace-nowrap">
{valStr(col.phase.cashStart, col.phase.cumulativeInflationStart, valueMode)}{" "}
@@ -435,7 +461,11 @@ export function PlanView({
onClick={() => setEditCashTransition(col.fromPhase.id)}
title="Einmalige Sonderein-/ausgaben"
className={`cursor-pointer border-b border-r border-border px-2 py-1.5 text-center text-[11px] ${
open ? "bg-accent font-semibold text-accent-fg" : "bg-accent-soft/40 text-accent"
open
? "bg-accent font-semibold text-accent-fg"
: diff?.cashTransitionCell.has(col.fromPhase.id)
? "bg-diff-soft text-diff ring-1 ring-inset ring-diff/40"
: "bg-accent-soft/40 text-accent"
}`}
>
{cashTransitionSummary(ct)}
@@ -474,8 +504,13 @@ export function PlanView({
{!collapsed &&
els.map((el) => (
<tr key={el.id} className="hover:bg-surface-2">
<td className="sticky left-0 z-10 border-b border-r border-border bg-surface px-3 py-1.5">
<div className="truncate text-xs font-medium text-fg">{el.name}</div>
<td className={`sticky left-0 z-10 border-b border-r border-border px-3 py-1.5 ${rowDiff(el.id) || "bg-surface"}`}>
<div className="flex items-center gap-1 truncate text-xs font-medium text-fg">
{el.name}
{diff?.elementRow.get(el.id) === "added" && (
<span className="rounded bg-diff-added px-1 text-[9px] font-semibold uppercase text-white">neu</span>
)}
</div>
{el.ownerRole && el.ownerRole !== "HOUSEHOLD" && (
<div className="text-[10px] text-faint">{personLabel(el.ownerRole)}</div>
)}
@@ -487,9 +522,10 @@ export function PlanView({
<td
key={col.phase.id}
onClick={() => setEditPhaseCell({ elementId: el.id, phaseId: col.phase.id })}
title={cellDiff(el.id, col.phase.id) ? "Weicht von der Vorlage ab" : undefined}
className={`cursor-pointer border-b border-r border-border px-2 py-1.5 text-center text-xs ${
ce?.locked ? "text-faint" : "text-fg"
}`}
} ${cellDiff(el.id, col.phase.id)}`}
>
{phaseCellContent(ce, col.phase, valueMode)}
</td>
@@ -505,9 +541,19 @@ export function PlanView({
canTransition &&
setEditTransition({ elementId: el.id, fromPhaseId: col.fromPhase.id })
}
title={transDiff(el.id, col.fromPhase.id) ? "Weicht von der Vorlage ab" : undefined}
className={`border-b border-r border-border px-2 py-1.5 text-center text-[11px] ${
canTransition ? "cursor-pointer" : "text-faint"
} ${open ? "bg-accent font-semibold text-accent-fg" : canTransition ? "bg-accent-soft/40 text-accent" : ""}`}
} ${
// Offene Entscheide bleiben in Akzentfarbe; sonst gewinnt die Abweichungs-Markierung.
open
? "bg-accent font-semibold text-accent-fg"
: transDiff(el.id, col.fromPhase.id)
? "bg-diff-soft text-diff ring-1 ring-inset ring-diff/40"
: canTransition
? "bg-accent-soft/40 text-accent"
: ""
}`}
>
{canTransition ? transitionSummary(el, col.fromPhase, col.toPhase) : locked ? "" : "→"}
</td>
@@ -518,7 +564,21 @@ export function PlanView({
</FragmentRows>
);
})}
{plan.elements.length === 0 && (
{/* Geisterzeilen: in der Vorlage vorhanden, in diesem Szenario geloescht. */}
{(diff?.removedElements ?? []).map((r) => (
<tr key={`removed-${r.id}`} className="bg-diff-removed-soft">
<td className="sticky left-0 z-10 border-b border-r border-border bg-diff-removed-soft px-3 py-1.5">
<div className="flex items-center gap-1 truncate text-xs font-medium text-diff-removed line-through">
{r.name}
</div>
<div className="text-[10px] text-diff-removed">{CATEGORY_LABELS[r.category]} · entfernt</div>
</td>
<td colSpan={columns.length} className="border-b border-border px-3 py-1.5 text-center text-[11px] text-diff-removed">
In diesem Szenario entfernt
</td>
</tr>
))}
{plan.elements.length === 0 && (diff?.removedElements.length ?? 0) === 0 && (
<tr>
<td className="sticky left-0 bg-surface px-3 py-4 text-xs text-faint" colSpan={columns.length + 1}>
Noch keine finanziellen Elemente. Fuegen Sie oben Ihr erstes Element hinzu.
@@ -793,12 +853,14 @@ function PhaseHeader({
phase,
personLabel,
mode,
diffKind,
onClick,
active,
}: {
phase: PhaseComputed;
personLabel: (role: string) => string;
mode: ValueMode;
diffKind: "changed" | "added" | "removed" | null;
onClick: () => void;
active: boolean;
}) {
@@ -810,11 +872,20 @@ function PhaseHeader({
<th
onClick={onClick}
className={`min-w-44 cursor-pointer border-b border-r border-border px-2 py-2 text-left align-top ${
active ? "bg-accent-soft" : "bg-surface"
active
? "bg-accent-soft"
: diffKind === "added"
? "bg-diff-added-soft"
: diffKind === "changed"
? "bg-diff-soft"
: "bg-surface"
}`}
>
<div className="flex items-center gap-1">
<span className="truncate text-xs font-semibold text-fg">{phase.name}</span>
{diffKind === "added" && (
<span className="rounded bg-diff-added px-1 text-[9px] font-semibold uppercase text-white">neu</span>
)}
{phase.cashNegative ? (
<AlertCircle className="h-3.5 w-3.5 shrink-0 text-danger" />
) : (
@@ -973,7 +1044,7 @@ function AddElementDialog({
setSaving(true);
setError(null);
try {
const { element } = await api.post<{ element: { id: string } }>(`/api/plans/${plan.id}/elements`, {
const { element } = await api.post<{ element: { id: string } }>(`/api/scenarios/${plan.id}/elements`, {
category,
name: name.trim() || CATEGORY_LABELS[category],
ownerRole,
@@ -1128,7 +1199,7 @@ function PlanSettingsDialog({ plan, onClose, onSaved }: { plan: PlanInput; onClo
setSaving(true);
setError(null);
try {
await api.patch(`/api/plans/${plan.id}`, draft);
await api.patch(`/api/scenarios/${plan.id}`, draft);
onSaved();
} catch (e) {
setError(e instanceof Error ? e.message : "Speichern fehlgeschlagen.");
@@ -1324,7 +1395,7 @@ function CashInitialDialog({ plan, onClose, onSaved }: { plan: PlanInput; onClos
setSaving(true);
setError(null);
try {
await api.patch(`/api/plans/${plan.id}`, { initialCash: value });
await api.patch(`/api/scenarios/${plan.id}`, { initialCash: value });
onSaved();
} catch (e) {
setError(e instanceof Error ? e.message : "Speichern fehlgeschlagen.");
+99
View File
@@ -0,0 +1,99 @@
import { describe, it, expect } from "vitest";
import { computeScenarioDiff } from "@/lib/diff";
import type { PlanInput } from "@/lib/types";
// Basis: 1 Phase, 2 Elemente. Das Kind ist eine Kopie mit Herkunfts-Verweisen.
function base(): PlanInput {
return {
id: "S1", name: "Basisszenario", householdType: "SINGLE", inflationRateDefault: 1.5, initialCash: 1000,
persons: [{ id: "pA", role: "PERSON_A", name: null, age: 40, retirementAge: 65 }],
phases: [{ id: "b-ph1", sequenceNumber: 1, name: "Erwerb", durationYears: 20, cashTransition: {}, sourcePhaseId: null }],
elements: [
{ id: "b-e1", category: "INCOME", name: "Lohn", ownerRole: "PERSON_A", orderIndex: 1, phaseValues: { "b-ph1": { amount: 90000 } }, transitionValues: {}, sourceElementId: null },
{ id: "b-e2", category: "OTHER_ASSET", name: "ETF", ownerRole: "HOUSEHOLD", orderIndex: 2, phaseValues: { "b-ph1": { startValue: 100000, expectedReturn: 5 } }, transitionValues: { "b-ph1": { decision: "HOLD" } }, sourceElementId: null },
],
};
}
// Unveraenderte Kopie.
function copy(): PlanInput {
return {
id: "S2", name: "Variante", householdType: "SINGLE", inflationRateDefault: 1.5, initialCash: 1000,
persons: [{ id: "pA2", role: "PERSON_A", name: null, age: 40, retirementAge: 65 }],
phases: [{ id: "c-ph1", sequenceNumber: 1, name: "Erwerb", durationYears: 20, cashTransition: {}, sourcePhaseId: "b-ph1" }],
elements: [
{ id: "c-e1", category: "INCOME", name: "Lohn", ownerRole: "PERSON_A", orderIndex: 1, phaseValues: { "c-ph1": { amount: 90000 } }, transitionValues: {}, sourceElementId: "b-e1" },
{ id: "c-e2", category: "OTHER_ASSET", name: "ETF", ownerRole: "HOUSEHOLD", orderIndex: 2, phaseValues: { "c-ph1": { startValue: 100000, expectedReturn: 5 } }, transitionValues: { "c-ph1": { decision: "HOLD" } }, sourceElementId: "b-e2" },
],
};
}
describe("Szenario-Diff", () => {
it("frische Kopie zeigt keine Abweichung", () => {
const d = computeScenarioDiff(copy(), base());
expect(d.total).toBe(0);
expect(d.phaseCell.size).toBe(0);
expect(d.profileChanged).toBe(false);
});
it("Basisszenario (ohne Elternteil) hat nie Abweichungen", () => {
expect(computeScenarioDiff(base(), null).total).toBe(0);
});
it("geaenderter Zellwert wird genau einer Zelle zugeordnet", () => {
const c = copy();
c.elements[0].phaseValues["c-ph1"] = { amount: 120000 };
const d = computeScenarioDiff(c, base());
expect(d.phaseCell.has("c-e1:c-ph1")).toBe(true);
expect(d.phaseCell.size).toBe(1);
expect(d.elementRow.size).toBe(0); // Zeile selbst unveraendert
});
it("geaenderter Uebergangs-Entscheid markiert die Uebergangszelle", () => {
const c = copy();
c.elements[1].transitionValues["c-ph1"] = { decision: "SELL" };
const d = computeScenarioDiff(c, base());
expect(d.transitionCell.has("c-e2:c-ph1")).toBe(true);
expect(d.phaseCell.size).toBe(0);
});
it("neues Element ist 'added', geloeschtes wird als entfernt gemeldet", () => {
const c = copy();
c.elements.push({ id: "c-e3", category: "OTHER_DEBT", name: "Kredit", ownerRole: "HOUSEHOLD", orderIndex: 3, phaseValues: {}, transitionValues: {}, sourceElementId: null });
c.elements = c.elements.filter((e) => e.id !== "c-e1"); // Lohn geloescht
const d = computeScenarioDiff(c, base());
expect(d.elementRow.get("c-e3")).toBe("added");
expect(d.removedElements.map((r) => r.name)).toEqual(["Lohn"]);
});
it("Phasenkopf: Dauer/Name geaendert bzw. Phase neu", () => {
const c = copy();
c.phases[0].durationYears = 15;
c.phases.push({ id: "c-ph2", sequenceNumber: 2, name: "Pension", durationYears: 25, cashTransition: {}, sourcePhaseId: null });
const d = computeScenarioDiff(c, base());
expect(d.phaseHeader.get("c-ph1")).toBe("changed");
expect(d.phaseHeader.get("c-ph2")).toBe("added");
});
it("Profil (Pensionsalter) und Cash-Anfangswert werden erkannt", () => {
const c = copy();
c.persons[0].retirementAge = 62;
c.initialCash = 5000;
const d = computeScenarioDiff(c, base());
expect(d.profileChanged).toBe(true);
expect(d.cashInitialChanged).toBe(true);
});
it("Cash-Uebergang wird erkannt", () => {
const c = copy();
c.phases[0].cashTransition = { mode: "INFLOW", inflowAmount: 100000 };
const d = computeScenarioDiff(c, base());
expect(d.cashTransitionCell.has("c-ph1")).toBe(true);
});
it("fehlendes Feld und 0 gelten als gleich (keine Falsch-Markierung)", () => {
const c = copy();
c.elements[0].phaseValues["c-ph1"] = { amount: 90000, teuerungsausgleich: 0 };
expect(computeScenarioDiff(c, base()).phaseCell.size).toBe(0);
});
});
+153
View File
@@ -0,0 +1,153 @@
// Abweichungs-Erkennung zwischen einem Szenario und seinem ELTERN-Szenario.
//
// Die Zuordnung laeuft ueber die Herkunfts-Verweise, die beim Kopieren gesetzt werden
// (Phase.sourcePhaseId, FinancialElement.sourceElementId). Ueber den Namen zu matchen waere
// fragil: Umbenennen wuerde die Verknuepfung brechen und gleichnamige Elemente kollidieren.
//
// Der Vergleich ist LIVE gegen den aktuellen Stand des Elternteils -- aendert man dort einen
// Wert, verschiebt sich die Markierung im Kind entsprechend.
import { num } from "@/lib/elements";
import type { PhaseData, TransitionData, CashTransitionData } from "@/lib/elements";
import type { ElementInput, PlanInput } from "@/lib/types";
export type DiffKind = "changed" | "added" | "removed";
export interface ScenarioDiff {
// elementId -> Status der ganzen Zeile ("added" = im Elternteil nicht vorhanden).
elementRow: Map<string, DiffKind>;
// `${elementId}:${phaseId}` -> Zelle weicht ab.
phaseCell: Set<string>;
// `${elementId}:${fromPhaseId}` -> Uebergangs-Zelle weicht ab.
transitionCell: Set<string>;
// phaseId -> Phasenkopf weicht ab (Name/Dauer) bzw. Phase ist neu.
phaseHeader: Map<string, DiffKind>;
// phaseId -> Cash-Uebergang nach dieser Phase weicht ab.
cashTransitionCell: Set<string>;
cashInitialChanged: boolean;
profileChanged: boolean;
// Im Elternteil vorhandene, hier geloeschte Elemente (fuer die Geisterzeilen).
removedElements: { id: string; name: string; category: ElementInput["category"] }[];
removedPhaseCount: number;
total: number;
}
export function emptyDiff(): ScenarioDiff {
return {
elementRow: new Map(),
phaseCell: new Set(),
transitionCell: new Set(),
phaseHeader: new Map(),
cashTransitionCell: new Set(),
cashInitialChanged: false,
profileChanged: false,
removedElements: [],
removedPhaseCount: 0,
total: 0,
};
}
// Vergleicht zwei JSON-Payloads feldweise; fehlend und 0 gelten als gleich, damit ein
// nicht gesetztes Feld nicht faelschlich als Aenderung erscheint.
function sameData(a: object = {}, b: object = {}): boolean {
const ra = a as Record<string, unknown>;
const rb = b as Record<string, unknown>;
const keys = new Set([...Object.keys(ra), ...Object.keys(rb)]);
for (const k of keys) {
const va = ra[k];
const vb = rb[k];
if (typeof va === "number" || typeof vb === "number") {
if (Math.round(num(va as number)) !== Math.round(num(vb as number))) return false;
} else if ((va ?? "") !== (vb ?? "")) {
return false;
}
}
return true;
}
export function computeScenarioDiff(scenario: PlanInput, base: PlanInput | null): ScenarioDiff {
const d = emptyDiff();
if (!base) return d;
// --- Phasen ---
const basePhaseById = new Map(base.phases.map((p) => [p.id, p]));
const usedBasePhases = new Set<string>();
// Zuordnung eigene Phase -> Eltern-Phase (fuer die Zellen-Vergleiche).
const phaseToBase = new Map<string, string>();
for (const ph of scenario.phases) {
const src = ph.sourcePhaseId ? basePhaseById.get(ph.sourcePhaseId) : undefined;
if (!src) {
d.phaseHeader.set(ph.id, "added");
continue;
}
usedBasePhases.add(src.id);
phaseToBase.set(ph.id, src.id);
if (ph.name !== src.name || ph.durationYears !== src.durationYears) {
d.phaseHeader.set(ph.id, "changed");
}
if (!sameData(ph.cashTransition as CashTransitionData, src.cashTransition as CashTransitionData)) {
d.cashTransitionCell.add(ph.id);
}
}
d.removedPhaseCount = base.phases.filter((p) => !usedBasePhases.has(p.id)).length;
// --- Elemente ---
const baseElById = new Map(base.elements.map((e) => [e.id, e]));
const usedBaseEls = new Set<string>();
for (const el of scenario.elements) {
const src = el.sourceElementId ? baseElById.get(el.sourceElementId) : undefined;
if (!src) {
d.elementRow.set(el.id, "added");
continue;
}
usedBaseEls.add(src.id);
if (el.name !== src.name || el.ownerRole !== src.ownerRole) {
d.elementRow.set(el.id, "changed");
}
// Zellen je Phase / Uebergang ueber die Phasen-Zuordnung vergleichen.
for (const ph of scenario.phases) {
const basePhaseId = phaseToBase.get(ph.id);
const mine = el.phaseValues[ph.id];
const theirs = basePhaseId ? src.phaseValues[basePhaseId] : undefined;
if (!basePhaseId) continue; // neue Phase -> Kopf ist bereits als "added" markiert
if (!sameData(mine as PhaseData, theirs as PhaseData)) {
d.phaseCell.add(`${el.id}:${ph.id}`);
}
const myT = el.transitionValues[ph.id];
const theirT = src.transitionValues[basePhaseId];
if (!sameData(myT as TransitionData, theirT as TransitionData)) {
d.transitionCell.add(`${el.id}:${ph.id}`);
}
}
}
d.removedElements = base.elements
.filter((e) => !usedBaseEls.has(e.id))
.map((e) => ({ id: e.id, name: e.name, category: e.category }));
// --- Profil und Cash-Anfangswert ---
d.cashInitialChanged = Math.round(scenario.initialCash) !== Math.round(base.initialCash);
const personKey = (p: PlanInput["persons"][number]) =>
`${p.role}|${p.name ?? ""}|${p.age}|${p.retirementAge}`;
d.profileChanged =
scenario.householdType !== base.householdType ||
scenario.inflationRateDefault !== base.inflationRateDefault ||
scenario.persons.map(personKey).sort().join(";") !== base.persons.map(personKey).sort().join(";");
d.total =
d.elementRow.size +
d.phaseCell.size +
d.transitionCell.size +
d.phaseHeader.size +
d.cashTransitionCell.size +
d.removedElements.length +
d.removedPhaseCount +
(d.cashInitialChanged ? 1 : 0) +
(d.profileChanged ? 1 : 0);
return d;
}
+63
View File
@@ -0,0 +1,63 @@
// Spielt ALLE Migrationen gegen ein echtes PostgreSQL (PGlite, in-process) ein und prueft das
// Ergebnis. Faengt kaputte oder nicht-idempotente Migrations-SQL ab, bevor sie beim Deploy
// gegen die Live-Datenbank laufen -- lokal steht sonst keine Datenbank zur Verfuegung.
import { describe, it, expect } from "vitest";
import { PGlite } from "@electric-sql/pglite";
import { readFileSync, readdirSync } from "node:fs";
import path from "node:path";
const MIG = path.join(process.cwd(), "prisma", "migrations");
describe("Datenbank-Migrationen", () => {
it("laufen vollstaendig durch und ergeben das erwartete Schema", async () => {
const db = await PGlite.create();
const dirs = readdirSync(MIG).filter((d) => !d.endsWith(".toml")).sort();
expect(dirs.length).toBeGreaterThan(0);
for (const d of dirs) {
await db.exec(readFileSync(path.join(MIG, d, "migration.sql"), "utf8"));
}
const tables = (
await db.query<{ table_name: string }>(
`SELECT table_name FROM information_schema.tables WHERE table_schema='public'`
)
).rows.map((r) => r.table_name);
// V6-Struktur: Behaelter Plan + berechenbares Scenario.
for (const t of ["User", "Plan", "Scenario", "Person", "Phase", "FinancialElement"]) {
expect(tables, `Tabelle ${t} fehlt`).toContain(t);
}
const cols = async (table: string) =>
(
await db.query<{ column_name: string }>(
`SELECT column_name FROM information_schema.columns WHERE table_name=$1`,
[table]
)
).rows.map((r) => r.column_name);
// Kind-Tabellen haengen am Szenario, nicht mehr am Plan.
for (const t of ["Person", "Phase", "FinancialElement"]) {
const c = await cols(t);
expect(c, `${t}.scenarioId fehlt`).toContain("scenarioId");
expect(c, `${t}.planId haette entfernt werden muessen`).not.toContain("planId");
}
// Herkunfts-Verweise fuer den Diff.
expect(await cols("Phase")).toContain("sourcePhaseId");
expect(await cols("FinancialElement")).toContain("sourceElementId");
// Der Plan traegt keine Finanzdaten mehr.
const planCols = await cols("Plan");
expect(planCols).not.toContain("householdType");
expect(planCols).toContain("userId");
// Das Szenario traegt sie.
const scenCols = await cols("Scenario");
for (const c of ["planId", "isBase", "parentScenarioId", "householdType", "initialCash"]) {
expect(scenCols, `Scenario.${c} fehlt`).toContain(c);
}
expect(scenCols).not.toContain("userId"); // Eigentuemer haengt am Plan
}, 60000);
});
+28 -6
View File
@@ -11,9 +11,9 @@ export const planInclude = {
orderBy: { orderIndex: "asc" },
include: { phaseValues: true, transitionValues: true },
},
} satisfies Prisma.PlanInclude;
} satisfies Prisma.ScenarioInclude;
export type PlanWithRelations = Prisma.PlanGetPayload<{ include: typeof planInclude }>;
export type PlanWithRelations = Prisma.ScenarioGetPayload<{ include: typeof planInclude }>;
function parsePhaseData(raw: unknown): PhaseData {
const parsed = phaseDataSchema.safeParse(raw);
@@ -30,6 +30,7 @@ function parseCashTransition(raw: unknown): CashTransitionData {
return parsed.success ? parsed.data : {};
}
// Wandelt ein Szenario (DB) in die berechenbare Einheit (PlanInput) um.
export function toPlanInput(plan: PlanWithRelations): PlanInput {
return {
id: plan.id,
@@ -50,6 +51,7 @@ export function toPlanInput(plan: PlanWithRelations): PlanInput {
name: phase.name,
durationYears: phase.durationYears,
cashTransition: parseCashTransition(phase.cashTransition),
sourcePhaseId: phase.sourcePhaseId,
})),
elements: plan.elements.map((e) => {
const phaseValues: Record<string, PhaseData> = {};
@@ -64,29 +66,49 @@ export function toPlanInput(plan: PlanWithRelations): PlanInput {
orderIndex: e.orderIndex,
phaseValues,
transitionValues,
sourceElementId: e.sourceElementId,
};
}),
};
}
// Laedt einen Plan inkl. Profil + Phasen + Elemente, aber nur wenn er dem Benutzer gehoert.
// --- Ownership-Abfragen. Der Eigentuemer haengt neu am PLAN; Szenario/Phase/Element
// erben ihn ueber die Kette Scenario -> Plan -> User. ---
// Laedt ein Szenario inkl. Profil + Phasen + Elemente, nur wenn es dem Benutzer gehoert.
export async function getOwnedScenario(scenarioId: string, userId: string) {
return prisma.scenario.findFirst({
where: { id: scenarioId, plan: { userId } },
include: planInclude,
});
}
// Wie oben, zusaetzlich mit den Kopfdaten (Plan, Basis-Flag, Elternteil).
export async function getOwnedScenarioWithMeta(scenarioId: string, userId: string) {
return prisma.scenario.findFirst({
where: { id: scenarioId, plan: { userId } },
include: { ...planInclude, plan: true },
});
}
// Laedt einen Plan (Behaelter) inkl. Szenario-Kopfdaten.
export async function getOwnedPlan(planId: string, userId: string) {
return prisma.plan.findFirst({
where: { id: planId, userId },
include: planInclude,
include: { scenarios: { orderBy: { createdAt: "asc" } } },
});
}
// Laedt eine Phase (Basisdaten), aber nur wenn sie dem Benutzer gehoert.
export async function getOwnedPhase(phaseId: string, userId: string) {
return prisma.phase.findFirst({
where: { id: phaseId, plan: { userId } },
where: { id: phaseId, scenario: { plan: { userId } } },
});
}
// Laedt ein Element (Basisdaten), aber nur wenn es dem Benutzer gehoert.
export async function getOwnedElement(elementId: string, userId: string) {
return prisma.financialElement.findFirst({
where: { id: elementId, plan: { userId } },
where: { id: elementId, scenario: { plan: { userId } } },
});
}
+25 -2
View File
@@ -23,6 +23,8 @@ export interface PhaseInput {
durationYears: number;
// Cash-Entscheid beim Uebergang NACH dieser Phase (einmalige Sonderein-/ausgaben).
cashTransition: CashTransitionData;
// Gegenstueck im Eltern-Szenario (Diff-Grundlage); null im Basisszenario.
sourcePhaseId?: string | null;
}
export interface ElementInput {
@@ -34,10 +36,31 @@ export interface ElementInput {
// Werte je Phase (Key = phaseId) bzw. je Uebergang (Key = fromPhaseId).
phaseValues: Record<string, PhaseData>;
transitionValues: Record<string, TransitionData>;
// Gegenstueck im Eltern-Szenario (Diff-Grundlage); null im Basisszenario.
sourceElementId?: string | null;
}
// Ein Plan ist selbsttragend: er traegt sein eigenes Grundprofil (Haushaltsform, Personen,
// Inflationsannahme) plus die Phasenkette und die finanziellen Elemente.
// Kopf-Daten eines Szenarios (fuer Baum und Auswahl in der Seitenleiste).
export interface ScenarioMeta {
id: string;
planId: string;
name: string;
isBase: boolean;
parentScenarioId: string | null;
}
// Ein Plan ist der Behaelter; er traegt nur den Namen und seine Szenarien.
export interface PlanListItem {
id: string;
name: string;
createdAt?: string;
scenarios: ScenarioMeta[];
}
// Die berechenbare Einheit (fachlich: ein SZENARIO). Sie ist selbsttragend und traegt ihr
// eigenes Grundprofil (Haushaltsform, Personen, Inflation, Cash) plus Phasen und Elemente.
// Der Name `PlanInput` ist historisch und bleibt, weil die ganze Berechnungsschicht darauf
// aufsetzt (computePlan, Monte Carlo, Tests).
export interface PlanInput {
id: string;
name: string;