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
+11
View File
@@ -0,0 +1,11 @@
node_modules
.next
.git
.gitea
.env
.env.*
!.env.example
npm-debug.log*
Info Dateien
.claude
*.md
+20
View File
@@ -0,0 +1,20 @@
name: Deploy App
on:
push:
branches:
- main
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v4
- name: Deploy to Server
run: |
APP_DIR="/opt/aicds/apps/${GITHUB_REPOSITORY#*/}"
mkdir -p $APP_DIR
cp -a . $APP_DIR/
cd $APP_DIR
docker compose down || true
docker compose up -d --build
docker image prune -f
+47
View File
@@ -0,0 +1,47 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
/src/generated/prisma
# lokale Konzept-Unterlagen und Claude Code Session-Settings (nicht Teil der App)
/Info Dateien/
/.claude/
+5
View File
@@ -0,0 +1,5 @@
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
<!-- END:nextjs-agent-rules -->
+1
View File
@@ -0,0 +1 @@
@AGENTS.md
+28
View File
@@ -0,0 +1,28 @@
FROM node:20-alpine AS base
WORKDIR /app
FROM base AS deps
COPY package.json package-lock.json ./
COPY prisma ./prisma
RUN npm ci
FROM base AS builder
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npx prisma generate
RUN npm run build
FROM base AS runner
ENV NODE_ENV=production
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/.next ./.next
COPY --from=builder /app/public ./public
COPY --from=builder /app/package.json ./package.json
COPY --from=builder /app/next.config.ts ./next.config.ts
COPY --from=builder /app/prisma ./prisma
COPY docker-entrypoint.sh ./docker-entrypoint.sh
RUN chmod +x ./docker-entrypoint.sh
EXPOSE 3000
ENTRYPOINT ["./docker-entrypoint.sh"]
CMD ["npm", "start"]
+36
View File
@@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
+40
View File
@@ -0,0 +1,40 @@
services:
app:
build: .
restart: unless-stopped
environment:
DATABASE_URL: postgresql://fpt:${POSTGRES_PASSWORD}@db:5432/fpt?schema=public
SESSION_SECRET: ${SESSION_SECRET}
NODE_ENV: production
depends_on:
- db
networks:
- agent-net
- internal
labels:
- "traefik.enable=true"
- "traefik.http.routers.fpt.rule=Host(`fpt.aicds.ch`)"
- "traefik.http.routers.fpt.entrypoints=websecure"
- "traefik.http.routers.fpt.tls.certresolver=myresolver"
- "traefik.http.services.fpt.loadbalancer.server.port=3000"
db:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_USER: fpt
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: fpt
volumes:
- fpt_db_data:/var/lib/postgresql/data
networks:
- internal
networks:
agent-net:
external: true
internal:
driver: bridge
volumes:
fpt_db_data:
+7
View File
@@ -0,0 +1,7 @@
#!/bin/sh
set -e
echo "Running database migrations..."
npx prisma migrate deploy
exec "$@"
+18
View File
@@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;
+7
View File
@@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
};
export default nextConfig;
+8346
View File
File diff suppressed because it is too large Load Diff
+43
View File
@@ -0,0 +1,43 @@
{
"name": "fpt",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint",
"postinstall": "prisma generate"
},
"dependencies": {
"@prisma/adapter-pg": "^7.8.0",
"@prisma/client": "^7.8.0",
"bcryptjs": "^3.0.3",
"jose": "^6.2.3",
"next": "16.2.10",
"pg": "^8.22.0",
"prisma": "^7.8.0",
"react": "19.2.4",
"react-dom": "19.2.4",
"recharts": "^3.9.2",
"zod": "^4.4.3"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/pg": "^8.20.0",
"@types/react": "^19",
"@types/react-dom": "^19",
"dotenv": "^17.4.2",
"eslint": "^9",
"eslint-config-next": "16.2.10",
"tailwindcss": "^4",
"typescript": "^5"
},
"allowScripts": {
"@prisma/engines@7.8.0": true,
"prisma@7.8.0": true,
"sharp@0.34.5": true,
"unrs-resolver@1.12.2": true
}
}
+7
View File
@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
+14
View File
@@ -0,0 +1,14 @@
// This file was generated by Prisma, and assumes you have installed the following:
// npm install --save-dev prisma dotenv
import "dotenv/config";
import { defineConfig } from "prisma/config";
export default defineConfig({
schema: "prisma/schema.prisma",
migrations: {
path: "prisma/migrations",
},
datasource: {
url: process.env["DATABASE_URL"],
},
});
@@ -0,0 +1,245 @@
-- CreateSchema
CREATE SCHEMA IF NOT EXISTS "public";
-- CreateEnum
CREATE TYPE "HouseholdType" AS ENUM ('SINGLE', 'COUPLE');
-- CreateEnum
CREATE TYPE "PersonRole" AS ENUM ('PERSON_A', 'PERSON_B');
-- CreateEnum
CREATE TYPE "IncomeMode" AS ENUM ('PER_PERSON', 'HOUSEHOLD');
-- CreateEnum
CREATE TYPE "OwnerTag" AS ENUM ('PERSON_A', 'PERSON_B', 'HOUSEHOLD');
-- CreateEnum
CREATE TYPE "OneTimeEventType" AS ENUM ('INCOME', 'EXPENSE');
-- CreateEnum
CREATE TYPE "TransitionDecision" AS ENUM ('CARRY_OVER', 'SELL');
-- CreateEnum
CREATE TYPE "PositionType" AS ENUM ('SECURITY', 'REAL_ESTATE');
-- CreateTable
CREATE TABLE "AppCredential" (
"id" TEXT NOT NULL,
"passwordHash" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AppCredential_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Household" (
"id" TEXT NOT NULL,
"householdType" "HouseholdType" NOT NULL,
"inflationRateDefault" DOUBLE PRECISION NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Household_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Person" (
"id" TEXT NOT NULL,
"householdId" TEXT NOT NULL,
"role" "PersonRole" NOT NULL,
"age" INTEGER NOT NULL,
"retirementAge" INTEGER NOT NULL,
CONSTRAINT "Person_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Plan" (
"id" TEXT NOT NULL,
"householdId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"parentPlanId" TEXT,
"branchFromPhaseId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Plan_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Phase" (
"id" TEXT NOT NULL,
"planId" TEXT NOT NULL,
"sequenceNumber" INTEGER NOT NULL,
"name" TEXT NOT NULL,
"durationYears" INTEGER NOT NULL,
"inflationRate" DOUBLE PRECISION,
"incomeMode" "IncomeMode" NOT NULL DEFAULT 'HOUSEHOLD',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Phase_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "IncomeEntry" (
"id" TEXT NOT NULL,
"phaseId" TEXT NOT NULL,
"personId" TEXT,
"label" TEXT,
"amount" DOUBLE PRECISION NOT NULL,
CONSTRAINT "IncomeEntry_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ExpenseEntry" (
"id" TEXT NOT NULL,
"phaseId" TEXT NOT NULL,
"label" TEXT,
"amount" DOUBLE PRECISION NOT NULL,
CONSTRAINT "ExpenseEntry_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Security" (
"id" TEXT NOT NULL,
"phaseId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"startValue" DOUBLE PRECISION NOT NULL,
"expectedReturn" DOUBLE PRECISION NOT NULL,
"annualContribution" DOUBLE PRECISION NOT NULL DEFAULT 0,
"ownerTag" "OwnerTag" NOT NULL DEFAULT 'HOUSEHOLD',
"saleTaxRate" DOUBLE PRECISION NOT NULL DEFAULT 0,
CONSTRAINT "Security_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "RealEstate" (
"id" TEXT NOT NULL,
"phaseId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"marketValue" DOUBLE PRECISION NOT NULL,
"mortgage" DOUBLE PRECISION NOT NULL,
"valueGrowth" DOUBLE PRECISION NOT NULL,
"amortization" DOUBLE PRECISION NOT NULL,
"salePrice" DOUBLE PRECISION,
"saleTaxRate" DOUBLE PRECISION NOT NULL DEFAULT 20,
CONSTRAINT "RealEstate_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "OneTimeEvent" (
"id" TEXT NOT NULL,
"phaseId" TEXT NOT NULL,
"type" "OneTimeEventType" NOT NULL,
"amount" DOUBLE PRECISION NOT NULL,
"description" TEXT,
CONSTRAINT "OneTimeEvent_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "RetirementInfo" (
"id" TEXT NOT NULL,
"phaseId" TEXT NOT NULL,
"personId" TEXT NOT NULL,
"ahvAmount" DOUBLE PRECISION NOT NULL DEFAULT 0,
"pkPensionAmount" DOUBLE PRECISION NOT NULL DEFAULT 0,
"lumpSumAmount" DOUBLE PRECISION NOT NULL DEFAULT 0,
"lumpSumTaxRate" DOUBLE PRECISION NOT NULL DEFAULT 8,
CONSTRAINT "RetirementInfo_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "PhaseTransition" (
"id" TEXT NOT NULL,
"fromPhaseId" TEXT NOT NULL,
"toPhaseId" TEXT NOT NULL,
CONSTRAINT "PhaseTransition_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "PhaseTransitionItem" (
"id" TEXT NOT NULL,
"transitionId" TEXT NOT NULL,
"positionType" "PositionType" NOT NULL,
"securityId" TEXT,
"realEstateId" TEXT,
"decision" "TransitionDecision" NOT NULL,
"salePrice" DOUBLE PRECISION,
CONSTRAINT "PhaseTransitionItem_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Person_householdId_role_key" ON "Person"("householdId", "role");
-- CreateIndex
CREATE UNIQUE INDEX "Phase_planId_sequenceNumber_key" ON "Phase"("planId", "sequenceNumber");
-- CreateIndex
CREATE UNIQUE INDEX "RetirementInfo_phaseId_personId_key" ON "RetirementInfo"("phaseId", "personId");
-- CreateIndex
CREATE UNIQUE INDEX "PhaseTransition_fromPhaseId_key" ON "PhaseTransition"("fromPhaseId");
-- CreateIndex
CREATE UNIQUE INDEX "PhaseTransition_toPhaseId_key" ON "PhaseTransition"("toPhaseId");
-- AddForeignKey
ALTER TABLE "Person" ADD CONSTRAINT "Person_householdId_fkey" FOREIGN KEY ("householdId") REFERENCES "Household"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Plan" ADD CONSTRAINT "Plan_householdId_fkey" FOREIGN KEY ("householdId") REFERENCES "Household"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Plan" ADD CONSTRAINT "Plan_parentPlanId_fkey" FOREIGN KEY ("parentPlanId") REFERENCES "Plan"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Phase" ADD CONSTRAINT "Phase_planId_fkey" FOREIGN KEY ("planId") REFERENCES "Plan"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "IncomeEntry" ADD CONSTRAINT "IncomeEntry_phaseId_fkey" FOREIGN KEY ("phaseId") REFERENCES "Phase"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "IncomeEntry" ADD CONSTRAINT "IncomeEntry_personId_fkey" FOREIGN KEY ("personId") REFERENCES "Person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ExpenseEntry" ADD CONSTRAINT "ExpenseEntry_phaseId_fkey" FOREIGN KEY ("phaseId") REFERENCES "Phase"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Security" ADD CONSTRAINT "Security_phaseId_fkey" FOREIGN KEY ("phaseId") REFERENCES "Phase"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RealEstate" ADD CONSTRAINT "RealEstate_phaseId_fkey" FOREIGN KEY ("phaseId") REFERENCES "Phase"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "OneTimeEvent" ADD CONSTRAINT "OneTimeEvent_phaseId_fkey" FOREIGN KEY ("phaseId") REFERENCES "Phase"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RetirementInfo" ADD CONSTRAINT "RetirementInfo_phaseId_fkey" FOREIGN KEY ("phaseId") REFERENCES "Phase"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RetirementInfo" ADD CONSTRAINT "RetirementInfo_personId_fkey" FOREIGN KEY ("personId") REFERENCES "Person"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PhaseTransition" ADD CONSTRAINT "PhaseTransition_fromPhaseId_fkey" FOREIGN KEY ("fromPhaseId") REFERENCES "Phase"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PhaseTransition" ADD CONSTRAINT "PhaseTransition_toPhaseId_fkey" FOREIGN KEY ("toPhaseId") REFERENCES "Phase"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PhaseTransitionItem" ADD CONSTRAINT "PhaseTransitionItem_transitionId_fkey" FOREIGN KEY ("transitionId") REFERENCES "PhaseTransition"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PhaseTransitionItem" ADD CONSTRAINT "PhaseTransitionItem_securityId_fkey" FOREIGN KEY ("securityId") REFERENCES "Security"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PhaseTransitionItem" ADD CONSTRAINT "PhaseTransitionItem_realEstateId_fkey" FOREIGN KEY ("realEstateId") REFERENCES "RealEstate"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+3
View File
@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"
+235
View File
@@ -0,0 +1,235 @@
// FPT (Financial Planning Tool) — Datenmodell gemaess FDD/TDD Kapitel 2.
// Get a free hosted Postgres database in seconds: `npx create-db`
generator client {
provider = "prisma-client"
output = "../src/generated/prisma"
}
datasource db {
provider = "postgresql"
}
// Zugriffsschutz der Webapplikation: kein vorkonfiguriertes Passwort -- der
// Benutzer legt es beim allerersten Login selbst fest (genau eine Zeile).
model AppCredential {
id String @id @default(cuid())
passwordHash String
createdAt DateTime @default(now())
}
enum HouseholdType {
SINGLE
COUPLE
}
enum PersonRole {
PERSON_A
PERSON_B
}
enum IncomeMode {
PER_PERSON
HOUSEHOLD
}
enum OwnerTag {
PERSON_A
PERSON_B
HOUSEHOLD
}
enum OneTimeEventType {
INCOME
EXPENSE
}
enum TransitionDecision {
CARRY_OVER
SELL
}
enum PositionType {
SECURITY
REAL_ESTATE
}
// Ein Haushalt (1 oder 2 Personen) - Wurzel-Objekt
model Household {
id String @id @default(cuid())
householdType HouseholdType
inflationRateDefault Float
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
persons Person[]
plans Plan[]
}
// Einzelperson im Haushalt (Alter, geplantes Pensionsalter)
model Person {
id String @id @default(cuid())
householdId String
household Household @relation(fields: [householdId], references: [id], onDelete: Cascade)
role PersonRole
age Int
retirementAge Int
incomeEntries IncomeEntry[]
retirementInfos RetirementInfo[]
@@unique([householdId, role])
}
// Eine vollstaendige Phasenkette; kann Szenario eines anderen Plans sein
model Plan {
id String @id @default(cuid())
householdId String
household Household @relation(fields: [householdId], references: [id], onDelete: Cascade)
name String
// Szenario-Verzweigung: ein Szenario ist ein eigener Plan mit Verweis auf den Ursprungsplan
// und die Phase, ab der die Ketten divergieren (branchFromPhaseId zeigt auf eine Phase
// dieses neuen Plans, welche die per Deep-Copy duplizierte letzte gemeinsame Phase ist).
parentPlanId String?
parentPlan Plan? @relation("PlanScenarios", fields: [parentPlanId], references: [id], onDelete: SetNull)
scenarios Plan[] @relation("PlanScenarios")
branchFromPhaseId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
phases Phase[]
}
// Ein Lebensabschnitt innerhalb eines Plans
model Phase {
id String @id @default(cuid())
planId String
plan Plan @relation(fields: [planId], references: [id], onDelete: Cascade)
sequenceNumber Int
name String
durationYears Int
inflationRate Float?
incomeMode IncomeMode @default(HOUSEHOLD)
incomeEntries IncomeEntry[]
expenseEntries ExpenseEntry[]
securities Security[]
realEstates RealEstate[]
oneTimeEvents OneTimeEvent[]
retirementInfos RetirementInfo[]
// Uebergang IN diese Phase (diese Phase ist Ziel) bzw. AUS dieser Phase (diese Phase ist Quelle)
transitionIn PhaseTransition? @relation("TransitionTarget")
transitionOut PhaseTransition? @relation("TransitionSource")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([planId, sequenceNumber])
}
// Einkommensposten einer Phase (pro Person oder gemeinsam, je nach Phase.incomeMode)
model IncomeEntry {
id String @id @default(cuid())
phaseId String
phase Phase @relation(fields: [phaseId], references: [id], onDelete: Cascade)
personId String?
person Person? @relation(fields: [personId], references: [id], onDelete: SetNull)
label String?
amount Float
}
// Ausgabenposten einer Phase (immer Haushaltsebene, generischer Gesamtbetrag)
model ExpenseEntry {
id String @id @default(cuid())
phaseId String
phase Phase @relation(fields: [phaseId], references: [id], onDelete: Cascade)
label String?
amount Float
}
// Eine Wertschrift innerhalb einer Phase (inkl. jaehrlichem Sparbeitrag)
model Security {
id String @id @default(cuid())
phaseId String
phase Phase @relation(fields: [phaseId], references: [id], onDelete: Cascade)
name String
startValue Float
expectedReturn Float
annualContribution Float @default(0)
ownerTag OwnerTag @default(HOUSEHOLD)
// Steuersatz auf Verkaufsgewinn bei Uebernahme in PhaseTransitionItem (Default 0%, siehe Kap. 9)
saleTaxRate Float @default(0)
transitionItems PhaseTransitionItem[]
}
// Eine Immobilie innerhalb einer Phase
model RealEstate {
id String @id @default(cuid())
phaseId String
phase Phase @relation(fields: [phaseId], references: [id], onDelete: Cascade)
name String
marketValue Float
mortgage Float
valueGrowth Float
amortization Float
salePrice Float?
// Geschaetzte Grundstueckgewinnsteuer (%), direkt am ausloesenden Ereignis erfasst (Kap. 9)
saleTaxRate Float @default(20)
transitionItems PhaseTransitionItem[]
}
// Einmalige Sondereinnahme/-ausgabe
model OneTimeEvent {
id String @id @default(cuid())
phaseId String
phase Phase @relation(fields: [phaseId], references: [id], onDelete: Cascade)
type OneTimeEventType
amount Float
description String?
}
// Renten-/Kapitalbezugsangaben (nur in Pensionierungsphasen), pro Person
model RetirementInfo {
id String @id @default(cuid())
phaseId String
phase Phase @relation(fields: [phaseId], references: [id], onDelete: Cascade)
personId String
person Person @relation(fields: [personId], references: [id], onDelete: Cascade)
ahvAmount Float @default(0)
pkPensionAmount Float @default(0)
lumpSumAmount Float @default(0)
// Geschaetzte Kapitalbezugssteuer (%), direkt am auslösenden Ereignis erfasst (Kap. 9)
lumpSumTaxRate Float @default(8)
@@unique([phaseId, personId])
}
// Entscheidungen beim Uebergang zweier Phasen (uebernehmen/verkaufen je Position)
model PhaseTransition {
id String @id @default(cuid())
fromPhaseId String @unique
fromPhase Phase @relation("TransitionSource", fields: [fromPhaseId], references: [id], onDelete: Cascade)
toPhaseId String @unique
toPhase Phase @relation("TransitionTarget", fields: [toPhaseId], references: [id], onDelete: Cascade)
items PhaseTransitionItem[]
}
// Einzelentscheidung fuer eine Position (Wertschrift oder Immobilie) beim Phasenuebergang
model PhaseTransitionItem {
id String @id @default(cuid())
transitionId String
transition PhaseTransition @relation(fields: [transitionId], references: [id], onDelete: Cascade)
positionType PositionType
securityId String?
security Security? @relation(fields: [securityId], references: [id], onDelete: Cascade)
realEstateId String?
realEstate RealEstate? @relation(fields: [realEstateId], references: [id], onDelete: Cascade)
decision TransitionDecision
salePrice Float?
}
+1
View File
@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

+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).*)"],
};
+34
View File
@@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}