This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
node_modules
|
||||
.next
|
||||
.git
|
||||
.gitea
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
npm-debug.log*
|
||||
Info Dateien
|
||||
.claude
|
||||
*.md
|
||||
@@ -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
@@ -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/
|
||||
@@ -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 -->
|
||||
+28
@@ -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"]
|
||||
@@ -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.
|
||||
@@ -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:
|
||||
@@ -0,0 +1,7 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
echo "Running database migrations..."
|
||||
npx prisma migrate deploy
|
||||
|
||||
exec "$@"
|
||||
@@ -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;
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: "standalone",
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
Generated
+8346
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
@@ -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?
|
||||
}
|
||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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) });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 |
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 "{nextPhaseName}" 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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" }),
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 } });
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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).*)"],
|
||||
};
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user