Rework core model: financial elements as plan-wide entities across phases; derived phase types (Erwerb/Pension/Misch) with retirement-capped durations and per-plan retirement age; AHV (gap years + couple ceiling), PK payout/annuity, 3a, real estate, other assets/debts; horizontal timeline with retirement markers; phase x element matrix with detail panel; savings/consumption quota + available-capital key figures with red status
Deploy App / deploy (push) Successful in 1m57s
Deploy App / deploy (push) Successful in 1m57s
This commit is contained in:
@@ -0,0 +1,73 @@
|
|||||||
|
-- Kern-Rework: finanzielle Elemente auf Plan-Ebene (JSON pro Phase/Uebergang).
|
||||||
|
-- Fresh-Start (abgestimmt 13.07.2026): bestehende Test-Daten werden verworfen.
|
||||||
|
|
||||||
|
-- Alle bestehenden Daten entfernen (Cascade raeumt Plaene, Phasen, alte Element-Tabellen ab)
|
||||||
|
DELETE FROM "Household";
|
||||||
|
|
||||||
|
-- Alte element-spezifische Tabellen entfernen
|
||||||
|
DROP TABLE IF EXISTS "PhaseTransitionItem" CASCADE;
|
||||||
|
DROP TABLE IF EXISTS "PhaseTransition" CASCADE;
|
||||||
|
DROP TABLE IF EXISTS "IncomeEntry" CASCADE;
|
||||||
|
DROP TABLE IF EXISTS "ExpenseEntry" CASCADE;
|
||||||
|
DROP TABLE IF EXISTS "Security" CASCADE;
|
||||||
|
DROP TABLE IF EXISTS "RealEstate" CASCADE;
|
||||||
|
DROP TABLE IF EXISTS "OneTimeEvent" CASCADE;
|
||||||
|
DROP TABLE IF EXISTS "RetirementInfo" CASCADE;
|
||||||
|
|
||||||
|
-- Phase: nicht mehr benoetigte Spalten entfernen (VOR dem Drop der genutzten Enums)
|
||||||
|
ALTER TABLE "Phase" DROP COLUMN IF EXISTS "incomeMode";
|
||||||
|
ALTER TABLE "Phase" DROP COLUMN IF EXISTS "incomingCapital";
|
||||||
|
|
||||||
|
-- Alte Enums entfernen (jetzt von keiner Spalte mehr referenziert)
|
||||||
|
DROP TYPE IF EXISTS "IncomeMode";
|
||||||
|
DROP TYPE IF EXISTS "OwnerTag";
|
||||||
|
DROP TYPE IF EXISTS "OneTimeEventType";
|
||||||
|
DROP TYPE IF EXISTS "TransitionDecision";
|
||||||
|
DROP TYPE IF EXISTS "PositionType";
|
||||||
|
|
||||||
|
-- Plan: plan-spezifische Pensionsalter-Overrides
|
||||||
|
ALTER TABLE "Plan" ADD COLUMN "retirementAgeA" INTEGER;
|
||||||
|
ALTER TABLE "Plan" ADD COLUMN "retirementAgeB" INTEGER;
|
||||||
|
|
||||||
|
-- Neue Enums
|
||||||
|
CREATE TYPE "OwnerRole" AS ENUM ('PERSON_A', 'PERSON_B', 'HOUSEHOLD');
|
||||||
|
CREATE TYPE "ElementCategory" AS ENUM ('INCOME', 'EXPENSE', 'AHV', 'PENSION_FUND', 'PILLAR_3A', 'REAL_ESTATE', 'OTHER_ASSET', 'OTHER_DEBT');
|
||||||
|
|
||||||
|
-- FinancialElement
|
||||||
|
CREATE TABLE "FinancialElement" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"planId" TEXT NOT NULL,
|
||||||
|
"category" "ElementCategory" NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"ownerRole" "OwnerRole",
|
||||||
|
"orderIndex" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT "FinancialElement_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ElementPhaseValue
|
||||||
|
CREATE TABLE "ElementPhaseValue" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"elementId" TEXT NOT NULL,
|
||||||
|
"phaseId" TEXT NOT NULL,
|
||||||
|
"data" JSONB NOT NULL,
|
||||||
|
CONSTRAINT "ElementPhaseValue_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX "ElementPhaseValue_elementId_phaseId_key" ON "ElementPhaseValue"("elementId", "phaseId");
|
||||||
|
|
||||||
|
-- ElementTransitionValue
|
||||||
|
CREATE TABLE "ElementTransitionValue" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"elementId" TEXT NOT NULL,
|
||||||
|
"fromPhaseId" TEXT NOT NULL,
|
||||||
|
"data" JSONB NOT NULL,
|
||||||
|
CONSTRAINT "ElementTransitionValue_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX "ElementTransitionValue_elementId_fromPhaseId_key" ON "ElementTransitionValue"("elementId", "fromPhaseId");
|
||||||
|
|
||||||
|
-- Foreign Keys
|
||||||
|
ALTER TABLE "FinancialElement" ADD CONSTRAINT "FinancialElement_planId_fkey" FOREIGN KEY ("planId") REFERENCES "Plan"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "ElementPhaseValue" ADD CONSTRAINT "ElementPhaseValue_elementId_fkey" FOREIGN KEY ("elementId") REFERENCES "FinancialElement"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "ElementPhaseValue" ADD CONSTRAINT "ElementPhaseValue_phaseId_fkey" FOREIGN KEY ("phaseId") REFERENCES "Phase"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "ElementTransitionValue" ADD CONSTRAINT "ElementTransitionValue_elementId_fkey" FOREIGN KEY ("elementId") REFERENCES "FinancialElement"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "ElementTransitionValue" ADD CONSTRAINT "ElementTransitionValue_fromPhaseId_fkey" FOREIGN KEY ("fromPhaseId") REFERENCES "Phase"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
+51
-147
@@ -1,5 +1,9 @@
|
|||||||
// FPT (Financial Planning Tool) — Datenmodell gemaess FDD/TDD Kapitel 2.
|
// FPT (Financial Planning Tool) — Datenmodell.
|
||||||
// Get a free hosted Postgres database in seconds: `npx create-db`
|
// Kernkonzept (Rework 07/2026): finanzielle Elemente leben auf PLAN-Ebene und sind
|
||||||
|
// ueber alle Lebensphasen hinweg dieselbe Entitaet. Pro Element existiert je Lebensphase
|
||||||
|
// ein Werte-Datensatz (ElementPhaseValue) und je Uebergang ein Entscheid-Datensatz
|
||||||
|
// (ElementTransitionValue). Die kategorie-/kontextspezifischen Felder liegen als JSON,
|
||||||
|
// validiert und typisiert in der Applikationsschicht (lib/elements.ts).
|
||||||
|
|
||||||
generator client {
|
generator client {
|
||||||
provider = "prisma-client"
|
provider = "prisma-client"
|
||||||
@@ -10,9 +14,6 @@ datasource db {
|
|||||||
provider = "postgresql"
|
provider = "postgresql"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Benutzerkonto: offene Registrierung mit Benutzername + Passwort (bcrypt-Hash).
|
|
||||||
// Jeder Benutzer hat seinen eigenen Haushalt samt Plaenen -- Daten sind strikt
|
|
||||||
// pro Konto isoliert.
|
|
||||||
model User {
|
model User {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
username String @unique
|
username String @unique
|
||||||
@@ -32,33 +33,24 @@ enum PersonRole {
|
|||||||
PERSON_B
|
PERSON_B
|
||||||
}
|
}
|
||||||
|
|
||||||
enum IncomeMode {
|
enum OwnerRole {
|
||||||
PER_PERSON
|
|
||||||
HOUSEHOLD
|
|
||||||
}
|
|
||||||
|
|
||||||
enum OwnerTag {
|
|
||||||
PERSON_A
|
PERSON_A
|
||||||
PERSON_B
|
PERSON_B
|
||||||
HOUSEHOLD
|
HOUSEHOLD
|
||||||
}
|
}
|
||||||
|
|
||||||
enum OneTimeEventType {
|
enum ElementCategory {
|
||||||
INCOME
|
INCOME
|
||||||
EXPENSE
|
EXPENSE
|
||||||
}
|
AHV
|
||||||
|
PENSION_FUND
|
||||||
enum TransitionDecision {
|
PILLAR_3A
|
||||||
CARRY_OVER
|
|
||||||
SELL
|
|
||||||
}
|
|
||||||
|
|
||||||
enum PositionType {
|
|
||||||
SECURITY
|
|
||||||
REAL_ESTATE
|
REAL_ESTATE
|
||||||
|
OTHER_ASSET
|
||||||
|
OTHER_DEBT
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ein Haushalt (1 oder 2 Personen) - Wurzel-Objekt, gehoert genau einem Benutzer
|
// Ein Haushalt (1 oder 2 Personen), gehoert genau einem Benutzer.
|
||||||
model Household {
|
model Household {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
userId String
|
userId String
|
||||||
@@ -72,7 +64,7 @@ model Household {
|
|||||||
plans Plan[]
|
plans Plan[]
|
||||||
}
|
}
|
||||||
|
|
||||||
// Einzelperson im Haushalt (Alter, geplantes Pensionsalter)
|
// Einzelperson im Haushalt. retirementAge ist die Standard-Annahme (im Plan uebersteuerbar).
|
||||||
model Person {
|
model Person {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
householdId String
|
householdId String
|
||||||
@@ -81,22 +73,20 @@ model Person {
|
|||||||
age Int
|
age Int
|
||||||
retirementAge Int
|
retirementAge Int
|
||||||
|
|
||||||
incomeEntries IncomeEntry[]
|
|
||||||
retirementInfos RetirementInfo[]
|
|
||||||
|
|
||||||
@@unique([householdId, role])
|
@@unique([householdId, role])
|
||||||
}
|
}
|
||||||
|
|
||||||
// Eine vollstaendige Phasenkette; kann Szenario eines anderen Plans sein
|
// Eine vollstaendige Phasenkette; kann Szenario eines anderen Plans sein.
|
||||||
|
// retirementAgeA/B uebersteuern das Pensionsalter der jeweiligen Person NUR fuer diesen
|
||||||
|
// Plan (null = Standard aus Person.retirementAge) -- ermoeglicht Fruehpensions-Szenarien.
|
||||||
model Plan {
|
model Plan {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
householdId String
|
householdId String
|
||||||
household Household @relation(fields: [householdId], references: [id], onDelete: Cascade)
|
household Household @relation(fields: [householdId], references: [id], onDelete: Cascade)
|
||||||
name String
|
name String
|
||||||
|
retirementAgeA Int?
|
||||||
|
retirementAgeB Int?
|
||||||
|
|
||||||
// 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?
|
parentPlanId String?
|
||||||
parentPlan Plan? @relation("PlanScenarios", fields: [parentPlanId], references: [id], onDelete: SetNull)
|
parentPlan Plan? @relation("PlanScenarios", fields: [parentPlanId], references: [id], onDelete: SetNull)
|
||||||
scenarios Plan[] @relation("PlanScenarios")
|
scenarios Plan[] @relation("PlanScenarios")
|
||||||
@@ -106,9 +96,11 @@ model Plan {
|
|||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
phases Phase[]
|
phases Phase[]
|
||||||
|
elements FinancialElement[]
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ein Lebensabschnitt innerhalb eines Plans
|
// Ein Lebensabschnitt innerhalb eines Plans. Der Phasentyp (Erwerb/Pension/Mischung)
|
||||||
|
// wird NICHT gespeichert, sondern aus Alter + Pensionsalter abgeleitet (lib/calculations).
|
||||||
model Phase {
|
model Phase {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
planId String
|
planId String
|
||||||
@@ -117,139 +109,51 @@ model Phase {
|
|||||||
name String
|
name String
|
||||||
durationYears Int
|
durationYears Int
|
||||||
inflationRate Float?
|
inflationRate Float?
|
||||||
incomeMode IncomeMode @default(HOUSEHOLD)
|
|
||||||
// Aus Verkaeufen im Uebergang aus der Vorphase verfuegbares Startkapital (wird beim
|
|
||||||
// Speichern des Uebergangs automatisch gesetzt, siehe PhaseTransition).
|
|
||||||
incomingCapital Float @default(0)
|
|
||||||
|
|
||||||
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())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
phaseValues ElementPhaseValue[]
|
||||||
|
transitionValues ElementTransitionValue[] @relation("TransitionFromPhase")
|
||||||
|
|
||||||
@@unique([planId, sequenceNumber])
|
@@unique([planId, sequenceNumber])
|
||||||
}
|
}
|
||||||
|
|
||||||
// Einkommensposten einer Phase (pro Person oder gemeinsam, je nach Phase.incomeMode)
|
// Ein finanzielles Element (plan-weit): Kategorie + optionale Personenzuordnung.
|
||||||
model IncomeEntry {
|
model FinancialElement {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
phaseId String
|
planId String
|
||||||
phase Phase @relation(fields: [phaseId], references: [id], onDelete: Cascade)
|
plan Plan @relation(fields: [planId], references: [id], onDelete: Cascade)
|
||||||
personId String?
|
category ElementCategory
|
||||||
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
|
name String
|
||||||
startValue Float
|
ownerRole OwnerRole?
|
||||||
expectedReturn Float
|
orderIndex Int @default(0)
|
||||||
annualContribution Float @default(0)
|
createdAt DateTime @default(now())
|
||||||
ownerTag OwnerTag @default(HOUSEHOLD)
|
|
||||||
// Steuersatz auf Verkaufsgewinn bei Uebernahme in PhaseTransitionItem (Default 0%, siehe Kap. 9)
|
|
||||||
saleTaxRate Float @default(0)
|
|
||||||
// Baseline-Wert bei automatischer Uebernahme aus der Vorphase (0 bei manuell angelegten
|
|
||||||
// Wertschriften). Dient dazu, im UI zu erkennen, wie viel vom verfuegbaren Startkapital
|
|
||||||
// bereits (on top of der Uebernahme) zugewiesen wurde.
|
|
||||||
carriedBaseValue Float @default(0)
|
|
||||||
// Verweist auf die Wertschrift der Vorphase, aus der automatisch uebernommen wurde
|
|
||||||
// (nur intern zur Deduplizierung bei wiederholtem Speichern des Uebergangs, kein FK).
|
|
||||||
carriedFromSecurityId String?
|
|
||||||
|
|
||||||
transitionItems PhaseTransitionItem[]
|
phaseValues ElementPhaseValue[]
|
||||||
|
transitionValues ElementTransitionValue[]
|
||||||
}
|
}
|
||||||
|
|
||||||
// Eine Immobilie innerhalb einer Phase. Vereinfachtes Modell: kein Wertsteigerungsfeld,
|
// Werte eines Elements INNERHALB einer Lebensphase (kategorie-/kontextspezifisch, JSON).
|
||||||
// der Kaufpreis bleibt ueber die Haltedauer fix -- einzig die Hypothek sinkt durch
|
model ElementPhaseValue {
|
||||||
// Amortisation. Verkaufspreis/-steuer werden nicht hier, sondern erst im Uebergangs-
|
|
||||||
// Screen im Moment des Verkaufs erfasst (siehe PhaseTransitionItem).
|
|
||||||
model RealEstate {
|
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
|
elementId String
|
||||||
|
element FinancialElement @relation(fields: [elementId], references: [id], onDelete: Cascade)
|
||||||
phaseId String
|
phaseId String
|
||||||
phase Phase @relation(fields: [phaseId], references: [id], onDelete: Cascade)
|
phase Phase @relation(fields: [phaseId], references: [id], onDelete: Cascade)
|
||||||
name String
|
data Json
|
||||||
purchasePrice Float
|
|
||||||
mortgage Float
|
|
||||||
amortization Float
|
|
||||||
// Verweist auf die Immobilie der Vorphase, aus der automatisch uebernommen wurde
|
|
||||||
// (nur intern zur Deduplizierung bei wiederholtem Speichern des Uebergangs, kein FK).
|
|
||||||
carriedFromRealEstateId String?
|
|
||||||
|
|
||||||
transitionItems PhaseTransitionItem[]
|
@@unique([elementId, phaseId])
|
||||||
}
|
}
|
||||||
|
|
||||||
// Einmalige Sondereinnahme/-ausgabe
|
// Entscheid/Werte eines Elements beim UEBERGANG nach der Phase fromPhase (JSON).
|
||||||
model OneTimeEvent {
|
model ElementTransitionValue {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
phaseId String
|
elementId String
|
||||||
phase Phase @relation(fields: [phaseId], references: [id], onDelete: Cascade)
|
element FinancialElement @relation(fields: [elementId], references: [id], onDelete: Cascade)
|
||||||
type OneTimeEventType
|
fromPhaseId String
|
||||||
amount Float
|
fromPhase Phase @relation("TransitionFromPhase", fields: [fromPhaseId], references: [id], onDelete: Cascade)
|
||||||
description String?
|
data Json
|
||||||
}
|
|
||||||
|
@@unique([elementId, fromPhaseId])
|
||||||
// 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?
|
|
||||||
// Nur bei Immobilien-Verkauf erfasst (Grundstueckgewinnsteuer in %), siehe RealEstate.
|
|
||||||
saleTaxRate Float?
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { getOwnedElement } from "@/lib/queries";
|
||||||
|
import { getCurrentUserId } from "@/lib/session";
|
||||||
|
import { phaseDataSchema } from "@/lib/elements";
|
||||||
|
|
||||||
|
// Speichert die Werte eines Elements innerhalb einer Lebensphase (Upsert).
|
||||||
|
export async function PUT(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ elementId: string; phaseId: string }> }
|
||||||
|
) {
|
||||||
|
const userId = await getCurrentUserId();
|
||||||
|
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
|
||||||
|
const { elementId, phaseId } = await params;
|
||||||
|
|
||||||
|
const element = await getOwnedElement(elementId, userId);
|
||||||
|
if (!element) return NextResponse.json({ error: "Element nicht gefunden." }, { status: 404 });
|
||||||
|
|
||||||
|
const phase = await prisma.phase.findFirst({ where: { id: phaseId, planId: element.planId } });
|
||||||
|
if (!phase) return NextResponse.json({ error: "Phase nicht gefunden." }, { status: 404 });
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const parsed = phaseDataSchema.safeParse(body);
|
||||||
|
if (!parsed.success) return NextResponse.json({ error: "Ungueltige Eingabe." }, { status: 400 });
|
||||||
|
|
||||||
|
await prisma.elementPhaseValue.upsert({
|
||||||
|
where: { elementId_phaseId: { elementId, phaseId } },
|
||||||
|
create: { elementId, phaseId, data: parsed.data },
|
||||||
|
update: { data: parsed.data },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { getOwnedElement } from "@/lib/queries";
|
||||||
|
import { getCurrentUserId } from "@/lib/session";
|
||||||
|
|
||||||
|
const patchSchema = z.object({ name: z.string().min(1).max(120) });
|
||||||
|
|
||||||
|
export async function PATCH(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ elementId: string }> }
|
||||||
|
) {
|
||||||
|
const userId = await getCurrentUserId();
|
||||||
|
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
|
||||||
|
const { elementId } = await params;
|
||||||
|
|
||||||
|
const element = await getOwnedElement(elementId, userId);
|
||||||
|
if (!element) return NextResponse.json({ error: "Element nicht gefunden." }, { status: 404 });
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const parsed = patchSchema.safeParse(body);
|
||||||
|
if (!parsed.success) return NextResponse.json({ error: "Ungueltige Eingabe." }, { status: 400 });
|
||||||
|
|
||||||
|
await prisma.financialElement.update({ where: { id: element.id }, data: { name: parsed.data.name } });
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(
|
||||||
|
_request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ elementId: string }> }
|
||||||
|
) {
|
||||||
|
const userId = await getCurrentUserId();
|
||||||
|
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
|
||||||
|
const { elementId } = await params;
|
||||||
|
|
||||||
|
const element = await getOwnedElement(elementId, userId);
|
||||||
|
if (!element) return NextResponse.json({ error: "Element nicht gefunden." }, { status: 404 });
|
||||||
|
|
||||||
|
await prisma.financialElement.delete({ where: { id: element.id } });
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { getOwnedElement } from "@/lib/queries";
|
||||||
|
import { getCurrentUserId } from "@/lib/session";
|
||||||
|
import { transitionDataSchema } from "@/lib/elements";
|
||||||
|
|
||||||
|
// Speichert den Uebergangs-Entscheid eines Elements nach der Phase fromPhase (Upsert).
|
||||||
|
export async function PUT(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ elementId: string; fromPhaseId: string }> }
|
||||||
|
) {
|
||||||
|
const userId = await getCurrentUserId();
|
||||||
|
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
|
||||||
|
const { elementId, fromPhaseId } = await params;
|
||||||
|
|
||||||
|
const element = await getOwnedElement(elementId, userId);
|
||||||
|
if (!element) return NextResponse.json({ error: "Element nicht gefunden." }, { status: 404 });
|
||||||
|
|
||||||
|
const phase = await prisma.phase.findFirst({ where: { id: fromPhaseId, planId: element.planId } });
|
||||||
|
if (!phase) return NextResponse.json({ error: "Phase nicht gefunden." }, { status: 404 });
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const parsed = transitionDataSchema.safeParse(body);
|
||||||
|
if (!parsed.success) return NextResponse.json({ error: "Ungueltige Eingabe." }, { status: 400 });
|
||||||
|
|
||||||
|
await prisma.elementTransitionValue.upsert({
|
||||||
|
where: { elementId_fromPhaseId: { elementId, fromPhaseId } },
|
||||||
|
create: { elementId, fromPhaseId, data: parsed.data },
|
||||||
|
update: { data: parsed.data },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
@@ -1,139 +1,75 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { phaseInclude, getOwnedPhase } from "@/lib/queries";
|
import { getHouseholdOrNull, getOwnedPhase, getOwnedPlan, toPlanInput } from "@/lib/queries";
|
||||||
import { getCurrentUserId } from "@/lib/session";
|
import { getCurrentUserId } from "@/lib/session";
|
||||||
|
import { maxPhaseDuration } from "@/lib/calculations";
|
||||||
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),
|
|
||||||
carriedBaseValue: z.number().default(0),
|
|
||||||
});
|
|
||||||
const realEstateSchema = z.object({
|
|
||||||
name: z.string().min(1),
|
|
||||||
// Muss zwingend angegeben werden (siehe Anforderung: Kaufpreis ist Pflichtfeld).
|
|
||||||
purchasePrice: z.number().positive("Kaufpreis muss groesser als 0 sein."),
|
|
||||||
mortgage: z.number(),
|
|
||||||
amortization: z.number(),
|
|
||||||
});
|
|
||||||
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({
|
const updatePhaseSchema = z.object({
|
||||||
name: z.string().min(1).max(120),
|
name: z.string().min(1).max(120).optional(),
|
||||||
durationYears: z.number().int().min(1).max(80),
|
durationYears: z.number().int().min(1).max(80).optional(),
|
||||||
inflationRate: z.number().min(-20).max(50).nullable().optional(),
|
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(
|
export async function PUT(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
{ params }: { params: Promise<{ phaseId: string }> }
|
{ params }: { params: Promise<{ phaseId: string }> }
|
||||||
) {
|
) {
|
||||||
const userId = await getCurrentUserId();
|
const userId = await getCurrentUserId();
|
||||||
if (!userId) {
|
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
|
||||||
return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
|
|
||||||
}
|
|
||||||
const { phaseId } = await params;
|
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 getOwnedPhase(phaseId, userId);
|
const existing = await getOwnedPhase(phaseId, userId);
|
||||||
if (!existing) {
|
if (!existing) return NextResponse.json({ error: "Phase nicht gefunden." }, { status: 404 });
|
||||||
return NextResponse.json({ error: "Phase nicht gefunden." }, { status: 404 });
|
|
||||||
|
const body = await request.json();
|
||||||
|
const parsed = updatePhaseSchema.safeParse(body);
|
||||||
|
if (!parsed.success) return NextResponse.json({ error: "Ungueltige Eingabe." }, { status: 400 });
|
||||||
|
|
||||||
|
let duration = parsed.data.durationYears;
|
||||||
|
if (duration != null) {
|
||||||
|
// Dauer ans naechste Pensionsereignis kappen (Jahre vor dieser Phase).
|
||||||
|
const household = await getHouseholdOrNull(userId);
|
||||||
|
const plan = await getOwnedPlan(existing.planId, userId);
|
||||||
|
if (household && plan) {
|
||||||
|
const planInput = toPlanInput(plan);
|
||||||
|
const yearsBefore = planInput.phases
|
||||||
|
.filter((p) => p.sequenceNumber < existing.sequenceNumber)
|
||||||
|
.reduce((s, p) => s + p.durationYears, 0);
|
||||||
|
const cap = maxPhaseDuration(household.persons, planInput, yearsBefore);
|
||||||
|
if (cap != null) duration = Math.min(duration, cap);
|
||||||
|
duration = Math.max(1, duration);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const phase = await prisma.$transaction(async (tx) => {
|
const phase = await prisma.phase.update({
|
||||||
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 },
|
where: { id: phaseId },
|
||||||
data: {
|
data: {
|
||||||
name: data.name,
|
name: parsed.data.name ?? undefined,
|
||||||
durationYears: data.durationYears,
|
durationYears: duration ?? undefined,
|
||||||
inflationRate: data.inflationRate ?? null,
|
inflationRate: parsed.data.inflationRate === undefined ? undefined : parsed.data.inflationRate,
|
||||||
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 },
|
|
||||||
oneTimeEvents: { create: data.oneTimeEvents.map((e) => ({ ...e, description: e.description ?? null })) },
|
|
||||||
retirementInfos: { create: data.retirementInfos },
|
|
||||||
},
|
},
|
||||||
include: phaseInclude,
|
|
||||||
});
|
});
|
||||||
});
|
return NextResponse.json({ phase: { id: phase.id } });
|
||||||
|
|
||||||
return NextResponse.json({ phase });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Eine Phase kann nur geloescht werden, wenn sie die letzte in der Kette ist -- so
|
// Nur die letzte Phase kann geloescht werden (Verkettung bleibt intakt).
|
||||||
// bleibt die Verkettung (Schlussvermoegen = Startvermoegen der Folgephase) immer intakt.
|
|
||||||
export async function DELETE(
|
export async function DELETE(
|
||||||
_request: NextRequest,
|
_request: NextRequest,
|
||||||
{ params }: { params: Promise<{ phaseId: string }> }
|
{ params }: { params: Promise<{ phaseId: string }> }
|
||||||
) {
|
) {
|
||||||
const userId = await getCurrentUserId();
|
const userId = await getCurrentUserId();
|
||||||
if (!userId) {
|
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
|
||||||
return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
|
|
||||||
}
|
|
||||||
const { phaseId } = await params;
|
const { phaseId } = await params;
|
||||||
const phase = await getOwnedPhase(phaseId, userId);
|
|
||||||
if (!phase) {
|
|
||||||
return NextResponse.json({ error: "Phase nicht gefunden." }, { status: 404 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const laterPhase = await prisma.phase.findFirst({
|
const phase = await getOwnedPhase(phaseId, userId);
|
||||||
|
if (!phase) return NextResponse.json({ error: "Phase nicht gefunden." }, { status: 404 });
|
||||||
|
|
||||||
|
const later = await prisma.phase.findFirst({
|
||||||
where: { planId: phase.planId, sequenceNumber: { gt: phase.sequenceNumber } },
|
where: { planId: phase.planId, sequenceNumber: { gt: phase.sequenceNumber } },
|
||||||
});
|
});
|
||||||
if (laterPhase) {
|
if (later) {
|
||||||
return NextResponse.json(
|
return NextResponse.json({ error: "Nur die letzte Phase kann geloescht werden." }, { status: 400 });
|
||||||
{ error: "Nur die letzte Phase eines Plans kann geloescht werden." },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await prisma.phase.delete({ where: { id: phaseId } });
|
await prisma.phase.delete({ where: { id: phaseId } });
|
||||||
|
|||||||
@@ -1,237 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { prisma } from "@/lib/db";
|
|
||||||
import { computeMortgageYearly, computeSecurityYearlyValues } from "@/lib/calculations";
|
|
||||||
import { floorToThousand } from "@/lib/format";
|
|
||||||
import { getCurrentUserId } from "@/lib/session";
|
|
||||||
|
|
||||||
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(),
|
|
||||||
// Nur bei Immobilien-Verkauf relevant (Grundstueckgewinnsteuer in %).
|
|
||||||
saleTaxRate: z.number().min(0).max(100).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 userId = await getCurrentUserId();
|
|
||||||
if (!userId) {
|
|
||||||
return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
|
|
||||||
}
|
|
||||||
const { phaseId } = await params;
|
|
||||||
const phase = await prisma.phase.findFirst({
|
|
||||||
where: { id: phaseId, plan: { household: { userId } } },
|
|
||||||
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 bzw. Halten/Verkaufen) fuer jede
|
|
||||||
// Position der Vorphase. Uebernommene/gehaltene Positionen werden automatisch 1:1 (mit
|
|
||||||
// zurueckgesetztem Sparbeitrag/Amortisation) in der Folgephase angelegt. Verkaufte
|
|
||||||
// Positionen fliessen als "verfuegbares Startkapital" (Phase.incomingCapital) ein.
|
|
||||||
export async function PUT(
|
|
||||||
request: NextRequest,
|
|
||||||
{ params }: { params: Promise<{ phaseId: string }> }
|
|
||||||
) {
|
|
||||||
const userId = await getCurrentUserId();
|
|
||||||
if (!userId) {
|
|
||||||
return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
|
|
||||||
}
|
|
||||||
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.findFirst({
|
|
||||||
where: { id: phaseId, plan: { household: { userId } } },
|
|
||||||
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/Halten oder Verkaufen gewaehlt werden." },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const securityById = new Map(phase.securities.map((s) => [s.id, s]));
|
|
||||||
const realEstateById = new Map(phase.realEstates.map((re) => [re.id, re]));
|
|
||||||
|
|
||||||
let incomingCapital = 0;
|
|
||||||
const securitiesToCarry: { source: (typeof phase.securities)[number]; endValue: number }[] = [];
|
|
||||||
const realEstatesToCarry: { source: (typeof phase.realEstates)[number]; remainingMortgage: number }[] = [];
|
|
||||||
|
|
||||||
for (const item of parsed.data.items) {
|
|
||||||
if (item.positionType === "SECURITY" && item.securityId) {
|
|
||||||
const security = securityById.get(item.securityId);
|
|
||||||
if (!security) continue;
|
|
||||||
const endValue = computeSecurityYearlyValues(
|
|
||||||
security.startValue,
|
|
||||||
security.expectedReturn,
|
|
||||||
security.annualContribution,
|
|
||||||
phase.durationYears
|
|
||||||
)[phase.durationYears];
|
|
||||||
|
|
||||||
if (item.decision === "CARRY_OVER") {
|
|
||||||
securitiesToCarry.push({ source: security, endValue });
|
|
||||||
} else {
|
|
||||||
const gain = Math.max(0, endValue - security.startValue);
|
|
||||||
const tax = gain * (security.saleTaxRate / 100);
|
|
||||||
incomingCapital += endValue - tax;
|
|
||||||
}
|
|
||||||
} else if (item.positionType === "REAL_ESTATE" && item.realEstateId) {
|
|
||||||
const realEstate = realEstateById.get(item.realEstateId);
|
|
||||||
if (!realEstate) continue;
|
|
||||||
const remainingMortgage = computeMortgageYearly(
|
|
||||||
realEstate.mortgage,
|
|
||||||
realEstate.amortization,
|
|
||||||
phase.durationYears
|
|
||||||
)[phase.durationYears];
|
|
||||||
|
|
||||||
if (item.decision === "CARRY_OVER") {
|
|
||||||
realEstatesToCarry.push({ source: realEstate, remainingMortgage });
|
|
||||||
} else {
|
|
||||||
const salePrice = item.salePrice ?? 0;
|
|
||||||
const saleTaxRate = item.saleTaxRate ?? 0;
|
|
||||||
const gain = Math.max(0, salePrice - realEstate.purchasePrice);
|
|
||||||
const tax = gain * (saleTaxRate / 100);
|
|
||||||
incomingCapital += salePrice - remainingMortgage - tax;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auf ein Vielfaches von 1'000 abrunden, damit der Betrag ueber Wertschriften
|
|
||||||
// (die nur in 1'000er-Schritten Sparbeitraege/Startwerte annehmen) vollstaendig
|
|
||||||
// verteilbar bleibt.
|
|
||||||
incomingCapital = floorToThousand(incomingCapital);
|
|
||||||
|
|
||||||
const transition = await prisma.$transaction(async (tx) => {
|
|
||||||
await tx.phaseTransition.deleteMany({ where: { fromPhaseId: phaseId } });
|
|
||||||
|
|
||||||
// Vorherige automatisch uebernommene Positionen aus einem frueheren Speichern
|
|
||||||
// dieses Uebergangs entfernen, damit sie nicht dupliziert werden. Manuell vom
|
|
||||||
// Benutzer angelegte Positionen (carriedFrom...Id = null) bleiben unberuehrt.
|
|
||||||
await tx.security.deleteMany({
|
|
||||||
where: {
|
|
||||||
phaseId: nextPhase.id,
|
|
||||||
carriedFromSecurityId: { in: phase.securities.map((s) => s.id) },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await tx.realEstate.deleteMany({
|
|
||||||
where: {
|
|
||||||
phaseId: nextPhase.id,
|
|
||||||
carriedFromRealEstateId: { in: phase.realEstates.map((re) => re.id) },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const { source, endValue } of securitiesToCarry) {
|
|
||||||
await tx.security.create({
|
|
||||||
data: {
|
|
||||||
phaseId: nextPhase.id,
|
|
||||||
name: source.name,
|
|
||||||
startValue: endValue,
|
|
||||||
carriedBaseValue: endValue,
|
|
||||||
expectedReturn: source.expectedReturn,
|
|
||||||
annualContribution: 0,
|
|
||||||
ownerTag: source.ownerTag,
|
|
||||||
saleTaxRate: source.saleTaxRate,
|
|
||||||
carriedFromSecurityId: source.id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const { source, remainingMortgage } of realEstatesToCarry) {
|
|
||||||
await tx.realEstate.create({
|
|
||||||
data: {
|
|
||||||
phaseId: nextPhase.id,
|
|
||||||
name: source.name,
|
|
||||||
purchasePrice: source.purchasePrice,
|
|
||||||
mortgage: remainingMortgage,
|
|
||||||
amortization: 0,
|
|
||||||
carriedFromRealEstateId: source.id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
await tx.phase.update({
|
|
||||||
where: { id: nextPhase.id },
|
|
||||||
data: { incomingCapital },
|
|
||||||
});
|
|
||||||
|
|
||||||
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,
|
|
||||||
saleTaxRate: i.saleTaxRate ?? null,
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
include: { items: true },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
return NextResponse.json({ transition, incomingCapital });
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { getOwnedPlan } from "@/lib/queries";
|
||||||
|
import { getCurrentUserId } from "@/lib/session";
|
||||||
|
import { PERSON_ONLY_CATEGORIES } from "@/lib/elements";
|
||||||
|
|
||||||
|
const createSchema = z.object({
|
||||||
|
category: z.enum([
|
||||||
|
"INCOME",
|
||||||
|
"EXPENSE",
|
||||||
|
"AHV",
|
||||||
|
"PENSION_FUND",
|
||||||
|
"PILLAR_3A",
|
||||||
|
"REAL_ESTATE",
|
||||||
|
"OTHER_ASSET",
|
||||||
|
"OTHER_DEBT",
|
||||||
|
]),
|
||||||
|
name: z.string().min(1).max(120),
|
||||||
|
ownerRole: z.enum(["PERSON_A", "PERSON_B", "HOUSEHOLD"]).nullable().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Legt ein neues finanzielles Element (plan-weit) an. Personen-Pflicht je Kategorie.
|
||||||
|
export async function POST(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ planId: string }> }
|
||||||
|
) {
|
||||||
|
const userId = await getCurrentUserId();
|
||||||
|
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
|
||||||
|
const { planId } = await params;
|
||||||
|
|
||||||
|
const plan = await getOwnedPlan(planId, userId);
|
||||||
|
if (!plan) return NextResponse.json({ error: "Plan nicht gefunden." }, { status: 404 });
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const parsed = createSchema.safeParse(body);
|
||||||
|
if (!parsed.success) return NextResponse.json({ error: "Ungueltige Eingabe." }, { status: 400 });
|
||||||
|
|
||||||
|
const { category, name } = parsed.data;
|
||||||
|
let ownerRole = parsed.data.ownerRole ?? null;
|
||||||
|
|
||||||
|
if (PERSON_ONLY_CATEGORIES.includes(category)) {
|
||||||
|
if (ownerRole !== "PERSON_A" && ownerRole !== "PERSON_B") {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Diese Kategorie muss einer Person zugeordnet werden." },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (ownerRole == null) {
|
||||||
|
ownerRole = "HOUSEHOLD";
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxOrder = await prisma.financialElement.aggregate({
|
||||||
|
where: { planId: plan.id },
|
||||||
|
_max: { orderIndex: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const element = await prisma.financialElement.create({
|
||||||
|
data: {
|
||||||
|
planId: plan.id,
|
||||||
|
category,
|
||||||
|
name,
|
||||||
|
ownerRole,
|
||||||
|
orderIndex: (maxOrder._max.orderIndex ?? 0) + 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ element: { id: element.id } }, { status: 201 });
|
||||||
|
}
|
||||||
@@ -1,72 +1,116 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { phaseInclude } from "@/lib/queries";
|
import { getHouseholdOrNull, getOwnedPlan, toHouseholdInput, toPlanInput } from "@/lib/queries";
|
||||||
import { getCurrentUserId } from "@/lib/session";
|
import { getCurrentUserId } from "@/lib/session";
|
||||||
|
import { Prisma } from "@/generated/prisma/client";
|
||||||
|
import { computePlan, maxPhaseDuration } from "@/lib/calculations";
|
||||||
|
import { num, type PhaseData } from "@/lib/elements";
|
||||||
|
|
||||||
const createPhaseSchema = z.object({
|
const createPhaseSchema = z.object({
|
||||||
name: z.string().min(1).max(120),
|
name: z.string().min(1).max(120).optional(),
|
||||||
durationYears: z.number().int().min(1).max(80),
|
durationYears: z.number().int().min(1).max(80).optional(),
|
||||||
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
|
// Legt eine neue Lebensphase am Ende der Kette an. Die Dauer wird ans naechste
|
||||||
// (TDD Kapitel 3: Phasen werden chronologisch aneinandergereiht).
|
// Pensionsereignis gekappt. Fuer bestehende Elemente werden die Werte 1:1 bzw. mit
|
||||||
|
// den fortgeschriebenen Endbestaenden aus der Vorphase vorbelegt.
|
||||||
export async function POST(
|
export async function POST(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
{ params }: { params: Promise<{ planId: string }> }
|
{ params }: { params: Promise<{ planId: string }> }
|
||||||
) {
|
) {
|
||||||
const userId = await getCurrentUserId();
|
const userId = await getCurrentUserId();
|
||||||
if (!userId) {
|
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
|
||||||
return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
|
|
||||||
}
|
|
||||||
const { planId } = await params;
|
const { planId } = await params;
|
||||||
const body = await request.json();
|
|
||||||
|
const household = await getHouseholdOrNull(userId);
|
||||||
|
if (!household) return NextResponse.json({ error: "Kein Haushalt vorhanden." }, { status: 400 });
|
||||||
|
const plan = await getOwnedPlan(planId, userId);
|
||||||
|
if (!plan) return NextResponse.json({ error: "Plan nicht gefunden." }, { status: 404 });
|
||||||
|
|
||||||
|
const body = await request.json().catch(() => ({}));
|
||||||
const parsed = createPhaseSchema.safeParse(body);
|
const parsed = createPhaseSchema.safeParse(body);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) return NextResponse.json({ error: "Ungueltige Eingabe." }, { status: 400 });
|
||||||
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const plan = await prisma.plan.findFirst({ where: { id: planId, household: { userId } } });
|
const householdInput = toHouseholdInput(household);
|
||||||
if (!plan) {
|
const planInput = toPlanInput(plan);
|
||||||
return NextResponse.json({ error: "Plan nicht gefunden." }, { status: 404 });
|
const yearsBefore = planInput.phases.reduce((s, p) => s + p.durationYears, 0);
|
||||||
}
|
const cap = maxPhaseDuration(household.persons, planInput, yearsBefore);
|
||||||
|
|
||||||
const lastPhase = await prisma.phase.findFirst({
|
let duration = parsed.data.durationYears ?? (cap ?? 10);
|
||||||
where: { planId },
|
if (cap != null) duration = Math.min(duration, cap);
|
||||||
orderBy: { sequenceNumber: "desc" },
|
duration = Math.max(1, duration);
|
||||||
include: { incomeEntries: true, expenseEntries: true },
|
|
||||||
|
const nextSequence = planInput.phases.length + 1;
|
||||||
|
|
||||||
|
// Phasentyp der neuen Phase fuer den Default-Namen bestimmen.
|
||||||
|
const anyRetiredAtStart = household.persons.some((p) => {
|
||||||
|
const ra = p.role === "PERSON_A" ? planInput.retirementAgeA ?? p.retirementAge : planInput.retirementAgeB ?? p.retirementAge;
|
||||||
|
return p.age + yearsBefore >= ra;
|
||||||
});
|
});
|
||||||
const nextSequence = (lastPhase?.sequenceNumber ?? 0) + 1;
|
const defaultName =
|
||||||
|
parsed.data.name ?? (nextSequence === 1 ? "Erste Lebensphase" : anyRetiredAtStart ? "Pensionsphase" : "Erwerbsphase");
|
||||||
|
|
||||||
// Einkommen und Ausgaben werden 1:1 aus der letzten Phase uebernommen (manuell
|
// Endbestaende der bisher letzten Phase (fuer Carry-Vorbelegung).
|
||||||
// anpassbar), damit man sie nicht bei jeder neuen Phase erneut eintippen muss.
|
const prevComputed = planInput.phases.length > 0 ? computePlan(planInput, householdInput) : null;
|
||||||
const phase = await prisma.phase.create({
|
const lastPhaseId = planInput.phases.at(-1)?.id;
|
||||||
|
const prevPhase = prevComputed?.phases.find((p) => p.id === lastPhaseId) ?? null;
|
||||||
|
const prevElemById = new Map((prevPhase?.elements ?? []).map((e) => [e.elementId, e]));
|
||||||
|
|
||||||
|
const phase = await prisma.$transaction(async (tx) => {
|
||||||
|
const created = await tx.phase.create({
|
||||||
data: {
|
data: {
|
||||||
planId,
|
planId: plan.id,
|
||||||
sequenceNumber: nextSequence,
|
sequenceNumber: nextSequence,
|
||||||
name: parsed.data.name,
|
name: defaultName,
|
||||||
durationYears: parsed.data.durationYears,
|
durationYears: duration,
|
||||||
inflationRate: parsed.data.inflationRate ?? null,
|
|
||||||
incomeMode: lastPhase?.incomeMode ?? parsed.data.incomeMode,
|
|
||||||
incomeEntries: lastPhase
|
|
||||||
? {
|
|
||||||
create: lastPhase.incomeEntries.map((e) => ({
|
|
||||||
personId: e.personId,
|
|
||||||
label: e.label,
|
|
||||||
amount: e.amount,
|
|
||||||
})),
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
expenseEntries: lastPhase
|
|
||||||
? {
|
|
||||||
create: lastPhase.expenseEntries.map((e) => ({ label: e.label, amount: e.amount })),
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
},
|
},
|
||||||
include: phaseInclude,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json({ phase }, { status: 201 });
|
// Carry-Vorbelegung fuer bestehende Elemente.
|
||||||
|
for (const e of planInput.elements) {
|
||||||
|
const prev = prevElemById.get(e.id);
|
||||||
|
if (prev && prev.status !== "ACTIVE") continue; // verkauft/getilgt -> nicht mehr fortfuehren
|
||||||
|
const prevData: PhaseData = e.phaseValues[lastPhaseId ?? ""] ?? {};
|
||||||
|
const data: PhaseData = buildCarryData(e.category, prevData, prev?.endValue);
|
||||||
|
await tx.elementPhaseValue.create({
|
||||||
|
data: { elementId: e.id, phaseId: created.id, data: data as Prisma.InputJsonValue },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return created;
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ phase: { id: phase.id } }, { status: 201 });
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCarryData(
|
||||||
|
category: string,
|
||||||
|
prev: PhaseData,
|
||||||
|
prevEndValue: number | undefined
|
||||||
|
): PhaseData {
|
||||||
|
const endVal = Math.max(0, Math.round(prevEndValue ?? 0));
|
||||||
|
switch (category) {
|
||||||
|
case "INCOME":
|
||||||
|
case "EXPENSE":
|
||||||
|
return { amount: num(prev.amount) };
|
||||||
|
case "AHV":
|
||||||
|
return { gapYears: 0 };
|
||||||
|
case "PENSION_FUND":
|
||||||
|
case "PILLAR_3A":
|
||||||
|
return { currentValue: endVal, annualContribution: num(prev.annualContribution), expectedReturn: num(prev.expectedReturn) };
|
||||||
|
case "OTHER_ASSET":
|
||||||
|
return { startValue: endVal, annualContribution: num(prev.annualContribution), expectedReturn: num(prev.expectedReturn) };
|
||||||
|
case "REAL_ESTATE":
|
||||||
|
// endValue = purchase - mortgage; Hypothek fortschreiben ueber purchasePrice - endValue.
|
||||||
|
return {
|
||||||
|
purchasePrice: num(prev.purchasePrice),
|
||||||
|
mortgage: Math.max(0, num(prev.purchasePrice) - endVal),
|
||||||
|
amortization: num(prev.amortization),
|
||||||
|
};
|
||||||
|
case "OTHER_DEBT":
|
||||||
|
return { startValue: Math.abs(endVal), annualRepayment: num(prev.annualRepayment) };
|
||||||
|
default:
|
||||||
|
return {};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { toHouseholdInput, toPlanInput, getHouseholdOrNull, getOwnedPlan } from "@/lib/queries";
|
import { toHouseholdInput, toPlanInput, getHouseholdOrNull, getOwnedPlan } from "@/lib/queries";
|
||||||
import { getCurrentUserId } from "@/lib/session";
|
import { getCurrentUserId } from "@/lib/session";
|
||||||
@@ -9,19 +10,14 @@ export async function GET(
|
|||||||
{ params }: { params: Promise<{ planId: string }> }
|
{ params }: { params: Promise<{ planId: string }> }
|
||||||
) {
|
) {
|
||||||
const userId = await getCurrentUserId();
|
const userId = await getCurrentUserId();
|
||||||
if (!userId) {
|
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
|
||||||
return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
|
|
||||||
}
|
|
||||||
const { planId } = await params;
|
const { planId } = await params;
|
||||||
|
|
||||||
const household = await getHouseholdOrNull(userId);
|
const household = await getHouseholdOrNull(userId);
|
||||||
if (!household) {
|
if (!household) return NextResponse.json({ error: "Kein Haushalt vorhanden." }, { status: 400 });
|
||||||
return NextResponse.json({ error: "Kein Haushalt vorhanden." }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const plan = await getOwnedPlan(planId, userId);
|
const plan = await getOwnedPlan(planId, userId);
|
||||||
if (!plan) {
|
if (!plan) return NextResponse.json({ error: "Plan nicht gefunden." }, { status: 404 });
|
||||||
return NextResponse.json({ error: "Plan nicht gefunden." }, { status: 404 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const householdInput = toHouseholdInput(household);
|
const householdInput = toHouseholdInput(household);
|
||||||
const planInput = toPlanInput(plan);
|
const planInput = toPlanInput(plan);
|
||||||
@@ -30,19 +26,47 @@ export async function GET(
|
|||||||
return NextResponse.json({ plan: planInput, computed });
|
return NextResponse.json({ plan: planInput, computed });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const patchSchema = z.object({
|
||||||
|
name: z.string().min(1).max(120).optional(),
|
||||||
|
retirementAgeA: z.number().int().min(30).max(100).nullable().optional(),
|
||||||
|
retirementAgeB: z.number().int().min(30).max(100).nullable().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function PATCH(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ planId: string }> }
|
||||||
|
) {
|
||||||
|
const userId = await getCurrentUserId();
|
||||||
|
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
|
||||||
|
const { planId } = await params;
|
||||||
|
|
||||||
|
const plan = await prisma.plan.findFirst({ where: { id: planId, household: { userId } } });
|
||||||
|
if (!plan) return NextResponse.json({ error: "Plan nicht gefunden." }, { status: 404 });
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const parsed = patchSchema.safeParse(body);
|
||||||
|
if (!parsed.success) return NextResponse.json({ error: "Ungueltige Eingabe." }, { status: 400 });
|
||||||
|
|
||||||
|
const updated = await prisma.plan.update({
|
||||||
|
where: { id: plan.id },
|
||||||
|
data: {
|
||||||
|
name: parsed.data.name ?? undefined,
|
||||||
|
retirementAgeA: parsed.data.retirementAgeA === undefined ? undefined : parsed.data.retirementAgeA,
|
||||||
|
retirementAgeB: parsed.data.retirementAgeB === undefined ? undefined : parsed.data.retirementAgeB,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return NextResponse.json({ plan: { id: updated.id, name: updated.name } });
|
||||||
|
}
|
||||||
|
|
||||||
export async function DELETE(
|
export async function DELETE(
|
||||||
_request: NextRequest,
|
_request: NextRequest,
|
||||||
{ params }: { params: Promise<{ planId: string }> }
|
{ params }: { params: Promise<{ planId: string }> }
|
||||||
) {
|
) {
|
||||||
const userId = await getCurrentUserId();
|
const userId = await getCurrentUserId();
|
||||||
if (!userId) {
|
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
|
||||||
return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
|
|
||||||
}
|
|
||||||
const { planId } = await params;
|
const { planId } = await params;
|
||||||
const plan = await prisma.plan.findFirst({ where: { id: planId, household: { userId } } });
|
const plan = await prisma.plan.findFirst({ where: { id: planId, household: { userId } } });
|
||||||
if (!plan) {
|
if (!plan) return NextResponse.json({ error: "Plan nicht gefunden." }, { status: 404 });
|
||||||
return NextResponse.json({ error: "Plan nicht gefunden." }, { status: 404 });
|
|
||||||
}
|
|
||||||
await prisma.plan.delete({ where: { id: plan.id } });
|
await prisma.plan.delete({ where: { id: plan.id } });
|
||||||
return NextResponse.json({ ok: true });
|
return NextResponse.json({ ok: true });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { phaseInclude, getOwnedPlan } from "@/lib/queries";
|
import { getOwnedPlan } from "@/lib/queries";
|
||||||
import { getCurrentUserId } from "@/lib/session";
|
import { getCurrentUserId } from "@/lib/session";
|
||||||
|
|
||||||
const scenarioSchema = z.object({
|
const scenarioSchema = z.object({
|
||||||
@@ -9,114 +9,87 @@ const scenarioSchema = z.object({
|
|||||||
branchFromPhaseId: z.string().min(1),
|
branchFromPhaseId: z.string().min(1),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Erstellt ein neues Szenario als Kopie eines bestehenden Plans ab einer gewaehlten
|
// Erstellt ein Szenario als Deep-Copy eines Plans bis zur Verzweigungsphase (inkl.).
|
||||||
// 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(
|
export async function POST(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
{ params }: { params: Promise<{ planId: string }> }
|
{ params }: { params: Promise<{ planId: string }> }
|
||||||
) {
|
) {
|
||||||
const userId = await getCurrentUserId();
|
const userId = await getCurrentUserId();
|
||||||
if (!userId) {
|
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
|
||||||
return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
|
|
||||||
}
|
|
||||||
const { planId } = await params;
|
const { planId } = await params;
|
||||||
|
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const parsed = scenarioSchema.safeParse(body);
|
const parsed = scenarioSchema.safeParse(body);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) return NextResponse.json({ error: "Ungueltige Eingabe." }, { status: 400 });
|
||||||
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const sourcePlan = await getOwnedPlan(planId, userId);
|
const source = await getOwnedPlan(planId, userId);
|
||||||
if (!sourcePlan) {
|
if (!source) return NextResponse.json({ error: "Ursprungsplan nicht gefunden." }, { status: 404 });
|
||||||
return NextResponse.json({ error: "Ursprungsplan nicht gefunden." }, { status: 404 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const branchPhase = sourcePlan.phases.find((p) => p.id === parsed.data.branchFromPhaseId);
|
const branchPhase = source.phases.find((p) => p.id === parsed.data.branchFromPhaseId);
|
||||||
if (!branchPhase) {
|
if (!branchPhase) return NextResponse.json({ error: "Verzweigungsphase nicht gefunden." }, { status: 404 });
|
||||||
return NextResponse.json({ error: "Verzweigungsphase nicht gefunden." }, { status: 404 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const phasesToCopy = sourcePlan.phases
|
const copiedPhases = source.phases
|
||||||
.filter((p) => p.sequenceNumber <= branchPhase.sequenceNumber)
|
.filter((p) => p.sequenceNumber <= branchPhase.sequenceNumber)
|
||||||
.sort((a, b) => a.sequenceNumber - b.sequenceNumber);
|
.sort((a, b) => a.sequenceNumber - b.sequenceNumber);
|
||||||
|
const copiedPhaseIds = new Set(copiedPhases.map((p) => p.id));
|
||||||
|
|
||||||
const newPlanId = await prisma.$transaction(async (tx) => {
|
const newPlanId = await prisma.$transaction(async (tx) => {
|
||||||
const newPlan = await tx.plan.create({
|
const newPlan = await tx.plan.create({
|
||||||
data: {
|
data: {
|
||||||
householdId: sourcePlan.householdId,
|
householdId: source.householdId,
|
||||||
name: parsed.data.name,
|
name: parsed.data.name,
|
||||||
parentPlanId: sourcePlan.id,
|
retirementAgeA: source.retirementAgeA,
|
||||||
|
retirementAgeB: source.retirementAgeB,
|
||||||
|
parentPlanId: source.id,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Phasen kopieren (alte -> neue Id).
|
||||||
|
const phaseIdMap = new Map<string, string>();
|
||||||
let lastNewPhaseId = "";
|
let lastNewPhaseId = "";
|
||||||
for (const phase of phasesToCopy) {
|
for (const phase of copiedPhases) {
|
||||||
const newPhase = await tx.phase.create({
|
const created = await tx.phase.create({
|
||||||
data: {
|
data: {
|
||||||
planId: newPlan.id,
|
planId: newPlan.id,
|
||||||
sequenceNumber: phase.sequenceNumber,
|
sequenceNumber: phase.sequenceNumber,
|
||||||
name: phase.name,
|
name: phase.name,
|
||||||
durationYears: phase.durationYears,
|
durationYears: phase.durationYears,
|
||||||
inflationRate: phase.inflationRate,
|
inflationRate: phase.inflationRate,
|
||||||
incomeMode: phase.incomeMode,
|
|
||||||
incomingCapital: phase.incomingCapital,
|
|
||||||
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,
|
|
||||||
carriedBaseValue: s.carriedBaseValue,
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
realEstates: {
|
|
||||||
create: phase.realEstates.map((re) => ({
|
|
||||||
name: re.name,
|
|
||||||
purchasePrice: re.purchasePrice,
|
|
||||||
mortgage: re.mortgage,
|
|
||||||
amortization: re.amortization,
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
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;
|
phaseIdMap.set(phase.id, created.id);
|
||||||
|
lastNewPhaseId = created.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
await tx.plan.update({
|
// Elemente + deren Phasen-/Uebergangswerte kopieren.
|
||||||
where: { id: newPlan.id },
|
for (const el of source.elements) {
|
||||||
data: { branchFromPhaseId: lastNewPhaseId },
|
const newEl = await tx.financialElement.create({
|
||||||
|
data: {
|
||||||
|
planId: newPlan.id,
|
||||||
|
category: el.category,
|
||||||
|
name: el.name,
|
||||||
|
ownerRole: el.ownerRole,
|
||||||
|
orderIndex: el.orderIndex,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
for (const pv of el.phaseValues) {
|
||||||
|
const newPhaseId = phaseIdMap.get(pv.phaseId);
|
||||||
|
if (!newPhaseId) continue;
|
||||||
|
await tx.elementPhaseValue.create({
|
||||||
|
data: { elementId: newEl.id, phaseId: newPhaseId, data: pv.data as object },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const tv of el.transitionValues) {
|
||||||
|
if (!copiedPhaseIds.has(tv.fromPhaseId)) continue;
|
||||||
|
const newFromId = phaseIdMap.get(tv.fromPhaseId);
|
||||||
|
if (!newFromId) continue;
|
||||||
|
await tx.elementTransitionValue.create({
|
||||||
|
data: { elementId: newEl.id, fromPhaseId: newFromId, data: tv.data as object },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await tx.plan.update({ where: { id: newPlan.id }, data: { branchFromPhaseId: lastNewPhaseId } });
|
||||||
return newPlan.id;
|
return newPlan.id;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -10,8 +10,7 @@ import {
|
|||||||
Trash2,
|
Trash2,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { PhaseCard, formatAges } from "@/components/PhaseCard";
|
import { PlanView } from "@/components/PlanView";
|
||||||
import { TransitionPanel } from "@/components/TransitionPanel";
|
|
||||||
import { Dashboard } from "@/components/Dashboard";
|
import { Dashboard } from "@/components/Dashboard";
|
||||||
import { HouseholdSettings } from "@/components/HouseholdSettings";
|
import { HouseholdSettings } from "@/components/HouseholdSettings";
|
||||||
import { ProfileMenu } from "@/components/ProfileMenu";
|
import { ProfileMenu } from "@/components/ProfileMenu";
|
||||||
@@ -81,16 +80,6 @@ export function AppShell({
|
|||||||
if (selectedPlanId) loadDetail(selectedPlanId);
|
if (selectedPlanId) loadDetail(selectedPlanId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleAddPhase() {
|
|
||||||
if (!selectedPlanId || !detail) return;
|
|
||||||
await api.post(`/api/plans/${selectedPlanId}/phases`, {
|
|
||||||
name: detail.plan.phases.length === 0 ? "Erste Lebensphase" : `Neue Phase ${detail.plan.phases.length + 1}`,
|
|
||||||
durationYears: 10,
|
|
||||||
incomeMode: "HOUSEHOLD",
|
|
||||||
});
|
|
||||||
refreshCurrent();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleDeletePlan(id: string) {
|
async function handleDeletePlan(id: string) {
|
||||||
if (!confirm("Diesen Plan wirklich loeschen?")) return;
|
if (!confirm("Diesen Plan wirklich loeschen?")) return;
|
||||||
await api.delete(`/api/plans/${id}`);
|
await api.delete(`/api/plans/${id}`);
|
||||||
@@ -232,16 +221,7 @@ export function AppShell({
|
|||||||
|
|
||||||
{!loading && detail && selectedPlanId && (
|
{!loading && detail && selectedPlanId && (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
{/* Plan-Kopf mit Aktionen */}
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleAddPhase}
|
|
||||||
className="flex items-center gap-1.5 rounded-lg border border-dashed border-indigo-300 bg-indigo-50/50 px-3 py-1.5 text-sm font-medium text-indigo-700 hover:bg-indigo-50 dark:border-indigo-500/30 dark:bg-indigo-500/10 dark:text-indigo-300 dark:hover:bg-indigo-500/20"
|
|
||||||
>
|
|
||||||
<Plus className="h-4 w-4" />
|
|
||||||
Phase hinzufuegen
|
|
||||||
</button>
|
|
||||||
{detail.plan.phases.length > 0 && (
|
{detail.plan.phases.length > 0 && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -262,64 +242,12 @@ export function AppShell({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Phasen mit Lebenslinie */}
|
<PlanView
|
||||||
<div className="flex flex-col">
|
plan={detail.plan}
|
||||||
{detail.plan.phases.map((phase, i) => {
|
|
||||||
const computedPhase = detail.computed.phases.find((c) => c.id === phase.id)!;
|
|
||||||
const nextPhase = detail.plan.phases[i + 1];
|
|
||||||
const startAges = computedPhase.ages.map((a) => a.startAge).join("·");
|
|
||||||
return (
|
|
||||||
<div key={phase.id} className="flex gap-3">
|
|
||||||
{/* Lebenslinie */}
|
|
||||||
<div className="hidden w-12 flex-col items-center sm:flex">
|
|
||||||
<div
|
|
||||||
title={`Alter zu Beginn: ${formatAges(computedPhase)}`}
|
|
||||||
className="flex h-9 w-12 items-center justify-center rounded-full border border-indigo-200 bg-indigo-50 text-[11px] font-semibold text-indigo-700 dark:border-indigo-500/30 dark:bg-indigo-500/15 dark:text-indigo-300"
|
|
||||||
>
|
|
||||||
{startAges}
|
|
||||||
</div>
|
|
||||||
<div className="w-px flex-1 bg-indigo-200 dark:bg-indigo-500/30" />
|
|
||||||
</div>
|
|
||||||
<div className="flex min-w-0 flex-1 flex-col gap-3 pb-3">
|
|
||||||
<PhaseCard
|
|
||||||
household={household}
|
household={household}
|
||||||
phase={phase}
|
computed={detail.computed}
|
||||||
computed={computedPhase}
|
|
||||||
isFirst={i === 0}
|
|
||||||
isLast={i === detail.plan.phases.length - 1}
|
|
||||||
onChanged={refreshCurrent}
|
onChanged={refreshCurrent}
|
||||||
/>
|
/>
|
||||||
{nextPhase && (
|
|
||||||
<TransitionPanel
|
|
||||||
phase={phase}
|
|
||||||
computed={computedPhase}
|
|
||||||
nextPhaseName={nextPhase.name}
|
|
||||||
onChanged={refreshCurrent}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
{detail.computed.phases.length > 0 && (
|
|
||||||
<div className="hidden w-12 flex-col items-center sm:flex">
|
|
||||||
<div
|
|
||||||
title="Alter am Ende der letzten Phase"
|
|
||||||
className="flex h-9 w-12 items-center justify-center rounded-full border border-zinc-300 bg-white text-[11px] font-semibold text-zinc-600 dark:border-zinc-600 dark:bg-zinc-800 dark:text-zinc-300"
|
|
||||||
>
|
|
||||||
{detail.computed.phases[detail.computed.phases.length - 1].ages
|
|
||||||
.map((a) => a.endAge)
|
|
||||||
.join("·")}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{detail.plan.phases.length === 0 && (
|
|
||||||
<p className="text-sm text-zinc-500">
|
|
||||||
Dieser Plan hat noch keine Phasen. Fuegen Sie oben die erste Lebensphase hinzu.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{detail.plan.phases.length > 0 && (
|
{detail.plan.phases.length > 0 && (
|
||||||
<Dashboard plan={detail.plan} computed={detail.computed} allPlans={plans} />
|
<Dashboard plan={detail.plan} computed={detail.computed} allPlans={plans} />
|
||||||
|
|||||||
@@ -50,23 +50,28 @@ export function Dashboard({
|
|||||||
return result;
|
return result;
|
||||||
}, [plan.name, computed, compareIds, compareData, allPlans]);
|
}, [plan.name, computed, compareIds, compareData, allPlans]);
|
||||||
|
|
||||||
|
const ASSET_CATS = ["PENSION_FUND", "PILLAR_3A", "REAL_ESTATE", "OTHER_ASSET"];
|
||||||
const barKeys = useMemo(() => {
|
const barKeys = useMemo(() => {
|
||||||
const keys = new Set<string>();
|
const keys = new Set<string>();
|
||||||
for (const phase of computed.phases) {
|
for (const phase of computed.phases) {
|
||||||
for (const s of phase.securities) keys.add(s.name);
|
for (const el of phase.elements) {
|
||||||
for (const re of phase.realEstates) keys.add(re.name);
|
if (ASSET_CATS.includes(el.category) && el.endValue > 0) keys.add(el.name);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return Array.from(keys);
|
return Array.from(keys);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [computed]);
|
}, [computed]);
|
||||||
|
|
||||||
const barData = useMemo(
|
const barData = useMemo(
|
||||||
() =>
|
() =>
|
||||||
computed.phases.map((phase) => {
|
computed.phases.map((phase) => {
|
||||||
const row: Record<string, number | string> = { phase: phase.name };
|
const row: Record<string, number | string> = { phase: phase.name };
|
||||||
for (const s of phase.securities) row[s.name] = s.endValue;
|
for (const el of phase.elements) {
|
||||||
for (const re of phase.realEstates) row[re.name] = re.endNet;
|
if (ASSET_CATS.includes(el.category) && el.endValue > 0) row[el.name] = el.endValue;
|
||||||
|
}
|
||||||
return row;
|
return row;
|
||||||
}),
|
}),
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
[computed]
|
[computed]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,349 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Trash2 } from "lucide-react";
|
||||||
|
import { MoneyField, NumberField, SelectField } from "@/components/FormField";
|
||||||
|
import { api } from "@/lib/api-client";
|
||||||
|
import { CATEGORY_LABELS, num } from "@/lib/elements";
|
||||||
|
import { PILLAR_3A_MAX_ANNUAL } from "@/lib/constants";
|
||||||
|
import type { ElementCategory, PhaseData, TransitionData } from "@/lib/elements";
|
||||||
|
|
||||||
|
export interface CellContext {
|
||||||
|
kind: "phase" | "transition";
|
||||||
|
phaseId: string; // bei transition: die fromPhaseId
|
||||||
|
ownerWorking: boolean;
|
||||||
|
isConsumption: boolean;
|
||||||
|
durationYears: number;
|
||||||
|
isRetirementTransition: boolean;
|
||||||
|
carriedEndValue: number; // Endwert des Elements in der (Vor-)Phase, fuer Bezugs-Maxima
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
element: { id: string; category: ElementCategory; name: string; ownerRole: string | null };
|
||||||
|
context: CellContext;
|
||||||
|
phaseData: PhaseData;
|
||||||
|
transitionData: TransitionData;
|
||||||
|
onSaved: () => void;
|
||||||
|
onDeleteElement: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ElementDetail({ element, context, phaseData, transitionData, onSaved, onDeleteElement }: Props) {
|
||||||
|
const [pd, setPd] = useState<PhaseData>({ ...phaseData });
|
||||||
|
const [td, setTd] = useState<TransitionData>({ ...transitionData });
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const isTransition = context.kind === "transition";
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
if (isTransition) {
|
||||||
|
await api.put(`/api/elements/${element.id}/transition/${context.phaseId}`, td);
|
||||||
|
} else {
|
||||||
|
await api.put(`/api/elements/${element.id}/phase/${context.phaseId}`, pd);
|
||||||
|
}
|
||||||
|
onSaved();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "Speichern fehlgeschlagen.");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div>
|
||||||
|
<div className="text-xs font-semibold uppercase tracking-wide text-indigo-600 dark:text-indigo-400">
|
||||||
|
{CATEGORY_LABELS[element.category]}
|
||||||
|
{isTransition ? " · Uebergang" : ""}
|
||||||
|
</div>
|
||||||
|
<div className="text-base font-semibold text-zinc-900 dark:text-zinc-100">{element.name}</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onDeleteElement}
|
||||||
|
className="flex items-center gap-1 rounded-lg border border-zinc-300 px-2 py-1 text-xs text-zinc-500 hover:border-red-200 hover:bg-red-50 hover:text-red-600 dark:border-zinc-700 dark:hover:bg-red-950"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" /> Element loeschen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
|
{isTransition ? renderTransitionFields() : renderPhaseFields()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="text-sm text-red-600 dark:text-red-400">{error}</p>}
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={saving}
|
||||||
|
onClick={save}
|
||||||
|
className="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-500 disabled:opacity-50 dark:bg-indigo-500 dark:hover:bg-indigo-400"
|
||||||
|
>
|
||||||
|
{saving ? "Speichern..." : "Speichern"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
function setP(patch: Partial<PhaseData>) {
|
||||||
|
setPd((prev) => ({ ...prev, ...patch }));
|
||||||
|
}
|
||||||
|
function setT(patch: Partial<TransitionData>) {
|
||||||
|
setTd((prev) => ({ ...prev, ...patch }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPhaseFields() {
|
||||||
|
switch (element.category) {
|
||||||
|
case "INCOME":
|
||||||
|
return (
|
||||||
|
<MoneyField label="Jahreseinkommen (CHF)" value={num(pd.amount)} onChange={(v) => setP({ amount: v })} />
|
||||||
|
);
|
||||||
|
case "EXPENSE":
|
||||||
|
return (
|
||||||
|
<MoneyField label="Jahresausgaben (CHF)" value={num(pd.amount)} onChange={(v) => setP({ amount: v })} />
|
||||||
|
);
|
||||||
|
case "AHV":
|
||||||
|
if (!context.ownerWorking) {
|
||||||
|
return (
|
||||||
|
<p className="col-span-2 text-sm text-zinc-500 dark:text-zinc-400">
|
||||||
|
Die AHV-Rente wird automatisch aus den bisherigen Ausfalljahren berechnet (siehe Kennzahl in der
|
||||||
|
Matrix). Bei Ehepaaren greift die Plafonierung auf 150% der Maximalrente.
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<NumberField
|
||||||
|
label="Geplante Ausfalljahre"
|
||||||
|
help="Jahre ohne AHV-Beitraege in dieser Phase. Jedes Ausfalljahr kuerzt die spaetere Rente um 1/44."
|
||||||
|
value={num(pd.gapYears)}
|
||||||
|
min={0}
|
||||||
|
max={context.durationYears}
|
||||||
|
onChange={(v) => setP({ gapYears: Math.max(0, Math.min(context.durationYears, Math.round(v))) })}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case "PENSION_FUND":
|
||||||
|
if (!context.ownerWorking) {
|
||||||
|
return (
|
||||||
|
<p className="col-span-2 text-sm text-zinc-500 dark:text-zinc-400">
|
||||||
|
Die PK-Rente wird aus dem beim Pensions-Uebergang gewaehlten Umwandlungssatz berechnet (siehe
|
||||||
|
Kennzahl). Bei reinem Kapitalbezug erscheint hier "Vollstaendig bezogen".
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<MoneyField label="Aktueller PK-Wert (CHF)" value={num(pd.currentValue)} onChange={(v) => setP({ currentValue: v })} />
|
||||||
|
<MoneyField
|
||||||
|
label="Jaehrliche Einzahlung (CHF)"
|
||||||
|
help="Arbeitnehmer- und Arbeitgeberbeitraege. Fliesst NICHT in die Sparquote ein (bereits in den Ausgaben beruecksichtigt)."
|
||||||
|
value={num(pd.annualContribution)}
|
||||||
|
onChange={(v) => setP({ annualContribution: v })}
|
||||||
|
/>
|
||||||
|
<NumberField label="Erwartete Rendite (%/Jahr)" step={0.1} value={num(pd.expectedReturn)} onChange={(v) => setP({ expectedReturn: v })} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
case "PILLAR_3A":
|
||||||
|
if (!context.ownerWorking) {
|
||||||
|
return (
|
||||||
|
<p className="col-span-2 text-sm text-zinc-500 dark:text-zinc-400">
|
||||||
|
Die Saeule 3a wird beim Pensions-Uebergang vollstaendig bezogen.
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<MoneyField label="Aktueller 3a-Wert (CHF)" value={num(pd.currentValue)} onChange={(v) => setP({ currentValue: v })} />
|
||||||
|
<NumberField
|
||||||
|
label="Jaehrliche Einzahlung (CHF)"
|
||||||
|
help={`Maximal CHF ${PILLAR_3A_MAX_ANNUAL.toLocaleString("de-CH")} (2026, mit PK). Wird von der Sparquote abgezogen. Schritte von 100.`}
|
||||||
|
step={100}
|
||||||
|
min={0}
|
||||||
|
max={PILLAR_3A_MAX_ANNUAL}
|
||||||
|
value={num(pd.annualContribution)}
|
||||||
|
onChange={(v) => setP({ annualContribution: Math.max(0, Math.min(PILLAR_3A_MAX_ANNUAL, Math.round(v / 100) * 100)) })}
|
||||||
|
/>
|
||||||
|
<NumberField label="Erwartete Rendite (%/Jahr)" step={0.1} value={num(pd.expectedReturn)} onChange={(v) => setP({ expectedReturn: v })} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
case "REAL_ESTATE":
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<MoneyField label="Kaufpreis (CHF)" value={num(pd.purchasePrice)} onChange={(v) => setP({ purchasePrice: v })} />
|
||||||
|
<MoneyField label="Hypothek (CHF)" value={num(pd.mortgage)} onChange={(v) => setP({ mortgage: v })} />
|
||||||
|
<MoneyField
|
||||||
|
label="Amortisation (CHF/Jahr)"
|
||||||
|
help="Jaehrliche Reduktion der Hypothek."
|
||||||
|
value={num(pd.amortization)}
|
||||||
|
onChange={(v) => setP({ amortization: v })}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
case "OTHER_ASSET":
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<MoneyField label="Startwert (CHF)" value={num(pd.startValue)} onChange={(v) => setP({ startValue: v })} />
|
||||||
|
<NumberField label="Erwartete Rendite (%/Jahr)" step={0.1} value={num(pd.expectedReturn)} onChange={(v) => setP({ expectedReturn: v })} />
|
||||||
|
<MoneyField
|
||||||
|
label={context.isConsumption ? "Jaehrliche Bezugsrate (CHF)" : "Jaehrlicher Sparbeitrag (CHF)"}
|
||||||
|
help={
|
||||||
|
context.isConsumption
|
||||||
|
? "In dieser Verzehrphase wird dieser Betrag jaehrlich entnommen und deckt die Verzehrquote."
|
||||||
|
: "Wird von der Sparquote abgezogen."
|
||||||
|
}
|
||||||
|
value={num(pd.annualContribution)}
|
||||||
|
onChange={(v) => setP({ annualContribution: v })}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
case "OTHER_DEBT":
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<MoneyField label="Restschuld (CHF)" value={num(pd.startValue)} onChange={(v) => setP({ startValue: v })} />
|
||||||
|
<MoneyField label="Jaehrliche Tilgung (CHF)" value={num(pd.annualRepayment)} onChange={(v) => setP({ annualRepayment: v })} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTransitionFields() {
|
||||||
|
switch (element.category) {
|
||||||
|
case "INCOME":
|
||||||
|
case "EXPENSE":
|
||||||
|
case "AHV":
|
||||||
|
return (
|
||||||
|
<p className="col-span-2 text-sm text-zinc-500 dark:text-zinc-400">
|
||||||
|
Fuer diese Kategorie gibt es im Uebergang keine Eingaben. Die Werte werden 1:1 in die naechste
|
||||||
|
Lebensphase uebernommen und koennen dort angepasst werden.
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
case "PENSION_FUND":
|
||||||
|
if (context.isRetirementTransition) {
|
||||||
|
const mode = td.payoutMode ?? "PENSION";
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SelectField
|
||||||
|
label="Bezugsart bei Pensionierung"
|
||||||
|
value={mode}
|
||||||
|
onChange={(v: "CAPITAL" | "PENSION" | "COMBI") => setT({ payoutMode: v })}
|
||||||
|
options={[
|
||||||
|
{ value: "PENSION", label: "Rente" },
|
||||||
|
{ value: "CAPITAL", label: "Kapitalbezug" },
|
||||||
|
{ value: "COMBI", label: "Kombination" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
{(mode === "PENSION" || mode === "COMBI") && (
|
||||||
|
<NumberField
|
||||||
|
label="Umwandlungssatz (%)"
|
||||||
|
help="Jaehrliche Rente = verrentetes Kapital x Umwandlungssatz."
|
||||||
|
step={0.1}
|
||||||
|
value={num(td.conversionRate, 6)}
|
||||||
|
onChange={(v) => setT({ conversionRate: v })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{(mode === "CAPITAL" || mode === "COMBI") && (
|
||||||
|
<NumberField
|
||||||
|
label="Kapitalbezugssteuer (%)"
|
||||||
|
step={0.5}
|
||||||
|
value={num(td.capitalTaxRate, 8)}
|
||||||
|
onChange={(v) => setT({ capitalTaxRate: v })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{mode === "COMBI" && (
|
||||||
|
<MoneyField
|
||||||
|
label="Davon Kapitalbezug (CHF)"
|
||||||
|
help={`Der Rest wird verrentet. Maximal ${context.carriedEndValue.toLocaleString("de-CH")}.`}
|
||||||
|
value={num(td.capitalAmount)}
|
||||||
|
max={context.carriedEndValue}
|
||||||
|
onChange={(v) => setT({ capitalAmount: v })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<MoneyField
|
||||||
|
label="PK-Bezug (CHF)"
|
||||||
|
help={`Optionaler Bezug. Maximal ${context.carriedEndValue.toLocaleString("de-CH")} (Endwert der Vorphase).`}
|
||||||
|
value={num(td.withdrawal)}
|
||||||
|
max={context.carriedEndValue}
|
||||||
|
onChange={(v) => setT({ withdrawal: v })}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case "PILLAR_3A":
|
||||||
|
if (context.isRetirementTransition) {
|
||||||
|
return (
|
||||||
|
<NumberField
|
||||||
|
label="Kapitalbezugssteuer (%)"
|
||||||
|
help="Die Saeule 3a wird bei Pensionierung vollstaendig bezogen."
|
||||||
|
step={0.5}
|
||||||
|
value={num(td.capitalTaxRate, 8)}
|
||||||
|
onChange={(v) => setT({ capitalTaxRate: v })}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<MoneyField
|
||||||
|
label="3a-Bezug (CHF)"
|
||||||
|
help={`Maximal ${context.carriedEndValue.toLocaleString("de-CH")} (Endwert der Vorphase).`}
|
||||||
|
value={num(td.withdrawal)}
|
||||||
|
max={context.carriedEndValue}
|
||||||
|
onChange={(v) => setT({ withdrawal: v })}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case "REAL_ESTATE": {
|
||||||
|
const decision = td.decision ?? "HOLD";
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SelectField
|
||||||
|
label="Entscheidung"
|
||||||
|
value={decision}
|
||||||
|
onChange={(v: "HOLD" | "SELL") => setT({ decision: v })}
|
||||||
|
options={[
|
||||||
|
{ value: "HOLD", label: "Halten" },
|
||||||
|
{ value: "SELL", label: "Verkaufen" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
{decision === "SELL" && (
|
||||||
|
<>
|
||||||
|
<MoneyField label="Verkaufspreis (CHF)" value={num(td.salePrice)} onChange={(v) => setT({ salePrice: v })} />
|
||||||
|
<NumberField
|
||||||
|
label="Grundstueckgewinnsteuer (%)"
|
||||||
|
step={1}
|
||||||
|
value={num(td.saleTaxRate, 20)}
|
||||||
|
onChange={(v) => setT({ saleTaxRate: v })}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
case "OTHER_ASSET": {
|
||||||
|
const decision = td.decision ?? "HOLD";
|
||||||
|
return (
|
||||||
|
<SelectField
|
||||||
|
label="Entscheidung"
|
||||||
|
value={decision}
|
||||||
|
onChange={(v: "HOLD" | "SELL") => setT({ decision: v })}
|
||||||
|
options={[
|
||||||
|
{ value: "HOLD", label: "Halten" },
|
||||||
|
{ value: "SELL", label: "Verkaufen" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
case "OTHER_DEBT":
|
||||||
|
return (
|
||||||
|
<MoneyField
|
||||||
|
label="Sofortige Tilgung (CHF)"
|
||||||
|
help="Wird sofort getilgt und vom verfuegbaren Kapital der naechsten Phase abgezogen."
|
||||||
|
value={num(td.immediateRepayment)}
|
||||||
|
onChange={(v) => setT({ immediateRepayment: v })}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -44,6 +44,7 @@ export function NumberField({
|
|||||||
step={step ?? "any"}
|
step={step ?? "any"}
|
||||||
min={min}
|
min={min}
|
||||||
max={max}
|
max={max}
|
||||||
|
onFocus={(e) => e.target.select()}
|
||||||
onChange={(e) => onChange(e.target.valueAsNumber || 0)}
|
onChange={(e) => onChange(e.target.valueAsNumber || 0)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,125 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import { LineChart, Line, ResponsiveContainer } from "recharts";
|
|
||||||
import { AlertTriangle, ChevronDown, ChevronRight, Trash2, Users } from "lucide-react";
|
|
||||||
import { PhaseForm } from "@/components/PhaseForm";
|
|
||||||
import { api } from "@/lib/api-client";
|
|
||||||
import { formatChf } from "@/lib/format";
|
|
||||||
import type { HouseholdInput, PhaseInput } from "@/lib/types";
|
|
||||||
import type { PhaseComputed } from "@/lib/calculations";
|
|
||||||
|
|
||||||
// Formatiert die Altersspannen der Personen einer Phase, z. B. "35–45" (Single)
|
|
||||||
// oder "A 35–45 · B 33–43" (Paar).
|
|
||||||
export function formatAges(computed: PhaseComputed): string {
|
|
||||||
if (computed.ages.length === 0) return "";
|
|
||||||
if (computed.ages.length === 1) {
|
|
||||||
const a = computed.ages[0];
|
|
||||||
return `${a.startAge}–${a.endAge}`;
|
|
||||||
}
|
|
||||||
return computed.ages
|
|
||||||
.map((a) => `${a.role === "PERSON_A" ? "A" : "B"} ${a.startAge}–${a.endAge}`)
|
|
||||||
.join(" · ");
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PhaseCard({
|
|
||||||
household,
|
|
||||||
phase,
|
|
||||||
computed,
|
|
||||||
isFirst,
|
|
||||||
isLast,
|
|
||||||
onChanged,
|
|
||||||
}: {
|
|
||||||
household: HouseholdInput;
|
|
||||||
phase: PhaseInput;
|
|
||||||
computed: PhaseComputed;
|
|
||||||
isFirst: boolean;
|
|
||||||
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-xl border border-zinc-200/70 bg-white shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setExpanded((v) => !v)}
|
|
||||||
className="flex w-full items-center gap-3 px-4 py-3 text-left hover:bg-zinc-50 sm:gap-4 dark:hover:bg-zinc-800/50"
|
|
||||||
>
|
|
||||||
<span className="text-indigo-500 dark:text-indigo-400">
|
|
||||||
{expanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
|
||||||
</span>
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
|
|
||||||
<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>
|
|
||||||
<span className="flex items-center gap-1 text-xs text-indigo-600 dark:text-indigo-400">
|
|
||||||
<Users className="h-3 w-3" />
|
|
||||||
Alter {formatAges(computed)}
|
|
||||||
</span>
|
|
||||||
{computed.savingsWarning && (
|
|
||||||
<span className="flex items-center gap-1 text-xs text-amber-600 dark:text-amber-400">
|
|
||||||
<AlertTriangle className="h-3 w-3" /> 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="hidden h-8 w-24 sm:block">
|
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
|
||||||
<LineChart data={sparklineData}>
|
|
||||||
<Line type="monotone" dataKey="value" stroke="#4f46e5" strokeWidth={1.5} dot={false} />
|
|
||||||
</LineChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
|
||||||
{isLast && (
|
|
||||||
<span
|
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
handleDelete();
|
|
||||||
}}
|
|
||||||
className="flex items-center gap-1 rounded-lg border border-zinc-300 px-2 py-1 text-xs text-zinc-500 hover:border-red-200 hover:bg-red-50 hover:text-red-600 dark:border-zinc-700 dark:hover:border-red-500/30 dark:hover:bg-red-950"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
|
||||||
{deleting ? "…" : "Loeschen"}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
{expanded && (
|
|
||||||
<PhaseForm
|
|
||||||
key={`${phase.id}:${phase.securities.length}:${phase.realEstates.length}:${phase.incomingCapital}`}
|
|
||||||
household={household}
|
|
||||||
phase={phase}
|
|
||||||
isFirstPhase={isFirst}
|
|
||||||
onSaved={() => {
|
|
||||||
onChanged();
|
|
||||||
}}
|
|
||||||
onCancel={() => setExpanded(false)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Trash2 } from "lucide-react";
|
||||||
|
import { NumberField, TextField } from "@/components/FormField";
|
||||||
|
import { api } from "@/lib/api-client";
|
||||||
|
import type { HouseholdInput, PhaseInput } from "@/lib/types";
|
||||||
|
|
||||||
|
export function PhaseDetail({
|
||||||
|
phase,
|
||||||
|
maxDurationYears,
|
||||||
|
isLast,
|
||||||
|
household,
|
||||||
|
onSaved,
|
||||||
|
onDeleted,
|
||||||
|
}: {
|
||||||
|
phase: PhaseInput;
|
||||||
|
maxDurationYears: number | null;
|
||||||
|
isLast: boolean;
|
||||||
|
household: HouseholdInput;
|
||||||
|
onSaved: () => void;
|
||||||
|
onDeleted: () => void;
|
||||||
|
}) {
|
||||||
|
const [name, setName] = useState(phase.name);
|
||||||
|
const [durationYears, setDurationYears] = useState(phase.durationYears);
|
||||||
|
const [inflationRate, setInflationRate] = useState<number | null>(phase.inflationRate);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const cap = maxDurationYears;
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await api.put(`/api/phases/${phase.id}`, { name, durationYears, inflationRate });
|
||||||
|
onSaved();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "Speichern fehlgeschlagen.");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove() {
|
||||||
|
if (!confirm(`Phase "${phase.name}" wirklich loeschen?`)) return;
|
||||||
|
try {
|
||||||
|
await api.delete(`/api/phases/${phase.id}`);
|
||||||
|
onDeleted();
|
||||||
|
} catch (e) {
|
||||||
|
alert(e instanceof Error ? e.message : "Loeschen fehlgeschlagen.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="text-xs font-semibold uppercase tracking-wide text-indigo-600 dark:text-indigo-400">
|
||||||
|
Lebensphase
|
||||||
|
</div>
|
||||||
|
{isLast && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={remove}
|
||||||
|
className="flex items-center gap-1 rounded-lg border border-zinc-300 px-2 py-1 text-xs text-zinc-500 hover:border-red-200 hover:bg-red-50 hover:text-red-600 dark:border-zinc-700 dark:hover:bg-red-950"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" /> Phase loeschen
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||||
|
<TextField label="Bezeichnung" value={name} onChange={setName} />
|
||||||
|
<NumberField
|
||||||
|
label={`Dauer (Jahre)${cap != null ? ` · max. ${cap}` : ""}`}
|
||||||
|
help={cap != null ? "Die Dauer ist ans naechste Pensionsereignis gekappt." : undefined}
|
||||||
|
value={durationYears}
|
||||||
|
min={1}
|
||||||
|
max={cap ?? undefined}
|
||||||
|
onChange={(v) => setDurationYears(cap != null ? Math.min(v, cap) : v)}
|
||||||
|
/>
|
||||||
|
<NumberField
|
||||||
|
label="Inflationsrate (%)"
|
||||||
|
help="Ueberschreibt die Standardannahme aus dem Grundprofil."
|
||||||
|
value={inflationRate ?? household.inflationRateDefault}
|
||||||
|
step={0.1}
|
||||||
|
onChange={setInflationRate}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="text-sm text-red-600 dark:text-red-400">{error}</p>}
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={saving}
|
||||||
|
onClick={save}
|
||||||
|
className="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-500 disabled:opacity-50 dark:bg-indigo-500 dark:hover:bg-indigo-400"
|
||||||
|
>
|
||||||
|
{saving ? "Speichern..." : "Speichern"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,594 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import {
|
|
||||||
AlertTriangle,
|
|
||||||
CheckCircle2,
|
|
||||||
ChevronDown,
|
|
||||||
ChevronRight,
|
|
||||||
Gift,
|
|
||||||
Home,
|
|
||||||
Plus,
|
|
||||||
PiggyBank,
|
|
||||||
TrendingUp,
|
|
||||||
Wallet,
|
|
||||||
X,
|
|
||||||
XCircle,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { MoneyField, NumberField, SelectField, TextField } from "@/components/FormField";
|
|
||||||
import { api } from "@/lib/api-client";
|
|
||||||
import { formatChf } from "@/lib/format";
|
|
||||||
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;
|
|
||||||
isFirstPhase: boolean;
|
|
||||||
onSaved: () => void;
|
|
||||||
onCancel: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PhaseForm({ household, phase, isFirstPhase, 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;
|
|
||||||
// Amortisationsraten aller Immobilien zaehlen gleichwertig mit den Sparbeitraegen der
|
|
||||||
// Wertschriften gegen dieselbe verfuegbare Sparquote (ein gemeinsamer Topf).
|
|
||||||
const allocated =
|
|
||||||
securities.reduce((s, sec) => s + sec.annualContribution, 0) +
|
|
||||||
realEstates.reduce((s, re) => s + re.amortization, 0);
|
|
||||||
const savingsRemaining = savingsQuota - allocated > 0.5;
|
|
||||||
|
|
||||||
const allocatedStartCapital = securities.reduce(
|
|
||||||
(s, sec) => s + Math.max(0, sec.startValue - sec.carriedBaseValue),
|
|
||||||
0
|
|
||||||
);
|
|
||||||
const startCapitalRemaining = phase.incomingCapital - allocatedStartCapital > 0.5;
|
|
||||||
|
|
||||||
// Live-Kappung: pro Feld das noch verfuegbare Budget (eigener Anteil zaehlt nicht
|
|
||||||
// gegen sich selbst, damit man einen bestehenden Wert wieder erhoehen/senken kann).
|
|
||||||
function maxContributionFor(current: number): number {
|
|
||||||
return Math.max(0, savingsQuota - (allocated - current));
|
|
||||||
}
|
|
||||||
function maxStartValueFor(sec: SecurityInput): number | undefined {
|
|
||||||
// In der ersten Phase wird der Ist-Bestand frei erfasst -- kein Limit.
|
|
||||||
if (isFirstPhase) return undefined;
|
|
||||||
const ownExtra = Math.max(0, sec.startValue - sec.carriedBaseValue);
|
|
||||||
const remaining = Math.max(0, phase.incomingCapital - (allocatedStartCapital - ownExtra));
|
|
||||||
return sec.carriedBaseValue + remaining;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSave() {
|
|
||||||
const missingPurchasePrice = realEstates.find((re) => !re.purchasePrice || re.purchasePrice <= 0);
|
|
||||||
if (missingPurchasePrice) {
|
|
||||||
setError(
|
|
||||||
`Bitte fuer "${missingPurchasePrice.name || "Immobilie"}" einen Kaufpreis groesser als 0 eintragen.`
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
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-5 border-t border-zinc-200 p-4 dark:border-zinc-800">
|
|
||||||
{/* Basis-Kopfzeile */}
|
|
||||||
<section className="grid grid-cols-2 gap-3 lg:grid-cols-4">
|
|
||||||
<div className="col-span-2 lg:col-span-1">
|
|
||||||
<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 (%)"
|
|
||||||
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>
|
|
||||||
|
|
||||||
{/* Matrix: Kategorien als Spalten */}
|
|
||||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-5">
|
|
||||||
{/* Einkommen & Ausgaben */}
|
|
||||||
<CollapsibleColumn
|
|
||||||
title="Einkommen & Ausgaben"
|
|
||||||
icon={<Wallet className="h-4 w-4" />}
|
|
||||||
summary={`${formatChf(totalIncome)} / ${formatChf(totalExpense)}`}
|
|
||||||
>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<div className="text-[11px] font-semibold uppercase tracking-wide text-zinc-400">Einkommen</div>
|
|
||||||
{incomeEntries.map((entry, i) => (
|
|
||||||
<EntryCard key={entry.id} onRemove={() => setIncomeEntries((prev) => prev.filter((_, idx) => idx !== i))}>
|
|
||||||
{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)))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<MoneyField
|
|
||||||
label="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)))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</EntryCard>
|
|
||||||
))}
|
|
||||||
<AddButton
|
|
||||||
label="Einkommensposten"
|
|
||||||
onClick={() =>
|
|
||||||
setIncomeEntries((prev) => [
|
|
||||||
...prev,
|
|
||||||
{ id: tempId(), personId: incomeMode === "PER_PERSON" ? household.persons[0]?.id ?? null : null, label: null, amount: 0 },
|
|
||||||
])
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="mt-2 text-[11px] font-semibold uppercase tracking-wide text-zinc-400">Ausgaben</div>
|
|
||||||
{expenseEntries.map((entry, i) => (
|
|
||||||
<EntryCard key={entry.id} onRemove={() => setExpenseEntries((prev) => prev.filter((_, idx) => idx !== i))}>
|
|
||||||
<MoneyField
|
|
||||||
label="Gesamtausgaben (CHF/Jahr)"
|
|
||||||
help="Saemtliche laufenden Kosten des Haushalts pro Jahr - inkl. Lebenshaltung, Hypothekarzinsen, laufende Einkommens- und Vermoegenssteuern."
|
|
||||||
value={entry.amount}
|
|
||||||
onChange={(v) =>
|
|
||||||
setExpenseEntries((prev) => prev.map((e, idx) => (idx === i ? { ...e, amount: v } : e)))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</EntryCard>
|
|
||||||
))}
|
|
||||||
<AddButton
|
|
||||||
label="Ausgabenposten"
|
|
||||||
onClick={() => setExpenseEntries((prev) => [...prev, { id: tempId(), label: null, amount: 0 }])}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</CollapsibleColumn>
|
|
||||||
|
|
||||||
{/* Wertschriften */}
|
|
||||||
<CollapsibleColumn
|
|
||||||
title="Wertschriften"
|
|
||||||
icon={<TrendingUp className="h-4 w-4" />}
|
|
||||||
summary={`${securities.length}`}
|
|
||||||
>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
{securities.map((s, i) => (
|
|
||||||
<EntryCard key={s.id} onRemove={() => setSecurities((prev) => prev.filter((_, idx) => idx !== i))}>
|
|
||||||
<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)))}
|
|
||||||
/>
|
|
||||||
<MoneyField
|
|
||||||
label="Startwert (CHF)"
|
|
||||||
help={
|
|
||||||
isFirstPhase
|
|
||||||
? "Wert dieser Position zu Beginn der Phase."
|
|
||||||
: "Wert zu Beginn der Phase. Erhoehungen gegenueber dem uebernommenen Wert werden vom verfuegbaren Startkapital abgezogen."
|
|
||||||
}
|
|
||||||
value={s.startValue}
|
|
||||||
max={maxStartValueFor(s)}
|
|
||||||
onChange={(v) => setSecurities((prev) => prev.map((x, idx) => (idx === i ? { ...x, startValue: v } : x)))}
|
|
||||||
/>
|
|
||||||
<NumberField
|
|
||||||
label="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)))}
|
|
||||||
/>
|
|
||||||
<MoneyField
|
|
||||||
label="Sparbeitrag (CHF/Jahr)"
|
|
||||||
help="Der Betrag aus Ihrer verfuegbaren Sparquote, den Sie jaehrlich in diese Position investieren. Wird automatisch auf die verbleibende Sparquote begrenzt."
|
|
||||||
value={s.annualContribution}
|
|
||||||
max={maxContributionFor(s.annualContribution)}
|
|
||||||
onChange={(v) => setSecurities((prev) => prev.map((x, idx) => (idx === i ? { ...x, annualContribution: v } : x)))}
|
|
||||||
/>
|
|
||||||
<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" },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</EntryCard>
|
|
||||||
))}
|
|
||||||
<AddButton
|
|
||||||
label="Wertschrift"
|
|
||||||
onClick={() =>
|
|
||||||
setSecurities((prev) => [
|
|
||||||
...prev,
|
|
||||||
{
|
|
||||||
id: tempId(),
|
|
||||||
name: "",
|
|
||||||
startValue: 0,
|
|
||||||
expectedReturn: 0,
|
|
||||||
annualContribution: 0,
|
|
||||||
ownerTag: "HOUSEHOLD",
|
|
||||||
saleTaxRate: 0,
|
|
||||||
carriedBaseValue: 0,
|
|
||||||
},
|
|
||||||
])
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</CollapsibleColumn>
|
|
||||||
|
|
||||||
{/* Immobilien */}
|
|
||||||
<CollapsibleColumn title="Immobilien" icon={<Home className="h-4 w-4" />} summary={`${realEstates.length}`}>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
{realEstates.map((re, i) => (
|
|
||||||
<EntryCard key={re.id} onRemove={() => setRealEstates((prev) => prev.filter((_, idx) => idx !== i))}>
|
|
||||||
<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)))}
|
|
||||||
/>
|
|
||||||
<MoneyField
|
|
||||||
label="Kaufpreis (CHF)"
|
|
||||||
help="Pflichtfeld. Der Kaufpreis bleibt ueber die ganze Haltedauer fix -- nur die Hypothek sinkt durch Amortisation."
|
|
||||||
value={re.purchasePrice}
|
|
||||||
onChange={(v) => setRealEstates((prev) => prev.map((x, idx) => (idx === i ? { ...x, purchasePrice: v } : x)))}
|
|
||||||
/>
|
|
||||||
<MoneyField
|
|
||||||
label="Hypothek (CHF)"
|
|
||||||
help="Ausstehender Hypothekarbetrag zu Beginn der Phase."
|
|
||||||
value={re.mortgage}
|
|
||||||
onChange={(v) => setRealEstates((prev) => prev.map((x, idx) => (idx === i ? { ...x, mortgage: v } : x)))}
|
|
||||||
/>
|
|
||||||
<MoneyField
|
|
||||||
label="Amortisation (CHF/Jahr)"
|
|
||||||
help="Betrag, um den die Hypothek pro Jahr reduziert wird. Zaehlt zusammen mit den Sparbeitraegen gegen die verfuegbare Sparquote."
|
|
||||||
value={re.amortization}
|
|
||||||
max={maxContributionFor(re.amortization)}
|
|
||||||
onChange={(v) => setRealEstates((prev) => prev.map((x, idx) => (idx === i ? { ...x, amortization: v } : x)))}
|
|
||||||
/>
|
|
||||||
</EntryCard>
|
|
||||||
))}
|
|
||||||
<AddButton
|
|
||||||
label="Immobilie"
|
|
||||||
onClick={() =>
|
|
||||||
setRealEstates((prev) => [
|
|
||||||
...prev,
|
|
||||||
{ id: tempId(), name: "", purchasePrice: 0, mortgage: 0, amortization: 0 },
|
|
||||||
])
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</CollapsibleColumn>
|
|
||||||
|
|
||||||
{/* Sondereinnahmen / -ausgaben */}
|
|
||||||
<CollapsibleColumn
|
|
||||||
title="Sondereinnahmen / -ausgaben"
|
|
||||||
icon={<Gift className="h-4 w-4" />}
|
|
||||||
summary={`${oneTimeEvents.length}`}
|
|
||||||
>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
{oneTimeEvents.map((ev, i) => (
|
|
||||||
<EntryCard key={ev.id} onRemove={() => setOneTimeEvents((prev) => prev.filter((_, idx) => idx !== i))}>
|
|
||||||
<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" },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<MoneyField
|
|
||||||
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)))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</EntryCard>
|
|
||||||
))}
|
|
||||||
<AddButton
|
|
||||||
label="Sondereintrag"
|
|
||||||
onClick={() =>
|
|
||||||
setOneTimeEvents((prev) => [...prev, { id: tempId(), type: "EXPENSE", amount: 0, description: null }])
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</CollapsibleColumn>
|
|
||||||
|
|
||||||
{/* Pensionierung */}
|
|
||||||
<CollapsibleColumn
|
|
||||||
title="Pensionierung"
|
|
||||||
icon={<PiggyBank className="h-4 w-4" />}
|
|
||||||
summary={`${retirementInfos.length}`}
|
|
||||||
>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
{retirementInfos.map((r, i) => (
|
|
||||||
<EntryCard key={r.id} onRemove={() => setRetirementInfos((prev) => prev.filter((_, idx) => idx !== i))}>
|
|
||||||
<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) }))}
|
|
||||||
/>
|
|
||||||
<MoneyField
|
|
||||||
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)))}
|
|
||||||
/>
|
|
||||||
<MoneyField
|
|
||||||
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)))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<MoneyField
|
|
||||||
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="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)))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</EntryCard>
|
|
||||||
))}
|
|
||||||
<AddButton
|
|
||||||
label="Pensionierungsangaben"
|
|
||||||
onClick={() =>
|
|
||||||
setRetirementInfos((prev) => [
|
|
||||||
...prev,
|
|
||||||
{
|
|
||||||
id: tempId(),
|
|
||||||
personId: household.persons[0]?.id ?? "",
|
|
||||||
ahvAmount: 0,
|
|
||||||
pkPensionAmount: 0,
|
|
||||||
lumpSumAmount: 0,
|
|
||||||
lumpSumTaxRate: 8,
|
|
||||||
},
|
|
||||||
])
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</CollapsibleColumn>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Budget-Status */}
|
|
||||||
<div className="flex flex-col gap-1.5 rounded-xl bg-indigo-50/60 px-3 py-2 text-sm dark:bg-indigo-500/10">
|
|
||||||
{!isFirstPhase && (
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<StatusDot ok={!startCapitalRemaining} />
|
|
||||||
Verfuegbares Startkapital (aus Verkaeufen der Vorphase): <strong>{formatChf(phase.incomingCapital)}</strong> CHF
|
|
||||||
{" "}— zugewiesen: {formatChf(allocatedStartCapital)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<StatusDot ok={!savingsRemaining} />
|
|
||||||
Verfuegbare Sparquote (CHF/Jahr): <strong>{formatChf(savingsQuota)}</strong>
|
|
||||||
{" "}— zugewiesen: {formatChf(allocated)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<p className="flex items-center gap-1.5 text-sm text-red-600 dark:text-red-400">
|
|
||||||
<AlertTriangle className="h-4 w-4 shrink-0" />
|
|
||||||
{error}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={saving}
|
|
||||||
onClick={handleSave}
|
|
||||||
className="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-500 disabled:opacity-50 dark:bg-indigo-500 dark:hover:bg-indigo-400"
|
|
||||||
>
|
|
||||||
{saving ? "Speichern..." : "Speichern"}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onCancel}
|
|
||||||
className="rounded-lg border border-zinc-300 px-4 py-2 text-sm font-medium text-zinc-700 hover:bg-zinc-50 dark:border-zinc-700 dark:text-zinc-200 dark:hover:bg-zinc-800"
|
|
||||||
>
|
|
||||||
Abbrechen
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Eine einklappbare Kategorien-Spalte der Matrix (Phase x Kategorie).
|
|
||||||
function CollapsibleColumn({
|
|
||||||
title,
|
|
||||||
icon,
|
|
||||||
summary,
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
title: string;
|
|
||||||
icon: React.ReactNode;
|
|
||||||
summary?: string;
|
|
||||||
children: React.ReactNode;
|
|
||||||
}) {
|
|
||||||
const [open, setOpen] = useState(true);
|
|
||||||
return (
|
|
||||||
<section className="flex flex-col self-start rounded-xl border border-zinc-100 bg-zinc-50/60 dark:border-zinc-800 dark:bg-zinc-800/30">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setOpen((v) => !v)}
|
|
||||||
className="flex w-full items-center gap-1.5 px-3 py-2.5 text-left"
|
|
||||||
>
|
|
||||||
<span className="text-indigo-500 dark:text-indigo-400">{icon}</span>
|
|
||||||
<span className="flex-1 text-sm font-semibold text-zinc-800 dark:text-zinc-100">{title}</span>
|
|
||||||
{summary != null && (
|
|
||||||
<span className="rounded-full bg-indigo-100 px-2 py-0.5 text-[11px] font-medium text-indigo-700 dark:bg-indigo-500/20 dark:text-indigo-300">
|
|
||||||
{summary}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<span className="text-zinc-400">
|
|
||||||
{open ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
{open && <div className="px-3 pb-3">{children}</div>}
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Kompakte Karte fuer einen einzelnen Eintrag (Felder vertikal gestapelt).
|
|
||||||
function EntryCard({ children, onRemove }: { children: React.ReactNode; onRemove: () => void }) {
|
|
||||||
return (
|
|
||||||
<div className="relative flex flex-col gap-2 rounded-lg border border-zinc-200/70 bg-white p-2.5 pr-8 shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onRemove}
|
|
||||||
aria-label="Entfernen"
|
|
||||||
className="absolute right-1.5 top-1.5 rounded-md p-1 text-zinc-400 hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-950"
|
|
||||||
>
|
|
||||||
<X className="h-3.5 w-3.5" />
|
|
||||||
</button>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function AddButton({ label, onClick }: { label: string; onClick: () => void }) {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onClick}
|
|
||||||
className="flex items-center gap-1 self-start text-xs font-medium text-indigo-600 hover:text-indigo-700 dark:text-indigo-400 dark:hover:text-indigo-300"
|
|
||||||
>
|
|
||||||
<Plus className="h-3.5 w-3.5" />
|
|
||||||
{label}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function StatusDot({ ok }: { ok: boolean }) {
|
|
||||||
return ok ? (
|
|
||||||
<CheckCircle2 className="h-4 w-4 shrink-0 text-emerald-500" aria-label="Vollstaendig verteilt" />
|
|
||||||
) : (
|
|
||||||
<XCircle className="h-4 w-4 shrink-0 text-red-500" aria-label="Noch nicht vollstaendig verteilt" />
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,581 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import {
|
||||||
|
AlertCircle,
|
||||||
|
Building2,
|
||||||
|
CheckCircle2,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronRight,
|
||||||
|
CreditCard,
|
||||||
|
Home,
|
||||||
|
Landmark,
|
||||||
|
PiggyBank,
|
||||||
|
Plus,
|
||||||
|
ShoppingCart,
|
||||||
|
TrendingUp,
|
||||||
|
Wallet,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Timeline } from "@/components/Timeline";
|
||||||
|
import { ElementDetail, type CellContext } from "@/components/ElementDetail";
|
||||||
|
import { PhaseDetail } from "@/components/PhaseDetail";
|
||||||
|
import { api } from "@/lib/api-client";
|
||||||
|
import { formatChf } from "@/lib/format";
|
||||||
|
import {
|
||||||
|
CATEGORY_LABELS,
|
||||||
|
CATEGORY_ORDER,
|
||||||
|
PERSON_ONLY_CATEGORIES,
|
||||||
|
num,
|
||||||
|
type ElementCategory,
|
||||||
|
} from "@/lib/elements";
|
||||||
|
import { resolveRetirementAge, type PhaseComputed, type PlanComputed } from "@/lib/calculations";
|
||||||
|
import type { ElementInput, HouseholdInput, PlanInput } from "@/lib/types";
|
||||||
|
|
||||||
|
const PERSON_A_COLOR = "#4f46e5";
|
||||||
|
const PERSON_B_COLOR = "#0ea5e9";
|
||||||
|
|
||||||
|
const CATEGORY_ICON: Record<ElementCategory, React.ReactNode> = {
|
||||||
|
INCOME: <Wallet className="h-4 w-4" />,
|
||||||
|
EXPENSE: <ShoppingCart className="h-4 w-4" />,
|
||||||
|
AHV: <Landmark className="h-4 w-4" />,
|
||||||
|
PENSION_FUND: <Building2 className="h-4 w-4" />,
|
||||||
|
PILLAR_3A: <PiggyBank className="h-4 w-4" />,
|
||||||
|
REAL_ESTATE: <Home className="h-4 w-4" />,
|
||||||
|
OTHER_ASSET: <TrendingUp className="h-4 w-4" />,
|
||||||
|
OTHER_DEBT: <CreditCard className="h-4 w-4" />,
|
||||||
|
};
|
||||||
|
|
||||||
|
const TRANSITION_CATEGORIES: ElementCategory[] = [
|
||||||
|
"PENSION_FUND",
|
||||||
|
"PILLAR_3A",
|
||||||
|
"REAL_ESTATE",
|
||||||
|
"OTHER_ASSET",
|
||||||
|
"OTHER_DEBT",
|
||||||
|
];
|
||||||
|
|
||||||
|
type Column =
|
||||||
|
| { kind: "phase"; phase: PhaseComputed }
|
||||||
|
| { kind: "transition"; fromPhase: PhaseComputed; toPhase: PhaseComputed };
|
||||||
|
|
||||||
|
type Selection =
|
||||||
|
| { type: "phaseCell"; elementId: string; phaseId: string }
|
||||||
|
| { type: "transitionCell"; elementId: string; fromPhaseId: string }
|
||||||
|
| { type: "phase"; phaseId: string };
|
||||||
|
|
||||||
|
export function PlanView({
|
||||||
|
plan,
|
||||||
|
household,
|
||||||
|
computed,
|
||||||
|
onChanged,
|
||||||
|
}: {
|
||||||
|
plan: PlanInput;
|
||||||
|
household: HouseholdInput;
|
||||||
|
computed: PlanComputed;
|
||||||
|
onChanged: () => void;
|
||||||
|
}) {
|
||||||
|
const [selected, setSelected] = useState<Selection | null>(null);
|
||||||
|
const [collapsedCats, setCollapsedCats] = useState<Set<ElementCategory>>(new Set());
|
||||||
|
const [showAdd, setShowAdd] = useState(false);
|
||||||
|
|
||||||
|
const columns = useMemo<Column[]>(() => {
|
||||||
|
const cols: Column[] = [];
|
||||||
|
computed.phases.forEach((p, i) => {
|
||||||
|
cols.push({ kind: "phase", phase: p });
|
||||||
|
if (i < computed.phases.length - 1) {
|
||||||
|
cols.push({ kind: "transition", fromPhase: p, toPhase: computed.phases[i + 1] });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return cols;
|
||||||
|
}, [computed.phases]);
|
||||||
|
|
||||||
|
const personAxes = household.persons.map((p) => ({
|
||||||
|
role: p.role,
|
||||||
|
label: p.role === "PERSON_A" ? "Person A" : "Person B",
|
||||||
|
currentAge: p.age,
|
||||||
|
retirementAge: resolveRetirementAge(p.role, plan, p.retirementAge),
|
||||||
|
color: p.role === "PERSON_A" ? PERSON_A_COLOR : PERSON_B_COLOR,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const elementsByCategory = useMemo(() => {
|
||||||
|
const map = new Map<ElementCategory, ElementInput[]>();
|
||||||
|
for (const cat of CATEGORY_ORDER) map.set(cat, []);
|
||||||
|
for (const e of [...plan.elements].sort((a, b) => a.orderIndex - b.orderIndex)) {
|
||||||
|
map.get(e.category)!.push(e);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [plan.elements]);
|
||||||
|
|
||||||
|
function computedElement(phaseId: string, elementId: string) {
|
||||||
|
return computed.phases.find((p) => p.id === phaseId)?.elements.find((e) => e.elementId === elementId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRetirementTransition(element: ElementInput, fromPhase: PhaseComputed, toPhase: PhaseComputed): boolean {
|
||||||
|
if (!element.ownerRole || element.ownerRole === "HOUSEHOLD") return false;
|
||||||
|
const before = fromPhase.persons.find((p) => p.role === element.ownerRole);
|
||||||
|
const after = toPhase.persons.find((p) => p.role === element.ownerRole);
|
||||||
|
return !!before?.working && !!after && !after.working;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAddPhase() {
|
||||||
|
await api.post(`/api/plans/${plan.id}/phases`, {});
|
||||||
|
onChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasPhases = computed.phases.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-5">
|
||||||
|
<Timeline phases={computed.phases} persons={personAxes} />
|
||||||
|
|
||||||
|
{/* Pensionsalter-Overrides */}
|
||||||
|
<div className="flex flex-wrap items-center gap-3 rounded-xl border border-zinc-200/70 bg-white px-4 py-3 text-sm shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
|
||||||
|
<span className="text-xs font-semibold uppercase tracking-wide text-zinc-400">Pensionsalter (Plan)</span>
|
||||||
|
{household.persons.map((p) => (
|
||||||
|
<label key={p.role} className="flex items-center gap-1.5 text-xs text-zinc-600 dark:text-zinc-300">
|
||||||
|
{p.role === "PERSON_A" ? "Person A" : "Person B"}:
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
defaultValue={resolveRetirementAge(p.role, plan, p.retirementAge)}
|
||||||
|
className="w-16 rounded-lg border border-zinc-300 px-2 py-1 text-sm focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/20 dark:border-zinc-700 dark:bg-zinc-800"
|
||||||
|
onBlur={async (e) => {
|
||||||
|
const val = parseInt(e.target.value, 10);
|
||||||
|
if (!Number.isFinite(val)) return;
|
||||||
|
await api.patch(`/api/plans/${plan.id}`, {
|
||||||
|
[p.role === "PERSON_A" ? "retirementAgeA" : "retirementAgeB"]: val,
|
||||||
|
});
|
||||||
|
onChanged();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
<span className="text-[11px] text-zinc-400">Standard aus Grundprofil, hier pro Plan uebersteuerbar.</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!hasPhases && (
|
||||||
|
<div className="rounded-xl border border-dashed border-zinc-300 bg-white p-8 text-center dark:border-zinc-700 dark:bg-zinc-900">
|
||||||
|
<p className="text-sm text-zinc-500">Dieser Plan hat noch keine Lebensphasen.</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleAddPhase}
|
||||||
|
className="mt-3 inline-flex items-center gap-1.5 rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-500 dark:bg-indigo-500"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" /> Erste Lebensphase
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasPhases && (
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowAdd(true)}
|
||||||
|
className="flex items-center gap-1.5 rounded-lg bg-indigo-600 px-3 py-1.5 text-sm font-medium text-white shadow-sm hover:bg-indigo-500 dark:bg-indigo-500 dark:hover:bg-indigo-400"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" /> Finanzielles Element
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleAddPhase}
|
||||||
|
className="flex items-center gap-1.5 rounded-lg border border-dashed border-indigo-300 bg-indigo-50/50 px-3 py-1.5 text-sm font-medium text-indigo-700 hover:bg-indigo-50 dark:border-indigo-500/30 dark:bg-indigo-500/10 dark:text-indigo-300"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" /> Lebensphase
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Matrix */}
|
||||||
|
{hasPhases && (
|
||||||
|
<div className="overflow-x-auto rounded-xl border border-zinc-200/70 bg-white shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
|
||||||
|
<table className="w-full border-collapse text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th className="sticky left-0 z-20 min-w-44 border-b border-r border-zinc-200 bg-zinc-50 px-3 py-2 text-left text-xs font-semibold uppercase tracking-wide text-zinc-500 dark:border-zinc-800 dark:bg-zinc-800/60">
|
||||||
|
Finanzielle Elemente
|
||||||
|
</th>
|
||||||
|
{columns.map((col) =>
|
||||||
|
col.kind === "phase" ? (
|
||||||
|
<PhaseHeader
|
||||||
|
key={col.phase.id}
|
||||||
|
phase={col.phase}
|
||||||
|
onClick={() => setSelected({ type: "phase", phaseId: col.phase.id })}
|
||||||
|
active={selected?.type === "phase" && selected.phaseId === col.phase.id}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<th
|
||||||
|
key={`t-${col.fromPhase.id}`}
|
||||||
|
className="border-b border-r border-zinc-200 bg-indigo-50/40 px-2 py-2 text-center text-[11px] font-medium text-indigo-500 dark:border-zinc-800 dark:bg-indigo-500/5"
|
||||||
|
>
|
||||||
|
Uebergang
|
||||||
|
</th>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{CATEGORY_ORDER.map((cat) => {
|
||||||
|
const els = elementsByCategory.get(cat)!;
|
||||||
|
if (els.length === 0) return null;
|
||||||
|
const collapsed = collapsedCats.has(cat);
|
||||||
|
return (
|
||||||
|
<FragmentRows key={cat}>
|
||||||
|
<tr className="bg-zinc-50/60 dark:bg-zinc-800/30">
|
||||||
|
<td
|
||||||
|
className="sticky left-0 z-10 cursor-pointer border-b border-r border-zinc-200 bg-zinc-50/90 px-3 py-1.5 dark:border-zinc-800 dark:bg-zinc-800/60"
|
||||||
|
onClick={() =>
|
||||||
|
setCollapsedCats((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(cat)) next.delete(cat);
|
||||||
|
else next.add(cat);
|
||||||
|
return next;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-1.5 text-xs font-semibold text-zinc-600 dark:text-zinc-300">
|
||||||
|
{collapsed ? <ChevronRight className="h-3.5 w-3.5" /> : <ChevronDown className="h-3.5 w-3.5" />}
|
||||||
|
<span className="text-indigo-500 dark:text-indigo-400">{CATEGORY_ICON[cat]}</span>
|
||||||
|
{CATEGORY_LABELS[cat]}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td colSpan={columns.length} className="border-b border-zinc-200 dark:border-zinc-800" />
|
||||||
|
</tr>
|
||||||
|
{!collapsed &&
|
||||||
|
els.map((el) => (
|
||||||
|
<tr key={el.id} className="hover:bg-zinc-50/50 dark:hover:bg-zinc-800/20">
|
||||||
|
<td className="sticky left-0 z-10 border-b border-r border-zinc-200 bg-white px-3 py-1.5 dark:border-zinc-800 dark:bg-zinc-900">
|
||||||
|
<div className="truncate text-xs font-medium text-zinc-800 dark:text-zinc-200">{el.name}</div>
|
||||||
|
{el.ownerRole && el.ownerRole !== "HOUSEHOLD" && (
|
||||||
|
<div className="text-[10px] text-zinc-400">
|
||||||
|
{el.ownerRole === "PERSON_A" ? "Person A" : "Person B"}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
{columns.map((col) => {
|
||||||
|
if (col.kind === "phase") {
|
||||||
|
const ce = computedElement(col.phase.id, el.id);
|
||||||
|
const isSel =
|
||||||
|
selected?.type === "phaseCell" &&
|
||||||
|
selected.elementId === el.id &&
|
||||||
|
selected.phaseId === col.phase.id;
|
||||||
|
return (
|
||||||
|
<td
|
||||||
|
key={col.phase.id}
|
||||||
|
onClick={() => setSelected({ type: "phaseCell", elementId: el.id, phaseId: col.phase.id })}
|
||||||
|
className={`cursor-pointer border-b border-r border-zinc-200 px-2 py-1.5 text-center text-xs dark:border-zinc-800 ${
|
||||||
|
isSel ? "bg-indigo-100 dark:bg-indigo-500/20" : ""
|
||||||
|
} ${ce?.locked ? "text-zinc-400" : "text-zinc-700 dark:text-zinc-200"}`}
|
||||||
|
>
|
||||||
|
{ce?.summary ?? "–"}
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const canTransition = TRANSITION_CATEGORIES.includes(el.category);
|
||||||
|
const isSel =
|
||||||
|
selected?.type === "transitionCell" &&
|
||||||
|
selected.elementId === el.id &&
|
||||||
|
selected.fromPhaseId === col.fromPhase.id;
|
||||||
|
return (
|
||||||
|
<td
|
||||||
|
key={`t-${col.fromPhase.id}`}
|
||||||
|
onClick={() =>
|
||||||
|
canTransition &&
|
||||||
|
setSelected({ type: "transitionCell", elementId: el.id, fromPhaseId: col.fromPhase.id })
|
||||||
|
}
|
||||||
|
className={`border-b border-r border-zinc-200 px-2 py-1.5 text-center text-[11px] dark:border-zinc-800 ${
|
||||||
|
canTransition ? "cursor-pointer text-indigo-500" : "text-zinc-300 dark:text-zinc-600"
|
||||||
|
} ${isSel ? "bg-indigo-100 dark:bg-indigo-500/20" : "bg-indigo-50/30 dark:bg-indigo-500/5"}`}
|
||||||
|
>
|
||||||
|
{canTransition ? transitionSummary(el, col.fromPhase, col.toPhase) : "→"}
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</FragmentRows>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{plan.elements.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td className="sticky left-0 bg-white px-3 py-4 text-xs text-zinc-400 dark:bg-zinc-900" colSpan={columns.length + 1}>
|
||||||
|
Noch keine finanziellen Elemente. Fuegen Sie oben Ihr erstes Element hinzu.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Detail-Panel */}
|
||||||
|
{selected && (
|
||||||
|
<div className="rounded-xl border border-indigo-200 bg-white p-4 shadow-sm dark:border-indigo-500/30 dark:bg-zinc-900">
|
||||||
|
{renderDetail()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showAdd && (
|
||||||
|
<AddElementDialog
|
||||||
|
household={household}
|
||||||
|
onClose={() => setShowAdd(false)}
|
||||||
|
onCreate={async (payload) => {
|
||||||
|
await api.post(`/api/plans/${plan.id}/elements`, payload);
|
||||||
|
setShowAdd(false);
|
||||||
|
onChanged();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
function renderDetail() {
|
||||||
|
if (!selected) return null;
|
||||||
|
|
||||||
|
if (selected.type === "phase") {
|
||||||
|
const phase = computed.phases.find((p) => p.id === selected.phaseId);
|
||||||
|
const phaseInput = plan.phases.find((p) => p.id === selected.phaseId);
|
||||||
|
if (!phase || !phaseInput) return null;
|
||||||
|
const isLast = phase.sequenceNumber === computed.phases.length;
|
||||||
|
return (
|
||||||
|
<PhaseDetail
|
||||||
|
phase={phaseInput}
|
||||||
|
maxDurationYears={phase.maxDurationYears}
|
||||||
|
isLast={isLast}
|
||||||
|
household={household}
|
||||||
|
onSaved={onChanged}
|
||||||
|
onDeleted={() => {
|
||||||
|
setSelected(null);
|
||||||
|
onChanged();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const element = plan.elements.find((e) => e.id === selected.elementId);
|
||||||
|
if (!element) return null;
|
||||||
|
|
||||||
|
if (selected.type === "phaseCell") {
|
||||||
|
const phase = computed.phases.find((p) => p.id === selected.phaseId)!;
|
||||||
|
const ownerWorking = element.ownerRole && element.ownerRole !== "HOUSEHOLD"
|
||||||
|
? phase.persons.find((p) => p.role === element.ownerRole)?.working ?? false
|
||||||
|
: phase.type !== "PENSION";
|
||||||
|
const ce = computedElement(phase.id, element.id);
|
||||||
|
const context: CellContext = {
|
||||||
|
kind: "phase",
|
||||||
|
phaseId: phase.id,
|
||||||
|
ownerWorking,
|
||||||
|
isConsumption: phase.isConsumption,
|
||||||
|
durationYears: phase.durationYears,
|
||||||
|
isRetirementTransition: false,
|
||||||
|
carriedEndValue: ce?.endValue ?? 0,
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<ElementDetail
|
||||||
|
element={element}
|
||||||
|
context={context}
|
||||||
|
phaseData={element.phaseValues[phase.id] ?? {}}
|
||||||
|
transitionData={{}}
|
||||||
|
onSaved={onChanged}
|
||||||
|
onDeleteElement={() => deleteElement(element.id)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// transitionCell
|
||||||
|
const fromPhase = computed.phases.find((p) => p.id === selected.fromPhaseId)!;
|
||||||
|
const toIndex = computed.phases.findIndex((p) => p.id === fromPhase.id) + 1;
|
||||||
|
const toPhase = computed.phases[toIndex];
|
||||||
|
const ce = computedElement(fromPhase.id, element.id);
|
||||||
|
const context: CellContext = {
|
||||||
|
kind: "transition",
|
||||||
|
phaseId: fromPhase.id,
|
||||||
|
ownerWorking: true,
|
||||||
|
isConsumption: fromPhase.isConsumption,
|
||||||
|
durationYears: fromPhase.durationYears,
|
||||||
|
isRetirementTransition: toPhase ? isRetirementTransition(element, fromPhase, toPhase) : false,
|
||||||
|
carriedEndValue: ce?.endValue ?? 0,
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<ElementDetail
|
||||||
|
element={element}
|
||||||
|
context={context}
|
||||||
|
phaseData={{}}
|
||||||
|
transitionData={element.transitionValues[fromPhase.id] ?? {}}
|
||||||
|
onSaved={onChanged}
|
||||||
|
onDeleteElement={() => deleteElement(element.id)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteElement(id: string) {
|
||||||
|
if (!confirm("Dieses Element wirklich loeschen (aus allen Phasen)?")) return;
|
||||||
|
await api.delete(`/api/elements/${id}`);
|
||||||
|
setSelected(null);
|
||||||
|
onChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
function transitionSummary(el: ElementInput, fromPhase: PhaseComputed, toPhase: PhaseComputed): string {
|
||||||
|
const td = el.transitionValues[fromPhase.id] ?? {};
|
||||||
|
switch (el.category) {
|
||||||
|
case "REAL_ESTATE":
|
||||||
|
case "OTHER_ASSET":
|
||||||
|
return td.decision === "SELL" ? "Verkauf" : "Halten";
|
||||||
|
case "PENSION_FUND":
|
||||||
|
if (isRetirementTransition(el, fromPhase, toPhase)) {
|
||||||
|
return td.payoutMode === "CAPITAL" ? "Kapital" : td.payoutMode === "COMBI" ? "Kombi" : "Rente";
|
||||||
|
}
|
||||||
|
return num(td.withdrawal) > 0 ? `−${formatChf(num(td.withdrawal))}` : "→";
|
||||||
|
case "PILLAR_3A":
|
||||||
|
if (isRetirementTransition(el, fromPhase, toPhase)) return "Bezug";
|
||||||
|
return num(td.withdrawal) > 0 ? `−${formatChf(num(td.withdrawal))}` : "→";
|
||||||
|
case "OTHER_DEBT":
|
||||||
|
return num(td.immediateRepayment) > 0 ? "Tilgung" : "→";
|
||||||
|
default:
|
||||||
|
return "→";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function PhaseHeader({ phase, onClick, active }: { phase: PhaseComputed; onClick: () => void; active: boolean }) {
|
||||||
|
const quotaLabel = phase.isConsumption ? "Verzehr" : "Sparquote";
|
||||||
|
return (
|
||||||
|
<th
|
||||||
|
onClick={onClick}
|
||||||
|
className={`min-w-40 cursor-pointer border-b border-r border-zinc-200 px-2 py-2 text-left align-top dark:border-zinc-800 ${
|
||||||
|
active ? "bg-indigo-100 dark:bg-indigo-500/20" : "bg-white dark:bg-zinc-900"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span className="truncate text-xs font-semibold text-zinc-800 dark:text-zinc-100">{phase.name}</span>
|
||||||
|
{phase.incomplete ? (
|
||||||
|
<AlertCircle className="h-3.5 w-3.5 shrink-0 text-red-500" />
|
||||||
|
) : (
|
||||||
|
<CheckCircle2 className="h-3.5 w-3.5 shrink-0 text-emerald-500" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-0.5 flex flex-wrap gap-1">
|
||||||
|
<span className="rounded bg-zinc-100 px-1 text-[10px] text-zinc-500 dark:bg-zinc-800">
|
||||||
|
{phase.type === "ERWERB" ? "Erwerb" : phase.type === "PENSION" ? "Pension" : "Misch"}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-zinc-400">{phase.durationYears} J.</span>
|
||||||
|
<span className="text-[10px] text-zinc-400">Alter {phase.persons.map((p) => p.startAge).join("/")}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 space-y-0.5 text-[10px] leading-tight text-zinc-500 dark:text-zinc-400">
|
||||||
|
<div>Einkommen {formatChf(phase.incomeTotal)}</div>
|
||||||
|
<div>Ausgaben {formatChf(phase.expenseTotal)}</div>
|
||||||
|
<div className={phase.quotaComplete ? "text-emerald-600 dark:text-emerald-400" : "text-red-600 dark:text-red-400"}>
|
||||||
|
{quotaLabel} {formatChf(Math.abs(phase.quota))}
|
||||||
|
</div>
|
||||||
|
<div className={phase.availableCapitalComplete ? "" : "text-red-600 dark:text-red-400"}>
|
||||||
|
Kapital {phase.availableCapital === null ? "n.a." : formatChf(phase.availableCapital)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FragmentRows({ children }: { children: React.ReactNode }) {
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AddElementDialog({
|
||||||
|
household,
|
||||||
|
onClose,
|
||||||
|
onCreate,
|
||||||
|
}: {
|
||||||
|
household: HouseholdInput;
|
||||||
|
onClose: () => void;
|
||||||
|
onCreate: (payload: { category: ElementCategory; name: string; ownerRole: string | null }) => void;
|
||||||
|
}) {
|
||||||
|
const [category, setCategory] = useState<ElementCategory>("INCOME");
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [ownerRole, setOwnerRole] = useState<string>(household.householdType === "COUPLE" ? "PERSON_A" : "PERSON_A");
|
||||||
|
|
||||||
|
const needsPerson = PERSON_ONLY_CATEGORIES.includes(category);
|
||||||
|
const isCouple = household.householdType === "COUPLE";
|
||||||
|
|
||||||
|
const ownerOptions = needsPerson
|
||||||
|
? isCouple
|
||||||
|
? [
|
||||||
|
{ value: "PERSON_A", label: "Person A" },
|
||||||
|
{ value: "PERSON_B", label: "Person B" },
|
||||||
|
]
|
||||||
|
: [{ value: "PERSON_A", label: "Person A" }]
|
||||||
|
: isCouple
|
||||||
|
? [
|
||||||
|
{ value: "HOUSEHOLD", label: "Gemeinsam" },
|
||||||
|
{ value: "PERSON_A", label: "Person A" },
|
||||||
|
{ value: "PERSON_B", label: "Person B" },
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
{ value: "HOUSEHOLD", label: "Gemeinsam" },
|
||||||
|
{ value: "PERSON_A", label: "Person A" },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-40 flex items-center justify-center bg-black/40 px-4" onClick={onClose}>
|
||||||
|
<div
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="flex w-full max-w-md flex-col gap-3 rounded-2xl border border-zinc-200 bg-white p-6 shadow-xl dark:border-zinc-700 dark:bg-zinc-900"
|
||||||
|
>
|
||||||
|
<h2 className="text-base font-semibold text-zinc-900 dark:text-zinc-50">Finanzielles Element</h2>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs font-medium text-zinc-600 dark:text-zinc-400">Kategorie</label>
|
||||||
|
<select
|
||||||
|
value={category}
|
||||||
|
onChange={(e) => {
|
||||||
|
const c = e.target.value as ElementCategory;
|
||||||
|
setCategory(c);
|
||||||
|
if (PERSON_ONLY_CATEGORIES.includes(c) && ownerRole === "HOUSEHOLD") setOwnerRole("PERSON_A");
|
||||||
|
if (!name) setName(CATEGORY_LABELS[c]);
|
||||||
|
}}
|
||||||
|
className="w-full rounded-lg border border-zinc-300 px-3 py-2 text-sm focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/20 dark:border-zinc-600 dark:bg-zinc-950"
|
||||||
|
>
|
||||||
|
{CATEGORY_ORDER.map((c) => (
|
||||||
|
<option key={c} value={c}>
|
||||||
|
{CATEGORY_LABELS[c]}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs font-medium text-zinc-600 dark:text-zinc-400">Bezeichnung</label>
|
||||||
|
<input
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder={CATEGORY_LABELS[category]}
|
||||||
|
className="w-full rounded-lg border border-zinc-300 px-3 py-2 text-sm focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/20 dark:border-zinc-600 dark:bg-zinc-950"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs font-medium text-zinc-600 dark:text-zinc-400">Zuordnung</label>
|
||||||
|
<select
|
||||||
|
value={ownerRole}
|
||||||
|
onChange={(e) => setOwnerRole(e.target.value)}
|
||||||
|
className="w-full rounded-lg border border-zinc-300 px-3 py-2 text-sm focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/20 dark:border-zinc-600 dark:bg-zinc-950"
|
||||||
|
>
|
||||||
|
{ownerOptions.map((o) => (
|
||||||
|
<option key={o.value} value={o.value}>
|
||||||
|
{o.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onCreate({ category, name: name.trim() || CATEGORY_LABELS[category], ownerRole })}
|
||||||
|
className="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-500 dark:bg-indigo-500 dark:hover:bg-indigo-400"
|
||||||
|
>
|
||||||
|
Erstellen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="rounded-lg border border-zinc-300 px-4 py-2 text-sm font-medium text-zinc-700 hover:bg-zinc-50 dark:border-zinc-700 dark:text-zinc-200 dark:hover:bg-zinc-800"
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Flag } from "lucide-react";
|
||||||
|
import type { PhaseComputed } from "@/lib/calculations";
|
||||||
|
|
||||||
|
interface PersonAxis {
|
||||||
|
role: "PERSON_A" | "PERSON_B";
|
||||||
|
label: string;
|
||||||
|
currentAge: number;
|
||||||
|
retirementAge: number;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Horizontale Zeitachse: Alter von links nach rechts, mit deutlich markiertem
|
||||||
|
// Pensionsalter je Person und Trennlinien an den Phasengrenzen.
|
||||||
|
export function Timeline({ phases, persons }: { phases: PhaseComputed[]; persons: PersonAxis[] }) {
|
||||||
|
if (phases.length === 0 || persons.length === 0) return null;
|
||||||
|
|
||||||
|
const totalYears = phases.reduce((s, p) => s + p.durationYears, 0);
|
||||||
|
const minAge = Math.min(...persons.map((p) => p.currentAge));
|
||||||
|
const maxAge = minAge + totalYears;
|
||||||
|
const span = Math.max(1, maxAge - minAge);
|
||||||
|
|
||||||
|
const pct = (age: number) => `${(Math.max(0, Math.min(span, age - minAge)) / span) * 100}%`;
|
||||||
|
|
||||||
|
// Phasengrenzen (kumulierte Jahre).
|
||||||
|
const boundaries: { year: number; label: string }[] = [];
|
||||||
|
let acc = 0;
|
||||||
|
for (const p of phases) {
|
||||||
|
boundaries.push({ year: acc, label: p.name });
|
||||||
|
acc += p.durationYears;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-zinc-200/70 bg-white p-4 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<h3 className="text-sm font-semibold text-zinc-800 dark:text-zinc-100">Zeitachse</h3>
|
||||||
|
<div className="flex gap-3 text-xs text-zinc-500">
|
||||||
|
{persons.map((p) => (
|
||||||
|
<span key={p.role} className="flex items-center gap-1">
|
||||||
|
<span className="inline-block h-2 w-2 rounded-full" style={{ backgroundColor: p.color }} />
|
||||||
|
{p.label} (heute {p.currentAge})
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative pt-6">
|
||||||
|
{/* Pensionsmarker je Person */}
|
||||||
|
{persons.map((p, i) =>
|
||||||
|
p.retirementAge > minAge && p.retirementAge < maxAge ? (
|
||||||
|
<div
|
||||||
|
key={p.role}
|
||||||
|
className="absolute top-0 flex -translate-x-1/2 flex-col items-center"
|
||||||
|
style={{ left: pct(p.retirementAge) }}
|
||||||
|
title={`${p.label}: Pensionierung mit ${p.retirementAge}`}
|
||||||
|
>
|
||||||
|
<Flag className="h-3.5 w-3.5" style={{ color: p.color }} fill={p.color} />
|
||||||
|
<span className="whitespace-nowrap text-[10px] font-medium" style={{ color: p.color }}>
|
||||||
|
{p.retirementAge}
|
||||||
|
</span>
|
||||||
|
<div className="mt-0.5 h-3 w-px" style={{ backgroundColor: p.color, marginTop: i * 2 }} />
|
||||||
|
</div>
|
||||||
|
) : null
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Achse */}
|
||||||
|
<div className="relative h-2 w-full rounded-full bg-gradient-to-r from-indigo-200 to-indigo-400 dark:from-indigo-500/30 dark:to-indigo-500/60">
|
||||||
|
{boundaries.slice(1).map((b) => (
|
||||||
|
<div
|
||||||
|
key={b.year}
|
||||||
|
className="absolute top-0 h-2 w-px bg-white/70 dark:bg-zinc-900/70"
|
||||||
|
style={{ left: pct(minAge + b.year) }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Alters-Beschriftung */}
|
||||||
|
<div className="mt-1 flex justify-between text-[11px] text-zinc-500">
|
||||||
|
<span>{minAge} J.</span>
|
||||||
|
<span>{maxAge} J.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,280 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { ArrowDown, CheckCircle2, ChevronDown, ChevronRight } from "lucide-react";
|
|
||||||
import { MoneyInput } from "@/components/FormField";
|
|
||||||
import { api } from "@/lib/api-client";
|
|
||||||
import { floorToThousand, formatChf } from "@/lib/format";
|
|
||||||
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;
|
|
||||||
// Nur fuer Immobilien editierbar (poppt bei "Verkaufen" auf); bei Wertschriften der
|
|
||||||
// fixe, am Wertpapier hinterlegte Steuersatz.
|
|
||||||
saleTaxRate: number;
|
|
||||||
// Referenzwerte fuer die Anzeige/Berechnung des verfuegbaren Startkapitals
|
|
||||||
carryOverValue: number; // Wert bei "Halten": Endwert (Wertschrift) bzw. Nettowert (Immobilie)
|
|
||||||
originalValue: number; // Wertschrift: Startwert: Immobilie: Kaufpreis
|
|
||||||
remainingMortgage: number; // nur Immobilien: Resthypothek am Ende der Phase
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TransitionPanel({
|
|
||||||
phase,
|
|
||||||
computed,
|
|
||||||
nextPhaseName,
|
|
||||||
onChanged,
|
|
||||||
}: {
|
|
||||||
phase: PhaseInput;
|
|
||||||
computed: PhaseComputed;
|
|
||||||
nextPhaseName: string;
|
|
||||||
onChanged: () => void;
|
|
||||||
}) {
|
|
||||||
const [items, setItems] = useState<ItemDraft[]>([]);
|
|
||||||
const [loaded, setLoaded] = useState(false);
|
|
||||||
const [expanded, setExpanded] = 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,
|
|
||||||
saleTaxRate: s.saleTaxRate,
|
|
||||||
carryOverValue: c?.endValue ?? 0,
|
|
||||||
originalValue: c?.startValue ?? 0,
|
|
||||||
remainingMortgage: 0,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
...phase.realEstates.map((re) => {
|
|
||||||
const c = computed.realEstates.find((cr) => cr.id === re.id);
|
|
||||||
const remainingMortgage = c ? c.mortgages[phase.durationYears] : 0;
|
|
||||||
return {
|
|
||||||
positionType: "REAL_ESTATE" as const,
|
|
||||||
id: re.id,
|
|
||||||
name: re.name,
|
|
||||||
decision: "CARRY_OVER" as TransitionDecision,
|
|
||||||
salePrice: re.purchasePrice,
|
|
||||||
saleTaxRate: 20,
|
|
||||||
carryOverValue: c?.endNet ?? 0,
|
|
||||||
originalValue: re.purchasePrice,
|
|
||||||
remainingMortgage,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
|
|
||||||
try {
|
|
||||||
const data = await api.get<{
|
|
||||||
transition: {
|
|
||||||
items: {
|
|
||||||
positionType: string;
|
|
||||||
securityId: string | null;
|
|
||||||
realEstateId: string | null;
|
|
||||||
decision: TransitionDecision;
|
|
||||||
salePrice: number | null;
|
|
||||||
saleTaxRate: 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;
|
|
||||||
if (savedItem.saleTaxRate != null) target.saleTaxRate = savedItem.saleTaxRate;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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-xl bg-zinc-100 px-4 py-3 text-xs text-zinc-500 dark:bg-zinc-800">
|
|
||||||
Uebergang wird geladen…
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const totalAvailableCapital = floorToThousand(
|
|
||||||
items.reduce((sum, it) => {
|
|
||||||
if (it.decision === "CARRY_OVER") return sum;
|
|
||||||
if (it.positionType === "SECURITY") {
|
|
||||||
const gain = Math.max(0, it.carryOverValue - it.originalValue);
|
|
||||||
const tax = gain * (it.saleTaxRate / 100);
|
|
||||||
return sum + (it.carryOverValue - tax);
|
|
||||||
}
|
|
||||||
const salePrice = it.salePrice ?? 0;
|
|
||||||
const gain = Math.max(0, salePrice - it.originalValue);
|
|
||||||
const tax = gain * (it.saleTaxRate / 100);
|
|
||||||
return sum + (salePrice - it.remainingMortgage - 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,
|
|
||||||
saleTaxRate: it.positionType === "REAL_ESTATE" && it.decision === "SELL" ? it.saleTaxRate : null,
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
setSaved(true);
|
|
||||||
onChanged();
|
|
||||||
} 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 rounded-xl border border-dashed border-indigo-200 bg-indigo-50/40 dark:border-indigo-500/30 dark:bg-indigo-500/5">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setExpanded((v) => !v)}
|
|
||||||
className="flex w-full flex-wrap items-center gap-1.5 px-4 py-2.5 text-left text-xs font-semibold uppercase tracking-wide text-indigo-600 dark:text-indigo-400"
|
|
||||||
>
|
|
||||||
<span>
|
|
||||||
{expanded ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
|
|
||||||
</span>
|
|
||||||
<ArrowDown className="h-3.5 w-3.5" />
|
|
||||||
<span className="flex-1">Uebergang → {nextPhaseName}</span>
|
|
||||||
<span className="normal-case tracking-normal text-zinc-500 dark:text-zinc-400">
|
|
||||||
Startkapital aus Verkaeufen: <strong className="text-indigo-600 dark:text-indigo-400">{formatChf(totalAvailableCapital)} CHF</strong>
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
{expanded && (
|
|
||||||
<div className="flex flex-col gap-3 px-4 pb-4">
|
|
||||||
<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</th>
|
|
||||||
<th className="pb-1 font-normal">Grundstueckgewinnsteuer</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{items.map((it, i) => (
|
|
||||||
<tr key={`${it.positionType}-${it.id}`} className="border-t border-zinc-200 dark:border-zinc-800">
|
|
||||||
<td className="py-2 pr-2">{it.name}</td>
|
|
||||||
<td className="py-2 pr-2">
|
|
||||||
<select
|
|
||||||
className="rounded-lg border border-zinc-300 bg-white px-2 py-1 text-xs focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/20 dark:border-zinc-700 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">{it.positionType === "REAL_ESTATE" ? "Halten" : "Uebernehmen"}</option>
|
|
||||||
<option value="SELL">Verkaufen</option>
|
|
||||||
</select>
|
|
||||||
</td>
|
|
||||||
<td className="py-2 pr-2">
|
|
||||||
{it.decision === "SELL" ? (
|
|
||||||
it.positionType === "REAL_ESTATE" ? (
|
|
||||||
<MoneyInput
|
|
||||||
className="w-32"
|
|
||||||
value={it.salePrice ?? 0}
|
|
||||||
onChange={(v) =>
|
|
||||||
setItems((prev) => prev.map((x, idx) => (idx === i ? { ...x, salePrice: v } : x)))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<span className="text-xs text-zinc-500">{formatChf(it.carryOverValue)} CHF</span>
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
<span className="text-xs text-zinc-500">{formatChf(it.carryOverValue)} CHF</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="py-2">
|
|
||||||
{it.positionType === "REAL_ESTATE" && it.decision === "SELL" ? (
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
className="w-20 rounded-lg border border-zinc-300 bg-white px-2 py-1 text-xs focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/20 dark:border-zinc-700 dark:bg-zinc-900"
|
|
||||||
value={it.saleTaxRate}
|
|
||||||
onChange={(e) =>
|
|
||||||
setItems((prev) =>
|
|
||||||
prev.map((x, idx) => (idx === i ? { ...x, saleTaxRate: e.target.valueAsNumber || 0 } : x))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<span className="text-xs text-zinc-500">—</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
<div className="rounded-xl bg-white px-3 py-2 text-sm shadow-sm dark:bg-zinc-900">
|
|
||||||
Verfuegbares Startkapital fuer neue Phase (aus Verkaeufen): <strong>{formatChf(totalAvailableCapital)} CHF</strong>
|
|
||||||
<p className="mt-1 text-xs text-zinc-500">
|
|
||||||
Wird beim Speichern automatisch in "{nextPhaseName}" als verfuegbares Startkapital hinterlegt.
|
|
||||||
Gehaltene/uebernommene Positionen erscheinen dort automatisch mit ihrem Endwert (Wertschriften) bzw.
|
|
||||||
Kaufpreis/Resthypothek (Immobilien) als neue Ausgangswerte.
|
|
||||||
</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-lg bg-indigo-600 px-3 py-1.5 text-xs font-medium text-white shadow-sm hover:bg-indigo-500 disabled:opacity-50 dark:bg-indigo-500 dark:hover:bg-indigo-400"
|
|
||||||
>
|
|
||||||
{saving ? "Speichern..." : "Uebergang speichern"}
|
|
||||||
</button>
|
|
||||||
{saved && (
|
|
||||||
<span className="flex items-center gap-1 text-xs text-emerald-600 dark:text-emerald-400">
|
|
||||||
<CheckCircle2 className="h-3.5 w-3.5" /> Gespeichert.
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -5,7 +5,6 @@ import {
|
|||||||
Legend,
|
Legend,
|
||||||
Line,
|
Line,
|
||||||
LineChart,
|
LineChart,
|
||||||
ReferenceLine,
|
|
||||||
ResponsiveContainer,
|
ResponsiveContainer,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
XAxis,
|
XAxis,
|
||||||
@@ -20,59 +19,41 @@ export interface TimelineSeries {
|
|||||||
computed: PlanComputed;
|
computed: PlanComputed;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildTimeline(computed: PlanComputed) {
|
// Liniendiagramm: Endvermoegen (nominal + real) je Lebensphase. Unterstuetzt mehrere
|
||||||
const points: { year: number; nominal: number; real: number }[] = [
|
// ueberlagerte Plaene fuer den Szenario-Vergleich.
|
||||||
{ 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[] }) {
|
export function WealthChart({ series }: { series: TimelineSeries[] }) {
|
||||||
if (series.length === 0 || series[0].computed.phases.length === 0) {
|
if (series.length === 0 || series[0].computed.phases.length === 0) {
|
||||||
return <p className="text-sm text-zinc-500">Noch keine Phasen vorhanden.</p>;
|
return <p className="text-sm text-zinc-500">Noch keine Phasen vorhanden.</p>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const primary = buildTimeline(series[0].computed);
|
// Datenpunkte je Phasen-Index; X-Achse = Phasenname des Hauptplans.
|
||||||
const maxYear = Math.max(...series.map((s) => buildTimeline(s.computed).points.length - 1));
|
const maxLen = Math.max(...series.map((s) => s.computed.phases.length));
|
||||||
|
const data = Array.from({ length: maxLen }, (_, i) => {
|
||||||
const merged: Record<number, Record<string, number>> = {};
|
const row: Record<string, number | string> = {
|
||||||
|
phase: series[0].computed.phases[i]?.name ?? `Phase ${i + 1}`,
|
||||||
|
};
|
||||||
for (const s of series) {
|
for (const s of series) {
|
||||||
const tl = buildTimeline(s.computed);
|
const p = s.computed.phases[i];
|
||||||
for (const p of tl.points) {
|
if (p) {
|
||||||
merged[p.year] = merged[p.year] ?? { year: p.year };
|
row[`${s.label} (nominal)`] = Math.round(p.endWealthNominal);
|
||||||
merged[p.year][`${s.label} (nominal)`] = p.nominal;
|
row[`${s.label} (real)`] = Math.round(p.endWealthReal);
|
||||||
merged[p.year][`${s.label} (real)`] = p.real;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const data = Array.from({ length: maxYear + 1 }, (_, y) => merged[y] ?? { year: y });
|
return row;
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-80 w-full">
|
<div className="h-80 w-full">
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<LineChart data={data} margin={{ top: 8, right: 16, left: 8, bottom: 8 }}>
|
<LineChart data={data} margin={{ top: 8, right: 16, left: 8, bottom: 8 }}>
|
||||||
<CartesianGrid strokeDasharray="3 3" className="stroke-zinc-200 dark:stroke-zinc-700" />
|
<CartesianGrid strokeDasharray="3 3" className="stroke-zinc-200 dark:stroke-zinc-700" />
|
||||||
<XAxis dataKey="year" tick={{ fontSize: 11 }} label={{ value: "Jahr", position: "insideBottomRight", offset: -4, fontSize: 11 }} />
|
<XAxis dataKey="phase" tick={{ fontSize: 11 }} />
|
||||||
<YAxis
|
<YAxis
|
||||||
tick={{ fontSize: 11 }}
|
tick={{ fontSize: 11 }}
|
||||||
tickFormatter={(v) => Intl.NumberFormat("de-CH", { notation: "compact" }).format(v)}
|
tickFormatter={(v) => Intl.NumberFormat("de-CH", { notation: "compact" }).format(v)}
|
||||||
/>
|
/>
|
||||||
<Tooltip formatter={(v) => (typeof v === "number" ? formatChf(v) : v)} />
|
<Tooltip formatter={(v) => (typeof v === "number" ? formatChf(v) : v)} />
|
||||||
<Legend wrapperStyle={{ fontSize: 12 }} />
|
<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) => (
|
{series.map((s) => (
|
||||||
<Line
|
<Line
|
||||||
key={`${s.label}-nominal`}
|
key={`${s.label}-nominal`}
|
||||||
|
|||||||
+486
-240
@@ -1,39 +1,41 @@
|
|||||||
import { AHV_COUPLE_CAP_FACTOR, AHV_MAX_PENSION_PER_YEAR } from "@/lib/constants";
|
import {
|
||||||
|
AHV_COUPLE_CAP_FACTOR,
|
||||||
|
AHV_FULL_CONTRIBUTION_YEARS,
|
||||||
|
AHV_MAX_ANNUAL_SINGLE,
|
||||||
|
} from "@/lib/constants";
|
||||||
import { floorToThousand } from "@/lib/format";
|
import { floorToThousand } from "@/lib/format";
|
||||||
import type { HouseholdInput, PhaseInput, PlanInput } from "@/lib/types";
|
import { num } from "@/lib/elements";
|
||||||
|
import type { ElementCategory } from "@/lib/elements";
|
||||||
|
import type { HouseholdInput, PersonRole, PlanInput } from "@/lib/types";
|
||||||
|
|
||||||
export interface SecurityComputed {
|
export type PhaseType = "ERWERB" | "PENSION" | "MIXED";
|
||||||
id: string;
|
export type ElementStatus = "ACTIVE" | "SOLD" | "SETTLED";
|
||||||
name: string;
|
|
||||||
ownerTag: string;
|
|
||||||
startValue: number;
|
|
||||||
endValue: number;
|
|
||||||
yearly: number[]; // Index 0 = Startwert, Index durationYears = Endwert
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RealEstateComputed {
|
export interface PersonPhaseInfo {
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
purchasePrice: number; // fix ueber die Haltedauer, keine Wertsteigerung im vereinfachten Modell
|
|
||||||
startNet: number;
|
|
||||||
endNet: number;
|
|
||||||
mortgages: number[]; // Index 0 = Start, Index durationYears = Ende
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RetirementComputed {
|
|
||||||
perPerson: {
|
|
||||||
personId: string;
|
personId: string;
|
||||||
ahvAmount: number;
|
role: PersonRole;
|
||||||
pkPensionAmount: number;
|
startAge: number;
|
||||||
lumpSumAmount: number;
|
endAge: number;
|
||||||
lumpSumNet: number;
|
working: boolean;
|
||||||
}[];
|
// Wird diese Person genau zu Beginn dieser Phase pensioniert (erste Pensionsphase)?
|
||||||
combinedAhv: number;
|
retiresAtStart: boolean;
|
||||||
ahvCapped: boolean;
|
}
|
||||||
pkTotal: number;
|
|
||||||
totalPensionIncome: number; // combinedAhv + pkTotal, fliesst als Einkommen in die Phase ein
|
export interface ElementPhaseComputed {
|
||||||
lumpSumGrossTotal: number;
|
elementId: string;
|
||||||
lumpSumNetTotal: number; // fliesst als Einmalbetrag in das Endvermoegen der Phase ein
|
category: ElementCategory;
|
||||||
|
name: string;
|
||||||
|
ownerRole: string | null;
|
||||||
|
status: ElementStatus;
|
||||||
|
locked: boolean; // verkauft/getilgt -> in dieser Phase nicht mehr editierbar
|
||||||
|
startValue: number; // Netto-Wert zu Phasenbeginn (Aktiven +, Schulden -)
|
||||||
|
endValue: number; // Netto-Wert am Phasenende
|
||||||
|
incomeContribution: number; // Beitrag zum Phasen-Einkommen
|
||||||
|
expenseContribution: number; // Beitrag zu den Phasen-Ausgaben
|
||||||
|
quotaUse: number; // Betrag, der Spar-/Verzehrquote verbraucht (3a/Sonstiges Vermoegen)
|
||||||
|
capitalUse: number; // verbrauchtes verfuegbares Startkapital (Aufstockung/Neuinvestition)
|
||||||
|
summary: string; // Kennzahl fuer die eingeklappte Zelle
|
||||||
|
note: string | null; // z. B. "Verkauft", "Getilgt", "Vollstaendig bezogen"
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PhaseComputed {
|
export interface PhaseComputed {
|
||||||
@@ -41,271 +43,515 @@ export interface PhaseComputed {
|
|||||||
name: string;
|
name: string;
|
||||||
sequenceNumber: number;
|
sequenceNumber: number;
|
||||||
durationYears: number;
|
durationYears: number;
|
||||||
incomeFromEntries: number;
|
type: PhaseType;
|
||||||
|
persons: PersonPhaseInfo[];
|
||||||
|
maxDurationYears: number | null; // Kappung ans naechste Pensionsereignis (null = unbegrenzt)
|
||||||
|
incomeTotal: number;
|
||||||
expenseTotal: number;
|
expenseTotal: number;
|
||||||
retirement: RetirementComputed | null;
|
quota: number; // Einkommen - Ausgaben (Sparquote wenn >=0, Verzehrquote wenn <0)
|
||||||
effectiveIncome: number; // incomeFromEntries + retirement.totalPensionIncome
|
isConsumption: boolean;
|
||||||
savingsQuota: number; // effectiveIncome - expenseTotal
|
quotaAllocated: number; // verteilte Sparbeitraege bzw. gedeckte Verzehr-Bezuege
|
||||||
allocatedSavings: number; // Summe der jaehrlichen Sparbeitraege auf Wertschriften
|
quotaComplete: boolean;
|
||||||
savingsWarning: boolean;
|
availableCapital: number | null; // null in der ersten Phase
|
||||||
securities: SecurityComputed[];
|
availableCapitalUsed: number;
|
||||||
realEstates: RealEstateComputed[];
|
availableCapitalComplete: boolean;
|
||||||
oneTimeNet: number;
|
incomplete: boolean; // roter Status (Quote/Kapital nicht vollstaendig verteilt)
|
||||||
|
elements: ElementPhaseComputed[];
|
||||||
startWealthNominal: number;
|
startWealthNominal: number;
|
||||||
endWealthNominal: number;
|
endWealthNominal: number;
|
||||||
cumulativeInflationStart: number;
|
|
||||||
cumulativeInflationEnd: number;
|
cumulativeInflationEnd: number;
|
||||||
startWealthReal: number;
|
|
||||||
endWealthReal: number;
|
endWealthReal: number;
|
||||||
yearlyNominal: number[]; // Laenge durationYears, Werte am Ende von Jahr 1..durationYears
|
|
||||||
yearlyReal: number[];
|
|
||||||
// Alter der Personen zu Beginn und am Ende dieser Phase (Grundprofil-Alter +
|
|
||||||
// kumulierte Dauer der Vorphasen).
|
|
||||||
ages: PersonAgeRange[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PersonAgeRange {
|
|
||||||
personId: string;
|
|
||||||
role: string; // PERSON_A | PERSON_B
|
|
||||||
startAge: number;
|
|
||||||
endAge: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PlanComputed {
|
export interface PlanComputed {
|
||||||
phases: PhaseComputed[];
|
phases: PhaseComputed[];
|
||||||
nachlass: number;
|
nachlass: number;
|
||||||
totalSavingsWarnings: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Alle Zwischen- und Endwerte werden auf ein Vielfaches von 1'000 abgerundet (siehe
|
// Loest das effektive Pensionsalter einer Person auf (Plan-Override vor Profil-Default).
|
||||||
// lib/format.ts): nur so bleiben Betraege, die spaeter bei einem Verkauf oder Uebergang
|
export function resolveRetirementAge(
|
||||||
// auf Wertschriften verteilt werden muessen, ueberhaupt vollstaendig verteilbar.
|
role: PersonRole,
|
||||||
export function computeSecurityYearlyValues(
|
plan: { retirementAgeA: number | null; retirementAgeB: number | null },
|
||||||
startValue: number,
|
profileDefault: number
|
||||||
expectedReturn: number,
|
): number {
|
||||||
annualContribution: number,
|
const override = role === "PERSON_A" ? plan.retirementAgeA : plan.retirementAgeB;
|
||||||
durationYears: number
|
return override ?? profileDefault;
|
||||||
): number[] {
|
|
||||||
const values = [floorToThousand(startValue)];
|
|
||||||
for (let year = 1; year <= durationYears; year++) {
|
|
||||||
const previous = values[year - 1];
|
|
||||||
values.push(floorToThousand(previous * (1 + expectedReturn / 100) + annualContribution));
|
|
||||||
}
|
|
||||||
return values;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Vereinfachtes Modell (keine Wertsteigerung): der Kaufpreis bleibt ueber die ganze
|
// Maximale Dauer einer neuen Phase, die yearsBefore Jahre nach Planbeginn startet:
|
||||||
// Haltedauer fix, nur die Hypothek sinkt jaehrlich um die Amortisationsrate.
|
// bis zum naechsten Pensionsereignis einer noch erwerbenden Person (null = unbegrenzt).
|
||||||
export function computeMortgageYearly(
|
export function maxPhaseDuration(
|
||||||
mortgage: number,
|
persons: { role: PersonRole; age: number; retirementAge: number }[],
|
||||||
amortization: number,
|
plan: { retirementAgeA: number | null; retirementAgeB: number | null },
|
||||||
durationYears: number
|
yearsBefore: number
|
||||||
): number[] {
|
): number | null {
|
||||||
const mortgages = [floorToThousand(mortgage)];
|
const caps: number[] = [];
|
||||||
for (let year = 1; year <= durationYears; year++) {
|
for (const p of persons) {
|
||||||
mortgages.push(floorToThousand(Math.max(0, mortgages[year - 1] - amortization)));
|
const ra = resolveRetirementAge(p.role, plan, p.retirementAge);
|
||||||
|
const startAge = p.age + yearsBefore;
|
||||||
|
if (startAge < ra) caps.push(ra - startAge);
|
||||||
}
|
}
|
||||||
return mortgages;
|
return caps.length > 0 ? Math.min(...caps) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function computeRetirement(
|
// Interner Zustand, der pro Element von Phase zu Phase weitergetragen wird.
|
||||||
household: HouseholdInput,
|
interface Carry {
|
||||||
phase: PhaseInput
|
status: ElementStatus;
|
||||||
): RetirementComputed | null {
|
value: number; // Aktiven-Saldo (PK/3a/Sonstiges Vermoegen) am Ende der Vorphase
|
||||||
if (phase.retirementInfos.length === 0) return null;
|
mortgage: number; // Immobilie: Resthypothek
|
||||||
|
owed: number; // Schulden: Restschuld (positiv)
|
||||||
|
pkPensionAnnual: number; // PK: jaehrliche Rente nach Verrentung
|
||||||
|
hasCarry: boolean; // gab es eine Vorphase mit diesem Element?
|
||||||
|
}
|
||||||
|
|
||||||
const perPerson = phase.retirementInfos.map((info) => ({
|
function emptyCarry(): Carry {
|
||||||
personId: info.personId,
|
return { status: "ACTIVE", value: 0, mortgage: 0, owed: 0, pkPensionAnnual: 0, hasCarry: false };
|
||||||
ahvAmount: info.ahvAmount,
|
}
|
||||||
pkPensionAmount: info.pkPensionAmount,
|
|
||||||
lumpSumAmount: info.lumpSumAmount,
|
|
||||||
lumpSumNet: floorToThousand(info.lumpSumAmount * (1 - info.lumpSumTaxRate / 100)),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const ahvSum = perPerson.reduce((sum, p) => sum + p.ahvAmount, 0);
|
function growAsset(startValue: number, expectedReturn: number, annual: number, years: number): number {
|
||||||
const ahvCap = AHV_MAX_PENSION_PER_YEAR * AHV_COUPLE_CAP_FACTOR;
|
let v = floorToThousand(startValue);
|
||||||
const isCoupleBothRetired = household.householdType === "COUPLE" && phase.retirementInfos.length === 2;
|
for (let y = 0; y < years; y++) {
|
||||||
const combinedAhv = isCoupleBothRetired ? Math.min(ahvSum, ahvCap) : ahvSum;
|
v = floorToThousand(v * (1 + expectedReturn / 100) + annual);
|
||||||
const ahvCapped = isCoupleBothRetired && ahvSum > ahvCap;
|
}
|
||||||
|
return Math.max(0, v);
|
||||||
|
}
|
||||||
|
|
||||||
const pkTotal = perPerson.reduce((sum, p) => sum + p.pkPensionAmount, 0);
|
export function computePlan(plan: PlanInput, household: HouseholdInput): PlanComputed {
|
||||||
const lumpSumGrossTotal = perPerson.reduce((sum, p) => sum + p.lumpSumAmount, 0);
|
const phases = [...plan.phases].sort((a, b) => a.sequenceNumber - b.sequenceNumber);
|
||||||
const lumpSumNetTotal = perPerson.reduce((sum, p) => sum + p.lumpSumNet, 0);
|
const persons = household.persons;
|
||||||
|
|
||||||
|
// Pensionsalter je Person (aufgeloest).
|
||||||
|
const retirementAge = new Map<string, number>();
|
||||||
|
for (const p of persons) {
|
||||||
|
retirementAge.set(p.id, resolveRetirementAge(p.role, plan, p.retirementAge));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kumulierte AHV-Ausfalljahre je Person (ueber die Erwerbsphasen aufsummiert).
|
||||||
|
const gapYearsByPerson = new Map<string, number>();
|
||||||
|
// Carry-Zustand je Element.
|
||||||
|
const carries = new Map<string, Carry>();
|
||||||
|
for (const e of plan.elements) carries.set(e.id, emptyCarry());
|
||||||
|
|
||||||
|
const result: PhaseComputed[] = [];
|
||||||
|
let yearsBefore = 0;
|
||||||
|
let cumulativeInflation = 1;
|
||||||
|
let incomingCapital: number | null = null; // in die aktuelle Phase einfliessendes Startkapital
|
||||||
|
|
||||||
|
for (let i = 0; i < phases.length; i++) {
|
||||||
|
const phase = phases[i];
|
||||||
|
const nextPhase = phases[i + 1];
|
||||||
|
|
||||||
|
// --- Personen-Status in dieser Phase ---
|
||||||
|
const personInfos: PersonPhaseInfo[] = persons.map((p) => {
|
||||||
|
const ra = retirementAge.get(p.id)!;
|
||||||
|
const startAge = p.age + yearsBefore;
|
||||||
|
const working = startAge < ra;
|
||||||
return {
|
return {
|
||||||
perPerson,
|
|
||||||
combinedAhv,
|
|
||||||
ahvCapped,
|
|
||||||
pkTotal,
|
|
||||||
totalPensionIncome: combinedAhv + pkTotal,
|
|
||||||
lumpSumGrossTotal,
|
|
||||||
lumpSumNetTotal,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function computePhase(
|
|
||||||
phase: PhaseInput,
|
|
||||||
household: HouseholdInput,
|
|
||||||
cumulativeInflationStart: number,
|
|
||||||
yearsBeforePhase: number
|
|
||||||
): PhaseComputed {
|
|
||||||
const ages: PersonAgeRange[] = household.persons.map((p) => ({
|
|
||||||
personId: p.id,
|
personId: p.id,
|
||||||
role: p.role,
|
role: p.role,
|
||||||
startAge: p.age + yearsBeforePhase,
|
startAge,
|
||||||
endAge: p.age + yearsBeforePhase + phase.durationYears,
|
endAge: startAge + phase.durationYears,
|
||||||
}));
|
working,
|
||||||
|
retiresAtStart: startAge === ra,
|
||||||
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;
|
|
||||||
// Amortisationsraten aller Immobilien zaehlen gleichwertig mit den Sparbeitraegen der
|
|
||||||
// Wertschriften gegen dieselbe verfuegbare Sparquote (ein gemeinsamer Topf).
|
|
||||||
const allocatedSavings =
|
|
||||||
phase.securities.reduce((sum, s) => sum + s.annualContribution, 0) +
|
|
||||||
phase.realEstates.reduce((sum, re) => sum + re.amortization, 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 anyWorking = personInfos.some((p) => p.working);
|
||||||
|
const anyRetired = personInfos.some((p) => !p.working);
|
||||||
|
const type: PhaseType = anyWorking && anyRetired ? "MIXED" : anyWorking ? "ERWERB" : "PENSION";
|
||||||
|
|
||||||
const realEstates: RealEstateComputed[] = phase.realEstates.map((re) => {
|
// Maximale Dauer: bis zum naechsten Pensionsereignis einer noch erwerbenden Person.
|
||||||
const mortgages = computeMortgageYearly(re.mortgage, re.amortization, phase.durationYears);
|
const capsFromWorking = personInfos
|
||||||
const purchasePrice = floorToThousand(re.purchasePrice);
|
.filter((p) => p.working)
|
||||||
return {
|
.map((p) => retirementAge.get(p.personId)! - p.startAge)
|
||||||
id: re.id,
|
.filter((d) => d > 0);
|
||||||
name: re.name,
|
const maxDurationYears = capsFromWorking.length > 0 ? Math.min(...capsFromWorking) : null;
|
||||||
purchasePrice,
|
|
||||||
startNet: purchasePrice - mortgages[0],
|
const workingByPerson = new Map(personInfos.map((p) => [p.personId, p.working]));
|
||||||
endNet: purchasePrice - mortgages[phase.durationYears],
|
|
||||||
mortgages,
|
// --- Ausfalljahre der Erwerbsphasen aufsummieren ---
|
||||||
|
for (const e of plan.elements) {
|
||||||
|
if (e.category !== "AHV" || !e.ownerRole) continue;
|
||||||
|
const owner = personByRole(persons, e.ownerRole);
|
||||||
|
if (!owner || !workingByPerson.get(owner.id)) continue;
|
||||||
|
const gy = Math.max(0, Math.round(num(e.phaseValues[phase.id]?.gapYears)));
|
||||||
|
gapYearsByPerson.set(owner.id, (gapYearsByPerson.get(owner.id) ?? 0) + gy);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- AHV-Renten je pensionierter Person (mit Plafonierung) ---
|
||||||
|
const ahvUncapped = new Map<string, number>();
|
||||||
|
for (const e of plan.elements) {
|
||||||
|
if (e.category !== "AHV" || !e.ownerRole) continue;
|
||||||
|
const owner = personByRole(persons, e.ownerRole);
|
||||||
|
if (!owner || workingByPerson.get(owner.id)) continue; // nur pensionierte Personen
|
||||||
|
const gap = gapYearsByPerson.get(owner.id) ?? 0;
|
||||||
|
const factor = Math.max(0, (AHV_FULL_CONTRIBUTION_YEARS - gap) / AHV_FULL_CONTRIBUTION_YEARS);
|
||||||
|
ahvUncapped.set(owner.id, floorToThousand(AHV_MAX_ANNUAL_SINGLE * factor));
|
||||||
|
}
|
||||||
|
const ahvFinal = new Map(ahvUncapped);
|
||||||
|
if (household.householdType === "COUPLE" && ahvUncapped.size === 2) {
|
||||||
|
const sum = [...ahvUncapped.values()].reduce((a, b) => a + b, 0);
|
||||||
|
const cap = AHV_MAX_ANNUAL_SINGLE * AHV_COUPLE_CAP_FACTOR;
|
||||||
|
if (sum > cap && sum > 0) {
|
||||||
|
for (const [pid, v] of ahvUncapped) ahvFinal.set(pid, floorToThousand(v * (cap / sum)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Elemente dieser Phase berechnen ---
|
||||||
|
const elementsComputed: ElementPhaseComputed[] = [];
|
||||||
|
let incomeTotal = 0;
|
||||||
|
let expenseTotal = 0;
|
||||||
|
let quotaAllocated = 0;
|
||||||
|
let capitalUsed = 0;
|
||||||
|
|
||||||
|
const orderedElements = [...plan.elements].sort((a, b) => a.orderIndex - b.orderIndex);
|
||||||
|
for (const e of orderedElements) {
|
||||||
|
const carry = carries.get(e.id)!;
|
||||||
|
const pd = e.phaseValues[phase.id] ?? {};
|
||||||
|
const owner = e.ownerRole && e.ownerRole !== "HOUSEHOLD" ? personByRole(persons, e.ownerRole) : null;
|
||||||
|
const ownerWorking = owner ? workingByPerson.get(owner.id) ?? false : anyWorking;
|
||||||
|
|
||||||
|
const ec: ElementPhaseComputed = {
|
||||||
|
elementId: e.id,
|
||||||
|
category: e.category,
|
||||||
|
name: e.name,
|
||||||
|
ownerRole: e.ownerRole,
|
||||||
|
status: carry.status,
|
||||||
|
locked: carry.status !== "ACTIVE",
|
||||||
|
startValue: 0,
|
||||||
|
endValue: 0,
|
||||||
|
incomeContribution: 0,
|
||||||
|
expenseContribution: 0,
|
||||||
|
quotaUse: 0,
|
||||||
|
capitalUse: 0,
|
||||||
|
summary: "",
|
||||||
|
note: null,
|
||||||
};
|
};
|
||||||
});
|
|
||||||
|
|
||||||
const oneTimeNet = phase.oneTimeEvents.reduce(
|
if (carry.status === "SOLD") {
|
||||||
(sum, e) => sum + (e.type === "INCOME" ? e.amount : -e.amount),
|
ec.note = "Verkauft";
|
||||||
0
|
ec.summary = "Verkauft";
|
||||||
);
|
elementsComputed.push(ec);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (carry.status === "SETTLED" && e.category === "OTHER_DEBT") {
|
||||||
|
ec.note = "Getilgt";
|
||||||
|
ec.summary = "Getilgt";
|
||||||
|
elementsComputed.push(ec);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const startWealthNominal =
|
switch (e.category) {
|
||||||
securities.reduce((sum, s) => sum + s.startValue, 0) +
|
case "INCOME": {
|
||||||
realEstates.reduce((sum, re) => sum + re.startNet, 0);
|
const amount = floorToThousand(num(pd.amount));
|
||||||
|
ec.incomeContribution = amount;
|
||||||
|
incomeTotal += amount;
|
||||||
|
ec.summary = fmt(amount);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "EXPENSE": {
|
||||||
|
const amount = floorToThousand(num(pd.amount));
|
||||||
|
ec.expenseContribution = amount;
|
||||||
|
expenseTotal += amount;
|
||||||
|
ec.summary = fmt(amount);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "AHV": {
|
||||||
|
if (owner && !ownerWorking) {
|
||||||
|
const pension = ahvFinal.get(owner.id) ?? 0;
|
||||||
|
ec.incomeContribution = pension;
|
||||||
|
incomeTotal += pension;
|
||||||
|
ec.summary = `Rente ${fmt(pension)}`;
|
||||||
|
} else {
|
||||||
|
const gap = Math.max(0, Math.round(num(pd.gapYears)));
|
||||||
|
ec.summary = gap > 0 ? `${gap} Ausfalljahre` : "Keine Ausfalljahre";
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "PENSION_FUND": {
|
||||||
|
if (!ownerWorking && carry.pkPensionAnnual > 0) {
|
||||||
|
// Verrentetes PK-Kapital: jaehrliche Rente als Einkommen.
|
||||||
|
const pension = carry.pkPensionAnnual;
|
||||||
|
ec.incomeContribution = pension;
|
||||||
|
incomeTotal += pension;
|
||||||
|
ec.summary = `Rente ${fmt(pension)}`;
|
||||||
|
} else if (!ownerWorking) {
|
||||||
|
ec.note = "Vollstaendig bezogen";
|
||||||
|
ec.summary = "Bezogen";
|
||||||
|
} else {
|
||||||
|
const start = floorToThousand(num(pd.currentValue));
|
||||||
|
const contribution = floorToThousand(num(pd.annualContribution));
|
||||||
|
const r = num(pd.expectedReturn);
|
||||||
|
ec.startValue = start;
|
||||||
|
ec.endValue = growAsset(start, r, contribution, phase.durationYears);
|
||||||
|
ec.capitalUse = Math.max(0, start - carry.value);
|
||||||
|
capitalUsed += ec.capitalUse;
|
||||||
|
// PK-Beitraege zaehlen NICHT gegen die Sparquote (in Ausgaben enthalten).
|
||||||
|
ec.summary = fmt(ec.endValue);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "PILLAR_3A": {
|
||||||
|
if (!ownerWorking) {
|
||||||
|
ec.note = "Vollstaendig bezogen";
|
||||||
|
ec.summary = "Bezogen";
|
||||||
|
} else {
|
||||||
|
const start = floorToThousand(num(pd.currentValue));
|
||||||
|
const contribution = roundToHundred(num(pd.annualContribution));
|
||||||
|
const r = num(pd.expectedReturn);
|
||||||
|
ec.startValue = start;
|
||||||
|
ec.endValue = growAsset(start, r, contribution, phase.durationYears);
|
||||||
|
ec.capitalUse = Math.max(0, start - carry.value);
|
||||||
|
capitalUsed += ec.capitalUse;
|
||||||
|
ec.quotaUse = contribution; // zaehlt gegen die Sparquote
|
||||||
|
quotaAllocated += contribution;
|
||||||
|
ec.summary = fmt(ec.endValue);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "OTHER_ASSET": {
|
||||||
|
const start = floorToThousand(num(pd.startValue));
|
||||||
|
const contribution = floorToThousand(num(pd.annualContribution));
|
||||||
|
const r = num(pd.expectedReturn);
|
||||||
|
ec.startValue = start;
|
||||||
|
ec.capitalUse = Math.max(0, start - carry.value);
|
||||||
|
capitalUsed += ec.capitalUse;
|
||||||
|
// In Erwerbsphasen (Sparen) wird eingezahlt, in Verzehrphasen bezogen -- das
|
||||||
|
// Vorzeichen ergibt sich aus der Phasenquote (siehe unten). Hier immer als
|
||||||
|
// Beitrag verbucht; die Verzehr-Logik nutzt denselben Betrag als Bezug.
|
||||||
|
ec.quotaUse = contribution;
|
||||||
|
quotaAllocated += contribution;
|
||||||
|
ec.endValue = growAsset(start, r, contribution, phase.durationYears);
|
||||||
|
ec.summary = fmt(ec.endValue);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "REAL_ESTATE": {
|
||||||
|
const purchase = floorToThousand(num(pd.purchasePrice));
|
||||||
|
const mortgageStart = carry.hasCarry ? carry.mortgage : floorToThousand(num(pd.mortgage));
|
||||||
|
const amort = floorToThousand(num(pd.amortization));
|
||||||
|
const mortgageEnd = Math.max(0, mortgageStart - amort * phase.durationYears);
|
||||||
|
ec.startValue = purchase - mortgageStart;
|
||||||
|
ec.endValue = purchase - mortgageEnd;
|
||||||
|
if (!carry.hasCarry) {
|
||||||
|
ec.capitalUse = Math.max(0, purchase - mortgageStart); // Eigenkapital bei Neukauf
|
||||||
|
capitalUsed += ec.capitalUse;
|
||||||
|
}
|
||||||
|
carry.mortgage = mortgageEnd; // fuer Uebergang
|
||||||
|
ec.summary = fmt(ec.endValue);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "OTHER_DEBT": {
|
||||||
|
const owedStart = carry.hasCarry ? carry.owed : floorToThousand(num(pd.startValue));
|
||||||
|
const repay = floorToThousand(num(pd.annualRepayment));
|
||||||
|
const owedEnd = Math.max(0, owedStart - repay * phase.durationYears);
|
||||||
|
ec.startValue = -owedStart;
|
||||||
|
ec.endValue = -owedEnd;
|
||||||
|
carry.owed = owedEnd;
|
||||||
|
ec.summary = fmt(ec.endValue);
|
||||||
|
if (owedEnd === 0) ec.note = "Wird getilgt";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const endWealthNominal =
|
elementsComputed.push(ec);
|
||||||
securities.reduce((sum, s) => sum + s.endValue, 0) +
|
}
|
||||||
realEstates.reduce((sum, re) => sum + re.endNet, 0) +
|
|
||||||
oneTimeNet +
|
// --- Kern-Kennzahlen ---
|
||||||
(retirement?.lumpSumNetTotal ?? 0);
|
const quota = incomeTotal - expenseTotal;
|
||||||
|
const isConsumption = quota < 0;
|
||||||
|
// Sparphase: alles verteilt, wenn quotaAllocated == quota. Verzehrphase: gedeckt,
|
||||||
|
// wenn Bezuege (quotaAllocated) den Fehlbetrag decken.
|
||||||
|
const quotaTarget = Math.abs(quota);
|
||||||
|
const quotaComplete = Math.abs(quotaTarget - quotaAllocated) < 1;
|
||||||
|
|
||||||
|
const availableCapital = incomingCapital;
|
||||||
|
const availableCapitalUsed = capitalUsed;
|
||||||
|
const availableCapitalComplete =
|
||||||
|
availableCapital === null || Math.abs(availableCapital - availableCapitalUsed) < 1;
|
||||||
|
|
||||||
|
const incomplete = !quotaComplete || !availableCapitalComplete;
|
||||||
|
|
||||||
const inflationRate = phase.inflationRate ?? household.inflationRateDefault;
|
const inflationRate = phase.inflationRate ?? household.inflationRateDefault;
|
||||||
// TDD Kapitel 3.5: kumulierte Inflation ist ein Produkt ueber die Phasen (ein Faktor
|
cumulativeInflation = cumulativeInflation * (1 + inflationRate / 100);
|
||||||
// pro Phase), nicht ueber einzelne Jahre. Bewusst woertlich gemaess Spezifikation umgesetzt.
|
|
||||||
const cumulativeInflationEnd = cumulativeInflationStart * (1 + inflationRate / 100);
|
|
||||||
|
|
||||||
const yearlyNominal: number[] = [];
|
const startWealthNominal = elementsComputed.reduce((s, ec) => s + ec.startValue, 0);
|
||||||
for (let year = 1; year <= phase.durationYears; year++) {
|
const endWealthNominal = elementsComputed.reduce((s, ec) => s + ec.endValue, 0);
|
||||||
let value =
|
|
||||||
securities.reduce((sum, s) => sum + s.yearly[year], 0) +
|
|
||||||
realEstates.reduce((sum, re) => sum + (re.purchasePrice - re.mortgages[year]), 0);
|
|
||||||
if (year === phase.durationYears) {
|
|
||||||
// Einmalige Ereignisse und Kapitalbezuege schlagen erst am Ende der Phase zu
|
|
||||||
// Buche (siehe Phasenuebergang, TDD Kapitel 10). Immobilien-/Wertschriften-
|
|
||||||
// Verkaeufe wirken sich nur auf die naechste Phase aus (incomingCapital), nicht
|
|
||||||
// mehr auf das Endvermoegen dieser Phase selbst.
|
|
||||||
value += oneTimeNet + (retirement?.lumpSumNetTotal ?? 0);
|
|
||||||
}
|
|
||||||
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 {
|
result.push({
|
||||||
id: phase.id,
|
id: phase.id,
|
||||||
name: phase.name,
|
name: phase.name,
|
||||||
sequenceNumber: phase.sequenceNumber,
|
sequenceNumber: phase.sequenceNumber,
|
||||||
durationYears: phase.durationYears,
|
durationYears: phase.durationYears,
|
||||||
incomeFromEntries,
|
type,
|
||||||
|
persons: personInfos,
|
||||||
|
maxDurationYears,
|
||||||
|
incomeTotal,
|
||||||
expenseTotal,
|
expenseTotal,
|
||||||
retirement,
|
quota,
|
||||||
effectiveIncome,
|
isConsumption,
|
||||||
savingsQuota,
|
quotaAllocated,
|
||||||
allocatedSavings,
|
quotaComplete,
|
||||||
savingsWarning,
|
availableCapital,
|
||||||
securities,
|
availableCapitalUsed,
|
||||||
realEstates,
|
availableCapitalComplete,
|
||||||
oneTimeNet,
|
incomplete,
|
||||||
|
elements: elementsComputed,
|
||||||
startWealthNominal,
|
startWealthNominal,
|
||||||
endWealthNominal,
|
endWealthNominal,
|
||||||
cumulativeInflationStart,
|
cumulativeInflationEnd: cumulativeInflation,
|
||||||
cumulativeInflationEnd,
|
endWealthReal: endWealthNominal / cumulativeInflation,
|
||||||
startWealthReal: startWealthNominal / cumulativeInflationStart,
|
});
|
||||||
endWealthReal: endWealthNominal / cumulativeInflationEnd,
|
|
||||||
yearlyNominal,
|
// --- Uebergang zur naechsten Phase: Carry aktualisieren + Startkapital berechnen ---
|
||||||
yearlyReal,
|
let outgoing = 0;
|
||||||
ages,
|
for (const e of orderedElements) {
|
||||||
};
|
const carry = carries.get(e.id)!;
|
||||||
|
const ec = elementsComputed.find((x) => x.elementId === e.id)!;
|
||||||
|
const td = e.transitionValues[phase.id] ?? {};
|
||||||
|
const owner = e.ownerRole && e.ownerRole !== "HOUSEHOLD" ? personByRole(persons, e.ownerRole) : null;
|
||||||
|
const ownerRetiresNext =
|
||||||
|
!!owner && !!nextPhase && workingByPerson.get(owner.id) === true && retiresInPhase(owner.id, nextPhase, persons, retirementAge, yearsBefore + phase.durationYears);
|
||||||
|
|
||||||
|
if (carry.status !== "ACTIVE") {
|
||||||
|
carry.hasCarry = true;
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function computePlan(plan: PlanInput, household: HouseholdInput): PlanComputed {
|
switch (e.category) {
|
||||||
const orderedPhases = [...plan.phases].sort((a, b) => a.sequenceNumber - b.sequenceNumber);
|
case "PENSION_FUND": {
|
||||||
|
if (ownerRetiresNext) {
|
||||||
|
const value = ec.endValue;
|
||||||
|
const mode = td.payoutMode ?? "PENSION";
|
||||||
|
if (mode === "CAPITAL") {
|
||||||
|
const net = floorToThousand(value * (1 - num(td.capitalTaxRate) / 100));
|
||||||
|
outgoing += net;
|
||||||
|
carry.value = 0;
|
||||||
|
carry.pkPensionAnnual = 0;
|
||||||
|
} else if (mode === "PENSION") {
|
||||||
|
carry.pkPensionAnnual = floorToThousand((value * num(td.conversionRate)) / 100);
|
||||||
|
carry.value = 0;
|
||||||
|
} else {
|
||||||
|
const capital = Math.min(value, floorToThousand(num(td.capitalAmount)));
|
||||||
|
const net = floorToThousand(capital * (1 - num(td.capitalTaxRate) / 100));
|
||||||
|
outgoing += net;
|
||||||
|
carry.pkPensionAnnual = floorToThousand(((value - capital) * num(td.conversionRate)) / 100);
|
||||||
|
carry.value = 0;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const withdrawal = Math.min(ec.endValue, floorToThousand(num(td.withdrawal)));
|
||||||
|
carry.value = ec.endValue - withdrawal;
|
||||||
|
outgoing += withdrawal;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "PILLAR_3A": {
|
||||||
|
if (ownerRetiresNext) {
|
||||||
|
const net = floorToThousand(ec.endValue * (1 - num(td.capitalTaxRate) / 100));
|
||||||
|
outgoing += net;
|
||||||
|
carry.value = 0;
|
||||||
|
} else {
|
||||||
|
const withdrawal = Math.min(ec.endValue, floorToThousand(num(td.withdrawal)));
|
||||||
|
carry.value = ec.endValue - withdrawal;
|
||||||
|
outgoing += withdrawal;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "OTHER_ASSET": {
|
||||||
|
if (td.decision === "SELL") {
|
||||||
|
outgoing += ec.endValue;
|
||||||
|
carry.status = "SOLD";
|
||||||
|
} else {
|
||||||
|
carry.value = ec.endValue;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "REAL_ESTATE": {
|
||||||
|
if (td.decision === "SELL") {
|
||||||
|
const purchase = floorToThousand(num(e.phaseValues[phase.id]?.purchasePrice));
|
||||||
|
const salePrice = floorToThousand(num(td.salePrice));
|
||||||
|
const gain = Math.max(0, salePrice - purchase);
|
||||||
|
const tax = gain * (num(td.saleTaxRate) / 100);
|
||||||
|
outgoing += floorToThousand(salePrice - carry.mortgage - tax);
|
||||||
|
carry.status = "SOLD";
|
||||||
|
}
|
||||||
|
// HOLD: carry.mortgage bereits gesetzt.
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "OTHER_DEBT": {
|
||||||
|
const immediate = Math.min(carry.owed, floorToThousand(num(td.immediateRepayment)));
|
||||||
|
if (immediate > 0) {
|
||||||
|
carry.owed = Math.max(0, carry.owed - immediate);
|
||||||
|
outgoing -= immediate; // sofortige Tilgung mindert das verfuegbare Kapital
|
||||||
|
}
|
||||||
|
if (carry.owed === 0) carry.status = "SETTLED";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
carry.hasCarry = true;
|
||||||
|
}
|
||||||
|
|
||||||
let cumulativeInflation = 1;
|
incomingCapital = nextPhase ? floorToThousand(outgoing) : null;
|
||||||
let yearsBefore = 0;
|
|
||||||
const phases: PhaseComputed[] = [];
|
|
||||||
for (const phase of orderedPhases) {
|
|
||||||
const computed = computePhase(phase, household, cumulativeInflation, yearsBefore);
|
|
||||||
cumulativeInflation = computed.cumulativeInflationEnd;
|
|
||||||
yearsBefore += phase.durationYears;
|
yearsBefore += phase.durationYears;
|
||||||
phases.push(computed);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const nachlass = phases.length > 0 ? phases[phases.length - 1].endWealthNominal : 0;
|
const nachlass = result.length > 0 ? result[result.length - 1].endWealthNominal : 0;
|
||||||
const totalSavingsWarnings = phases.filter((p) => p.savingsWarning).length;
|
return { phases: result, nachlass };
|
||||||
|
|
||||||
return { phases, nachlass, totalSavingsWarnings };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function planToCsv(plan: PlanInput, planComputed: PlanComputed): string {
|
function personByRole(persons: { id: string; role: PersonRole }[], role: string) {
|
||||||
|
return persons.find((p) => p.role === role) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prueft, ob eine Person in der gegebenen Phase (mit gegebenem Jahres-Offset) pensioniert ist,
|
||||||
|
// obwohl sie in der Vorphase noch erwerbend war.
|
||||||
|
function retiresInPhase(
|
||||||
|
personId: string,
|
||||||
|
phase: { durationYears: number },
|
||||||
|
persons: { id: string; role: PersonRole; age: number }[],
|
||||||
|
retirementAge: Map<string, number>,
|
||||||
|
yearsBeforeNext: number
|
||||||
|
): boolean {
|
||||||
|
const p = persons.find((x) => x.id === personId);
|
||||||
|
if (!p) return false;
|
||||||
|
const ra = retirementAge.get(personId)!;
|
||||||
|
const startAgeNext = p.age + yearsBeforeNext;
|
||||||
|
return startAgeNext >= ra;
|
||||||
|
}
|
||||||
|
|
||||||
|
function roundToHundred(v: number): number {
|
||||||
|
return Math.round((v || 0) / 100) * 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmt(v: number): string {
|
||||||
|
const rounded = Math.round(v || 0);
|
||||||
|
const sign = rounded < 0 ? "-" : "";
|
||||||
|
return sign + Math.abs(rounded).toString().replace(/\B(?=(\d{3})+(?!\d))/g, "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
// CSV-Export (eine Zeile pro Lebensphase, Kernkennzahlen).
|
||||||
|
export function planToCsv(plan: PlanInput, computed: PlanComputed): string {
|
||||||
const header = [
|
const header = [
|
||||||
"Phase",
|
"Phase",
|
||||||
"Dauer (Jahre)",
|
"Typ",
|
||||||
"Startvermoegen (nominal)",
|
"Dauer",
|
||||||
"Endvermoegen (nominal)",
|
|
||||||
"Endvermoegen (real)",
|
|
||||||
"Einkommen",
|
"Einkommen",
|
||||||
"Ausgaben",
|
"Ausgaben",
|
||||||
"Sparquote",
|
"Spar-/Verzehrquote",
|
||||||
"Verplante Sparbeitraege",
|
"Verfuegbares Kapital",
|
||||||
"Einmalige Ereignisse (netto)",
|
"Endvermoegen (nominal)",
|
||||||
|
"Endvermoegen (real)",
|
||||||
];
|
];
|
||||||
const rows = planComputed.phases.map((p) => [
|
const rows = computed.phases.map((p) => [
|
||||||
p.name,
|
p.name,
|
||||||
|
p.type,
|
||||||
String(p.durationYears),
|
String(p.durationYears),
|
||||||
p.startWealthNominal.toFixed(2),
|
p.incomeTotal.toFixed(0),
|
||||||
p.endWealthNominal.toFixed(2),
|
p.expenseTotal.toFixed(0),
|
||||||
p.endWealthReal.toFixed(2),
|
p.quota.toFixed(0),
|
||||||
p.effectiveIncome.toFixed(2),
|
p.availableCapital === null ? "n.a." : p.availableCapital.toFixed(0),
|
||||||
p.expenseTotal.toFixed(2),
|
p.endWealthNominal.toFixed(0),
|
||||||
p.savingsQuota.toFixed(2),
|
p.endWealthReal.toFixed(0),
|
||||||
p.allocatedSavings.toFixed(2),
|
|
||||||
p.oneTimeNet.toFixed(2),
|
|
||||||
]);
|
]);
|
||||||
return [header, ...rows].map((r) => r.join(";")).join("\n");
|
return [header, ...rows].map((r) => r.join(";")).join("\n");
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-5
@@ -1,7 +1,18 @@
|
|||||||
// AHV-Maximalrente (Einzelperson, CHF/Jahr). Aendert sich periodisch durch Anpassungen
|
// Konfigurierbare Systemparameter (Stand 2026). Aendern sich periodisch durch
|
||||||
// des Bundes -- deshalb hier als einzelner konfigurierbarer Systemparameter gefuehrt
|
// Anpassungen des Bundes -- deshalb hier zentral gefuehrt, nicht im Code verteilt.
|
||||||
// (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).
|
// Maximale einfache AHV-Altersrente pro Jahr, inkl. 13. Rente (2'520/Monat × 13 = 32'760).
|
||||||
|
// Quelle: BSV / AHV-IV 2026.
|
||||||
|
export const AHV_MAX_ANNUAL_SINGLE = 32760;
|
||||||
|
|
||||||
|
// Ehepaar-Plafonierung: die Summe beider Einzelrenten ist auf 150% der Einzel-
|
||||||
|
// Maximalrente begrenzt. Bei Ueberschreitung werden beide Renten proportional gekuerzt.
|
||||||
export const AHV_COUPLE_CAP_FACTOR = 1.5;
|
export const AHV_COUPLE_CAP_FACTOR = 1.5;
|
||||||
|
|
||||||
|
// Volle Beitragsdauer fuer eine ungekuerzte AHV-Rente (Rentenskala 44). Pro fehlendes
|
||||||
|
// Beitragsjahr (Ausfalljahr) wird die Rente um 1/44 gekuerzt.
|
||||||
|
export const AHV_FULL_CONTRIBUTION_YEARS = 44;
|
||||||
|
|
||||||
|
// Maximaler jaehrlicher Saeule-3a-Beitrag fuer PK-Versicherte (2026). Wird in
|
||||||
|
// 100er-Schritten erfasst (nicht 1'000er wie andere Betraege).
|
||||||
|
export const PILLAR_3A_MAX_ANNUAL = 7258;
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
// Typ- und Validierungs-Layer fuer die finanziellen Elemente. Die kategorie- und
|
||||||
|
// kontextspezifischen Felder liegen in der DB als JSON; hier werden sie typisiert und
|
||||||
|
// (an der API-Grenze) mit Zod validiert.
|
||||||
|
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export type ElementCategory =
|
||||||
|
| "INCOME"
|
||||||
|
| "EXPENSE"
|
||||||
|
| "AHV"
|
||||||
|
| "PENSION_FUND"
|
||||||
|
| "PILLAR_3A"
|
||||||
|
| "REAL_ESTATE"
|
||||||
|
| "OTHER_ASSET"
|
||||||
|
| "OTHER_DEBT";
|
||||||
|
|
||||||
|
export type OwnerRole = "PERSON_A" | "PERSON_B" | "HOUSEHOLD";
|
||||||
|
|
||||||
|
// Kategorien, deren Element zwingend genau einer Person zugeordnet ist.
|
||||||
|
export const PERSON_ONLY_CATEGORIES: ElementCategory[] = ["INCOME", "AHV", "PENSION_FUND", "PILLAR_3A"];
|
||||||
|
|
||||||
|
// Kategorien, die gemeinsam ODER pro Person erfasst werden koennen.
|
||||||
|
export const OWNER_OPTIONAL_CATEGORIES: ElementCategory[] = ["EXPENSE", "REAL_ESTATE", "OTHER_ASSET", "OTHER_DEBT"];
|
||||||
|
|
||||||
|
export const CATEGORY_LABELS: Record<ElementCategory, string> = {
|
||||||
|
INCOME: "Einkommen",
|
||||||
|
EXPENSE: "Ausgaben",
|
||||||
|
AHV: "AHV",
|
||||||
|
PENSION_FUND: "Pensionskasse",
|
||||||
|
PILLAR_3A: "Saeule 3a",
|
||||||
|
REAL_ESTATE: "Immobilie",
|
||||||
|
OTHER_ASSET: "Sonstiges Vermoegen",
|
||||||
|
OTHER_DEBT: "Sonstige Schulden",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Reihenfolge der Kategorien in der Matrix (Gruppierung der Zeilen).
|
||||||
|
export const CATEGORY_ORDER: ElementCategory[] = [
|
||||||
|
"INCOME",
|
||||||
|
"EXPENSE",
|
||||||
|
"AHV",
|
||||||
|
"PENSION_FUND",
|
||||||
|
"PILLAR_3A",
|
||||||
|
"REAL_ESTATE",
|
||||||
|
"OTHER_ASSET",
|
||||||
|
"OTHER_DEBT",
|
||||||
|
];
|
||||||
|
|
||||||
|
// --- Roh-Payloads (JSON in der DB) ---
|
||||||
|
// Bewusst tolerant getippt (alle Felder optional): die Berechnung liest defensiv mit
|
||||||
|
// Defaults, das UI zeigt je nach Kontext nur die relevanten Felder.
|
||||||
|
|
||||||
|
export interface PhaseData {
|
||||||
|
// INCOME / EXPENSE
|
||||||
|
amount?: number;
|
||||||
|
// AHV
|
||||||
|
gapYears?: number;
|
||||||
|
// PENSION_FUND / PILLAR_3A / OTHER_ASSET
|
||||||
|
currentValue?: number;
|
||||||
|
startValue?: number;
|
||||||
|
expectedReturn?: number;
|
||||||
|
annualContribution?: number;
|
||||||
|
// REAL_ESTATE
|
||||||
|
purchasePrice?: number;
|
||||||
|
mortgage?: number;
|
||||||
|
amortization?: number;
|
||||||
|
// OTHER_DEBT
|
||||||
|
annualRepayment?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TransitionDecision = "HOLD" | "SELL";
|
||||||
|
export type PkPayoutMode = "CAPITAL" | "PENSION" | "COMBI";
|
||||||
|
|
||||||
|
export interface TransitionData {
|
||||||
|
// PENSION_FUND / PILLAR_3A (normaler Uebergang)
|
||||||
|
withdrawal?: number;
|
||||||
|
// PENSION_FUND (Pensions-Uebergang)
|
||||||
|
payoutMode?: PkPayoutMode;
|
||||||
|
capitalAmount?: number;
|
||||||
|
conversionRate?: number;
|
||||||
|
// PENSION_FUND (Kapital) / PILLAR_3A (Pensions-Uebergang) / REAL_ESTATE
|
||||||
|
capitalTaxRate?: number;
|
||||||
|
saleTaxRate?: number;
|
||||||
|
// REAL_ESTATE / OTHER_ASSET
|
||||||
|
decision?: TransitionDecision;
|
||||||
|
salePrice?: number;
|
||||||
|
// OTHER_DEBT
|
||||||
|
immediateRepayment?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Zod-Schemas (nachsichtig: unbekannte Felder werden verworfen) ---
|
||||||
|
|
||||||
|
const nonNeg = z.number().min(0);
|
||||||
|
|
||||||
|
export const phaseDataSchema = z
|
||||||
|
.object({
|
||||||
|
amount: nonNeg.optional(),
|
||||||
|
gapYears: z.number().int().min(0).optional(),
|
||||||
|
currentValue: nonNeg.optional(),
|
||||||
|
startValue: nonNeg.optional(),
|
||||||
|
expectedReturn: z.number().min(-50).max(100).optional(),
|
||||||
|
annualContribution: nonNeg.optional(),
|
||||||
|
purchasePrice: nonNeg.optional(),
|
||||||
|
mortgage: nonNeg.optional(),
|
||||||
|
amortization: nonNeg.optional(),
|
||||||
|
annualRepayment: nonNeg.optional(),
|
||||||
|
})
|
||||||
|
.strip();
|
||||||
|
|
||||||
|
export const transitionDataSchema = z
|
||||||
|
.object({
|
||||||
|
withdrawal: nonNeg.optional(),
|
||||||
|
payoutMode: z.enum(["CAPITAL", "PENSION", "COMBI"]).optional(),
|
||||||
|
capitalAmount: nonNeg.optional(),
|
||||||
|
conversionRate: z.number().min(0).max(20).optional(),
|
||||||
|
capitalTaxRate: z.number().min(0).max(100).optional(),
|
||||||
|
saleTaxRate: z.number().min(0).max(100).optional(),
|
||||||
|
decision: z.enum(["HOLD", "SELL"]).optional(),
|
||||||
|
salePrice: nonNeg.optional(),
|
||||||
|
immediateRepayment: nonNeg.optional(),
|
||||||
|
})
|
||||||
|
.strip();
|
||||||
|
|
||||||
|
export function num(value: number | undefined | null, fallback = 0): number {
|
||||||
|
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
||||||
|
}
|
||||||
+41
-60
@@ -1,25 +1,18 @@
|
|||||||
import { Prisma } from "@/generated/prisma/client";
|
import { Prisma } from "@/generated/prisma/client";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
|
import { phaseDataSchema, transitionDataSchema } from "@/lib/elements";
|
||||||
|
import type { PhaseData, TransitionData } from "@/lib/elements";
|
||||||
import type { HouseholdInput, PlanInput } from "@/lib/types";
|
import type { 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 = {
|
export const planInclude = {
|
||||||
phases: {
|
phases: { orderBy: { sequenceNumber: "asc" } },
|
||||||
include: phaseInclude,
|
elements: {
|
||||||
orderBy: { sequenceNumber: "asc" },
|
orderBy: { orderIndex: "asc" },
|
||||||
|
include: { phaseValues: true, transitionValues: true },
|
||||||
},
|
},
|
||||||
} satisfies Prisma.PlanInclude;
|
} satisfies Prisma.PlanInclude;
|
||||||
|
|
||||||
export type PlanWithRelations = Prisma.PlanGetPayload<{ include: typeof 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 type HouseholdWithPersons = Prisma.HouseholdGetPayload<{ include: { persons: true } }>;
|
||||||
|
|
||||||
export function toHouseholdInput(household: HouseholdWithPersons): HouseholdInput {
|
export function toHouseholdInput(household: HouseholdWithPersons): HouseholdInput {
|
||||||
@@ -36,63 +29,44 @@ export function toHouseholdInput(household: HouseholdWithPersons): HouseholdInpu
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parsePhaseData(raw: unknown): PhaseData {
|
||||||
|
const parsed = phaseDataSchema.safeParse(raw);
|
||||||
|
return parsed.success ? parsed.data : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTransitionData(raw: unknown): TransitionData {
|
||||||
|
const parsed = transitionDataSchema.safeParse(raw);
|
||||||
|
return parsed.success ? parsed.data : {};
|
||||||
|
}
|
||||||
|
|
||||||
export function toPlanInput(plan: PlanWithRelations): PlanInput {
|
export function toPlanInput(plan: PlanWithRelations): PlanInput {
|
||||||
return {
|
return {
|
||||||
id: plan.id,
|
id: plan.id,
|
||||||
name: plan.name,
|
name: plan.name,
|
||||||
parentPlanId: plan.parentPlanId,
|
retirementAgeA: plan.retirementAgeA,
|
||||||
branchFromPhaseId: plan.branchFromPhaseId,
|
retirementAgeB: plan.retirementAgeB,
|
||||||
phases: plan.phases.map((phase) => ({
|
phases: plan.phases.map((phase) => ({
|
||||||
id: phase.id,
|
id: phase.id,
|
||||||
sequenceNumber: phase.sequenceNumber,
|
sequenceNumber: phase.sequenceNumber,
|
||||||
name: phase.name,
|
name: phase.name,
|
||||||
durationYears: phase.durationYears,
|
durationYears: phase.durationYears,
|
||||||
inflationRate: phase.inflationRate,
|
inflationRate: phase.inflationRate,
|
||||||
incomeMode: phase.incomeMode,
|
})),
|
||||||
incomingCapital: phase.incomingCapital,
|
elements: plan.elements.map((e) => {
|
||||||
incomeEntries: phase.incomeEntries.map((e) => ({
|
const phaseValues: Record<string, PhaseData> = {};
|
||||||
|
for (const pv of e.phaseValues) phaseValues[pv.phaseId] = parsePhaseData(pv.data);
|
||||||
|
const transitionValues: Record<string, TransitionData> = {};
|
||||||
|
for (const tv of e.transitionValues) transitionValues[tv.fromPhaseId] = parseTransitionData(tv.data);
|
||||||
|
return {
|
||||||
id: e.id,
|
id: e.id,
|
||||||
personId: e.personId,
|
category: e.category,
|
||||||
label: e.label,
|
name: e.name,
|
||||||
amount: e.amount,
|
ownerRole: e.ownerRole,
|
||||||
})),
|
orderIndex: e.orderIndex,
|
||||||
expenseEntries: phase.expenseEntries.map((e) => ({
|
phaseValues,
|
||||||
id: e.id,
|
transitionValues,
|
||||||
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,
|
|
||||||
carriedBaseValue: s.carriedBaseValue,
|
|
||||||
})),
|
|
||||||
realEstates: phase.realEstates.map((re) => ({
|
|
||||||
id: re.id,
|
|
||||||
name: re.name,
|
|
||||||
purchasePrice: re.purchasePrice,
|
|
||||||
mortgage: re.mortgage,
|
|
||||||
amortization: re.amortization,
|
|
||||||
})),
|
|
||||||
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,
|
|
||||||
})),
|
|
||||||
})),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,7 +75,7 @@ export async function getHouseholdOrNull(userId: string): Promise<HouseholdWithP
|
|||||||
return prisma.household.findFirst({ where: { userId }, include: { persons: true } });
|
return prisma.household.findFirst({ where: { userId }, include: { persons: true } });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Laedt einen Plan inkl. aller Phasen, aber nur wenn er dem Benutzer gehoert.
|
// Laedt einen Plan inkl. Phasen + Elemente, aber nur wenn er dem Benutzer gehoert.
|
||||||
export async function getOwnedPlan(planId: string, userId: string) {
|
export async function getOwnedPlan(planId: string, userId: string) {
|
||||||
return prisma.plan.findFirst({
|
return prisma.plan.findFirst({
|
||||||
where: { id: planId, household: { userId } },
|
where: { id: planId, household: { userId } },
|
||||||
@@ -115,3 +89,10 @@ export async function getOwnedPhase(phaseId: string, userId: string) {
|
|||||||
where: { id: phaseId, plan: { household: { userId } } },
|
where: { id: phaseId, plan: { household: { userId } } },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Laedt ein Element (Basisdaten), aber nur wenn es dem Benutzer gehoert.
|
||||||
|
export async function getOwnedElement(elementId: string, userId: string) {
|
||||||
|
return prisma.financialElement.findFirst({
|
||||||
|
where: { id: elementId, plan: { household: { userId } } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
+19
-70
@@ -1,19 +1,16 @@
|
|||||||
// Domain-Typen fuer die Berechnungslogik (lib/calculations.ts) und die API-Payloads.
|
// Domain-Typen fuer Berechnungslogik und API-Payloads. Entkoppelt von den generierten
|
||||||
// Bewusst von den generierten Prisma-Typen entkoppelt, damit die Berechnungslogik
|
// Prisma-Typen, damit die Berechnung unabhaengig testbar bleibt.
|
||||||
// unabhaengig von der konkreten DB-Repraesentation testbar bleibt.
|
|
||||||
|
import type { ElementCategory, OwnerRole, PhaseData, TransitionData } from "@/lib/elements";
|
||||||
|
|
||||||
export type HouseholdType = "SINGLE" | "COUPLE";
|
export type HouseholdType = "SINGLE" | "COUPLE";
|
||||||
export type PersonRole = "PERSON_A" | "PERSON_B";
|
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 {
|
export interface PersonInput {
|
||||||
id: string;
|
id: string;
|
||||||
role: PersonRole;
|
role: PersonRole;
|
||||||
age: number;
|
age: number;
|
||||||
|
// Bereits aufgeloestes Pensionsalter (Plan-Override oder Profil-Default).
|
||||||
retirementAge: number;
|
retirementAge: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,78 +21,30 @@ export interface HouseholdInput {
|
|||||||
persons: PersonInput[];
|
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;
|
|
||||||
// Baseline-Wert bei automatischer Uebernahme aus der Vorphase (0 bei manuell angelegten
|
|
||||||
// Wertschriften). Siehe PhaseInput.incomingCapital fuer den Kontext.
|
|
||||||
carriedBaseValue: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RealEstateInput {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
purchasePrice: number;
|
|
||||||
mortgage: number;
|
|
||||||
amortization: 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 {
|
export interface PhaseInput {
|
||||||
id: string;
|
id: string;
|
||||||
sequenceNumber: number;
|
sequenceNumber: number;
|
||||||
name: string;
|
name: string;
|
||||||
durationYears: number;
|
durationYears: number;
|
||||||
inflationRate: number | null;
|
inflationRate: number | null;
|
||||||
incomeMode: IncomeMode;
|
}
|
||||||
// Aus Verkaeufen im Uebergang aus der Vorphase verfuegbares Startkapital (automatisch
|
|
||||||
// gesetzt beim Speichern des Uebergangs der Vorphase).
|
export interface ElementInput {
|
||||||
incomingCapital: number;
|
id: string;
|
||||||
incomeEntries: IncomeEntryInput[];
|
category: ElementCategory;
|
||||||
expenseEntries: ExpenseEntryInput[];
|
name: string;
|
||||||
securities: SecurityInput[];
|
ownerRole: OwnerRole | null;
|
||||||
realEstates: RealEstateInput[];
|
orderIndex: number;
|
||||||
oneTimeEvents: OneTimeEventInput[];
|
// Werte je Phase (Key = phaseId) bzw. je Uebergang (Key = fromPhaseId).
|
||||||
retirementInfos: RetirementInfoInput[];
|
phaseValues: Record<string, PhaseData>;
|
||||||
|
transitionValues: Record<string, TransitionData>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PlanInput {
|
export interface PlanInput {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
parentPlanId: string | null;
|
retirementAgeA: number | null;
|
||||||
branchFromPhaseId: string | null;
|
retirementAgeB: number | null;
|
||||||
phases: PhaseInput[];
|
phases: PhaseInput[];
|
||||||
|
elements: ElementInput[];
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user