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
// (ElementTransitionValue). Die kategorie-/kontextspezifischen Felder liegen als JSON,
// validiert und typisiert in der Applikationsschicht (lib/elements.ts).
//
// Rework 07/2026 (V3): Das Grundprofil (Haushaltsform, Personen, Inflation) wurde vom
// frueheren Household auf die PLAN-Ebene verschoben. Jeder Plan ist selbsttragend und
// definiert seine eigenen Personen (Alter, Pensionsalter) und Inflationsannahme.
generator client {
provider = "prisma-client"
@@ -20,7 +24,7 @@ model User {
passwordHash String
createdAt DateTime @default(now())
households Household[]
plans Plan[]
}
enum HouseholdType {
@@ -50,42 +54,27 @@ enum ElementCategory {
OTHER_DEBT
}
// Ein Haushalt (1 oder 2 Personen), gehoert genau einem Benutzer.
model Household {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
householdType HouseholdType
inflationRateDefault Float
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
persons Person[]
plans Plan[]
}
// Einzelperson im Haushalt. retirementAge ist die Standard-Annahme (im Plan uebersteuerbar).
// Einzelperson eines Plans. retirementAge ist das (plan-eigene) Pensionsalter.
model Person {
id String @id @default(cuid())
householdId String
household Household @relation(fields: [householdId], references: [id], onDelete: Cascade)
planId String
plan Plan @relation(fields: [planId], references: [id], onDelete: Cascade)
role PersonRole
age Int
retirementAge Int
@@unique([householdId, role])
@@unique([planId, role])
}
// Eine vollstaendige Phasenkette; kann Szenario eines anderen Plans sein.
// retirementAgeA/B uebersteuern das Pensionsalter der jeweiligen Person NUR fuer diesen
// Plan (null = Standard aus Person.retirementAge) -- ermoeglicht Fruehpensions-Szenarien.
// Eine vollstaendige Phasenkette; selbsttragend inkl. Grundprofil (Haushaltsform,
// Personen, Inflationsannahme). Kann Szenario eines anderen Plans sein (Deep-Copy).
model Plan {
id String @id @default(cuid())
householdId String
household Household @relation(fields: [householdId], references: [id], onDelete: Cascade)
name String
retirementAgeA Int?
retirementAgeB Int?
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
name String
householdType HouseholdType
inflationRateDefault Float
parentPlanId String?
parentPlan Plan? @relation("PlanScenarios", fields: [parentPlanId], references: [id], onDelete: SetNull)
@@ -95,6 +84,7 @@ model Plan {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
persons Person[]
phases Phase[]
elements FinancialElement[]
}
-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 { z } from "zod";
import { prisma } from "@/lib/db";
import { getHouseholdOrNull, getOwnedPhase, getOwnedPlan, toPlanInput } from "@/lib/queries";
import { getOwnedPhase, getOwnedPlan, toPlanInput } from "@/lib/queries";
import { getCurrentUserId } from "@/lib/session";
import { maxPhaseDuration } from "@/lib/calculations";
@@ -29,14 +29,13 @@ export async function PUT(
let duration = parsed.data.durationYears;
if (duration != null) {
// Dauer ans naechste Pensionsereignis kappen (Jahre vor dieser Phase).
const household = await getHouseholdOrNull(userId);
const plan = await getOwnedPlan(existing.planId, userId);
if (household && plan) {
if (plan) {
const planInput = toPlanInput(plan);
const yearsBefore = planInput.phases
.filter((p) => p.sequenceNumber < existing.sequenceNumber)
.reduce((s, p) => s + p.durationYears, 0);
const cap = maxPhaseDuration(household.persons, planInput, yearsBefore);
const cap = maxPhaseDuration(planInput.persons, yearsBefore);
if (cap != null) duration = Math.min(duration, cap);
duration = Math.max(1, duration);
}
+2 -6
View File
@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from "next/server";
import { toHouseholdInput, toPlanInput, getHouseholdOrNull, getOwnedPlan } from "@/lib/queries";
import { toPlanInput, getOwnedPlan } from "@/lib/queries";
import { getCurrentUserId } from "@/lib/session";
import { computePlan, planToCsv } from "@/lib/calculations";
@@ -12,10 +12,6 @@ export async function GET(
return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
}
const { planId } = await params;
const household = await getHouseholdOrNull(userId);
if (!household) {
return NextResponse.json({ error: "Kein Haushalt vorhanden." }, { status: 400 });
}
const plan = await getOwnedPlan(planId, userId);
if (!plan) {
@@ -23,7 +19,7 @@ export async function GET(
}
const planInput = toPlanInput(plan);
const computed = computePlan(planInput, toHouseholdInput(household));
const computed = computePlan(planInput);
const csv = planToCsv(planInput, computed);
return new NextResponse(csv, {
+21 -33
View File
@@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { prisma } from "@/lib/db";
import { getHouseholdOrNull, getOwnedPlan, toHouseholdInput, toPlanInput } from "@/lib/queries";
import { getOwnedPlan, toPlanInput } from "@/lib/queries";
import { getCurrentUserId } from "@/lib/session";
import { Prisma } from "@/generated/prisma/client";
import { computePlan, maxPhaseDuration } from "@/lib/calculations";
@@ -10,11 +10,12 @@ import { num, type PhaseData } from "@/lib/elements";
const createPhaseSchema = z.object({
name: z.string().min(1).max(120).optional(),
durationYears: z.number().int().min(1).max(80).optional(),
inflationRate: z.number().min(-20).max(50).nullable().optional(),
});
// Legt eine neue Lebensphase am Ende der Kette an. Die Dauer wird ans naechste
// Pensionsereignis gekappt. Fuer bestehende Elemente werden die Werte 1:1 bzw. mit
// den fortgeschriebenen Endbestaenden aus der Vorphase vorbelegt.
// Pensionsereignis gekappt. Fuer bestehende Elemente werden die editierbaren Felder
// vorbelegt; die Startwerte werden in der Berechnung live aus der Vorphase fortgeschrieben.
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ planId: string }> }
@@ -23,8 +24,6 @@ export async function POST(
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
const { planId } = await params;
const household = await getHouseholdOrNull(userId);
if (!household) return NextResponse.json({ error: "Kein Haushalt vorhanden." }, { status: 400 });
const plan = await getOwnedPlan(planId, userId);
if (!plan) return NextResponse.json({ error: "Plan nicht gefunden." }, { status: 404 });
@@ -32,10 +31,9 @@ export async function POST(
const parsed = createPhaseSchema.safeParse(body);
if (!parsed.success) return NextResponse.json({ error: "Ungueltige Eingabe." }, { status: 400 });
const householdInput = toHouseholdInput(household);
const planInput = toPlanInput(plan);
const yearsBefore = planInput.phases.reduce((s, p) => s + p.durationYears, 0);
const cap = maxPhaseDuration(household.persons, planInput, yearsBefore);
const cap = maxPhaseDuration(planInput.persons, yearsBefore);
let duration = parsed.data.durationYears ?? (cap ?? 10);
if (cap != null) duration = Math.min(duration, cap);
@@ -44,18 +42,15 @@ export async function POST(
const nextSequence = planInput.phases.length + 1;
// Phasentyp der neuen Phase fuer den Default-Namen bestimmen.
const anyRetiredAtStart = household.persons.some((p) => {
const ra = p.role === "PERSON_A" ? planInput.retirementAgeA ?? p.retirementAge : planInput.retirementAgeB ?? p.retirementAge;
return p.age + yearsBefore >= ra;
});
const anyRetiredAtStart = planInput.persons.some((p) => p.age + yearsBefore >= p.retirementAge);
const defaultName =
parsed.data.name ?? (nextSequence === 1 ? "Erste Lebensphase" : anyRetiredAtStart ? "Pensionsphase" : "Erwerbsphase");
// Endbestaende der bisher letzten Phase (fuer Carry-Vorbelegung).
const prevComputed = planInput.phases.length > 0 ? computePlan(planInput, householdInput) : null;
// Status der Elemente in der bisher letzten Phase (verkauft/getilgt nicht fortfuehren).
const prevComputed = planInput.phases.length > 0 ? computePlan(planInput) : null;
const lastPhaseId = planInput.phases.at(-1)?.id;
const prevPhase = prevComputed?.phases.find((p) => p.id === lastPhaseId) ?? null;
const prevElemById = new Map((prevPhase?.elements ?? []).map((e) => [e.elementId, e]));
const prevStatusById = new Map((prevPhase?.elements ?? []).map((e) => [e.elementId, e.status]));
const phase = await prisma.$transaction(async (tx) => {
const created = await tx.phase.create({
@@ -64,15 +59,15 @@ export async function POST(
sequenceNumber: nextSequence,
name: defaultName,
durationYears: duration,
inflationRate: parsed.data.inflationRate ?? undefined,
},
});
// Carry-Vorbelegung fuer bestehende Elemente.
// Vorbelegung der editierbaren Felder bestehender Elemente.
for (const e of planInput.elements) {
const prev = prevElemById.get(e.id);
if (prev && prev.status !== "ACTIVE") continue; // verkauft/getilgt -> nicht mehr fortfuehren
if ((prevStatusById.get(e.id) ?? "ACTIVE") !== "ACTIVE") continue; // verkauft/getilgt
const prevData: PhaseData = e.phaseValues[lastPhaseId ?? ""] ?? {};
const data: PhaseData = buildCarryData(e.category, prevData, prev?.endValue);
const data: PhaseData = buildCarryData(e.category, prevData);
await tx.elementPhaseValue.create({
data: { elementId: e.id, phaseId: created.id, data: data as Prisma.InputJsonValue },
});
@@ -84,12 +79,10 @@ export async function POST(
return NextResponse.json({ phase: { id: phase.id } }, { status: 201 });
}
function buildCarryData(
category: string,
prev: PhaseData,
prevEndValue: number | undefined
): PhaseData {
const endVal = Math.max(0, Math.round(prevEndValue ?? 0));
// Vorbelegung fuer eine neue Phase: nur die editierbaren Felder werden uebernommen. Start-
// bzw. Restwerte (PK/3a/Vermoegen/Hypothek/Schuld) werden in der Berechnung live aus der
// Vorphase fortgeschrieben und deshalb hier NICHT als Snapshot gespeichert.
function buildCarryData(category: string, prev: PhaseData): PhaseData {
switch (category) {
case "INCOME":
case "EXPENSE":
@@ -98,18 +91,13 @@ function buildCarryData(
return { gapYears: 0 };
case "PENSION_FUND":
case "PILLAR_3A":
return { currentValue: endVal, annualContribution: num(prev.annualContribution), expectedReturn: num(prev.expectedReturn) };
case "OTHER_ASSET":
return { startValue: endVal, annualContribution: num(prev.annualContribution), expectedReturn: num(prev.expectedReturn) };
return { annualContribution: num(prev.annualContribution), expectedReturn: num(prev.expectedReturn) };
case "REAL_ESTATE":
// endValue = purchase - mortgage; Hypothek fortschreiben ueber purchasePrice - endValue.
return {
purchasePrice: num(prev.purchasePrice),
mortgage: Math.max(0, num(prev.purchasePrice) - endVal),
amortization: num(prev.amortization),
};
// purchasePrice + amortization bleiben; die Resthypothek wird live fortgeschrieben.
return { purchasePrice: num(prev.purchasePrice), amortization: num(prev.amortization) };
case "OTHER_DEBT":
return { startValue: Math.abs(endVal), annualRepayment: num(prev.annualRepayment) };
return { annualRepayment: num(prev.annualRepayment) };
default:
return {};
}
+32 -18
View File
@@ -1,9 +1,10 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { prisma } from "@/lib/db";
import { toHouseholdInput, toPlanInput, getHouseholdOrNull, getOwnedPlan } from "@/lib/queries";
import { toPlanInput, getOwnedPlan } from "@/lib/queries";
import { getCurrentUserId } from "@/lib/session";
import { computePlan } from "@/lib/calculations";
import { planProfileSchema, validatePersonsForType } from "@/app/api/plans/route";
export async function GET(
_request: NextRequest,
@@ -13,24 +14,20 @@ export async function GET(
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
const { planId } = await params;
const household = await getHouseholdOrNull(userId);
if (!household) return NextResponse.json({ error: "Kein Haushalt vorhanden." }, { status: 400 });
const plan = await getOwnedPlan(planId, userId);
if (!plan) return NextResponse.json({ error: "Plan nicht gefunden." }, { status: 404 });
const householdInput = toHouseholdInput(household);
const planInput = toPlanInput(plan);
const computed = computePlan(planInput, householdInput);
const computed = computePlan(planInput);
return NextResponse.json({ plan: planInput, computed });
}
const patchSchema = z.object({
name: z.string().min(1).max(120).optional(),
retirementAgeA: z.number().int().min(30).max(100).nullable().optional(),
retirementAgeB: z.number().int().min(30).max(100).nullable().optional(),
});
// Name allein aendern ODER das ganze Plan-Profil (Haushaltsform/Personen/Inflation).
const patchSchema = z.union([
z.object({ name: z.string().min(1).max(120) }),
planProfileSchema.extend({ name: z.string().min(1).max(120).optional() }),
]);
export async function PATCH(
request: NextRequest,
@@ -40,20 +37,37 @@ export async function PATCH(
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
const { planId } = await params;
const plan = await prisma.plan.findFirst({ where: { id: planId, household: { userId } } });
const plan = await prisma.plan.findFirst({ where: { id: planId, userId } });
if (!plan) return NextResponse.json({ error: "Plan nicht gefunden." }, { status: 404 });
const body = await request.json();
const parsed = patchSchema.safeParse(body);
if (!parsed.success) return NextResponse.json({ error: "Ungueltige Eingabe." }, { status: 400 });
const data = parsed.data;
const hasProfile = "householdType" in data;
if (hasProfile) {
const error = validatePersonsForType(data);
if (error) return NextResponse.json({ error }, { status: 400 });
const updated = await prisma.$transaction(async (tx) => {
await tx.person.deleteMany({ where: { planId: plan.id } });
return tx.plan.update({
where: { id: plan.id },
data: {
name: data.name ?? undefined,
householdType: data.householdType,
inflationRateDefault: data.inflationRateDefault,
persons: { create: data.persons },
},
});
});
return NextResponse.json({ plan: { id: updated.id, name: updated.name } });
}
const updated = await prisma.plan.update({
where: { id: plan.id },
data: {
name: parsed.data.name ?? undefined,
retirementAgeA: parsed.data.retirementAgeA === undefined ? undefined : parsed.data.retirementAgeA,
retirementAgeB: parsed.data.retirementAgeB === undefined ? undefined : parsed.data.retirementAgeB,
},
data: { name: data.name },
});
return NextResponse.json({ plan: { id: updated.id, name: updated.name } });
}
@@ -65,7 +79,7 @@ export async function DELETE(
const userId = await getCurrentUserId();
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
const { planId } = await params;
const plan = await prisma.plan.findFirst({ where: { id: planId, household: { userId } } });
const plan = await prisma.plan.findFirst({ where: { id: planId, userId } });
if (!plan) return NextResponse.json({ error: "Plan nicht gefunden." }, { status: 404 });
await prisma.plan.delete({ where: { id: plan.id } });
return NextResponse.json({ ok: true });
+6 -3
View File
@@ -36,11 +36,14 @@ export async function POST(
const newPlanId = await prisma.$transaction(async (tx) => {
const newPlan = await tx.plan.create({
data: {
householdId: source.householdId,
userId,
name: parsed.data.name,
retirementAgeA: source.retirementAgeA,
retirementAgeB: source.retirementAgeB,
householdType: source.householdType,
inflationRateDefault: source.inflationRateDefault,
parentPlanId: source.id,
persons: {
create: source.persons.map((p) => ({ role: p.role, age: p.age, retirementAge: p.retirementAge })),
},
},
});
+36 -17
View File
@@ -1,24 +1,42 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { prisma } from "@/lib/db";
import { getHouseholdOrNull } from "@/lib/queries";
import { getCurrentUserId } from "@/lib/session";
const createPlanSchema = z.object({
name: z.string().min(1).max(120),
const personSchema = z.object({
role: z.enum(["PERSON_A", "PERSON_B"]),
age: z.number().int().min(0).max(120),
retirementAge: z.number().int().min(30).max(100),
});
// Ein Plan traegt sein eigenes Grundprofil (Haushaltsform, Personen, Inflation).
export const planProfileSchema = z.object({
householdType: z.enum(["SINGLE", "COUPLE"]),
inflationRateDefault: z.number().min(-20).max(50),
persons: z.array(personSchema).min(1).max(2),
});
const createPlanSchema = z
.object({ name: z.string().min(1).max(120) })
.and(planProfileSchema);
export function validatePersonsForType(data: z.infer<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() {
const userId = await getCurrentUserId();
if (!userId) {
return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
}
const household = await getHouseholdOrNull(userId);
if (!household) {
return NextResponse.json({ plans: [] });
}
const plans = await prisma.plan.findMany({
where: { householdId: household.id },
where: { userId },
orderBy: { createdAt: "asc" },
select: {
id: true,
@@ -40,23 +58,24 @@ export async function POST(request: NextRequest) {
if (!userId) {
return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
}
const household = await getHouseholdOrNull(userId);
if (!household) {
return NextResponse.json(
{ error: "Bitte zuerst das Grundprofil (Onboarding) anlegen." },
{ status: 400 }
);
}
const body = await request.json();
const parsed = createPlanSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
}
const error = validatePersonsForType(parsed.data);
if (error) return NextResponse.json({ error }, { status: 400 });
const plan = await prisma.plan.create({
data: { householdId: household.id, name: parsed.data.name },
data: {
userId,
name: parsed.data.name,
householdType: parsed.data.householdType,
inflationRateDefault: parsed.data.inflationRateDefault,
persons: { create: parsed.data.persons },
},
});
return NextResponse.json({ plan }, { status: 201 });
return NextResponse.json({ plan: { id: plan.id } }, { status: 201 });
}
+118 -14
View File
@@ -1,26 +1,130 @@
@import "tailwindcss";
:root {
--background: #f8fafc;
--foreground: #1e293b;
/* -------------------------------------------------------------------------
Semantische Farb-Tokens. Drei umschaltbare Schemata ueber data-theme am
<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 {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-bg: var(--bg);
--color-surface: var(--surface);
--color-surface-2: var(--surface-2);
--color-input: var(--input);
--color-border: var(--border);
--color-border-strong: var(--border-strong);
--color-fg: var(--fg);
--color-muted: var(--muted);
--color-faint: var(--faint);
--color-accent: var(--accent);
--color-accent-hover: var(--accent-hover);
--color-accent-fg: var(--accent-fg);
--color-accent-soft: var(--accent-soft);
--color-accent-soft-fg: var(--accent-soft-fg);
--color-danger: var(--danger);
--color-danger-soft: var(--danger-soft);
--color-success: var(--success);
--color-person-a: var(--person-a);
--color-person-b: var(--person-b);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #10131c;
--foreground: #e2e8f0;
}
}
body {
background: var(--background);
color: var(--foreground);
background: var(--bg);
color: var(--fg);
font-family: Arial, Helvetica, sans-serif;
}
+8 -1
View File
@@ -17,6 +17,9 @@ export const metadata: Metadata = {
description: "Persoenliche Finanzplanung ueber Lebensabschnittsphasen (AICDS)",
};
// Setzt data-theme aus localStorage noch vor dem ersten Paint (verhindert FOUC).
const themeInitScript = `try{var t=localStorage.getItem('fpt-theme');if(t==='light'||t==='dark'||t==='warm'){document.documentElement.setAttribute('data-theme',t);}}catch(e){}`;
export default function RootLayout({
children,
}: Readonly<{
@@ -24,9 +27,13 @@ export default function RootLayout({
}>) {
return (
<html
lang="en"
lang="de"
suppressHydrationWarning
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
>
<head>
<script dangerouslySetInnerHTML={{ __html: themeInitScript }} />
</head>
<body className="min-h-full flex flex-col">{children}</body>
</html>
);
+14 -14
View File
@@ -46,28 +46,28 @@ function LoginForm() {
}
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 (
<div className="flex flex-1 items-center justify-center px-4">
<div className="w-full max-w-sm">
<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">
<PiggyBank className="h-7 w-7 text-white" />
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-accent shadow-md">
<PiggyBank className="h-7 w-7 text-accent-fg" />
</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
</h1>
<p className="text-xs text-zinc-500 dark:text-zinc-400">
<p className="text-xs text-muted">
Persoenliche Finanzplanung ueber Lebensphasen
</p>
</div>
<form
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) => (
<button
key={m}
@@ -78,8 +78,8 @@ function LoginForm() {
}}
className={`flex-1 rounded-md px-3 py-1.5 text-sm font-medium transition-colors ${
mode === m
? "bg-white text-indigo-600 shadow-sm dark:bg-zinc-900 dark:text-indigo-400"
: "text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300"
? "bg-surface text-accent shadow-sm"
: "text-muted hover:text-fg"
}`}
>
{m === "login" ? "Anmelden" : "Registrieren"}
@@ -88,7 +88,7 @@ function LoginForm() {
</div>
<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
type="text"
autoFocus
@@ -100,7 +100,7 @@ function LoginForm() {
/>
</div>
<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
type="password"
autoComplete={mode === "login" ? "current-password" : "new-password"}
@@ -112,7 +112,7 @@ function LoginForm() {
</div>
{mode === "register" && (
<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
type="password"
autoComplete="new-password"
@@ -123,11 +123,11 @@ function LoginForm() {
/>
</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
type="submit"
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"}
</button>
+7 -19
View File
@@ -1,41 +1,29 @@
"use client";
import { useEffect, useState } from "react";
import { Onboarding } from "@/components/Onboarding";
import { AppShell } from "@/components/AppShell";
import { api } from "@/lib/api-client";
import type { HouseholdInput } from "@/lib/types";
export default function Home() {
const [household, setHousehold] = useState<HouseholdInput | null | undefined>(undefined);
const [username, setUsername] = useState<string>("");
const [username, setUsername] = useState<string | null>(null);
useEffect(() => {
Promise.all([
api.get<{ household: HouseholdInput | null }>("/api/household"),
api.get<{ user: { username: string } }>("/api/auth/me"),
])
.then(([householdData, meData]) => {
setUsername(meData.user.username);
setHousehold(householdData.household);
})
api
.get<{ user: { username: string } }>("/api/auth/me")
.then((data) => setUsername(data.user.username))
.catch(() => {
// Session abgelaufen/ungueltig -- Middleware leitet beim naechsten Request um.
window.location.href = "/login";
});
}, []);
if (household === undefined) {
if (username === null) {
return (
<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>
);
}
if (household === null) {
return <Onboarding onDone={setHousehold} />;
}
return <AppShell initialHousehold={household} username={username} />;
return <AppShell username={username} />;
}
+71 -105
View File
@@ -12,10 +12,10 @@ import {
} from "lucide-react";
import { PlanView } from "@/components/PlanView";
import { Dashboard } from "@/components/Dashboard";
import { HouseholdSettings } from "@/components/HouseholdSettings";
import { ProfileMenu } from "@/components/ProfileMenu";
import { PlanProfileFields, emptyProfileDraft, type ProfileDraft } from "@/components/PlanProfileFields";
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";
interface PlanListItem {
@@ -26,15 +26,7 @@ interface PlanListItem {
phases: { id: string; name: string; sequenceNumber: number }[];
}
export function AppShell({
initialHousehold,
username,
}: {
initialHousehold: HouseholdInput;
username: string;
}) {
const [household, setHousehold] = useState(initialHousehold);
const [showSettings, setShowSettings] = useState(false);
export function AppShell({ username }: { username: string }) {
const [plans, setPlans] = useState<PlanListItem[]>([]);
const [selectedPlanId, setSelectedPlanId] = useState<string | null>(null);
const [detail, setDetail] = useState<{ plan: PlanInput; computed: PlanComputed } | null>(null);
@@ -46,9 +38,7 @@ export function AppShell({
const loadPlans = useCallback(async (preferId?: string) => {
const data = await api.get<{ plans: PlanListItem[] }>("/api/plans");
setPlans(data.plans);
if (preferId) {
setSelectedPlanId(preferId);
}
if (preferId) setSelectedPlanId(preferId);
return data.plans;
}, []);
@@ -84,9 +74,7 @@ export function AppShell({
if (!confirm("Diesen Plan wirklich loeschen?")) return;
await api.delete(`/api/plans/${id}`);
await loadPlans();
if (selectedPlanId === id) {
setSelectedPlanId(null);
}
if (selectedPlanId === id) setSelectedPlanId(null);
}
const activePlan = plans.find((p) => p.id === selectedPlanId) ?? null;
@@ -94,10 +82,10 @@ export function AppShell({
const sidebar = (
<div className="flex h-full flex-col">
<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">
<PiggyBank className="h-5 w-5 text-white" />
<div className="flex h-8 w-8 items-center justify-center rounded-xl bg-accent">
<PiggyBank className="h-5 w-5 text-accent-fg" />
</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>
<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);
}}
className={`flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium ${
selectedPlanId === null
? "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"
selectedPlanId === null ? "bg-accent-soft text-accent-soft-fg" : "text-muted hover:bg-surface-2"
}`}
>
<LayoutDashboard className="h-4 w-4" />
@@ -118,19 +104,17 @@ export function AppShell({
</button>
<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
type="button"
onClick={() => setShowNewPlan(true)}
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" />
</button>
</div>
{plans.length === 0 && (
<p className="px-3 py-2 text-xs text-zinc-400">Noch keine Plaene.</p>
)}
{plans.length === 0 && <p className="px-3 py-2 text-xs text-faint">Noch keine Plaene.</p>}
{plans.map((p) => (
<button
key={p.id}
@@ -140,14 +124,12 @@ export function AppShell({
setSidebarOpen(false);
}}
className={`flex items-center gap-2 rounded-lg px-3 py-2 text-left text-sm ${
selectedPlanId === p.id
? "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"
selectedPlanId === p.id ? "bg-accent-soft font-medium text-accent-soft-fg" : "text-muted hover:bg-surface-2"
}`}
>
<FolderKanban className="h-4 w-4 shrink-0" />
<span className="min-w-0 flex-1 truncate">{p.name}</span>
<span className="text-[11px] text-zinc-400">{p.phases.length}</span>
<span className="text-[11px] text-faint">{p.phases.length}</span>
</button>
))}
</nav>
@@ -157,20 +139,18 @@ export function AppShell({
return (
<div className="flex min-h-screen w-full">
{/* 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">
{sidebar}
</aside>
<aside className="hidden w-60 shrink-0 border-r border-border bg-surface lg:block">{sidebar}</aside>
{/* Sidebar Mobile (Overlay) */}
{sidebarOpen && (
<div className="fixed inset-0 z-40 lg:hidden">
<div className="absolute inset-0 bg-black/40" onClick={() => setSidebarOpen(false)} />
<aside className="absolute left-0 top-0 h-full w-64 border-r border-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
type="button"
onClick={() => setSidebarOpen(false)}
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" />
</button>
@@ -181,33 +161,23 @@ export function AppShell({
{/* Hauptbereich */}
<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
type="button"
onClick={() => setSidebarOpen(true)}
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" />
</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"}
</h1>
<ProfileMenu username={username} onOpenHouseholdSettings={() => setShowSettings(true)} />
<ProfileMenu username={username} />
</header>
<main className="flex-1 px-4 py-6 lg:px-8">
{showSettings && (
<div className="mb-6">
<HouseholdSettings
household={household}
onUpdated={setHousehold}
onClose={() => setShowSettings(false)}
/>
</div>
)}
{loading && <p className="text-sm text-zinc-500">Laedt</p>}
{loading && <p className="text-sm text-muted">Laedt</p>}
{!loading && selectedPlanId === null && (
<DashboardHome
@@ -226,7 +196,7 @@ export function AppShell({
<button
type="button"
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" />
Szenario
@@ -235,23 +205,16 @@ export function AppShell({
<button
type="button"
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" />
Plan loeschen
</button>
</div>
<PlanView
plan={detail.plan}
household={household}
computed={detail.computed}
onChanged={refreshCurrent}
/>
<PlanView plan={detail.plan} computed={detail.computed} onChanged={refreshCurrent} />
{detail.plan.phases.length > 0 && (
<Dashboard plan={detail.plan} computed={detail.computed} allPlans={plans} />
)}
{detail.plan.phases.length > 0 && <Dashboard plan={detail.plan} computed={detail.computed} allPlans={plans} />}
</div>
)}
</main>
@@ -260,10 +223,8 @@ export function AppShell({
{/* Dialoge */}
{showNewPlan && (
<PlanDialog
title="Neuen Plan erstellen"
defaultName="Basisplan"
onCreate={async (name) => {
const { plan } = await api.post<{ plan: { id: string } }>("/api/plans", { name });
onCreate={async (name, profile) => {
const { plan } = await api.post<{ plan: { id: string } }>("/api/plans", { name, ...profile });
setShowNewPlan(false);
await loadPlans(plan.id);
}}
@@ -305,10 +266,8 @@ function DashboardHome({
return (
<div className="flex flex-col gap-6">
<div>
<h2 className="text-xl font-semibold text-zinc-900 dark:text-zinc-50">
Willkommen, {username}
</h2>
<p className="mt-1 text-sm text-zinc-500 dark:text-zinc-400">
<h2 className="text-xl font-semibold text-fg">Willkommen, {username}</h2>
<p className="mt-1 text-sm text-muted">
Waehlen Sie einen Plan oder erstellen Sie einen neuen, um Ihre finanzielle Zukunft zu planen.
</p>
</div>
@@ -317,16 +276,16 @@ function DashboardHome({
{plans.map((p) => (
<div
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)}
>
<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">
<FolderKanban className="h-5 w-5 text-indigo-600 dark:text-indigo-300" />
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-accent-soft">
<FolderKanban className="h-5 w-5 text-accent-soft-fg" />
</div>
<div className="min-w-0 flex-1">
<div className="truncate font-medium text-zinc-900 dark:text-zinc-100">{p.name}</div>
<div className="text-xs text-zinc-500">
<div className="truncate font-medium text-fg">{p.name}</div>
<div className="text-xs text-muted">
{p.phases.length} {p.phases.length === 1 ? "Phase" : "Phasen"}
{p.parentPlanId ? " · Szenario" : ""}
</div>
@@ -338,7 +297,7 @@ function DashboardHome({
e.stopPropagation();
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" />
</button>
@@ -349,7 +308,7 @@ function DashboardHome({
<button
type="button"
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" />
Neuer Plan
@@ -360,43 +319,50 @@ function DashboardHome({
}
function PlanDialog({
title,
defaultName,
onCreate,
onClose,
}: {
title: string;
defaultName: string;
onCreate: (name: string) => void;
onCreate: (name: string, profile: ProfileDraft) => 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 (
<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
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>
<input
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"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Name des Plans"
/>
<h2 className="text-base font-semibold text-fg">Neuen Plan erstellen</h2>
<div>
<label className="mb-1 block text-xs font-medium text-muted">Name des Plans</label>
<input
autoFocus
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-fg focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/25"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Name des Plans"
/>
</div>
<PlanProfileFields draft={draft} onChange={setDraft} />
<div className="flex gap-2">
<button
type="button"
onClick={() => onCreate(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"
disabled={saving}
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
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"
className="rounded-lg border border-border px-4 py-2 text-sm font-medium text-muted hover:bg-surface-2"
>
Abbrechen
</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
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
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}
onChange={(e) => setName(e.target.value)}
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
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}
onChange={(e) => setBranchFromPhaseId(e.target.value)}
>
@@ -447,14 +413,14 @@ function ScenarioDialog({
<button
type="button"
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
</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"
className="rounded-lg border border-border px-4 py-2 text-sm font-medium text-muted hover:bg-surface-2"
>
Abbrechen
</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." />
</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">
<h3 className="flex items-center gap-1.5 text-sm font-semibold text-zinc-800 dark:text-zinc-100">
<LineChartIcon className="h-4 w-4 text-indigo-500 dark:text-indigo-400" />
<h3 className="flex items-center gap-1.5 text-sm font-semibold text-fg">
<LineChartIcon className="h-4 w-4 text-accent" />
Vermoegensverlauf
</h3>
<a
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" />
CSV-Export
@@ -102,9 +102,9 @@ export function Dashboard({
</div>
{otherPlans.length > 0 && (
<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) => (
<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
type="checkbox"
checked={compareIds.includes(p.id)}
@@ -118,15 +118,15 @@ export function Dashboard({
<WealthChart series={series} />
</section>
<section className="rounded-xl border border-zinc-200/70 bg-white p-4 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<h3 className="mb-3 flex items-center gap-1.5 text-sm font-semibold text-zinc-800 dark:text-zinc-100">
<BarChart3 className="h-4 w-4 text-indigo-500 dark:text-indigo-400" />
<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-fg">
<BarChart3 className="h-4 w-4 text-accent" />
Vermoegensaufteilung pro Phase (Endvermoegen)
</h3>
<div className="h-72 w-full">
<ResponsiveContainer width="100%" height="100%">
<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 }} />
<YAxis
tick={{ fontSize: 11 }}
@@ -147,11 +147,11 @@ export function Dashboard({
function StatCard({ label, value, help }: { label: string; value: number; help?: string }) {
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="text-xs text-zinc-500 dark:text-zinc-400" title={help}>
<div className="rounded-xl border border-border bg-surface p-4 shadow-sm">
<div className="text-xs text-muted" title={help}>
{label}
</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
</div>
</div>
+376 -287
View File
@@ -2,7 +2,8 @@
import { useState } from "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 { CATEGORY_LABELS, num } from "@/lib/elements";
import { PILLAR_3A_MAX_ANNUAL } from "@/lib/constants";
@@ -16,6 +17,10 @@ export interface CellContext {
durationYears: number;
isRetirementTransition: boolean;
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 {
@@ -27,6 +32,337 @@ interface Props {
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) {
const [pd, setPd] = useState<PhaseData>({ ...phaseData });
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>) {
setPd((prev) => ({ ...prev, ...patch }));
}
@@ -96,254 +395,44 @@ export function ElementDetail({ element, context, phaseData, transitionData, onS
setTd((prev) => ({ ...prev, ...patch }));
}
function renderPhaseFields() {
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-zinc-500 dark:text-zinc-400">
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-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 })} />
</>
);
}
}
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-accent-soft-fg">
{CATEGORY_LABELS[element.category]}
{isTransition ? " · Uebergang" : ""}
</div>
<div className="text-base font-semibold text-fg">{element.name}</div>
</div>
<button
type="button"
onClick={onDeleteElement}
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" /> Element loeschen
</button>
</div>
function renderTransitionFields() {
switch (element.category) {
case "INCOME":
case "EXPENSE":
case "AHV":
return (
<p className="col-span-2 text-sm text-zinc-500 dark:text-zinc-400">
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 ${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 })}
/>
);
}
}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{isTransition ? (
<ElementTransitionFields element={element} context={context} td={td} setT={setT} />
) : (
<ElementPhaseFields element={element} context={context} pd={pd} setP={setP} />
)}
</div>
{error && <p className="text-sm text-danger">{error}</p>}
<div>
<button
type="button"
disabled={saving}
onClick={save}
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"}
</button>
</div>
</div>
);
}
+57 -33
View File
@@ -3,14 +3,14 @@
import { useEffect, useRef, useState } from "react";
import { ChevronDown, ChevronUp } from "lucide-react";
import { InfoBubble } from "@/components/InfoBubble";
import { floorToThousand, formatChf, parseChfInput } from "@/lib/format";
import { formatChf, parseChfInput } from "@/lib/format";
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 }) {
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}
{help && <InfoBubble text={help} />}
</label>
@@ -44,32 +44,34 @@ export function NumberField({
step={step ?? "any"}
min={min}
max={max}
onFocus={(e) => e.target.select()}
onFocus={(e) => e.currentTarget.select()}
onChange={(e) => onChange(e.target.valueAsNumber || 0)}
/>
</div>
);
}
// Roher Betrags-Input ohne Label (fuer kompakte Tabellenzellen o.ae.). Zeigt den Wert
// formatiert mit 1'000er-Trennzeichen an, solange das Feld nicht fokussiert ist, rundet
// beim Verlassen des Feldes auf ein Vielfaches von 1'000 ABwaerts (siehe lib/format.ts)
// und bietet Pfeil-Buttons zum Erhoehen/Verringern in 1'000er-Schritten.
// Optionales `max` kappt Eingaben live auf das verfuegbare Budget (z. B. Sparquote).
// Ganzzahliges Betragsfeld. Zeigt den Wert unfokussiert mit 1'000er-Trennzeichen an,
// akzeptiert fokussiert beliebige ganze Zahlen (keine Nachkommastellen) und bietet
// Pfeil-Buttons mit Klick-und-Halten-BESCHLEUNIGUNG (1 -> 10 -> 100 -> 1'000 -> ...).
// Optionales `max` (und `min`) klammern die Eingabe hart (Live-Cap).
export function MoneyInput({
value,
onChange,
className,
min = 0,
max,
}: {
value: number;
onChange: (value: number) => void;
className?: string;
min?: number;
max?: number;
}) {
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 inputRef = useRef<HTMLInputElement | null>(null);
useEffect(() => {
valueRef.current = value;
}, [value]);
@@ -77,13 +79,14 @@ export function MoneyInput({
const holdInterval = useRef<ReturnType<typeof setInterval> | null>(null);
function clamp(v: number): number {
let result = Math.max(0, v);
if (max != null) result = Math.min(result, Math.max(0, floorToThousand(max)));
let result = Math.round(v);
if (min != null) result = Math.max(min, result);
if (max != null) result = Math.min(result, Math.round(max));
return result;
}
function step(delta: number) {
onChange(clamp(floorToThousand(valueRef.current) + delta));
function stepBy(delta: number) {
onChange(clamp(Math.round(valueRef.current) + delta));
}
function stopHold() {
@@ -97,72 +100,91 @@ export function MoneyInput({
}
}
// Klick-und-Halten: sofortiger erster Schritt, nach kurzer Verzoegerung
// fortlaufende Wiederholung, bis losgelassen wird.
function startHold(delta: number) {
step(delta);
// Klick = 1 Schritt. Halten: nach 400 ms Wiederholung im 70-ms-Takt, wobei die
// Schrittweite mit der Haltedauer waechst (immer schneller).
function startHold(sign: number) {
stepBy(sign);
const start = Date.now();
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);
}
useEffect(() => stopHold, []);
const arrowBtn =
"flex flex-1 items-center justify-center text-faint hover:bg-accent-soft hover:text-accent-soft-fg";
return (
<div className={`relative ${className ?? "w-full"}`}>
<input
ref={inputRef}
type="text"
inputMode="numeric"
className={`${baseInputClass} w-full pr-6`}
value={focused ? text : formatChf(value)}
onFocus={() => {
setFocused(true);
// Default-0 sofort leeren, damit man direkt lostippen kann.
const current = Math.floor(value || 0);
const current = Math.round(value || 0);
// Default-0 sofort leeren; sonst Wert markieren, damit man ihn ueberschreiben kann.
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={() => {
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
type="button"
tabIndex={-1}
aria-label="Um 1'000 erhoehen"
aria-label="Erhoehen"
onMouseDown={(e) => {
e.preventDefault();
startHold(1000);
startHold(1);
}}
onMouseUp={stopHold}
onMouseLeave={stopHold}
onTouchStart={(e) => {
e.preventDefault();
startHold(1000);
startHold(1);
}}
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" />
</button>
<button
type="button"
tabIndex={-1}
aria-label="Um 1'000 verringern"
aria-label="Verringern"
onMouseDown={(e) => {
e.preventDefault();
startHold(-1000);
startHold(-1);
}}
onMouseUp={stopHold}
onMouseLeave={stopHold}
onTouchStart={(e) => {
e.preventDefault();
startHold(-1000);
startHold(-1);
}}
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" />
</button>
@@ -176,18 +198,20 @@ export function MoneyField({
help,
value,
onChange,
min,
max,
}: {
label: string;
help?: string;
value: number;
onChange: (value: number) => void;
min?: number;
max?: number;
}) {
return (
<div>
<FieldLabel label={label} help={help} />
<MoneyInput value={value} onChange={onChange} max={max} />
<MoneyInput value={value} onChange={onChange} min={min} max={max} />
</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)}
onMouseLeave={() => setOpen(false)}
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} />
</button>
{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}
</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 { NumberField, TextField } from "@/components/FormField";
import { api } from "@/lib/api-client";
import type { HouseholdInput, PhaseInput } from "@/lib/types";
import type { PhaseInput } from "@/lib/types";
export function PhaseDetail({
phase,
maxDurationYears,
isLast,
household,
inflationDefault,
onSaved,
onDeleted,
}: {
phase: PhaseInput;
maxDurationYears: number | null;
isLast: boolean;
household: HouseholdInput;
inflationDefault: number;
onSaved: () => void;
onDeleted: () => void;
}) {
@@ -55,14 +55,14 @@ export function PhaseDetail({
return (
<div className="flex flex-col gap-4">
<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
</div>
{isLast && (
<button
type="button"
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
</button>
@@ -81,20 +81,20 @@ export function PhaseDetail({
/>
<NumberField
label="Inflationsrate (%)"
help="Ueberschreibt die Standardannahme aus dem Grundprofil."
value={inflationRate ?? household.inflationRateDefault}
help="Ueberschreibt die Standardannahme aus dem Plan-Grundprofil."
value={inflationRate ?? inflationDefault}
step={0.1}
onChange={setInflationRate}
/>
</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>
<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"
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"}
</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,
PiggyBank,
Plus,
Settings2,
ShoppingCart,
TrendingUp,
Wallet,
X,
} from "lucide-react";
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 { PlanProfileFields, type ProfileDraft } from "@/components/PlanProfileFields";
import { api } from "@/lib/api-client";
import { formatChf } from "@/lib/format";
import {
@@ -27,12 +35,10 @@ import {
PERSON_ONLY_CATEGORIES,
num,
type ElementCategory,
type PhaseData,
} from "@/lib/elements";
import { resolveRetirementAge, type PhaseComputed, type PlanComputed } from "@/lib/calculations";
import type { ElementInput, HouseholdInput, PlanInput } from "@/lib/types";
const PERSON_A_COLOR = "#4f46e5";
const PERSON_B_COLOR = "#0ea5e9";
import type { PhaseComputed, PlanComputed } from "@/lib/calculations";
import type { ElementInput, PlanInput } from "@/lib/types";
const CATEGORY_ICON: Record<ElementCategory, React.ReactNode> = {
INCOME: <Wallet className="h-4 w-4" />,
@@ -53,6 +59,14 @@ const TRANSITION_CATEGORIES: ElementCategory[] = [
"OTHER_DEBT",
];
const VALUE_CATEGORIES: ElementCategory[] = [
"PENSION_FUND",
"PILLAR_3A",
"REAL_ESTATE",
"OTHER_ASSET",
"OTHER_DEBT",
];
type Column =
| { kind: "phase"; phase: PhaseComputed }
| { kind: "transition"; fromPhase: PhaseComputed; toPhase: PhaseComputed };
@@ -64,18 +78,19 @@ type Selection =
export function PlanView({
plan,
household,
computed,
onChanged,
}: {
plan: PlanInput;
household: HouseholdInput;
computed: PlanComputed;
onChanged: () => void;
}) {
const [selected, setSelected] = useState<Selection | null>(null);
const [collapsedCats, setCollapsedCats] = useState<Set<ElementCategory>>(new Set());
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 cols: Column[] = [];
@@ -88,12 +103,12 @@ export function PlanView({
return cols;
}, [computed.phases]);
const personAxes = household.persons.map((p) => ({
const personAxes = plan.persons.map((p) => ({
role: p.role,
label: p.role === "PERSON_A" ? "Person A" : "Person B",
currentAge: p.age,
retirementAge: resolveRetirementAge(p.role, plan, p.retirementAge),
color: p.role === "PERSON_A" ? PERSON_A_COLOR : PERSON_B_COLOR,
retirementAge: p.retirementAge,
color: p.role === "PERSON_A" ? "var(--person-a)" : "var(--person-b)",
}));
const elementsByCategory = useMemo(() => {
@@ -116,48 +131,117 @@ export function PlanView({
return !!before?.working && !!after && !after.working;
}
async function handleAddPhase() {
await api.post(`/api/plans/${plan.id}/phases`, {});
// Baut den Kontext (inkl. Live-Caps) fuer eine Phasenzelle.
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();
}
const hasPhases = computed.phases.length > 0;
const firstPhase = computed.phases[0] ?? null;
return (
<div className="flex flex-col gap-5">
<Timeline phases={computed.phases} persons={personAxes} />
{/* Pensionsalter-Overrides */}
<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">
<span className="text-xs font-semibold uppercase tracking-wide text-zinc-400">Pensionsalter (Plan)</span>
{household.persons.map((p) => (
<label key={p.role} className="flex items-center gap-1.5 text-xs text-zinc-600 dark:text-zinc-300">
{p.role === "PERSON_A" ? "Person A" : "Person B"}:
<input
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>
{/* Plan-Profil */}
<div className="flex flex-wrap items-center gap-3 rounded-xl border border-border bg-surface px-4 py-3 text-sm shadow-sm">
<span className="text-xs font-semibold uppercase tracking-wide text-faint">Grundprofil (Plan)</span>
{plan.persons.map((p) => (
<span key={p.role} className="text-xs text-muted">
{plan.householdType === "COUPLE" ? (p.role === "PERSON_A" ? "Person A" : "Person B") : "Person"}: {p.age} J., Pension {p.retirementAge}
</span>
))}
<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>
{!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">
<p className="text-sm text-zinc-500">Dieser Plan hat noch keine Lebensphasen.</p>
<div className="rounded-xl border border-dashed border-border bg-surface p-8 text-center">
<p className="text-sm text-muted">Dieser Plan hat noch keine Lebensphasen.</p>
<button
type="button"
onClick={handleAddPhase}
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"
onClick={() => setShowAddPhase(true)}
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
</button>
@@ -169,14 +253,14 @@ export function PlanView({
<button
type="button"
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
</button>
<button
type="button"
onClick={handleAddPhase}
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"
onClick={() => setShowAddPhase(true)}
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
</button>
@@ -185,11 +269,11 @@ export function PlanView({
{/* Matrix */}
{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">
<thead>
<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
</th>
{columns.map((col) =>
@@ -201,12 +285,11 @@ export function PlanView({
active={selected?.type === "phase" && selected.phaseId === col.phase.id}
/>
) : (
<th
<TransitionHeader
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"
>
Uebergang
</th>
openCount={transitionOpenCount(col.fromPhase, col.toPhase)}
onClick={() => setReviewFromPhaseId(col.fromPhase.id)}
/>
)
)}
</tr>
@@ -218,9 +301,9 @@ export function PlanView({
const collapsed = collapsedCats.has(cat);
return (
<FragmentRows key={cat}>
<tr className="bg-zinc-50/60 dark:bg-zinc-800/30">
<tr className="bg-surface-2">
<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={() =>
setCollapsedCats((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" />}
<span className="text-indigo-500 dark:text-indigo-400">{CATEGORY_ICON[cat]}</span>
<span className="text-accent">{CATEGORY_ICON[cat]}</span>
{CATEGORY_LABELS[cat]}
</span>
</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>
{!collapsed &&
els.map((el) => (
<tr key={el.id} className="hover:bg-zinc-50/50 dark:hover:bg-zinc-800/20">
<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">
<div className="truncate text-xs font-medium text-zinc-800 dark:text-zinc-200">{el.name}</div>
<tr key={el.id} className="hover:bg-surface-2">
<td className="sticky left-0 z-10 border-b border-r border-border bg-surface px-3 py-1.5">
<div className="truncate text-xs font-medium text-fg">{el.name}</div>
{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"}
</div>
)}
@@ -260,11 +343,11 @@ export function PlanView({
<td
key={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 ${
isSel ? "bg-indigo-100 dark:bg-indigo-500/20" : ""
} ${ce?.locked ? "text-zinc-400" : "text-zinc-700 dark:text-zinc-200"}`}
className={`cursor-pointer border-b border-r border-border px-2 py-1.5 text-center text-xs ${
isSel ? "bg-accent-soft" : ""
} ${ce?.locked ? "text-faint" : "text-fg"}`}
>
{ce?.summary ?? ""}
{phaseCellContent(ce)}
</td>
);
}
@@ -280,9 +363,9 @@ export function PlanView({
canTransition &&
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 ${
canTransition ? "cursor-pointer text-indigo-500" : "text-zinc-300 dark:text-zinc-600"
} ${isSel ? "bg-indigo-100 dark:bg-indigo-500/20" : "bg-indigo-50/30 dark:bg-indigo-500/5"}`}
className={`border-b border-r border-border px-2 py-1.5 text-center text-[11px] ${
canTransition ? "cursor-pointer text-accent" : "text-faint"
} ${isSel ? "bg-accent-soft" : "bg-accent-soft/40"}`}
>
{canTransition ? transitionSummary(el, col.fromPhase, col.toPhase) : "→"}
</td>
@@ -295,7 +378,7 @@ export function PlanView({
})}
{plan.elements.length === 0 && (
<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.
</td>
</tr>
@@ -307,25 +390,74 @@ export function PlanView({
{/* Detail-Panel */}
{selected && (
<div className="rounded-xl border border-indigo-200 bg-white p-4 shadow-sm dark:border-indigo-500/30 dark:bg-zinc-900">
{renderDetail()}
</div>
<div className="rounded-xl border border-accent bg-surface p-4 shadow-sm">{renderDetail()}</div>
)}
{showAdd && (
{showAdd && firstPhase && (
<AddElementDialog
household={household}
plan={plan}
firstPhase={firstPhase}
onClose={() => setShowAdd(false)}
onCreate={async (payload) => {
await api.post(`/api/plans/${plan.id}/elements`, payload);
onCreated={() => {
setShowAdd(false);
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>
);
// 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() {
if (!selected) return null;
@@ -339,7 +471,7 @@ export function PlanView({
phase={phaseInput}
maxDurationYears={phase.maxDurationYears}
isLast={isLast}
household={household}
inflationDefault={plan.inflationRateDefault}
onSaved={onChanged}
onDeleted={() => {
setSelected(null);
@@ -354,19 +486,7 @@ export function PlanView({
if (selected.type === "phaseCell") {
const phase = computed.phases.find((p) => p.id === selected.phaseId)!;
const ownerWorking = element.ownerRole && element.ownerRole !== "HOUSEHOLD"
? 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,
};
const context = buildPhaseContext(phase, element);
return (
<ElementDetail
element={element}
@@ -383,16 +503,7 @@ export function PlanView({
const fromPhase = computed.phases.find((p) => p.id === selected.fromPhaseId)!;
const toIndex = computed.phases.findIndex((p) => p.id === fromPhase.id) + 1;
const toPhase = computed.phases[toIndex];
const ce = computedElement(fromPhase.id, element.id);
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,
};
const context = buildTransitionContext(fromPhase, toPhase, element);
return (
<ElementDetail
element={element}
@@ -417,10 +528,10 @@ export function PlanView({
switch (el.category) {
case "REAL_ESTATE":
case "OTHER_ASSET":
return td.decision === "SELL" ? "Verkauf" : "Halten";
return td.decision === "SELL" ? "Verkauf" : td.decision === "HOLD" ? "Halten" : "?";
case "PENSION_FUND":
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))}` : "→";
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 }) {
const quotaLabel = phase.isConsumption ? "Verzehr" : "Sparquote";
const quotaRemaining = Math.max(0, phase.quotaRemaining);
return (
<th
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 ${
active ? "bg-indigo-100 dark:bg-indigo-500/20" : "bg-white dark:bg-zinc-900"
className={`min-w-40 cursor-pointer border-b border-r border-border px-2 py-2 text-left align-top ${
active ? "bg-accent-soft" : "bg-surface"
}`}
>
<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 ? (
<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 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"}
</span>
<span className="text-[10px] text-zinc-400">{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">{phase.durationYears} J.</span>
<span className="text-[10px] text-faint">Alter {phase.persons.map((p) => p.startAge).join("/")}</span>
</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>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))}
{!phase.quotaComplete && <span> · offen {formatChf(quotaRemaining)}</span>}
</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)}
{phase.availableCapital !== null && !phase.availableCapitalComplete && (
<span> · offen {formatChf(Math.max(0, phase.availableCapitalRemaining))}</span>
)}
</div>
</div>
</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 }) {
return <>{children}</>;
}
// --- Dialog: neues finanzielles Element mit Direkteingabe der Phase-1-Werte ---
function AddElementDialog({
household,
plan,
firstPhase,
onClose,
onCreate,
onCreated,
}: {
household: HouseholdInput;
plan: PlanInput;
firstPhase: PhaseComputed;
onClose: () => void;
onCreate: (payload: { category: ElementCategory; name: string; ownerRole: string | null }) => void;
onCreated: () => void;
}) {
const [category, setCategory] = useState<ElementCategory>("INCOME");
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 isCouple = household.householdType === "COUPLE";
const isCouple = plan.householdType === "COUPLE";
const ownerOptions = needsPerson
? isCouple
@@ -510,24 +666,62 @@ function AddElementDialog({
{ 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 (
<div className="fixed inset-0 z-40 flex items-center justify-center bg-black/40 px-4" onClick={onClose}>
<div
onClick={(e) => e.stopPropagation()}
className="flex w-full max-w-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>
<DialogShell title="Finanzielles Element" onClose={onClose} wide>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<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
value={category}
onChange={(e) => {
const c = e.target.value as ElementCategory;
setCategory(c);
setPd({});
if (PERSON_ONLY_CATEGORIES.includes(c) && ownerRole === "HOUSEHOLD") setOwnerRole("PERSON_A");
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) => (
<option key={c} value={c}>
@@ -537,20 +731,11 @@ function AddElementDialog({
</select>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-zinc-600 dark:text-zinc-400">Bezeichnung</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>
<label className="mb-1 block text-xs font-medium text-muted">Zuordnung</label>
<select
value={ownerRole}
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) => (
<option key={o.value} value={o.value}>
@@ -559,23 +744,271 @@ function AddElementDialog({
))}
</select>
</div>
<div className="flex gap-2">
<button
type="button"
onClick={() => onCreate({ category, name: name.trim() || CATEGORY_LABELS[category], ownerRole })}
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"
>
Erstellen
</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
<div className="sm:col-span-2">
<label className="mb-1 block text-xs font-medium text-muted">Bezeichnung</label>
<input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={CATEGORY_LABELS[category]}
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>
<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>
<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>
</div>
{children}
</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";
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 { getEffectiveTheme, setTheme, THEMES, type Theme } from "@/lib/theme";
export function ProfileMenu({
username,
onOpenHouseholdSettings,
}: {
username: string;
onOpenHouseholdSettings: () => void;
}) {
export function ProfileMenu({ username }: { username: string }) {
const [open, setOpen] = useState(false);
const [showPasswordDialog, setShowPasswordDialog] = useState(false);
const [theme, setThemeState] = useState<Theme>("light");
const menuRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
setThemeState(getEffectiveTheme());
}, []);
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
setOpen(false);
}
if (menuRef.current && !menuRef.current.contains(e.target as Node)) setOpen(false);
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
function chooseTheme(t: Theme) {
setTheme(t);
setThemeState(t);
}
return (
<div className="relative" ref={menuRef}>
<button
type="button"
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)}
</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>
{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="border-b border-zinc-100 px-4 py-3 dark:border-zinc-700">
<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-border px-4 py-3">
<div className="flex items-center gap-2">
<UserCircle2 className="h-4 w-4 text-indigo-500 dark:text-indigo-400" />
<span className="text-sm font-medium text-zinc-800 dark:text-zinc-100">{username}</span>
<UserCircle2 className="h-4 w-4 text-accent" />
<span className="text-sm font-medium text-fg">{username}</span>
</div>
</div>
<MenuItem
icon={<Settings className="h-4 w-4" />}
label="Grundprofil bearbeiten"
onClick={() => {
setOpen(false);
onOpenHouseholdSettings();
}}
/>
<div className="border-b border-border px-4 py-3">
<div className="mb-2 flex items-center gap-2 text-xs font-medium text-muted">
<Palette className="h-3.5 w-3.5" /> Farbschema
</div>
<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
icon={<KeyRound className="h-4 w-4" />}
label="Passwort aendern"
@@ -78,20 +96,12 @@ export function ProfileMenu({
);
}
function MenuItem({
icon,
label,
onClick,
}: {
icon: React.ReactNode;
label: string;
onClick: () => void;
}) {
function MenuItem({ icon, label, onClick }: { icon: React.ReactNode; label: string; onClick: () => void }) {
return (
<button
type="button"
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}
{label}
@@ -127,55 +137,26 @@ function ChangePasswordDialog({ onClose }: { onClose: () => void }) {
}
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 (
<div className="fixed inset-0 z-40 flex items-center justify-center bg-black/40 px-4" onClick={onClose}>
<form
onSubmit={handleSubmit}
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>
<input
type="password"
placeholder="Aktuelles Passwort"
autoComplete="current-password"
value={currentPassword}
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>}
<h2 className="text-base font-semibold text-fg">Passwort aendern</h2>
<input type="password" placeholder="Aktuelles Passwort" autoComplete="current-password" value={currentPassword} 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-danger">{error}</p>}
{done && <p className="text-sm text-success">Passwort geaendert.</p>}
<div className="flex gap-2 pt-1">
<button
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"
>
<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">
{saving ? "..." : "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"
>
<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>
+6 -6
View File
@@ -32,10 +32,10 @@ export function Timeline({ phases, persons }: { phases: PhaseComputed[]; persons
}
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">
<h3 className="text-sm font-semibold text-zinc-800 dark:text-zinc-100">Zeitachse</h3>
<div className="flex gap-3 text-xs text-zinc-500">
<h3 className="text-sm font-semibold text-fg">Zeitachse</h3>
<div className="flex gap-3 text-xs text-muted">
{persons.map((p) => (
<span key={p.role} className="flex items-center gap-1">
<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 */}
<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) => (
<div
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) }}
/>
))}
</div>
{/* 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>{maxAge} J.</span>
</div>
+2 -2
View File
@@ -23,7 +23,7 @@ export interface TimelineSeries {
// ueberlagerte Plaene fuer den Szenario-Vergleich.
export function WealthChart({ series }: { series: TimelineSeries[] }) {
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.
@@ -46,7 +46,7 @@ export function WealthChart({ series }: { series: TimelineSeries[] }) {
<div className="h-80 w-full">
<ResponsiveContainer width="100%" height="100%">
<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 }} />
<YAxis
tick={{ fontSize: 11 }}
+75 -74
View File
@@ -3,10 +3,9 @@ import {
AHV_FULL_CONTRIBUTION_YEARS,
AHV_MAX_ANNUAL_SINGLE,
} from "@/lib/constants";
import { floorToThousand } from "@/lib/format";
import { num } 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 ElementStatus = "ACTIVE" | "SOLD" | "SETTLED";
@@ -28,11 +27,12 @@ export interface ElementPhaseComputed {
ownerRole: string | null;
status: ElementStatus;
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 -)
endValue: number; // Netto-Wert am Phasenende
incomeContribution: number; // Beitrag zum Phasen-Einkommen
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)
summary: string; // Kennzahl fuer die eingeklappte Zelle
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)
isConsumption: boolean;
quotaAllocated: number; // verteilte Sparbeitraege bzw. gedeckte Verzehr-Bezuege
quotaRemaining: number; // |quota| - quotaAllocated (offener Rest, kann negativ = ueberzogen)
quotaComplete: boolean;
availableCapital: number | null; // null in der ersten Phase
availableCapitalUsed: number;
availableCapitalRemaining: number; // 0 in der ersten Phase
availableCapitalComplete: boolean;
incomplete: boolean; // roter Status (Quote/Kapital nicht vollstaendig verteilt)
elements: ElementPhaseComputed[];
@@ -68,28 +70,16 @@ export interface PlanComputed {
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:
// bis zum naechsten Pensionsereignis einer noch erwerbenden Person (null = unbegrenzt).
export function maxPhaseDuration(
persons: { role: PersonRole; age: number; retirementAge: number }[],
plan: { retirementAgeA: number | null; retirementAgeB: number | null },
yearsBefore: number
): number | null {
const caps: number[] = [];
for (const p of persons) {
const ra = resolveRetirementAge(p.role, plan, p.retirementAge);
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;
}
@@ -108,23 +98,23 @@ function emptyCarry(): Carry {
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 {
let v = floorToThousand(startValue);
let v = startValue;
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 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>();
for (const p of persons) {
retirementAge.set(p.id, resolveRetirementAge(p.role, plan, p.retirementAge));
}
for (const p of persons) retirementAge.set(p.id, p.retirementAge);
// Kumulierte AHV-Ausfalljahre je Person (ueber die Erwerbsphasen aufsummiert).
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
const gap = gapYearsByPerson.get(owner.id) ?? 0;
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);
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 cap = AHV_MAX_ANNUAL_SINGLE * AHV_COUPLE_CAP_FACTOR;
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,
status: carry.status,
locked: carry.status !== "ACTIVE",
carried: carry.hasCarry,
startValue: 0,
endValue: 0,
incomeContribution: 0,
@@ -242,14 +233,14 @@ export function computePlan(plan: PlanInput, household: HouseholdInput): PlanCom
switch (e.category) {
case "INCOME": {
const amount = floorToThousand(num(pd.amount));
const amount = Math.round(num(pd.amount));
ec.incomeContribution = amount;
incomeTotal += amount;
ec.summary = fmt(amount);
break;
}
case "EXPENSE": {
const amount = floorToThousand(num(pd.amount));
const amount = Math.round(num(pd.amount));
ec.expenseContribution = amount;
expenseTotal += amount;
ec.summary = fmt(amount);
@@ -278,13 +269,15 @@ export function computePlan(plan: PlanInput, household: HouseholdInput): PlanCom
ec.note = "Vollstaendig bezogen";
ec.summary = "Bezogen";
} else {
const start = floorToThousand(num(pd.currentValue));
const contribution = floorToThousand(num(pd.annualContribution));
const base = carry.hasCarry ? carry.value : Math.round(num(pd.currentValue));
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);
ec.startValue = start;
ec.endValue = growAsset(start, r, contribution, phase.durationYears);
ec.capitalUse = Math.max(0, start - carry.value);
capitalUsed += ec.capitalUse;
ec.capitalUse = topUp;
capitalUsed += topUp;
// PK-Beitraege zaehlen NICHT gegen die Sparquote (in Ausgaben enthalten).
ec.summary = fmt(ec.endValue);
}
@@ -295,13 +288,15 @@ export function computePlan(plan: PlanInput, household: HouseholdInput): PlanCom
ec.note = "Vollstaendig bezogen";
ec.summary = "Bezogen";
} else {
const start = floorToThousand(num(pd.currentValue));
const contribution = roundToHundred(num(pd.annualContribution));
const base = carry.hasCarry ? carry.value : Math.round(num(pd.currentValue));
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);
ec.startValue = start;
ec.endValue = growAsset(start, r, contribution, phase.durationYears);
ec.capitalUse = Math.max(0, start - carry.value);
capitalUsed += ec.capitalUse;
ec.capitalUse = topUp;
capitalUsed += topUp;
ec.quotaUse = contribution; // zaehlt gegen die Sparquote
quotaAllocated += contribution;
ec.summary = fmt(ec.endValue);
@@ -309,15 +304,16 @@ export function computePlan(plan: PlanInput, household: HouseholdInput): PlanCom
break;
}
case "OTHER_ASSET": {
const start = floorToThousand(num(pd.startValue));
const contribution = floorToThousand(num(pd.annualContribution));
const base = carry.hasCarry ? carry.value : Math.round(num(pd.startValue));
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);
ec.startValue = start;
ec.capitalUse = Math.max(0, start - carry.value);
capitalUsed += ec.capitalUse;
// In Erwerbsphasen (Sparen) wird eingezahlt, in Verzehrphasen bezogen -- das
// Vorzeichen ergibt sich aus der Phasenquote (siehe unten). Hier immer als
// Beitrag verbucht; die Verzehr-Logik nutzt denselben Betrag als Bezug.
ec.capitalUse = topUp;
capitalUsed += topUp;
// In Erwerbsphasen Sparbeitrag, in Verzehrphasen Bezugsrate -- beides zaehlt gegen
// die Quote (Vorzeichen ergibt sich aus der Phasenquote).
ec.quotaUse = contribution;
quotaAllocated += contribution;
ec.endValue = growAsset(start, r, contribution, phase.durationYears);
@@ -325,9 +321,9 @@ export function computePlan(plan: PlanInput, household: HouseholdInput): PlanCom
break;
}
case "REAL_ESTATE": {
const purchase = floorToThousand(num(pd.purchasePrice));
const mortgageStart = carry.hasCarry ? carry.mortgage : floorToThousand(num(pd.mortgage));
const amort = floorToThousand(num(pd.amortization));
const purchase = Math.round(num(pd.purchasePrice));
const mortgageStart = carry.hasCarry ? carry.mortgage : Math.round(num(pd.mortgage));
const amort = Math.round(num(pd.amortization));
const mortgageEnd = Math.max(0, mortgageStart - amort * phase.durationYears);
ec.startValue = purchase - mortgageStart;
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
capitalUsed += ec.capitalUse;
}
// Amortisation ist quotenwirksam (jaehrlicher Budgetbetrag).
ec.quotaUse = amort;
quotaAllocated += amort;
carry.mortgage = mortgageEnd; // fuer Uebergang
ec.summary = fmt(ec.endValue);
break;
}
case "OTHER_DEBT": {
const owedStart = carry.hasCarry ? carry.owed : floorToThousand(num(pd.startValue));
const repay = floorToThousand(num(pd.annualRepayment));
const owedStart = carry.hasCarry ? carry.owed : Math.round(num(pd.startValue));
const repay = Math.round(num(pd.annualRepayment));
const owedEnd = Math.max(0, owedStart - repay * phase.durationYears);
ec.startValue = -owedStart;
ec.endValue = -owedEnd;
carry.owed = owedEnd;
// Tilgung ist quotenwirksam (jaehrlicher Budgetbetrag).
ec.quotaUse = repay;
quotaAllocated += repay;
ec.summary = fmt(ec.endValue);
if (owedEnd === 0) ec.note = "Wird getilgt";
break;
@@ -361,16 +363,18 @@ export function computePlan(plan: PlanInput, household: HouseholdInput): PlanCom
// Sparphase: alles verteilt, wenn quotaAllocated == quota. Verzehrphase: gedeckt,
// wenn Bezuege (quotaAllocated) den Fehlbetrag decken.
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 availableCapitalUsed = capitalUsed;
const availableCapitalRemaining = availableCapital === null ? 0 : availableCapital - availableCapitalUsed;
const availableCapitalComplete =
availableCapital === null || Math.abs(availableCapital - availableCapitalUsed) < 1;
availableCapital === null || Math.abs(availableCapitalRemaining) < 1;
const incomplete = !quotaComplete || !availableCapitalComplete;
const inflationRate = phase.inflationRate ?? household.inflationRateDefault;
const inflationRate = phase.inflationRate ?? plan.inflationRateDefault;
cumulativeInflation = cumulativeInflation * (1 + inflationRate / 100);
const startWealthNominal = elementsComputed.reduce((s, ec) => s + ec.startValue, 0);
@@ -389,9 +393,11 @@ export function computePlan(plan: PlanInput, household: HouseholdInput): PlanCom
quota,
isConsumption,
quotaAllocated,
quotaRemaining,
quotaComplete,
availableCapital,
availableCapitalUsed,
availableCapitalRemaining,
availableCapitalComplete,
incomplete,
elements: elementsComputed,
@@ -409,7 +415,7 @@ export function computePlan(plan: PlanInput, household: HouseholdInput): PlanCom
const td = e.transitionValues[phase.id] ?? {};
const owner = e.ownerRole && e.ownerRole !== "HOUSEHOLD" ? personByRole(persons, e.ownerRole) : null;
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") {
carry.hasCarry = true;
@@ -422,22 +428,22 @@ export function computePlan(plan: PlanInput, household: HouseholdInput): PlanCom
const value = ec.endValue;
const mode = td.payoutMode ?? "PENSION";
if (mode === "CAPITAL") {
const net = floorToThousand(value * (1 - num(td.capitalTaxRate) / 100));
const net = Math.round(value * (1 - num(td.capitalTaxRate) / 100));
outgoing += net;
carry.value = 0;
carry.pkPensionAnnual = 0;
} else if (mode === "PENSION") {
carry.pkPensionAnnual = floorToThousand((value * num(td.conversionRate)) / 100);
carry.pkPensionAnnual = Math.round((value * num(td.conversionRate)) / 100);
carry.value = 0;
} else {
const capital = Math.min(value, floorToThousand(num(td.capitalAmount)));
const net = floorToThousand(capital * (1 - num(td.capitalTaxRate) / 100));
const capital = Math.min(value, Math.round(num(td.capitalAmount)));
const net = Math.round(capital * (1 - num(td.capitalTaxRate) / 100));
outgoing += net;
carry.pkPensionAnnual = floorToThousand(((value - capital) * num(td.conversionRate)) / 100);
carry.pkPensionAnnual = Math.round(((value - capital) * num(td.conversionRate)) / 100);
carry.value = 0;
}
} 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;
outgoing += withdrawal;
}
@@ -445,11 +451,11 @@ export function computePlan(plan: PlanInput, household: HouseholdInput): PlanCom
}
case "PILLAR_3A": {
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;
carry.value = 0;
} 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;
outgoing += withdrawal;
}
@@ -466,18 +472,18 @@ export function computePlan(plan: PlanInput, household: HouseholdInput): PlanCom
}
case "REAL_ESTATE": {
if (td.decision === "SELL") {
const purchase = floorToThousand(num(e.phaseValues[phase.id]?.purchasePrice));
const salePrice = floorToThousand(num(td.salePrice));
const purchase = Math.round(num(e.phaseValues[phase.id]?.purchasePrice));
const salePrice = Math.round(num(td.salePrice));
const gain = Math.max(0, salePrice - purchase);
const tax = gain * (num(td.saleTaxRate) / 100);
outgoing += floorToThousand(salePrice - carry.mortgage - tax);
outgoing += Math.round(salePrice - carry.mortgage - tax);
carry.status = "SOLD";
}
// HOLD: carry.mortgage bereits gesetzt.
break;
}
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) {
carry.owed = Math.max(0, carry.owed - immediate);
outgoing -= immediate; // sofortige Tilgung mindert das verfuegbare Kapital
@@ -491,7 +497,7 @@ export function computePlan(plan: PlanInput, household: HouseholdInput): PlanCom
carry.hasCarry = true;
}
incomingCapital = nextPhase ? floorToThousand(outgoing) : null;
incomingCapital = nextPhase ? Math.round(outgoing) : null;
yearsBefore += phase.durationYears;
}
@@ -503,11 +509,10 @@ function personByRole(persons: { id: string; role: PersonRole }[], role: string)
return persons.find((p) => p.role === role) ?? null;
}
// Prueft, ob eine Person in der gegebenen Phase (mit gegebenem Jahres-Offset) pensioniert ist,
// obwohl sie in der Vorphase noch erwerbend war.
// Prueft, ob eine Person mit dem gegebenen Jahres-Offset (zu Beginn der Folgephase) pensioniert
// ist, obwohl sie in der Vorphase noch erwerbend war.
function retiresInPhase(
personId: string,
phase: { durationYears: number },
persons: { id: string; role: PersonRole; age: number }[],
retirementAge: Map<string, number>,
yearsBeforeNext: number
@@ -519,10 +524,6 @@ function retiresInPhase(
return startAgeNext >= ra;
}
function roundToHundred(v: number): number {
return Math.round((v || 0) / 100) * 100;
}
function fmt(v: number): string {
const rounded = Math.round(v || 0);
const sign = rounded < 0 ? "-" : "";
+4
View File
@@ -59,6 +59,9 @@ export interface PhaseData {
startValue?: number;
expectedReturn?: 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
purchasePrice?: number;
mortgage?: number;
@@ -99,6 +102,7 @@ export const phaseDataSchema = z
startValue: nonNeg.optional(),
expectedReturn: z.number().min(-50).max(100).optional(),
annualContribution: nonNeg.optional(),
additionalInvestment: nonNeg.optional(),
purchasePrice: nonNeg.optional(),
mortgage: nonNeg.optional(),
amortization: nonNeg.optional(),
-8
View File
@@ -10,14 +10,6 @@ export function formatChf(value: number): string {
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 {
const cleaned = text.replace(/[^0-9-]/g, "");
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 { phaseDataSchema, transitionDataSchema } 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 = {
persons: { orderBy: { role: "asc" } },
phases: { orderBy: { sequenceNumber: "asc" } },
elements: {
orderBy: { orderIndex: "asc" },
@@ -13,21 +14,6 @@ export const planInclude = {
} satisfies Prisma.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 {
const parsed = phaseDataSchema.safeParse(raw);
@@ -43,8 +29,14 @@ export function toPlanInput(plan: PlanWithRelations): PlanInput {
return {
id: plan.id,
name: plan.name,
retirementAgeA: plan.retirementAgeA,
retirementAgeB: plan.retirementAgeB,
householdType: plan.householdType,
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) => ({
id: phase.id,
sequenceNumber: phase.sequenceNumber,
@@ -70,15 +62,10 @@ export function toPlanInput(plan: PlanWithRelations): PlanInput {
};
}
// Liefert den Haushalt des eingeloggten Benutzers (pro Konto genau einer).
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.
// Laedt einen Plan inkl. Profil + Phasen + Elemente, aber nur wenn er dem Benutzer gehoert.
export async function getOwnedPlan(planId: string, userId: string) {
return prisma.plan.findFirst({
where: { id: planId, household: { userId } },
where: { id: planId, userId },
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.
export async function getOwnedPhase(phaseId: string, userId: string) {
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.
export async function getOwnedElement(elementId: string, userId: string) {
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
// 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";
@@ -10,17 +12,9 @@ export interface PersonInput {
id: string;
role: PersonRole;
age: number;
// Bereits aufgeloestes Pensionsalter (Plan-Override oder Profil-Default).
retirementAge: number;
}
export interface HouseholdInput {
id: string;
householdType: HouseholdType;
inflationRateDefault: number;
persons: PersonInput[];
}
export interface PhaseInput {
id: string;
sequenceNumber: number;
@@ -40,11 +34,14 @@ export interface ElementInput {
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 {
id: string;
name: string;
retirementAgeA: number | null;
retirementAgeB: number | null;
householdType: HouseholdType;
inflationRateDefault: number;
persons: PersonInput[];
phases: PhaseInput[];
elements: ElementInput[];
}