From 32adfb4476cbf7d190abeaf845bebe54f8d2319e Mon Sep 17 00:00:00 2001 From: kelle Date: Mon, 13 Jul 2026 14:49:42 +0200 Subject: [PATCH] V3: 3 Farbschemata, Grundprofil auf Plan-Ebene, Erstellungs-Popups, integer-Zahlenfelder mit Beschleunigungs-Spinner, Carry-Forward des Zielwerts, gefuehrter Uebergang - Theming: semantische CSS-Tokens + 3 waehlbare Schemata (Hell/Dunkel/Warm/Sunset), Umschalter im Profil-Menue, FOUC-frei via Inline-Script, localStorage; Klassen-Sweep aller Komponenten, Recharts aus Tokens - Datenmodell: Household entfaellt; Plan traegt Haushaltsform/Personen/Inflation selbst (Person -> planId, Plan -> userId); destruktive Migration (TRUNCATE); Onboarding/HouseholdSettings entfernt; Plan-Erstellung & -Einstellungen mit Profilfeldern - Popups: Element-Erstellung mit Inline-Feldern (geteilte ElementPhaseFields/ElementTransitionFields), Phase- und Plan-Popups mit Direkteingabe - Zahlenfelder: 1'000er-Runden entfernt (floorToThousand/roundToHundred weg), integer MoneyInput mit beschleunigendem Press-and-Hold-Spinner, 0-Bug-Fix, harte Live-Caps - Quote: Amortisation + Tilgung neu quotenwirksam; Restquote sichtbar (sinkt beim Verteilen); Invest-Deckel = verfuegbares Kapital + fortgeschriebener Zielwert - Carry-Forward: Startwert der Folgephase = Zielwert der Vorphase minus Uebergangs-Bezug (live abgeleitet); optionale Zusatzinvestition aus verfuegbarem Kapital - Matrix: Zelle zeigt Start -> Ziel; Uebergangs-Spaltenkopf mit "n offen"-Badge + gefuehrtem Pruef-Panel Co-Authored-By: Claude Opus 4.8 --- .../migration.sql | 40 + prisma/schema.prisma | 46 +- src/app/api/household/route.ts | 110 --- src/app/api/phases/[phaseId]/route.ts | 7 +- src/app/api/plans/[planId]/export/route.ts | 8 +- src/app/api/plans/[planId]/phases/route.ts | 54 +- src/app/api/plans/[planId]/route.ts | 50 +- src/app/api/plans/[planId]/scenario/route.ts | 9 +- src/app/api/plans/route.ts | 53 +- src/app/globals.css | 132 +++- src/app/layout.tsx | 9 +- src/app/login/page.tsx | 28 +- src/app/page.tsx | 26 +- src/components/AppShell.tsx | 176 ++--- src/components/Dashboard.tsx | 26 +- src/components/ElementDetail.tsx | 663 +++++++++------- src/components/FormField.tsx | 90 ++- src/components/HouseholdSettings.tsx | 105 --- src/components/InfoBubble.tsx | 4 +- src/components/Onboarding.tsx | 119 --- src/components/PhaseDetail.tsx | 18 +- src/components/PlanProfileFields.tsx | 93 +++ src/components/PlanView.tsx | 729 ++++++++++++++---- src/components/ProfileMenu.tsx | 131 ++-- src/components/Timeline.tsx | 12 +- src/components/WealthChart.tsx | 4 +- src/lib/calculations.ts | 149 ++-- src/lib/elements.ts | 4 + src/lib/format.ts | 8 - src/lib/queries.ts | 41 +- src/lib/theme.ts | 35 + src/lib/types.ts | 17 +- 32 files changed, 1706 insertions(+), 1290 deletions(-) create mode 100644 prisma/migrations/20260713150000_profile_to_plan_v3/migration.sql delete mode 100644 src/app/api/household/route.ts delete mode 100644 src/components/HouseholdSettings.tsx delete mode 100644 src/components/Onboarding.tsx create mode 100644 src/components/PlanProfileFields.tsx create mode 100644 src/lib/theme.ts diff --git a/prisma/migrations/20260713150000_profile_to_plan_v3/migration.sql b/prisma/migrations/20260713150000_profile_to_plan_v3/migration.sql new file mode 100644 index 0000000..3f13783 --- /dev/null +++ b/prisma/migrations/20260713150000_profile_to_plan_v3/migration.sql @@ -0,0 +1,40 @@ +-- V3-Rework: Das Grundprofil (Haushaltsform, Personen, Inflation) wandert vom Household +-- auf die Plan-Ebene. Bestehende Plaene/Personen sind ohne Household-Bezug bzw. ohne die +-- neuen Pflichtfelder nicht migrierbar -- die (disponiblen) Testdaten werden verworfen. +TRUNCATE TABLE "Person", "Plan" CASCADE; + +-- DropForeignKey +ALTER TABLE "Household" DROP CONSTRAINT "Household_userId_fkey"; + +-- DropForeignKey +ALTER TABLE "Person" DROP CONSTRAINT "Person_householdId_fkey"; + +-- DropForeignKey +ALTER TABLE "Plan" DROP CONSTRAINT "Plan_householdId_fkey"; + +-- DropIndex +DROP INDEX "Person_householdId_role_key"; + +-- AlterTable +ALTER TABLE "Person" DROP COLUMN "householdId", +ADD COLUMN "planId" TEXT NOT NULL; + +-- AlterTable +ALTER TABLE "Plan" DROP COLUMN "householdId", +DROP COLUMN "retirementAgeA", +DROP COLUMN "retirementAgeB", +ADD COLUMN "householdType" "HouseholdType" NOT NULL, +ADD COLUMN "inflationRateDefault" DOUBLE PRECISION NOT NULL, +ADD COLUMN "userId" TEXT NOT NULL; + +-- DropTable +DROP TABLE "Household"; + +-- CreateIndex +CREATE UNIQUE INDEX "Person_planId_role_key" ON "Person"("planId", "role"); + +-- AddForeignKey +ALTER TABLE "Person" ADD CONSTRAINT "Person_planId_fkey" FOREIGN KEY ("planId") REFERENCES "Plan"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Plan" ADD CONSTRAINT "Plan_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index ad150d1..c69e746 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -4,6 +4,10 @@ // ein Werte-Datensatz (ElementPhaseValue) und je Uebergang ein Entscheid-Datensatz // (ElementTransitionValue). Die kategorie-/kontextspezifischen Felder liegen als JSON, // validiert und typisiert in der Applikationsschicht (lib/elements.ts). +// +// Rework 07/2026 (V3): Das Grundprofil (Haushaltsform, Personen, Inflation) wurde vom +// frueheren Household auf die PLAN-Ebene verschoben. Jeder Plan ist selbsttragend und +// definiert seine eigenen Personen (Alter, Pensionsalter) und Inflationsannahme. generator client { provider = "prisma-client" @@ -20,7 +24,7 @@ model User { passwordHash String createdAt DateTime @default(now()) - households Household[] + plans Plan[] } enum HouseholdType { @@ -50,42 +54,27 @@ enum ElementCategory { OTHER_DEBT } -// Ein Haushalt (1 oder 2 Personen), gehoert genau einem Benutzer. -model Household { - id String @id @default(cuid()) - userId String - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - householdType HouseholdType - inflationRateDefault Float - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - persons Person[] - plans Plan[] -} - -// Einzelperson im Haushalt. retirementAge ist die Standard-Annahme (im Plan uebersteuerbar). +// Einzelperson eines Plans. retirementAge ist das (plan-eigene) Pensionsalter. model Person { id String @id @default(cuid()) - householdId String - household Household @relation(fields: [householdId], references: [id], onDelete: Cascade) + planId String + plan Plan @relation(fields: [planId], references: [id], onDelete: Cascade) role PersonRole age Int retirementAge Int - @@unique([householdId, role]) + @@unique([planId, role]) } -// Eine vollstaendige Phasenkette; kann Szenario eines anderen Plans sein. -// retirementAgeA/B uebersteuern das Pensionsalter der jeweiligen Person NUR fuer diesen -// Plan (null = Standard aus Person.retirementAge) -- ermoeglicht Fruehpensions-Szenarien. +// Eine vollstaendige Phasenkette; selbsttragend inkl. Grundprofil (Haushaltsform, +// Personen, Inflationsannahme). Kann Szenario eines anderen Plans sein (Deep-Copy). model Plan { - id String @id @default(cuid()) - householdId String - household Household @relation(fields: [householdId], references: [id], onDelete: Cascade) - name String - retirementAgeA Int? - retirementAgeB Int? + id String @id @default(cuid()) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + name String + householdType HouseholdType + inflationRateDefault Float parentPlanId String? parentPlan Plan? @relation("PlanScenarios", fields: [parentPlanId], references: [id], onDelete: SetNull) @@ -95,6 +84,7 @@ model Plan { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + persons Person[] phases Phase[] elements FinancialElement[] } diff --git a/src/app/api/household/route.ts b/src/app/api/household/route.ts deleted file mode 100644 index 1260003..0000000 --- a/src/app/api/household/route.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { z } from "zod"; -import { prisma } from "@/lib/db"; -import { getHouseholdOrNull, toHouseholdInput } from "@/lib/queries"; -import { getCurrentUserId } from "@/lib/session"; - -const personSchema = z.object({ - role: z.enum(["PERSON_A", "PERSON_B"]), - age: z.number().int().min(0).max(120), - retirementAge: z.number().int().min(0).max(120), -}); - -const householdSchema = z.object({ - householdType: z.enum(["SINGLE", "COUPLE"]), - inflationRateDefault: z.number().min(-20).max(50), - persons: z.array(personSchema).min(1).max(2), -}); - -function validatePersonsForType(data: z.infer) { - if (data.householdType === "SINGLE" && data.persons.length !== 1) { - return "Einzelperson-Haushalt benoetigt genau eine Person."; - } - if (data.householdType === "COUPLE" && data.persons.length !== 2) { - return "Paar-Haushalt benoetigt genau zwei Personen (Person A und Person B)."; - } - return null; -} - -export async function GET() { - const userId = await getCurrentUserId(); - if (!userId) { - return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 }); - } - const household = await getHouseholdOrNull(userId); - return NextResponse.json({ household: household ? toHouseholdInput(household) : null }); -} - -export async function POST(request: NextRequest) { - const userId = await getCurrentUserId(); - if (!userId) { - return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 }); - } - - const existing = await getHouseholdOrNull(userId); - if (existing) { - return NextResponse.json( - { error: "Es existiert bereits ein Haushalt. Bitte PATCH verwenden, um ihn zu bearbeiten." }, - { status: 409 } - ); - } - - const body = await request.json(); - const parsed = householdSchema.safeParse(body); - 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 }); - } - - const household = await prisma.household.create({ - data: { - userId, - householdType: parsed.data.householdType, - inflationRateDefault: parsed.data.inflationRateDefault, - persons: { create: parsed.data.persons }, - }, - include: { persons: true }, - }); - - return NextResponse.json({ household: toHouseholdInput(household) }, { status: 201 }); -} - -export async function PATCH(request: NextRequest) { - const userId = await getCurrentUserId(); - if (!userId) { - return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 }); - } - - const existing = await getHouseholdOrNull(userId); - if (!existing) { - return NextResponse.json({ error: "Kein Haushalt vorhanden." }, { status: 404 }); - } - - const body = await request.json(); - const parsed = householdSchema.safeParse(body); - 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 }); - } - - const household = await prisma.$transaction(async (tx) => { - await tx.person.deleteMany({ where: { householdId: existing.id } }); - return tx.household.update({ - where: { id: existing.id }, - data: { - householdType: parsed.data.householdType, - inflationRateDefault: parsed.data.inflationRateDefault, - persons: { create: parsed.data.persons }, - }, - include: { persons: true }, - }); - }); - - return NextResponse.json({ household: toHouseholdInput(household) }); -} diff --git a/src/app/api/phases/[phaseId]/route.ts b/src/app/api/phases/[phaseId]/route.ts index 240a631..02df3e8 100644 --- a/src/app/api/phases/[phaseId]/route.ts +++ b/src/app/api/phases/[phaseId]/route.ts @@ -1,7 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { z } from "zod"; import { prisma } from "@/lib/db"; -import { getHouseholdOrNull, getOwnedPhase, getOwnedPlan, toPlanInput } from "@/lib/queries"; +import { getOwnedPhase, getOwnedPlan, toPlanInput } from "@/lib/queries"; import { getCurrentUserId } from "@/lib/session"; import { maxPhaseDuration } from "@/lib/calculations"; @@ -29,14 +29,13 @@ export async function PUT( let duration = parsed.data.durationYears; if (duration != null) { // Dauer ans naechste Pensionsereignis kappen (Jahre vor dieser Phase). - const household = await getHouseholdOrNull(userId); const plan = await getOwnedPlan(existing.planId, userId); - if (household && plan) { + if (plan) { const planInput = toPlanInput(plan); const yearsBefore = planInput.phases .filter((p) => p.sequenceNumber < existing.sequenceNumber) .reduce((s, p) => s + p.durationYears, 0); - const cap = maxPhaseDuration(household.persons, planInput, yearsBefore); + const cap = maxPhaseDuration(planInput.persons, yearsBefore); if (cap != null) duration = Math.min(duration, cap); duration = Math.max(1, duration); } diff --git a/src/app/api/plans/[planId]/export/route.ts b/src/app/api/plans/[planId]/export/route.ts index 9f0c0e6..43412d6 100644 --- a/src/app/api/plans/[planId]/export/route.ts +++ b/src/app/api/plans/[planId]/export/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from "next/server"; -import { toHouseholdInput, toPlanInput, getHouseholdOrNull, getOwnedPlan } from "@/lib/queries"; +import { toPlanInput, getOwnedPlan } from "@/lib/queries"; import { getCurrentUserId } from "@/lib/session"; import { computePlan, planToCsv } from "@/lib/calculations"; @@ -12,10 +12,6 @@ export async function GET( return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 }); } const { planId } = await params; - const household = await getHouseholdOrNull(userId); - if (!household) { - return NextResponse.json({ error: "Kein Haushalt vorhanden." }, { status: 400 }); - } const plan = await getOwnedPlan(planId, userId); if (!plan) { @@ -23,7 +19,7 @@ export async function GET( } const planInput = toPlanInput(plan); - const computed = computePlan(planInput, toHouseholdInput(household)); + const computed = computePlan(planInput); const csv = planToCsv(planInput, computed); return new NextResponse(csv, { diff --git a/src/app/api/plans/[planId]/phases/route.ts b/src/app/api/plans/[planId]/phases/route.ts index b233fa2..65c31b7 100644 --- a/src/app/api/plans/[planId]/phases/route.ts +++ b/src/app/api/plans/[planId]/phases/route.ts @@ -1,7 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { z } from "zod"; import { prisma } from "@/lib/db"; -import { getHouseholdOrNull, getOwnedPlan, toHouseholdInput, toPlanInput } from "@/lib/queries"; +import { getOwnedPlan, toPlanInput } from "@/lib/queries"; import { getCurrentUserId } from "@/lib/session"; import { Prisma } from "@/generated/prisma/client"; import { computePlan, maxPhaseDuration } from "@/lib/calculations"; @@ -10,11 +10,12 @@ import { num, type PhaseData } from "@/lib/elements"; const createPhaseSchema = z.object({ name: z.string().min(1).max(120).optional(), durationYears: z.number().int().min(1).max(80).optional(), + inflationRate: z.number().min(-20).max(50).nullable().optional(), }); // Legt eine neue Lebensphase am Ende der Kette an. Die Dauer wird ans naechste -// Pensionsereignis gekappt. Fuer bestehende Elemente werden die Werte 1:1 bzw. mit -// den fortgeschriebenen Endbestaenden aus der Vorphase vorbelegt. +// Pensionsereignis gekappt. Fuer bestehende Elemente werden die editierbaren Felder +// vorbelegt; die Startwerte werden in der Berechnung live aus der Vorphase fortgeschrieben. export async function POST( request: NextRequest, { params }: { params: Promise<{ planId: string }> } @@ -23,8 +24,6 @@ export async function POST( if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 }); const { planId } = await params; - const household = await getHouseholdOrNull(userId); - if (!household) return NextResponse.json({ error: "Kein Haushalt vorhanden." }, { status: 400 }); const plan = await getOwnedPlan(planId, userId); if (!plan) return NextResponse.json({ error: "Plan nicht gefunden." }, { status: 404 }); @@ -32,10 +31,9 @@ export async function POST( const parsed = createPhaseSchema.safeParse(body); if (!parsed.success) return NextResponse.json({ error: "Ungueltige Eingabe." }, { status: 400 }); - const householdInput = toHouseholdInput(household); const planInput = toPlanInput(plan); const yearsBefore = planInput.phases.reduce((s, p) => s + p.durationYears, 0); - const cap = maxPhaseDuration(household.persons, planInput, yearsBefore); + const cap = maxPhaseDuration(planInput.persons, yearsBefore); let duration = parsed.data.durationYears ?? (cap ?? 10); if (cap != null) duration = Math.min(duration, cap); @@ -44,18 +42,15 @@ export async function POST( const nextSequence = planInput.phases.length + 1; // Phasentyp der neuen Phase fuer den Default-Namen bestimmen. - const anyRetiredAtStart = household.persons.some((p) => { - const ra = p.role === "PERSON_A" ? planInput.retirementAgeA ?? p.retirementAge : planInput.retirementAgeB ?? p.retirementAge; - return p.age + yearsBefore >= ra; - }); + const anyRetiredAtStart = planInput.persons.some((p) => p.age + yearsBefore >= p.retirementAge); const defaultName = parsed.data.name ?? (nextSequence === 1 ? "Erste Lebensphase" : anyRetiredAtStart ? "Pensionsphase" : "Erwerbsphase"); - // Endbestaende der bisher letzten Phase (fuer Carry-Vorbelegung). - const prevComputed = planInput.phases.length > 0 ? computePlan(planInput, householdInput) : null; + // Status der Elemente in der bisher letzten Phase (verkauft/getilgt nicht fortfuehren). + const prevComputed = planInput.phases.length > 0 ? computePlan(planInput) : null; const lastPhaseId = planInput.phases.at(-1)?.id; const prevPhase = prevComputed?.phases.find((p) => p.id === lastPhaseId) ?? null; - const prevElemById = new Map((prevPhase?.elements ?? []).map((e) => [e.elementId, e])); + const prevStatusById = new Map((prevPhase?.elements ?? []).map((e) => [e.elementId, e.status])); const phase = await prisma.$transaction(async (tx) => { const created = await tx.phase.create({ @@ -64,15 +59,15 @@ export async function POST( sequenceNumber: nextSequence, name: defaultName, durationYears: duration, + inflationRate: parsed.data.inflationRate ?? undefined, }, }); - // Carry-Vorbelegung fuer bestehende Elemente. + // Vorbelegung der editierbaren Felder bestehender Elemente. for (const e of planInput.elements) { - const prev = prevElemById.get(e.id); - if (prev && prev.status !== "ACTIVE") continue; // verkauft/getilgt -> nicht mehr fortfuehren + if ((prevStatusById.get(e.id) ?? "ACTIVE") !== "ACTIVE") continue; // verkauft/getilgt const prevData: PhaseData = e.phaseValues[lastPhaseId ?? ""] ?? {}; - const data: PhaseData = buildCarryData(e.category, prevData, prev?.endValue); + const data: PhaseData = buildCarryData(e.category, prevData); await tx.elementPhaseValue.create({ data: { elementId: e.id, phaseId: created.id, data: data as Prisma.InputJsonValue }, }); @@ -84,12 +79,10 @@ export async function POST( return NextResponse.json({ phase: { id: phase.id } }, { status: 201 }); } -function buildCarryData( - category: string, - prev: PhaseData, - prevEndValue: number | undefined -): PhaseData { - const endVal = Math.max(0, Math.round(prevEndValue ?? 0)); +// Vorbelegung fuer eine neue Phase: nur die editierbaren Felder werden uebernommen. Start- +// bzw. Restwerte (PK/3a/Vermoegen/Hypothek/Schuld) werden in der Berechnung live aus der +// Vorphase fortgeschrieben und deshalb hier NICHT als Snapshot gespeichert. +function buildCarryData(category: string, prev: PhaseData): PhaseData { switch (category) { case "INCOME": case "EXPENSE": @@ -98,18 +91,13 @@ function buildCarryData( return { gapYears: 0 }; case "PENSION_FUND": case "PILLAR_3A": - return { currentValue: endVal, annualContribution: num(prev.annualContribution), expectedReturn: num(prev.expectedReturn) }; case "OTHER_ASSET": - return { startValue: endVal, annualContribution: num(prev.annualContribution), expectedReturn: num(prev.expectedReturn) }; + return { annualContribution: num(prev.annualContribution), expectedReturn: num(prev.expectedReturn) }; case "REAL_ESTATE": - // endValue = purchase - mortgage; Hypothek fortschreiben ueber purchasePrice - endValue. - return { - purchasePrice: num(prev.purchasePrice), - mortgage: Math.max(0, num(prev.purchasePrice) - endVal), - amortization: num(prev.amortization), - }; + // purchasePrice + amortization bleiben; die Resthypothek wird live fortgeschrieben. + return { purchasePrice: num(prev.purchasePrice), amortization: num(prev.amortization) }; case "OTHER_DEBT": - return { startValue: Math.abs(endVal), annualRepayment: num(prev.annualRepayment) }; + return { annualRepayment: num(prev.annualRepayment) }; default: return {}; } diff --git a/src/app/api/plans/[planId]/route.ts b/src/app/api/plans/[planId]/route.ts index 95432f6..f1e067b 100644 --- a/src/app/api/plans/[planId]/route.ts +++ b/src/app/api/plans/[planId]/route.ts @@ -1,9 +1,10 @@ import { NextRequest, NextResponse } from "next/server"; import { z } from "zod"; import { prisma } from "@/lib/db"; -import { toHouseholdInput, toPlanInput, getHouseholdOrNull, getOwnedPlan } from "@/lib/queries"; +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, @@ -13,24 +14,20 @@ export async function GET( if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 }); const { planId } = await params; - const household = await getHouseholdOrNull(userId); - if (!household) return NextResponse.json({ error: "Kein Haushalt vorhanden." }, { status: 400 }); - const plan = await getOwnedPlan(planId, userId); if (!plan) return NextResponse.json({ error: "Plan nicht gefunden." }, { status: 404 }); - const householdInput = toHouseholdInput(household); const planInput = toPlanInput(plan); - const computed = computePlan(planInput, householdInput); + const computed = computePlan(planInput); return NextResponse.json({ plan: planInput, computed }); } -const patchSchema = z.object({ - name: z.string().min(1).max(120).optional(), - retirementAgeA: z.number().int().min(30).max(100).nullable().optional(), - retirementAgeB: z.number().int().min(30).max(100).nullable().optional(), -}); +// Name allein aendern ODER das ganze Plan-Profil (Haushaltsform/Personen/Inflation). +const patchSchema = z.union([ + z.object({ name: z.string().min(1).max(120) }), + planProfileSchema.extend({ name: z.string().min(1).max(120).optional() }), +]); export async function PATCH( request: NextRequest, @@ -40,20 +37,37 @@ export async function PATCH( if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 }); const { planId } = await params; - const plan = await prisma.plan.findFirst({ where: { id: planId, household: { userId } } }); + 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); 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: parsed.data.name ?? undefined, - retirementAgeA: parsed.data.retirementAgeA === undefined ? undefined : parsed.data.retirementAgeA, - retirementAgeB: parsed.data.retirementAgeB === undefined ? undefined : parsed.data.retirementAgeB, - }, + data: { name: data.name }, }); return NextResponse.json({ plan: { id: updated.id, name: updated.name } }); } @@ -65,7 +79,7 @@ export async function DELETE( 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, household: { userId } } }); + 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 }); diff --git a/src/app/api/plans/[planId]/scenario/route.ts b/src/app/api/plans/[planId]/scenario/route.ts index 1dbb951..f11722f 100644 --- a/src/app/api/plans/[planId]/scenario/route.ts +++ b/src/app/api/plans/[planId]/scenario/route.ts @@ -36,11 +36,14 @@ export async function POST( const newPlanId = await prisma.$transaction(async (tx) => { const newPlan = await tx.plan.create({ data: { - householdId: source.householdId, + userId, name: parsed.data.name, - retirementAgeA: source.retirementAgeA, - retirementAgeB: source.retirementAgeB, + householdType: source.householdType, + inflationRateDefault: source.inflationRateDefault, parentPlanId: source.id, + persons: { + create: source.persons.map((p) => ({ role: p.role, age: p.age, retirementAge: p.retirementAge })), + }, }, }); diff --git a/src/app/api/plans/route.ts b/src/app/api/plans/route.ts index 2ce6034..43da198 100644 --- a/src/app/api/plans/route.ts +++ b/src/app/api/plans/route.ts @@ -1,24 +1,42 @@ import { NextRequest, NextResponse } from "next/server"; import { z } from "zod"; import { prisma } from "@/lib/db"; -import { getHouseholdOrNull } from "@/lib/queries"; import { getCurrentUserId } from "@/lib/session"; -const createPlanSchema = z.object({ - name: z.string().min(1).max(120), +const personSchema = z.object({ + role: z.enum(["PERSON_A", "PERSON_B"]), + age: z.number().int().min(0).max(120), + retirementAge: z.number().int().min(30).max(100), }); +// Ein Plan traegt sein eigenes Grundprofil (Haushaltsform, Personen, Inflation). +export const planProfileSchema = 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); + +export function validatePersonsForType(data: z.infer): string | null { + if (data.householdType === "SINGLE" && data.persons.length !== 1) { + return "Einzelperson-Plan benoetigt genau eine Person."; + } + if (data.householdType === "COUPLE" && data.persons.length !== 2) { + return "Paar-Plan benoetigt genau zwei Personen (Person A und Person B)."; + } + return null; +} + export async function GET() { const userId = await getCurrentUserId(); if (!userId) { return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 }); } - const household = await getHouseholdOrNull(userId); - if (!household) { - return NextResponse.json({ plans: [] }); - } const plans = await prisma.plan.findMany({ - where: { householdId: household.id }, + where: { userId }, orderBy: { createdAt: "asc" }, select: { id: true, @@ -40,23 +58,24 @@ export async function POST(request: NextRequest) { if (!userId) { return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 }); } - const household = await getHouseholdOrNull(userId); - if (!household) { - return NextResponse.json( - { error: "Bitte zuerst das Grundprofil (Onboarding) anlegen." }, - { status: 400 } - ); - } const body = await request.json(); const parsed = createPlanSchema.safeParse(body); 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 }); const plan = await prisma.plan.create({ - data: { householdId: household.id, name: parsed.data.name }, + data: { + userId, + name: parsed.data.name, + householdType: parsed.data.householdType, + inflationRateDefault: parsed.data.inflationRateDefault, + persons: { create: parsed.data.persons }, + }, }); - return NextResponse.json({ plan }, { status: 201 }); + return NextResponse.json({ plan: { id: plan.id } }, { status: 201 }); } diff --git a/src/app/globals.css b/src/app/globals.css index cf130a8..a469e43 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1,26 +1,130 @@ @import "tailwindcss"; -:root { - --background: #f8fafc; - --foreground: #1e293b; +/* ------------------------------------------------------------------------- + Semantische Farb-Tokens. Drei umschaltbare Schemata ueber data-theme am + : "light", "dark", "warm". Ohne explizite Wahl folgt das Standard- + :root der OS-Einstellung (prefers-color-scheme). Umschaltung: lib/theme.ts. + ------------------------------------------------------------------------- */ + +:root, +:root[data-theme="light"] { + --bg: #f8fafc; + --surface: #ffffff; + --surface-2: #f4f4f5; + --input: #ffffff; + --border: #e4e4e7; + --border-strong: #d4d4d8; + --fg: #18181b; + --muted: #52525b; + --faint: #a1a1aa; + --accent: #4f46e5; + --accent-hover: #4338ca; + --accent-fg: #ffffff; + --accent-soft: #eef2ff; + --accent-soft-fg: #4338ca; + --danger: #dc2626; + --danger-soft: #fef2f2; + --success: #059669; + --person-a: #4f46e5; + --person-b: #0ea5e9; +} + +:root[data-theme="dark"] { + --bg: #10131c; + --surface: #18181b; + --surface-2: #27272a; + --input: #27272a; + --border: #27272a; + --border-strong: #3f3f46; + --fg: #f4f4f5; + --muted: #a1a1aa; + --faint: #71717a; + --accent: #6366f1; + --accent-hover: #818cf8; + --accent-fg: #ffffff; + --accent-soft: rgba(99, 102, 241, 0.16); + --accent-soft-fg: #a5b4fc; + --danger: #f87171; + --danger-soft: rgba(220, 38, 38, 0.16); + --success: #34d399; + --person-a: #818cf8; + --person-b: #38bdf8; +} + +/* Warm / Sunset: cremefarbener Grund, Koralle-Akzent, Amber-Sekundaerton. */ +:root[data-theme="warm"] { + --bg: #fbf7f2; + --surface: #fffdfa; + --surface-2: #f5ede3; + --input: #ffffff; + --border: #eadfd2; + --border-strong: #dac9b6; + --fg: #2b2320; + --muted: #6b5d53; + --faint: #a89a8c; + --accent: #e8663c; + --accent-hover: #d2542c; + --accent-fg: #ffffff; + --accent-soft: #fcebe2; + --accent-soft-fg: #b24521; + --danger: #c0392b; + --danger-soft: #fbeae7; + --success: #2e9e7b; + --person-a: #e8663c; + --person-b: #f2a93b; +} + +/* Ohne gespeicherte Wahl der Dunkel-OS-Einstellung folgen. */ +@media (prefers-color-scheme: dark) { + :root:not([data-theme]) { + --bg: #10131c; + --surface: #18181b; + --surface-2: #27272a; + --input: #27272a; + --border: #27272a; + --border-strong: #3f3f46; + --fg: #f4f4f5; + --muted: #a1a1aa; + --faint: #71717a; + --accent: #6366f1; + --accent-hover: #818cf8; + --accent-fg: #ffffff; + --accent-soft: rgba(99, 102, 241, 0.16); + --accent-soft-fg: #a5b4fc; + --danger: #f87171; + --danger-soft: rgba(220, 38, 38, 0.16); + --success: #34d399; + --person-a: #818cf8; + --person-b: #38bdf8; + } } @theme inline { - --color-background: var(--background); - --color-foreground: var(--foreground); + --color-bg: var(--bg); + --color-surface: var(--surface); + --color-surface-2: var(--surface-2); + --color-input: var(--input); + --color-border: var(--border); + --color-border-strong: var(--border-strong); + --color-fg: var(--fg); + --color-muted: var(--muted); + --color-faint: var(--faint); + --color-accent: var(--accent); + --color-accent-hover: var(--accent-hover); + --color-accent-fg: var(--accent-fg); + --color-accent-soft: var(--accent-soft); + --color-accent-soft-fg: var(--accent-soft-fg); + --color-danger: var(--danger); + --color-danger-soft: var(--danger-soft); + --color-success: var(--success); + --color-person-a: var(--person-a); + --color-person-b: var(--person-b); --font-sans: var(--font-geist-sans); --font-mono: var(--font-geist-mono); } -@media (prefers-color-scheme: dark) { - :root { - --background: #10131c; - --foreground: #e2e8f0; - } -} - body { - background: var(--background); - color: var(--foreground); + background: var(--bg); + color: var(--fg); font-family: Arial, Helvetica, sans-serif; } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 9bf6a14..34d350a 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -17,6 +17,9 @@ export const metadata: Metadata = { description: "Persoenliche Finanzplanung ueber Lebensabschnittsphasen (AICDS)", }; +// Setzt data-theme aus localStorage noch vor dem ersten Paint (verhindert FOUC). +const themeInitScript = `try{var t=localStorage.getItem('fpt-theme');if(t==='light'||t==='dark'||t==='warm'){document.documentElement.setAttribute('data-theme',t);}}catch(e){}`; + export default function RootLayout({ children, }: Readonly<{ @@ -24,9 +27,13 @@ export default function RootLayout({ }>) { return ( + +