V3: 3 Farbschemata, Grundprofil auf Plan-Ebene, Erstellungs-Popups, integer-Zahlenfelder mit Beschleunigungs-Spinner, Carry-Forward des Zielwerts, gefuehrter Uebergang
Deploy App / deploy (push) Successful in 1m51s

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-07-13 14:49:42 +02:00
parent b775ab77cb
commit 32adfb4476
32 changed files with 1706 additions and 1290 deletions
@@ -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;
+18 -28
View File
@@ -4,6 +4,10 @@
// ein Werte-Datensatz (ElementPhaseValue) und je Uebergang ein Entscheid-Datensatz // ein Werte-Datensatz (ElementPhaseValue) und je Uebergang ein Entscheid-Datensatz
// (ElementTransitionValue). Die kategorie-/kontextspezifischen Felder liegen als JSON, // (ElementTransitionValue). Die kategorie-/kontextspezifischen Felder liegen als JSON,
// validiert und typisiert in der Applikationsschicht (lib/elements.ts). // 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 { generator client {
provider = "prisma-client" provider = "prisma-client"
@@ -20,7 +24,7 @@ model User {
passwordHash String passwordHash String
createdAt DateTime @default(now()) createdAt DateTime @default(now())
households Household[] plans Plan[]
} }
enum HouseholdType { enum HouseholdType {
@@ -50,42 +54,27 @@ enum ElementCategory {
OTHER_DEBT OTHER_DEBT
} }
// Ein Haushalt (1 oder 2 Personen), gehoert genau einem Benutzer. // Einzelperson eines Plans. retirementAge ist das (plan-eigene) Pensionsalter.
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).
model Person { model Person {
id String @id @default(cuid()) id String @id @default(cuid())
householdId String planId String
household Household @relation(fields: [householdId], references: [id], onDelete: Cascade) plan Plan @relation(fields: [planId], references: [id], onDelete: Cascade)
role PersonRole role PersonRole
age Int age Int
retirementAge Int retirementAge Int
@@unique([householdId, role]) @@unique([planId, role])
} }
// Eine vollstaendige Phasenkette; kann Szenario eines anderen Plans sein. // Eine vollstaendige Phasenkette; selbsttragend inkl. Grundprofil (Haushaltsform,
// retirementAgeA/B uebersteuern das Pensionsalter der jeweiligen Person NUR fuer diesen // Personen, Inflationsannahme). Kann Szenario eines anderen Plans sein (Deep-Copy).
// Plan (null = Standard aus Person.retirementAge) -- ermoeglicht Fruehpensions-Szenarien.
model Plan { model Plan {
id String @id @default(cuid()) id String @id @default(cuid())
householdId String userId String
household Household @relation(fields: [householdId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
name String name String
retirementAgeA Int? householdType HouseholdType
retirementAgeB Int? inflationRateDefault Float
parentPlanId String? parentPlanId String?
parentPlan Plan? @relation("PlanScenarios", fields: [parentPlanId], references: [id], onDelete: SetNull) parentPlan Plan? @relation("PlanScenarios", fields: [parentPlanId], references: [id], onDelete: SetNull)
@@ -95,6 +84,7 @@ model Plan {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
persons Person[]
phases Phase[] phases Phase[]
elements FinancialElement[] elements FinancialElement[]
} }
-110
View File
@@ -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<typeof householdSchema>) {
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) });
}
+3 -4
View File
@@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { z } from "zod"; import { z } from "zod";
import { prisma } from "@/lib/db"; 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 { getCurrentUserId } from "@/lib/session";
import { maxPhaseDuration } from "@/lib/calculations"; import { maxPhaseDuration } from "@/lib/calculations";
@@ -29,14 +29,13 @@ export async function PUT(
let duration = parsed.data.durationYears; let duration = parsed.data.durationYears;
if (duration != null) { if (duration != null) {
// Dauer ans naechste Pensionsereignis kappen (Jahre vor dieser Phase). // Dauer ans naechste Pensionsereignis kappen (Jahre vor dieser Phase).
const household = await getHouseholdOrNull(userId);
const plan = await getOwnedPlan(existing.planId, userId); const plan = await getOwnedPlan(existing.planId, userId);
if (household && plan) { if (plan) {
const planInput = toPlanInput(plan); const planInput = toPlanInput(plan);
const yearsBefore = planInput.phases const yearsBefore = planInput.phases
.filter((p) => p.sequenceNumber < existing.sequenceNumber) .filter((p) => p.sequenceNumber < existing.sequenceNumber)
.reduce((s, p) => s + p.durationYears, 0); .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); if (cap != null) duration = Math.min(duration, cap);
duration = Math.max(1, duration); duration = Math.max(1, duration);
} }
+2 -6
View File
@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from "next/server"; 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 { getCurrentUserId } from "@/lib/session";
import { computePlan, planToCsv } from "@/lib/calculations"; import { computePlan, planToCsv } from "@/lib/calculations";
@@ -12,10 +12,6 @@ export async function GET(
return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 }); return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
} }
const { planId } = await params; 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); const plan = await getOwnedPlan(planId, userId);
if (!plan) { if (!plan) {
@@ -23,7 +19,7 @@ export async function GET(
} }
const planInput = toPlanInput(plan); const planInput = toPlanInput(plan);
const computed = computePlan(planInput, toHouseholdInput(household)); const computed = computePlan(planInput);
const csv = planToCsv(planInput, computed); const csv = planToCsv(planInput, computed);
return new NextResponse(csv, { return new NextResponse(csv, {
+21 -33
View File
@@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { z } from "zod"; import { z } from "zod";
import { prisma } from "@/lib/db"; 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 { getCurrentUserId } from "@/lib/session";
import { Prisma } from "@/generated/prisma/client"; import { Prisma } from "@/generated/prisma/client";
import { computePlan, maxPhaseDuration } from "@/lib/calculations"; import { computePlan, maxPhaseDuration } from "@/lib/calculations";
@@ -10,11 +10,12 @@ import { num, type PhaseData } from "@/lib/elements";
const createPhaseSchema = z.object({ const createPhaseSchema = z.object({
name: z.string().min(1).max(120).optional(), name: z.string().min(1).max(120).optional(),
durationYears: z.number().int().min(1).max(80).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 // 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 // Pensionsereignis gekappt. Fuer bestehende Elemente werden die editierbaren Felder
// den fortgeschriebenen Endbestaenden aus der Vorphase vorbelegt. // vorbelegt; die Startwerte werden in der Berechnung live aus der Vorphase fortgeschrieben.
export async function POST( export async function POST(
request: NextRequest, request: NextRequest,
{ params }: { params: Promise<{ planId: string }> } { params }: { params: Promise<{ planId: string }> }
@@ -23,8 +24,6 @@ export async function POST(
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 }); if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
const { planId } = await params; 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); const plan = await getOwnedPlan(planId, userId);
if (!plan) return NextResponse.json({ error: "Plan nicht gefunden." }, { status: 404 }); if (!plan) return NextResponse.json({ error: "Plan nicht gefunden." }, { status: 404 });
@@ -32,10 +31,9 @@ export async function POST(
const parsed = createPhaseSchema.safeParse(body); const parsed = createPhaseSchema.safeParse(body);
if (!parsed.success) return NextResponse.json({ error: "Ungueltige Eingabe." }, { status: 400 }); if (!parsed.success) return NextResponse.json({ error: "Ungueltige Eingabe." }, { status: 400 });
const householdInput = toHouseholdInput(household);
const planInput = toPlanInput(plan); const planInput = toPlanInput(plan);
const yearsBefore = planInput.phases.reduce((s, p) => s + p.durationYears, 0); 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); let duration = parsed.data.durationYears ?? (cap ?? 10);
if (cap != null) duration = Math.min(duration, cap); if (cap != null) duration = Math.min(duration, cap);
@@ -44,18 +42,15 @@ export async function POST(
const nextSequence = planInput.phases.length + 1; const nextSequence = planInput.phases.length + 1;
// Phasentyp der neuen Phase fuer den Default-Namen bestimmen. // Phasentyp der neuen Phase fuer den Default-Namen bestimmen.
const anyRetiredAtStart = household.persons.some((p) => { const anyRetiredAtStart = planInput.persons.some((p) => p.age + yearsBefore >= p.retirementAge);
const ra = p.role === "PERSON_A" ? planInput.retirementAgeA ?? p.retirementAge : planInput.retirementAgeB ?? p.retirementAge;
return p.age + yearsBefore >= ra;
});
const defaultName = const defaultName =
parsed.data.name ?? (nextSequence === 1 ? "Erste Lebensphase" : anyRetiredAtStart ? "Pensionsphase" : "Erwerbsphase"); parsed.data.name ?? (nextSequence === 1 ? "Erste Lebensphase" : anyRetiredAtStart ? "Pensionsphase" : "Erwerbsphase");
// Endbestaende der bisher letzten Phase (fuer Carry-Vorbelegung). // Status der Elemente in der bisher letzten Phase (verkauft/getilgt nicht fortfuehren).
const prevComputed = planInput.phases.length > 0 ? computePlan(planInput, householdInput) : null; const prevComputed = planInput.phases.length > 0 ? computePlan(planInput) : null;
const lastPhaseId = planInput.phases.at(-1)?.id; const lastPhaseId = planInput.phases.at(-1)?.id;
const prevPhase = prevComputed?.phases.find((p) => p.id === lastPhaseId) ?? null; 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 phase = await prisma.$transaction(async (tx) => {
const created = await tx.phase.create({ const created = await tx.phase.create({
@@ -64,15 +59,15 @@ export async function POST(
sequenceNumber: nextSequence, sequenceNumber: nextSequence,
name: defaultName, name: defaultName,
durationYears: duration, durationYears: duration,
inflationRate: parsed.data.inflationRate ?? undefined,
}, },
}); });
// Carry-Vorbelegung fuer bestehende Elemente. // Vorbelegung der editierbaren Felder bestehender Elemente.
for (const e of planInput.elements) { for (const e of planInput.elements) {
const prev = prevElemById.get(e.id); if ((prevStatusById.get(e.id) ?? "ACTIVE") !== "ACTIVE") continue; // verkauft/getilgt
if (prev && prev.status !== "ACTIVE") continue; // verkauft/getilgt -> nicht mehr fortfuehren
const prevData: PhaseData = e.phaseValues[lastPhaseId ?? ""] ?? {}; 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({ await tx.elementPhaseValue.create({
data: { elementId: e.id, phaseId: created.id, data: data as Prisma.InputJsonValue }, 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 }); return NextResponse.json({ phase: { id: phase.id } }, { status: 201 });
} }
function buildCarryData( // Vorbelegung fuer eine neue Phase: nur die editierbaren Felder werden uebernommen. Start-
category: string, // bzw. Restwerte (PK/3a/Vermoegen/Hypothek/Schuld) werden in der Berechnung live aus der
prev: PhaseData, // Vorphase fortgeschrieben und deshalb hier NICHT als Snapshot gespeichert.
prevEndValue: number | undefined function buildCarryData(category: string, prev: PhaseData): PhaseData {
): PhaseData {
const endVal = Math.max(0, Math.round(prevEndValue ?? 0));
switch (category) { switch (category) {
case "INCOME": case "INCOME":
case "EXPENSE": case "EXPENSE":
@@ -98,18 +91,13 @@ function buildCarryData(
return { gapYears: 0 }; return { gapYears: 0 };
case "PENSION_FUND": case "PENSION_FUND":
case "PILLAR_3A": case "PILLAR_3A":
return { currentValue: endVal, annualContribution: num(prev.annualContribution), expectedReturn: num(prev.expectedReturn) };
case "OTHER_ASSET": 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": case "REAL_ESTATE":
// endValue = purchase - mortgage; Hypothek fortschreiben ueber purchasePrice - endValue. // purchasePrice + amortization bleiben; die Resthypothek wird live fortgeschrieben.
return { return { purchasePrice: num(prev.purchasePrice), amortization: num(prev.amortization) };
purchasePrice: num(prev.purchasePrice),
mortgage: Math.max(0, num(prev.purchasePrice) - endVal),
amortization: num(prev.amortization),
};
case "OTHER_DEBT": case "OTHER_DEBT":
return { startValue: Math.abs(endVal), annualRepayment: num(prev.annualRepayment) }; return { annualRepayment: num(prev.annualRepayment) };
default: default:
return {}; return {};
} }
+32 -18
View File
@@ -1,9 +1,10 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { z } from "zod"; import { z } from "zod";
import { prisma } from "@/lib/db"; 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 { getCurrentUserId } from "@/lib/session";
import { computePlan } from "@/lib/calculations"; import { computePlan } from "@/lib/calculations";
import { planProfileSchema, validatePersonsForType } from "@/app/api/plans/route";
export async function GET( export async function GET(
_request: NextRequest, _request: NextRequest,
@@ -13,24 +14,20 @@ export async function GET(
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 }); if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
const { planId } = await params; 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); const plan = await getOwnedPlan(planId, userId);
if (!plan) return NextResponse.json({ error: "Plan nicht gefunden." }, { status: 404 }); if (!plan) return NextResponse.json({ error: "Plan nicht gefunden." }, { status: 404 });
const householdInput = toHouseholdInput(household);
const planInput = toPlanInput(plan); const planInput = toPlanInput(plan);
const computed = computePlan(planInput, householdInput); const computed = computePlan(planInput);
return NextResponse.json({ plan: planInput, computed }); return NextResponse.json({ plan: planInput, computed });
} }
const patchSchema = z.object({ // Name allein aendern ODER das ganze Plan-Profil (Haushaltsform/Personen/Inflation).
name: z.string().min(1).max(120).optional(), const patchSchema = z.union([
retirementAgeA: z.number().int().min(30).max(100).nullable().optional(), z.object({ name: z.string().min(1).max(120) }),
retirementAgeB: z.number().int().min(30).max(100).nullable().optional(), planProfileSchema.extend({ name: z.string().min(1).max(120).optional() }),
}); ]);
export async function PATCH( export async function PATCH(
request: NextRequest, request: NextRequest,
@@ -40,20 +37,37 @@ export async function PATCH(
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 }); if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
const { planId } = await params; 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 }); if (!plan) return NextResponse.json({ error: "Plan nicht gefunden." }, { status: 404 });
const body = await request.json(); const body = await request.json();
const parsed = patchSchema.safeParse(body); const parsed = patchSchema.safeParse(body);
if (!parsed.success) return NextResponse.json({ error: "Ungueltige Eingabe." }, { status: 400 }); 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({ const updated = await prisma.plan.update({
where: { id: plan.id }, where: { id: plan.id },
data: { data: { name: data.name },
name: parsed.data.name ?? undefined,
retirementAgeA: parsed.data.retirementAgeA === undefined ? undefined : parsed.data.retirementAgeA,
retirementAgeB: parsed.data.retirementAgeB === undefined ? undefined : parsed.data.retirementAgeB,
},
}); });
return NextResponse.json({ plan: { id: updated.id, name: updated.name } }); return NextResponse.json({ plan: { id: updated.id, name: updated.name } });
} }
@@ -65,7 +79,7 @@ export async function DELETE(
const userId = await getCurrentUserId(); 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 { planId } = await params; 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 }); if (!plan) return NextResponse.json({ error: "Plan nicht gefunden." }, { status: 404 });
await prisma.plan.delete({ where: { id: plan.id } }); await prisma.plan.delete({ where: { id: plan.id } });
return NextResponse.json({ ok: true }); return NextResponse.json({ ok: true });
+6 -3
View File
@@ -36,11 +36,14 @@ export async function POST(
const newPlanId = await prisma.$transaction(async (tx) => { const newPlanId = await prisma.$transaction(async (tx) => {
const newPlan = await tx.plan.create({ const newPlan = await tx.plan.create({
data: { data: {
householdId: source.householdId, userId,
name: parsed.data.name, name: parsed.data.name,
retirementAgeA: source.retirementAgeA, householdType: source.householdType,
retirementAgeB: source.retirementAgeB, inflationRateDefault: source.inflationRateDefault,
parentPlanId: source.id, parentPlanId: source.id,
persons: {
create: source.persons.map((p) => ({ role: p.role, age: p.age, retirementAge: p.retirementAge })),
},
}, },
}); });
+36 -17
View File
@@ -1,24 +1,42 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { z } from "zod"; import { z } from "zod";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { getHouseholdOrNull } from "@/lib/queries";
import { getCurrentUserId } from "@/lib/session"; import { getCurrentUserId } from "@/lib/session";
const createPlanSchema = z.object({ const personSchema = z.object({
name: z.string().min(1).max(120), 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<typeof planProfileSchema>): 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() { export async function GET() {
const userId = await getCurrentUserId(); const userId = await getCurrentUserId();
if (!userId) { if (!userId) {
return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 }); 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({ const plans = await prisma.plan.findMany({
where: { householdId: household.id }, where: { userId },
orderBy: { createdAt: "asc" }, orderBy: { createdAt: "asc" },
select: { select: {
id: true, id: true,
@@ -40,23 +58,24 @@ export async function POST(request: NextRequest) {
if (!userId) { if (!userId) {
return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 }); 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 body = await request.json();
const parsed = createPlanSchema.safeParse(body); const parsed = createPlanSchema.safeParse(body);
if (!parsed.success) { if (!parsed.success) {
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 }); 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({ 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 });
} }
+118 -14
View File
@@ -1,26 +1,130 @@
@import "tailwindcss"; @import "tailwindcss";
:root { /* -------------------------------------------------------------------------
--background: #f8fafc; Semantische Farb-Tokens. Drei umschaltbare Schemata ueber data-theme am
--foreground: #1e293b; <html>: "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 { @theme inline {
--color-background: var(--background); --color-bg: var(--bg);
--color-foreground: var(--foreground); --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-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono); --font-mono: var(--font-geist-mono);
} }
@media (prefers-color-scheme: dark) {
:root {
--background: #10131c;
--foreground: #e2e8f0;
}
}
body { body {
background: var(--background); background: var(--bg);
color: var(--foreground); color: var(--fg);
font-family: Arial, Helvetica, sans-serif; font-family: Arial, Helvetica, sans-serif;
} }
+8 -1
View File
@@ -17,6 +17,9 @@ export const metadata: Metadata = {
description: "Persoenliche Finanzplanung ueber Lebensabschnittsphasen (AICDS)", 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({ export default function RootLayout({
children, children,
}: Readonly<{ }: Readonly<{
@@ -24,9 +27,13 @@ export default function RootLayout({
}>) { }>) {
return ( return (
<html <html
lang="en" lang="de"
suppressHydrationWarning
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`} className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
> >
<head>
<script dangerouslySetInnerHTML={{ __html: themeInitScript }} />
</head>
<body className="min-h-full flex flex-col">{children}</body> <body className="min-h-full flex flex-col">{children}</body>
</html> </html>
); );
+14 -14
View File
@@ -46,28 +46,28 @@ function LoginForm() {
} }
const inputClass = const inputClass =
"w-full rounded-lg border border-zinc-300 bg-white py-2 pl-9 pr-3 text-sm text-zinc-900 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/20 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100"; "w-full rounded-lg border border-border bg-input py-2 pl-9 pr-3 text-sm text-fg shadow-sm focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/25";
return ( return (
<div className="flex flex-1 items-center justify-center px-4"> <div className="flex flex-1 items-center justify-center px-4">
<div className="w-full max-w-sm"> <div className="w-full max-w-sm">
<div className="mb-6 flex flex-col items-center gap-2 text-center"> <div className="mb-6 flex flex-col items-center gap-2 text-center">
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-indigo-600 shadow-md dark:bg-indigo-500"> <div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-accent shadow-md">
<PiggyBank className="h-7 w-7 text-white" /> <PiggyBank className="h-7 w-7 text-accent-fg" />
</div> </div>
<h1 className="text-xl font-semibold text-zinc-900 dark:text-zinc-50"> <h1 className="text-xl font-semibold text-fg">
Financial Planning Tool Financial Planning Tool
</h1> </h1>
<p className="text-xs text-zinc-500 dark:text-zinc-400"> <p className="text-xs text-muted">
Persoenliche Finanzplanung ueber Lebensphasen Persoenliche Finanzplanung ueber Lebensphasen
</p> </p>
</div> </div>
<form <form
onSubmit={handleSubmit} onSubmit={handleSubmit}
className="flex flex-col gap-4 rounded-2xl border border-zinc-200/70 bg-white p-6 shadow-sm dark:border-zinc-800 dark:bg-zinc-900" className="flex flex-col gap-4 rounded-2xl border border-border bg-surface p-6 shadow-sm"
> >
<div className="flex rounded-lg bg-zinc-100 p-1 dark:bg-zinc-800"> <div className="flex rounded-lg bg-surface-2 p-1">
{(["login", "register"] as Mode[]).map((m) => ( {(["login", "register"] as Mode[]).map((m) => (
<button <button
key={m} key={m}
@@ -78,8 +78,8 @@ function LoginForm() {
}} }}
className={`flex-1 rounded-md px-3 py-1.5 text-sm font-medium transition-colors ${ className={`flex-1 rounded-md px-3 py-1.5 text-sm font-medium transition-colors ${
mode === m mode === m
? "bg-white text-indigo-600 shadow-sm dark:bg-zinc-900 dark:text-indigo-400" ? "bg-surface text-accent shadow-sm"
: "text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300" : "text-muted hover:text-fg"
}`} }`}
> >
{m === "login" ? "Anmelden" : "Registrieren"} {m === "login" ? "Anmelden" : "Registrieren"}
@@ -88,7 +88,7 @@ function LoginForm() {
</div> </div>
<div className="relative"> <div className="relative">
<User className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-zinc-400" /> <User className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-faint" />
<input <input
type="text" type="text"
autoFocus autoFocus
@@ -100,7 +100,7 @@ function LoginForm() {
/> />
</div> </div>
<div className="relative"> <div className="relative">
<Lock className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-zinc-400" /> <Lock className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-faint" />
<input <input
type="password" type="password"
autoComplete={mode === "login" ? "current-password" : "new-password"} autoComplete={mode === "login" ? "current-password" : "new-password"}
@@ -112,7 +112,7 @@ function LoginForm() {
</div> </div>
{mode === "register" && ( {mode === "register" && (
<div className="relative"> <div className="relative">
<Lock className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-zinc-400" /> <Lock className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-faint" />
<input <input
type="password" type="password"
autoComplete="new-password" autoComplete="new-password"
@@ -123,11 +123,11 @@ function LoginForm() {
/> />
</div> </div>
)} )}
{error && <p className="text-sm text-red-600 dark:text-red-400">{error}</p>} {error && <p className="text-sm text-danger">{error}</p>}
<button <button
type="submit" type="submit"
disabled={loading} disabled={loading}
className="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-500 disabled:opacity-50 dark:bg-indigo-500 dark:hover:bg-indigo-400" 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"
> >
{loading ? "..." : mode === "login" ? "Anmelden" : "Konto erstellen"} {loading ? "..." : mode === "login" ? "Anmelden" : "Konto erstellen"}
</button> </button>
+7 -19
View File
@@ -1,41 +1,29 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Onboarding } from "@/components/Onboarding";
import { AppShell } from "@/components/AppShell"; import { AppShell } from "@/components/AppShell";
import { api } from "@/lib/api-client"; import { api } from "@/lib/api-client";
import type { HouseholdInput } from "@/lib/types";
export default function Home() { export default function Home() {
const [household, setHousehold] = useState<HouseholdInput | null | undefined>(undefined); const [username, setUsername] = useState<string | null>(null);
const [username, setUsername] = useState<string>("");
useEffect(() => { useEffect(() => {
Promise.all([ api
api.get<{ household: HouseholdInput | null }>("/api/household"), .get<{ user: { username: string } }>("/api/auth/me")
api.get<{ user: { username: string } }>("/api/auth/me"), .then((data) => setUsername(data.user.username))
])
.then(([householdData, meData]) => {
setUsername(meData.user.username);
setHousehold(householdData.household);
})
.catch(() => { .catch(() => {
// Session abgelaufen/ungueltig -- Middleware leitet beim naechsten Request um. // Session abgelaufen/ungueltig -- Middleware leitet beim naechsten Request um.
window.location.href = "/login"; window.location.href = "/login";
}); });
}, []); }, []);
if (household === undefined) { if (username === null) {
return ( return (
<div className="flex flex-1 items-center justify-center"> <div className="flex flex-1 items-center justify-center">
<p className="text-sm text-zinc-500">Laedt</p> <p className="text-sm text-muted">Laedt</p>
</div> </div>
); );
} }
if (household === null) { return <AppShell username={username} />;
return <Onboarding onDone={setHousehold} />;
}
return <AppShell initialHousehold={household} username={username} />;
} }
+71 -105
View File
@@ -12,10 +12,10 @@ import {
} from "lucide-react"; } from "lucide-react";
import { PlanView } from "@/components/PlanView"; import { PlanView } from "@/components/PlanView";
import { Dashboard } from "@/components/Dashboard"; import { Dashboard } from "@/components/Dashboard";
import { HouseholdSettings } from "@/components/HouseholdSettings";
import { ProfileMenu } from "@/components/ProfileMenu"; import { ProfileMenu } from "@/components/ProfileMenu";
import { PlanProfileFields, emptyProfileDraft, type ProfileDraft } from "@/components/PlanProfileFields";
import { api } from "@/lib/api-client"; import { api } from "@/lib/api-client";
import type { HouseholdInput, PlanInput } from "@/lib/types"; import type { PlanInput } from "@/lib/types";
import type { PlanComputed } from "@/lib/calculations"; import type { PlanComputed } from "@/lib/calculations";
interface PlanListItem { interface PlanListItem {
@@ -26,15 +26,7 @@ interface PlanListItem {
phases: { id: string; name: string; sequenceNumber: number }[]; phases: { id: string; name: string; sequenceNumber: number }[];
} }
export function AppShell({ export function AppShell({ username }: { username: string }) {
initialHousehold,
username,
}: {
initialHousehold: HouseholdInput;
username: string;
}) {
const [household, setHousehold] = useState(initialHousehold);
const [showSettings, setShowSettings] = useState(false);
const [plans, setPlans] = useState<PlanListItem[]>([]); const [plans, setPlans] = useState<PlanListItem[]>([]);
const [selectedPlanId, setSelectedPlanId] = useState<string | null>(null); const [selectedPlanId, setSelectedPlanId] = useState<string | null>(null);
const [detail, setDetail] = useState<{ plan: PlanInput; computed: PlanComputed } | null>(null); const [detail, setDetail] = useState<{ plan: PlanInput; computed: PlanComputed } | null>(null);
@@ -46,9 +38,7 @@ export function AppShell({
const loadPlans = useCallback(async (preferId?: string) => { const loadPlans = useCallback(async (preferId?: string) => {
const data = await api.get<{ plans: PlanListItem[] }>("/api/plans"); const data = await api.get<{ plans: PlanListItem[] }>("/api/plans");
setPlans(data.plans); setPlans(data.plans);
if (preferId) { if (preferId) setSelectedPlanId(preferId);
setSelectedPlanId(preferId);
}
return data.plans; return data.plans;
}, []); }, []);
@@ -84,9 +74,7 @@ export function AppShell({
if (!confirm("Diesen Plan wirklich loeschen?")) return; if (!confirm("Diesen Plan wirklich loeschen?")) return;
await api.delete(`/api/plans/${id}`); await api.delete(`/api/plans/${id}`);
await loadPlans(); await loadPlans();
if (selectedPlanId === id) { if (selectedPlanId === id) setSelectedPlanId(null);
setSelectedPlanId(null);
}
} }
const activePlan = plans.find((p) => p.id === selectedPlanId) ?? null; const activePlan = plans.find((p) => p.id === selectedPlanId) ?? null;
@@ -94,10 +82,10 @@ export function AppShell({
const sidebar = ( const sidebar = (
<div className="flex h-full flex-col"> <div className="flex h-full flex-col">
<div className="flex items-center gap-2 px-4 py-4"> <div className="flex items-center gap-2 px-4 py-4">
<div className="flex h-8 w-8 items-center justify-center rounded-xl bg-indigo-600 dark:bg-indigo-500"> <div className="flex h-8 w-8 items-center justify-center rounded-xl bg-accent">
<PiggyBank className="h-5 w-5 text-white" /> <PiggyBank className="h-5 w-5 text-accent-fg" />
</div> </div>
<span className="text-sm font-semibold text-zinc-900 dark:text-zinc-50">FPT</span> <span className="text-sm font-semibold text-fg">FPT</span>
</div> </div>
<nav className="flex flex-1 flex-col gap-1 overflow-y-auto px-3 pb-4"> <nav className="flex flex-1 flex-col gap-1 overflow-y-auto px-3 pb-4">
@@ -108,9 +96,7 @@ export function AppShell({
setSidebarOpen(false); setSidebarOpen(false);
}} }}
className={`flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium ${ className={`flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium ${
selectedPlanId === null selectedPlanId === null ? "bg-accent-soft text-accent-soft-fg" : "text-muted hover:bg-surface-2"
? "bg-indigo-50 text-indigo-700 dark:bg-indigo-500/15 dark:text-indigo-300"
: "text-zinc-600 hover:bg-zinc-100 dark:text-zinc-300 dark:hover:bg-zinc-800"
}`} }`}
> >
<LayoutDashboard className="h-4 w-4" /> <LayoutDashboard className="h-4 w-4" />
@@ -118,19 +104,17 @@ export function AppShell({
</button> </button>
<div className="mt-4 flex items-center justify-between px-3"> <div className="mt-4 flex items-center justify-between px-3">
<span className="text-[11px] font-semibold uppercase tracking-wide text-zinc-400">Plaene</span> <span className="text-[11px] font-semibold uppercase tracking-wide text-faint">Plaene</span>
<button <button
type="button" type="button"
onClick={() => setShowNewPlan(true)} onClick={() => setShowNewPlan(true)}
aria-label="Neuen Plan erstellen" aria-label="Neuen Plan erstellen"
className="rounded-md p-1 text-indigo-600 hover:bg-indigo-50 dark:text-indigo-400 dark:hover:bg-indigo-500/10" className="rounded-md p-1 text-accent hover:bg-accent-soft"
> >
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
</button> </button>
</div> </div>
{plans.length === 0 && ( {plans.length === 0 && <p className="px-3 py-2 text-xs text-faint">Noch keine Plaene.</p>}
<p className="px-3 py-2 text-xs text-zinc-400">Noch keine Plaene.</p>
)}
{plans.map((p) => ( {plans.map((p) => (
<button <button
key={p.id} key={p.id}
@@ -140,14 +124,12 @@ export function AppShell({
setSidebarOpen(false); setSidebarOpen(false);
}} }}
className={`flex items-center gap-2 rounded-lg px-3 py-2 text-left text-sm ${ className={`flex items-center gap-2 rounded-lg px-3 py-2 text-left text-sm ${
selectedPlanId === p.id selectedPlanId === p.id ? "bg-accent-soft font-medium text-accent-soft-fg" : "text-muted hover:bg-surface-2"
? "bg-indigo-50 font-medium text-indigo-700 dark:bg-indigo-500/15 dark:text-indigo-300"
: "text-zinc-600 hover:bg-zinc-100 dark:text-zinc-300 dark:hover:bg-zinc-800"
}`} }`}
> >
<FolderKanban className="h-4 w-4 shrink-0" /> <FolderKanban className="h-4 w-4 shrink-0" />
<span className="min-w-0 flex-1 truncate">{p.name}</span> <span className="min-w-0 flex-1 truncate">{p.name}</span>
<span className="text-[11px] text-zinc-400">{p.phases.length}</span> <span className="text-[11px] text-faint">{p.phases.length}</span>
</button> </button>
))} ))}
</nav> </nav>
@@ -157,20 +139,18 @@ export function AppShell({
return ( return (
<div className="flex min-h-screen w-full"> <div className="flex min-h-screen w-full">
{/* Sidebar Desktop */} {/* Sidebar Desktop */}
<aside className="hidden w-60 shrink-0 border-r border-zinc-200 bg-white lg:block dark:border-zinc-800 dark:bg-zinc-900"> <aside className="hidden w-60 shrink-0 border-r border-border bg-surface lg:block">{sidebar}</aside>
{sidebar}
</aside>
{/* Sidebar Mobile (Overlay) */} {/* Sidebar Mobile (Overlay) */}
{sidebarOpen && ( {sidebarOpen && (
<div className="fixed inset-0 z-40 lg:hidden"> <div className="fixed inset-0 z-40 lg:hidden">
<div className="absolute inset-0 bg-black/40" onClick={() => setSidebarOpen(false)} /> <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-zinc-200 bg-white shadow-xl dark:border-zinc-800 dark:bg-zinc-900"> <aside className="absolute left-0 top-0 h-full w-64 border-r border-border bg-surface shadow-xl">
<button <button
type="button" type="button"
onClick={() => setSidebarOpen(false)} onClick={() => setSidebarOpen(false)}
aria-label="Menue schliessen" aria-label="Menue schliessen"
className="absolute right-2 top-3 rounded-md p-1.5 text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-800" className="absolute right-2 top-3 rounded-md p-1.5 text-faint hover:bg-surface-2"
> >
<X className="h-4 w-4" /> <X className="h-4 w-4" />
</button> </button>
@@ -181,33 +161,23 @@ export function AppShell({
{/* Hauptbereich */} {/* Hauptbereich */}
<div className="flex min-w-0 flex-1 flex-col"> <div className="flex min-w-0 flex-1 flex-col">
<header className="flex items-center gap-3 border-b border-zinc-200 bg-white px-4 py-3 dark:border-zinc-800 dark:bg-zinc-900"> <header className="flex items-center gap-3 border-b border-border bg-surface px-4 py-3">
<button <button
type="button" type="button"
onClick={() => setSidebarOpen(true)} onClick={() => setSidebarOpen(true)}
aria-label="Menue oeffnen" aria-label="Menue oeffnen"
className="rounded-lg border border-zinc-200 p-2 text-zinc-600 lg:hidden dark:border-zinc-700 dark:text-zinc-300" className="rounded-lg border border-border p-2 text-muted lg:hidden"
> >
<Menu className="h-4 w-4" /> <Menu className="h-4 w-4" />
</button> </button>
<h1 className="min-w-0 flex-1 truncate text-base font-semibold text-zinc-900 dark:text-zinc-50"> <h1 className="min-w-0 flex-1 truncate text-base font-semibold text-fg">
{activePlan ? activePlan.name : "Uebersicht"} {activePlan ? activePlan.name : "Uebersicht"}
</h1> </h1>
<ProfileMenu username={username} onOpenHouseholdSettings={() => setShowSettings(true)} /> <ProfileMenu username={username} />
</header> </header>
<main className="flex-1 px-4 py-6 lg:px-8"> <main className="flex-1 px-4 py-6 lg:px-8">
{showSettings && ( {loading && <p className="text-sm text-muted">Laedt</p>}
<div className="mb-6">
<HouseholdSettings
household={household}
onUpdated={setHousehold}
onClose={() => setShowSettings(false)}
/>
</div>
)}
{loading && <p className="text-sm text-zinc-500">Laedt</p>}
{!loading && selectedPlanId === null && ( {!loading && selectedPlanId === null && (
<DashboardHome <DashboardHome
@@ -226,7 +196,7 @@ export function AppShell({
<button <button
type="button" type="button"
onClick={() => setShowScenario(true)} onClick={() => setShowScenario(true)}
className="flex items-center gap-1.5 rounded-lg border border-zinc-300 px-3 py-1.5 text-sm font-medium text-zinc-700 hover:bg-zinc-50 dark:border-zinc-700 dark:text-zinc-200 dark:hover:bg-zinc-800" 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" /> <Plus className="h-4 w-4" />
Szenario Szenario
@@ -235,23 +205,16 @@ export function AppShell({
<button <button
type="button" type="button"
onClick={() => handleDeletePlan(selectedPlanId)} onClick={() => handleDeletePlan(selectedPlanId)}
className="flex items-center gap-1.5 rounded-lg border border-zinc-300 px-3 py-1.5 text-sm font-medium text-zinc-500 hover:border-red-200 hover:bg-red-50 hover:text-red-600 dark:border-zinc-700 dark:hover:border-red-500/30 dark:hover:bg-red-950" 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" /> <Trash2 className="h-4 w-4" />
Plan loeschen Plan loeschen
</button> </button>
</div> </div>
<PlanView <PlanView plan={detail.plan} computed={detail.computed} onChanged={refreshCurrent} />
plan={detail.plan}
household={household}
computed={detail.computed}
onChanged={refreshCurrent}
/>
{detail.plan.phases.length > 0 && ( {detail.plan.phases.length > 0 && <Dashboard plan={detail.plan} computed={detail.computed} allPlans={plans} />}
<Dashboard plan={detail.plan} computed={detail.computed} allPlans={plans} />
)}
</div> </div>
)} )}
</main> </main>
@@ -260,10 +223,8 @@ export function AppShell({
{/* Dialoge */} {/* Dialoge */}
{showNewPlan && ( {showNewPlan && (
<PlanDialog <PlanDialog
title="Neuen Plan erstellen" onCreate={async (name, profile) => {
defaultName="Basisplan" const { plan } = await api.post<{ plan: { id: string } }>("/api/plans", { name, ...profile });
onCreate={async (name) => {
const { plan } = await api.post<{ plan: { id: string } }>("/api/plans", { name });
setShowNewPlan(false); setShowNewPlan(false);
await loadPlans(plan.id); await loadPlans(plan.id);
}} }}
@@ -305,10 +266,8 @@ function DashboardHome({
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<div> <div>
<h2 className="text-xl font-semibold text-zinc-900 dark:text-zinc-50"> <h2 className="text-xl font-semibold text-fg">Willkommen, {username}</h2>
Willkommen, {username} <p className="mt-1 text-sm text-muted">
</h2>
<p className="mt-1 text-sm text-zinc-500 dark:text-zinc-400">
Waehlen Sie einen Plan oder erstellen Sie einen neuen, um Ihre finanzielle Zukunft zu planen. Waehlen Sie einen Plan oder erstellen Sie einen neuen, um Ihre finanzielle Zukunft zu planen.
</p> </p>
</div> </div>
@@ -317,16 +276,16 @@ function DashboardHome({
{plans.map((p) => ( {plans.map((p) => (
<div <div
key={p.id} key={p.id}
className="group relative flex cursor-pointer flex-col gap-2 rounded-xl border border-zinc-200/70 bg-white p-4 shadow-sm transition-shadow hover:shadow-md dark:border-zinc-800 dark:bg-zinc-900" 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)} onClick={() => onSelect(p.id)}
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-indigo-100 dark:bg-indigo-500/20"> <div className="flex h-9 w-9 items-center justify-center rounded-lg bg-accent-soft">
<FolderKanban className="h-5 w-5 text-indigo-600 dark:text-indigo-300" /> <FolderKanban className="h-5 w-5 text-accent-soft-fg" />
</div> </div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="truncate font-medium text-zinc-900 dark:text-zinc-100">{p.name}</div> <div className="truncate font-medium text-fg">{p.name}</div>
<div className="text-xs text-zinc-500"> <div className="text-xs text-muted">
{p.phases.length} {p.phases.length === 1 ? "Phase" : "Phasen"} {p.phases.length} {p.phases.length === 1 ? "Phase" : "Phasen"}
{p.parentPlanId ? " · Szenario" : ""} {p.parentPlanId ? " · Szenario" : ""}
</div> </div>
@@ -338,7 +297,7 @@ function DashboardHome({
e.stopPropagation(); e.stopPropagation();
onDelete(p.id); onDelete(p.id);
}} }}
className="rounded-md p-1.5 text-zinc-300 opacity-0 transition-opacity hover:bg-red-50 hover:text-red-600 group-hover:opacity-100 dark:hover:bg-red-950" 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" /> <Trash2 className="h-4 w-4" />
</button> </button>
@@ -349,7 +308,7 @@ function DashboardHome({
<button <button
type="button" type="button"
onClick={onCreate} onClick={onCreate}
className="flex min-h-20 items-center justify-center gap-2 rounded-xl border border-dashed border-indigo-300 bg-indigo-50/40 text-sm font-medium text-indigo-700 hover:bg-indigo-50 dark:border-indigo-500/30 dark:bg-indigo-500/5 dark:text-indigo-300 dark:hover:bg-indigo-500/15" className="flex min-h-20 items-center justify-center gap-2 rounded-xl border border-dashed border-accent bg-accent-soft text-sm font-medium text-accent-soft-fg hover:bg-accent-soft"
> >
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
Neuer Plan Neuer Plan
@@ -360,43 +319,50 @@ function DashboardHome({
} }
function PlanDialog({ function PlanDialog({
title,
defaultName,
onCreate, onCreate,
onClose, onClose,
}: { }: {
title: string; onCreate: (name: string, profile: ProfileDraft) => void;
defaultName: string;
onCreate: (name: string) => void;
onClose: () => void; onClose: () => void;
}) { }) {
const [name, setName] = useState(defaultName); const [name, setName] = useState("Basisplan");
const [draft, setDraft] = useState<ProfileDraft>(emptyProfileDraft);
const [saving, setSaving] = useState(false);
return ( return (
<div className="fixed inset-0 z-40 flex items-center justify-center bg-black/40 px-4" onClick={onClose}> <div className="fixed inset-0 z-40 flex items-start justify-center overflow-y-auto bg-black/40 px-4 py-8" onClick={onClose}>
<div <div
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
className="flex w-full max-w-sm flex-col gap-3 rounded-2xl border border-zinc-200 bg-white p-6 shadow-xl dark:border-zinc-700 dark:bg-zinc-900" 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-zinc-900 dark:text-zinc-50">{title}</h2> <h2 className="text-base font-semibold text-fg">Neuen Plan erstellen</h2>
<input <div>
autoFocus <label className="mb-1 block text-xs font-medium text-muted">Name des Plans</label>
className="w-full rounded-lg border border-zinc-300 px-3 py-2 text-sm focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/20 dark:border-zinc-600 dark:bg-zinc-950" <input
value={name} autoFocus
onChange={(e) => setName(e.target.value)} 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"
placeholder="Name des Plans" value={name}
/> onChange={(e) => setName(e.target.value)}
placeholder="Name des Plans"
/>
</div>
<PlanProfileFields draft={draft} onChange={setDraft} />
<div className="flex gap-2"> <div className="flex gap-2">
<button <button
type="button" type="button"
onClick={() => onCreate(name)} disabled={saving}
className="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-500 dark:bg-indigo-500 dark:hover:bg-indigo-400" onClick={() => {
setSaving(true);
onCreate(name.trim() || "Plan", draft);
}}
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>
<button <button
type="button" type="button"
onClick={onClose} onClick={onClose}
className="rounded-lg border border-zinc-300 px-4 py-2 text-sm font-medium text-zinc-700 hover:bg-zinc-50 dark:border-zinc-700 dark:text-zinc-200 dark:hover:bg-zinc-800" className="rounded-lg border border-border px-4 py-2 text-sm font-medium text-muted hover:bg-surface-2"
> >
Abbrechen Abbrechen
</button> </button>
@@ -421,19 +387,19 @@ function ScenarioDialog({
<div className="fixed inset-0 z-40 flex items-center justify-center bg-black/40 px-4" onClick={onClose}> <div className="fixed inset-0 z-40 flex items-center justify-center bg-black/40 px-4" onClick={onClose}>
<div <div
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
className="flex w-full max-w-sm flex-col gap-3 rounded-2xl border border-zinc-200 bg-white p-6 shadow-xl dark:border-zinc-700 dark:bg-zinc-900" 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-zinc-900 dark:text-zinc-50">Szenario erstellen</h2> <h2 className="text-base font-semibold text-fg">Szenario erstellen</h2>
<input <input
autoFocus autoFocus
className="w-full rounded-lg border border-zinc-300 px-3 py-2 text-sm focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/20 dark:border-zinc-600 dark:bg-zinc-950" 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={name} value={name}
onChange={(e) => setName(e.target.value)} onChange={(e) => setName(e.target.value)}
placeholder="Name des Szenarios" placeholder="Name des Szenarios"
/> />
<label className="text-xs text-zinc-500">Verzweigen ab Phase</label> <label className="text-xs text-muted">Verzweigen ab Phase</label>
<select <select
className="w-full rounded-lg border border-zinc-300 px-3 py-2 text-sm focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/20 dark:border-zinc-600 dark:bg-zinc-950" 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} value={branchFromPhaseId}
onChange={(e) => setBranchFromPhaseId(e.target.value)} onChange={(e) => setBranchFromPhaseId(e.target.value)}
> >
@@ -447,14 +413,14 @@ function ScenarioDialog({
<button <button
type="button" type="button"
onClick={() => onCreate(name, branchFromPhaseId)} onClick={() => onCreate(name, branchFromPhaseId)}
className="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-500 dark:bg-indigo-500 dark:hover:bg-indigo-400" className="rounded-lg bg-accent px-4 py-2 text-sm font-medium text-accent-fg shadow-sm hover:bg-accent-hover"
> >
Erstellen Erstellen
</button> </button>
<button <button
type="button" type="button"
onClick={onClose} onClick={onClose}
className="rounded-lg border border-zinc-300 px-4 py-2 text-sm font-medium text-zinc-700 hover:bg-zinc-50 dark:border-zinc-700 dark:text-zinc-200 dark:hover:bg-zinc-800" className="rounded-lg border border-border px-4 py-2 text-sm font-medium text-muted hover:bg-surface-2"
> >
Abbrechen Abbrechen
</button> </button>
+13 -13
View File
@@ -86,15 +86,15 @@ export function Dashboard({
<StatCard label="Geschaetzter Nachlass" value={computed.nachlass} help="Endvermoegen der letzten Phase - potenziell vererbbar." /> <StatCard label="Geschaetzter Nachlass" value={computed.nachlass} help="Endvermoegen der letzten Phase - potenziell vererbbar." />
</div> </div>
<section className="rounded-xl border border-zinc-200/70 bg-white p-4 shadow-sm dark:border-zinc-800 dark:bg-zinc-900"> <section className="rounded-xl border border-border bg-surface p-4 shadow-sm">
<div className="mb-3 flex flex-wrap items-center justify-between gap-2"> <div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<h3 className="flex items-center gap-1.5 text-sm font-semibold text-zinc-800 dark:text-zinc-100"> <h3 className="flex items-center gap-1.5 text-sm font-semibold text-fg">
<LineChartIcon className="h-4 w-4 text-indigo-500 dark:text-indigo-400" /> <LineChartIcon className="h-4 w-4 text-accent" />
Vermoegensverlauf Vermoegensverlauf
</h3> </h3>
<a <a
href={`/api/plans/${plan.id}/export`} href={`/api/plans/${plan.id}/export`}
className="flex items-center gap-1.5 rounded-lg border border-zinc-300 px-3 py-1.5 text-xs font-medium text-zinc-700 hover:bg-zinc-50 dark:border-zinc-700 dark:text-zinc-200 dark:hover:bg-zinc-800" 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" /> <Download className="h-3.5 w-3.5" />
CSV-Export CSV-Export
@@ -102,9 +102,9 @@ export function Dashboard({
</div> </div>
{otherPlans.length > 0 && ( {otherPlans.length > 0 && (
<div className="mb-3 flex flex-wrap gap-2"> <div className="mb-3 flex flex-wrap gap-2">
<span className="text-xs text-zinc-500">Vergleichen mit:</span> <span className="text-xs text-muted">Vergleichen mit:</span>
{otherPlans.map((p) => ( {otherPlans.map((p) => (
<label key={p.id} className="flex items-center gap-1 text-xs text-zinc-600 dark:text-zinc-300"> <label key={p.id} className="flex items-center gap-1 text-xs text-muted">
<input <input
type="checkbox" type="checkbox"
checked={compareIds.includes(p.id)} checked={compareIds.includes(p.id)}
@@ -118,15 +118,15 @@ export function Dashboard({
<WealthChart series={series} /> <WealthChart series={series} />
</section> </section>
<section className="rounded-xl border border-zinc-200/70 bg-white p-4 shadow-sm dark:border-zinc-800 dark:bg-zinc-900"> <section className="rounded-xl border border-border bg-surface p-4 shadow-sm">
<h3 className="mb-3 flex items-center gap-1.5 text-sm font-semibold text-zinc-800 dark:text-zinc-100"> <h3 className="mb-3 flex items-center gap-1.5 text-sm font-semibold text-fg">
<BarChart3 className="h-4 w-4 text-indigo-500 dark:text-indigo-400" /> <BarChart3 className="h-4 w-4 text-accent" />
Vermoegensaufteilung pro Phase (Endvermoegen) Vermoegensaufteilung pro Phase (Endvermoegen)
</h3> </h3>
<div className="h-72 w-full"> <div className="h-72 w-full">
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
<BarChart data={barData} margin={{ top: 8, right: 16, left: 8, bottom: 8 }}> <BarChart data={barData} margin={{ top: 8, right: 16, left: 8, bottom: 8 }}>
<CartesianGrid strokeDasharray="3 3" className="stroke-zinc-200 dark:stroke-zinc-700" /> <CartesianGrid strokeDasharray="3 3" className="stroke-border" />
<XAxis dataKey="phase" tick={{ fontSize: 11 }} /> <XAxis dataKey="phase" tick={{ fontSize: 11 }} />
<YAxis <YAxis
tick={{ fontSize: 11 }} tick={{ fontSize: 11 }}
@@ -147,11 +147,11 @@ export function Dashboard({
function StatCard({ label, value, help }: { label: string; value: number; help?: string }) { function StatCard({ label, value, help }: { label: string; value: number; help?: string }) {
return ( return (
<div className="rounded-xl border border-zinc-200/70 bg-white p-4 shadow-sm dark:border-zinc-800 dark:bg-zinc-900"> <div className="rounded-xl border border-border bg-surface p-4 shadow-sm">
<div className="text-xs text-zinc-500 dark:text-zinc-400" title={help}> <div className="text-xs text-muted" title={help}>
{label} {label}
</div> </div>
<div className="mt-1 text-xl font-semibold text-indigo-600 dark:text-indigo-400"> <div className="mt-1 text-xl font-semibold text-accent">
{formatChf(value)} CHF {formatChf(value)} CHF
</div> </div>
</div> </div>
+376 -287
View File
@@ -2,7 +2,8 @@
import { useState } from "react"; import { useState } from "react";
import { Trash2 } from "lucide-react"; import { Trash2 } from "lucide-react";
import { MoneyField, NumberField, SelectField } from "@/components/FormField"; import { FieldLabel, MoneyField, NumberField, SelectField } from "@/components/FormField";
import { formatChf } from "@/lib/format";
import { api } from "@/lib/api-client"; import { api } from "@/lib/api-client";
import { CATEGORY_LABELS, num } from "@/lib/elements"; import { CATEGORY_LABELS, num } from "@/lib/elements";
import { PILLAR_3A_MAX_ANNUAL } from "@/lib/constants"; import { PILLAR_3A_MAX_ANNUAL } from "@/lib/constants";
@@ -16,6 +17,10 @@ export interface CellContext {
durationYears: number; durationYears: number;
isRetirementTransition: boolean; isRetirementTransition: boolean;
carriedEndValue: number; // Endwert des Elements in der (Vor-)Phase, fuer Bezugs-Maxima carriedEndValue: number; // Endwert des Elements in der (Vor-)Phase, fuer Bezugs-Maxima
carried: boolean; // Phase >= 2: Startwert wird aus der Vorphase fortgeschrieben
derivedStart: number; // fortgeschriebener Basis-Startwert (read-only Anzeige)
quotaRateMax: number; // Max fuer eine Sparrate/Verzehrrate dieses Elements
capitalMax?: number; // Max fuer Startkapital/Neuinvestition (undefined = kein Cap)
} }
interface Props { interface Props {
@@ -27,6 +32,337 @@ interface Props {
onDeleteElement: () => void; onDeleteElement: () => void;
} }
// Read-only Anzeige eines abgeleiteten (fortgeschriebenen) Wertes.
function DerivedField({ label, value, help }: { label: string; value: number; help?: string }) {
return (
<div>
<FieldLabel label={label} help={help} />
<div className="w-full rounded-lg border border-dashed border-border bg-surface-2 px-2.5 py-1.5 text-sm text-muted">
{formatChf(value)}
</div>
</div>
);
}
// --- Wiederverwendbare Feldgruppen (Detail-Panel, Erstell-Popup, Uebergangs-Review) ---
export function ElementPhaseFields({
element,
context,
pd,
setP,
}: {
element: { category: ElementCategory };
context: CellContext;
pd: PhaseData;
setP: (patch: Partial<PhaseData>) => void;
}) {
const carried = context.carried;
switch (element.category) {
case "INCOME":
return <MoneyField label="Jahreseinkommen (CHF)" value={num(pd.amount)} onChange={(v) => setP({ amount: v })} />;
case "EXPENSE":
return <MoneyField label="Jahresausgaben (CHF)" value={num(pd.amount)} onChange={(v) => setP({ amount: v })} />;
case "AHV":
if (!context.ownerWorking) {
return (
<p className="col-span-2 text-sm text-muted">
Die AHV-Rente wird automatisch aus den bisherigen Ausfalljahren berechnet (siehe Kennzahl in der
Matrix). Bei Ehepaaren greift die Plafonierung auf 150% der Maximalrente.
</p>
);
}
return (
<NumberField
label="Geplante Ausfalljahre"
help="Jahre ohne AHV-Beitraege in dieser Phase. Jedes Ausfalljahr kuerzt die spaetere Rente um 1/44."
value={num(pd.gapYears)}
min={0}
max={context.durationYears}
onChange={(v) => setP({ gapYears: Math.max(0, Math.min(context.durationYears, Math.round(v))) })}
/>
);
case "PENSION_FUND":
if (!context.ownerWorking) {
return (
<p className="col-span-2 text-sm text-muted">
Die PK-Rente wird aus dem beim Pensions-Uebergang gewaehlten Umwandlungssatz berechnet (siehe
Kennzahl). Bei reinem Kapitalbezug erscheint hier &quot;Vollstaendig bezogen&quot;.
</p>
);
}
return (
<>
{carried ? (
<>
<DerivedField label="Startwert (fortgeschrieben)" value={context.derivedStart} help="Endwert der Vorphase, fortgeschrieben." />
<MoneyField
label="Zusatzeinlage aus Kapital (CHF)"
help="Aufstockung aus dem verfuegbaren Kapital dieser Phase."
value={num(pd.additionalInvestment)}
max={context.capitalMax}
onChange={(v) => setP({ additionalInvestment: v })}
/>
</>
) : (
<MoneyField label="Aktueller PK-Wert (CHF)" value={num(pd.currentValue)} max={context.capitalMax} onChange={(v) => setP({ currentValue: v })} />
)}
<MoneyField
label="Jaehrliche Einzahlung (CHF)"
help="Arbeitnehmer- und Arbeitgeberbeitraege. Fliesst NICHT in die Sparquote ein (bereits in den Ausgaben beruecksichtigt)."
value={num(pd.annualContribution)}
onChange={(v) => setP({ annualContribution: v })}
/>
<NumberField label="Erwartete Rendite (%/Jahr)" step={0.1} value={num(pd.expectedReturn)} onChange={(v) => setP({ expectedReturn: v })} />
</>
);
case "PILLAR_3A":
if (!context.ownerWorking) {
return <p className="col-span-2 text-sm text-muted">Die Saeule 3a wird beim Pensions-Uebergang vollstaendig bezogen.</p>;
}
return (
<>
{carried ? (
<>
<DerivedField label="Startwert (fortgeschrieben)" value={context.derivedStart} help="Endwert der Vorphase, fortgeschrieben." />
<MoneyField
label="Zusatzeinlage aus Kapital (CHF)"
help="Aufstockung aus dem verfuegbaren Kapital dieser Phase."
value={num(pd.additionalInvestment)}
max={context.capitalMax}
onChange={(v) => setP({ additionalInvestment: v })}
/>
</>
) : (
<MoneyField label="Aktueller 3a-Wert (CHF)" value={num(pd.currentValue)} max={context.capitalMax} onChange={(v) => setP({ currentValue: v })} />
)}
<MoneyField
label="Jaehrliche Einzahlung (CHF)"
help={`Maximal CHF ${PILLAR_3A_MAX_ANNUAL.toLocaleString("de-CH")} (2026, mit PK) und hoechstens die Sparquote. Wird von der Sparquote abgezogen.`}
value={num(pd.annualContribution)}
max={Math.min(PILLAR_3A_MAX_ANNUAL, context.quotaRateMax)}
onChange={(v) => setP({ annualContribution: v })}
/>
<NumberField label="Erwartete Rendite (%/Jahr)" step={0.1} value={num(pd.expectedReturn)} onChange={(v) => setP({ expectedReturn: v })} />
</>
);
case "REAL_ESTATE":
if (carried) {
return (
<>
<DerivedField label="Startwert Netto (fortgeschrieben)" value={context.derivedStart} help="Kaufpreis minus fortgeschriebene Resthypothek." />
<MoneyField
label="Amortisation (CHF/Jahr)"
help="Jaehrliche Reduktion der Hypothek. Zaehlt gegen die Sparquote."
value={num(pd.amortization)}
max={context.quotaRateMax}
onChange={(v) => setP({ amortization: v })}
/>
</>
);
}
return (
<>
<MoneyField label="Kaufpreis (CHF)" value={num(pd.purchasePrice)} max={context.capitalMax != null ? context.capitalMax + num(pd.mortgage) : undefined} onChange={(v) => setP({ purchasePrice: v })} />
<MoneyField label="Hypothek (CHF)" value={num(pd.mortgage)} onChange={(v) => setP({ mortgage: v })} />
<MoneyField
label="Amortisation (CHF/Jahr)"
help="Jaehrliche Reduktion der Hypothek. Zaehlt gegen die Sparquote."
value={num(pd.amortization)}
max={context.quotaRateMax}
onChange={(v) => setP({ amortization: v })}
/>
</>
);
case "OTHER_ASSET":
return (
<>
{carried ? (
<>
<DerivedField label="Startwert (fortgeschrieben)" value={context.derivedStart} help="Endwert der Vorphase, fortgeschrieben." />
<MoneyField
label="Zusatzinvestition aus Kapital (CHF)"
help="Neuinvestition aus dem verfuegbaren Kapital dieser Phase."
value={num(pd.additionalInvestment)}
max={context.capitalMax}
onChange={(v) => setP({ additionalInvestment: v })}
/>
</>
) : (
<MoneyField label="Startwert (CHF)" value={num(pd.startValue)} max={context.capitalMax} onChange={(v) => setP({ startValue: v })} />
)}
<NumberField label="Erwartete Rendite (%/Jahr)" step={0.1} value={num(pd.expectedReturn)} onChange={(v) => setP({ expectedReturn: v })} />
<MoneyField
label={context.isConsumption ? "Jaehrliche Bezugsrate (CHF)" : "Jaehrlicher Sparbeitrag (CHF)"}
help={
context.isConsumption
? "In dieser Verzehrphase deckt dieser Betrag die Verzehrquote (max. die Verzehrquote)."
: "Wird von der Sparquote abgezogen (max. die Sparquote)."
}
value={num(pd.annualContribution)}
max={context.quotaRateMax}
onChange={(v) => setP({ annualContribution: v })}
/>
</>
);
case "OTHER_DEBT":
return (
<>
{carried ? (
<DerivedField label="Restschuld (fortgeschrieben)" value={Math.abs(context.derivedStart)} help="Fortgeschriebene Restschuld aus der Vorphase." />
) : (
<MoneyField label="Restschuld (CHF)" value={num(pd.startValue)} onChange={(v) => setP({ startValue: v })} />
)}
<MoneyField
label="Jaehrliche Tilgung (CHF)"
help="Zaehlt gegen die Sparquote (max. die Sparquote)."
value={num(pd.annualRepayment)}
max={context.quotaRateMax}
onChange={(v) => setP({ annualRepayment: v })}
/>
</>
);
}
}
export function ElementTransitionFields({
element,
context,
td,
setT,
}: {
element: { category: ElementCategory };
context: CellContext;
td: TransitionData;
setT: (patch: Partial<TransitionData>) => void;
}) {
switch (element.category) {
case "INCOME":
case "EXPENSE":
case "AHV":
return (
<p className="col-span-2 text-sm text-muted">
Fuer diese Kategorie gibt es im Uebergang keine Eingaben. Die Werte werden 1:1 in die naechste
Lebensphase uebernommen und koennen dort angepasst werden.
</p>
);
case "PENSION_FUND":
if (context.isRetirementTransition) {
const mode = td.payoutMode ?? "PENSION";
return (
<>
<SelectField
label="Bezugsart bei Pensionierung"
value={mode}
onChange={(v: "CAPITAL" | "PENSION" | "COMBI") => setT({ payoutMode: v })}
options={[
{ value: "PENSION", label: "Rente" },
{ value: "CAPITAL", label: "Kapitalbezug" },
{ value: "COMBI", label: "Kombination" },
]}
/>
{(mode === "PENSION" || mode === "COMBI") && (
<NumberField
label="Umwandlungssatz (%)"
help="Jaehrliche Rente = verrentetes Kapital x Umwandlungssatz."
step={0.1}
value={num(td.conversionRate, 6)}
onChange={(v) => setT({ conversionRate: v })}
/>
)}
{(mode === "CAPITAL" || mode === "COMBI") && (
<NumberField label="Kapitalbezugssteuer (%)" step={0.5} value={num(td.capitalTaxRate, 8)} onChange={(v) => setT({ capitalTaxRate: v })} />
)}
{mode === "COMBI" && (
<MoneyField
label="Davon Kapitalbezug (CHF)"
help={`Der Rest wird verrentet. Maximal ${formatChf(context.carriedEndValue)}.`}
value={num(td.capitalAmount)}
max={context.carriedEndValue}
onChange={(v) => setT({ capitalAmount: v })}
/>
)}
</>
);
}
return (
<MoneyField
label="PK-Bezug (CHF)"
help={`Optionaler Bezug. Maximal ${formatChf(context.carriedEndValue)} (Endwert der Vorphase).`}
value={num(td.withdrawal)}
max={context.carriedEndValue}
onChange={(v) => setT({ withdrawal: v })}
/>
);
case "PILLAR_3A":
if (context.isRetirementTransition) {
return (
<NumberField
label="Kapitalbezugssteuer (%)"
help="Die Saeule 3a wird bei Pensionierung vollstaendig bezogen."
step={0.5}
value={num(td.capitalTaxRate, 8)}
onChange={(v) => setT({ capitalTaxRate: v })}
/>
);
}
return (
<MoneyField
label="3a-Bezug (CHF)"
help={`Maximal ${formatChf(context.carriedEndValue)} (Endwert der Vorphase).`}
value={num(td.withdrawal)}
max={context.carriedEndValue}
onChange={(v) => setT({ withdrawal: v })}
/>
);
case "REAL_ESTATE": {
const decision = td.decision ?? "HOLD";
return (
<>
<SelectField
label="Entscheidung"
value={decision}
onChange={(v: "HOLD" | "SELL") => setT({ decision: v })}
options={[
{ value: "HOLD", label: "Halten" },
{ value: "SELL", label: "Verkaufen" },
]}
/>
{decision === "SELL" && (
<>
<MoneyField label="Verkaufspreis (CHF)" value={num(td.salePrice)} onChange={(v) => setT({ salePrice: v })} />
<NumberField label="Grundstueckgewinnsteuer (%)" step={1} value={num(td.saleTaxRate, 20)} onChange={(v) => setT({ saleTaxRate: v })} />
</>
)}
</>
);
}
case "OTHER_ASSET": {
const decision = td.decision ?? "HOLD";
return (
<SelectField
label="Entscheidung"
value={decision}
onChange={(v: "HOLD" | "SELL") => setT({ decision: v })}
options={[
{ value: "HOLD", label: "Halten" },
{ value: "SELL", label: "Verkaufen" },
]}
/>
);
}
case "OTHER_DEBT":
return (
<MoneyField
label="Sofortige Tilgung (CHF)"
help="Wird sofort getilgt und vom verfuegbaren Kapital der naechsten Phase abgezogen."
value={num(td.immediateRepayment)}
onChange={(v) => setT({ immediateRepayment: v })}
/>
);
}
}
export function ElementDetail({ element, context, phaseData, transitionData, onSaved, onDeleteElement }: Props) { export function ElementDetail({ element, context, phaseData, transitionData, onSaved, onDeleteElement }: Props) {
const [pd, setPd] = useState<PhaseData>({ ...phaseData }); const [pd, setPd] = useState<PhaseData>({ ...phaseData });
const [td, setTd] = useState<TransitionData>({ ...transitionData }); const [td, setTd] = useState<TransitionData>({ ...transitionData });
@@ -52,43 +388,6 @@ export function ElementDetail({ element, context, phaseData, transitionData, onS
} }
} }
return (
<div className="flex flex-col gap-4">
<div className="flex items-start justify-between gap-2">
<div>
<div className="text-xs font-semibold uppercase tracking-wide text-indigo-600 dark:text-indigo-400">
{CATEGORY_LABELS[element.category]}
{isTransition ? " · Uebergang" : ""}
</div>
<div className="text-base font-semibold text-zinc-900 dark:text-zinc-100">{element.name}</div>
</div>
<button
type="button"
onClick={onDeleteElement}
className="flex items-center gap-1 rounded-lg border border-zinc-300 px-2 py-1 text-xs text-zinc-500 hover:border-red-200 hover:bg-red-50 hover:text-red-600 dark:border-zinc-700 dark:hover:bg-red-950"
>
<Trash2 className="h-3.5 w-3.5" /> Element loeschen
</button>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{isTransition ? renderTransitionFields() : renderPhaseFields()}
</div>
{error && <p className="text-sm text-red-600 dark:text-red-400">{error}</p>}
<div>
<button
type="button"
disabled={saving}
onClick={save}
className="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-500 disabled:opacity-50 dark:bg-indigo-500 dark:hover:bg-indigo-400"
>
{saving ? "Speichern..." : "Speichern"}
</button>
</div>
</div>
);
function setP(patch: Partial<PhaseData>) { function setP(patch: Partial<PhaseData>) {
setPd((prev) => ({ ...prev, ...patch })); setPd((prev) => ({ ...prev, ...patch }));
} }
@@ -96,254 +395,44 @@ export function ElementDetail({ element, context, phaseData, transitionData, onS
setTd((prev) => ({ ...prev, ...patch })); setTd((prev) => ({ ...prev, ...patch }));
} }
function renderPhaseFields() { return (
switch (element.category) { <div className="flex flex-col gap-4">
case "INCOME": <div className="flex items-start justify-between gap-2">
return ( <div>
<MoneyField label="Jahreseinkommen (CHF)" value={num(pd.amount)} onChange={(v) => setP({ amount: v })} /> <div className="text-xs font-semibold uppercase tracking-wide text-accent-soft-fg">
); {CATEGORY_LABELS[element.category]}
case "EXPENSE": {isTransition ? " · Uebergang" : ""}
return ( </div>
<MoneyField label="Jahresausgaben (CHF)" value={num(pd.amount)} onChange={(v) => setP({ amount: v })} /> <div className="text-base font-semibold text-fg">{element.name}</div>
); </div>
case "AHV": <button
if (!context.ownerWorking) { type="button"
return ( onClick={onDeleteElement}
<p className="col-span-2 text-sm text-zinc-500 dark:text-zinc-400"> className="flex items-center gap-1 rounded-lg border border-border px-2 py-1 text-xs text-muted hover:border-danger hover:bg-danger-soft hover:text-danger"
Die AHV-Rente wird automatisch aus den bisherigen Ausfalljahren berechnet (siehe Kennzahl in der >
Matrix). Bei Ehepaaren greift die Plafonierung auf 150% der Maximalrente. <Trash2 className="h-3.5 w-3.5" /> Element loeschen
</p> </button>
); </div>
}
return (
<NumberField
label="Geplante Ausfalljahre"
help="Jahre ohne AHV-Beitraege in dieser Phase. Jedes Ausfalljahr kuerzt die spaetere Rente um 1/44."
value={num(pd.gapYears)}
min={0}
max={context.durationYears}
onChange={(v) => setP({ gapYears: Math.max(0, Math.min(context.durationYears, Math.round(v))) })}
/>
);
case "PENSION_FUND":
if (!context.ownerWorking) {
return (
<p className="col-span-2 text-sm text-zinc-500 dark:text-zinc-400">
Die PK-Rente wird aus dem beim Pensions-Uebergang gewaehlten Umwandlungssatz berechnet (siehe
Kennzahl). Bei reinem Kapitalbezug erscheint hier &quot;Vollstaendig bezogen&quot;.
</p>
);
}
return (
<>
<MoneyField label="Aktueller PK-Wert (CHF)" value={num(pd.currentValue)} onChange={(v) => setP({ currentValue: v })} />
<MoneyField
label="Jaehrliche Einzahlung (CHF)"
help="Arbeitnehmer- und Arbeitgeberbeitraege. Fliesst NICHT in die Sparquote ein (bereits in den Ausgaben beruecksichtigt)."
value={num(pd.annualContribution)}
onChange={(v) => setP({ annualContribution: v })}
/>
<NumberField label="Erwartete Rendite (%/Jahr)" step={0.1} value={num(pd.expectedReturn)} onChange={(v) => setP({ expectedReturn: v })} />
</>
);
case "PILLAR_3A":
if (!context.ownerWorking) {
return (
<p className="col-span-2 text-sm text-zinc-500 dark:text-zinc-400">
Die Saeule 3a wird beim Pensions-Uebergang vollstaendig bezogen.
</p>
);
}
return (
<>
<MoneyField label="Aktueller 3a-Wert (CHF)" value={num(pd.currentValue)} onChange={(v) => setP({ currentValue: v })} />
<NumberField
label="Jaehrliche Einzahlung (CHF)"
help={`Maximal CHF ${PILLAR_3A_MAX_ANNUAL.toLocaleString("de-CH")} (2026, mit PK). Wird von der Sparquote abgezogen. Schritte von 100.`}
step={100}
min={0}
max={PILLAR_3A_MAX_ANNUAL}
value={num(pd.annualContribution)}
onChange={(v) => setP({ annualContribution: Math.max(0, Math.min(PILLAR_3A_MAX_ANNUAL, Math.round(v / 100) * 100)) })}
/>
<NumberField label="Erwartete Rendite (%/Jahr)" step={0.1} value={num(pd.expectedReturn)} onChange={(v) => setP({ expectedReturn: v })} />
</>
);
case "REAL_ESTATE":
return (
<>
<MoneyField label="Kaufpreis (CHF)" value={num(pd.purchasePrice)} onChange={(v) => setP({ purchasePrice: v })} />
<MoneyField label="Hypothek (CHF)" value={num(pd.mortgage)} onChange={(v) => setP({ mortgage: v })} />
<MoneyField
label="Amortisation (CHF/Jahr)"
help="Jaehrliche Reduktion der Hypothek."
value={num(pd.amortization)}
onChange={(v) => setP({ amortization: v })}
/>
</>
);
case "OTHER_ASSET":
return (
<>
<MoneyField label="Startwert (CHF)" value={num(pd.startValue)} onChange={(v) => setP({ startValue: v })} />
<NumberField label="Erwartete Rendite (%/Jahr)" step={0.1} value={num(pd.expectedReturn)} onChange={(v) => setP({ expectedReturn: v })} />
<MoneyField
label={context.isConsumption ? "Jaehrliche Bezugsrate (CHF)" : "Jaehrlicher Sparbeitrag (CHF)"}
help={
context.isConsumption
? "In dieser Verzehrphase wird dieser Betrag jaehrlich entnommen und deckt die Verzehrquote."
: "Wird von der Sparquote abgezogen."
}
value={num(pd.annualContribution)}
onChange={(v) => setP({ annualContribution: v })}
/>
</>
);
case "OTHER_DEBT":
return (
<>
<MoneyField label="Restschuld (CHF)" value={num(pd.startValue)} onChange={(v) => setP({ startValue: v })} />
<MoneyField label="Jaehrliche Tilgung (CHF)" value={num(pd.annualRepayment)} onChange={(v) => setP({ annualRepayment: v })} />
</>
);
}
}
function renderTransitionFields() { <div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
switch (element.category) { {isTransition ? (
case "INCOME": <ElementTransitionFields element={element} context={context} td={td} setT={setT} />
case "EXPENSE": ) : (
case "AHV": <ElementPhaseFields element={element} context={context} pd={pd} setP={setP} />
return ( )}
<p className="col-span-2 text-sm text-zinc-500 dark:text-zinc-400"> </div>
Fuer diese Kategorie gibt es im Uebergang keine Eingaben. Die Werte werden 1:1 in die naechste
Lebensphase uebernommen und koennen dort angepasst werden. {error && <p className="text-sm text-danger">{error}</p>}
</p> <div>
); <button
case "PENSION_FUND": type="button"
if (context.isRetirementTransition) { disabled={saving}
const mode = td.payoutMode ?? "PENSION"; onClick={save}
return ( 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"
<> >
<SelectField {saving ? "Speichern..." : "Speichern"}
label="Bezugsart bei Pensionierung" </button>
value={mode} </div>
onChange={(v: "CAPITAL" | "PENSION" | "COMBI") => setT({ payoutMode: v })} </div>
options={[ );
{ value: "PENSION", label: "Rente" },
{ value: "CAPITAL", label: "Kapitalbezug" },
{ value: "COMBI", label: "Kombination" },
]}
/>
{(mode === "PENSION" || mode === "COMBI") && (
<NumberField
label="Umwandlungssatz (%)"
help="Jaehrliche Rente = verrentetes Kapital x Umwandlungssatz."
step={0.1}
value={num(td.conversionRate, 6)}
onChange={(v) => setT({ conversionRate: v })}
/>
)}
{(mode === "CAPITAL" || mode === "COMBI") && (
<NumberField
label="Kapitalbezugssteuer (%)"
step={0.5}
value={num(td.capitalTaxRate, 8)}
onChange={(v) => setT({ capitalTaxRate: v })}
/>
)}
{mode === "COMBI" && (
<MoneyField
label="Davon Kapitalbezug (CHF)"
help={`Der Rest wird verrentet. Maximal ${context.carriedEndValue.toLocaleString("de-CH")}.`}
value={num(td.capitalAmount)}
max={context.carriedEndValue}
onChange={(v) => setT({ capitalAmount: v })}
/>
)}
</>
);
}
return (
<MoneyField
label="PK-Bezug (CHF)"
help={`Optionaler Bezug. Maximal ${context.carriedEndValue.toLocaleString("de-CH")} (Endwert der Vorphase).`}
value={num(td.withdrawal)}
max={context.carriedEndValue}
onChange={(v) => setT({ withdrawal: v })}
/>
);
case "PILLAR_3A":
if (context.isRetirementTransition) {
return (
<NumberField
label="Kapitalbezugssteuer (%)"
help="Die Saeule 3a wird bei Pensionierung vollstaendig bezogen."
step={0.5}
value={num(td.capitalTaxRate, 8)}
onChange={(v) => setT({ capitalTaxRate: v })}
/>
);
}
return (
<MoneyField
label="3a-Bezug (CHF)"
help={`Maximal ${context.carriedEndValue.toLocaleString("de-CH")} (Endwert der Vorphase).`}
value={num(td.withdrawal)}
max={context.carriedEndValue}
onChange={(v) => setT({ withdrawal: v })}
/>
);
case "REAL_ESTATE": {
const decision = td.decision ?? "HOLD";
return (
<>
<SelectField
label="Entscheidung"
value={decision}
onChange={(v: "HOLD" | "SELL") => setT({ decision: v })}
options={[
{ value: "HOLD", label: "Halten" },
{ value: "SELL", label: "Verkaufen" },
]}
/>
{decision === "SELL" && (
<>
<MoneyField label="Verkaufspreis (CHF)" value={num(td.salePrice)} onChange={(v) => setT({ salePrice: v })} />
<NumberField
label="Grundstueckgewinnsteuer (%)"
step={1}
value={num(td.saleTaxRate, 20)}
onChange={(v) => setT({ saleTaxRate: v })}
/>
</>
)}
</>
);
}
case "OTHER_ASSET": {
const decision = td.decision ?? "HOLD";
return (
<SelectField
label="Entscheidung"
value={decision}
onChange={(v: "HOLD" | "SELL") => setT({ decision: v })}
options={[
{ value: "HOLD", label: "Halten" },
{ value: "SELL", label: "Verkaufen" },
]}
/>
);
}
case "OTHER_DEBT":
return (
<MoneyField
label="Sofortige Tilgung (CHF)"
help="Wird sofort getilgt und vom verfuegbaren Kapital der naechsten Phase abgezogen."
value={num(td.immediateRepayment)}
onChange={(v) => setT({ immediateRepayment: v })}
/>
);
}
}
} }
+57 -33
View File
@@ -3,14 +3,14 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { ChevronDown, ChevronUp } from "lucide-react"; import { ChevronDown, ChevronUp } from "lucide-react";
import { InfoBubble } from "@/components/InfoBubble"; import { InfoBubble } from "@/components/InfoBubble";
import { floorToThousand, formatChf, parseChfInput } from "@/lib/format"; import { formatChf, parseChfInput } from "@/lib/format";
const baseInputClass = const baseInputClass =
"w-full rounded-lg border border-zinc-300 bg-white px-2.5 py-1.5 text-sm text-zinc-900 shadow-sm transition-colors focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/20 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100"; "w-full rounded-lg border border-border bg-input px-2.5 py-1.5 text-sm text-fg shadow-sm transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/25";
export function FieldLabel({ label, help }: { label: string; help?: string }) { export function FieldLabel({ label, help }: { label: string; help?: string }) {
return ( return (
<label className="mb-1 flex items-center text-xs font-medium text-zinc-600 dark:text-zinc-400"> <label className="mb-1 flex items-center text-xs font-medium text-muted">
{label} {label}
{help && <InfoBubble text={help} />} {help && <InfoBubble text={help} />}
</label> </label>
@@ -44,32 +44,34 @@ export function NumberField({
step={step ?? "any"} step={step ?? "any"}
min={min} min={min}
max={max} max={max}
onFocus={(e) => e.target.select()} onFocus={(e) => e.currentTarget.select()}
onChange={(e) => onChange(e.target.valueAsNumber || 0)} onChange={(e) => onChange(e.target.valueAsNumber || 0)}
/> />
</div> </div>
); );
} }
// Roher Betrags-Input ohne Label (fuer kompakte Tabellenzellen o.ae.). Zeigt den Wert // Ganzzahliges Betragsfeld. Zeigt den Wert unfokussiert mit 1'000er-Trennzeichen an,
// formatiert mit 1'000er-Trennzeichen an, solange das Feld nicht fokussiert ist, rundet // akzeptiert fokussiert beliebige ganze Zahlen (keine Nachkommastellen) und bietet
// beim Verlassen des Feldes auf ein Vielfaches von 1'000 ABwaerts (siehe lib/format.ts) // Pfeil-Buttons mit Klick-und-Halten-BESCHLEUNIGUNG (1 -> 10 -> 100 -> 1'000 -> ...).
// und bietet Pfeil-Buttons zum Erhoehen/Verringern in 1'000er-Schritten. // Optionales `max` (und `min`) klammern die Eingabe hart (Live-Cap).
// Optionales `max` kappt Eingaben live auf das verfuegbare Budget (z. B. Sparquote).
export function MoneyInput({ export function MoneyInput({
value, value,
onChange, onChange,
className, className,
min = 0,
max, max,
}: { }: {
value: number; value: number;
onChange: (value: number) => void; onChange: (value: number) => void;
className?: string; className?: string;
min?: number;
max?: number; max?: number;
}) { }) {
const [focused, setFocused] = useState(false); const [focused, setFocused] = useState(false);
const [text, setText] = useState(() => String(Math.floor(value || 0))); const [text, setText] = useState(() => String(Math.round(value || 0)));
const valueRef = useRef(value); const valueRef = useRef(value);
const inputRef = useRef<HTMLInputElement | null>(null);
useEffect(() => { useEffect(() => {
valueRef.current = value; valueRef.current = value;
}, [value]); }, [value]);
@@ -77,13 +79,14 @@ export function MoneyInput({
const holdInterval = useRef<ReturnType<typeof setInterval> | null>(null); const holdInterval = useRef<ReturnType<typeof setInterval> | null>(null);
function clamp(v: number): number { function clamp(v: number): number {
let result = Math.max(0, v); let result = Math.round(v);
if (max != null) result = Math.min(result, Math.max(0, floorToThousand(max))); if (min != null) result = Math.max(min, result);
if (max != null) result = Math.min(result, Math.round(max));
return result; return result;
} }
function step(delta: number) { function stepBy(delta: number) {
onChange(clamp(floorToThousand(valueRef.current) + delta)); onChange(clamp(Math.round(valueRef.current) + delta));
} }
function stopHold() { function stopHold() {
@@ -97,72 +100,91 @@ export function MoneyInput({
} }
} }
// Klick-und-Halten: sofortiger erster Schritt, nach kurzer Verzoegerung // Klick = 1 Schritt. Halten: nach 400 ms Wiederholung im 70-ms-Takt, wobei die
// fortlaufende Wiederholung, bis losgelassen wird. // Schrittweite mit der Haltedauer waechst (immer schneller).
function startHold(delta: number) { function startHold(sign: number) {
step(delta); stepBy(sign);
const start = Date.now();
holdTimeout.current = setTimeout(() => { holdTimeout.current = setTimeout(() => {
holdInterval.current = setInterval(() => step(delta), 100); holdInterval.current = setInterval(() => {
const s = (Date.now() - start) / 1000;
const mag = s < 1.5 ? 1 : s < 3 ? 10 : s < 4.5 ? 100 : s < 6 ? 1000 : 10000;
stepBy(sign * mag);
}, 70);
}, 400); }, 400);
} }
useEffect(() => stopHold, []); useEffect(() => stopHold, []);
const arrowBtn =
"flex flex-1 items-center justify-center text-faint hover:bg-accent-soft hover:text-accent-soft-fg";
return ( return (
<div className={`relative ${className ?? "w-full"}`}> <div className={`relative ${className ?? "w-full"}`}>
<input <input
ref={inputRef}
type="text" type="text"
inputMode="numeric" inputMode="numeric"
className={`${baseInputClass} w-full pr-6`} className={`${baseInputClass} w-full pr-6`}
value={focused ? text : formatChf(value)} value={focused ? text : formatChf(value)}
onFocus={() => { onFocus={() => {
setFocused(true); setFocused(true);
// Default-0 sofort leeren, damit man direkt lostippen kann. const current = Math.round(value || 0);
const current = Math.floor(value || 0); // Default-0 sofort leeren; sonst Wert markieren, damit man ihn ueberschreiben kann.
setText(current === 0 ? "" : String(current)); setText(current === 0 ? "" : String(current));
requestAnimationFrame(() => inputRef.current?.select());
}}
onChange={(e) => setText(e.target.value.replace(min < 0 ? /[^0-9-]/g : /[^0-9]/g, ""))}
onKeyDown={(e) => {
if (e.key === "ArrowUp") {
e.preventDefault();
stepBy(e.shiftKey ? 100 : 1);
} else if (e.key === "ArrowDown") {
e.preventDefault();
stepBy(e.shiftKey ? -100 : -1);
}
}} }}
onChange={(e) => setText(e.target.value.replace(/[^0-9-]/g, ""))}
onBlur={() => { onBlur={() => {
setFocused(false); setFocused(false);
onChange(clamp(floorToThousand(parseChfInput(text)))); onChange(clamp(parseChfInput(text)));
}} }}
/> />
<div className="absolute inset-y-0 right-0 flex w-6 flex-col overflow-hidden rounded-r-lg border-l border-zinc-300 dark:border-zinc-700"> <div className="absolute inset-y-0 right-0 flex w-6 flex-col overflow-hidden rounded-r-lg border-l border-border">
<button <button
type="button" type="button"
tabIndex={-1} tabIndex={-1}
aria-label="Um 1'000 erhoehen" aria-label="Erhoehen"
onMouseDown={(e) => { onMouseDown={(e) => {
e.preventDefault(); e.preventDefault();
startHold(1000); startHold(1);
}} }}
onMouseUp={stopHold} onMouseUp={stopHold}
onMouseLeave={stopHold} onMouseLeave={stopHold}
onTouchStart={(e) => { onTouchStart={(e) => {
e.preventDefault(); e.preventDefault();
startHold(1000); startHold(1);
}} }}
onTouchEnd={stopHold} onTouchEnd={stopHold}
className="flex flex-1 items-center justify-center text-zinc-500 hover:bg-indigo-50 hover:text-indigo-600 dark:text-zinc-400 dark:hover:bg-zinc-700 dark:hover:text-indigo-400" className={arrowBtn}
> >
<ChevronUp className="h-3 w-3" /> <ChevronUp className="h-3 w-3" />
</button> </button>
<button <button
type="button" type="button"
tabIndex={-1} tabIndex={-1}
aria-label="Um 1'000 verringern" aria-label="Verringern"
onMouseDown={(e) => { onMouseDown={(e) => {
e.preventDefault(); e.preventDefault();
startHold(-1000); startHold(-1);
}} }}
onMouseUp={stopHold} onMouseUp={stopHold}
onMouseLeave={stopHold} onMouseLeave={stopHold}
onTouchStart={(e) => { onTouchStart={(e) => {
e.preventDefault(); e.preventDefault();
startHold(-1000); startHold(-1);
}} }}
onTouchEnd={stopHold} onTouchEnd={stopHold}
className="flex flex-1 items-center justify-center border-t border-zinc-300 text-zinc-500 hover:bg-indigo-50 hover:text-indigo-600 dark:border-zinc-700 dark:text-zinc-400 dark:hover:bg-zinc-700 dark:hover:text-indigo-400" className={`${arrowBtn} border-t border-border`}
> >
<ChevronDown className="h-3 w-3" /> <ChevronDown className="h-3 w-3" />
</button> </button>
@@ -176,18 +198,20 @@ export function MoneyField({
help, help,
value, value,
onChange, onChange,
min,
max, max,
}: { }: {
label: string; label: string;
help?: string; help?: string;
value: number; value: number;
onChange: (value: number) => void; onChange: (value: number) => void;
min?: number;
max?: number; max?: number;
}) { }) {
return ( return (
<div> <div>
<FieldLabel label={label} help={help} /> <FieldLabel label={label} help={help} />
<MoneyInput value={value} onChange={onChange} max={max} /> <MoneyInput value={value} onChange={onChange} min={min} max={max} />
</div> </div>
); );
} }
-105
View File
@@ -1,105 +0,0 @@
"use client";
import { useState } from "react";
import { NumberField, SelectField } from "@/components/FormField";
import { api } from "@/lib/api-client";
import type { HouseholdInput, HouseholdType } from "@/lib/types";
export function HouseholdSettings({
household,
onUpdated,
onClose,
}: {
household: HouseholdInput;
onUpdated: (household: HouseholdInput) => void;
onClose: () => void;
}) {
const [householdType, setHouseholdType] = useState<HouseholdType>(household.householdType);
const [inflationRateDefault, setInflationRateDefault] = useState(household.inflationRateDefault);
const [persons, setPersons] = useState(
household.persons.map((p) => ({ role: p.role, age: p.age, retirementAge: p.retirementAge }))
);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
function handleTypeChange(type: HouseholdType) {
setHouseholdType(type);
if (type === "SINGLE") {
setPersons((p) => p.slice(0, 1));
} else if (persons.length < 2) {
setPersons((p) => [...p, { role: "PERSON_B" as const, age: 35, retirementAge: 65 }]);
}
}
async function handleSubmit() {
setSaving(true);
setError(null);
try {
const { household: updated } = await api.patch<{ household: HouseholdInput }>("/api/household", {
householdType,
inflationRateDefault,
persons,
});
onUpdated(updated);
onClose();
} catch (e) {
setError(e instanceof Error ? e.message : "Speichern fehlgeschlagen.");
} finally {
setSaving(false);
}
}
return (
<div className="flex flex-col gap-4 rounded-xl border border-zinc-200/70 bg-white p-4 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<SelectField
label="Haushaltsform"
value={householdType}
onChange={handleTypeChange}
options={[
{ value: "SINGLE", label: "Einzelperson" },
{ value: "COUPLE", label: "Paar (zwei Personen)" },
]}
/>
{persons.map((person, index) => (
<div key={person.role} className="grid grid-cols-2 gap-3">
<NumberField
label={`Alter (${person.role === "PERSON_A" ? "Person A" : "Person B"})`}
value={person.age}
onChange={(v) => setPersons((prev) => prev.map((p, i) => (i === index ? { ...p, age: v } : p)))}
/>
<NumberField
label="Geplantes Pensionierungsalter"
value={person.retirementAge}
onChange={(v) =>
setPersons((prev) => prev.map((p, i) => (i === index ? { ...p, retirementAge: v } : p)))
}
/>
</div>
))}
<NumberField
label="Erwartete Inflationsrate (%)"
value={inflationRateDefault}
step={0.1}
onChange={setInflationRateDefault}
/>
{error && <p className="text-sm text-red-600 dark:text-red-400">{error}</p>}
<div className="flex gap-2">
<button
type="button"
disabled={saving}
onClick={handleSubmit}
className="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-500 disabled:opacity-50 dark:bg-indigo-500 dark:hover:bg-indigo-400"
>
{saving ? "Speichern..." : "Speichern"}
</button>
<button
type="button"
onClick={onClose}
className="rounded-lg border border-zinc-300 px-4 py-2 text-sm font-medium text-zinc-700 hover:bg-zinc-50 dark:border-zinc-700 dark:text-zinc-200 dark:hover:bg-zinc-800"
>
Abbrechen
</button>
</div>
</div>
);
}
+2 -2
View File
@@ -14,12 +14,12 @@ export function InfoBubble({ text }: { text: string }) {
onMouseEnter={() => setOpen(true)} onMouseEnter={() => setOpen(true)}
onMouseLeave={() => setOpen(false)} onMouseLeave={() => setOpen(false)}
onClick={() => setOpen((o) => !o)} onClick={() => setOpen((o) => !o)}
className="flex h-4 w-4 items-center justify-center rounded-full bg-indigo-100 text-indigo-500 hover:bg-indigo-200 dark:bg-indigo-500/20 dark:text-indigo-300 dark:hover:bg-indigo-500/30" className="flex h-4 w-4 items-center justify-center rounded-full bg-accent-soft text-accent-soft-fg hover:opacity-80"
> >
<Info className="h-2.5 w-2.5" strokeWidth={2.5} /> <Info className="h-2.5 w-2.5" strokeWidth={2.5} />
</button> </button>
{open && ( {open && (
<span className="absolute left-1/2 top-6 z-20 w-64 -translate-x-1/2 rounded-lg border border-zinc-200 bg-white p-2.5 text-xs leading-snug text-zinc-700 shadow-lg dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-200"> <span className="absolute left-1/2 top-6 z-20 w-64 -translate-x-1/2 rounded-lg border border-border bg-surface p-2.5 text-xs leading-snug text-fg shadow-lg">
{text} {text}
</span> </span>
)} )}
-119
View File
@@ -1,119 +0,0 @@
"use client";
import { useState } from "react";
import { PiggyBank } from "lucide-react";
import { api } from "@/lib/api-client";
import { NumberField, SelectField } from "@/components/FormField";
import type { HouseholdInput, HouseholdType, PersonRole } from "@/lib/types";
interface PersonDraft {
role: PersonRole;
age: number;
retirementAge: number;
}
export function Onboarding({ onDone }: { onDone: (household: HouseholdInput) => void }) {
const [householdType, setHouseholdType] = useState<HouseholdType>("SINGLE");
const [inflationRateDefault, setInflationRateDefault] = useState(1.5);
const [persons, setPersons] = useState<PersonDraft[]>([
{ role: "PERSON_A", age: 35, retirementAge: 65 },
]);
const [error, setError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
function handleTypeChange(type: HouseholdType) {
setHouseholdType(type);
if (type === "SINGLE") {
setPersons((p) => p.slice(0, 1));
} else if (persons.length < 2) {
setPersons((p) => [...p, { role: "PERSON_B", age: 35, retirementAge: 65 }]);
}
}
function updatePerson(index: number, patch: Partial<PersonDraft>) {
setPersons((prev) => prev.map((p, i) => (i === index ? { ...p, ...patch } : p)));
}
async function handleSubmit() {
setSaving(true);
setError(null);
try {
const { household } = await api.post<{ household: HouseholdInput }>("/api/household", {
householdType,
inflationRateDefault,
persons,
});
onDone(household);
} catch (e) {
setError(e instanceof Error ? e.message : "Unbekannter Fehler.");
} finally {
setSaving(false);
}
}
return (
<div className="mx-auto flex w-full max-w-xl flex-1 flex-col justify-center px-6 py-16">
<div className="mb-2 flex items-center gap-2">
<PiggyBank className="h-7 w-7 text-indigo-600 dark:text-indigo-400" />
<h1 className="text-2xl font-semibold text-zinc-900 dark:text-zinc-50">
Willkommen beim Financial Planning Tool
</h1>
</div>
<p className="mb-8 text-sm text-zinc-600 dark:text-zinc-400">
Bevor es losgeht, brauchen wir ein paar Eckdaten zu Ihrem Haushalt.
</p>
<div className="flex flex-col gap-5 rounded-xl border border-zinc-200/70 bg-white p-6 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<SelectField
label="Haushaltsform"
help="Waehlen Sie, ob Sie alleine oder gemeinsam mit einer Partnerin / einem Partner planen."
value={householdType}
onChange={handleTypeChange}
options={[
{ value: "SINGLE", label: "Einzelperson" },
{ value: "COUPLE", label: "Paar (zwei Personen)" },
]}
/>
{persons.map((person, index) => (
<div key={person.role} className="grid grid-cols-2 gap-3 rounded-xl border border-zinc-100 bg-zinc-50/60 p-3 dark:border-zinc-800 dark:bg-zinc-800/30">
<div className="col-span-2 text-xs font-semibold uppercase tracking-wide text-zinc-500">
{householdType === "COUPLE" ? (person.role === "PERSON_A" ? "Person A" : "Person B") : "Ihre Angaben"}
</div>
<NumberField
label="Aktuelles Alter"
help="Ihr heutiges Alter in vollen Jahren."
value={person.age}
onChange={(v) => updatePerson(index, { age: v })}
/>
<NumberField
label="Geplantes Pensionierungsalter"
help="Das Alter, in dem Sie voraussichtlich in Rente gehen moechten. Dient nur der groben Orientierung bei der Phasenplanung."
value={person.retirementAge}
onChange={(v) => updatePerson(index, { retirementAge: v })}
/>
</div>
))}
<NumberField
label="Erwartete Inflationsrate (%)"
help="Langfristige Annahme zur jaehrlichen Teuerung. Kann pro Lebensphase individuell ueberschrieben werden."
value={inflationRateDefault}
step={0.1}
onChange={setInflationRateDefault}
/>
{error && <p className="text-sm text-red-600 dark:text-red-400">{error}</p>}
<button
type="button"
disabled={saving}
onClick={handleSubmit}
className="mt-2 rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-500 disabled:opacity-50 dark:bg-indigo-500 dark:hover:bg-indigo-400"
>
{saving ? "Speichern..." : "Weiter"}
</button>
</div>
</div>
);
}
+9 -9
View File
@@ -4,20 +4,20 @@ import { useState } from "react";
import { Trash2 } from "lucide-react"; import { Trash2 } from "lucide-react";
import { NumberField, TextField } from "@/components/FormField"; import { NumberField, TextField } from "@/components/FormField";
import { api } from "@/lib/api-client"; import { api } from "@/lib/api-client";
import type { HouseholdInput, PhaseInput } from "@/lib/types"; import type { PhaseInput } from "@/lib/types";
export function PhaseDetail({ export function PhaseDetail({
phase, phase,
maxDurationYears, maxDurationYears,
isLast, isLast,
household, inflationDefault,
onSaved, onSaved,
onDeleted, onDeleted,
}: { }: {
phase: PhaseInput; phase: PhaseInput;
maxDurationYears: number | null; maxDurationYears: number | null;
isLast: boolean; isLast: boolean;
household: HouseholdInput; inflationDefault: number;
onSaved: () => void; onSaved: () => void;
onDeleted: () => void; onDeleted: () => void;
}) { }) {
@@ -55,14 +55,14 @@ export function PhaseDetail({
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="text-xs font-semibold uppercase tracking-wide text-indigo-600 dark:text-indigo-400"> <div className="text-xs font-semibold uppercase tracking-wide text-accent-soft-fg">
Lebensphase Lebensphase
</div> </div>
{isLast && ( {isLast && (
<button <button
type="button" type="button"
onClick={remove} onClick={remove}
className="flex items-center gap-1 rounded-lg border border-zinc-300 px-2 py-1 text-xs text-zinc-500 hover:border-red-200 hover:bg-red-50 hover:text-red-600 dark:border-zinc-700 dark:hover:bg-red-950" className="flex items-center gap-1 rounded-lg border border-border px-2 py-1 text-xs text-muted hover:border-danger hover:bg-danger-soft hover:text-danger"
> >
<Trash2 className="h-3.5 w-3.5" /> Phase loeschen <Trash2 className="h-3.5 w-3.5" /> Phase loeschen
</button> </button>
@@ -81,20 +81,20 @@ export function PhaseDetail({
/> />
<NumberField <NumberField
label="Inflationsrate (%)" label="Inflationsrate (%)"
help="Ueberschreibt die Standardannahme aus dem Grundprofil." help="Ueberschreibt die Standardannahme aus dem Plan-Grundprofil."
value={inflationRate ?? household.inflationRateDefault} value={inflationRate ?? inflationDefault}
step={0.1} step={0.1}
onChange={setInflationRate} onChange={setInflationRate}
/> />
</div> </div>
{error && <p className="text-sm text-red-600 dark:text-red-400">{error}</p>} {error && <p className="text-sm text-danger">{error}</p>}
<div> <div>
<button <button
type="button" type="button"
disabled={saving} disabled={saving}
onClick={save} onClick={save}
className="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-500 disabled:opacity-50 dark:bg-indigo-500 dark:hover:bg-indigo-400" 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"
> >
{saving ? "Speichern..." : "Speichern"} {saving ? "Speichern..." : "Speichern"}
</button> </button>
+93
View File
@@ -0,0 +1,93 @@
"use client";
import { NumberField, SelectField } from "@/components/FormField";
import type { HouseholdType, PersonRole } from "@/lib/types";
export interface ProfileDraft {
householdType: HouseholdType;
inflationRateDefault: number;
persons: { role: PersonRole; age: number; retirementAge: number }[];
}
export function emptyProfileDraft(): ProfileDraft {
return {
householdType: "SINGLE",
inflationRateDefault: 1.5,
persons: [{ role: "PERSON_A", age: 35, retirementAge: 65 }],
};
}
// Gemeinsame Formularfelder fuer das Grundprofil eines Plans (Haushaltsform, Personen,
// Inflation). Wird beim Plan-Erstellen und in den Plan-Einstellungen verwendet.
export function PlanProfileFields({
draft,
onChange,
}: {
draft: ProfileDraft;
onChange: (next: ProfileDraft) => void;
}) {
function setType(type: HouseholdType) {
if (type === "SINGLE") {
onChange({ ...draft, householdType: type, persons: draft.persons.slice(0, 1) });
} else {
const persons =
draft.persons.length < 2
? [...draft.persons, { role: "PERSON_B" as PersonRole, age: 35, retirementAge: 65 }]
: draft.persons;
onChange({ ...draft, householdType: type, persons });
}
}
function updatePerson(index: number, patch: Partial<ProfileDraft["persons"][number]>) {
onChange({
...draft,
persons: draft.persons.map((p, i) => (i === index ? { ...p, ...patch } : p)),
});
}
return (
<div className="flex flex-col gap-4">
<SelectField
label="Haushaltsform"
help="Waehlen Sie, ob Sie alleine oder gemeinsam mit einer Partnerin / einem Partner planen."
value={draft.householdType}
onChange={setType}
options={[
{ value: "SINGLE", label: "Einzelperson" },
{ value: "COUPLE", label: "Paar (zwei Personen)" },
]}
/>
{draft.persons.map((person, index) => (
<div key={person.role} className="grid grid-cols-2 gap-3 rounded-xl border border-border bg-surface-2 p-3">
<div className="col-span-2 text-xs font-semibold uppercase tracking-wide text-faint">
{draft.householdType === "COUPLE" ? (person.role === "PERSON_A" ? "Person A" : "Person B") : "Ihre Angaben"}
</div>
<NumberField
label="Aktuelles Alter"
value={person.age}
min={0}
max={120}
onChange={(v) => updatePerson(index, { age: Math.round(v) })}
/>
<NumberField
label="Pensionierungsalter"
help="Steuert die Ableitung des Phasentyps (Erwerb/Pension)."
value={person.retirementAge}
min={30}
max={100}
onChange={(v) => updatePerson(index, { retirementAge: Math.round(v) })}
/>
</div>
))}
<NumberField
label="Erwartete Inflationsrate (%)"
help="Langfristige Annahme zur jaehrlichen Teuerung. Kann pro Lebensphase individuell ueberschrieben werden."
value={draft.inflationRateDefault}
step={0.1}
onChange={(v) => onChange({ ...draft, inflationRateDefault: v })}
/>
</div>
);
}
+581 -148
View File
@@ -12,13 +12,21 @@ import {
Landmark, Landmark,
PiggyBank, PiggyBank,
Plus, Plus,
Settings2,
ShoppingCart, ShoppingCart,
TrendingUp, TrendingUp,
Wallet, Wallet,
X,
} from "lucide-react"; } from "lucide-react";
import { Timeline } from "@/components/Timeline"; import { Timeline } from "@/components/Timeline";
import { ElementDetail, type CellContext } from "@/components/ElementDetail"; import {
ElementDetail,
ElementPhaseFields,
ElementTransitionFields,
type CellContext,
} from "@/components/ElementDetail";
import { PhaseDetail } from "@/components/PhaseDetail"; import { PhaseDetail } from "@/components/PhaseDetail";
import { PlanProfileFields, type ProfileDraft } from "@/components/PlanProfileFields";
import { api } from "@/lib/api-client"; import { api } from "@/lib/api-client";
import { formatChf } from "@/lib/format"; import { formatChf } from "@/lib/format";
import { import {
@@ -27,12 +35,10 @@ import {
PERSON_ONLY_CATEGORIES, PERSON_ONLY_CATEGORIES,
num, num,
type ElementCategory, type ElementCategory,
type PhaseData,
} from "@/lib/elements"; } from "@/lib/elements";
import { resolveRetirementAge, type PhaseComputed, type PlanComputed } from "@/lib/calculations"; import type { PhaseComputed, PlanComputed } from "@/lib/calculations";
import type { ElementInput, HouseholdInput, PlanInput } from "@/lib/types"; import type { ElementInput, PlanInput } from "@/lib/types";
const PERSON_A_COLOR = "#4f46e5";
const PERSON_B_COLOR = "#0ea5e9";
const CATEGORY_ICON: Record<ElementCategory, React.ReactNode> = { const CATEGORY_ICON: Record<ElementCategory, React.ReactNode> = {
INCOME: <Wallet className="h-4 w-4" />, INCOME: <Wallet className="h-4 w-4" />,
@@ -53,6 +59,14 @@ const TRANSITION_CATEGORIES: ElementCategory[] = [
"OTHER_DEBT", "OTHER_DEBT",
]; ];
const VALUE_CATEGORIES: ElementCategory[] = [
"PENSION_FUND",
"PILLAR_3A",
"REAL_ESTATE",
"OTHER_ASSET",
"OTHER_DEBT",
];
type Column = type Column =
| { kind: "phase"; phase: PhaseComputed } | { kind: "phase"; phase: PhaseComputed }
| { kind: "transition"; fromPhase: PhaseComputed; toPhase: PhaseComputed }; | { kind: "transition"; fromPhase: PhaseComputed; toPhase: PhaseComputed };
@@ -64,18 +78,19 @@ type Selection =
export function PlanView({ export function PlanView({
plan, plan,
household,
computed, computed,
onChanged, onChanged,
}: { }: {
plan: PlanInput; plan: PlanInput;
household: HouseholdInput;
computed: PlanComputed; computed: PlanComputed;
onChanged: () => void; onChanged: () => void;
}) { }) {
const [selected, setSelected] = useState<Selection | null>(null); const [selected, setSelected] = useState<Selection | null>(null);
const [collapsedCats, setCollapsedCats] = useState<Set<ElementCategory>>(new Set()); const [collapsedCats, setCollapsedCats] = useState<Set<ElementCategory>>(new Set());
const [showAdd, setShowAdd] = useState(false); const [showAdd, setShowAdd] = useState(false);
const [showAddPhase, setShowAddPhase] = useState(false);
const [showSettings, setShowSettings] = useState(false);
const [reviewFromPhaseId, setReviewFromPhaseId] = useState<string | null>(null);
const columns = useMemo<Column[]>(() => { const columns = useMemo<Column[]>(() => {
const cols: Column[] = []; const cols: Column[] = [];
@@ -88,12 +103,12 @@ export function PlanView({
return cols; return cols;
}, [computed.phases]); }, [computed.phases]);
const personAxes = household.persons.map((p) => ({ const personAxes = plan.persons.map((p) => ({
role: p.role, role: p.role,
label: p.role === "PERSON_A" ? "Person A" : "Person B", label: p.role === "PERSON_A" ? "Person A" : "Person B",
currentAge: p.age, currentAge: p.age,
retirementAge: resolveRetirementAge(p.role, plan, p.retirementAge), retirementAge: p.retirementAge,
color: p.role === "PERSON_A" ? PERSON_A_COLOR : PERSON_B_COLOR, color: p.role === "PERSON_A" ? "var(--person-a)" : "var(--person-b)",
})); }));
const elementsByCategory = useMemo(() => { const elementsByCategory = useMemo(() => {
@@ -116,48 +131,117 @@ export function PlanView({
return !!before?.working && !!after && !after.working; return !!before?.working && !!after && !after.working;
} }
async function handleAddPhase() { // Baut den Kontext (inkl. Live-Caps) fuer eine Phasenzelle.
await api.post(`/api/plans/${plan.id}/phases`, {}); function buildPhaseContext(phase: PhaseComputed, element: ElementInput): CellContext {
const ce = computedElement(phase.id, element.id);
const ownerWorking =
element.ownerRole && element.ownerRole !== "HOUSEHOLD"
? phase.persons.find((p) => p.role === element.ownerRole)?.working ?? false
: phase.type !== "PENSION";
const otherQuota = phase.quotaAllocated - (ce?.quotaUse ?? 0);
const quotaRateMax = Math.max(0, Math.abs(phase.quota) - otherQuota);
const capitalMax =
phase.availableCapital == null
? undefined
: Math.max(0, phase.availableCapital - (phase.availableCapitalUsed - (ce?.capitalUse ?? 0)));
const derivedStart = (ce?.startValue ?? 0) - (ce?.capitalUse ?? 0);
return {
kind: "phase",
phaseId: phase.id,
ownerWorking,
isConsumption: phase.isConsumption,
durationYears: phase.durationYears,
isRetirementTransition: false,
carriedEndValue: ce?.endValue ?? 0,
carried: ce?.carried ?? false,
derivedStart,
quotaRateMax,
capitalMax,
};
}
function buildTransitionContext(fromPhase: PhaseComputed, toPhase: PhaseComputed | undefined, element: ElementInput): CellContext {
const ce = computedElement(fromPhase.id, element.id);
return {
kind: "transition",
phaseId: fromPhase.id,
ownerWorking: true,
isConsumption: fromPhase.isConsumption,
durationYears: fromPhase.durationYears,
isRetirementTransition: toPhase ? isRetirementTransition(element, fromPhase, toPhase) : false,
carriedEndValue: ce?.endValue ?? 0,
carried: ce?.carried ?? false,
derivedStart: 0,
quotaRateMax: 0,
capitalMax: undefined,
};
}
// Anzahl offener (noch nicht getroffener) Uebergangs-Entscheide an einer Grenze.
function transitionOpenCount(fromPhase: PhaseComputed, toPhase: PhaseComputed): number {
let n = 0;
for (const el of plan.elements) {
if (!TRANSITION_CATEGORIES.includes(el.category)) continue;
const ce = computedElement(fromPhase.id, el.id);
if (ce && ce.status !== "ACTIVE") continue;
const td = el.transitionValues[fromPhase.id] ?? {};
if (el.category === "REAL_ESTATE" || el.category === "OTHER_ASSET") {
if (td.decision === undefined) n++;
} else if (el.category === "PENSION_FUND" && isRetirementTransition(el, fromPhase, toPhase)) {
if (td.payoutMode === undefined) n++;
}
}
return n;
}
function transitionElements(fromPhase: PhaseComputed): ElementInput[] {
return plan.elements
.filter((el) => TRANSITION_CATEGORIES.includes(el.category))
.filter((el) => {
const ce = computedElement(fromPhase.id, el.id);
return !ce || ce.status === "ACTIVE";
})
.sort((a, b) => a.orderIndex - b.orderIndex);
}
async function handleAddPhase(payload: { name?: string; durationYears?: number; inflationRate?: number | null }) {
await api.post(`/api/plans/${plan.id}/phases`, payload);
setShowAddPhase(false);
onChanged(); onChanged();
} }
const hasPhases = computed.phases.length > 0; const hasPhases = computed.phases.length > 0;
const firstPhase = computed.phases[0] ?? null;
return ( return (
<div className="flex flex-col gap-5"> <div className="flex flex-col gap-5">
<Timeline phases={computed.phases} persons={personAxes} /> <Timeline phases={computed.phases} persons={personAxes} />
{/* Pensionsalter-Overrides */} {/* Plan-Profil */}
<div className="flex flex-wrap items-center gap-3 rounded-xl border border-zinc-200/70 bg-white px-4 py-3 text-sm shadow-sm dark:border-zinc-800 dark:bg-zinc-900"> <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-zinc-400">Pensionsalter (Plan)</span> <span className="text-xs font-semibold uppercase tracking-wide text-faint">Grundprofil (Plan)</span>
{household.persons.map((p) => ( {plan.persons.map((p) => (
<label key={p.role} className="flex items-center gap-1.5 text-xs text-zinc-600 dark:text-zinc-300"> <span key={p.role} className="text-xs text-muted">
{p.role === "PERSON_A" ? "Person A" : "Person B"}: {plan.householdType === "COUPLE" ? (p.role === "PERSON_A" ? "Person A" : "Person B") : "Person"}: {p.age} J., Pension {p.retirementAge}
<input </span>
type="number"
defaultValue={resolveRetirementAge(p.role, plan, p.retirementAge)}
className="w-16 rounded-lg border border-zinc-300 px-2 py-1 text-sm focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/20 dark:border-zinc-700 dark:bg-zinc-800"
onBlur={async (e) => {
const val = parseInt(e.target.value, 10);
if (!Number.isFinite(val)) return;
await api.patch(`/api/plans/${plan.id}`, {
[p.role === "PERSON_A" ? "retirementAgeA" : "retirementAgeB"]: val,
});
onChanged();
}}
/>
</label>
))} ))}
<span className="text-[11px] text-zinc-400">Standard aus Grundprofil, hier pro Plan uebersteuerbar.</span> <span className="text-xs text-muted">Inflation {plan.inflationRateDefault}%</span>
<button
type="button"
onClick={() => setShowSettings(true)}
className="ml-auto flex items-center gap-1.5 rounded-lg border border-border px-2.5 py-1 text-xs font-medium text-muted hover:bg-surface-2"
>
<Settings2 className="h-3.5 w-3.5" /> Einstellungen
</button>
</div> </div>
{!hasPhases && ( {!hasPhases && (
<div className="rounded-xl border border-dashed border-zinc-300 bg-white p-8 text-center dark:border-zinc-700 dark:bg-zinc-900"> <div className="rounded-xl border border-dashed border-border bg-surface p-8 text-center">
<p className="text-sm text-zinc-500">Dieser Plan hat noch keine Lebensphasen.</p> <p className="text-sm text-muted">Dieser Plan hat noch keine Lebensphasen.</p>
<button <button
type="button" type="button"
onClick={handleAddPhase} onClick={() => setShowAddPhase(true)}
className="mt-3 inline-flex items-center gap-1.5 rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-500 dark:bg-indigo-500" className="mt-3 inline-flex items-center gap-1.5 rounded-lg bg-accent px-4 py-2 text-sm font-medium text-accent-fg hover:bg-accent-hover"
> >
<Plus className="h-4 w-4" /> Erste Lebensphase <Plus className="h-4 w-4" /> Erste Lebensphase
</button> </button>
@@ -169,14 +253,14 @@ export function PlanView({
<button <button
type="button" type="button"
onClick={() => setShowAdd(true)} onClick={() => setShowAdd(true)}
className="flex items-center gap-1.5 rounded-lg bg-indigo-600 px-3 py-1.5 text-sm font-medium text-white shadow-sm hover:bg-indigo-500 dark:bg-indigo-500 dark:hover:bg-indigo-400" className="flex items-center gap-1.5 rounded-lg bg-accent px-3 py-1.5 text-sm font-medium text-accent-fg shadow-sm hover:bg-accent-hover"
> >
<Plus className="h-4 w-4" /> Finanzielles Element <Plus className="h-4 w-4" /> Finanzielles Element
</button> </button>
<button <button
type="button" type="button"
onClick={handleAddPhase} onClick={() => setShowAddPhase(true)}
className="flex items-center gap-1.5 rounded-lg border border-dashed border-indigo-300 bg-indigo-50/50 px-3 py-1.5 text-sm font-medium text-indigo-700 hover:bg-indigo-50 dark:border-indigo-500/30 dark:bg-indigo-500/10 dark:text-indigo-300" className="flex items-center gap-1.5 rounded-lg border border-dashed border-accent bg-accent-soft px-3 py-1.5 text-sm font-medium text-accent-soft-fg hover:bg-accent-soft"
> >
<Plus className="h-4 w-4" /> Lebensphase <Plus className="h-4 w-4" /> Lebensphase
</button> </button>
@@ -185,11 +269,11 @@ export function PlanView({
{/* Matrix */} {/* Matrix */}
{hasPhases && ( {hasPhases && (
<div className="overflow-x-auto rounded-xl border border-zinc-200/70 bg-white shadow-sm dark:border-zinc-800 dark:bg-zinc-900"> <div className="overflow-x-auto rounded-xl border border-border bg-surface shadow-sm">
<table className="w-full border-collapse text-sm"> <table className="w-full border-collapse text-sm">
<thead> <thead>
<tr> <tr>
<th className="sticky left-0 z-20 min-w-44 border-b border-r border-zinc-200 bg-zinc-50 px-3 py-2 text-left text-xs font-semibold uppercase tracking-wide text-zinc-500 dark:border-zinc-800 dark:bg-zinc-800/60"> <th className="sticky left-0 z-20 min-w-44 border-b border-r border-border bg-surface-2 px-3 py-2 text-left text-xs font-semibold uppercase tracking-wide text-faint">
Finanzielle Elemente Finanzielle Elemente
</th> </th>
{columns.map((col) => {columns.map((col) =>
@@ -201,12 +285,11 @@ export function PlanView({
active={selected?.type === "phase" && selected.phaseId === col.phase.id} active={selected?.type === "phase" && selected.phaseId === col.phase.id}
/> />
) : ( ) : (
<th <TransitionHeader
key={`t-${col.fromPhase.id}`} key={`t-${col.fromPhase.id}`}
className="border-b border-r border-zinc-200 bg-indigo-50/40 px-2 py-2 text-center text-[11px] font-medium text-indigo-500 dark:border-zinc-800 dark:bg-indigo-500/5" openCount={transitionOpenCount(col.fromPhase, col.toPhase)}
> onClick={() => setReviewFromPhaseId(col.fromPhase.id)}
Uebergang />
</th>
) )
)} )}
</tr> </tr>
@@ -218,9 +301,9 @@ export function PlanView({
const collapsed = collapsedCats.has(cat); const collapsed = collapsedCats.has(cat);
return ( return (
<FragmentRows key={cat}> <FragmentRows key={cat}>
<tr className="bg-zinc-50/60 dark:bg-zinc-800/30"> <tr className="bg-surface-2">
<td <td
className="sticky left-0 z-10 cursor-pointer border-b border-r border-zinc-200 bg-zinc-50/90 px-3 py-1.5 dark:border-zinc-800 dark:bg-zinc-800/60" className="sticky left-0 z-10 cursor-pointer border-b border-r border-border bg-surface-2 px-3 py-1.5"
onClick={() => onClick={() =>
setCollapsedCats((prev) => { setCollapsedCats((prev) => {
const next = new Set(prev); const next = new Set(prev);
@@ -230,21 +313,21 @@ export function PlanView({
}) })
} }
> >
<span className="flex items-center gap-1.5 text-xs font-semibold text-zinc-600 dark:text-zinc-300"> <span className="flex items-center gap-1.5 text-xs font-semibold text-muted">
{collapsed ? <ChevronRight className="h-3.5 w-3.5" /> : <ChevronDown className="h-3.5 w-3.5" />} {collapsed ? <ChevronRight className="h-3.5 w-3.5" /> : <ChevronDown className="h-3.5 w-3.5" />}
<span className="text-indigo-500 dark:text-indigo-400">{CATEGORY_ICON[cat]}</span> <span className="text-accent">{CATEGORY_ICON[cat]}</span>
{CATEGORY_LABELS[cat]} {CATEGORY_LABELS[cat]}
</span> </span>
</td> </td>
<td colSpan={columns.length} className="border-b border-zinc-200 dark:border-zinc-800" /> <td colSpan={columns.length} className="border-b border-border" />
</tr> </tr>
{!collapsed && {!collapsed &&
els.map((el) => ( els.map((el) => (
<tr key={el.id} className="hover:bg-zinc-50/50 dark:hover:bg-zinc-800/20"> <tr key={el.id} className="hover:bg-surface-2">
<td className="sticky left-0 z-10 border-b border-r border-zinc-200 bg-white px-3 py-1.5 dark:border-zinc-800 dark:bg-zinc-900"> <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-zinc-800 dark:text-zinc-200">{el.name}</div> <div className="truncate text-xs font-medium text-fg">{el.name}</div>
{el.ownerRole && el.ownerRole !== "HOUSEHOLD" && ( {el.ownerRole && el.ownerRole !== "HOUSEHOLD" && (
<div className="text-[10px] text-zinc-400"> <div className="text-[10px] text-faint">
{el.ownerRole === "PERSON_A" ? "Person A" : "Person B"} {el.ownerRole === "PERSON_A" ? "Person A" : "Person B"}
</div> </div>
)} )}
@@ -260,11 +343,11 @@ export function PlanView({
<td <td
key={col.phase.id} key={col.phase.id}
onClick={() => setSelected({ type: "phaseCell", elementId: el.id, phaseId: col.phase.id })} onClick={() => setSelected({ type: "phaseCell", elementId: el.id, phaseId: col.phase.id })}
className={`cursor-pointer border-b border-r border-zinc-200 px-2 py-1.5 text-center text-xs dark:border-zinc-800 ${ className={`cursor-pointer border-b border-r border-border px-2 py-1.5 text-center text-xs ${
isSel ? "bg-indigo-100 dark:bg-indigo-500/20" : "" isSel ? "bg-accent-soft" : ""
} ${ce?.locked ? "text-zinc-400" : "text-zinc-700 dark:text-zinc-200"}`} } ${ce?.locked ? "text-faint" : "text-fg"}`}
> >
{ce?.summary ?? ""} {phaseCellContent(ce)}
</td> </td>
); );
} }
@@ -280,9 +363,9 @@ export function PlanView({
canTransition && canTransition &&
setSelected({ type: "transitionCell", elementId: el.id, fromPhaseId: col.fromPhase.id }) setSelected({ type: "transitionCell", elementId: el.id, fromPhaseId: col.fromPhase.id })
} }
className={`border-b border-r border-zinc-200 px-2 py-1.5 text-center text-[11px] dark:border-zinc-800 ${ className={`border-b border-r border-border px-2 py-1.5 text-center text-[11px] ${
canTransition ? "cursor-pointer text-indigo-500" : "text-zinc-300 dark:text-zinc-600" canTransition ? "cursor-pointer text-accent" : "text-faint"
} ${isSel ? "bg-indigo-100 dark:bg-indigo-500/20" : "bg-indigo-50/30 dark:bg-indigo-500/5"}`} } ${isSel ? "bg-accent-soft" : "bg-accent-soft/40"}`}
> >
{canTransition ? transitionSummary(el, col.fromPhase, col.toPhase) : "→"} {canTransition ? transitionSummary(el, col.fromPhase, col.toPhase) : "→"}
</td> </td>
@@ -295,7 +378,7 @@ export function PlanView({
})} })}
{plan.elements.length === 0 && ( {plan.elements.length === 0 && (
<tr> <tr>
<td className="sticky left-0 bg-white px-3 py-4 text-xs text-zinc-400 dark:bg-zinc-900" colSpan={columns.length + 1}> <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. Noch keine finanziellen Elemente. Fuegen Sie oben Ihr erstes Element hinzu.
</td> </td>
</tr> </tr>
@@ -307,25 +390,74 @@ export function PlanView({
{/* Detail-Panel */} {/* Detail-Panel */}
{selected && ( {selected && (
<div className="rounded-xl border border-indigo-200 bg-white p-4 shadow-sm dark:border-indigo-500/30 dark:bg-zinc-900"> <div className="rounded-xl border border-accent bg-surface p-4 shadow-sm">{renderDetail()}</div>
{renderDetail()}
</div>
)} )}
{showAdd && ( {showAdd && firstPhase && (
<AddElementDialog <AddElementDialog
household={household} plan={plan}
firstPhase={firstPhase}
onClose={() => setShowAdd(false)} onClose={() => setShowAdd(false)}
onCreate={async (payload) => { onCreated={() => {
await api.post(`/api/plans/${plan.id}/elements`, payload);
setShowAdd(false); setShowAdd(false);
onChanged(); onChanged();
}} }}
/> />
)} )}
{showAddPhase && (
<AddPhaseDialog
maxDurationYears={nextPhaseCap()}
onClose={() => setShowAddPhase(false)}
onCreate={handleAddPhase}
/>
)}
{showSettings && (
<PlanSettingsDialog
plan={plan}
onClose={() => setShowSettings(false)}
onSaved={() => {
setShowSettings(false);
onChanged();
}}
/>
)}
{reviewFromPhaseId && (() => {
const fromPhase = computed.phases.find((p) => p.id === reviewFromPhaseId);
if (!fromPhase) return null;
const toIndex = computed.phases.findIndex((p) => p.id === fromPhase.id) + 1;
const toPhase = computed.phases[toIndex];
const els = transitionElements(fromPhase);
return (
<TransitionReviewDialog
fromPhase={fromPhase}
toPhase={toPhase}
elements={els}
buildContext={(el) => buildTransitionContext(fromPhase, toPhase, el)}
isRetirement={(el) => (toPhase ? isRetirementTransition(el, fromPhase, toPhase) : false)}
onClose={() => setReviewFromPhaseId(null)}
onSaved={() => {
setReviewFromPhaseId(null);
onChanged();
}}
/>
);
})()}
</div> </div>
); );
// Naechste Phasen-Kappung (fuer das Phase-Popup).
function nextPhaseCap(): number | null {
// Simpel aus den Personen ableiten (Jahre nach Planbeginn = Summe der Dauern).
const yearsBefore = plan.phases.reduce((s, p) => s + p.durationYears, 0);
const caps = plan.persons
.map((p) => p.retirementAge - (p.age + yearsBefore))
.filter((d) => d > 0);
return caps.length > 0 ? Math.min(...caps) : null;
}
function renderDetail() { function renderDetail() {
if (!selected) return null; if (!selected) return null;
@@ -339,7 +471,7 @@ export function PlanView({
phase={phaseInput} phase={phaseInput}
maxDurationYears={phase.maxDurationYears} maxDurationYears={phase.maxDurationYears}
isLast={isLast} isLast={isLast}
household={household} inflationDefault={plan.inflationRateDefault}
onSaved={onChanged} onSaved={onChanged}
onDeleted={() => { onDeleted={() => {
setSelected(null); setSelected(null);
@@ -354,19 +486,7 @@ export function PlanView({
if (selected.type === "phaseCell") { if (selected.type === "phaseCell") {
const phase = computed.phases.find((p) => p.id === selected.phaseId)!; const phase = computed.phases.find((p) => p.id === selected.phaseId)!;
const ownerWorking = element.ownerRole && element.ownerRole !== "HOUSEHOLD" const context = buildPhaseContext(phase, element);
? phase.persons.find((p) => p.role === element.ownerRole)?.working ?? false
: phase.type !== "PENSION";
const ce = computedElement(phase.id, element.id);
const context: CellContext = {
kind: "phase",
phaseId: phase.id,
ownerWorking,
isConsumption: phase.isConsumption,
durationYears: phase.durationYears,
isRetirementTransition: false,
carriedEndValue: ce?.endValue ?? 0,
};
return ( return (
<ElementDetail <ElementDetail
element={element} element={element}
@@ -383,16 +503,7 @@ export function PlanView({
const fromPhase = computed.phases.find((p) => p.id === selected.fromPhaseId)!; const fromPhase = computed.phases.find((p) => p.id === selected.fromPhaseId)!;
const toIndex = computed.phases.findIndex((p) => p.id === fromPhase.id) + 1; const toIndex = computed.phases.findIndex((p) => p.id === fromPhase.id) + 1;
const toPhase = computed.phases[toIndex]; const toPhase = computed.phases[toIndex];
const ce = computedElement(fromPhase.id, element.id); const context = buildTransitionContext(fromPhase, toPhase, element);
const context: CellContext = {
kind: "transition",
phaseId: fromPhase.id,
ownerWorking: true,
isConsumption: fromPhase.isConsumption,
durationYears: fromPhase.durationYears,
isRetirementTransition: toPhase ? isRetirementTransition(element, fromPhase, toPhase) : false,
carriedEndValue: ce?.endValue ?? 0,
};
return ( return (
<ElementDetail <ElementDetail
element={element} element={element}
@@ -417,10 +528,10 @@ export function PlanView({
switch (el.category) { switch (el.category) {
case "REAL_ESTATE": case "REAL_ESTATE":
case "OTHER_ASSET": case "OTHER_ASSET":
return td.decision === "SELL" ? "Verkauf" : "Halten"; return td.decision === "SELL" ? "Verkauf" : td.decision === "HOLD" ? "Halten" : "?";
case "PENSION_FUND": case "PENSION_FUND":
if (isRetirementTransition(el, fromPhase, toPhase)) { if (isRetirementTransition(el, fromPhase, toPhase)) {
return td.payoutMode === "CAPITAL" ? "Kapital" : td.payoutMode === "COMBI" ? "Kombi" : "Rente"; return td.payoutMode === "CAPITAL" ? "Kapital" : td.payoutMode === "COMBI" ? "Kombi" : td.payoutMode === "PENSION" ? "Rente" : "?";
} }
return num(td.withdrawal) > 0 ? `${formatChf(num(td.withdrawal))}` : "→"; return num(td.withdrawal) > 0 ? `${formatChf(num(td.withdrawal))}` : "→";
case "PILLAR_3A": case "PILLAR_3A":
@@ -434,63 +545,108 @@ export function PlanView({
} }
} }
// Zellinhalt: Start- UND Zielwert fuer wertbehaftete Elemente, sonst die Kennzahl.
function phaseCellContent(ce: ReturnType<PhaseComputed["elements"]["find"]> | undefined): React.ReactNode {
if (!ce) return "";
if (ce.note) return ce.note;
if (VALUE_CATEGORIES.includes(ce.category) && (ce.startValue !== 0 || ce.endValue !== 0)) {
return (
<span className="whitespace-nowrap">
{formatChf(ce.startValue)} <span className="text-faint"></span> {formatChf(ce.endValue)}
</span>
);
}
return ce.summary || "";
}
function PhaseHeader({ phase, onClick, active }: { phase: PhaseComputed; onClick: () => void; active: boolean }) { function PhaseHeader({ phase, onClick, active }: { phase: PhaseComputed; onClick: () => void; active: boolean }) {
const quotaLabel = phase.isConsumption ? "Verzehr" : "Sparquote"; const quotaLabel = phase.isConsumption ? "Verzehr" : "Sparquote";
const quotaRemaining = Math.max(0, phase.quotaRemaining);
return ( return (
<th <th
onClick={onClick} onClick={onClick}
className={`min-w-40 cursor-pointer border-b border-r border-zinc-200 px-2 py-2 text-left align-top dark:border-zinc-800 ${ className={`min-w-40 cursor-pointer border-b border-r border-border px-2 py-2 text-left align-top ${
active ? "bg-indigo-100 dark:bg-indigo-500/20" : "bg-white dark:bg-zinc-900" active ? "bg-accent-soft" : "bg-surface"
}`} }`}
> >
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<span className="truncate text-xs font-semibold text-zinc-800 dark:text-zinc-100">{phase.name}</span> <span className="truncate text-xs font-semibold text-fg">{phase.name}</span>
{phase.incomplete ? ( {phase.incomplete ? (
<AlertCircle className="h-3.5 w-3.5 shrink-0 text-red-500" /> <AlertCircle className="h-3.5 w-3.5 shrink-0 text-danger" />
) : ( ) : (
<CheckCircle2 className="h-3.5 w-3.5 shrink-0 text-emerald-500" /> <CheckCircle2 className="h-3.5 w-3.5 shrink-0 text-success" />
)} )}
</div> </div>
<div className="mt-0.5 flex flex-wrap gap-1"> <div className="mt-0.5 flex flex-wrap gap-1">
<span className="rounded bg-zinc-100 px-1 text-[10px] text-zinc-500 dark:bg-zinc-800"> <span className="rounded bg-surface-2 px-1 text-[10px] text-muted">
{phase.type === "ERWERB" ? "Erwerb" : phase.type === "PENSION" ? "Pension" : "Misch"} {phase.type === "ERWERB" ? "Erwerb" : phase.type === "PENSION" ? "Pension" : "Misch"}
</span> </span>
<span className="text-[10px] text-zinc-400">{phase.durationYears} J.</span> <span className="text-[10px] text-faint">{phase.durationYears} J.</span>
<span className="text-[10px] text-zinc-400">Alter {phase.persons.map((p) => p.startAge).join("/")}</span> <span className="text-[10px] text-faint">Alter {phase.persons.map((p) => p.startAge).join("/")}</span>
</div> </div>
<div className="mt-1 space-y-0.5 text-[10px] leading-tight text-zinc-500 dark:text-zinc-400"> <div className="mt-1 space-y-0.5 text-[10px] leading-tight text-muted">
<div>Einkommen {formatChf(phase.incomeTotal)}</div> <div>Einkommen {formatChf(phase.incomeTotal)}</div>
<div>Ausgaben {formatChf(phase.expenseTotal)}</div> <div>Ausgaben {formatChf(phase.expenseTotal)}</div>
<div className={phase.quotaComplete ? "text-emerald-600 dark:text-emerald-400" : "text-red-600 dark:text-red-400"}> <div className={phase.quotaComplete ? "text-success" : "text-danger"}>
{quotaLabel} {formatChf(Math.abs(phase.quota))} {quotaLabel} {formatChf(Math.abs(phase.quota))}
{!phase.quotaComplete && <span> · offen {formatChf(quotaRemaining)}</span>}
</div> </div>
<div className={phase.availableCapitalComplete ? "" : "text-red-600 dark:text-red-400"}> <div className={phase.availableCapitalComplete ? "" : "text-danger"}>
Kapital {phase.availableCapital === null ? "n.a." : formatChf(phase.availableCapital)} Kapital {phase.availableCapital === null ? "n.a." : formatChf(phase.availableCapital)}
{phase.availableCapital !== null && !phase.availableCapitalComplete && (
<span> · offen {formatChf(Math.max(0, phase.availableCapitalRemaining))}</span>
)}
</div> </div>
</div> </div>
</th> </th>
); );
} }
function TransitionHeader({ openCount, onClick }: { openCount: number; onClick: () => void }) {
return (
<th
onClick={onClick}
className={`cursor-pointer border-b border-r border-border px-2 py-2 text-center align-top text-[11px] font-medium ${
openCount > 0 ? "bg-accent text-accent-fg" : "bg-accent-soft text-accent-soft-fg"
}`}
>
<div>Uebergang</div>
{openCount > 0 ? (
<div className="mt-1 rounded-full bg-accent-fg/20 px-1.5 py-0.5 text-[10px] font-semibold">
{openCount} offen
</div>
) : (
<div className="mt-1 text-[10px] opacity-80">pruefen</div>
)}
</th>
);
}
function FragmentRows({ children }: { children: React.ReactNode }) { function FragmentRows({ children }: { children: React.ReactNode }) {
return <>{children}</>; return <>{children}</>;
} }
// --- Dialog: neues finanzielles Element mit Direkteingabe der Phase-1-Werte ---
function AddElementDialog({ function AddElementDialog({
household, plan,
firstPhase,
onClose, onClose,
onCreate, onCreated,
}: { }: {
household: HouseholdInput; plan: PlanInput;
firstPhase: PhaseComputed;
onClose: () => void; onClose: () => void;
onCreate: (payload: { category: ElementCategory; name: string; ownerRole: string | null }) => void; onCreated: () => void;
}) { }) {
const [category, setCategory] = useState<ElementCategory>("INCOME"); const [category, setCategory] = useState<ElementCategory>("INCOME");
const [name, setName] = useState(""); const [name, setName] = useState("");
const [ownerRole, setOwnerRole] = useState<string>(household.householdType === "COUPLE" ? "PERSON_A" : "PERSON_A"); const [ownerRole, setOwnerRole] = useState<string>("PERSON_A");
const [pd, setPd] = useState<PhaseData>({});
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const needsPerson = PERSON_ONLY_CATEGORIES.includes(category); const needsPerson = PERSON_ONLY_CATEGORIES.includes(category);
const isCouple = household.householdType === "COUPLE"; const isCouple = plan.householdType === "COUPLE";
const ownerOptions = needsPerson const ownerOptions = needsPerson
? isCouple ? isCouple
@@ -510,24 +666,62 @@ function AddElementDialog({
{ value: "PERSON_A", label: "Person A" }, { value: "PERSON_A", label: "Person A" },
]; ];
// Kontext fuer die Phase-1-Felder des neuen Elements.
const owner = needsPerson || ownerRole !== "HOUSEHOLD" ? ownerRole : null;
const ownerWorking =
owner && owner !== "HOUSEHOLD"
? firstPhase.persons.find((p) => p.role === owner)?.working ?? false
: firstPhase.type !== "PENSION";
const context: CellContext = {
kind: "phase",
phaseId: firstPhase.id,
ownerWorking,
isConsumption: firstPhase.isConsumption,
durationYears: firstPhase.durationYears,
isRetirementTransition: false,
carriedEndValue: 0,
carried: false,
derivedStart: 0,
quotaRateMax: Math.max(0, Math.abs(firstPhase.quota) - firstPhase.quotaAllocated),
capitalMax: firstPhase.availableCapital == null ? undefined : Math.max(0, firstPhase.availableCapital - firstPhase.availableCapitalUsed),
};
async function create() {
setSaving(true);
setError(null);
try {
const { element } = await api.post<{ element: { id: string } }>(`/api/plans/${plan.id}/elements`, {
category,
name: name.trim() || CATEGORY_LABELS[category],
ownerRole,
});
// Ist-Zustand direkt in Phase 1 speichern (sofern Felder ausgefuellt).
if (Object.keys(pd).length > 0) {
await api.put(`/api/elements/${element.id}/phase/${firstPhase.id}`, pd);
}
onCreated();
} catch (e) {
setError(e instanceof Error ? e.message : "Erstellen fehlgeschlagen.");
} finally {
setSaving(false);
}
}
return ( return (
<div className="fixed inset-0 z-40 flex items-center justify-center bg-black/40 px-4" onClick={onClose}> <DialogShell title="Finanzielles Element" onClose={onClose} wide>
<div <div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
onClick={(e) => e.stopPropagation()}
className="flex w-full max-w-md flex-col gap-3 rounded-2xl border border-zinc-200 bg-white p-6 shadow-xl dark:border-zinc-700 dark:bg-zinc-900"
>
<h2 className="text-base font-semibold text-zinc-900 dark:text-zinc-50">Finanzielles Element</h2>
<div> <div>
<label className="mb-1 block text-xs font-medium text-zinc-600 dark:text-zinc-400">Kategorie</label> <label className="mb-1 block text-xs font-medium text-muted">Kategorie</label>
<select <select
value={category} value={category}
onChange={(e) => { onChange={(e) => {
const c = e.target.value as ElementCategory; const c = e.target.value as ElementCategory;
setCategory(c); setCategory(c);
setPd({});
if (PERSON_ONLY_CATEGORIES.includes(c) && ownerRole === "HOUSEHOLD") setOwnerRole("PERSON_A"); if (PERSON_ONLY_CATEGORIES.includes(c) && ownerRole === "HOUSEHOLD") setOwnerRole("PERSON_A");
if (!name) setName(CATEGORY_LABELS[c]); if (!name) setName(CATEGORY_LABELS[c]);
}} }}
className="w-full rounded-lg border border-zinc-300 px-3 py-2 text-sm focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/20 dark:border-zinc-600 dark:bg-zinc-950" 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"
> >
{CATEGORY_ORDER.map((c) => ( {CATEGORY_ORDER.map((c) => (
<option key={c} value={c}> <option key={c} value={c}>
@@ -537,20 +731,11 @@ function AddElementDialog({
</select> </select>
</div> </div>
<div> <div>
<label className="mb-1 block text-xs font-medium text-zinc-600 dark:text-zinc-400">Bezeichnung</label> <label className="mb-1 block text-xs font-medium text-muted">Zuordnung</label>
<input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={CATEGORY_LABELS[category]}
className="w-full rounded-lg border border-zinc-300 px-3 py-2 text-sm focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/20 dark:border-zinc-600 dark:bg-zinc-950"
/>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-zinc-600 dark:text-zinc-400">Zuordnung</label>
<select <select
value={ownerRole} value={ownerRole}
onChange={(e) => setOwnerRole(e.target.value)} onChange={(e) => setOwnerRole(e.target.value)}
className="w-full rounded-lg border border-zinc-300 px-3 py-2 text-sm focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/20 dark:border-zinc-600 dark:bg-zinc-950" 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"
> >
{ownerOptions.map((o) => ( {ownerOptions.map((o) => (
<option key={o.value} value={o.value}> <option key={o.value} value={o.value}>
@@ -559,23 +744,271 @@ function AddElementDialog({
))} ))}
</select> </select>
</div> </div>
<div className="flex gap-2"> <div className="sm:col-span-2">
<button <label className="mb-1 block text-xs font-medium text-muted">Bezeichnung</label>
type="button" <input
onClick={() => onCreate({ category, name: name.trim() || CATEGORY_LABELS[category], ownerRole })} value={name}
className="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-500 dark:bg-indigo-500 dark:hover:bg-indigo-400" onChange={(e) => setName(e.target.value)}
> placeholder={CATEGORY_LABELS[category]}
Erstellen 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"
</button> />
<button </div>
type="button" </div>
onClick={onClose}
className="rounded-lg border border-zinc-300 px-4 py-2 text-sm font-medium text-zinc-700 hover:bg-zinc-50 dark:border-zinc-700 dark:text-zinc-200 dark:hover:bg-zinc-800" <div className="mt-1 border-t border-border pt-3">
> <div className="mb-2 text-xs font-semibold uppercase tracking-wide text-faint">Werte (erste Lebensphase)</div>
Abbrechen <div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<ElementPhaseFields
element={{ category }}
context={context}
pd={pd}
setP={(patch) => setPd((prev) => ({ ...prev, ...patch }))}
/>
</div>
</div>
{error && <p className="text-sm text-danger">{error}</p>}
<DialogActions saving={saving} onConfirm={create} onClose={onClose} confirmLabel="Erstellen" />
</DialogShell>
);
}
// --- Dialog: neue Lebensphase ---
function AddPhaseDialog({
maxDurationYears,
onClose,
onCreate,
}: {
maxDurationYears: number | null;
onClose: () => void;
onCreate: (payload: { name?: string; durationYears?: number; inflationRate?: number | null }) => void;
}) {
const cap = maxDurationYears;
const [name, setName] = useState("");
const [durationYears, setDurationYears] = useState(cap ?? 10);
const [saving, setSaving] = useState(false);
return (
<DialogShell title="Neue Lebensphase" onClose={onClose}>
<div className="grid grid-cols-1 gap-3">
<div>
<label className="mb-1 block text-xs font-medium text-muted">Bezeichnung (optional)</label>
<input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="automatisch (Erwerb/Pension)"
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"
/>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-muted">
Dauer (Jahre){cap != null ? ` · max. ${cap}` : ""}
</label>
<input
type="number"
value={durationYears}
min={1}
max={cap ?? undefined}
onFocus={(e) => e.currentTarget.select()}
onChange={(e) => {
const v = e.target.valueAsNumber || 1;
setDurationYears(cap != null ? Math.min(v, cap) : v);
}}
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"
/>
</div>
</div>
<DialogActions
saving={saving}
onConfirm={() => {
setSaving(true);
onCreate({ name: name.trim() || undefined, durationYears });
}}
onClose={onClose}
confirmLabel="Erstellen"
/>
</DialogShell>
);
}
// --- Dialog: Plan-Einstellungen (Grundprofil bearbeiten) ---
function PlanSettingsDialog({ plan, onClose, onSaved }: { plan: PlanInput; onClose: () => void; onSaved: () => void }) {
const [draft, setDraft] = useState<ProfileDraft>({
householdType: plan.householdType,
inflationRateDefault: plan.inflationRateDefault,
persons: plan.persons.map((p) => ({ role: p.role, age: p.age, retirementAge: p.retirementAge })),
});
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
async function save() {
setSaving(true);
setError(null);
try {
await api.patch(`/api/plans/${plan.id}`, draft);
onSaved();
} catch (e) {
setError(e instanceof Error ? e.message : "Speichern fehlgeschlagen.");
} finally {
setSaving(false);
}
}
return (
<DialogShell title="Plan-Einstellungen" onClose={onClose}>
<PlanProfileFields draft={draft} onChange={setDraft} />
{error && <p className="text-sm text-danger">{error}</p>}
<DialogActions saving={saving} onConfirm={save} onClose={onClose} confirmLabel="Speichern" />
</DialogShell>
);
}
// --- Dialog: geführter Übergang ---
function TransitionReviewDialog({
fromPhase,
toPhase,
elements,
buildContext,
isRetirement,
onClose,
onSaved,
}: {
fromPhase: PhaseComputed;
toPhase: PhaseComputed | undefined;
elements: ElementInput[];
buildContext: (el: ElementInput) => CellContext;
isRetirement: (el: ElementInput) => boolean;
onClose: () => void;
onSaved: () => void;
}) {
const [tds, setTds] = useState<Record<string, import("@/lib/elements").TransitionData>>(() =>
Object.fromEntries(elements.map((e) => [e.id, { ...(e.transitionValues[fromPhase.id] ?? {}) }]))
);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
async function saveAll() {
setSaving(true);
setError(null);
try {
for (const e of elements) {
await api.put(`/api/elements/${e.id}/transition/${fromPhase.id}`, tds[e.id] ?? {});
}
onSaved();
} catch (err) {
setError(err instanceof Error ? err.message : "Speichern fehlgeschlagen.");
} finally {
setSaving(false);
}
}
return (
<DialogShell title={`Übergang prüfen: ${fromPhase.name}${toPhase?.name ?? "Ende"}`} onClose={onClose} wide>
<p className="text-sm text-muted">
Gehen Sie die Positionen durch und treffen Sie je Element den Übergangs-Entscheid (Halten, Verkaufen,
Bezug). Danach werden gehaltene Werte automatisch in die nächste Phase fortgeschrieben.
</p>
<div className="flex flex-col gap-3">
{elements.length === 0 && (
<p className="rounded-lg border border-dashed border-border bg-surface-2 p-3 text-sm text-muted">
An diesem Übergang gibt es keine zu entscheidenden Positionen.
</p>
)}
{elements.map((el) => {
const ctx = buildContext(el);
const retire = isRetirement(el);
const hint =
(el.category === "PENSION_FUND" || el.category === "PILLAR_3A") && !retire
? "Hier könnten Sie optional Kapital beziehen."
: el.category === "PENSION_FUND" && retire
? "Pensionierung: Bezugsart wählen (Rente / Kapital / Kombination)."
: el.category === "PILLAR_3A" && retire
? "Wird bei Pensionierung vollständig bezogen."
: null;
return (
<div key={el.id} className="rounded-xl border border-border bg-surface-2 p-3">
<div className="mb-2 flex items-center gap-2">
<span className="text-accent">{CATEGORY_ICON[el.category]}</span>
<span className="text-sm font-semibold text-fg">{el.name}</span>
<span className="text-xs text-faint">{CATEGORY_LABELS[el.category]}</span>
</div>
{hint && <p className="mb-2 text-xs text-accent-soft-fg">{hint}</p>}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<ElementTransitionFields
element={el}
context={ctx}
td={tds[el.id] ?? {}}
setT={(patch) => setTds((prev) => ({ ...prev, [el.id]: { ...prev[el.id], ...patch } }))}
/>
</div>
</div>
);
})}
</div>
{error && <p className="text-sm text-danger">{error}</p>}
<DialogActions saving={saving} onConfirm={saveAll} onClose={onClose} confirmLabel="Alle speichern" />
</DialogShell>
);
}
// --- gemeinsame Dialog-Bausteine ---
function DialogShell({
title,
onClose,
children,
wide,
}: {
title: string;
onClose: () => void;
children: React.ReactNode;
wide?: boolean;
}) {
return (
<div className="fixed inset-0 z-40 flex items-start justify-center overflow-y-auto bg-black/40 px-4 py-8" onClick={onClose}>
<div
onClick={(e) => e.stopPropagation()}
className={`flex w-full ${wide ? "max-w-2xl" : "max-w-md"} flex-col gap-3 rounded-2xl border border-border bg-surface p-6 shadow-xl`}
>
<div className="flex items-center justify-between">
<h2 className="text-base font-semibold text-fg">{title}</h2>
<button type="button" onClick={onClose} aria-label="Schliessen" className="rounded-md p-1 text-faint hover:bg-surface-2">
<X className="h-4 w-4" />
</button> </button>
</div> </div>
{children}
</div> </div>
</div> </div>
); );
} }
function DialogActions({
saving,
onConfirm,
onClose,
confirmLabel,
}: {
saving: boolean;
onConfirm: () => void;
onClose: () => void;
confirmLabel: string;
}) {
return (
<div className="flex gap-2 pt-1">
<button
type="button"
disabled={saving}
onClick={onConfirm}
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"
>
{saving ? "..." : confirmLabel}
</button>
<button
type="button"
onClick={onClose}
className="rounded-lg border border-border px-4 py-2 text-sm font-medium text-muted hover:bg-surface-2"
>
Abbrechen
</button>
</div>
);
}
+56 -75
View File
@@ -1,59 +1,77 @@
"use client"; "use client";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { KeyRound, LogOut, Settings, UserCircle2 } from "lucide-react"; import { KeyRound, LogOut, Palette, UserCircle2 } from "lucide-react";
import { api } from "@/lib/api-client"; import { api } from "@/lib/api-client";
import { getEffectiveTheme, setTheme, THEMES, type Theme } from "@/lib/theme";
export function ProfileMenu({ export function ProfileMenu({ username }: { username: string }) {
username,
onOpenHouseholdSettings,
}: {
username: string;
onOpenHouseholdSettings: () => void;
}) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [showPasswordDialog, setShowPasswordDialog] = useState(false); const [showPasswordDialog, setShowPasswordDialog] = useState(false);
const [theme, setThemeState] = useState<Theme>("light");
const menuRef = useRef<HTMLDivElement | null>(null); const menuRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
setThemeState(getEffectiveTheme());
}, []);
useEffect(() => { useEffect(() => {
function handleClickOutside(e: MouseEvent) { function handleClickOutside(e: MouseEvent) {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) { if (menuRef.current && !menuRef.current.contains(e.target as Node)) setOpen(false);
setOpen(false);
}
} }
document.addEventListener("mousedown", handleClickOutside); document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside); return () => document.removeEventListener("mousedown", handleClickOutside);
}, []); }, []);
function chooseTheme(t: Theme) {
setTheme(t);
setThemeState(t);
}
return ( return (
<div className="relative" ref={menuRef}> <div className="relative" ref={menuRef}>
<button <button
type="button" type="button"
onClick={() => setOpen((v) => !v)} onClick={() => setOpen((v) => !v)}
className="flex items-center gap-2 rounded-full border border-zinc-200 bg-white py-1 pl-1 pr-3 text-sm shadow-sm hover:bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-800 dark:hover:bg-zinc-700" className="flex items-center gap-2 rounded-full border border-border bg-surface py-1 pl-1 pr-3 text-sm shadow-sm hover:bg-surface-2"
> >
<span className="flex h-7 w-7 items-center justify-center rounded-full bg-indigo-100 text-xs font-semibold uppercase text-indigo-700 dark:bg-indigo-500/20 dark:text-indigo-300"> <span className="flex h-7 w-7 items-center justify-center rounded-full bg-accent-soft text-xs font-semibold uppercase text-accent-soft-fg">
{username.slice(0, 2)} {username.slice(0, 2)}
</span> </span>
<span className="hidden font-medium text-zinc-700 sm:inline dark:text-zinc-200">{username}</span> <span className="hidden font-medium text-fg sm:inline">{username}</span>
</button> </button>
{open && ( {open && (
<div className="absolute right-0 top-11 z-30 w-56 overflow-hidden rounded-xl border border-zinc-200 bg-white shadow-lg dark:border-zinc-700 dark:bg-zinc-800"> <div className="absolute right-0 top-11 z-30 w-60 overflow-hidden rounded-xl border border-border bg-surface shadow-lg">
<div className="border-b border-zinc-100 px-4 py-3 dark:border-zinc-700"> <div className="border-b border-border px-4 py-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<UserCircle2 className="h-4 w-4 text-indigo-500 dark:text-indigo-400" /> <UserCircle2 className="h-4 w-4 text-accent" />
<span className="text-sm font-medium text-zinc-800 dark:text-zinc-100">{username}</span> <span className="text-sm font-medium text-fg">{username}</span>
</div> </div>
</div> </div>
<MenuItem
icon={<Settings className="h-4 w-4" />} <div className="border-b border-border px-4 py-3">
label="Grundprofil bearbeiten" <div className="mb-2 flex items-center gap-2 text-xs font-medium text-muted">
onClick={() => { <Palette className="h-3.5 w-3.5" /> Farbschema
setOpen(false); </div>
onOpenHouseholdSettings(); <div className="grid grid-cols-3 gap-1">
}} {THEMES.map((t) => (
/> <button
key={t.value}
type="button"
onClick={() => chooseTheme(t.value)}
className={`rounded-lg border px-2 py-1.5 text-xs font-medium ${
theme === t.value
? "border-accent bg-accent-soft text-accent-soft-fg"
: "border-border text-muted hover:bg-surface-2"
}`}
>
{t.label}
</button>
))}
</div>
</div>
<MenuItem <MenuItem
icon={<KeyRound className="h-4 w-4" />} icon={<KeyRound className="h-4 w-4" />}
label="Passwort aendern" label="Passwort aendern"
@@ -78,20 +96,12 @@ export function ProfileMenu({
); );
} }
function MenuItem({ function MenuItem({ icon, label, onClick }: { icon: React.ReactNode; label: string; onClick: () => void }) {
icon,
label,
onClick,
}: {
icon: React.ReactNode;
label: string;
onClick: () => void;
}) {
return ( return (
<button <button
type="button" type="button"
onClick={onClick} onClick={onClick}
className="flex w-full items-center gap-2.5 px-4 py-2.5 text-left text-sm text-zinc-700 hover:bg-indigo-50 hover:text-indigo-700 dark:text-zinc-200 dark:hover:bg-indigo-500/10 dark:hover:text-indigo-300" className="flex w-full items-center gap-2.5 px-4 py-2.5 text-left text-sm text-muted hover:bg-accent-soft hover:text-accent-soft-fg"
> >
{icon} {icon}
{label} {label}
@@ -127,55 +137,26 @@ function ChangePasswordDialog({ onClose }: { onClose: () => void }) {
} }
const inputClass = const inputClass =
"w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/20 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100"; "w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-fg shadow-sm focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/25";
return ( return (
<div className="fixed inset-0 z-40 flex items-center justify-center bg-black/40 px-4" onClick={onClose}> <div className="fixed inset-0 z-40 flex items-center justify-center bg-black/40 px-4" onClick={onClose}>
<form <form
onSubmit={handleSubmit} onSubmit={handleSubmit}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
className="flex w-full max-w-sm flex-col gap-3 rounded-2xl border border-zinc-200 bg-white p-6 shadow-xl dark:border-zinc-700 dark:bg-zinc-900" 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-zinc-900 dark:text-zinc-50">Passwort aendern</h2> <h2 className="text-base font-semibold text-fg">Passwort aendern</h2>
<input <input type="password" placeholder="Aktuelles Passwort" autoComplete="current-password" value={currentPassword} onChange={(e) => setCurrentPassword(e.target.value)} className={inputClass} />
type="password" <input type="password" placeholder="Neues Passwort" autoComplete="new-password" value={newPassword} onChange={(e) => setNewPassword(e.target.value)} className={inputClass} />
placeholder="Aktuelles Passwort" <input type="password" placeholder="Neues Passwort bestaetigen" autoComplete="new-password" value={newPasswordConfirm} onChange={(e) => setNewPasswordConfirm(e.target.value)} className={inputClass} />
autoComplete="current-password" {error && <p className="text-sm text-danger">{error}</p>}
value={currentPassword} {done && <p className="text-sm text-success">Passwort geaendert.</p>}
onChange={(e) => setCurrentPassword(e.target.value)}
className={inputClass}
/>
<input
type="password"
placeholder="Neues Passwort"
autoComplete="new-password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
className={inputClass}
/>
<input
type="password"
placeholder="Neues Passwort bestaetigen"
autoComplete="new-password"
value={newPasswordConfirm}
onChange={(e) => setNewPasswordConfirm(e.target.value)}
className={inputClass}
/>
{error && <p className="text-sm text-red-600 dark:text-red-400">{error}</p>}
{done && <p className="text-sm text-emerald-600 dark:text-emerald-400">Passwort geaendert.</p>}
<div className="flex gap-2 pt-1"> <div className="flex gap-2 pt-1">
<button <button type="submit" disabled={saving} 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">
type="submit"
disabled={saving}
className="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-500 disabled:opacity-50 dark:bg-indigo-500 dark:hover:bg-indigo-400"
>
{saving ? "..." : "Speichern"} {saving ? "..." : "Speichern"}
</button> </button>
<button <button type="button" onClick={onClose} className="rounded-lg border border-border px-4 py-2 text-sm font-medium text-muted hover:bg-surface-2">
type="button"
onClick={onClose}
className="rounded-lg border border-zinc-300 px-4 py-2 text-sm font-medium text-zinc-700 hover:bg-zinc-50 dark:border-zinc-700 dark:text-zinc-200 dark:hover:bg-zinc-800"
>
Abbrechen Abbrechen
</button> </button>
</div> </div>
+6 -6
View File
@@ -32,10 +32,10 @@ export function Timeline({ phases, persons }: { phases: PhaseComputed[]; persons
} }
return ( return (
<div className="rounded-xl border border-zinc-200/70 bg-white p-4 shadow-sm dark:border-zinc-800 dark:bg-zinc-900"> <div className="rounded-xl border border-border bg-surface p-4 shadow-sm">
<div className="mb-3 flex items-center justify-between"> <div className="mb-3 flex items-center justify-between">
<h3 className="text-sm font-semibold text-zinc-800 dark:text-zinc-100">Zeitachse</h3> <h3 className="text-sm font-semibold text-fg">Zeitachse</h3>
<div className="flex gap-3 text-xs text-zinc-500"> <div className="flex gap-3 text-xs text-muted">
{persons.map((p) => ( {persons.map((p) => (
<span key={p.role} className="flex items-center gap-1"> <span key={p.role} className="flex items-center gap-1">
<span className="inline-block h-2 w-2 rounded-full" style={{ backgroundColor: p.color }} /> <span className="inline-block h-2 w-2 rounded-full" style={{ backgroundColor: p.color }} />
@@ -65,18 +65,18 @@ export function Timeline({ phases, persons }: { phases: PhaseComputed[]; persons
)} )}
{/* Achse */} {/* Achse */}
<div className="relative h-2 w-full rounded-full bg-gradient-to-r from-indigo-200 to-indigo-400 dark:from-indigo-500/30 dark:to-indigo-500/60"> <div className="relative h-2 w-full rounded-full bg-gradient-to-r from-accent-soft to-accent">
{boundaries.slice(1).map((b) => ( {boundaries.slice(1).map((b) => (
<div <div
key={b.year} key={b.year}
className="absolute top-0 h-2 w-px bg-white/70 dark:bg-zinc-900/70" className="absolute top-0 h-2 w-px bg-surface/70"
style={{ left: pct(minAge + b.year) }} style={{ left: pct(minAge + b.year) }}
/> />
))} ))}
</div> </div>
{/* Alters-Beschriftung */} {/* Alters-Beschriftung */}
<div className="mt-1 flex justify-between text-[11px] text-zinc-500"> <div className="mt-1 flex justify-between text-[11px] text-muted">
<span>{minAge} J.</span> <span>{minAge} J.</span>
<span>{maxAge} J.</span> <span>{maxAge} J.</span>
</div> </div>
+2 -2
View File
@@ -23,7 +23,7 @@ export interface TimelineSeries {
// ueberlagerte Plaene fuer den Szenario-Vergleich. // ueberlagerte Plaene fuer den Szenario-Vergleich.
export function WealthChart({ series }: { series: TimelineSeries[] }) { export function WealthChart({ series }: { series: TimelineSeries[] }) {
if (series.length === 0 || series[0].computed.phases.length === 0) { if (series.length === 0 || series[0].computed.phases.length === 0) {
return <p className="text-sm text-zinc-500">Noch keine Phasen vorhanden.</p>; return <p className="text-sm text-muted">Noch keine Phasen vorhanden.</p>;
} }
// Datenpunkte je Phasen-Index; X-Achse = Phasenname des Hauptplans. // Datenpunkte je Phasen-Index; X-Achse = Phasenname des Hauptplans.
@@ -46,7 +46,7 @@ export function WealthChart({ series }: { series: TimelineSeries[] }) {
<div className="h-80 w-full"> <div className="h-80 w-full">
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
<LineChart data={data} margin={{ top: 8, right: 16, left: 8, bottom: 8 }}> <LineChart data={data} margin={{ top: 8, right: 16, left: 8, bottom: 8 }}>
<CartesianGrid strokeDasharray="3 3" className="stroke-zinc-200 dark:stroke-zinc-700" /> <CartesianGrid strokeDasharray="3 3" className="stroke-border" />
<XAxis dataKey="phase" tick={{ fontSize: 11 }} /> <XAxis dataKey="phase" tick={{ fontSize: 11 }} />
<YAxis <YAxis
tick={{ fontSize: 11 }} tick={{ fontSize: 11 }}
+75 -74
View File
@@ -3,10 +3,9 @@ import {
AHV_FULL_CONTRIBUTION_YEARS, AHV_FULL_CONTRIBUTION_YEARS,
AHV_MAX_ANNUAL_SINGLE, AHV_MAX_ANNUAL_SINGLE,
} from "@/lib/constants"; } from "@/lib/constants";
import { floorToThousand } from "@/lib/format";
import { num } from "@/lib/elements"; import { num } from "@/lib/elements";
import type { ElementCategory } from "@/lib/elements"; import type { ElementCategory } from "@/lib/elements";
import type { HouseholdInput, PersonRole, PlanInput } from "@/lib/types"; import type { PersonRole, PlanInput } from "@/lib/types";
export type PhaseType = "ERWERB" | "PENSION" | "MIXED"; export type PhaseType = "ERWERB" | "PENSION" | "MIXED";
export type ElementStatus = "ACTIVE" | "SOLD" | "SETTLED"; export type ElementStatus = "ACTIVE" | "SOLD" | "SETTLED";
@@ -28,11 +27,12 @@ export interface ElementPhaseComputed {
ownerRole: string | null; ownerRole: string | null;
status: ElementStatus; status: ElementStatus;
locked: boolean; // verkauft/getilgt -> in dieser Phase nicht mehr editierbar locked: boolean; // verkauft/getilgt -> in dieser Phase nicht mehr editierbar
carried: boolean; // Startwert wird aus der Vorphase fortgeschrieben (Phase >= 2)
startValue: number; // Netto-Wert zu Phasenbeginn (Aktiven +, Schulden -) startValue: number; // Netto-Wert zu Phasenbeginn (Aktiven +, Schulden -)
endValue: number; // Netto-Wert am Phasenende endValue: number; // Netto-Wert am Phasenende
incomeContribution: number; // Beitrag zum Phasen-Einkommen incomeContribution: number; // Beitrag zum Phasen-Einkommen
expenseContribution: number; // Beitrag zu den Phasen-Ausgaben expenseContribution: number; // Beitrag zu den Phasen-Ausgaben
quotaUse: number; // Betrag, der Spar-/Verzehrquote verbraucht (3a/Sonstiges Vermoegen) quotaUse: number; // Betrag, der Spar-/Verzehrquote verbraucht (3a/Vermoegen/Amort./Tilgung)
capitalUse: number; // verbrauchtes verfuegbares Startkapital (Aufstockung/Neuinvestition) capitalUse: number; // verbrauchtes verfuegbares Startkapital (Aufstockung/Neuinvestition)
summary: string; // Kennzahl fuer die eingeklappte Zelle summary: string; // Kennzahl fuer die eingeklappte Zelle
note: string | null; // z. B. "Verkauft", "Getilgt", "Vollstaendig bezogen" note: string | null; // z. B. "Verkauft", "Getilgt", "Vollstaendig bezogen"
@@ -51,9 +51,11 @@ export interface PhaseComputed {
quota: number; // Einkommen - Ausgaben (Sparquote wenn >=0, Verzehrquote wenn <0) quota: number; // Einkommen - Ausgaben (Sparquote wenn >=0, Verzehrquote wenn <0)
isConsumption: boolean; isConsumption: boolean;
quotaAllocated: number; // verteilte Sparbeitraege bzw. gedeckte Verzehr-Bezuege quotaAllocated: number; // verteilte Sparbeitraege bzw. gedeckte Verzehr-Bezuege
quotaRemaining: number; // |quota| - quotaAllocated (offener Rest, kann negativ = ueberzogen)
quotaComplete: boolean; quotaComplete: boolean;
availableCapital: number | null; // null in der ersten Phase availableCapital: number | null; // null in der ersten Phase
availableCapitalUsed: number; availableCapitalUsed: number;
availableCapitalRemaining: number; // 0 in der ersten Phase
availableCapitalComplete: boolean; availableCapitalComplete: boolean;
incomplete: boolean; // roter Status (Quote/Kapital nicht vollstaendig verteilt) incomplete: boolean; // roter Status (Quote/Kapital nicht vollstaendig verteilt)
elements: ElementPhaseComputed[]; elements: ElementPhaseComputed[];
@@ -68,28 +70,16 @@ export interface PlanComputed {
nachlass: number; nachlass: number;
} }
// Loest das effektive Pensionsalter einer Person auf (Plan-Override vor Profil-Default).
export function resolveRetirementAge(
role: PersonRole,
plan: { retirementAgeA: number | null; retirementAgeB: number | null },
profileDefault: number
): number {
const override = role === "PERSON_A" ? plan.retirementAgeA : plan.retirementAgeB;
return override ?? profileDefault;
}
// Maximale Dauer einer neuen Phase, die yearsBefore Jahre nach Planbeginn startet: // Maximale Dauer einer neuen Phase, die yearsBefore Jahre nach Planbeginn startet:
// bis zum naechsten Pensionsereignis einer noch erwerbenden Person (null = unbegrenzt). // bis zum naechsten Pensionsereignis einer noch erwerbenden Person (null = unbegrenzt).
export function maxPhaseDuration( export function maxPhaseDuration(
persons: { role: PersonRole; age: number; retirementAge: number }[], persons: { role: PersonRole; age: number; retirementAge: number }[],
plan: { retirementAgeA: number | null; retirementAgeB: number | null },
yearsBefore: number yearsBefore: number
): number | null { ): number | null {
const caps: number[] = []; const caps: number[] = [];
for (const p of persons) { for (const p of persons) {
const ra = resolveRetirementAge(p.role, plan, p.retirementAge);
const startAge = p.age + yearsBefore; const startAge = p.age + yearsBefore;
if (startAge < ra) caps.push(ra - startAge); if (startAge < p.retirementAge) caps.push(p.retirementAge - startAge);
} }
return caps.length > 0 ? Math.min(...caps) : null; return caps.length > 0 ? Math.min(...caps) : null;
} }
@@ -108,23 +98,23 @@ function emptyCarry(): Carry {
return { status: "ACTIVE", value: 0, mortgage: 0, owed: 0, pkPensionAnnual: 0, hasCarry: false }; return { status: "ACTIVE", value: 0, mortgage: 0, owed: 0, pkPensionAnnual: 0, hasCarry: false };
} }
// Zinseszins mit jaehrlichem Beitrag; kein Zwischen-Runden mehr (1'000er-Konzept entfernt),
// nur das Endresultat wird auf ganze Franken gerundet.
function growAsset(startValue: number, expectedReturn: number, annual: number, years: number): number { function growAsset(startValue: number, expectedReturn: number, annual: number, years: number): number {
let v = floorToThousand(startValue); let v = startValue;
for (let y = 0; y < years; y++) { for (let y = 0; y < years; y++) {
v = floorToThousand(v * (1 + expectedReturn / 100) + annual); v = v * (1 + expectedReturn / 100) + annual;
} }
return Math.max(0, v); return Math.max(0, Math.round(v));
} }
export function computePlan(plan: PlanInput, household: HouseholdInput): PlanComputed { export function computePlan(plan: PlanInput): PlanComputed {
const phases = [...plan.phases].sort((a, b) => a.sequenceNumber - b.sequenceNumber); const phases = [...plan.phases].sort((a, b) => a.sequenceNumber - b.sequenceNumber);
const persons = household.persons; const persons = plan.persons;
// Pensionsalter je Person (aufgeloest). // Pensionsalter je Person (liegt direkt am plan-eigenen Personensatz).
const retirementAge = new Map<string, number>(); const retirementAge = new Map<string, number>();
for (const p of persons) { for (const p of persons) retirementAge.set(p.id, p.retirementAge);
retirementAge.set(p.id, resolveRetirementAge(p.role, plan, p.retirementAge));
}
// Kumulierte AHV-Ausfalljahre je Person (ueber die Erwerbsphasen aufsummiert). // Kumulierte AHV-Ausfalljahre je Person (ueber die Erwerbsphasen aufsummiert).
const gapYearsByPerson = new Map<string, number>(); const gapYearsByPerson = new Map<string, number>();
@@ -185,14 +175,14 @@ export function computePlan(plan: PlanInput, household: HouseholdInput): PlanCom
if (!owner || workingByPerson.get(owner.id)) continue; // nur pensionierte Personen if (!owner || workingByPerson.get(owner.id)) continue; // nur pensionierte Personen
const gap = gapYearsByPerson.get(owner.id) ?? 0; const gap = gapYearsByPerson.get(owner.id) ?? 0;
const factor = Math.max(0, (AHV_FULL_CONTRIBUTION_YEARS - gap) / AHV_FULL_CONTRIBUTION_YEARS); const factor = Math.max(0, (AHV_FULL_CONTRIBUTION_YEARS - gap) / AHV_FULL_CONTRIBUTION_YEARS);
ahvUncapped.set(owner.id, floorToThousand(AHV_MAX_ANNUAL_SINGLE * factor)); ahvUncapped.set(owner.id, Math.round(AHV_MAX_ANNUAL_SINGLE * factor));
} }
const ahvFinal = new Map(ahvUncapped); const ahvFinal = new Map(ahvUncapped);
if (household.householdType === "COUPLE" && ahvUncapped.size === 2) { if (plan.householdType === "COUPLE" && ahvUncapped.size === 2) {
const sum = [...ahvUncapped.values()].reduce((a, b) => a + b, 0); const sum = [...ahvUncapped.values()].reduce((a, b) => a + b, 0);
const cap = AHV_MAX_ANNUAL_SINGLE * AHV_COUPLE_CAP_FACTOR; const cap = AHV_MAX_ANNUAL_SINGLE * AHV_COUPLE_CAP_FACTOR;
if (sum > cap && sum > 0) { if (sum > cap && sum > 0) {
for (const [pid, v] of ahvUncapped) ahvFinal.set(pid, floorToThousand(v * (cap / sum))); for (const [pid, v] of ahvUncapped) ahvFinal.set(pid, Math.round(v * (cap / sum)));
} }
} }
@@ -217,6 +207,7 @@ export function computePlan(plan: PlanInput, household: HouseholdInput): PlanCom
ownerRole: e.ownerRole, ownerRole: e.ownerRole,
status: carry.status, status: carry.status,
locked: carry.status !== "ACTIVE", locked: carry.status !== "ACTIVE",
carried: carry.hasCarry,
startValue: 0, startValue: 0,
endValue: 0, endValue: 0,
incomeContribution: 0, incomeContribution: 0,
@@ -242,14 +233,14 @@ export function computePlan(plan: PlanInput, household: HouseholdInput): PlanCom
switch (e.category) { switch (e.category) {
case "INCOME": { case "INCOME": {
const amount = floorToThousand(num(pd.amount)); const amount = Math.round(num(pd.amount));
ec.incomeContribution = amount; ec.incomeContribution = amount;
incomeTotal += amount; incomeTotal += amount;
ec.summary = fmt(amount); ec.summary = fmt(amount);
break; break;
} }
case "EXPENSE": { case "EXPENSE": {
const amount = floorToThousand(num(pd.amount)); const amount = Math.round(num(pd.amount));
ec.expenseContribution = amount; ec.expenseContribution = amount;
expenseTotal += amount; expenseTotal += amount;
ec.summary = fmt(amount); ec.summary = fmt(amount);
@@ -278,13 +269,15 @@ export function computePlan(plan: PlanInput, household: HouseholdInput): PlanCom
ec.note = "Vollstaendig bezogen"; ec.note = "Vollstaendig bezogen";
ec.summary = "Bezogen"; ec.summary = "Bezogen";
} else { } else {
const start = floorToThousand(num(pd.currentValue)); const base = carry.hasCarry ? carry.value : Math.round(num(pd.currentValue));
const contribution = floorToThousand(num(pd.annualContribution)); const topUp = carry.hasCarry ? Math.round(num(pd.additionalInvestment)) : 0;
const start = base + topUp;
const contribution = Math.round(num(pd.annualContribution));
const r = num(pd.expectedReturn); const r = num(pd.expectedReturn);
ec.startValue = start; ec.startValue = start;
ec.endValue = growAsset(start, r, contribution, phase.durationYears); ec.endValue = growAsset(start, r, contribution, phase.durationYears);
ec.capitalUse = Math.max(0, start - carry.value); ec.capitalUse = topUp;
capitalUsed += ec.capitalUse; capitalUsed += topUp;
// PK-Beitraege zaehlen NICHT gegen die Sparquote (in Ausgaben enthalten). // PK-Beitraege zaehlen NICHT gegen die Sparquote (in Ausgaben enthalten).
ec.summary = fmt(ec.endValue); ec.summary = fmt(ec.endValue);
} }
@@ -295,13 +288,15 @@ export function computePlan(plan: PlanInput, household: HouseholdInput): PlanCom
ec.note = "Vollstaendig bezogen"; ec.note = "Vollstaendig bezogen";
ec.summary = "Bezogen"; ec.summary = "Bezogen";
} else { } else {
const start = floorToThousand(num(pd.currentValue)); const base = carry.hasCarry ? carry.value : Math.round(num(pd.currentValue));
const contribution = roundToHundred(num(pd.annualContribution)); const topUp = carry.hasCarry ? Math.round(num(pd.additionalInvestment)) : 0;
const start = base + topUp;
const contribution = Math.round(num(pd.annualContribution));
const r = num(pd.expectedReturn); const r = num(pd.expectedReturn);
ec.startValue = start; ec.startValue = start;
ec.endValue = growAsset(start, r, contribution, phase.durationYears); ec.endValue = growAsset(start, r, contribution, phase.durationYears);
ec.capitalUse = Math.max(0, start - carry.value); ec.capitalUse = topUp;
capitalUsed += ec.capitalUse; capitalUsed += topUp;
ec.quotaUse = contribution; // zaehlt gegen die Sparquote ec.quotaUse = contribution; // zaehlt gegen die Sparquote
quotaAllocated += contribution; quotaAllocated += contribution;
ec.summary = fmt(ec.endValue); ec.summary = fmt(ec.endValue);
@@ -309,15 +304,16 @@ export function computePlan(plan: PlanInput, household: HouseholdInput): PlanCom
break; break;
} }
case "OTHER_ASSET": { case "OTHER_ASSET": {
const start = floorToThousand(num(pd.startValue)); const base = carry.hasCarry ? carry.value : Math.round(num(pd.startValue));
const contribution = floorToThousand(num(pd.annualContribution)); const topUp = carry.hasCarry ? Math.round(num(pd.additionalInvestment)) : 0;
const start = base + topUp;
const contribution = Math.round(num(pd.annualContribution));
const r = num(pd.expectedReturn); const r = num(pd.expectedReturn);
ec.startValue = start; ec.startValue = start;
ec.capitalUse = Math.max(0, start - carry.value); ec.capitalUse = topUp;
capitalUsed += ec.capitalUse; capitalUsed += topUp;
// In Erwerbsphasen (Sparen) wird eingezahlt, in Verzehrphasen bezogen -- das // In Erwerbsphasen Sparbeitrag, in Verzehrphasen Bezugsrate -- beides zaehlt gegen
// Vorzeichen ergibt sich aus der Phasenquote (siehe unten). Hier immer als // die Quote (Vorzeichen ergibt sich aus der Phasenquote).
// Beitrag verbucht; die Verzehr-Logik nutzt denselben Betrag als Bezug.
ec.quotaUse = contribution; ec.quotaUse = contribution;
quotaAllocated += contribution; quotaAllocated += contribution;
ec.endValue = growAsset(start, r, contribution, phase.durationYears); ec.endValue = growAsset(start, r, contribution, phase.durationYears);
@@ -325,9 +321,9 @@ export function computePlan(plan: PlanInput, household: HouseholdInput): PlanCom
break; break;
} }
case "REAL_ESTATE": { case "REAL_ESTATE": {
const purchase = floorToThousand(num(pd.purchasePrice)); const purchase = Math.round(num(pd.purchasePrice));
const mortgageStart = carry.hasCarry ? carry.mortgage : floorToThousand(num(pd.mortgage)); const mortgageStart = carry.hasCarry ? carry.mortgage : Math.round(num(pd.mortgage));
const amort = floorToThousand(num(pd.amortization)); const amort = Math.round(num(pd.amortization));
const mortgageEnd = Math.max(0, mortgageStart - amort * phase.durationYears); const mortgageEnd = Math.max(0, mortgageStart - amort * phase.durationYears);
ec.startValue = purchase - mortgageStart; ec.startValue = purchase - mortgageStart;
ec.endValue = purchase - mortgageEnd; ec.endValue = purchase - mortgageEnd;
@@ -335,17 +331,23 @@ export function computePlan(plan: PlanInput, household: HouseholdInput): PlanCom
ec.capitalUse = Math.max(0, purchase - mortgageStart); // Eigenkapital bei Neukauf ec.capitalUse = Math.max(0, purchase - mortgageStart); // Eigenkapital bei Neukauf
capitalUsed += ec.capitalUse; capitalUsed += ec.capitalUse;
} }
// Amortisation ist quotenwirksam (jaehrlicher Budgetbetrag).
ec.quotaUse = amort;
quotaAllocated += amort;
carry.mortgage = mortgageEnd; // fuer Uebergang carry.mortgage = mortgageEnd; // fuer Uebergang
ec.summary = fmt(ec.endValue); ec.summary = fmt(ec.endValue);
break; break;
} }
case "OTHER_DEBT": { case "OTHER_DEBT": {
const owedStart = carry.hasCarry ? carry.owed : floorToThousand(num(pd.startValue)); const owedStart = carry.hasCarry ? carry.owed : Math.round(num(pd.startValue));
const repay = floorToThousand(num(pd.annualRepayment)); const repay = Math.round(num(pd.annualRepayment));
const owedEnd = Math.max(0, owedStart - repay * phase.durationYears); const owedEnd = Math.max(0, owedStart - repay * phase.durationYears);
ec.startValue = -owedStart; ec.startValue = -owedStart;
ec.endValue = -owedEnd; ec.endValue = -owedEnd;
carry.owed = owedEnd; carry.owed = owedEnd;
// Tilgung ist quotenwirksam (jaehrlicher Budgetbetrag).
ec.quotaUse = repay;
quotaAllocated += repay;
ec.summary = fmt(ec.endValue); ec.summary = fmt(ec.endValue);
if (owedEnd === 0) ec.note = "Wird getilgt"; if (owedEnd === 0) ec.note = "Wird getilgt";
break; break;
@@ -361,16 +363,18 @@ export function computePlan(plan: PlanInput, household: HouseholdInput): PlanCom
// Sparphase: alles verteilt, wenn quotaAllocated == quota. Verzehrphase: gedeckt, // Sparphase: alles verteilt, wenn quotaAllocated == quota. Verzehrphase: gedeckt,
// wenn Bezuege (quotaAllocated) den Fehlbetrag decken. // wenn Bezuege (quotaAllocated) den Fehlbetrag decken.
const quotaTarget = Math.abs(quota); const quotaTarget = Math.abs(quota);
const quotaComplete = Math.abs(quotaTarget - quotaAllocated) < 1; const quotaRemaining = quotaTarget - quotaAllocated;
const quotaComplete = Math.abs(quotaRemaining) < 1;
const availableCapital = incomingCapital; const availableCapital = incomingCapital;
const availableCapitalUsed = capitalUsed; const availableCapitalUsed = capitalUsed;
const availableCapitalRemaining = availableCapital === null ? 0 : availableCapital - availableCapitalUsed;
const availableCapitalComplete = const availableCapitalComplete =
availableCapital === null || Math.abs(availableCapital - availableCapitalUsed) < 1; availableCapital === null || Math.abs(availableCapitalRemaining) < 1;
const incomplete = !quotaComplete || !availableCapitalComplete; const incomplete = !quotaComplete || !availableCapitalComplete;
const inflationRate = phase.inflationRate ?? household.inflationRateDefault; const inflationRate = phase.inflationRate ?? plan.inflationRateDefault;
cumulativeInflation = cumulativeInflation * (1 + inflationRate / 100); cumulativeInflation = cumulativeInflation * (1 + inflationRate / 100);
const startWealthNominal = elementsComputed.reduce((s, ec) => s + ec.startValue, 0); const startWealthNominal = elementsComputed.reduce((s, ec) => s + ec.startValue, 0);
@@ -389,9 +393,11 @@ export function computePlan(plan: PlanInput, household: HouseholdInput): PlanCom
quota, quota,
isConsumption, isConsumption,
quotaAllocated, quotaAllocated,
quotaRemaining,
quotaComplete, quotaComplete,
availableCapital, availableCapital,
availableCapitalUsed, availableCapitalUsed,
availableCapitalRemaining,
availableCapitalComplete, availableCapitalComplete,
incomplete, incomplete,
elements: elementsComputed, elements: elementsComputed,
@@ -409,7 +415,7 @@ export function computePlan(plan: PlanInput, household: HouseholdInput): PlanCom
const td = e.transitionValues[phase.id] ?? {}; const td = e.transitionValues[phase.id] ?? {};
const owner = e.ownerRole && e.ownerRole !== "HOUSEHOLD" ? personByRole(persons, e.ownerRole) : null; const owner = e.ownerRole && e.ownerRole !== "HOUSEHOLD" ? personByRole(persons, e.ownerRole) : null;
const ownerRetiresNext = const ownerRetiresNext =
!!owner && !!nextPhase && workingByPerson.get(owner.id) === true && retiresInPhase(owner.id, nextPhase, persons, retirementAge, yearsBefore + phase.durationYears); !!owner && !!nextPhase && workingByPerson.get(owner.id) === true && retiresInPhase(owner.id, persons, retirementAge, yearsBefore + phase.durationYears);
if (carry.status !== "ACTIVE") { if (carry.status !== "ACTIVE") {
carry.hasCarry = true; carry.hasCarry = true;
@@ -422,22 +428,22 @@ export function computePlan(plan: PlanInput, household: HouseholdInput): PlanCom
const value = ec.endValue; const value = ec.endValue;
const mode = td.payoutMode ?? "PENSION"; const mode = td.payoutMode ?? "PENSION";
if (mode === "CAPITAL") { if (mode === "CAPITAL") {
const net = floorToThousand(value * (1 - num(td.capitalTaxRate) / 100)); const net = Math.round(value * (1 - num(td.capitalTaxRate) / 100));
outgoing += net; outgoing += net;
carry.value = 0; carry.value = 0;
carry.pkPensionAnnual = 0; carry.pkPensionAnnual = 0;
} else if (mode === "PENSION") { } else if (mode === "PENSION") {
carry.pkPensionAnnual = floorToThousand((value * num(td.conversionRate)) / 100); carry.pkPensionAnnual = Math.round((value * num(td.conversionRate)) / 100);
carry.value = 0; carry.value = 0;
} else { } else {
const capital = Math.min(value, floorToThousand(num(td.capitalAmount))); const capital = Math.min(value, Math.round(num(td.capitalAmount)));
const net = floorToThousand(capital * (1 - num(td.capitalTaxRate) / 100)); const net = Math.round(capital * (1 - num(td.capitalTaxRate) / 100));
outgoing += net; outgoing += net;
carry.pkPensionAnnual = floorToThousand(((value - capital) * num(td.conversionRate)) / 100); carry.pkPensionAnnual = Math.round(((value - capital) * num(td.conversionRate)) / 100);
carry.value = 0; carry.value = 0;
} }
} else { } else {
const withdrawal = Math.min(ec.endValue, floorToThousand(num(td.withdrawal))); const withdrawal = Math.min(ec.endValue, Math.round(num(td.withdrawal)));
carry.value = ec.endValue - withdrawal; carry.value = ec.endValue - withdrawal;
outgoing += withdrawal; outgoing += withdrawal;
} }
@@ -445,11 +451,11 @@ export function computePlan(plan: PlanInput, household: HouseholdInput): PlanCom
} }
case "PILLAR_3A": { case "PILLAR_3A": {
if (ownerRetiresNext) { if (ownerRetiresNext) {
const net = floorToThousand(ec.endValue * (1 - num(td.capitalTaxRate) / 100)); const net = Math.round(ec.endValue * (1 - num(td.capitalTaxRate) / 100));
outgoing += net; outgoing += net;
carry.value = 0; carry.value = 0;
} else { } else {
const withdrawal = Math.min(ec.endValue, floorToThousand(num(td.withdrawal))); const withdrawal = Math.min(ec.endValue, Math.round(num(td.withdrawal)));
carry.value = ec.endValue - withdrawal; carry.value = ec.endValue - withdrawal;
outgoing += withdrawal; outgoing += withdrawal;
} }
@@ -466,18 +472,18 @@ export function computePlan(plan: PlanInput, household: HouseholdInput): PlanCom
} }
case "REAL_ESTATE": { case "REAL_ESTATE": {
if (td.decision === "SELL") { if (td.decision === "SELL") {
const purchase = floorToThousand(num(e.phaseValues[phase.id]?.purchasePrice)); const purchase = Math.round(num(e.phaseValues[phase.id]?.purchasePrice));
const salePrice = floorToThousand(num(td.salePrice)); const salePrice = Math.round(num(td.salePrice));
const gain = Math.max(0, salePrice - purchase); const gain = Math.max(0, salePrice - purchase);
const tax = gain * (num(td.saleTaxRate) / 100); const tax = gain * (num(td.saleTaxRate) / 100);
outgoing += floorToThousand(salePrice - carry.mortgage - tax); outgoing += Math.round(salePrice - carry.mortgage - tax);
carry.status = "SOLD"; carry.status = "SOLD";
} }
// HOLD: carry.mortgage bereits gesetzt. // HOLD: carry.mortgage bereits gesetzt.
break; break;
} }
case "OTHER_DEBT": { case "OTHER_DEBT": {
const immediate = Math.min(carry.owed, floorToThousand(num(td.immediateRepayment))); const immediate = Math.min(carry.owed, Math.round(num(td.immediateRepayment)));
if (immediate > 0) { if (immediate > 0) {
carry.owed = Math.max(0, carry.owed - immediate); carry.owed = Math.max(0, carry.owed - immediate);
outgoing -= immediate; // sofortige Tilgung mindert das verfuegbare Kapital outgoing -= immediate; // sofortige Tilgung mindert das verfuegbare Kapital
@@ -491,7 +497,7 @@ export function computePlan(plan: PlanInput, household: HouseholdInput): PlanCom
carry.hasCarry = true; carry.hasCarry = true;
} }
incomingCapital = nextPhase ? floorToThousand(outgoing) : null; incomingCapital = nextPhase ? Math.round(outgoing) : null;
yearsBefore += phase.durationYears; yearsBefore += phase.durationYears;
} }
@@ -503,11 +509,10 @@ function personByRole(persons: { id: string; role: PersonRole }[], role: string)
return persons.find((p) => p.role === role) ?? null; return persons.find((p) => p.role === role) ?? null;
} }
// Prueft, ob eine Person in der gegebenen Phase (mit gegebenem Jahres-Offset) pensioniert ist, // Prueft, ob eine Person mit dem gegebenen Jahres-Offset (zu Beginn der Folgephase) pensioniert
// obwohl sie in der Vorphase noch erwerbend war. // ist, obwohl sie in der Vorphase noch erwerbend war.
function retiresInPhase( function retiresInPhase(
personId: string, personId: string,
phase: { durationYears: number },
persons: { id: string; role: PersonRole; age: number }[], persons: { id: string; role: PersonRole; age: number }[],
retirementAge: Map<string, number>, retirementAge: Map<string, number>,
yearsBeforeNext: number yearsBeforeNext: number
@@ -519,10 +524,6 @@ function retiresInPhase(
return startAgeNext >= ra; return startAgeNext >= ra;
} }
function roundToHundred(v: number): number {
return Math.round((v || 0) / 100) * 100;
}
function fmt(v: number): string { function fmt(v: number): string {
const rounded = Math.round(v || 0); const rounded = Math.round(v || 0);
const sign = rounded < 0 ? "-" : ""; const sign = rounded < 0 ? "-" : "";
+4
View File
@@ -59,6 +59,9 @@ export interface PhaseData {
startValue?: number; startValue?: number;
expectedReturn?: number; expectedReturn?: number;
annualContribution?: number; annualContribution?: number;
// PENSION_FUND / PILLAR_3A / OTHER_ASSET (ab Phase 2): zusaetzliche Einlage aus dem
// verfuegbaren Kapital der Phase. Der Basis-Startwert wird aus der Vorphase fortgeschrieben.
additionalInvestment?: number;
// REAL_ESTATE // REAL_ESTATE
purchasePrice?: number; purchasePrice?: number;
mortgage?: number; mortgage?: number;
@@ -99,6 +102,7 @@ export const phaseDataSchema = z
startValue: nonNeg.optional(), startValue: nonNeg.optional(),
expectedReturn: z.number().min(-50).max(100).optional(), expectedReturn: z.number().min(-50).max(100).optional(),
annualContribution: nonNeg.optional(), annualContribution: nonNeg.optional(),
additionalInvestment: nonNeg.optional(),
purchasePrice: nonNeg.optional(), purchasePrice: nonNeg.optional(),
mortgage: nonNeg.optional(), mortgage: nonNeg.optional(),
amortization: nonNeg.optional(), amortization: nonNeg.optional(),
-8
View File
@@ -10,14 +10,6 @@ export function formatChf(value: number): string {
return sign + withSeparators; return sign + withSeparators;
} }
// Rundet ABwaerts auf ein Vielfaches von 1'000. Bewusst floor statt round: Betraege wie
// z. B. eine verfuegbare Sparquote von 1'450 CHF liessen sich sonst nicht vollstaendig
// auf Wertschriften verteilen (nur 1'000er-Schritte moeglich) -- durch Abrunden bleibt
// der angezeigte/verplanbare Betrag immer tatsaechlich erreichbar.
export function floorToThousand(value: number): number {
return Math.floor((value || 0) / 1000) * 1000;
}
export function parseChfInput(text: string): number { export function parseChfInput(text: string): number {
const cleaned = text.replace(/[^0-9-]/g, ""); const cleaned = text.replace(/[^0-9-]/g, "");
const parsed = parseInt(cleaned, 10); const parsed = parseInt(cleaned, 10);
+14 -27
View File
@@ -2,9 +2,10 @@ import { Prisma } from "@/generated/prisma/client";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { phaseDataSchema, transitionDataSchema } from "@/lib/elements"; import { phaseDataSchema, transitionDataSchema } from "@/lib/elements";
import type { PhaseData, TransitionData } from "@/lib/elements"; import type { PhaseData, TransitionData } from "@/lib/elements";
import type { HouseholdInput, PlanInput } from "@/lib/types"; import type { PlanInput } from "@/lib/types";
export const planInclude = { export const planInclude = {
persons: { orderBy: { role: "asc" } },
phases: { orderBy: { sequenceNumber: "asc" } }, phases: { orderBy: { sequenceNumber: "asc" } },
elements: { elements: {
orderBy: { orderIndex: "asc" }, orderBy: { orderIndex: "asc" },
@@ -13,21 +14,6 @@ export const planInclude = {
} satisfies Prisma.PlanInclude; } satisfies Prisma.PlanInclude;
export type PlanWithRelations = Prisma.PlanGetPayload<{ include: typeof planInclude }>; export type PlanWithRelations = Prisma.PlanGetPayload<{ include: typeof planInclude }>;
export type HouseholdWithPersons = Prisma.HouseholdGetPayload<{ include: { persons: true } }>;
export function toHouseholdInput(household: HouseholdWithPersons): HouseholdInput {
return {
id: household.id,
householdType: household.householdType,
inflationRateDefault: household.inflationRateDefault,
persons: household.persons.map((p) => ({
id: p.id,
role: p.role,
age: p.age,
retirementAge: p.retirementAge,
})),
};
}
function parsePhaseData(raw: unknown): PhaseData { function parsePhaseData(raw: unknown): PhaseData {
const parsed = phaseDataSchema.safeParse(raw); const parsed = phaseDataSchema.safeParse(raw);
@@ -43,8 +29,14 @@ export function toPlanInput(plan: PlanWithRelations): PlanInput {
return { return {
id: plan.id, id: plan.id,
name: plan.name, name: plan.name,
retirementAgeA: plan.retirementAgeA, householdType: plan.householdType,
retirementAgeB: plan.retirementAgeB, inflationRateDefault: plan.inflationRateDefault,
persons: plan.persons.map((p) => ({
id: p.id,
role: p.role,
age: p.age,
retirementAge: p.retirementAge,
})),
phases: plan.phases.map((phase) => ({ phases: plan.phases.map((phase) => ({
id: phase.id, id: phase.id,
sequenceNumber: phase.sequenceNumber, sequenceNumber: phase.sequenceNumber,
@@ -70,15 +62,10 @@ export function toPlanInput(plan: PlanWithRelations): PlanInput {
}; };
} }
// Liefert den Haushalt des eingeloggten Benutzers (pro Konto genau einer). // Laedt einen Plan inkl. Profil + Phasen + Elemente, aber nur wenn er dem Benutzer gehoert.
export async function getHouseholdOrNull(userId: string): Promise<HouseholdWithPersons | null> {
return prisma.household.findFirst({ where: { userId }, include: { persons: true } });
}
// Laedt einen Plan inkl. Phasen + Elemente, aber nur wenn er dem Benutzer gehoert.
export async function getOwnedPlan(planId: string, userId: string) { export async function getOwnedPlan(planId: string, userId: string) {
return prisma.plan.findFirst({ return prisma.plan.findFirst({
where: { id: planId, household: { userId } }, where: { id: planId, userId },
include: planInclude, include: planInclude,
}); });
} }
@@ -86,13 +73,13 @@ export async function getOwnedPlan(planId: string, userId: string) {
// Laedt eine Phase (Basisdaten), aber nur wenn sie dem Benutzer gehoert. // Laedt eine Phase (Basisdaten), aber nur wenn sie dem Benutzer gehoert.
export async function getOwnedPhase(phaseId: string, userId: string) { export async function getOwnedPhase(phaseId: string, userId: string) {
return prisma.phase.findFirst({ return prisma.phase.findFirst({
where: { id: phaseId, plan: { household: { userId } } }, where: { id: phaseId, plan: { userId } },
}); });
} }
// Laedt ein Element (Basisdaten), aber nur wenn es dem Benutzer gehoert. // Laedt ein Element (Basisdaten), aber nur wenn es dem Benutzer gehoert.
export async function getOwnedElement(elementId: string, userId: string) { export async function getOwnedElement(elementId: string, userId: string) {
return prisma.financialElement.findFirst({ return prisma.financialElement.findFirst({
where: { id: elementId, plan: { household: { userId } } }, where: { id: elementId, plan: { userId } },
}); });
} }
+35
View File
@@ -0,0 +1,35 @@
// Theme-Verwaltung: drei waehlbare Schemata, persistiert in localStorage und als
// data-theme am <html> gesetzt. Ohne gespeicherte Wahl folgt die Oberflaeche der
// OS-Einstellung (siehe globals.css, prefers-color-scheme).
export type Theme = "light" | "dark" | "warm";
export const THEMES: { value: Theme; label: string }[] = [
{ value: "light", label: "Hell" },
{ value: "dark", label: "Dunkel" },
{ value: "warm", label: "Warm" },
];
const STORAGE_KEY = "fpt-theme";
export function getStoredTheme(): Theme | null {
if (typeof window === "undefined") return null;
const v = window.localStorage.getItem(STORAGE_KEY);
return v === "light" || v === "dark" || v === "warm" ? v : null;
}
// Das effektiv aktive Theme (gespeicherte Wahl oder OS-Ableitung).
export function getEffectiveTheme(): Theme {
const stored = getStoredTheme();
if (stored) return stored;
if (typeof window !== "undefined" && window.matchMedia("(prefers-color-scheme: dark)").matches) {
return "dark";
}
return "light";
}
export function setTheme(theme: Theme): void {
if (typeof window === "undefined") return;
window.localStorage.setItem(STORAGE_KEY, theme);
document.documentElement.setAttribute("data-theme", theme);
}
+7 -10
View File
@@ -1,5 +1,7 @@
// Domain-Typen fuer Berechnungslogik und API-Payloads. Entkoppelt von den generierten // Domain-Typen fuer Berechnungslogik und API-Payloads. Entkoppelt von den generierten
// Prisma-Typen, damit die Berechnung unabhaengig testbar bleibt. // Prisma-Typen, damit die Berechnung unabhaengig testbar bleibt.
//
// V3-Rework: Das Grundprofil (Haushaltsform, Personen, Inflation) liegt neu direkt am Plan.
import type { ElementCategory, OwnerRole, PhaseData, TransitionData } from "@/lib/elements"; import type { ElementCategory, OwnerRole, PhaseData, TransitionData } from "@/lib/elements";
@@ -10,17 +12,9 @@ export interface PersonInput {
id: string; id: string;
role: PersonRole; role: PersonRole;
age: number; age: number;
// Bereits aufgeloestes Pensionsalter (Plan-Override oder Profil-Default).
retirementAge: number; retirementAge: number;
} }
export interface HouseholdInput {
id: string;
householdType: HouseholdType;
inflationRateDefault: number;
persons: PersonInput[];
}
export interface PhaseInput { export interface PhaseInput {
id: string; id: string;
sequenceNumber: number; sequenceNumber: number;
@@ -40,11 +34,14 @@ export interface ElementInput {
transitionValues: Record<string, TransitionData>; transitionValues: Record<string, TransitionData>;
} }
// Ein Plan ist selbsttragend: er traegt sein eigenes Grundprofil (Haushaltsform, Personen,
// Inflationsannahme) plus die Phasenkette und die finanziellen Elemente.
export interface PlanInput { export interface PlanInput {
id: string; id: string;
name: string; name: string;
retirementAgeA: number | null; householdType: HouseholdType;
retirementAgeB: number | null; inflationRateDefault: number;
persons: PersonInput[];
phases: PhaseInput[]; phases: PhaseInput[];
elements: ElementInput[]; elements: ElementInput[];
} }