Initial commit: FPT Financial Planning Tool
Deploy App / deploy (push) Successful in 3m3s

This commit is contained in:
2026-07-08 19:19:39 +02:00
commit 4f990c9686
60 changed files with 12436 additions and 0 deletions
+30
View File
@@ -0,0 +1,30 @@
import { NextRequest, NextResponse } from "next/server";
import { createSessionToken, SESSION_COOKIE_NAME } from "@/lib/auth";
import { getAppCredential, verifyAppPassword } from "@/lib/credentials";
export async function POST(request: NextRequest) {
const { password } = await request.json();
const credential = await getAppCredential();
if (!credential) {
return NextResponse.json(
{ error: "Es ist noch kein Passwort gesetzt. Bitte zuerst ein Passwort festlegen." },
{ status: 409 }
);
}
if (typeof password !== "string" || password.length === 0 || !(await verifyAppPassword(password))) {
return NextResponse.json({ error: "Falsches Passwort." }, { status: 401 });
}
const token = await createSessionToken();
const response = NextResponse.json({ ok: true });
response.cookies.set(SESSION_COOKIE_NAME, token, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
maxAge: 60 * 60 * 24 * 30,
});
return response;
}
+8
View File
@@ -0,0 +1,8 @@
import { NextResponse } from "next/server";
import { SESSION_COOKIE_NAME } from "@/lib/auth";
export async function POST() {
const response = NextResponse.json({ ok: true });
response.cookies.delete(SESSION_COOKIE_NAME);
return response;
}
+37
View File
@@ -0,0 +1,37 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { createSessionToken, SESSION_COOKIE_NAME } from "@/lib/auth";
import { getAppCredential, setAppPassword } from "@/lib/credentials";
const setupSchema = z.object({
password: z.string().min(4, "Das Passwort muss mindestens 4 Zeichen lang sein."),
});
// Legt das Login-Passwort einmalig fest. Nur solange noch keine AppCredential-Zeile
// existiert (d. h. beim allerersten Login) erreichbar -- danach ausschliesslich
// ueber /api/auth/login.
export async function POST(request: NextRequest) {
const existing = await getAppCredential();
if (existing) {
return NextResponse.json({ error: "Es ist bereits ein Passwort gesetzt." }, { status: 409 });
}
const body = await request.json();
const parsed = setupSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
}
await setAppPassword(parsed.data.password);
const token = await createSessionToken();
const response = NextResponse.json({ ok: true });
response.cookies.set(SESSION_COOKIE_NAME, token, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
maxAge: 60 * 60 * 24 * 30,
});
return response;
}
+7
View File
@@ -0,0 +1,7 @@
import { NextResponse } from "next/server";
import { getAppCredential } from "@/lib/credentials";
export async function GET() {
const credential = await getAppCredential();
return NextResponse.json({ passwordSet: credential != null });
}
+94
View File
@@ -0,0 +1,94 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { prisma } from "@/lib/db";
import { getHouseholdOrNull, toHouseholdInput } from "@/lib/queries";
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 household = await getHouseholdOrNull();
return NextResponse.json({ household: household ? toHouseholdInput(household) : null });
}
export async function POST(request: NextRequest) {
const existing = await getHouseholdOrNull();
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: {
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 existing = await getHouseholdOrNull();
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) });
}
+133
View File
@@ -0,0 +1,133 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { prisma } from "@/lib/db";
import { phaseInclude } from "@/lib/queries";
const incomeEntrySchema = z.object({
personId: z.string().nullable().optional(),
label: z.string().nullable().optional(),
amount: z.number(),
});
const expenseEntrySchema = z.object({
label: z.string().nullable().optional(),
amount: z.number(),
});
const securitySchema = z.object({
name: z.string().min(1),
startValue: z.number(),
expectedReturn: z.number(),
annualContribution: z.number(),
ownerTag: z.enum(["PERSON_A", "PERSON_B", "HOUSEHOLD"]),
saleTaxRate: z.number().min(0).max(100),
});
const realEstateSchema = z.object({
name: z.string().min(1),
marketValue: z.number(),
mortgage: z.number(),
valueGrowth: z.number(),
amortization: z.number(),
salePrice: z.number().nullable().optional(),
saleTaxRate: z.number().min(0).max(100),
});
const oneTimeEventSchema = z.object({
type: z.enum(["INCOME", "EXPENSE"]),
amount: z.number(),
description: z.string().nullable().optional(),
});
const retirementInfoSchema = z.object({
personId: z.string().min(1),
ahvAmount: z.number().min(0),
pkPensionAmount: z.number().min(0),
lumpSumAmount: z.number().min(0),
lumpSumTaxRate: z.number().min(0).max(100),
});
const updatePhaseSchema = z.object({
name: z.string().min(1).max(120),
durationYears: z.number().int().min(1).max(80),
inflationRate: z.number().min(-20).max(50).nullable().optional(),
incomeMode: z.enum(["PER_PERSON", "HOUSEHOLD"]),
incomeEntries: z.array(incomeEntrySchema).default([]),
expenseEntries: z.array(expenseEntrySchema).default([]),
securities: z.array(securitySchema).default([]),
realEstates: z.array(realEstateSchema).default([]),
oneTimeEvents: z.array(oneTimeEventSchema).default([]),
retirementInfos: z.array(retirementInfoSchema).default([]),
});
// Ersetzt eine Phase vollstaendig (Basisfelder + alle Unter-Sammlungen). Fuer ein
// Single-User-Tool ohne nennenswerte Nebenlaeufigkeit ist ein "delete + recreate" der
// Kindobjekte einfacher und robuster als granulares Diffing pro Zeile.
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ phaseId: string }> }
) {
const { phaseId } = await params;
const body = await request.json();
const parsed = updatePhaseSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
}
const data = parsed.data;
const existing = await prisma.phase.findUnique({ where: { id: phaseId } });
if (!existing) {
return NextResponse.json({ error: "Phase nicht gefunden." }, { status: 404 });
}
const phase = await prisma.$transaction(async (tx) => {
await Promise.all([
tx.incomeEntry.deleteMany({ where: { phaseId } }),
tx.expenseEntry.deleteMany({ where: { phaseId } }),
tx.security.deleteMany({ where: { phaseId } }),
tx.realEstate.deleteMany({ where: { phaseId } }),
tx.oneTimeEvent.deleteMany({ where: { phaseId } }),
tx.retirementInfo.deleteMany({ where: { phaseId } }),
]);
return tx.phase.update({
where: { id: phaseId },
data: {
name: data.name,
durationYears: data.durationYears,
inflationRate: data.inflationRate ?? null,
incomeMode: data.incomeMode,
incomeEntries: { create: data.incomeEntries.map((e) => ({ ...e, label: e.label ?? null, personId: e.personId ?? null })) },
expenseEntries: { create: data.expenseEntries.map((e) => ({ ...e, label: e.label ?? null })) },
securities: { create: data.securities },
realEstates: { create: data.realEstates.map((re) => ({ ...re, salePrice: re.salePrice ?? null })) },
oneTimeEvents: { create: data.oneTimeEvents.map((e) => ({ ...e, description: e.description ?? null })) },
retirementInfos: { create: data.retirementInfos },
},
include: phaseInclude,
});
});
return NextResponse.json({ phase });
}
// Eine Phase kann nur geloescht werden, wenn sie die letzte in der Kette ist -- so
// bleibt die Verkettung (Schlussvermoegen = Startvermoegen der Folgephase) immer intakt.
export async function DELETE(
_request: NextRequest,
{ params }: { params: Promise<{ phaseId: string }> }
) {
const { phaseId } = await params;
const phase = await prisma.phase.findUnique({ where: { id: phaseId } });
if (!phase) {
return NextResponse.json({ error: "Phase nicht gefunden." }, { status: 404 });
}
const laterPhase = await prisma.phase.findFirst({
where: { planId: phase.planId, sequenceNumber: { gt: phase.sequenceNumber } },
});
if (laterPhase) {
return NextResponse.json(
{ error: "Nur die letzte Phase eines Plans kann geloescht werden." },
{ status: 400 }
);
}
await prisma.phase.delete({ where: { id: phaseId } });
return NextResponse.json({ ok: true });
}
@@ -0,0 +1,118 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { prisma } from "@/lib/db";
const transitionItemSchema = z.object({
positionType: z.enum(["SECURITY", "REAL_ESTATE"]),
securityId: z.string().nullable().optional(),
realEstateId: z.string().nullable().optional(),
decision: z.enum(["CARRY_OVER", "SELL"]),
salePrice: z.number().nullable().optional(),
});
const putTransitionSchema = z.object({
items: z.array(transitionItemSchema),
});
// Liefert die aktuellen Positionen der Phase (Wertschriften + Immobilien) sowie eine
// evtl. bereits vorhandene Entscheidung, damit die UI den Uebergangs-Screen (TDD 4.4)
// rendern kann.
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ phaseId: string }> }
) {
const { phaseId } = await params;
const phase = await prisma.phase.findUnique({
where: { id: phaseId },
include: { securities: true, realEstates: true },
});
if (!phase) {
return NextResponse.json({ error: "Phase nicht gefunden." }, { status: 404 });
}
const nextPhase = await prisma.phase.findFirst({
where: { planId: phase.planId, sequenceNumber: phase.sequenceNumber + 1 },
});
const transition = await prisma.phaseTransition.findUnique({
where: { fromPhaseId: phaseId },
include: { items: true },
});
return NextResponse.json({
positions: {
securities: phase.securities,
realEstates: phase.realEstates,
},
nextPhase,
transition,
});
}
// Speichert die Entscheidungen (Uebernehmen/Verkaufen) fuer jede Position der Vorphase.
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ phaseId: string }> }
) {
const { phaseId } = await params;
const body = await request.json();
const parsed = putTransitionSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
}
const phase = await prisma.phase.findUnique({
where: { id: phaseId },
include: { securities: true, realEstates: true },
});
if (!phase) {
return NextResponse.json({ error: "Phase nicht gefunden." }, { status: 404 });
}
const nextPhase = await prisma.phase.findFirst({
where: { planId: phase.planId, sequenceNumber: phase.sequenceNumber + 1 },
});
if (!nextPhase) {
return NextResponse.json(
{ error: "Es existiert noch keine Folgephase fuer diesen Uebergang." },
{ status: 400 }
);
}
const requiredIds = new Set([
...phase.securities.map((s) => `SECURITY:${s.id}`),
...phase.realEstates.map((re) => `REAL_ESTATE:${re.id}`),
]);
const providedIds = new Set(
parsed.data.items.map((i) => `${i.positionType}:${i.securityId ?? i.realEstateId}`)
);
const missing = [...requiredIds].filter((id) => !providedIds.has(id));
if (missing.length > 0) {
return NextResponse.json(
{ error: "Fuer jede bestehende Position muss Uebernehmen oder Verkaufen gewaehlt werden." },
{ status: 400 }
);
}
const transition = await prisma.$transaction(async (tx) => {
await tx.phaseTransition.deleteMany({ where: { fromPhaseId: phaseId } });
return tx.phaseTransition.create({
data: {
fromPhaseId: phaseId,
toPhaseId: nextPhase.id,
items: {
create: parsed.data.items.map((i) => ({
positionType: i.positionType,
securityId: i.securityId ?? null,
realEstateId: i.realEstateId ?? null,
decision: i.decision,
salePrice: i.salePrice ?? null,
})),
},
},
include: { items: true },
});
});
return NextResponse.json({ transition });
}
@@ -0,0 +1,31 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { planInclude, toHouseholdInput, toPlanInput, getHouseholdOrNull } from "@/lib/queries";
import { computePlan, planToCsv } from "@/lib/calculations";
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ planId: string }> }
) {
const { planId } = await params;
const household = await getHouseholdOrNull();
if (!household) {
return NextResponse.json({ error: "Kein Haushalt vorhanden." }, { status: 400 });
}
const plan = await prisma.plan.findUnique({ where: { id: planId }, include: planInclude });
if (!plan) {
return NextResponse.json({ error: "Plan nicht gefunden." }, { status: 404 });
}
const planInput = toPlanInput(plan);
const computed = computePlan(planInput, toHouseholdInput(household));
const csv = planToCsv(planInput, computed);
return new NextResponse(csv, {
headers: {
"Content-Type": "text/csv; charset=utf-8",
"Content-Disposition": `attachment; filename="${plan.name.replace(/[^a-z0-9]+/gi, "_")}.csv"`,
},
});
}
@@ -0,0 +1,50 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { prisma } from "@/lib/db";
import { phaseInclude } from "@/lib/queries";
const createPhaseSchema = z.object({
name: z.string().min(1).max(120),
durationYears: z.number().int().min(1).max(80),
inflationRate: z.number().min(-20).max(50).nullable().optional(),
incomeMode: z.enum(["PER_PERSON", "HOUSEHOLD"]).default("HOUSEHOLD"),
});
// Fuegt eine neue Lebensabschnittsphase am Ende der Phasenkette eines Plans an
// (TDD Kapitel 3: Phasen werden chronologisch aneinandergereiht).
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ planId: string }> }
) {
const { planId } = await params;
const body = await request.json();
const parsed = createPhaseSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
}
const plan = await prisma.plan.findUnique({ where: { id: planId } });
if (!plan) {
return NextResponse.json({ error: "Plan nicht gefunden." }, { status: 404 });
}
const lastPhase = await prisma.phase.findFirst({
where: { planId },
orderBy: { sequenceNumber: "desc" },
});
const nextSequence = (lastPhase?.sequenceNumber ?? 0) + 1;
const phase = await prisma.phase.create({
data: {
planId,
sequenceNumber: nextSequence,
name: parsed.data.name,
durationYears: parsed.data.durationYears,
inflationRate: parsed.data.inflationRate ?? null,
incomeMode: parsed.data.incomeMode,
},
include: phaseInclude,
});
return NextResponse.json({ phase }, { status: 201 });
}
+35
View File
@@ -0,0 +1,35 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { planInclude, toHouseholdInput, toPlanInput, getHouseholdOrNull } from "@/lib/queries";
import { computePlan } from "@/lib/calculations";
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ planId: string }> }
) {
const { planId } = await params;
const household = await getHouseholdOrNull();
if (!household) {
return NextResponse.json({ error: "Kein Haushalt vorhanden." }, { status: 400 });
}
const plan = await prisma.plan.findUnique({ where: { id: planId }, include: planInclude });
if (!plan) {
return NextResponse.json({ error: "Plan nicht gefunden." }, { status: 404 });
}
const householdInput = toHouseholdInput(household);
const planInput = toPlanInput(plan);
const computed = computePlan(planInput, householdInput);
return NextResponse.json({ plan: planInput, computed });
}
export async function DELETE(
_request: NextRequest,
{ params }: { params: Promise<{ planId: string }> }
) {
const { planId } = await params;
await prisma.plan.delete({ where: { id: planId } });
return NextResponse.json({ ok: true });
}
@@ -0,0 +1,120 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { prisma } from "@/lib/db";
import { phaseInclude, planInclude } from "@/lib/queries";
const scenarioSchema = z.object({
name: z.string().min(1).max(120),
branchFromPhaseId: z.string().min(1),
});
// Erstellt ein neues Szenario als Kopie eines bestehenden Plans ab einer gewaehlten
// Phase (inklusive). Die Phasenkette bis zu diesem Punkt wird per Deep-Copy dupliziert;
// ab dort kann der Benutzer die Kette unabhaengig weiterentwickeln (TDD Kapitel 13).
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ planId: string }> }
) {
const { planId } = await params;
const body = await request.json();
const parsed = scenarioSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
}
const sourcePlan = await prisma.plan.findUnique({ where: { id: planId }, include: planInclude });
if (!sourcePlan) {
return NextResponse.json({ error: "Ursprungsplan nicht gefunden." }, { status: 404 });
}
const branchPhase = sourcePlan.phases.find((p) => p.id === parsed.data.branchFromPhaseId);
if (!branchPhase) {
return NextResponse.json({ error: "Verzweigungsphase nicht gefunden." }, { status: 404 });
}
const phasesToCopy = sourcePlan.phases
.filter((p) => p.sequenceNumber <= branchPhase.sequenceNumber)
.sort((a, b) => a.sequenceNumber - b.sequenceNumber);
const newPlanId = await prisma.$transaction(async (tx) => {
const newPlan = await tx.plan.create({
data: {
householdId: sourcePlan.householdId,
name: parsed.data.name,
parentPlanId: sourcePlan.id,
},
});
let lastNewPhaseId = "";
for (const phase of phasesToCopy) {
const newPhase = await tx.phase.create({
data: {
planId: newPlan.id,
sequenceNumber: phase.sequenceNumber,
name: phase.name,
durationYears: phase.durationYears,
inflationRate: phase.inflationRate,
incomeMode: phase.incomeMode,
incomeEntries: {
create: phase.incomeEntries.map((e) => ({
personId: e.personId,
label: e.label,
amount: e.amount,
})),
},
expenseEntries: {
create: phase.expenseEntries.map((e) => ({ label: e.label, amount: e.amount })),
},
securities: {
create: phase.securities.map((s) => ({
name: s.name,
startValue: s.startValue,
expectedReturn: s.expectedReturn,
annualContribution: s.annualContribution,
ownerTag: s.ownerTag,
saleTaxRate: s.saleTaxRate,
})),
},
realEstates: {
create: phase.realEstates.map((re) => ({
name: re.name,
marketValue: re.marketValue,
mortgage: re.mortgage,
valueGrowth: re.valueGrowth,
amortization: re.amortization,
salePrice: re.salePrice,
saleTaxRate: re.saleTaxRate,
})),
},
oneTimeEvents: {
create: phase.oneTimeEvents.map((e) => ({
type: e.type,
amount: e.amount,
description: e.description,
})),
},
retirementInfos: {
create: phase.retirementInfos.map((r) => ({
personId: r.personId,
ahvAmount: r.ahvAmount,
pkPensionAmount: r.pkPensionAmount,
lumpSumAmount: r.lumpSumAmount,
lumpSumTaxRate: r.lumpSumTaxRate,
})),
},
},
include: phaseInclude,
});
lastNewPhaseId = newPhase.id;
}
await tx.plan.update({
where: { id: newPlan.id },
data: { branchFromPhaseId: lastNewPhaseId },
});
return newPlan.id;
});
return NextResponse.json({ planId: newPlanId }, { status: 201 });
}
+53
View File
@@ -0,0 +1,53 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { prisma } from "@/lib/db";
import { getHouseholdOrNull } from "@/lib/queries";
const createPlanSchema = z.object({
name: z.string().min(1).max(120),
});
export async function GET() {
const household = await getHouseholdOrNull();
if (!household) {
return NextResponse.json({ plans: [] });
}
const plans = await prisma.plan.findMany({
where: { householdId: household.id },
orderBy: { createdAt: "asc" },
select: {
id: true,
name: true,
parentPlanId: true,
branchFromPhaseId: true,
createdAt: true,
phases: {
select: { id: true, name: true, sequenceNumber: true },
orderBy: { sequenceNumber: "asc" },
},
},
});
return NextResponse.json({ plans });
}
export async function POST(request: NextRequest) {
const household = await getHouseholdOrNull();
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 plan = await prisma.plan.create({
data: { householdId: household.id, name: parsed.data.name },
});
return NextResponse.json({ plan }, { status: 201 });
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+26
View File
@@ -0,0 +1,26 @@
@import "tailwindcss";
:root {
--background: #ffffff;
--foreground: #171717;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
}
+33
View File
@@ -0,0 +1,33 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "FPT — Financial Planning Tool",
description: "Persoenliche Finanzplanung ueber Lebensabschnittsphasen (AICDS)",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html
lang="en"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
>
<body className="min-h-full flex flex-col">{children}</body>
</html>
);
}
+109
View File
@@ -0,0 +1,109 @@
"use client";
import { Suspense, useEffect, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
function LoginForm() {
const router = useRouter();
const searchParams = useSearchParams();
const [passwordSet, setPasswordSet] = useState<boolean | undefined>(undefined);
const [password, setPassword] = useState("");
const [passwordConfirm, setPasswordConfirm] = useState("");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
useEffect(() => {
fetch("/api/auth/status")
.then((r) => r.json())
.then((data) => setPasswordSet(Boolean(data.passwordSet)));
}, []);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError(null);
if (!passwordSet && password !== passwordConfirm) {
setError("Die Passwoerter stimmen nicht ueberein.");
return;
}
setLoading(true);
try {
const response = await fetch(passwordSet ? "/api/auth/login" : "/api/auth/setup", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ password }),
});
if (!response.ok) {
const body = await response.json().catch(() => ({}));
throw new Error(typeof body.error === "string" ? body.error : "Anmeldung fehlgeschlagen.");
}
router.push(searchParams.get("next") ?? "/");
router.refresh();
} catch (err) {
setError(err instanceof Error ? err.message : "Anmeldung fehlgeschlagen.");
} finally {
setLoading(false);
}
}
if (passwordSet === undefined) {
return (
<div className="flex flex-1 items-center justify-center">
<p className="text-sm text-zinc-500">Laedt</p>
</div>
);
}
return (
<div className="flex flex-1 items-center justify-center px-4">
<form
onSubmit={handleSubmit}
className="flex w-full max-w-sm flex-col gap-4 rounded-lg border border-zinc-200 bg-white p-6 dark:border-zinc-700 dark:bg-zinc-900"
>
<h1 className="text-lg font-semibold text-zinc-900 dark:text-zinc-50">
{passwordSet ? "FPT — Anmelden" : "FPT — Passwort festlegen"}
</h1>
{!passwordSet && (
<p className="text-xs text-zinc-500 dark:text-zinc-400">
Es ist noch kein Passwort eingerichtet. Legen Sie hier Ihr persoenliches Passwort fest,
um den Zugriff auf Ihre Finanzplanung zu schuetzen.
</p>
)}
<input
type="password"
autoFocus
placeholder="Passwort"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 focus:border-zinc-500 focus:outline-none dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-100"
/>
{!passwordSet && (
<input
type="password"
placeholder="Passwort bestaetigen"
value={passwordConfirm}
onChange={(e) => setPasswordConfirm(e.target.value)}
className="w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 focus:border-zinc-500 focus:outline-none dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-100"
/>
)}
{error && <p className="text-sm text-red-600 dark:text-red-400">{error}</p>}
<button
type="submit"
disabled={loading}
className="rounded-md bg-zinc-900 px-4 py-2 text-sm font-medium text-white hover:bg-zinc-700 disabled:opacity-50 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-zinc-300"
>
{loading ? "..." : passwordSet ? "Anmelden" : "Passwort festlegen"}
</button>
</form>
</div>
);
}
export default function LoginPage() {
return (
<Suspense>
<LoginForm />
</Suspense>
);
}
+31
View File
@@ -0,0 +1,31 @@
"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);
useEffect(() => {
api.get<{ household: HouseholdInput | null }>("/api/household").then((data) => {
setHousehold(data.household);
});
}, []);
if (household === undefined) {
return (
<div className="flex flex-1 items-center justify-center">
<p className="text-sm text-zinc-500">Laedt</p>
</div>
);
}
if (household === null) {
return <Onboarding onDone={setHousehold} />;
}
return <AppShell initialHousehold={household} />;
}
+318
View File
@@ -0,0 +1,318 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { PhaseCard } from "@/components/PhaseCard";
import { TransitionPanel } from "@/components/TransitionPanel";
import { Dashboard } from "@/components/Dashboard";
import { HouseholdSettings } from "@/components/HouseholdSettings";
import { api } from "@/lib/api-client";
import type { HouseholdInput, PlanInput } from "@/lib/types";
import type { PlanComputed } from "@/lib/calculations";
interface PlanListItem {
id: string;
name: string;
parentPlanId: string | null;
branchFromPhaseId: string | null;
phases: { id: string; name: string; sequenceNumber: number }[];
}
export function AppShell({ initialHousehold }: { initialHousehold: HouseholdInput }) {
const [household, setHousehold] = useState(initialHousehold);
const [showSettings, setShowSettings] = useState(false);
const [plans, setPlans] = useState<PlanListItem[]>([]);
const [selectedPlanId, setSelectedPlanId] = useState<string | null>(null);
const [detail, setDetail] = useState<{ plan: PlanInput; computed: PlanComputed } | null>(null);
const [loading, setLoading] = useState(true);
const [showNewPlan, setShowNewPlan] = useState(false);
const [showScenario, setShowScenario] = useState(false);
const loadPlans = useCallback(async (preferId?: string) => {
const data = await api.get<{ plans: PlanListItem[] }>("/api/plans");
setPlans(data.plans);
if (preferId) {
setSelectedPlanId(preferId);
} else if (!selectedPlanId && data.plans.length > 0) {
setSelectedPlanId(data.plans[0].id);
}
return data.plans;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const loadDetail = useCallback(async (planId: string) => {
setLoading(true);
try {
const data = await api.get<{ plan: PlanInput; computed: PlanComputed }>(`/api/plans/${planId}`);
setDetail(data);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- asynchroner Datenabruf beim Mount, kein synchrones setState
loadPlans();
}, [loadPlans]);
useEffect(() => {
if (selectedPlanId) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- asynchroner Datenabruf, kein synchrones setState
loadDetail(selectedPlanId);
} else {
setDetail(null);
setLoading(false);
}
}, [selectedPlanId, loadDetail]);
function refreshCurrent() {
if (selectedPlanId) loadDetail(selectedPlanId);
}
async function handleAddPhase() {
if (!selectedPlanId) return;
const lastPhase = detail?.plan.phases[detail.plan.phases.length - 1];
await api.post(`/api/plans/${selectedPlanId}/phases`, {
name: lastPhase ? `Neue Phase ${detail!.plan.phases.length + 1}` : "Erste Lebensphase",
durationYears: 10,
incomeMode: "HOUSEHOLD",
});
refreshCurrent();
}
async function handleDeletePlan(id: string) {
if (!confirm("Diesen Plan wirklich loeschen?")) return;
await api.delete(`/api/plans/${id}`);
const remaining = await loadPlans();
if (selectedPlanId === id) {
setSelectedPlanId(remaining[0]?.id ?? null);
}
}
return (
<div className="mx-auto flex w-full max-w-5xl flex-1 flex-col gap-6 px-4 py-8">
<header className="flex items-center justify-between">
<h1 className="text-xl font-semibold text-zinc-900 dark:text-zinc-50">
Financial Planning Tool
</h1>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setShowSettings((v) => !v)}
className="rounded-md border border-zinc-300 px-3 py-1.5 text-xs font-medium text-zinc-700 hover:bg-zinc-50 dark:border-zinc-600 dark:text-zinc-200 dark:hover:bg-zinc-800"
>
Grundprofil
</button>
<button
type="button"
onClick={async () => {
await api.post("/api/auth/logout");
window.location.href = "/login";
}}
className="rounded-md border border-zinc-300 px-3 py-1.5 text-xs font-medium text-zinc-700 hover:bg-zinc-50 dark:border-zinc-600 dark:text-zinc-200 dark:hover:bg-zinc-800"
>
Abmelden
</button>
</div>
</header>
{showSettings && (
<HouseholdSettings
household={household}
onUpdated={setHousehold}
onClose={() => setShowSettings(false)}
/>
)}
{/* Tab-Leiste */}
<div className="flex flex-wrap items-center gap-2 border-b border-zinc-200 pb-2 dark:border-zinc-700">
{plans.map((p) => (
<div key={p.id} className="flex items-center">
<button
type="button"
onClick={() => setSelectedPlanId(p.id)}
className={`rounded-t-md px-3 py-1.5 text-sm font-medium ${
selectedPlanId === p.id
? "bg-zinc-900 text-white dark:bg-zinc-100 dark:text-zinc-900"
: "text-zinc-600 hover:bg-zinc-100 dark:text-zinc-300 dark:hover:bg-zinc-800"
}`}
>
{p.name}
</button>
{selectedPlanId === p.id && (
<button
type="button"
onClick={() => handleDeletePlan(p.id)}
className="ml-1 text-xs text-zinc-400 hover:text-red-600"
aria-label="Plan loeschen"
>
</button>
)}
</div>
))}
<div className="relative">
<button
type="button"
onClick={() => setShowNewPlan((v) => !v)}
className="rounded-md px-2 py-1.5 text-sm text-zinc-500 hover:bg-zinc-100 dark:hover:bg-zinc-800"
>
+ Plan
</button>
{showNewPlan && (
<NewPlanPopover
onCreate={async (name) => {
const { plan } = await api.post<{ plan: { id: string } }>("/api/plans", { name });
setShowNewPlan(false);
await loadPlans(plan.id);
}}
onClose={() => setShowNewPlan(false)}
/>
)}
</div>
{detail && detail.plan.phases.length > 0 && (
<div className="relative">
<button
type="button"
onClick={() => setShowScenario((v) => !v)}
className="rounded-md px-2 py-1.5 text-sm text-zinc-500 hover:bg-zinc-100 dark:hover:bg-zinc-800"
>
+ Szenario
</button>
{showScenario && (
<ScenarioPopover
phases={detail.plan.phases}
onCreate={async (name, branchFromPhaseId) => {
const { planId } = await api.post<{ planId: string }>(
`/api/plans/${selectedPlanId}/scenario`,
{ name, branchFromPhaseId }
);
setShowScenario(false);
await loadPlans(planId);
}}
onClose={() => setShowScenario(false)}
/>
)}
</div>
)}
</div>
{loading && <p className="text-sm text-zinc-500">Laedt</p>}
{!loading && plans.length === 0 && (
<p className="text-sm text-zinc-500">
Noch kein Plan vorhanden. Erstellen Sie oben Ihren ersten Plan.
</p>
)}
{!loading && detail && (
<>
<div className="flex flex-col gap-3">
{detail.plan.phases.map((phase, i) => {
const computedPhase = detail.computed.phases.find((c) => c.id === phase.id)!;
const nextPhase = detail.plan.phases[i + 1];
return (
<div key={phase.id} className="flex flex-col gap-3">
<PhaseCard
household={household}
phase={phase}
computed={computedPhase}
isLast={i === detail.plan.phases.length - 1}
onChanged={refreshCurrent}
/>
{nextPhase && (
<TransitionPanel phase={phase} computed={computedPhase} nextPhaseName={nextPhase.name} />
)}
</div>
);
})}
</div>
<button
type="button"
onClick={handleAddPhase}
className="self-start rounded-md border border-zinc-300 px-3 py-2 text-sm font-medium text-zinc-700 hover:bg-zinc-50 dark:border-zinc-600 dark:text-zinc-200 dark:hover:bg-zinc-800"
>
+ Phase hinzufuegen
</button>
{detail.plan.phases.length > 0 && (
<Dashboard plan={detail.plan} computed={detail.computed} allPlans={plans} />
)}
</>
)}
</div>
);
}
function NewPlanPopover({ onCreate, onClose }: { onCreate: (name: string) => void; onClose: () => void }) {
const [name, setName] = useState("Basisplan");
return (
<div className="absolute left-0 top-10 z-10 w-64 rounded-md border border-zinc-200 bg-white p-3 shadow-lg dark:border-zinc-700 dark:bg-zinc-900">
<input
className="mb-2 w-full rounded-md border border-zinc-300 px-2 py-1 text-sm dark:border-zinc-600 dark:bg-zinc-800"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Name des Plans"
/>
<div className="flex gap-2">
<button
type="button"
onClick={() => onCreate(name)}
className="rounded-md bg-zinc-900 px-2 py-1 text-xs text-white dark:bg-zinc-100 dark:text-zinc-900"
>
Erstellen
</button>
<button type="button" onClick={onClose} className="text-xs text-zinc-500">
Abbrechen
</button>
</div>
</div>
);
}
function ScenarioPopover({
phases,
onCreate,
onClose,
}: {
phases: { id: string; name: string }[];
onCreate: (name: string, branchFromPhaseId: string) => void;
onClose: () => void;
}) {
const [name, setName] = useState("Neues Szenario");
const [branchFromPhaseId, setBranchFromPhaseId] = useState(phases[phases.length - 1]?.id ?? "");
return (
<div className="absolute left-0 top-10 z-10 w-72 rounded-md border border-zinc-200 bg-white p-3 shadow-lg dark:border-zinc-700 dark:bg-zinc-900">
<input
className="mb-2 w-full rounded-md border border-zinc-300 px-2 py-1 text-sm dark:border-zinc-600 dark:bg-zinc-800"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Name des Szenarios"
/>
<label className="mb-1 block text-xs text-zinc-500">Verzweigen ab Phase</label>
<select
className="mb-2 w-full rounded-md border border-zinc-300 px-2 py-1 text-sm dark:border-zinc-600 dark:bg-zinc-800"
value={branchFromPhaseId}
onChange={(e) => setBranchFromPhaseId(e.target.value)}
>
{phases.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
<div className="flex gap-2">
<button
type="button"
onClick={() => onCreate(name, branchFromPhaseId)}
className="rounded-md bg-zinc-900 px-2 py-1 text-xs text-white dark:bg-zinc-100 dark:text-zinc-900"
>
Erstellen
</button>
<button type="button" onClick={onClose} className="text-xs text-zinc-500">
Abbrechen
</button>
</div>
</div>
);
}
+151
View File
@@ -0,0 +1,151 @@
"use client";
import { useMemo, useState } from "react";
import { Bar, BarChart, CartesianGrid, Legend, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
import { WealthChart, type TimelineSeries } from "@/components/WealthChart";
import { api } from "@/lib/api-client";
import type { PlanComputed } from "@/lib/calculations";
import type { PlanInput } from "@/lib/types";
const PALETTE = ["#3f3f46", "#2563eb", "#16a34a", "#d97706", "#dc2626", "#7c3aed"];
interface PlanListItem {
id: string;
name: string;
}
export function Dashboard({
plan,
computed,
allPlans,
}: {
plan: PlanInput;
computed: PlanComputed;
allPlans: PlanListItem[];
}) {
const [compareIds, setCompareIds] = useState<string[]>([]);
const [compareData, setCompareData] = useState<Record<string, PlanComputed>>({});
async function toggleCompare(id: string) {
if (compareIds.includes(id)) {
setCompareIds((prev) => prev.filter((p) => p !== id));
return;
}
setCompareIds((prev) => [...prev, id]);
if (!compareData[id]) {
const data = await api.get<{ computed: PlanComputed }>(`/api/plans/${id}`);
setCompareData((prev) => ({ ...prev, [id]: data.computed }));
}
}
const series: TimelineSeries[] = useMemo(() => {
const result: TimelineSeries[] = [{ label: plan.name, color: PALETTE[0], computed }];
compareIds.forEach((id, i) => {
const c = compareData[id];
const name = allPlans.find((p) => p.id === id)?.name ?? id;
if (c) result.push({ label: name, color: PALETTE[(i + 1) % PALETTE.length], computed: c });
});
return result;
}, [plan.name, computed, compareIds, compareData, allPlans]);
const barKeys = useMemo(() => {
const keys = new Set<string>();
for (const phase of computed.phases) {
for (const s of phase.securities) keys.add(s.name);
for (const re of phase.realEstates) keys.add(re.name);
}
return Array.from(keys);
}, [computed]);
const barData = useMemo(
() =>
computed.phases.map((phase) => {
const row: Record<string, number | string> = { phase: phase.name };
for (const s of phase.securities) row[s.name] = s.endValue;
for (const re of phase.realEstates) row[re.name] = re.endContribution;
return row;
}),
[computed]
);
const otherPlans = allPlans.filter((p) => p.id !== plan.id);
const lastPhase = computed.phases[computed.phases.length - 1];
return (
<div className="flex flex-col gap-6">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<StatCard label="Endvermoegen (nominal)" value={lastPhase ? lastPhase.endWealthNominal : 0} />
<StatCard label="Endvermoegen (real, kaufkraftbereinigt)" value={lastPhase ? lastPhase.endWealthReal : 0} />
<StatCard label="Geschaetzter Nachlass" value={computed.nachlass} help="Endvermoegen der letzten Phase - potenziell vererbbar." />
</div>
<section className="rounded-lg border border-zinc-200 bg-white p-4 dark:border-zinc-700 dark:bg-zinc-900">
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<h3 className="text-sm font-semibold text-zinc-800 dark:text-zinc-100">Vermoegensverlauf</h3>
<a
href={`/api/plans/${plan.id}/export`}
className="rounded-md border border-zinc-300 px-3 py-1.5 text-xs font-medium text-zinc-700 hover:bg-zinc-50 dark:border-zinc-600 dark:text-zinc-200 dark:hover:bg-zinc-800"
>
CSV-Export
</a>
</div>
{otherPlans.length > 0 && (
<div className="mb-3 flex flex-wrap gap-2">
<span className="text-xs text-zinc-500">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">
<input
type="checkbox"
checked={compareIds.includes(p.id)}
onChange={() => toggleCompare(p.id)}
/>
{p.name}
</label>
))}
</div>
)}
<WealthChart series={series} />
</section>
<section className="rounded-lg border border-zinc-200 bg-white p-4 dark:border-zinc-700 dark:bg-zinc-900">
<h3 className="mb-3 text-sm font-semibold text-zinc-800 dark:text-zinc-100">
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" />
<XAxis dataKey="phase" tick={{ fontSize: 11 }} />
<YAxis
tick={{ fontSize: 11 }}
tickFormatter={(v) => Intl.NumberFormat("de-CH", { notation: "compact" }).format(v)}
/>
<Tooltip
formatter={(v) =>
typeof v === "number" ? v.toLocaleString("de-CH", { maximumFractionDigits: 0 }) : v
}
/>
<Legend wrapperStyle={{ fontSize: 12 }} />
{barKeys.map((key, i) => (
<Bar key={key} dataKey={key} stackId="a" fill={PALETTE[i % PALETTE.length]} />
))}
</BarChart>
</ResponsiveContainer>
</div>
</section>
</div>
);
}
function StatCard({ label, value, help }: { label: string; value: number; help?: string }) {
return (
<div className="rounded-lg border border-zinc-200 bg-white p-4 dark:border-zinc-700 dark:bg-zinc-900">
<div className="text-xs text-zinc-500 dark:text-zinc-400" title={help}>
{label}
</div>
<div className="mt-1 text-xl font-semibold text-zinc-900 dark:text-zinc-50">
{value.toLocaleString("de-CH", { maximumFractionDigits: 0 })} CHF
</div>
</div>
);
}
+106
View File
@@ -0,0 +1,106 @@
"use client";
import { InfoBubble } from "@/components/InfoBubble";
const baseInputClass =
"w-full rounded-md border border-zinc-300 bg-white px-2 py-1.5 text-sm text-zinc-900 focus:border-zinc-500 focus:outline-none dark:border-zinc-600 dark:bg-zinc-900 dark:text-zinc-100";
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}
{help && <InfoBubble text={help} />}
</label>
);
}
export function NumberField({
label,
help,
value,
onChange,
step,
min,
max,
}: {
label: string;
help?: string;
value: number;
onChange: (value: number) => void;
step?: number;
min?: number;
max?: number;
}) {
return (
<div>
<FieldLabel label={label} help={help} />
<input
type="number"
className={baseInputClass}
value={Number.isFinite(value) ? value : 0}
step={step ?? "any"}
min={min}
max={max}
onChange={(e) => onChange(e.target.valueAsNumber || 0)}
/>
</div>
);
}
export function TextField({
label,
help,
value,
onChange,
placeholder,
}: {
label: string;
help?: string;
value: string;
onChange: (value: string) => void;
placeholder?: string;
}) {
return (
<div>
<FieldLabel label={label} help={help} />
<input
type="text"
className={baseInputClass}
value={value}
placeholder={placeholder}
onChange={(e) => onChange(e.target.value)}
/>
</div>
);
}
export function SelectField<T extends string>({
label,
help,
value,
onChange,
options,
}: {
label: string;
help?: string;
value: T;
onChange: (value: T) => void;
options: { value: T; label: string }[];
}) {
return (
<div>
<FieldLabel label={label} help={help} />
<select
className={baseInputClass}
value={value}
onChange={(e) => onChange(e.target.value as T)}
>
{options.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</div>
);
}
+105
View File
@@ -0,0 +1,105 @@
"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-lg border border-zinc-200 bg-white p-4 dark:border-zinc-700 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-md bg-zinc-900 px-4 py-2 text-sm font-medium text-white hover:bg-zinc-700 disabled:opacity-50 dark:bg-zinc-100 dark:text-zinc-900"
>
{saving ? "Speichern..." : "Speichern"}
</button>
<button
type="button"
onClick={onClose}
className="rounded-md border border-zinc-300 px-4 py-2 text-sm font-medium text-zinc-700 hover:bg-zinc-50 dark:border-zinc-600 dark:text-zinc-200"
>
Abbrechen
</button>
</div>
</div>
);
}
+27
View File
@@ -0,0 +1,27 @@
"use client";
import { useState } from "react";
export function InfoBubble({ text }: { text: string }) {
const [open, setOpen] = useState(false);
return (
<span className="relative inline-flex align-middle ml-1">
<button
type="button"
aria-label="Hilfe anzeigen"
onMouseEnter={() => setOpen(true)}
onMouseLeave={() => setOpen(false)}
onClick={() => setOpen((o) => !o)}
className="flex h-4 w-4 items-center justify-center rounded-full bg-zinc-200 text-[10px] font-semibold text-zinc-600 hover:bg-zinc-300 dark:bg-zinc-700 dark:text-zinc-300 dark:hover:bg-zinc-600"
>
i
</button>
{open && (
<span className="absolute left-1/2 top-6 z-20 w-64 -translate-x-1/2 rounded-md border border-zinc-200 bg-white p-2 text-xs leading-snug text-zinc-700 shadow-lg dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-200">
{text}
</span>
)}
</span>
);
}
+115
View File
@@ -0,0 +1,115 @@
"use client";
import { useState } from "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">
<h1 className="mb-2 text-2xl font-semibold text-zinc-900 dark:text-zinc-50">
Willkommen beim Financial Planning Tool
</h1>
<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-lg border border-zinc-200 bg-white p-6 dark:border-zinc-700 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-md bg-zinc-50 p-3 dark:bg-zinc-800/50">
<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-md bg-zinc-900 px-4 py-2 text-sm font-medium text-white hover:bg-zinc-700 disabled:opacity-50 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-zinc-300"
>
{saving ? "Speichern..." : "Weiter"}
</button>
</div>
</div>
);
}
+101
View File
@@ -0,0 +1,101 @@
"use client";
import { useState } from "react";
import { LineChart, Line, ResponsiveContainer } from "recharts";
import { PhaseForm } from "@/components/PhaseForm";
import { api } from "@/lib/api-client";
import type { HouseholdInput, PhaseInput } from "@/lib/types";
import type { PhaseComputed } from "@/lib/calculations";
function formatChf(value: number) {
return value.toLocaleString("de-CH", { maximumFractionDigits: 0 });
}
export function PhaseCard({
household,
phase,
computed,
isLast,
onChanged,
}: {
household: HouseholdInput;
phase: PhaseInput;
computed: PhaseComputed;
isLast: boolean;
onChanged: () => void;
}) {
const [expanded, setExpanded] = useState(false);
const [deleting, setDeleting] = useState(false);
const sparklineData = [computed.startWealthNominal, ...computed.yearlyNominal].map((v, i) => ({
year: i,
value: v,
}));
async function handleDelete() {
if (!confirm(`Phase "${phase.name}" wirklich loeschen?`)) return;
setDeleting(true);
try {
await api.delete(`/api/phases/${phase.id}`);
onChanged();
} catch (e) {
alert(e instanceof Error ? e.message : "Loeschen fehlgeschlagen.");
} finally {
setDeleting(false);
}
}
return (
<div className="overflow-hidden rounded-lg border border-zinc-200 bg-white dark:border-zinc-700 dark:bg-zinc-900">
<button
type="button"
onClick={() => setExpanded((v) => !v)}
className="flex w-full items-center gap-4 px-4 py-3 text-left hover:bg-zinc-50 dark:hover:bg-zinc-800/50"
>
<span className="text-zinc-400">{expanded ? "▾" : "▸"}</span>
<div className="flex-1">
<div className="flex items-baseline gap-2">
<span className="font-medium text-zinc-900 dark:text-zinc-100">{phase.name}</span>
<span className="text-xs text-zinc-500">{phase.durationYears} Jahre</span>
{computed.savingsWarning && (
<span className="text-xs text-amber-600 dark:text-amber-400"> Sparquote ueberschritten</span>
)}
</div>
<div className="text-xs text-zinc-500 dark:text-zinc-400">
Start {formatChf(computed.startWealthNominal)} CHF Ende {formatChf(computed.endWealthNominal)} CHF (nominal)
</div>
</div>
<div className="h-8 w-24">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={sparklineData}>
<Line type="monotone" dataKey="value" stroke="#3f3f46" strokeWidth={1.5} dot={false} />
</LineChart>
</ResponsiveContainer>
</div>
{isLast && (
<span
role="button"
tabIndex={0}
onClick={(e) => {
e.stopPropagation();
handleDelete();
}}
className="rounded-md border border-zinc-300 px-2 py-1 text-xs text-zinc-500 hover:bg-red-50 hover:text-red-600 dark:border-zinc-600 dark:hover:bg-red-950"
>
{deleting ? "…" : "Loeschen"}
</span>
)}
</button>
{expanded && (
<PhaseForm
household={household}
phase={phase}
onSaved={() => {
onChanged();
}}
onCancel={() => setExpanded(false)}
/>
)}
</div>
);
}
+475
View File
@@ -0,0 +1,475 @@
"use client";
import { useState } from "react";
import { NumberField, SelectField, TextField } from "@/components/FormField";
import { api } from "@/lib/api-client";
import type {
ExpenseEntryInput,
HouseholdInput,
IncomeEntryInput,
IncomeMode,
OneTimeEventInput,
OneTimeEventType,
OwnerTag,
PhaseInput,
RealEstateInput,
RetirementInfoInput,
SecurityInput,
} from "@/lib/types";
let tempIdCounter = 0;
function tempId() {
tempIdCounter += 1;
return `tmp-${tempIdCounter}`;
}
function personLabel(household: HouseholdInput, personId: string | null) {
if (!personId) return "Haushalt";
const person = household.persons.find((p) => p.id === personId);
if (!person) return "Haushalt";
return person.role === "PERSON_A" ? "Person A" : "Person B";
}
interface Props {
household: HouseholdInput;
phase: PhaseInput;
onSaved: () => void;
onCancel: () => void;
}
export function PhaseForm({ household, phase, onSaved, onCancel }: Props) {
const [name, setName] = useState(phase.name);
const [durationYears, setDurationYears] = useState(phase.durationYears);
const [inflationRate, setInflationRate] = useState<number | null>(phase.inflationRate);
const [incomeMode, setIncomeMode] = useState<IncomeMode>(phase.incomeMode);
const [incomeEntries, setIncomeEntries] = useState<IncomeEntryInput[]>(phase.incomeEntries);
const [expenseEntries, setExpenseEntries] = useState<ExpenseEntryInput[]>(
phase.expenseEntries.length > 0 ? phase.expenseEntries : [{ id: tempId(), label: null, amount: 0 }]
);
const [securities, setSecurities] = useState<SecurityInput[]>(phase.securities);
const [realEstates, setRealEstates] = useState<RealEstateInput[]>(phase.realEstates);
const [oneTimeEvents, setOneTimeEvents] = useState<OneTimeEventInput[]>(phase.oneTimeEvents);
const [retirementInfos, setRetirementInfos] = useState<RetirementInfoInput[]>(phase.retirementInfos);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const totalIncome = incomeEntries.reduce((s, e) => s + e.amount, 0);
const totalExpense = expenseEntries.reduce((s, e) => s + e.amount, 0);
const savingsQuota = totalIncome - totalExpense;
const allocated = securities.reduce((s, sec) => s + sec.annualContribution, 0);
const overAllocated = allocated > savingsQuota;
async function handleSave() {
setSaving(true);
setError(null);
try {
await api.put(`/api/phases/${phase.id}`, {
name,
durationYears,
inflationRate,
incomeMode,
incomeEntries: incomeEntries.map((e) => ({
personId: incomeMode === "PER_PERSON" ? e.personId : null,
label: e.label,
amount: e.amount,
})),
expenseEntries: expenseEntries.map((e) => ({ label: e.label, amount: e.amount })),
securities,
realEstates,
oneTimeEvents,
retirementInfos,
});
onSaved();
} catch (e) {
setError(e instanceof Error ? e.message : "Speichern fehlgeschlagen.");
} finally {
setSaving(false);
}
}
return (
<div className="flex flex-col gap-6 border-t border-zinc-200 p-4 dark:border-zinc-700">
{/* Basis */}
<section className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<div className="col-span-2 sm:col-span-2">
<TextField
label="Bezeichnung der Lebensphase"
help="Ein frei waehlbarer Name, z. B. 'Kinder zuhause' oder 'Fruehpensionierung'."
value={name}
onChange={setName}
/>
</div>
<NumberField
label="Dauer (Jahre)"
help="Wie viele Jahre umfasst diese Lebensphase?"
value={durationYears}
min={1}
onChange={setDurationYears}
/>
<NumberField
label="Inflationsrate dieser Phase (%)"
help="Ueberschreibt fuer diese Phase die im Grundprofil hinterlegte Standardannahme."
value={inflationRate ?? household.inflationRateDefault}
step={0.1}
onChange={setInflationRate}
/>
{household.householdType === "COUPLE" && (
<SelectField
label="Einkommen eingeben als"
help="Pro Person einzeln oder direkt als gemeinsamer Betrag fuer den Haushalt."
value={incomeMode}
onChange={setIncomeMode}
options={[
{ value: "HOUSEHOLD", label: "Gemeinsam" },
{ value: "PER_PERSON", label: "Pro Person" },
]}
/>
)}
</section>
{/* Einkommen */}
<Section title="Einkommen">
{incomeEntries.map((entry, i) => (
<div key={entry.id} className="grid grid-cols-[1fr_1fr_auto] items-end gap-2">
{incomeMode === "PER_PERSON" ? (
<SelectField
label="Person"
value={(entry.personId ?? household.persons[0]?.id ?? "") as string}
onChange={(v) =>
setIncomeEntries((prev) => prev.map((e, idx) => (idx === i ? { ...e, personId: v } : e)))
}
options={household.persons.map((p) => ({
value: p.id,
label: p.role === "PERSON_A" ? "Person A" : "Person B",
}))}
/>
) : (
<TextField
label="Bezeichnung (optional)"
value={entry.label ?? ""}
onChange={(v) =>
setIncomeEntries((prev) => prev.map((e, idx) => (idx === i ? { ...e, label: v || null } : e)))
}
/>
)}
<NumberField
label="Geschaetztes Jahreseinkommen (CHF)"
help="Ihr erwartetes Bruttoeinkommen pro Jahr waehrend dieser Lebensphase."
value={entry.amount}
onChange={(v) =>
setIncomeEntries((prev) => prev.map((e, idx) => (idx === i ? { ...e, amount: v } : e)))
}
/>
<RemoveButton onClick={() => setIncomeEntries((prev) => prev.filter((_, idx) => idx !== i))} />
</div>
))}
<AddButton
label="+ Einkommensposten"
onClick={() =>
setIncomeEntries((prev) => [
...prev,
{ id: tempId(), personId: incomeMode === "PER_PERSON" ? household.persons[0]?.id ?? null : null, label: null, amount: 0 },
])
}
/>
</Section>
{/* Ausgaben */}
<Section title="Ausgaben">
{expenseEntries.map((entry, i) => (
<div key={entry.id} className="grid grid-cols-[1fr_auto] items-end gap-2">
<NumberField
label="Geschaetzte Gesamtausgaben (CHF/Jahr)"
help="Saemtliche laufenden Kosten des Haushalts pro Jahr - inkl. Lebenshaltung, Hypothekarzinsen, laufende Einkommens- und Vermoegenssteuern. Keine separate Kategorisierung noetig."
value={entry.amount}
onChange={(v) =>
setExpenseEntries((prev) => prev.map((e, idx) => (idx === i ? { ...e, amount: v } : e)))
}
/>
<RemoveButton onClick={() => setExpenseEntries((prev) => prev.filter((_, idx) => idx !== i))} />
</div>
))}
<AddButton
label="+ Ausgabenposten"
onClick={() => setExpenseEntries((prev) => [...prev, { id: tempId(), label: null, amount: 0 }])}
/>
<div
className={`rounded-md px-3 py-2 text-sm ${
overAllocated
? "bg-amber-50 text-amber-800 dark:bg-amber-950 dark:text-amber-300"
: "bg-zinc-50 text-zinc-600 dark:bg-zinc-800 dark:text-zinc-300"
}`}
>
Verfuegbare Sparquote (CHF/Jahr): <strong>{savingsQuota.toLocaleString("de-CH")}</strong>
{" "} zugewiesen an Wertschriften: {allocated.toLocaleString("de-CH")}
{overAllocated && " ⚠ Die zugewiesenen Sparbeitraege uebersteigen die verfuegbare Sparquote."}
</div>
</Section>
{/* Wertschriften */}
<Section title="Wertschriften">
{securities.map((s, i) => (
<div key={s.id} className="grid grid-cols-2 gap-2 rounded-md bg-zinc-50 p-3 sm:grid-cols-5 dark:bg-zinc-800/50">
<TextField
label="Name"
help="Frei waehlbare Bezeichnung, z. B. 'Globaler ETF' oder 'Schweizer Aktien'."
value={s.name}
onChange={(v) => setSecurities((prev) => prev.map((x, idx) => (idx === i ? { ...x, name: v } : x)))}
/>
<NumberField
label="Startwert (CHF)"
help="Wert dieser Position zu Beginn der Phase."
value={s.startValue}
onChange={(v) => setSecurities((prev) => prev.map((x, idx) => (idx === i ? { ...x, startValue: v } : x)))}
/>
<NumberField
label="Erwartete Rendite (%/Jahr)"
help="Ihre Annahme zur durchschnittlichen jaehrlichen Wertentwicklung dieser Anlage."
step={0.1}
value={s.expectedReturn}
onChange={(v) => setSecurities((prev) => prev.map((x, idx) => (idx === i ? { ...x, expectedReturn: v } : x)))}
/>
<NumberField
label="Jaehrlicher Sparbeitrag (CHF)"
help="Der Betrag aus Ihrer verfuegbaren Sparquote, den Sie jaehrlich in diese Position investieren moechten."
value={s.annualContribution}
onChange={(v) => setSecurities((prev) => prev.map((x, idx) => (idx === i ? { ...x, annualContribution: v } : x)))}
/>
<div className="flex items-end gap-2">
<div className="flex-1">
<SelectField
label="Gehoert zu"
help="Rein informativ: Person A, Person B oder gemeinsam. Hat keinen Einfluss auf die Berechnung."
value={s.ownerTag}
onChange={(v: OwnerTag) => setSecurities((prev) => prev.map((x, idx) => (idx === i ? { ...x, ownerTag: v } : x)))}
options={[
{ value: "HOUSEHOLD", label: "Gemeinsam" },
{ value: "PERSON_A", label: "Person A" },
{ value: "PERSON_B", label: "Person B" },
]}
/>
</div>
<RemoveButton onClick={() => setSecurities((prev) => prev.filter((_, idx) => idx !== i))} />
</div>
</div>
))}
<AddButton
label="+ Wertschrift"
onClick={() =>
setSecurities((prev) => [
...prev,
{ id: tempId(), name: "", startValue: 0, expectedReturn: 0, annualContribution: 0, ownerTag: "HOUSEHOLD", saleTaxRate: 0 },
])
}
/>
</Section>
{/* Immobilien */}
<Section title="Immobilien">
{realEstates.map((re, i) => (
<div key={re.id} className="grid grid-cols-2 gap-2 rounded-md bg-zinc-50 p-3 sm:grid-cols-4 dark:bg-zinc-800/50">
<TextField
label="Bezeichnung"
help="Z. B. 'Eigenheim' oder 'Ferienwohnung'."
value={re.name}
onChange={(v) => setRealEstates((prev) => prev.map((x, idx) => (idx === i ? { ...x, name: v } : x)))}
/>
<NumberField
label="Aktueller Marktwert (CHF)"
help="Geschaetzter heutiger Verkehrswert der Liegenschaft."
value={re.marketValue}
onChange={(v) => setRealEstates((prev) => prev.map((x, idx) => (idx === i ? { ...x, marketValue: v } : x)))}
/>
<NumberField
label="Aktuelle Hypothek (CHF)"
help="Ausstehender Hypothekarbetrag."
value={re.mortgage}
onChange={(v) => setRealEstates((prev) => prev.map((x, idx) => (idx === i ? { ...x, mortgage: v } : x)))}
/>
<NumberField
label="Wertsteigerung (%/Jahr)"
help="Ihre Annahme zur Wertentwicklung der Immobilie pro Jahr."
step={0.1}
value={re.valueGrowth}
onChange={(v) => setRealEstates((prev) => prev.map((x, idx) => (idx === i ? { ...x, valueGrowth: v } : x)))}
/>
<NumberField
label="Jaehrliche Amortisation (CHF)"
help="Betrag, um den die Hypothek pro Jahr reduziert wird."
value={re.amortization}
onChange={(v) => setRealEstates((prev) => prev.map((x, idx) => (idx === i ? { ...x, amortization: v } : x)))}
/>
<NumberField
label="Geschaetzter Verkaufspreis (CHF)"
help="Nur bei geplantem Verkauf am Ende der Phase auszufuellen."
value={re.salePrice ?? 0}
onChange={(v) => setRealEstates((prev) => prev.map((x, idx) => (idx === i ? { ...x, salePrice: v || null } : x)))}
/>
<NumberField
label="Geschaetzte Grundstueckgewinnsteuer (%)"
help="Kantonale Steuer auf den Verkaufsgewinn, ca. 10-30% je nach Kanton und Besitzdauer."
value={re.saleTaxRate}
onChange={(v) => setRealEstates((prev) => prev.map((x, idx) => (idx === i ? { ...x, saleTaxRate: v } : x)))}
/>
<div className="flex items-end">
<RemoveButton onClick={() => setRealEstates((prev) => prev.filter((_, idx) => idx !== i))} />
</div>
</div>
))}
<AddButton
label="+ Immobilie"
onClick={() =>
setRealEstates((prev) => [
...prev,
{ id: tempId(), name: "", marketValue: 0, mortgage: 0, valueGrowth: 0, amortization: 0, salePrice: null, saleTaxRate: 20 },
])
}
/>
</Section>
{/* Sondereinnahmen/-ausgaben */}
<Section title="Sondereinnahmen / -ausgaben">
{oneTimeEvents.map((ev, i) => (
<div key={ev.id} className="grid grid-cols-[auto_1fr_2fr_auto] items-end gap-2">
<SelectField
label="Art"
help="Einmalige Einnahme (z. B. Erbschaft) oder einmalige Ausgabe (z. B. Poolbau)."
value={ev.type}
onChange={(v: OneTimeEventType) => setOneTimeEvents((prev) => prev.map((x, idx) => (idx === i ? { ...x, type: v } : x)))}
options={[
{ value: "INCOME", label: "Einnahme" },
{ value: "EXPENSE", label: "Ausgabe" },
]}
/>
<NumberField
label="Betrag (CHF)"
value={ev.amount}
onChange={(v) => setOneTimeEvents((prev) => prev.map((x, idx) => (idx === i ? { ...x, amount: v } : x)))}
/>
<TextField
label="Beschreibung"
value={ev.description ?? ""}
onChange={(v) => setOneTimeEvents((prev) => prev.map((x, idx) => (idx === i ? { ...x, description: v || null } : x)))}
/>
<RemoveButton onClick={() => setOneTimeEvents((prev) => prev.filter((_, idx) => idx !== i))} />
</div>
))}
<AddButton
label="+ Sondereinnahme/-ausgabe"
onClick={() => setOneTimeEvents((prev) => [...prev, { id: tempId(), type: "EXPENSE", amount: 0, description: null }])}
/>
</Section>
{/* Pensionierung */}
<Section title="Pensionierung (optional)">
{retirementInfos.map((r, i) => (
<div key={r.id} className="grid grid-cols-2 gap-2 rounded-md bg-zinc-50 p-3 sm:grid-cols-4 dark:bg-zinc-800/50">
<SelectField
label="Person"
value={r.personId}
onChange={(v) => setRetirementInfos((prev) => prev.map((x, idx) => (idx === i ? { ...x, personId: v } : x)))}
options={household.persons.map((p) => ({ value: p.id, label: personLabel(household, p.id) }))}
/>
<NumberField
label="AHV-Rente (CHF/Jahr)"
help="Zusammengesetzt mit der PK-Rente zur 'Erwarteten Rente'. Bei Ehepaaren max. 1.5x AHV-Maximalrente gemeinsam."
value={r.ahvAmount}
onChange={(v) => setRetirementInfos((prev) => prev.map((x, idx) => (idx === i ? { ...x, ahvAmount: v } : x)))}
/>
<NumberField
label="PK-Rente (CHF/Jahr)"
help="Pensionskassenrente (2. Saeule)."
value={r.pkPensionAmount}
onChange={(v) => setRetirementInfos((prev) => prev.map((x, idx) => (idx === i ? { ...x, pkPensionAmount: v } : x)))}
/>
<NumberField
label="Kapitalbezug brutto (CHF)"
help="Zusammengesetzt aus Saeule 3a und/oder Kapitalbezug aus der Pensionskasse."
value={r.lumpSumAmount}
onChange={(v) => setRetirementInfos((prev) => prev.map((x, idx) => (idx === i ? { ...x, lumpSumAmount: v } : x)))}
/>
<NumberField
label="Geschaetzte Kapitalbezugssteuer (%)"
help="Realistische Bandbreite: ca. 3-15% des Bruttobetrags."
value={r.lumpSumTaxRate}
onChange={(v) => setRetirementInfos((prev) => prev.map((x, idx) => (idx === i ? { ...x, lumpSumTaxRate: v } : x)))}
/>
<div className="flex items-end">
<RemoveButton onClick={() => setRetirementInfos((prev) => prev.filter((_, idx) => idx !== i))} />
</div>
</div>
))}
<AddButton
label="+ Pensionierungsangaben"
onClick={() =>
setRetirementInfos((prev) => [
...prev,
{
id: tempId(),
personId: household.persons[0]?.id ?? "",
ahvAmount: 0,
pkPensionAmount: 0,
lumpSumAmount: 0,
lumpSumTaxRate: 8,
},
])
}
/>
</Section>
{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={handleSave}
className="rounded-md bg-zinc-900 px-4 py-2 text-sm font-medium text-white hover:bg-zinc-700 disabled:opacity-50 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-zinc-300"
>
{saving ? "Speichern..." : "Speichern"}
</button>
<button
type="button"
onClick={onCancel}
className="rounded-md border border-zinc-300 px-4 py-2 text-sm font-medium text-zinc-700 hover:bg-zinc-50 dark:border-zinc-600 dark:text-zinc-200 dark:hover:bg-zinc-800"
>
Abbrechen
</button>
</div>
</div>
);
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<section className="flex flex-col gap-2">
<h4 className="text-sm font-semibold text-zinc-800 dark:text-zinc-100">{title}</h4>
{children}
</section>
);
}
function AddButton({ label, onClick }: { label: string; onClick: () => void }) {
return (
<button
type="button"
onClick={onClick}
className="self-start text-xs font-medium text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-100"
>
{label}
</button>
);
}
function RemoveButton({ onClick }: { onClick: () => void }) {
return (
<button
type="button"
onClick={onClick}
aria-label="Entfernen"
className="rounded-md border border-zinc-300 px-2 py-1.5 text-xs text-zinc-500 hover:bg-red-50 hover:text-red-600 dark:border-zinc-600 dark:hover:bg-red-950"
>
</button>
);
}
+219
View File
@@ -0,0 +1,219 @@
"use client";
import { useEffect, useState } from "react";
import { api } from "@/lib/api-client";
import type { PhaseInput, TransitionDecision } from "@/lib/types";
import type { PhaseComputed } from "@/lib/calculations";
interface ItemDraft {
positionType: "SECURITY" | "REAL_ESTATE";
id: string;
name: string;
decision: TransitionDecision;
salePrice: number | null;
// Referenzwerte fuer die Anzeige/Berechnung des verfuegbaren Startkapitals
carryOverValue: number;
originalValue: number;
saleTaxRate: number;
}
export function TransitionPanel({
phase,
computed,
nextPhaseName,
}: {
phase: PhaseInput;
computed: PhaseComputed;
nextPhaseName: string;
}) {
const [items, setItems] = useState<ItemDraft[]>([]);
const [loaded, setLoaded] = useState(false);
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
async function load() {
const initial: ItemDraft[] = [
...phase.securities.map((s) => {
const c = computed.securities.find((cs) => cs.id === s.id);
return {
positionType: "SECURITY" as const,
id: s.id,
name: s.name,
decision: "CARRY_OVER" as TransitionDecision,
salePrice: null,
carryOverValue: c?.endValue ?? 0,
originalValue: c?.startValue ?? 0,
saleTaxRate: s.saleTaxRate,
};
}),
...phase.realEstates.map((re) => {
const c = computed.realEstates.find((cr) => cr.id === re.id);
return {
positionType: "REAL_ESTATE" as const,
id: re.id,
name: re.name,
decision: "CARRY_OVER" as TransitionDecision,
salePrice: re.marketValue,
carryOverValue: c?.endNetIfKept ?? 0,
originalValue: re.marketValue,
saleTaxRate: re.saleTaxRate,
};
}),
];
try {
const data = await api.get<{ transition: { items: { positionType: string; securityId: string | null; realEstateId: string | null; decision: TransitionDecision; salePrice: number | null }[] } | null }>(
`/api/phases/${phase.id}/transition`
);
if (cancelled) return;
if (data.transition) {
for (const savedItem of data.transition.items) {
const target = initial.find(
(it) => it.id === (savedItem.securityId ?? savedItem.realEstateId)
);
if (target) {
target.decision = savedItem.decision;
if (savedItem.salePrice != null) target.salePrice = savedItem.salePrice;
}
}
}
setItems(initial);
setLoaded(true);
} catch {
setItems(initial);
setLoaded(true);
}
}
load();
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [phase.id]);
if (!loaded) {
return (
<div className="mx-2 rounded-md bg-zinc-100 px-4 py-3 text-xs text-zinc-500 dark:bg-zinc-800">
Uebergang wird geladen
</div>
);
}
const totalAvailableCapital = items.reduce((sum, it) => {
if (it.positionType === "SECURITY") {
if (it.decision === "CARRY_OVER") return sum;
const gain = Math.max(0, it.carryOverValue - it.originalValue);
const tax = gain * (it.saleTaxRate / 100);
return sum + (it.carryOverValue - tax);
}
if (it.decision === "CARRY_OVER") return sum;
const salePrice = it.salePrice ?? 0;
const gain = Math.max(0, salePrice - it.originalValue);
const tax = gain * (it.saleTaxRate / 100);
return sum + (salePrice - tax);
}, 0);
async function handleSave() {
setSaving(true);
setError(null);
setSaved(false);
try {
await api.put(`/api/phases/${phase.id}/transition`, {
items: items.map((it) => ({
positionType: it.positionType,
securityId: it.positionType === "SECURITY" ? it.id : null,
realEstateId: it.positionType === "REAL_ESTATE" ? it.id : null,
decision: it.decision,
salePrice: it.positionType === "REAL_ESTATE" && it.decision === "SELL" ? it.salePrice : null,
})),
});
setSaved(true);
} catch (e) {
setError(e instanceof Error ? e.message : "Speichern fehlgeschlagen.");
} finally {
setSaving(false);
}
}
if (items.length === 0) {
return null;
}
return (
<div className="mx-2 flex flex-col gap-3 rounded-md border border-dashed border-zinc-300 bg-zinc-50 p-4 dark:border-zinc-600 dark:bg-zinc-800/40">
<div className="text-xs font-semibold uppercase tracking-wide text-zinc-500">
Uebergang {nextPhaseName}
</div>
<table className="w-full text-sm">
<thead>
<tr className="text-left text-xs text-zinc-500">
<th className="pb-1 font-normal">Position</th>
<th className="pb-1 font-normal">Entscheidung</th>
<th className="pb-1 font-normal">Verkaufspreis / Wert</th>
</tr>
</thead>
<tbody>
{items.map((it, i) => (
<tr key={`${it.positionType}-${it.id}`} className="border-t border-zinc-200 dark:border-zinc-700">
<td className="py-2 pr-2">{it.name}</td>
<td className="py-2 pr-2">
<select
className="rounded-md border border-zinc-300 bg-white px-2 py-1 text-xs dark:border-zinc-600 dark:bg-zinc-900"
value={it.decision}
onChange={(e) =>
setItems((prev) =>
prev.map((x, idx) => (idx === i ? { ...x, decision: e.target.value as TransitionDecision } : x))
)
}
>
<option value="CARRY_OVER">Uebernehmen</option>
<option value="SELL">Verkaufen</option>
</select>
</td>
<td className="py-2">
{it.positionType === "REAL_ESTATE" && it.decision === "SELL" ? (
<input
type="number"
className="w-32 rounded-md border border-zinc-300 bg-white px-2 py-1 text-xs dark:border-zinc-600 dark:bg-zinc-900"
value={it.salePrice ?? 0}
onChange={(e) =>
setItems((prev) =>
prev.map((x, idx) => (idx === i ? { ...x, salePrice: e.target.valueAsNumber || 0 } : x))
)
}
/>
) : (
<span className="text-xs text-zinc-500">
{it.decision === "CARRY_OVER" ? it.carryOverValue.toLocaleString("de-CH") : it.carryOverValue.toLocaleString("de-CH")} CHF
</span>
)}
</td>
</tr>
))}
</tbody>
</table>
<div className="rounded-md bg-white px-3 py-2 text-sm dark:bg-zinc-900">
Verfuegbares Startkapital fuer neue Phase (aus Verkaeufen): <strong>{totalAvailableCapital.toLocaleString("de-CH")} CHF</strong>
<p className="mt-1 text-xs text-zinc-500">
Dieser Betrag kann anschliessend frei auf neue oder bestehende Wertschriften der Folgephase verteilt werden
(Startwert der jeweiligen Wertschrift in &quot;{nextPhaseName}&quot; manuell anpassen).
</p>
</div>
{error && <p className="text-sm text-red-600 dark:text-red-400">{error}</p>}
<div className="flex items-center gap-3">
<button
type="button"
disabled={saving}
onClick={handleSave}
className="self-start rounded-md bg-zinc-900 px-3 py-1.5 text-xs font-medium text-white hover:bg-zinc-700 disabled:opacity-50 dark:bg-zinc-100 dark:text-zinc-900"
>
{saving ? "Speichern..." : "Uebergang speichern"}
</button>
{saved && <span className="text-xs text-emerald-600 dark:text-emerald-400">Gespeichert.</span>}
</div>
</div>
);
}
+104
View File
@@ -0,0 +1,104 @@
"use client";
import {
CartesianGrid,
Legend,
Line,
LineChart,
ReferenceLine,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import type { PlanComputed } from "@/lib/calculations";
export interface TimelineSeries {
label: string;
color: string;
computed: PlanComputed;
}
function buildTimeline(computed: PlanComputed) {
const points: { year: number; nominal: number; real: number }[] = [
{ year: 0, nominal: computed.phases[0]?.startWealthNominal ?? 0, real: computed.phases[0]?.startWealthNominal ?? 0 },
];
const boundaries: { year: number; name: string }[] = [];
let year = 0;
for (const phase of computed.phases) {
boundaries.push({ year, name: phase.name });
for (let y = 0; y < phase.durationYears; y++) {
year += 1;
points.push({ year, nominal: phase.yearlyNominal[y], real: phase.yearlyReal[y] });
}
}
return { points, boundaries };
}
// Liniendiagramm ueber alle Phasen, nominal + real, mit Markierungen an den
// Phasengrenzen (TDD Kapitel 4.5 / 14). Unterstuetzt optional mehrere 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>;
}
const primary = buildTimeline(series[0].computed);
const maxYear = Math.max(...series.map((s) => buildTimeline(s.computed).points.length - 1));
const merged: Record<number, Record<string, number>> = {};
for (const s of series) {
const tl = buildTimeline(s.computed);
for (const p of tl.points) {
merged[p.year] = merged[p.year] ?? { year: p.year };
merged[p.year][`${s.label} (nominal)`] = p.nominal;
merged[p.year][`${s.label} (real)`] = p.real;
}
}
const data = Array.from({ length: maxYear + 1 }, (_, y) => merged[y] ?? { year: y });
return (
<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" />
<XAxis dataKey="year" tick={{ fontSize: 11 }} label={{ value: "Jahr", position: "insideBottomRight", offset: -4, fontSize: 11 }} />
<YAxis
tick={{ fontSize: 11 }}
tickFormatter={(v) => Intl.NumberFormat("de-CH", { notation: "compact" }).format(v)}
/>
<Tooltip
formatter={(v) =>
typeof v === "number" ? v.toLocaleString("de-CH", { maximumFractionDigits: 0 }) : v
}
/>
<Legend wrapperStyle={{ fontSize: 12 }} />
{primary.boundaries.slice(1).map((b) => (
<ReferenceLine key={b.year} x={b.year} stroke="#a1a1aa" strokeDasharray="2 2" />
))}
{series.map((s) => (
<Line
key={`${s.label}-nominal`}
type="monotone"
dataKey={`${s.label} (nominal)`}
stroke={s.color}
strokeWidth={2}
dot={false}
/>
))}
{series.map((s) => (
<Line
key={`${s.label}-real`}
type="monotone"
dataKey={`${s.label} (real)`}
stroke={s.color}
strokeWidth={2}
strokeDasharray="5 3"
dot={false}
/>
))}
</LineChart>
</ResponsiveContainer>
</div>
);
}
+25
View File
@@ -0,0 +1,25 @@
async function request<T>(url: string, init?: RequestInit): Promise<T> {
const response = await fetch(url, {
...init,
headers: { "Content-Type": "application/json", ...init?.headers },
});
const body = await response.json().catch(() => null);
if (!response.ok) {
const message =
(body && typeof body.error === "string" && body.error) ||
(body && body.error ? JSON.stringify(body.error) : `Fehler ${response.status}`);
throw new Error(message);
}
return body as T;
}
export const api = {
get: <T>(url: string) => request<T>(url),
post: <T>(url: string, data?: unknown) =>
request<T>(url, { method: "POST", body: JSON.stringify(data ?? {}) }),
put: <T>(url: string, data: unknown) =>
request<T>(url, { method: "PUT", body: JSON.stringify(data) }),
patch: <T>(url: string, data: unknown) =>
request<T>(url, { method: "PATCH", body: JSON.stringify(data) }),
delete: <T>(url: string) => request<T>(url, { method: "DELETE" }),
};
+31
View File
@@ -0,0 +1,31 @@
import { SignJWT, jwtVerify } from "jose";
const SESSION_COOKIE_NAME = "fpt_session";
const SESSION_DURATION = "30d";
function getSecretKey() {
const secret = process.env.SESSION_SECRET;
if (!secret) {
throw new Error("SESSION_SECRET ist nicht gesetzt.");
}
return new TextEncoder().encode(secret);
}
export async function createSessionToken(): Promise<string> {
return new SignJWT({ auth: true })
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setExpirationTime(SESSION_DURATION)
.sign(getSecretKey());
}
export async function verifySessionToken(token: string): Promise<boolean> {
try {
const { payload } = await jwtVerify(token, getSecretKey());
return payload.auth === true;
} catch {
return false;
}
}
export { SESSION_COOKIE_NAME };
+311
View File
@@ -0,0 +1,311 @@
import { AHV_COUPLE_CAP_FACTOR, AHV_MAX_PENSION_PER_YEAR } from "@/lib/constants";
import type { HouseholdInput, PhaseInput, PlanInput } from "@/lib/types";
export interface SecurityComputed {
id: string;
name: string;
ownerTag: string;
startValue: number;
endValue: number;
yearly: number[]; // Index 0 = Startwert, Index durationYears = Endwert
}
export interface RealEstateComputed {
id: string;
name: string;
startNet: number;
endNetIfKept: number;
sold: boolean;
saleNetProceeds: number | null;
taxAmount: number;
endContribution: number; // was tatsaechlich in die Endvermoegens-Summe der Phase einfliesst
marketValues: number[];
mortgages: number[];
}
export interface RetirementComputed {
perPerson: {
personId: string;
ahvAmount: number;
pkPensionAmount: number;
lumpSumAmount: number;
lumpSumNet: number;
}[];
combinedAhv: number;
ahvCapped: boolean;
pkTotal: number;
totalPensionIncome: number; // combinedAhv + pkTotal, fliesst als Einkommen in die Phase ein
lumpSumGrossTotal: number;
lumpSumNetTotal: number; // fliesst als Einmalbetrag in das Endvermoegen der Phase ein
}
export interface PhaseComputed {
id: string;
name: string;
sequenceNumber: number;
durationYears: number;
incomeFromEntries: number;
expenseTotal: number;
retirement: RetirementComputed | null;
effectiveIncome: number; // incomeFromEntries + retirement.totalPensionIncome
savingsQuota: number; // effectiveIncome - expenseTotal
allocatedSavings: number; // Summe der jaehrlichen Sparbeitraege auf Wertschriften
savingsWarning: boolean;
securities: SecurityComputed[];
realEstates: RealEstateComputed[];
oneTimeNet: number;
startWealthNominal: number;
endWealthNominal: number;
cumulativeInflationStart: number;
cumulativeInflationEnd: number;
startWealthReal: number;
endWealthReal: number;
yearlyNominal: number[]; // Laenge durationYears, Werte am Ende von Jahr 1..durationYears
yearlyReal: number[];
}
export interface PlanComputed {
phases: PhaseComputed[];
nachlass: number;
totalSavingsWarnings: number;
}
export function computeSecurityYearlyValues(
startValue: number,
expectedReturn: number,
annualContribution: number,
durationYears: number
): number[] {
const values = [startValue];
for (let year = 1; year <= durationYears; year++) {
const previous = values[year - 1];
values.push(previous * (1 + expectedReturn / 100) + annualContribution);
}
return values;
}
export function computeRealEstateYearly(
marketValue: number,
mortgage: number,
valueGrowth: number,
amortization: number,
durationYears: number
): { marketValues: number[]; mortgages: number[] } {
const marketValues = [marketValue];
const mortgages = [mortgage];
for (let year = 1; year <= durationYears; year++) {
marketValues.push(marketValues[year - 1] * (1 + valueGrowth / 100));
mortgages.push(Math.max(0, mortgages[year - 1] - amortization));
}
return { marketValues, mortgages };
}
function computeRetirement(
household: HouseholdInput,
phase: PhaseInput
): RetirementComputed | null {
if (phase.retirementInfos.length === 0) return null;
const perPerson = phase.retirementInfos.map((info) => ({
personId: info.personId,
ahvAmount: info.ahvAmount,
pkPensionAmount: info.pkPensionAmount,
lumpSumAmount: info.lumpSumAmount,
lumpSumNet: info.lumpSumAmount * (1 - info.lumpSumTaxRate / 100),
}));
const ahvSum = perPerson.reduce((sum, p) => sum + p.ahvAmount, 0);
const ahvCap = AHV_MAX_PENSION_PER_YEAR * AHV_COUPLE_CAP_FACTOR;
const isCoupleBothRetired = household.householdType === "COUPLE" && phase.retirementInfos.length === 2;
const combinedAhv = isCoupleBothRetired ? Math.min(ahvSum, ahvCap) : ahvSum;
const ahvCapped = isCoupleBothRetired && ahvSum > ahvCap;
const pkTotal = perPerson.reduce((sum, p) => sum + p.pkPensionAmount, 0);
const lumpSumGrossTotal = perPerson.reduce((sum, p) => sum + p.lumpSumAmount, 0);
const lumpSumNetTotal = perPerson.reduce((sum, p) => sum + p.lumpSumNet, 0);
return {
perPerson,
combinedAhv,
ahvCapped,
pkTotal,
totalPensionIncome: combinedAhv + pkTotal,
lumpSumGrossTotal,
lumpSumNetTotal,
};
}
function computePhase(
phase: PhaseInput,
household: HouseholdInput,
cumulativeInflationStart: number
): PhaseComputed {
const incomeFromEntries = phase.incomeEntries.reduce((sum, e) => sum + e.amount, 0);
const expenseTotal = phase.expenseEntries.reduce((sum, e) => sum + e.amount, 0);
const retirement = computeRetirement(household, phase);
const effectiveIncome = incomeFromEntries + (retirement?.totalPensionIncome ?? 0);
const savingsQuota = effectiveIncome - expenseTotal;
const allocatedSavings = phase.securities.reduce((sum, s) => sum + s.annualContribution, 0);
const savingsWarning = allocatedSavings > savingsQuota;
const securities: SecurityComputed[] = phase.securities.map((s) => {
const yearly = computeSecurityYearlyValues(
s.startValue,
s.expectedReturn,
s.annualContribution,
phase.durationYears
);
return {
id: s.id,
name: s.name,
ownerTag: s.ownerTag,
startValue: yearly[0],
endValue: yearly[phase.durationYears],
yearly,
};
});
const realEstates: RealEstateComputed[] = phase.realEstates.map((re) => {
const { marketValues, mortgages } = computeRealEstateYearly(
re.marketValue,
re.mortgage,
re.valueGrowth,
re.amortization,
phase.durationYears
);
const startNet = marketValues[0] - mortgages[0];
const endNetIfKept = marketValues[phase.durationYears] - mortgages[phase.durationYears];
const sold = re.salePrice != null;
let saleNetProceeds: number | null = null;
let taxAmount = 0;
if (sold) {
// Vereinfachung gemaess TDD 3.3: Gewinn = Verkaufspreis - urspruenglich erfasster Startwert
const gain = Math.max(0, re.salePrice! - re.marketValue);
taxAmount = gain * (re.saleTaxRate / 100);
saleNetProceeds = re.salePrice! - taxAmount;
}
return {
id: re.id,
name: re.name,
startNet,
endNetIfKept,
sold,
saleNetProceeds,
taxAmount,
endContribution: sold ? saleNetProceeds! : endNetIfKept,
marketValues,
mortgages,
};
});
const oneTimeNet = phase.oneTimeEvents.reduce(
(sum, e) => sum + (e.type === "INCOME" ? e.amount : -e.amount),
0
);
const startWealthNominal =
securities.reduce((sum, s) => sum + s.startValue, 0) +
realEstates.reduce((sum, re) => sum + re.startNet, 0);
const endWealthNominal =
securities.reduce((sum, s) => sum + s.endValue, 0) +
realEstates.reduce((sum, re) => sum + re.endContribution, 0) +
oneTimeNet +
(retirement?.lumpSumNetTotal ?? 0);
const inflationRate = phase.inflationRate ?? household.inflationRateDefault;
// TDD Kapitel 3.5: kumulierte Inflation ist ein Produkt ueber die Phasen (ein Faktor
// pro Phase), nicht ueber einzelne Jahre. Bewusst woertlich gemaess Spezifikation umgesetzt.
const cumulativeInflationEnd = cumulativeInflationStart * (1 + inflationRate / 100);
const yearlyNominal: number[] = [];
for (let year = 1; year <= phase.durationYears; year++) {
let value =
securities.reduce((sum, s) => sum + s.yearly[year], 0) +
realEstates.reduce((sum, re) => sum + (re.marketValues[year] - re.mortgages[year]), 0);
if (year === phase.durationYears) {
// Einmalige Ereignisse, Verkaufserloese und Kapitalbezuege schlagen erst am Ende
// der Phase zu Buche (siehe Phasenuebergang, TDD Kapitel 10).
value += oneTimeNet + (retirement?.lumpSumNetTotal ?? 0);
const soldReplacement = realEstates.reduce(
(sum, re) => sum + (re.sold ? re.saleNetProceeds! - (re.marketValues[year] - re.mortgages[year]) : 0),
0
);
value += soldReplacement;
}
yearlyNominal.push(value);
}
// Vereinfachung: innerhalb einer Phase wird fuer den Realwert durchgehend die am
// Phasenende gueltige kumulierte Inflation verwendet (siehe cumulativeInflationEnd oben).
const yearlyReal = yearlyNominal.map((v) => v / cumulativeInflationEnd);
return {
id: phase.id,
name: phase.name,
sequenceNumber: phase.sequenceNumber,
durationYears: phase.durationYears,
incomeFromEntries,
expenseTotal,
retirement,
effectiveIncome,
savingsQuota,
allocatedSavings,
savingsWarning,
securities,
realEstates,
oneTimeNet,
startWealthNominal,
endWealthNominal,
cumulativeInflationStart,
cumulativeInflationEnd,
startWealthReal: startWealthNominal / cumulativeInflationStart,
endWealthReal: endWealthNominal / cumulativeInflationEnd,
yearlyNominal,
yearlyReal,
};
}
export function computePlan(plan: PlanInput, household: HouseholdInput): PlanComputed {
const orderedPhases = [...plan.phases].sort((a, b) => a.sequenceNumber - b.sequenceNumber);
let cumulativeInflation = 1;
const phases: PhaseComputed[] = [];
for (const phase of orderedPhases) {
const computed = computePhase(phase, household, cumulativeInflation);
cumulativeInflation = computed.cumulativeInflationEnd;
phases.push(computed);
}
const nachlass = phases.length > 0 ? phases[phases.length - 1].endWealthNominal : 0;
const totalSavingsWarnings = phases.filter((p) => p.savingsWarning).length;
return { phases, nachlass, totalSavingsWarnings };
}
export function planToCsv(plan: PlanInput, planComputed: PlanComputed): string {
const header = [
"Phase",
"Dauer (Jahre)",
"Startvermoegen (nominal)",
"Endvermoegen (nominal)",
"Endvermoegen (real)",
"Einkommen",
"Ausgaben",
"Sparquote",
"Verplante Sparbeitraege",
"Einmalige Ereignisse (netto)",
];
const rows = planComputed.phases.map((p) => [
p.name,
String(p.durationYears),
p.startWealthNominal.toFixed(2),
p.endWealthNominal.toFixed(2),
p.endWealthReal.toFixed(2),
p.effectiveIncome.toFixed(2),
p.expenseTotal.toFixed(2),
p.savingsQuota.toFixed(2),
p.allocatedSavings.toFixed(2),
p.oneTimeNet.toFixed(2),
]);
return [header, ...rows].map((r) => r.join(";")).join("\n");
}
+7
View File
@@ -0,0 +1,7 @@
// AHV-Maximalrente (Einzelperson, CHF/Jahr). Aendert sich periodisch durch Anpassungen
// des Bundes -- deshalb hier als einzelner konfigurierbarer Systemparameter gefuehrt
// (TDD Kapitel 3.4), nicht hart im Code verteilt.
export const AHV_MAX_PENSION_PER_YEAR = 30240;
// Faktor fuer die Plafonierung der AHV-Rente bei Ehepaaren (TDD Kapitel 3.4).
export const AHV_COUPLE_CAP_FACTOR = 1.5;
+28
View File
@@ -0,0 +1,28 @@
import bcrypt from "bcryptjs";
import { prisma } from "@/lib/db";
// Nur von API-Routes (Node.js-Runtime) verwendet -- niemals von middleware.ts
// importieren, da dort (Edge-Runtime) kein Datenbankzugriff moeglich ist.
// Es gibt genau eine AppCredential-Zeile. Solange keine existiert, ist die App
// "unconfigured" und der naechste Login-Versuch legt das Passwort fest.
export async function getAppCredential() {
return prisma.appCredential.findFirst();
}
export async function setAppPassword(password: string) {
const existing = await getAppCredential();
if (existing) {
// Sollte durch die UI (Passwort-Setup nur beim ersten Login sichtbar) nicht
// vorkommen, wird aber sicherheitshalber serverseitig verhindert.
throw new Error("Es ist bereits ein Passwort gesetzt.");
}
const passwordHash = await bcrypt.hash(password, 12);
return prisma.appCredential.create({ data: { passwordHash } });
}
export async function verifyAppPassword(password: string): Promise<boolean> {
const credential = await getAppCredential();
if (!credential) return false;
return bcrypt.compare(password, credential.passwordHash);
}
+12
View File
@@ -0,0 +1,12 @@
import { PrismaPg } from "@prisma/adapter-pg";
import { PrismaClient } from "@/generated/prisma/client";
const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient };
const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL });
export const prisma = globalForPrisma.prisma ?? new PrismaClient({ adapter });
if (process.env.NODE_ENV !== "production") {
globalForPrisma.prisma = prisma;
}
+102
View File
@@ -0,0 +1,102 @@
import { Prisma } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import type { HouseholdInput, PlanInput } from "@/lib/types";
export const phaseInclude = {
incomeEntries: true,
expenseEntries: true,
securities: true,
realEstates: true,
oneTimeEvents: true,
retirementInfos: true,
} satisfies Prisma.PhaseInclude;
export const planInclude = {
phases: {
include: phaseInclude,
orderBy: { sequenceNumber: "asc" },
},
} satisfies Prisma.PlanInclude;
export type PlanWithRelations = Prisma.PlanGetPayload<{ include: typeof planInclude }>;
export type PhaseWithRelations = Prisma.PhaseGetPayload<{ include: typeof phaseInclude }>;
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,
})),
};
}
export function toPlanInput(plan: PlanWithRelations): PlanInput {
return {
id: plan.id,
name: plan.name,
parentPlanId: plan.parentPlanId,
branchFromPhaseId: plan.branchFromPhaseId,
phases: plan.phases.map((phase) => ({
id: phase.id,
sequenceNumber: phase.sequenceNumber,
name: phase.name,
durationYears: phase.durationYears,
inflationRate: phase.inflationRate,
incomeMode: phase.incomeMode,
incomeEntries: phase.incomeEntries.map((e) => ({
id: e.id,
personId: e.personId,
label: e.label,
amount: e.amount,
})),
expenseEntries: phase.expenseEntries.map((e) => ({
id: e.id,
label: e.label,
amount: e.amount,
})),
securities: phase.securities.map((s) => ({
id: s.id,
name: s.name,
startValue: s.startValue,
expectedReturn: s.expectedReturn,
annualContribution: s.annualContribution,
ownerTag: s.ownerTag,
saleTaxRate: s.saleTaxRate,
})),
realEstates: phase.realEstates.map((re) => ({
id: re.id,
name: re.name,
marketValue: re.marketValue,
mortgage: re.mortgage,
valueGrowth: re.valueGrowth,
amortization: re.amortization,
salePrice: re.salePrice,
saleTaxRate: re.saleTaxRate,
})),
oneTimeEvents: phase.oneTimeEvents.map((e) => ({
id: e.id,
type: e.type,
amount: e.amount,
description: e.description,
})),
retirementInfos: phase.retirementInfos.map((r) => ({
id: r.id,
personId: r.personId,
ahvAmount: r.ahvAmount,
pkPensionAmount: r.pkPensionAmount,
lumpSumAmount: r.lumpSumAmount,
lumpSumTaxRate: r.lumpSumTaxRate,
})),
})),
};
}
export async function getHouseholdOrNull(): Promise<HouseholdWithPersons | null> {
return prisma.household.findFirst({ include: { persons: true } });
}
+98
View File
@@ -0,0 +1,98 @@
// Domain-Typen fuer die Berechnungslogik (lib/calculations.ts) und die API-Payloads.
// Bewusst von den generierten Prisma-Typen entkoppelt, damit die Berechnungslogik
// unabhaengig von der konkreten DB-Repraesentation testbar bleibt.
export type HouseholdType = "SINGLE" | "COUPLE";
export type PersonRole = "PERSON_A" | "PERSON_B";
export type IncomeMode = "PER_PERSON" | "HOUSEHOLD";
export type OwnerTag = "PERSON_A" | "PERSON_B" | "HOUSEHOLD";
export type OneTimeEventType = "INCOME" | "EXPENSE";
export type TransitionDecision = "CARRY_OVER" | "SELL";
export type PositionType = "SECURITY" | "REAL_ESTATE";
export interface PersonInput {
id: string;
role: PersonRole;
age: number;
retirementAge: number;
}
export interface HouseholdInput {
id: string;
householdType: HouseholdType;
inflationRateDefault: number;
persons: PersonInput[];
}
export interface IncomeEntryInput {
id: string;
personId: string | null;
label: string | null;
amount: number;
}
export interface ExpenseEntryInput {
id: string;
label: string | null;
amount: number;
}
export interface SecurityInput {
id: string;
name: string;
startValue: number;
expectedReturn: number;
annualContribution: number;
ownerTag: OwnerTag;
saleTaxRate: number;
}
export interface RealEstateInput {
id: string;
name: string;
marketValue: number;
mortgage: number;
valueGrowth: number;
amortization: number;
salePrice: number | null;
saleTaxRate: number;
}
export interface OneTimeEventInput {
id: string;
type: OneTimeEventType;
amount: number;
description: string | null;
}
export interface RetirementInfoInput {
id: string;
personId: string;
ahvAmount: number;
pkPensionAmount: number;
lumpSumAmount: number;
lumpSumTaxRate: number;
}
export interface PhaseInput {
id: string;
sequenceNumber: number;
name: string;
durationYears: number;
inflationRate: number | null;
incomeMode: IncomeMode;
incomeEntries: IncomeEntryInput[];
expenseEntries: ExpenseEntryInput[];
securities: SecurityInput[];
realEstates: RealEstateInput[];
oneTimeEvents: OneTimeEventInput[];
retirementInfos: RetirementInfoInput[];
}
export interface PlanInput {
id: string;
name: string;
parentPlanId: string | null;
branchFromPhaseId: string | null;
phases: PhaseInput[];
}
+34
View File
@@ -0,0 +1,34 @@
import { NextRequest, NextResponse } from "next/server";
import { SESSION_COOKIE_NAME, verifySessionToken } from "@/lib/auth";
const PUBLIC_PATHS = ["/login", "/api/auth/login", "/api/auth/setup", "/api/auth/status"];
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
if (
PUBLIC_PATHS.includes(pathname) ||
pathname.startsWith("/_next") ||
pathname.startsWith("/favicon")
) {
return NextResponse.next();
}
const token = request.cookies.get(SESSION_COOKIE_NAME)?.value;
const isAuthenticated = token ? await verifySessionToken(token) : false;
if (!isAuthenticated) {
if (pathname.startsWith("/api")) {
return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
}
const loginUrl = new URL("/login", request.url);
loginUrl.searchParams.set("next", pathname);
return NextResponse.redirect(loginUrl);
}
return NextResponse.next();
}
export const config = {
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
};