V3: 3 Farbschemata, Grundprofil auf Plan-Ebene, Erstellungs-Popups, integer-Zahlenfelder mit Beschleunigungs-Spinner, Carry-Forward des Zielwerts, gefuehrter Uebergang
Deploy App / deploy (push) Successful in 1m51s
Deploy App / deploy (push) Successful in 1m51s
- Theming: semantische CSS-Tokens + 3 waehlbare Schemata (Hell/Dunkel/Warm/Sunset), Umschalter im Profil-Menue, FOUC-frei via Inline-Script, localStorage; Klassen-Sweep aller Komponenten, Recharts aus Tokens - Datenmodell: Household entfaellt; Plan traegt Haushaltsform/Personen/Inflation selbst (Person -> planId, Plan -> userId); destruktive Migration (TRUNCATE); Onboarding/HouseholdSettings entfernt; Plan-Erstellung & -Einstellungen mit Profilfeldern - Popups: Element-Erstellung mit Inline-Feldern (geteilte ElementPhaseFields/ElementTransitionFields), Phase- und Plan-Popups mit Direkteingabe - Zahlenfelder: 1'000er-Runden entfernt (floorToThousand/roundToHundred weg), integer MoneyInput mit beschleunigendem Press-and-Hold-Spinner, 0-Bug-Fix, harte Live-Caps - Quote: Amortisation + Tilgung neu quotenwirksam; Restquote sichtbar (sinkt beim Verteilen); Invest-Deckel = verfuegbares Kapital + fortgeschriebener Zielwert - Carry-Forward: Startwert der Folgephase = Zielwert der Vorphase minus Uebergangs-Bezug (live abgeleitet); optionale Zusatzinvestition aus verfuegbarem Kapital - Matrix: Zelle zeigt Start -> Ziel; Uebergangs-Spaltenkopf mit "n offen"-Badge + gefuehrtem Pruef-Panel Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,40 @@
|
|||||||
|
-- V3-Rework: Das Grundprofil (Haushaltsform, Personen, Inflation) wandert vom Household
|
||||||
|
-- auf die Plan-Ebene. Bestehende Plaene/Personen sind ohne Household-Bezug bzw. ohne die
|
||||||
|
-- neuen Pflichtfelder nicht migrierbar -- die (disponiblen) Testdaten werden verworfen.
|
||||||
|
TRUNCATE TABLE "Person", "Plan" CASCADE;
|
||||||
|
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE "Household" DROP CONSTRAINT "Household_userId_fkey";
|
||||||
|
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE "Person" DROP CONSTRAINT "Person_householdId_fkey";
|
||||||
|
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE "Plan" DROP CONSTRAINT "Plan_householdId_fkey";
|
||||||
|
|
||||||
|
-- DropIndex
|
||||||
|
DROP INDEX "Person_householdId_role_key";
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Person" DROP COLUMN "householdId",
|
||||||
|
ADD COLUMN "planId" TEXT NOT NULL;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Plan" DROP COLUMN "householdId",
|
||||||
|
DROP COLUMN "retirementAgeA",
|
||||||
|
DROP COLUMN "retirementAgeB",
|
||||||
|
ADD COLUMN "householdType" "HouseholdType" NOT NULL,
|
||||||
|
ADD COLUMN "inflationRateDefault" DOUBLE PRECISION NOT NULL,
|
||||||
|
ADD COLUMN "userId" TEXT NOT NULL;
|
||||||
|
|
||||||
|
-- DropTable
|
||||||
|
DROP TABLE "Household";
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Person_planId_role_key" ON "Person"("planId", "role");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Person" ADD CONSTRAINT "Person_planId_fkey" FOREIGN KEY ("planId") REFERENCES "Plan"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Plan" ADD CONSTRAINT "Plan_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
+16
-26
@@ -4,6 +4,10 @@
|
|||||||
// ein Werte-Datensatz (ElementPhaseValue) und je Uebergang ein Entscheid-Datensatz
|
// ein Werte-Datensatz (ElementPhaseValue) und je Uebergang ein Entscheid-Datensatz
|
||||||
// (ElementTransitionValue). Die kategorie-/kontextspezifischen Felder liegen als JSON,
|
// (ElementTransitionValue). Die kategorie-/kontextspezifischen Felder liegen als JSON,
|
||||||
// validiert und typisiert in der Applikationsschicht (lib/elements.ts).
|
// validiert und typisiert in der Applikationsschicht (lib/elements.ts).
|
||||||
|
//
|
||||||
|
// Rework 07/2026 (V3): Das Grundprofil (Haushaltsform, Personen, Inflation) wurde vom
|
||||||
|
// frueheren Household auf die PLAN-Ebene verschoben. Jeder Plan ist selbsttragend und
|
||||||
|
// definiert seine eigenen Personen (Alter, Pensionsalter) und Inflationsannahme.
|
||||||
|
|
||||||
generator client {
|
generator client {
|
||||||
provider = "prisma-client"
|
provider = "prisma-client"
|
||||||
@@ -20,7 +24,7 @@ model User {
|
|||||||
passwordHash String
|
passwordHash String
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
households Household[]
|
plans Plan[]
|
||||||
}
|
}
|
||||||
|
|
||||||
enum HouseholdType {
|
enum HouseholdType {
|
||||||
@@ -50,42 +54,27 @@ enum ElementCategory {
|
|||||||
OTHER_DEBT
|
OTHER_DEBT
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ein Haushalt (1 oder 2 Personen), gehoert genau einem Benutzer.
|
// Einzelperson eines Plans. retirementAge ist das (plan-eigene) Pensionsalter.
|
||||||
model Household {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
userId String
|
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
||||||
householdType HouseholdType
|
|
||||||
inflationRateDefault Float
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
|
|
||||||
persons Person[]
|
|
||||||
plans Plan[]
|
|
||||||
}
|
|
||||||
|
|
||||||
// Einzelperson im Haushalt. retirementAge ist die Standard-Annahme (im Plan uebersteuerbar).
|
|
||||||
model Person {
|
model Person {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
householdId String
|
planId String
|
||||||
household Household @relation(fields: [householdId], references: [id], onDelete: Cascade)
|
plan Plan @relation(fields: [planId], references: [id], onDelete: Cascade)
|
||||||
role PersonRole
|
role PersonRole
|
||||||
age Int
|
age Int
|
||||||
retirementAge Int
|
retirementAge Int
|
||||||
|
|
||||||
@@unique([householdId, role])
|
@@unique([planId, role])
|
||||||
}
|
}
|
||||||
|
|
||||||
// Eine vollstaendige Phasenkette; kann Szenario eines anderen Plans sein.
|
// Eine vollstaendige Phasenkette; selbsttragend inkl. Grundprofil (Haushaltsform,
|
||||||
// retirementAgeA/B uebersteuern das Pensionsalter der jeweiligen Person NUR fuer diesen
|
// Personen, Inflationsannahme). Kann Szenario eines anderen Plans sein (Deep-Copy).
|
||||||
// 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
|
userId String
|
||||||
household Household @relation(fields: [householdId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
name String
|
name String
|
||||||
retirementAgeA Int?
|
householdType HouseholdType
|
||||||
retirementAgeB Int?
|
inflationRateDefault Float
|
||||||
|
|
||||||
parentPlanId String?
|
parentPlanId String?
|
||||||
parentPlan Plan? @relation("PlanScenarios", fields: [parentPlanId], references: [id], onDelete: SetNull)
|
parentPlan Plan? @relation("PlanScenarios", fields: [parentPlanId], references: [id], onDelete: SetNull)
|
||||||
@@ -95,6 +84,7 @@ model Plan {
|
|||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
persons Person[]
|
||||||
phases Phase[]
|
phases Phase[]
|
||||||
elements FinancialElement[]
|
elements FinancialElement[]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,110 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { prisma } from "@/lib/db";
|
|
||||||
import { getHouseholdOrNull, toHouseholdInput } from "@/lib/queries";
|
|
||||||
import { getCurrentUserId } from "@/lib/session";
|
|
||||||
|
|
||||||
const personSchema = z.object({
|
|
||||||
role: z.enum(["PERSON_A", "PERSON_B"]),
|
|
||||||
age: z.number().int().min(0).max(120),
|
|
||||||
retirementAge: z.number().int().min(0).max(120),
|
|
||||||
});
|
|
||||||
|
|
||||||
const householdSchema = z.object({
|
|
||||||
householdType: z.enum(["SINGLE", "COUPLE"]),
|
|
||||||
inflationRateDefault: z.number().min(-20).max(50),
|
|
||||||
persons: z.array(personSchema).min(1).max(2),
|
|
||||||
});
|
|
||||||
|
|
||||||
function validatePersonsForType(data: z.infer<typeof householdSchema>) {
|
|
||||||
if (data.householdType === "SINGLE" && data.persons.length !== 1) {
|
|
||||||
return "Einzelperson-Haushalt benoetigt genau eine Person.";
|
|
||||||
}
|
|
||||||
if (data.householdType === "COUPLE" && data.persons.length !== 2) {
|
|
||||||
return "Paar-Haushalt benoetigt genau zwei Personen (Person A und Person B).";
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET() {
|
|
||||||
const userId = await getCurrentUserId();
|
|
||||||
if (!userId) {
|
|
||||||
return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
|
|
||||||
}
|
|
||||||
const household = await getHouseholdOrNull(userId);
|
|
||||||
return NextResponse.json({ household: household ? toHouseholdInput(household) : null });
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
|
||||||
const userId = await getCurrentUserId();
|
|
||||||
if (!userId) {
|
|
||||||
return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const existing = await getHouseholdOrNull(userId);
|
|
||||||
if (existing) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Es existiert bereits ein Haushalt. Bitte PATCH verwenden, um ihn zu bearbeiten." },
|
|
||||||
{ status: 409 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = await request.json();
|
|
||||||
const parsed = householdSchema.safeParse(body);
|
|
||||||
if (!parsed.success) {
|
|
||||||
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
|
|
||||||
}
|
|
||||||
const error = validatePersonsForType(parsed.data);
|
|
||||||
if (error) {
|
|
||||||
return NextResponse.json({ error }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const household = await prisma.household.create({
|
|
||||||
data: {
|
|
||||||
userId,
|
|
||||||
householdType: parsed.data.householdType,
|
|
||||||
inflationRateDefault: parsed.data.inflationRateDefault,
|
|
||||||
persons: { create: parsed.data.persons },
|
|
||||||
},
|
|
||||||
include: { persons: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
return NextResponse.json({ household: toHouseholdInput(household) }, { status: 201 });
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function PATCH(request: NextRequest) {
|
|
||||||
const userId = await getCurrentUserId();
|
|
||||||
if (!userId) {
|
|
||||||
return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const existing = await getHouseholdOrNull(userId);
|
|
||||||
if (!existing) {
|
|
||||||
return NextResponse.json({ error: "Kein Haushalt vorhanden." }, { status: 404 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = await request.json();
|
|
||||||
const parsed = householdSchema.safeParse(body);
|
|
||||||
if (!parsed.success) {
|
|
||||||
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
|
|
||||||
}
|
|
||||||
const error = validatePersonsForType(parsed.data);
|
|
||||||
if (error) {
|
|
||||||
return NextResponse.json({ error }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const household = await prisma.$transaction(async (tx) => {
|
|
||||||
await tx.person.deleteMany({ where: { householdId: existing.id } });
|
|
||||||
return tx.household.update({
|
|
||||||
where: { id: existing.id },
|
|
||||||
data: {
|
|
||||||
householdType: parsed.data.householdType,
|
|
||||||
inflationRateDefault: parsed.data.inflationRateDefault,
|
|
||||||
persons: { create: parsed.data.persons },
|
|
||||||
},
|
|
||||||
include: { persons: true },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
return NextResponse.json({ household: toHouseholdInput(household) });
|
|
||||||
}
|
|
||||||
@@ -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 { getHouseholdOrNull, getOwnedPhase, getOwnedPlan, toPlanInput } from "@/lib/queries";
|
import { getOwnedPhase, getOwnedPlan, toPlanInput } from "@/lib/queries";
|
||||||
import { getCurrentUserId } from "@/lib/session";
|
import { getCurrentUserId } from "@/lib/session";
|
||||||
import { maxPhaseDuration } from "@/lib/calculations";
|
import { maxPhaseDuration } from "@/lib/calculations";
|
||||||
|
|
||||||
@@ -29,14 +29,13 @@ export async function PUT(
|
|||||||
let duration = parsed.data.durationYears;
|
let duration = parsed.data.durationYears;
|
||||||
if (duration != null) {
|
if (duration != null) {
|
||||||
// Dauer ans naechste Pensionsereignis kappen (Jahre vor dieser Phase).
|
// Dauer ans naechste Pensionsereignis kappen (Jahre vor dieser Phase).
|
||||||
const household = await getHouseholdOrNull(userId);
|
|
||||||
const plan = await getOwnedPlan(existing.planId, userId);
|
const plan = await getOwnedPlan(existing.planId, userId);
|
||||||
if (household && plan) {
|
if (plan) {
|
||||||
const planInput = toPlanInput(plan);
|
const planInput = toPlanInput(plan);
|
||||||
const yearsBefore = planInput.phases
|
const yearsBefore = planInput.phases
|
||||||
.filter((p) => p.sequenceNumber < existing.sequenceNumber)
|
.filter((p) => p.sequenceNumber < existing.sequenceNumber)
|
||||||
.reduce((s, p) => s + p.durationYears, 0);
|
.reduce((s, p) => s + p.durationYears, 0);
|
||||||
const cap = maxPhaseDuration(household.persons, planInput, yearsBefore);
|
const cap = maxPhaseDuration(planInput.persons, yearsBefore);
|
||||||
if (cap != null) duration = Math.min(duration, cap);
|
if (cap != null) duration = Math.min(duration, cap);
|
||||||
duration = Math.max(1, duration);
|
duration = Math.max(1, duration);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { toHouseholdInput, toPlanInput, getHouseholdOrNull, getOwnedPlan } from "@/lib/queries";
|
import { toPlanInput, getOwnedPlan } from "@/lib/queries";
|
||||||
import { getCurrentUserId } from "@/lib/session";
|
import { getCurrentUserId } from "@/lib/session";
|
||||||
import { computePlan, planToCsv } from "@/lib/calculations";
|
import { computePlan, planToCsv } from "@/lib/calculations";
|
||||||
|
|
||||||
@@ -12,10 +12,6 @@ export async function GET(
|
|||||||
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);
|
|
||||||
if (!household) {
|
|
||||||
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) {
|
||||||
@@ -23,7 +19,7 @@ export async function GET(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const planInput = toPlanInput(plan);
|
const planInput = toPlanInput(plan);
|
||||||
const computed = computePlan(planInput, toHouseholdInput(household));
|
const computed = computePlan(planInput);
|
||||||
const csv = planToCsv(planInput, computed);
|
const csv = planToCsv(planInput, computed);
|
||||||
|
|
||||||
return new NextResponse(csv, {
|
return new NextResponse(csv, {
|
||||||
|
|||||||
@@ -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 { getHouseholdOrNull, getOwnedPlan, toHouseholdInput, toPlanInput } from "@/lib/queries";
|
import { getOwnedPlan, toPlanInput } from "@/lib/queries";
|
||||||
import { getCurrentUserId } from "@/lib/session";
|
import { getCurrentUserId } from "@/lib/session";
|
||||||
import { Prisma } from "@/generated/prisma/client";
|
import { Prisma } from "@/generated/prisma/client";
|
||||||
import { computePlan, maxPhaseDuration } from "@/lib/calculations";
|
import { computePlan, maxPhaseDuration } from "@/lib/calculations";
|
||||||
@@ -10,11 +10,12 @@ import { num, type PhaseData } from "@/lib/elements";
|
|||||||
const createPhaseSchema = z.object({
|
const createPhaseSchema = z.object({
|
||||||
name: z.string().min(1).max(120).optional(),
|
name: z.string().min(1).max(120).optional(),
|
||||||
durationYears: z.number().int().min(1).max(80).optional(),
|
durationYears: z.number().int().min(1).max(80).optional(),
|
||||||
|
inflationRate: z.number().min(-20).max(50).nullable().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Legt eine neue Lebensphase am Ende der Kette an. Die Dauer wird ans naechste
|
// Legt eine neue Lebensphase am Ende der Kette an. Die Dauer wird ans naechste
|
||||||
// Pensionsereignis gekappt. Fuer bestehende Elemente werden die Werte 1:1 bzw. mit
|
// Pensionsereignis gekappt. Fuer bestehende Elemente werden die editierbaren Felder
|
||||||
// den fortgeschriebenen Endbestaenden aus der Vorphase vorbelegt.
|
// vorbelegt; die Startwerte werden in der Berechnung live aus der Vorphase fortgeschrieben.
|
||||||
export async function POST(
|
export async function POST(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
{ params }: { params: Promise<{ planId: string }> }
|
{ params }: { params: Promise<{ planId: string }> }
|
||||||
@@ -23,8 +24,6 @@ export async function POST(
|
|||||||
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
|
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
|
||||||
const { planId } = await params;
|
const { planId } = await params;
|
||||||
|
|
||||||
const household = await getHouseholdOrNull(userId);
|
|
||||||
if (!household) return NextResponse.json({ error: "Kein Haushalt vorhanden." }, { status: 400 });
|
|
||||||
const plan = await getOwnedPlan(planId, userId);
|
const plan = await getOwnedPlan(planId, userId);
|
||||||
if (!plan) return NextResponse.json({ error: "Plan nicht gefunden." }, { status: 404 });
|
if (!plan) return NextResponse.json({ error: "Plan nicht gefunden." }, { status: 404 });
|
||||||
|
|
||||||
@@ -32,10 +31,9 @@ export async function POST(
|
|||||||
const parsed = createPhaseSchema.safeParse(body);
|
const parsed = createPhaseSchema.safeParse(body);
|
||||||
if (!parsed.success) return NextResponse.json({ error: "Ungueltige Eingabe." }, { status: 400 });
|
if (!parsed.success) return NextResponse.json({ error: "Ungueltige Eingabe." }, { status: 400 });
|
||||||
|
|
||||||
const householdInput = toHouseholdInput(household);
|
|
||||||
const planInput = toPlanInput(plan);
|
const planInput = toPlanInput(plan);
|
||||||
const yearsBefore = planInput.phases.reduce((s, p) => s + p.durationYears, 0);
|
const yearsBefore = planInput.phases.reduce((s, p) => s + p.durationYears, 0);
|
||||||
const cap = maxPhaseDuration(household.persons, planInput, yearsBefore);
|
const cap = maxPhaseDuration(planInput.persons, yearsBefore);
|
||||||
|
|
||||||
let duration = parsed.data.durationYears ?? (cap ?? 10);
|
let duration = parsed.data.durationYears ?? (cap ?? 10);
|
||||||
if (cap != null) duration = Math.min(duration, cap);
|
if (cap != null) duration = Math.min(duration, cap);
|
||||||
@@ -44,18 +42,15 @@ export async function POST(
|
|||||||
const nextSequence = planInput.phases.length + 1;
|
const nextSequence = planInput.phases.length + 1;
|
||||||
|
|
||||||
// Phasentyp der neuen Phase fuer den Default-Namen bestimmen.
|
// Phasentyp der neuen Phase fuer den Default-Namen bestimmen.
|
||||||
const anyRetiredAtStart = household.persons.some((p) => {
|
const anyRetiredAtStart = planInput.persons.some((p) => p.age + yearsBefore >= p.retirementAge);
|
||||||
const ra = p.role === "PERSON_A" ? planInput.retirementAgeA ?? p.retirementAge : planInput.retirementAgeB ?? p.retirementAge;
|
|
||||||
return p.age + yearsBefore >= ra;
|
|
||||||
});
|
|
||||||
const defaultName =
|
const defaultName =
|
||||||
parsed.data.name ?? (nextSequence === 1 ? "Erste Lebensphase" : anyRetiredAtStart ? "Pensionsphase" : "Erwerbsphase");
|
parsed.data.name ?? (nextSequence === 1 ? "Erste Lebensphase" : anyRetiredAtStart ? "Pensionsphase" : "Erwerbsphase");
|
||||||
|
|
||||||
// Endbestaende der bisher letzten Phase (fuer Carry-Vorbelegung).
|
// Status der Elemente in der bisher letzten Phase (verkauft/getilgt nicht fortfuehren).
|
||||||
const prevComputed = planInput.phases.length > 0 ? computePlan(planInput, householdInput) : null;
|
const prevComputed = planInput.phases.length > 0 ? computePlan(planInput) : null;
|
||||||
const lastPhaseId = planInput.phases.at(-1)?.id;
|
const lastPhaseId = planInput.phases.at(-1)?.id;
|
||||||
const prevPhase = prevComputed?.phases.find((p) => p.id === lastPhaseId) ?? null;
|
const prevPhase = prevComputed?.phases.find((p) => p.id === lastPhaseId) ?? null;
|
||||||
const prevElemById = new Map((prevPhase?.elements ?? []).map((e) => [e.elementId, e]));
|
const prevStatusById = new Map((prevPhase?.elements ?? []).map((e) => [e.elementId, e.status]));
|
||||||
|
|
||||||
const phase = await prisma.$transaction(async (tx) => {
|
const phase = await prisma.$transaction(async (tx) => {
|
||||||
const created = await tx.phase.create({
|
const created = await tx.phase.create({
|
||||||
@@ -64,15 +59,15 @@ export async function POST(
|
|||||||
sequenceNumber: nextSequence,
|
sequenceNumber: nextSequence,
|
||||||
name: defaultName,
|
name: defaultName,
|
||||||
durationYears: duration,
|
durationYears: duration,
|
||||||
|
inflationRate: parsed.data.inflationRate ?? undefined,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Carry-Vorbelegung fuer bestehende Elemente.
|
// Vorbelegung der editierbaren Felder bestehender Elemente.
|
||||||
for (const e of planInput.elements) {
|
for (const e of planInput.elements) {
|
||||||
const prev = prevElemById.get(e.id);
|
if ((prevStatusById.get(e.id) ?? "ACTIVE") !== "ACTIVE") continue; // verkauft/getilgt
|
||||||
if (prev && prev.status !== "ACTIVE") continue; // verkauft/getilgt -> nicht mehr fortfuehren
|
|
||||||
const prevData: PhaseData = e.phaseValues[lastPhaseId ?? ""] ?? {};
|
const prevData: PhaseData = e.phaseValues[lastPhaseId ?? ""] ?? {};
|
||||||
const data: PhaseData = buildCarryData(e.category, prevData, prev?.endValue);
|
const data: PhaseData = buildCarryData(e.category, prevData);
|
||||||
await tx.elementPhaseValue.create({
|
await tx.elementPhaseValue.create({
|
||||||
data: { elementId: e.id, phaseId: created.id, data: data as Prisma.InputJsonValue },
|
data: { elementId: e.id, phaseId: created.id, data: data as Prisma.InputJsonValue },
|
||||||
});
|
});
|
||||||
@@ -84,12 +79,10 @@ export async function POST(
|
|||||||
return NextResponse.json({ phase: { id: phase.id } }, { status: 201 });
|
return NextResponse.json({ phase: { id: phase.id } }, { status: 201 });
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildCarryData(
|
// Vorbelegung fuer eine neue Phase: nur die editierbaren Felder werden uebernommen. Start-
|
||||||
category: string,
|
// bzw. Restwerte (PK/3a/Vermoegen/Hypothek/Schuld) werden in der Berechnung live aus der
|
||||||
prev: PhaseData,
|
// Vorphase fortgeschrieben und deshalb hier NICHT als Snapshot gespeichert.
|
||||||
prevEndValue: number | undefined
|
function buildCarryData(category: string, prev: PhaseData): PhaseData {
|
||||||
): PhaseData {
|
|
||||||
const endVal = Math.max(0, Math.round(prevEndValue ?? 0));
|
|
||||||
switch (category) {
|
switch (category) {
|
||||||
case "INCOME":
|
case "INCOME":
|
||||||
case "EXPENSE":
|
case "EXPENSE":
|
||||||
@@ -98,18 +91,13 @@ function buildCarryData(
|
|||||||
return { gapYears: 0 };
|
return { gapYears: 0 };
|
||||||
case "PENSION_FUND":
|
case "PENSION_FUND":
|
||||||
case "PILLAR_3A":
|
case "PILLAR_3A":
|
||||||
return { currentValue: endVal, annualContribution: num(prev.annualContribution), expectedReturn: num(prev.expectedReturn) };
|
|
||||||
case "OTHER_ASSET":
|
case "OTHER_ASSET":
|
||||||
return { startValue: endVal, annualContribution: num(prev.annualContribution), expectedReturn: num(prev.expectedReturn) };
|
return { annualContribution: num(prev.annualContribution), expectedReturn: num(prev.expectedReturn) };
|
||||||
case "REAL_ESTATE":
|
case "REAL_ESTATE":
|
||||||
// endValue = purchase - mortgage; Hypothek fortschreiben ueber purchasePrice - endValue.
|
// purchasePrice + amortization bleiben; die Resthypothek wird live fortgeschrieben.
|
||||||
return {
|
return { purchasePrice: num(prev.purchasePrice), amortization: num(prev.amortization) };
|
||||||
purchasePrice: num(prev.purchasePrice),
|
|
||||||
mortgage: Math.max(0, num(prev.purchasePrice) - endVal),
|
|
||||||
amortization: num(prev.amortization),
|
|
||||||
};
|
|
||||||
case "OTHER_DEBT":
|
case "OTHER_DEBT":
|
||||||
return { startValue: Math.abs(endVal), annualRepayment: num(prev.annualRepayment) };
|
return { annualRepayment: num(prev.annualRepayment) };
|
||||||
default:
|
default:
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
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 { toHouseholdInput, toPlanInput, getHouseholdOrNull, getOwnedPlan } from "@/lib/queries";
|
import { toPlanInput, getOwnedPlan } from "@/lib/queries";
|
||||||
import { getCurrentUserId } from "@/lib/session";
|
import { getCurrentUserId } from "@/lib/session";
|
||||||
import { computePlan } from "@/lib/calculations";
|
import { computePlan } from "@/lib/calculations";
|
||||||
|
import { planProfileSchema, validatePersonsForType } from "@/app/api/plans/route";
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
_request: NextRequest,
|
_request: NextRequest,
|
||||||
@@ -13,24 +14,20 @@ export async function GET(
|
|||||||
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
|
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
|
||||||
const { planId } = await params;
|
const { planId } = await params;
|
||||||
|
|
||||||
const household = await getHouseholdOrNull(userId);
|
|
||||||
if (!household) return NextResponse.json({ error: "Kein Haushalt vorhanden." }, { status: 400 });
|
|
||||||
|
|
||||||
const plan = await getOwnedPlan(planId, userId);
|
const plan = await getOwnedPlan(planId, userId);
|
||||||
if (!plan) return NextResponse.json({ error: "Plan nicht gefunden." }, { status: 404 });
|
if (!plan) return NextResponse.json({ error: "Plan nicht gefunden." }, { status: 404 });
|
||||||
|
|
||||||
const householdInput = toHouseholdInput(household);
|
|
||||||
const planInput = toPlanInput(plan);
|
const planInput = toPlanInput(plan);
|
||||||
const computed = computePlan(planInput, householdInput);
|
const computed = computePlan(planInput);
|
||||||
|
|
||||||
return NextResponse.json({ plan: planInput, computed });
|
return NextResponse.json({ plan: planInput, computed });
|
||||||
}
|
}
|
||||||
|
|
||||||
const patchSchema = z.object({
|
// Name allein aendern ODER das ganze Plan-Profil (Haushaltsform/Personen/Inflation).
|
||||||
name: z.string().min(1).max(120).optional(),
|
const patchSchema = z.union([
|
||||||
retirementAgeA: z.number().int().min(30).max(100).nullable().optional(),
|
z.object({ name: z.string().min(1).max(120) }),
|
||||||
retirementAgeB: z.number().int().min(30).max(100).nullable().optional(),
|
planProfileSchema.extend({ name: z.string().min(1).max(120).optional() }),
|
||||||
});
|
]);
|
||||||
|
|
||||||
export async function PATCH(
|
export async function PATCH(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
@@ -40,21 +37,38 @@ export async function PATCH(
|
|||||||
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
|
if (!userId) 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, userId } });
|
||||||
if (!plan) return NextResponse.json({ error: "Plan nicht gefunden." }, { status: 404 });
|
if (!plan) return NextResponse.json({ error: "Plan nicht gefunden." }, { status: 404 });
|
||||||
|
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const parsed = patchSchema.safeParse(body);
|
const parsed = patchSchema.safeParse(body);
|
||||||
if (!parsed.success) return NextResponse.json({ error: "Ungueltige Eingabe." }, { status: 400 });
|
if (!parsed.success) return NextResponse.json({ error: "Ungueltige Eingabe." }, { status: 400 });
|
||||||
|
|
||||||
const updated = await prisma.plan.update({
|
const data = parsed.data;
|
||||||
|
const hasProfile = "householdType" in data;
|
||||||
|
|
||||||
|
if (hasProfile) {
|
||||||
|
const error = validatePersonsForType(data);
|
||||||
|
if (error) return NextResponse.json({ error }, { status: 400 });
|
||||||
|
const updated = await prisma.$transaction(async (tx) => {
|
||||||
|
await tx.person.deleteMany({ where: { planId: plan.id } });
|
||||||
|
return tx.plan.update({
|
||||||
where: { id: plan.id },
|
where: { id: plan.id },
|
||||||
data: {
|
data: {
|
||||||
name: parsed.data.name ?? undefined,
|
name: data.name ?? undefined,
|
||||||
retirementAgeA: parsed.data.retirementAgeA === undefined ? undefined : parsed.data.retirementAgeA,
|
householdType: data.householdType,
|
||||||
retirementAgeB: parsed.data.retirementAgeB === undefined ? undefined : parsed.data.retirementAgeB,
|
inflationRateDefault: data.inflationRateDefault,
|
||||||
|
persons: { create: data.persons },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
return NextResponse.json({ plan: { id: updated.id, name: updated.name } });
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await prisma.plan.update({
|
||||||
|
where: { id: plan.id },
|
||||||
|
data: { name: data.name },
|
||||||
|
});
|
||||||
return NextResponse.json({ plan: { id: updated.id, name: updated.name } });
|
return NextResponse.json({ plan: { id: updated.id, name: updated.name } });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,7 +79,7 @@ export async function DELETE(
|
|||||||
const userId = await getCurrentUserId();
|
const userId = await getCurrentUserId();
|
||||||
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
|
if (!userId) 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, userId } });
|
||||||
if (!plan) return NextResponse.json({ error: "Plan nicht gefunden." }, { status: 404 });
|
if (!plan) 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 });
|
||||||
|
|||||||
@@ -36,11 +36,14 @@ export async function POST(
|
|||||||
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: source.householdId,
|
userId,
|
||||||
name: parsed.data.name,
|
name: parsed.data.name,
|
||||||
retirementAgeA: source.retirementAgeA,
|
householdType: source.householdType,
|
||||||
retirementAgeB: source.retirementAgeB,
|
inflationRateDefault: source.inflationRateDefault,
|
||||||
parentPlanId: source.id,
|
parentPlanId: source.id,
|
||||||
|
persons: {
|
||||||
|
create: source.persons.map((p) => ({ role: p.role, age: p.age, retirementAge: p.retirementAge })),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+36
-17
@@ -1,24 +1,42 @@
|
|||||||
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 { getHouseholdOrNull } from "@/lib/queries";
|
|
||||||
import { getCurrentUserId } from "@/lib/session";
|
import { getCurrentUserId } from "@/lib/session";
|
||||||
|
|
||||||
const createPlanSchema = z.object({
|
const personSchema = z.object({
|
||||||
name: z.string().min(1).max(120),
|
role: z.enum(["PERSON_A", "PERSON_B"]),
|
||||||
|
age: z.number().int().min(0).max(120),
|
||||||
|
retirementAge: z.number().int().min(30).max(100),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Ein Plan traegt sein eigenes Grundprofil (Haushaltsform, Personen, Inflation).
|
||||||
|
export const planProfileSchema = z.object({
|
||||||
|
householdType: z.enum(["SINGLE", "COUPLE"]),
|
||||||
|
inflationRateDefault: z.number().min(-20).max(50),
|
||||||
|
persons: z.array(personSchema).min(1).max(2),
|
||||||
|
});
|
||||||
|
|
||||||
|
const createPlanSchema = z
|
||||||
|
.object({ name: z.string().min(1).max(120) })
|
||||||
|
.and(planProfileSchema);
|
||||||
|
|
||||||
|
export function validatePersonsForType(data: z.infer<typeof planProfileSchema>): string | null {
|
||||||
|
if (data.householdType === "SINGLE" && data.persons.length !== 1) {
|
||||||
|
return "Einzelperson-Plan benoetigt genau eine Person.";
|
||||||
|
}
|
||||||
|
if (data.householdType === "COUPLE" && data.persons.length !== 2) {
|
||||||
|
return "Paar-Plan benoetigt genau zwei Personen (Person A und Person B).";
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
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 household = await getHouseholdOrNull(userId);
|
|
||||||
if (!household) {
|
|
||||||
return NextResponse.json({ plans: [] });
|
|
||||||
}
|
|
||||||
const plans = await prisma.plan.findMany({
|
const plans = await prisma.plan.findMany({
|
||||||
where: { householdId: household.id },
|
where: { userId },
|
||||||
orderBy: { createdAt: "asc" },
|
orderBy: { createdAt: "asc" },
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
@@ -40,23 +58,24 @@ export async function POST(request: NextRequest) {
|
|||||||
if (!userId) {
|
if (!userId) {
|
||||||
return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
|
return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
|
||||||
}
|
}
|
||||||
const household = await getHouseholdOrNull(userId);
|
|
||||||
if (!household) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Bitte zuerst das Grundprofil (Onboarding) anlegen." },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const parsed = createPlanSchema.safeParse(body);
|
const parsed = createPlanSchema.safeParse(body);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
|
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
const error = validatePersonsForType(parsed.data);
|
||||||
|
if (error) return NextResponse.json({ error }, { status: 400 });
|
||||||
|
|
||||||
const plan = await prisma.plan.create({
|
const plan = await prisma.plan.create({
|
||||||
data: { householdId: household.id, name: parsed.data.name },
|
data: {
|
||||||
|
userId,
|
||||||
|
name: parsed.data.name,
|
||||||
|
householdType: parsed.data.householdType,
|
||||||
|
inflationRateDefault: parsed.data.inflationRateDefault,
|
||||||
|
persons: { create: parsed.data.persons },
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json({ plan }, { status: 201 });
|
return NextResponse.json({ plan: { id: plan.id } }, { status: 201 });
|
||||||
}
|
}
|
||||||
|
|||||||
+118
-14
@@ -1,26 +1,130 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
:root {
|
/* -------------------------------------------------------------------------
|
||||||
--background: #f8fafc;
|
Semantische Farb-Tokens. Drei umschaltbare Schemata ueber data-theme am
|
||||||
--foreground: #1e293b;
|
<html>: "light", "dark", "warm". Ohne explizite Wahl folgt das Standard-
|
||||||
|
:root der OS-Einstellung (prefers-color-scheme). Umschaltung: lib/theme.ts.
|
||||||
|
------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
:root,
|
||||||
|
:root[data-theme="light"] {
|
||||||
|
--bg: #f8fafc;
|
||||||
|
--surface: #ffffff;
|
||||||
|
--surface-2: #f4f4f5;
|
||||||
|
--input: #ffffff;
|
||||||
|
--border: #e4e4e7;
|
||||||
|
--border-strong: #d4d4d8;
|
||||||
|
--fg: #18181b;
|
||||||
|
--muted: #52525b;
|
||||||
|
--faint: #a1a1aa;
|
||||||
|
--accent: #4f46e5;
|
||||||
|
--accent-hover: #4338ca;
|
||||||
|
--accent-fg: #ffffff;
|
||||||
|
--accent-soft: #eef2ff;
|
||||||
|
--accent-soft-fg: #4338ca;
|
||||||
|
--danger: #dc2626;
|
||||||
|
--danger-soft: #fef2f2;
|
||||||
|
--success: #059669;
|
||||||
|
--person-a: #4f46e5;
|
||||||
|
--person-b: #0ea5e9;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="dark"] {
|
||||||
|
--bg: #10131c;
|
||||||
|
--surface: #18181b;
|
||||||
|
--surface-2: #27272a;
|
||||||
|
--input: #27272a;
|
||||||
|
--border: #27272a;
|
||||||
|
--border-strong: #3f3f46;
|
||||||
|
--fg: #f4f4f5;
|
||||||
|
--muted: #a1a1aa;
|
||||||
|
--faint: #71717a;
|
||||||
|
--accent: #6366f1;
|
||||||
|
--accent-hover: #818cf8;
|
||||||
|
--accent-fg: #ffffff;
|
||||||
|
--accent-soft: rgba(99, 102, 241, 0.16);
|
||||||
|
--accent-soft-fg: #a5b4fc;
|
||||||
|
--danger: #f87171;
|
||||||
|
--danger-soft: rgba(220, 38, 38, 0.16);
|
||||||
|
--success: #34d399;
|
||||||
|
--person-a: #818cf8;
|
||||||
|
--person-b: #38bdf8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Warm / Sunset: cremefarbener Grund, Koralle-Akzent, Amber-Sekundaerton. */
|
||||||
|
:root[data-theme="warm"] {
|
||||||
|
--bg: #fbf7f2;
|
||||||
|
--surface: #fffdfa;
|
||||||
|
--surface-2: #f5ede3;
|
||||||
|
--input: #ffffff;
|
||||||
|
--border: #eadfd2;
|
||||||
|
--border-strong: #dac9b6;
|
||||||
|
--fg: #2b2320;
|
||||||
|
--muted: #6b5d53;
|
||||||
|
--faint: #a89a8c;
|
||||||
|
--accent: #e8663c;
|
||||||
|
--accent-hover: #d2542c;
|
||||||
|
--accent-fg: #ffffff;
|
||||||
|
--accent-soft: #fcebe2;
|
||||||
|
--accent-soft-fg: #b24521;
|
||||||
|
--danger: #c0392b;
|
||||||
|
--danger-soft: #fbeae7;
|
||||||
|
--success: #2e9e7b;
|
||||||
|
--person-a: #e8663c;
|
||||||
|
--person-b: #f2a93b;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ohne gespeicherte Wahl der Dunkel-OS-Einstellung folgen. */
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root:not([data-theme]) {
|
||||||
|
--bg: #10131c;
|
||||||
|
--surface: #18181b;
|
||||||
|
--surface-2: #27272a;
|
||||||
|
--input: #27272a;
|
||||||
|
--border: #27272a;
|
||||||
|
--border-strong: #3f3f46;
|
||||||
|
--fg: #f4f4f5;
|
||||||
|
--muted: #a1a1aa;
|
||||||
|
--faint: #71717a;
|
||||||
|
--accent: #6366f1;
|
||||||
|
--accent-hover: #818cf8;
|
||||||
|
--accent-fg: #ffffff;
|
||||||
|
--accent-soft: rgba(99, 102, 241, 0.16);
|
||||||
|
--accent-soft-fg: #a5b4fc;
|
||||||
|
--danger: #f87171;
|
||||||
|
--danger-soft: rgba(220, 38, 38, 0.16);
|
||||||
|
--success: #34d399;
|
||||||
|
--person-a: #818cf8;
|
||||||
|
--person-b: #38bdf8;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
--color-background: var(--background);
|
--color-bg: var(--bg);
|
||||||
--color-foreground: var(--foreground);
|
--color-surface: var(--surface);
|
||||||
|
--color-surface-2: var(--surface-2);
|
||||||
|
--color-input: var(--input);
|
||||||
|
--color-border: var(--border);
|
||||||
|
--color-border-strong: var(--border-strong);
|
||||||
|
--color-fg: var(--fg);
|
||||||
|
--color-muted: var(--muted);
|
||||||
|
--color-faint: var(--faint);
|
||||||
|
--color-accent: var(--accent);
|
||||||
|
--color-accent-hover: var(--accent-hover);
|
||||||
|
--color-accent-fg: var(--accent-fg);
|
||||||
|
--color-accent-soft: var(--accent-soft);
|
||||||
|
--color-accent-soft-fg: var(--accent-soft-fg);
|
||||||
|
--color-danger: var(--danger);
|
||||||
|
--color-danger-soft: var(--danger-soft);
|
||||||
|
--color-success: var(--success);
|
||||||
|
--color-person-a: var(--person-a);
|
||||||
|
--color-person-b: var(--person-b);
|
||||||
--font-sans: var(--font-geist-sans);
|
--font-sans: var(--font-geist-sans);
|
||||||
--font-mono: var(--font-geist-mono);
|
--font-mono: var(--font-geist-mono);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
:root {
|
|
||||||
--background: #10131c;
|
|
||||||
--foreground: #e2e8f0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
body {
|
||||||
background: var(--background);
|
background: var(--bg);
|
||||||
color: var(--foreground);
|
color: var(--fg);
|
||||||
font-family: Arial, Helvetica, sans-serif;
|
font-family: Arial, Helvetica, sans-serif;
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-1
@@ -17,6 +17,9 @@ export const metadata: Metadata = {
|
|||||||
description: "Persoenliche Finanzplanung ueber Lebensabschnittsphasen (AICDS)",
|
description: "Persoenliche Finanzplanung ueber Lebensabschnittsphasen (AICDS)",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Setzt data-theme aus localStorage noch vor dem ersten Paint (verhindert FOUC).
|
||||||
|
const themeInitScript = `try{var t=localStorage.getItem('fpt-theme');if(t==='light'||t==='dark'||t==='warm'){document.documentElement.setAttribute('data-theme',t);}}catch(e){}`;
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
children,
|
children,
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
@@ -24,9 +27,13 @@ export default function RootLayout({
|
|||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<html
|
<html
|
||||||
lang="en"
|
lang="de"
|
||||||
|
suppressHydrationWarning
|
||||||
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
||||||
>
|
>
|
||||||
|
<head>
|
||||||
|
<script dangerouslySetInnerHTML={{ __html: themeInitScript }} />
|
||||||
|
</head>
|
||||||
<body className="min-h-full flex flex-col">{children}</body>
|
<body className="min-h-full flex flex-col">{children}</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
+14
-14
@@ -46,28 +46,28 @@ function LoginForm() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const inputClass =
|
const inputClass =
|
||||||
"w-full rounded-lg border border-zinc-300 bg-white py-2 pl-9 pr-3 text-sm text-zinc-900 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/20 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100";
|
"w-full rounded-lg border border-border bg-input py-2 pl-9 pr-3 text-sm text-fg shadow-sm focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/25";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-1 items-center justify-center px-4">
|
<div className="flex flex-1 items-center justify-center px-4">
|
||||||
<div className="w-full max-w-sm">
|
<div className="w-full max-w-sm">
|
||||||
<div className="mb-6 flex flex-col items-center gap-2 text-center">
|
<div className="mb-6 flex flex-col items-center gap-2 text-center">
|
||||||
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-indigo-600 shadow-md dark:bg-indigo-500">
|
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-accent shadow-md">
|
||||||
<PiggyBank className="h-7 w-7 text-white" />
|
<PiggyBank className="h-7 w-7 text-accent-fg" />
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-xl font-semibold text-zinc-900 dark:text-zinc-50">
|
<h1 className="text-xl font-semibold text-fg">
|
||||||
Financial Planning Tool
|
Financial Planning Tool
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-xs text-zinc-500 dark:text-zinc-400">
|
<p className="text-xs text-muted">
|
||||||
Persoenliche Finanzplanung ueber Lebensphasen
|
Persoenliche Finanzplanung ueber Lebensphasen
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form
|
<form
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
className="flex flex-col gap-4 rounded-2xl border border-zinc-200/70 bg-white p-6 shadow-sm dark:border-zinc-800 dark:bg-zinc-900"
|
className="flex flex-col gap-4 rounded-2xl border border-border bg-surface p-6 shadow-sm"
|
||||||
>
|
>
|
||||||
<div className="flex rounded-lg bg-zinc-100 p-1 dark:bg-zinc-800">
|
<div className="flex rounded-lg bg-surface-2 p-1">
|
||||||
{(["login", "register"] as Mode[]).map((m) => (
|
{(["login", "register"] as Mode[]).map((m) => (
|
||||||
<button
|
<button
|
||||||
key={m}
|
key={m}
|
||||||
@@ -78,8 +78,8 @@ function LoginForm() {
|
|||||||
}}
|
}}
|
||||||
className={`flex-1 rounded-md px-3 py-1.5 text-sm font-medium transition-colors ${
|
className={`flex-1 rounded-md px-3 py-1.5 text-sm font-medium transition-colors ${
|
||||||
mode === m
|
mode === m
|
||||||
? "bg-white text-indigo-600 shadow-sm dark:bg-zinc-900 dark:text-indigo-400"
|
? "bg-surface text-accent shadow-sm"
|
||||||
: "text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300"
|
: "text-muted hover:text-fg"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{m === "login" ? "Anmelden" : "Registrieren"}
|
{m === "login" ? "Anmelden" : "Registrieren"}
|
||||||
@@ -88,7 +88,7 @@ function LoginForm() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<User className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-zinc-400" />
|
<User className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-faint" />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
autoFocus
|
autoFocus
|
||||||
@@ -100,7 +100,7 @@ function LoginForm() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Lock className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-zinc-400" />
|
<Lock className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-faint" />
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
autoComplete={mode === "login" ? "current-password" : "new-password"}
|
autoComplete={mode === "login" ? "current-password" : "new-password"}
|
||||||
@@ -112,7 +112,7 @@ function LoginForm() {
|
|||||||
</div>
|
</div>
|
||||||
{mode === "register" && (
|
{mode === "register" && (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Lock className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-zinc-400" />
|
<Lock className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-faint" />
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
@@ -123,11 +123,11 @@ function LoginForm() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{error && <p className="text-sm text-red-600 dark:text-red-400">{error}</p>}
|
{error && <p className="text-sm text-danger">{error}</p>}
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
className="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-500 disabled:opacity-50 dark:bg-indigo-500 dark:hover:bg-indigo-400"
|
className="rounded-lg bg-accent px-4 py-2 text-sm font-medium text-accent-fg shadow-sm hover:bg-accent-hover disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{loading ? "..." : mode === "login" ? "Anmelden" : "Konto erstellen"}
|
{loading ? "..." : mode === "login" ? "Anmelden" : "Konto erstellen"}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
+7
-19
@@ -1,41 +1,29 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Onboarding } from "@/components/Onboarding";
|
|
||||||
import { AppShell } from "@/components/AppShell";
|
import { AppShell } from "@/components/AppShell";
|
||||||
import { api } from "@/lib/api-client";
|
import { api } from "@/lib/api-client";
|
||||||
import type { HouseholdInput } from "@/lib/types";
|
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const [household, setHousehold] = useState<HouseholdInput | null | undefined>(undefined);
|
const [username, setUsername] = useState<string | null>(null);
|
||||||
const [username, setUsername] = useState<string>("");
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
Promise.all([
|
api
|
||||||
api.get<{ household: HouseholdInput | null }>("/api/household"),
|
.get<{ user: { username: string } }>("/api/auth/me")
|
||||||
api.get<{ user: { username: string } }>("/api/auth/me"),
|
.then((data) => setUsername(data.user.username))
|
||||||
])
|
|
||||||
.then(([householdData, meData]) => {
|
|
||||||
setUsername(meData.user.username);
|
|
||||||
setHousehold(householdData.household);
|
|
||||||
})
|
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
// Session abgelaufen/ungueltig -- Middleware leitet beim naechsten Request um.
|
// Session abgelaufen/ungueltig -- Middleware leitet beim naechsten Request um.
|
||||||
window.location.href = "/login";
|
window.location.href = "/login";
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
if (household === undefined) {
|
if (username === null) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-1 items-center justify-center">
|
<div className="flex flex-1 items-center justify-center">
|
||||||
<p className="text-sm text-zinc-500">Laedt…</p>
|
<p className="text-sm text-muted">Laedt…</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (household === null) {
|
return <AppShell username={username} />;
|
||||||
return <Onboarding onDone={setHousehold} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return <AppShell initialHousehold={household} username={username} />;
|
|
||||||
}
|
}
|
||||||
|
|||||||
+65
-99
@@ -12,10 +12,10 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { PlanView } from "@/components/PlanView";
|
import { PlanView } from "@/components/PlanView";
|
||||||
import { Dashboard } from "@/components/Dashboard";
|
import { Dashboard } from "@/components/Dashboard";
|
||||||
import { HouseholdSettings } from "@/components/HouseholdSettings";
|
|
||||||
import { ProfileMenu } from "@/components/ProfileMenu";
|
import { ProfileMenu } from "@/components/ProfileMenu";
|
||||||
|
import { PlanProfileFields, emptyProfileDraft, type ProfileDraft } from "@/components/PlanProfileFields";
|
||||||
import { api } from "@/lib/api-client";
|
import { api } from "@/lib/api-client";
|
||||||
import type { HouseholdInput, PlanInput } from "@/lib/types";
|
import type { PlanInput } from "@/lib/types";
|
||||||
import type { PlanComputed } from "@/lib/calculations";
|
import type { PlanComputed } from "@/lib/calculations";
|
||||||
|
|
||||||
interface PlanListItem {
|
interface PlanListItem {
|
||||||
@@ -26,15 +26,7 @@ interface PlanListItem {
|
|||||||
phases: { id: string; name: string; sequenceNumber: number }[];
|
phases: { id: string; name: string; sequenceNumber: number }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AppShell({
|
export function AppShell({ username }: { username: string }) {
|
||||||
initialHousehold,
|
|
||||||
username,
|
|
||||||
}: {
|
|
||||||
initialHousehold: HouseholdInput;
|
|
||||||
username: string;
|
|
||||||
}) {
|
|
||||||
const [household, setHousehold] = useState(initialHousehold);
|
|
||||||
const [showSettings, setShowSettings] = useState(false);
|
|
||||||
const [plans, setPlans] = useState<PlanListItem[]>([]);
|
const [plans, setPlans] = useState<PlanListItem[]>([]);
|
||||||
const [selectedPlanId, setSelectedPlanId] = useState<string | null>(null);
|
const [selectedPlanId, setSelectedPlanId] = useState<string | null>(null);
|
||||||
const [detail, setDetail] = useState<{ plan: PlanInput; computed: PlanComputed } | null>(null);
|
const [detail, setDetail] = useState<{ plan: PlanInput; computed: PlanComputed } | null>(null);
|
||||||
@@ -46,9 +38,7 @@ export function AppShell({
|
|||||||
const loadPlans = useCallback(async (preferId?: string) => {
|
const loadPlans = useCallback(async (preferId?: string) => {
|
||||||
const data = await api.get<{ plans: PlanListItem[] }>("/api/plans");
|
const data = await api.get<{ plans: PlanListItem[] }>("/api/plans");
|
||||||
setPlans(data.plans);
|
setPlans(data.plans);
|
||||||
if (preferId) {
|
if (preferId) setSelectedPlanId(preferId);
|
||||||
setSelectedPlanId(preferId);
|
|
||||||
}
|
|
||||||
return data.plans;
|
return data.plans;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -84,9 +74,7 @@ export function AppShell({
|
|||||||
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}`);
|
||||||
await loadPlans();
|
await loadPlans();
|
||||||
if (selectedPlanId === id) {
|
if (selectedPlanId === id) setSelectedPlanId(null);
|
||||||
setSelectedPlanId(null);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const activePlan = plans.find((p) => p.id === selectedPlanId) ?? null;
|
const activePlan = plans.find((p) => p.id === selectedPlanId) ?? null;
|
||||||
@@ -94,10 +82,10 @@ export function AppShell({
|
|||||||
const sidebar = (
|
const sidebar = (
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col">
|
||||||
<div className="flex items-center gap-2 px-4 py-4">
|
<div className="flex items-center gap-2 px-4 py-4">
|
||||||
<div className="flex h-8 w-8 items-center justify-center rounded-xl bg-indigo-600 dark:bg-indigo-500">
|
<div className="flex h-8 w-8 items-center justify-center rounded-xl bg-accent">
|
||||||
<PiggyBank className="h-5 w-5 text-white" />
|
<PiggyBank className="h-5 w-5 text-accent-fg" />
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm font-semibold text-zinc-900 dark:text-zinc-50">FPT</span>
|
<span className="text-sm font-semibold text-fg">FPT</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav className="flex flex-1 flex-col gap-1 overflow-y-auto px-3 pb-4">
|
<nav className="flex flex-1 flex-col gap-1 overflow-y-auto px-3 pb-4">
|
||||||
@@ -108,9 +96,7 @@ export function AppShell({
|
|||||||
setSidebarOpen(false);
|
setSidebarOpen(false);
|
||||||
}}
|
}}
|
||||||
className={`flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium ${
|
className={`flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium ${
|
||||||
selectedPlanId === null
|
selectedPlanId === null ? "bg-accent-soft text-accent-soft-fg" : "text-muted hover:bg-surface-2"
|
||||||
? "bg-indigo-50 text-indigo-700 dark:bg-indigo-500/15 dark:text-indigo-300"
|
|
||||||
: "text-zinc-600 hover:bg-zinc-100 dark:text-zinc-300 dark:hover:bg-zinc-800"
|
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<LayoutDashboard className="h-4 w-4" />
|
<LayoutDashboard className="h-4 w-4" />
|
||||||
@@ -118,19 +104,17 @@ export function AppShell({
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="mt-4 flex items-center justify-between px-3">
|
<div className="mt-4 flex items-center justify-between px-3">
|
||||||
<span className="text-[11px] font-semibold uppercase tracking-wide text-zinc-400">Plaene</span>
|
<span className="text-[11px] font-semibold uppercase tracking-wide text-faint">Plaene</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowNewPlan(true)}
|
onClick={() => setShowNewPlan(true)}
|
||||||
aria-label="Neuen Plan erstellen"
|
aria-label="Neuen Plan erstellen"
|
||||||
className="rounded-md p-1 text-indigo-600 hover:bg-indigo-50 dark:text-indigo-400 dark:hover:bg-indigo-500/10"
|
className="rounded-md p-1 text-accent hover:bg-accent-soft"
|
||||||
>
|
>
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{plans.length === 0 && (
|
{plans.length === 0 && <p className="px-3 py-2 text-xs text-faint">Noch keine Plaene.</p>}
|
||||||
<p className="px-3 py-2 text-xs text-zinc-400">Noch keine Plaene.</p>
|
|
||||||
)}
|
|
||||||
{plans.map((p) => (
|
{plans.map((p) => (
|
||||||
<button
|
<button
|
||||||
key={p.id}
|
key={p.id}
|
||||||
@@ -140,14 +124,12 @@ export function AppShell({
|
|||||||
setSidebarOpen(false);
|
setSidebarOpen(false);
|
||||||
}}
|
}}
|
||||||
className={`flex items-center gap-2 rounded-lg px-3 py-2 text-left text-sm ${
|
className={`flex items-center gap-2 rounded-lg px-3 py-2 text-left text-sm ${
|
||||||
selectedPlanId === p.id
|
selectedPlanId === p.id ? "bg-accent-soft font-medium text-accent-soft-fg" : "text-muted hover:bg-surface-2"
|
||||||
? "bg-indigo-50 font-medium text-indigo-700 dark:bg-indigo-500/15 dark:text-indigo-300"
|
|
||||||
: "text-zinc-600 hover:bg-zinc-100 dark:text-zinc-300 dark:hover:bg-zinc-800"
|
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<FolderKanban className="h-4 w-4 shrink-0" />
|
<FolderKanban className="h-4 w-4 shrink-0" />
|
||||||
<span className="min-w-0 flex-1 truncate">{p.name}</span>
|
<span className="min-w-0 flex-1 truncate">{p.name}</span>
|
||||||
<span className="text-[11px] text-zinc-400">{p.phases.length}</span>
|
<span className="text-[11px] text-faint">{p.phases.length}</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
@@ -157,20 +139,18 @@ export function AppShell({
|
|||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen w-full">
|
<div className="flex min-h-screen w-full">
|
||||||
{/* Sidebar Desktop */}
|
{/* Sidebar Desktop */}
|
||||||
<aside className="hidden w-60 shrink-0 border-r border-zinc-200 bg-white lg:block dark:border-zinc-800 dark:bg-zinc-900">
|
<aside className="hidden w-60 shrink-0 border-r border-border bg-surface lg:block">{sidebar}</aside>
|
||||||
{sidebar}
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
{/* Sidebar Mobile (Overlay) */}
|
{/* Sidebar Mobile (Overlay) */}
|
||||||
{sidebarOpen && (
|
{sidebarOpen && (
|
||||||
<div className="fixed inset-0 z-40 lg:hidden">
|
<div className="fixed inset-0 z-40 lg:hidden">
|
||||||
<div className="absolute inset-0 bg-black/40" onClick={() => setSidebarOpen(false)} />
|
<div className="absolute inset-0 bg-black/40" onClick={() => setSidebarOpen(false)} />
|
||||||
<aside className="absolute left-0 top-0 h-full w-64 border-r border-zinc-200 bg-white shadow-xl dark:border-zinc-800 dark:bg-zinc-900">
|
<aside className="absolute left-0 top-0 h-full w-64 border-r border-border bg-surface shadow-xl">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setSidebarOpen(false)}
|
onClick={() => setSidebarOpen(false)}
|
||||||
aria-label="Menue schliessen"
|
aria-label="Menue schliessen"
|
||||||
className="absolute right-2 top-3 rounded-md p-1.5 text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-800"
|
className="absolute right-2 top-3 rounded-md p-1.5 text-faint hover:bg-surface-2"
|
||||||
>
|
>
|
||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
@@ -181,33 +161,23 @@ export function AppShell({
|
|||||||
|
|
||||||
{/* Hauptbereich */}
|
{/* Hauptbereich */}
|
||||||
<div className="flex min-w-0 flex-1 flex-col">
|
<div className="flex min-w-0 flex-1 flex-col">
|
||||||
<header className="flex items-center gap-3 border-b border-zinc-200 bg-white px-4 py-3 dark:border-zinc-800 dark:bg-zinc-900">
|
<header className="flex items-center gap-3 border-b border-border bg-surface px-4 py-3">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setSidebarOpen(true)}
|
onClick={() => setSidebarOpen(true)}
|
||||||
aria-label="Menue oeffnen"
|
aria-label="Menue oeffnen"
|
||||||
className="rounded-lg border border-zinc-200 p-2 text-zinc-600 lg:hidden dark:border-zinc-700 dark:text-zinc-300"
|
className="rounded-lg border border-border p-2 text-muted lg:hidden"
|
||||||
>
|
>
|
||||||
<Menu className="h-4 w-4" />
|
<Menu className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
<h1 className="min-w-0 flex-1 truncate text-base font-semibold text-zinc-900 dark:text-zinc-50">
|
<h1 className="min-w-0 flex-1 truncate text-base font-semibold text-fg">
|
||||||
{activePlan ? activePlan.name : "Uebersicht"}
|
{activePlan ? activePlan.name : "Uebersicht"}
|
||||||
</h1>
|
</h1>
|
||||||
<ProfileMenu username={username} onOpenHouseholdSettings={() => setShowSettings(true)} />
|
<ProfileMenu username={username} />
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main className="flex-1 px-4 py-6 lg:px-8">
|
<main className="flex-1 px-4 py-6 lg:px-8">
|
||||||
{showSettings && (
|
{loading && <p className="text-sm text-muted">Laedt…</p>}
|
||||||
<div className="mb-6">
|
|
||||||
<HouseholdSettings
|
|
||||||
household={household}
|
|
||||||
onUpdated={setHousehold}
|
|
||||||
onClose={() => setShowSettings(false)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{loading && <p className="text-sm text-zinc-500">Laedt…</p>}
|
|
||||||
|
|
||||||
{!loading && selectedPlanId === null && (
|
{!loading && selectedPlanId === null && (
|
||||||
<DashboardHome
|
<DashboardHome
|
||||||
@@ -226,7 +196,7 @@ export function AppShell({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowScenario(true)}
|
onClick={() => setShowScenario(true)}
|
||||||
className="flex items-center gap-1.5 rounded-lg border border-zinc-300 px-3 py-1.5 text-sm font-medium text-zinc-700 hover:bg-zinc-50 dark:border-zinc-700 dark:text-zinc-200 dark:hover:bg-zinc-800"
|
className="flex items-center gap-1.5 rounded-lg border border-border px-3 py-1.5 text-sm font-medium text-muted hover:bg-surface-2"
|
||||||
>
|
>
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
Szenario
|
Szenario
|
||||||
@@ -235,23 +205,16 @@ export function AppShell({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleDeletePlan(selectedPlanId)}
|
onClick={() => handleDeletePlan(selectedPlanId)}
|
||||||
className="flex items-center gap-1.5 rounded-lg border border-zinc-300 px-3 py-1.5 text-sm font-medium text-zinc-500 hover:border-red-200 hover:bg-red-50 hover:text-red-600 dark:border-zinc-700 dark:hover:border-red-500/30 dark:hover:bg-red-950"
|
className="flex items-center gap-1.5 rounded-lg border border-border px-3 py-1.5 text-sm font-medium text-muted hover:border-danger hover:bg-danger-soft hover:text-danger"
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
Plan loeschen
|
Plan loeschen
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<PlanView
|
<PlanView plan={detail.plan} computed={detail.computed} onChanged={refreshCurrent} />
|
||||||
plan={detail.plan}
|
|
||||||
household={household}
|
|
||||||
computed={detail.computed}
|
|
||||||
onChanged={refreshCurrent}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{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} />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
@@ -260,10 +223,8 @@ export function AppShell({
|
|||||||
{/* Dialoge */}
|
{/* Dialoge */}
|
||||||
{showNewPlan && (
|
{showNewPlan && (
|
||||||
<PlanDialog
|
<PlanDialog
|
||||||
title="Neuen Plan erstellen"
|
onCreate={async (name, profile) => {
|
||||||
defaultName="Basisplan"
|
const { plan } = await api.post<{ plan: { id: string } }>("/api/plans", { name, ...profile });
|
||||||
onCreate={async (name) => {
|
|
||||||
const { plan } = await api.post<{ plan: { id: string } }>("/api/plans", { name });
|
|
||||||
setShowNewPlan(false);
|
setShowNewPlan(false);
|
||||||
await loadPlans(plan.id);
|
await loadPlans(plan.id);
|
||||||
}}
|
}}
|
||||||
@@ -305,10 +266,8 @@ function DashboardHome({
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-xl font-semibold text-zinc-900 dark:text-zinc-50">
|
<h2 className="text-xl font-semibold text-fg">Willkommen, {username}</h2>
|
||||||
Willkommen, {username}
|
<p className="mt-1 text-sm text-muted">
|
||||||
</h2>
|
|
||||||
<p className="mt-1 text-sm text-zinc-500 dark:text-zinc-400">
|
|
||||||
Waehlen Sie einen Plan oder erstellen Sie einen neuen, um Ihre finanzielle Zukunft zu planen.
|
Waehlen Sie einen Plan oder erstellen Sie einen neuen, um Ihre finanzielle Zukunft zu planen.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -317,16 +276,16 @@ function DashboardHome({
|
|||||||
{plans.map((p) => (
|
{plans.map((p) => (
|
||||||
<div
|
<div
|
||||||
key={p.id}
|
key={p.id}
|
||||||
className="group relative flex cursor-pointer flex-col gap-2 rounded-xl border border-zinc-200/70 bg-white p-4 shadow-sm transition-shadow hover:shadow-md dark:border-zinc-800 dark:bg-zinc-900"
|
className="group relative flex cursor-pointer flex-col gap-2 rounded-xl border border-border bg-surface p-4 shadow-sm transition-shadow hover:shadow-md"
|
||||||
onClick={() => onSelect(p.id)}
|
onClick={() => onSelect(p.id)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-indigo-100 dark:bg-indigo-500/20">
|
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-accent-soft">
|
||||||
<FolderKanban className="h-5 w-5 text-indigo-600 dark:text-indigo-300" />
|
<FolderKanban className="h-5 w-5 text-accent-soft-fg" />
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="truncate font-medium text-zinc-900 dark:text-zinc-100">{p.name}</div>
|
<div className="truncate font-medium text-fg">{p.name}</div>
|
||||||
<div className="text-xs text-zinc-500">
|
<div className="text-xs text-muted">
|
||||||
{p.phases.length} {p.phases.length === 1 ? "Phase" : "Phasen"}
|
{p.phases.length} {p.phases.length === 1 ? "Phase" : "Phasen"}
|
||||||
{p.parentPlanId ? " · Szenario" : ""}
|
{p.parentPlanId ? " · Szenario" : ""}
|
||||||
</div>
|
</div>
|
||||||
@@ -338,7 +297,7 @@ function DashboardHome({
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onDelete(p.id);
|
onDelete(p.id);
|
||||||
}}
|
}}
|
||||||
className="rounded-md p-1.5 text-zinc-300 opacity-0 transition-opacity hover:bg-red-50 hover:text-red-600 group-hover:opacity-100 dark:hover:bg-red-950"
|
className="rounded-md p-1.5 text-faint opacity-0 transition-opacity hover:bg-danger-soft hover:text-danger group-hover:opacity-100"
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
@@ -349,7 +308,7 @@ function DashboardHome({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onCreate}
|
onClick={onCreate}
|
||||||
className="flex min-h-20 items-center justify-center gap-2 rounded-xl border border-dashed border-indigo-300 bg-indigo-50/40 text-sm font-medium text-indigo-700 hover:bg-indigo-50 dark:border-indigo-500/30 dark:bg-indigo-500/5 dark:text-indigo-300 dark:hover:bg-indigo-500/15"
|
className="flex min-h-20 items-center justify-center gap-2 rounded-xl border border-dashed border-accent bg-accent-soft text-sm font-medium text-accent-soft-fg hover:bg-accent-soft"
|
||||||
>
|
>
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
Neuer Plan
|
Neuer Plan
|
||||||
@@ -360,43 +319,50 @@ function DashboardHome({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function PlanDialog({
|
function PlanDialog({
|
||||||
title,
|
|
||||||
defaultName,
|
|
||||||
onCreate,
|
onCreate,
|
||||||
onClose,
|
onClose,
|
||||||
}: {
|
}: {
|
||||||
title: string;
|
onCreate: (name: string, profile: ProfileDraft) => void;
|
||||||
defaultName: string;
|
|
||||||
onCreate: (name: string) => void;
|
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [name, setName] = useState(defaultName);
|
const [name, setName] = useState("Basisplan");
|
||||||
|
const [draft, setDraft] = useState<ProfileDraft>(emptyProfileDraft);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-40 flex items-center justify-center bg-black/40 px-4" onClick={onClose}>
|
<div className="fixed inset-0 z-40 flex items-start justify-center overflow-y-auto bg-black/40 px-4 py-8" onClick={onClose}>
|
||||||
<div
|
<div
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
className="flex w-full max-w-sm flex-col gap-3 rounded-2xl border border-zinc-200 bg-white p-6 shadow-xl dark:border-zinc-700 dark:bg-zinc-900"
|
className="flex w-full max-w-md flex-col gap-4 rounded-2xl border border-border bg-surface p-6 shadow-xl"
|
||||||
>
|
>
|
||||||
<h2 className="text-base font-semibold text-zinc-900 dark:text-zinc-50">{title}</h2>
|
<h2 className="text-base font-semibold text-fg">Neuen Plan erstellen</h2>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs font-medium text-muted">Name des Plans</label>
|
||||||
<input
|
<input
|
||||||
autoFocus
|
autoFocus
|
||||||
className="w-full rounded-lg border border-zinc-300 px-3 py-2 text-sm focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/20 dark:border-zinc-600 dark:bg-zinc-950"
|
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-fg focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/25"
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(e) => setName(e.target.value)}
|
onChange={(e) => setName(e.target.value)}
|
||||||
placeholder="Name des Plans"
|
placeholder="Name des Plans"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
<PlanProfileFields draft={draft} onChange={setDraft} />
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onCreate(name)}
|
disabled={saving}
|
||||||
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"
|
onClick={() => {
|
||||||
|
setSaving(true);
|
||||||
|
onCreate(name.trim() || "Plan", draft);
|
||||||
|
}}
|
||||||
|
className="rounded-lg bg-accent px-4 py-2 text-sm font-medium text-accent-fg shadow-sm hover:bg-accent-hover disabled:opacity-50"
|
||||||
>
|
>
|
||||||
Erstellen
|
{saving ? "..." : "Erstellen"}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="rounded-lg border border-zinc-300 px-4 py-2 text-sm font-medium text-zinc-700 hover:bg-zinc-50 dark:border-zinc-700 dark:text-zinc-200 dark:hover:bg-zinc-800"
|
className="rounded-lg border border-border px-4 py-2 text-sm font-medium text-muted hover:bg-surface-2"
|
||||||
>
|
>
|
||||||
Abbrechen
|
Abbrechen
|
||||||
</button>
|
</button>
|
||||||
@@ -421,19 +387,19 @@ function ScenarioDialog({
|
|||||||
<div className="fixed inset-0 z-40 flex items-center justify-center bg-black/40 px-4" onClick={onClose}>
|
<div className="fixed inset-0 z-40 flex items-center justify-center bg-black/40 px-4" onClick={onClose}>
|
||||||
<div
|
<div
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
className="flex w-full max-w-sm flex-col gap-3 rounded-2xl border border-zinc-200 bg-white p-6 shadow-xl dark:border-zinc-700 dark:bg-zinc-900"
|
className="flex w-full max-w-sm flex-col gap-3 rounded-2xl border border-border bg-surface p-6 shadow-xl"
|
||||||
>
|
>
|
||||||
<h2 className="text-base font-semibold text-zinc-900 dark:text-zinc-50">Szenario erstellen</h2>
|
<h2 className="text-base font-semibold text-fg">Szenario erstellen</h2>
|
||||||
<input
|
<input
|
||||||
autoFocus
|
autoFocus
|
||||||
className="w-full rounded-lg border border-zinc-300 px-3 py-2 text-sm focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/20 dark:border-zinc-600 dark:bg-zinc-950"
|
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-fg focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/25"
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(e) => setName(e.target.value)}
|
onChange={(e) => setName(e.target.value)}
|
||||||
placeholder="Name des Szenarios"
|
placeholder="Name des Szenarios"
|
||||||
/>
|
/>
|
||||||
<label className="text-xs text-zinc-500">Verzweigen ab Phase</label>
|
<label className="text-xs text-muted">Verzweigen ab Phase</label>
|
||||||
<select
|
<select
|
||||||
className="w-full rounded-lg border border-zinc-300 px-3 py-2 text-sm focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/20 dark:border-zinc-600 dark:bg-zinc-950"
|
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-fg focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/25"
|
||||||
value={branchFromPhaseId}
|
value={branchFromPhaseId}
|
||||||
onChange={(e) => setBranchFromPhaseId(e.target.value)}
|
onChange={(e) => setBranchFromPhaseId(e.target.value)}
|
||||||
>
|
>
|
||||||
@@ -447,14 +413,14 @@ function ScenarioDialog({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onCreate(name, branchFromPhaseId)}
|
onClick={() => onCreate(name, branchFromPhaseId)}
|
||||||
className="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-500 dark:bg-indigo-500 dark:hover:bg-indigo-400"
|
className="rounded-lg bg-accent px-4 py-2 text-sm font-medium text-accent-fg shadow-sm hover:bg-accent-hover"
|
||||||
>
|
>
|
||||||
Erstellen
|
Erstellen
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="rounded-lg border border-zinc-300 px-4 py-2 text-sm font-medium text-zinc-700 hover:bg-zinc-50 dark:border-zinc-700 dark:text-zinc-200 dark:hover:bg-zinc-800"
|
className="rounded-lg border border-border px-4 py-2 text-sm font-medium text-muted hover:bg-surface-2"
|
||||||
>
|
>
|
||||||
Abbrechen
|
Abbrechen
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -86,15 +86,15 @@ export function Dashboard({
|
|||||||
<StatCard label="Geschaetzter Nachlass" value={computed.nachlass} help="Endvermoegen der letzten Phase - potenziell vererbbar." />
|
<StatCard label="Geschaetzter Nachlass" value={computed.nachlass} help="Endvermoegen der letzten Phase - potenziell vererbbar." />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section className="rounded-xl border border-zinc-200/70 bg-white p-4 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
|
<section className="rounded-xl border border-border bg-surface p-4 shadow-sm">
|
||||||
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
|
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||||
<h3 className="flex items-center gap-1.5 text-sm font-semibold text-zinc-800 dark:text-zinc-100">
|
<h3 className="flex items-center gap-1.5 text-sm font-semibold text-fg">
|
||||||
<LineChartIcon className="h-4 w-4 text-indigo-500 dark:text-indigo-400" />
|
<LineChartIcon className="h-4 w-4 text-accent" />
|
||||||
Vermoegensverlauf
|
Vermoegensverlauf
|
||||||
</h3>
|
</h3>
|
||||||
<a
|
<a
|
||||||
href={`/api/plans/${plan.id}/export`}
|
href={`/api/plans/${plan.id}/export`}
|
||||||
className="flex items-center gap-1.5 rounded-lg border border-zinc-300 px-3 py-1.5 text-xs font-medium text-zinc-700 hover:bg-zinc-50 dark:border-zinc-700 dark:text-zinc-200 dark:hover:bg-zinc-800"
|
className="flex items-center gap-1.5 rounded-lg border border-border px-3 py-1.5 text-xs font-medium text-muted hover:bg-surface-2"
|
||||||
>
|
>
|
||||||
<Download className="h-3.5 w-3.5" />
|
<Download className="h-3.5 w-3.5" />
|
||||||
CSV-Export
|
CSV-Export
|
||||||
@@ -102,9 +102,9 @@ export function Dashboard({
|
|||||||
</div>
|
</div>
|
||||||
{otherPlans.length > 0 && (
|
{otherPlans.length > 0 && (
|
||||||
<div className="mb-3 flex flex-wrap gap-2">
|
<div className="mb-3 flex flex-wrap gap-2">
|
||||||
<span className="text-xs text-zinc-500">Vergleichen mit:</span>
|
<span className="text-xs text-muted">Vergleichen mit:</span>
|
||||||
{otherPlans.map((p) => (
|
{otherPlans.map((p) => (
|
||||||
<label key={p.id} className="flex items-center gap-1 text-xs text-zinc-600 dark:text-zinc-300">
|
<label key={p.id} className="flex items-center gap-1 text-xs text-muted">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={compareIds.includes(p.id)}
|
checked={compareIds.includes(p.id)}
|
||||||
@@ -118,15 +118,15 @@ export function Dashboard({
|
|||||||
<WealthChart series={series} />
|
<WealthChart series={series} />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="rounded-xl border border-zinc-200/70 bg-white p-4 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
|
<section className="rounded-xl border border-border bg-surface p-4 shadow-sm">
|
||||||
<h3 className="mb-3 flex items-center gap-1.5 text-sm font-semibold text-zinc-800 dark:text-zinc-100">
|
<h3 className="mb-3 flex items-center gap-1.5 text-sm font-semibold text-fg">
|
||||||
<BarChart3 className="h-4 w-4 text-indigo-500 dark:text-indigo-400" />
|
<BarChart3 className="h-4 w-4 text-accent" />
|
||||||
Vermoegensaufteilung pro Phase (Endvermoegen)
|
Vermoegensaufteilung pro Phase (Endvermoegen)
|
||||||
</h3>
|
</h3>
|
||||||
<div className="h-72 w-full">
|
<div className="h-72 w-full">
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<BarChart data={barData} margin={{ top: 8, right: 16, left: 8, bottom: 8 }}>
|
<BarChart data={barData} margin={{ top: 8, right: 16, left: 8, bottom: 8 }}>
|
||||||
<CartesianGrid strokeDasharray="3 3" className="stroke-zinc-200 dark:stroke-zinc-700" />
|
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
|
||||||
<XAxis dataKey="phase" tick={{ fontSize: 11 }} />
|
<XAxis dataKey="phase" tick={{ fontSize: 11 }} />
|
||||||
<YAxis
|
<YAxis
|
||||||
tick={{ fontSize: 11 }}
|
tick={{ fontSize: 11 }}
|
||||||
@@ -147,11 +147,11 @@ export function Dashboard({
|
|||||||
|
|
||||||
function StatCard({ label, value, help }: { label: string; value: number; help?: string }) {
|
function StatCard({ label, value, help }: { label: string; value: number; help?: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border border-zinc-200/70 bg-white p-4 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
|
<div className="rounded-xl border border-border bg-surface p-4 shadow-sm">
|
||||||
<div className="text-xs text-zinc-500 dark:text-zinc-400" title={help}>
|
<div className="text-xs text-muted" title={help}>
|
||||||
{label}
|
{label}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 text-xl font-semibold text-indigo-600 dark:text-indigo-400">
|
<div className="mt-1 text-xl font-semibold text-accent">
|
||||||
{formatChf(value)} CHF
|
{formatChf(value)} CHF
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+197
-108
@@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Trash2 } from "lucide-react";
|
import { Trash2 } from "lucide-react";
|
||||||
import { MoneyField, NumberField, SelectField } from "@/components/FormField";
|
import { FieldLabel, MoneyField, NumberField, SelectField } from "@/components/FormField";
|
||||||
|
import { formatChf } from "@/lib/format";
|
||||||
import { api } from "@/lib/api-client";
|
import { api } from "@/lib/api-client";
|
||||||
import { CATEGORY_LABELS, num } from "@/lib/elements";
|
import { CATEGORY_LABELS, num } from "@/lib/elements";
|
||||||
import { PILLAR_3A_MAX_ANNUAL } from "@/lib/constants";
|
import { PILLAR_3A_MAX_ANNUAL } from "@/lib/constants";
|
||||||
@@ -16,6 +17,10 @@ export interface CellContext {
|
|||||||
durationYears: number;
|
durationYears: number;
|
||||||
isRetirementTransition: boolean;
|
isRetirementTransition: boolean;
|
||||||
carriedEndValue: number; // Endwert des Elements in der (Vor-)Phase, fuer Bezugs-Maxima
|
carriedEndValue: number; // Endwert des Elements in der (Vor-)Phase, fuer Bezugs-Maxima
|
||||||
|
carried: boolean; // Phase >= 2: Startwert wird aus der Vorphase fortgeschrieben
|
||||||
|
derivedStart: number; // fortgeschriebener Basis-Startwert (read-only Anzeige)
|
||||||
|
quotaRateMax: number; // Max fuer eine Sparrate/Verzehrrate dieses Elements
|
||||||
|
capitalMax?: number; // Max fuer Startkapital/Neuinvestition (undefined = kein Cap)
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -27,89 +32,41 @@ interface Props {
|
|||||||
onDeleteElement: () => void;
|
onDeleteElement: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ElementDetail({ element, context, phaseData, transitionData, onSaved, onDeleteElement }: Props) {
|
// Read-only Anzeige eines abgeleiteten (fortgeschriebenen) Wertes.
|
||||||
const [pd, setPd] = useState<PhaseData>({ ...phaseData });
|
function DerivedField({ label, value, help }: { label: string; value: number; help?: string }) {
|
||||||
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 (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<div className="flex items-start justify-between gap-2">
|
|
||||||
<div>
|
<div>
|
||||||
<div className="text-xs font-semibold uppercase tracking-wide text-indigo-600 dark:text-indigo-400">
|
<FieldLabel label={label} help={help} />
|
||||||
{CATEGORY_LABELS[element.category]}
|
<div className="w-full rounded-lg border border-dashed border-border bg-surface-2 px-2.5 py-1.5 text-sm text-muted">
|
||||||
{isTransition ? " · Uebergang" : ""}
|
{formatChf(value)}
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
function setP(patch: Partial<PhaseData>) {
|
|
||||||
setPd((prev) => ({ ...prev, ...patch }));
|
|
||||||
}
|
|
||||||
function setT(patch: Partial<TransitionData>) {
|
|
||||||
setTd((prev) => ({ ...prev, ...patch }));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderPhaseFields() {
|
// --- Wiederverwendbare Feldgruppen (Detail-Panel, Erstell-Popup, Uebergangs-Review) ---
|
||||||
|
|
||||||
|
export function ElementPhaseFields({
|
||||||
|
element,
|
||||||
|
context,
|
||||||
|
pd,
|
||||||
|
setP,
|
||||||
|
}: {
|
||||||
|
element: { category: ElementCategory };
|
||||||
|
context: CellContext;
|
||||||
|
pd: PhaseData;
|
||||||
|
setP: (patch: Partial<PhaseData>) => void;
|
||||||
|
}) {
|
||||||
|
const carried = context.carried;
|
||||||
switch (element.category) {
|
switch (element.category) {
|
||||||
case "INCOME":
|
case "INCOME":
|
||||||
return (
|
return <MoneyField label="Jahreseinkommen (CHF)" value={num(pd.amount)} onChange={(v) => setP({ amount: v })} />;
|
||||||
<MoneyField label="Jahreseinkommen (CHF)" value={num(pd.amount)} onChange={(v) => setP({ amount: v })} />
|
|
||||||
);
|
|
||||||
case "EXPENSE":
|
case "EXPENSE":
|
||||||
return (
|
return <MoneyField label="Jahresausgaben (CHF)" value={num(pd.amount)} onChange={(v) => setP({ amount: v })} />;
|
||||||
<MoneyField label="Jahresausgaben (CHF)" value={num(pd.amount)} onChange={(v) => setP({ amount: v })} />
|
|
||||||
);
|
|
||||||
case "AHV":
|
case "AHV":
|
||||||
if (!context.ownerWorking) {
|
if (!context.ownerWorking) {
|
||||||
return (
|
return (
|
||||||
<p className="col-span-2 text-sm text-zinc-500 dark:text-zinc-400">
|
<p className="col-span-2 text-sm text-muted">
|
||||||
Die AHV-Rente wird automatisch aus den bisherigen Ausfalljahren berechnet (siehe Kennzahl in der
|
Die AHV-Rente wird automatisch aus den bisherigen Ausfalljahren berechnet (siehe Kennzahl in der
|
||||||
Matrix). Bei Ehepaaren greift die Plafonierung auf 150% der Maximalrente.
|
Matrix). Bei Ehepaaren greift die Plafonierung auf 150% der Maximalrente.
|
||||||
</p>
|
</p>
|
||||||
@@ -128,7 +85,7 @@ export function ElementDetail({ element, context, phaseData, transitionData, onS
|
|||||||
case "PENSION_FUND":
|
case "PENSION_FUND":
|
||||||
if (!context.ownerWorking) {
|
if (!context.ownerWorking) {
|
||||||
return (
|
return (
|
||||||
<p className="col-span-2 text-sm text-zinc-500 dark:text-zinc-400">
|
<p className="col-span-2 text-sm text-muted">
|
||||||
Die PK-Rente wird aus dem beim Pensions-Uebergang gewaehlten Umwandlungssatz berechnet (siehe
|
Die PK-Rente wird aus dem beim Pensions-Uebergang gewaehlten Umwandlungssatz berechnet (siehe
|
||||||
Kennzahl). Bei reinem Kapitalbezug erscheint hier "Vollstaendig bezogen".
|
Kennzahl). Bei reinem Kapitalbezug erscheint hier "Vollstaendig bezogen".
|
||||||
</p>
|
</p>
|
||||||
@@ -136,7 +93,20 @@ export function ElementDetail({ element, context, phaseData, transitionData, onS
|
|||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<MoneyField label="Aktueller PK-Wert (CHF)" value={num(pd.currentValue)} onChange={(v) => setP({ currentValue: v })} />
|
{carried ? (
|
||||||
|
<>
|
||||||
|
<DerivedField label="Startwert (fortgeschrieben)" value={context.derivedStart} help="Endwert der Vorphase, fortgeschrieben." />
|
||||||
|
<MoneyField
|
||||||
|
label="Zusatzeinlage aus Kapital (CHF)"
|
||||||
|
help="Aufstockung aus dem verfuegbaren Kapital dieser Phase."
|
||||||
|
value={num(pd.additionalInvestment)}
|
||||||
|
max={context.capitalMax}
|
||||||
|
onChange={(v) => setP({ additionalInvestment: v })}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<MoneyField label="Aktueller PK-Wert (CHF)" value={num(pd.currentValue)} max={context.capitalMax} onChange={(v) => setP({ currentValue: v })} />
|
||||||
|
)}
|
||||||
<MoneyField
|
<MoneyField
|
||||||
label="Jaehrliche Einzahlung (CHF)"
|
label="Jaehrliche Einzahlung (CHF)"
|
||||||
help="Arbeitnehmer- und Arbeitgeberbeitraege. Fliesst NICHT in die Sparquote ein (bereits in den Ausgaben beruecksichtigt)."
|
help="Arbeitnehmer- und Arbeitgeberbeitraege. Fliesst NICHT in die Sparquote ein (bereits in den Ausgaben beruecksichtigt)."
|
||||||
@@ -148,36 +118,58 @@ export function ElementDetail({ element, context, phaseData, transitionData, onS
|
|||||||
);
|
);
|
||||||
case "PILLAR_3A":
|
case "PILLAR_3A":
|
||||||
if (!context.ownerWorking) {
|
if (!context.ownerWorking) {
|
||||||
return (
|
return <p className="col-span-2 text-sm text-muted">Die Saeule 3a wird beim Pensions-Uebergang vollstaendig bezogen.</p>;
|
||||||
<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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<MoneyField label="Aktueller 3a-Wert (CHF)" value={num(pd.currentValue)} onChange={(v) => setP({ currentValue: v })} />
|
{carried ? (
|
||||||
<NumberField
|
<>
|
||||||
|
<DerivedField label="Startwert (fortgeschrieben)" value={context.derivedStart} help="Endwert der Vorphase, fortgeschrieben." />
|
||||||
|
<MoneyField
|
||||||
|
label="Zusatzeinlage aus Kapital (CHF)"
|
||||||
|
help="Aufstockung aus dem verfuegbaren Kapital dieser Phase."
|
||||||
|
value={num(pd.additionalInvestment)}
|
||||||
|
max={context.capitalMax}
|
||||||
|
onChange={(v) => setP({ additionalInvestment: v })}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<MoneyField label="Aktueller 3a-Wert (CHF)" value={num(pd.currentValue)} max={context.capitalMax} onChange={(v) => setP({ currentValue: v })} />
|
||||||
|
)}
|
||||||
|
<MoneyField
|
||||||
label="Jaehrliche Einzahlung (CHF)"
|
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.`}
|
help={`Maximal CHF ${PILLAR_3A_MAX_ANNUAL.toLocaleString("de-CH")} (2026, mit PK) und hoechstens die Sparquote. Wird von der Sparquote abgezogen.`}
|
||||||
step={100}
|
|
||||||
min={0}
|
|
||||||
max={PILLAR_3A_MAX_ANNUAL}
|
|
||||||
value={num(pd.annualContribution)}
|
value={num(pd.annualContribution)}
|
||||||
onChange={(v) => setP({ annualContribution: Math.max(0, Math.min(PILLAR_3A_MAX_ANNUAL, Math.round(v / 100) * 100)) })}
|
max={Math.min(PILLAR_3A_MAX_ANNUAL, context.quotaRateMax)}
|
||||||
|
onChange={(v) => setP({ annualContribution: v })}
|
||||||
/>
|
/>
|
||||||
<NumberField label="Erwartete Rendite (%/Jahr)" step={0.1} value={num(pd.expectedReturn)} onChange={(v) => setP({ expectedReturn: v })} />
|
<NumberField label="Erwartete Rendite (%/Jahr)" step={0.1} value={num(pd.expectedReturn)} onChange={(v) => setP({ expectedReturn: v })} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
case "REAL_ESTATE":
|
case "REAL_ESTATE":
|
||||||
|
if (carried) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<MoneyField label="Kaufpreis (CHF)" value={num(pd.purchasePrice)} onChange={(v) => setP({ purchasePrice: v })} />
|
<DerivedField label="Startwert Netto (fortgeschrieben)" value={context.derivedStart} help="Kaufpreis minus fortgeschriebene Resthypothek." />
|
||||||
|
<MoneyField
|
||||||
|
label="Amortisation (CHF/Jahr)"
|
||||||
|
help="Jaehrliche Reduktion der Hypothek. Zaehlt gegen die Sparquote."
|
||||||
|
value={num(pd.amortization)}
|
||||||
|
max={context.quotaRateMax}
|
||||||
|
onChange={(v) => setP({ amortization: v })}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<MoneyField label="Kaufpreis (CHF)" value={num(pd.purchasePrice)} max={context.capitalMax != null ? context.capitalMax + num(pd.mortgage) : undefined} onChange={(v) => setP({ purchasePrice: v })} />
|
||||||
<MoneyField label="Hypothek (CHF)" value={num(pd.mortgage)} onChange={(v) => setP({ mortgage: v })} />
|
<MoneyField label="Hypothek (CHF)" value={num(pd.mortgage)} onChange={(v) => setP({ mortgage: v })} />
|
||||||
<MoneyField
|
<MoneyField
|
||||||
label="Amortisation (CHF/Jahr)"
|
label="Amortisation (CHF/Jahr)"
|
||||||
help="Jaehrliche Reduktion der Hypothek."
|
help="Jaehrliche Reduktion der Hypothek. Zaehlt gegen die Sparquote."
|
||||||
value={num(pd.amortization)}
|
value={num(pd.amortization)}
|
||||||
|
max={context.quotaRateMax}
|
||||||
onChange={(v) => setP({ amortization: v })}
|
onChange={(v) => setP({ amortization: v })}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
@@ -185,16 +177,30 @@ export function ElementDetail({ element, context, phaseData, transitionData, onS
|
|||||||
case "OTHER_ASSET":
|
case "OTHER_ASSET":
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<MoneyField label="Startwert (CHF)" value={num(pd.startValue)} onChange={(v) => setP({ startValue: v })} />
|
{carried ? (
|
||||||
|
<>
|
||||||
|
<DerivedField label="Startwert (fortgeschrieben)" value={context.derivedStart} help="Endwert der Vorphase, fortgeschrieben." />
|
||||||
|
<MoneyField
|
||||||
|
label="Zusatzinvestition aus Kapital (CHF)"
|
||||||
|
help="Neuinvestition aus dem verfuegbaren Kapital dieser Phase."
|
||||||
|
value={num(pd.additionalInvestment)}
|
||||||
|
max={context.capitalMax}
|
||||||
|
onChange={(v) => setP({ additionalInvestment: v })}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<MoneyField label="Startwert (CHF)" value={num(pd.startValue)} max={context.capitalMax} onChange={(v) => setP({ startValue: v })} />
|
||||||
|
)}
|
||||||
<NumberField label="Erwartete Rendite (%/Jahr)" step={0.1} value={num(pd.expectedReturn)} onChange={(v) => setP({ expectedReturn: v })} />
|
<NumberField label="Erwartete Rendite (%/Jahr)" step={0.1} value={num(pd.expectedReturn)} onChange={(v) => setP({ expectedReturn: v })} />
|
||||||
<MoneyField
|
<MoneyField
|
||||||
label={context.isConsumption ? "Jaehrliche Bezugsrate (CHF)" : "Jaehrlicher Sparbeitrag (CHF)"}
|
label={context.isConsumption ? "Jaehrliche Bezugsrate (CHF)" : "Jaehrlicher Sparbeitrag (CHF)"}
|
||||||
help={
|
help={
|
||||||
context.isConsumption
|
context.isConsumption
|
||||||
? "In dieser Verzehrphase wird dieser Betrag jaehrlich entnommen und deckt die Verzehrquote."
|
? "In dieser Verzehrphase deckt dieser Betrag die Verzehrquote (max. die Verzehrquote)."
|
||||||
: "Wird von der Sparquote abgezogen."
|
: "Wird von der Sparquote abgezogen (max. die Sparquote)."
|
||||||
}
|
}
|
||||||
value={num(pd.annualContribution)}
|
value={num(pd.annualContribution)}
|
||||||
|
max={context.quotaRateMax}
|
||||||
onChange={(v) => setP({ annualContribution: v })}
|
onChange={(v) => setP({ annualContribution: v })}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
@@ -202,20 +208,40 @@ export function ElementDetail({ element, context, phaseData, transitionData, onS
|
|||||||
case "OTHER_DEBT":
|
case "OTHER_DEBT":
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
{carried ? (
|
||||||
|
<DerivedField label="Restschuld (fortgeschrieben)" value={Math.abs(context.derivedStart)} help="Fortgeschriebene Restschuld aus der Vorphase." />
|
||||||
|
) : (
|
||||||
<MoneyField label="Restschuld (CHF)" value={num(pd.startValue)} onChange={(v) => setP({ startValue: v })} />
|
<MoneyField label="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 })} />
|
)}
|
||||||
|
<MoneyField
|
||||||
|
label="Jaehrliche Tilgung (CHF)"
|
||||||
|
help="Zaehlt gegen die Sparquote (max. die Sparquote)."
|
||||||
|
value={num(pd.annualRepayment)}
|
||||||
|
max={context.quotaRateMax}
|
||||||
|
onChange={(v) => setP({ annualRepayment: v })}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderTransitionFields() {
|
export function ElementTransitionFields({
|
||||||
|
element,
|
||||||
|
context,
|
||||||
|
td,
|
||||||
|
setT,
|
||||||
|
}: {
|
||||||
|
element: { category: ElementCategory };
|
||||||
|
context: CellContext;
|
||||||
|
td: TransitionData;
|
||||||
|
setT: (patch: Partial<TransitionData>) => void;
|
||||||
|
}) {
|
||||||
switch (element.category) {
|
switch (element.category) {
|
||||||
case "INCOME":
|
case "INCOME":
|
||||||
case "EXPENSE":
|
case "EXPENSE":
|
||||||
case "AHV":
|
case "AHV":
|
||||||
return (
|
return (
|
||||||
<p className="col-span-2 text-sm text-zinc-500 dark:text-zinc-400">
|
<p className="col-span-2 text-sm text-muted">
|
||||||
Fuer diese Kategorie gibt es im Uebergang keine Eingaben. Die Werte werden 1:1 in die naechste
|
Fuer diese Kategorie gibt es im Uebergang keine Eingaben. Die Werte werden 1:1 in die naechste
|
||||||
Lebensphase uebernommen und koennen dort angepasst werden.
|
Lebensphase uebernommen und koennen dort angepasst werden.
|
||||||
</p>
|
</p>
|
||||||
@@ -245,17 +271,12 @@ export function ElementDetail({ element, context, phaseData, transitionData, onS
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{(mode === "CAPITAL" || mode === "COMBI") && (
|
{(mode === "CAPITAL" || mode === "COMBI") && (
|
||||||
<NumberField
|
<NumberField label="Kapitalbezugssteuer (%)" step={0.5} value={num(td.capitalTaxRate, 8)} onChange={(v) => setT({ capitalTaxRate: v })} />
|
||||||
label="Kapitalbezugssteuer (%)"
|
|
||||||
step={0.5}
|
|
||||||
value={num(td.capitalTaxRate, 8)}
|
|
||||||
onChange={(v) => setT({ capitalTaxRate: v })}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
{mode === "COMBI" && (
|
{mode === "COMBI" && (
|
||||||
<MoneyField
|
<MoneyField
|
||||||
label="Davon Kapitalbezug (CHF)"
|
label="Davon Kapitalbezug (CHF)"
|
||||||
help={`Der Rest wird verrentet. Maximal ${context.carriedEndValue.toLocaleString("de-CH")}.`}
|
help={`Der Rest wird verrentet. Maximal ${formatChf(context.carriedEndValue)}.`}
|
||||||
value={num(td.capitalAmount)}
|
value={num(td.capitalAmount)}
|
||||||
max={context.carriedEndValue}
|
max={context.carriedEndValue}
|
||||||
onChange={(v) => setT({ capitalAmount: v })}
|
onChange={(v) => setT({ capitalAmount: v })}
|
||||||
@@ -267,7 +288,7 @@ export function ElementDetail({ element, context, phaseData, transitionData, onS
|
|||||||
return (
|
return (
|
||||||
<MoneyField
|
<MoneyField
|
||||||
label="PK-Bezug (CHF)"
|
label="PK-Bezug (CHF)"
|
||||||
help={`Optionaler Bezug. Maximal ${context.carriedEndValue.toLocaleString("de-CH")} (Endwert der Vorphase).`}
|
help={`Optionaler Bezug. Maximal ${formatChf(context.carriedEndValue)} (Endwert der Vorphase).`}
|
||||||
value={num(td.withdrawal)}
|
value={num(td.withdrawal)}
|
||||||
max={context.carriedEndValue}
|
max={context.carriedEndValue}
|
||||||
onChange={(v) => setT({ withdrawal: v })}
|
onChange={(v) => setT({ withdrawal: v })}
|
||||||
@@ -288,7 +309,7 @@ export function ElementDetail({ element, context, phaseData, transitionData, onS
|
|||||||
return (
|
return (
|
||||||
<MoneyField
|
<MoneyField
|
||||||
label="3a-Bezug (CHF)"
|
label="3a-Bezug (CHF)"
|
||||||
help={`Maximal ${context.carriedEndValue.toLocaleString("de-CH")} (Endwert der Vorphase).`}
|
help={`Maximal ${formatChf(context.carriedEndValue)} (Endwert der Vorphase).`}
|
||||||
value={num(td.withdrawal)}
|
value={num(td.withdrawal)}
|
||||||
max={context.carriedEndValue}
|
max={context.carriedEndValue}
|
||||||
onChange={(v) => setT({ withdrawal: v })}
|
onChange={(v) => setT({ withdrawal: v })}
|
||||||
@@ -310,12 +331,7 @@ export function ElementDetail({ element, context, phaseData, transitionData, onS
|
|||||||
{decision === "SELL" && (
|
{decision === "SELL" && (
|
||||||
<>
|
<>
|
||||||
<MoneyField label="Verkaufspreis (CHF)" value={num(td.salePrice)} onChange={(v) => setT({ salePrice: v })} />
|
<MoneyField label="Verkaufspreis (CHF)" value={num(td.salePrice)} onChange={(v) => setT({ salePrice: v })} />
|
||||||
<NumberField
|
<NumberField label="Grundstueckgewinnsteuer (%)" step={1} value={num(td.saleTaxRate, 20)} onChange={(v) => setT({ saleTaxRate: v })} />
|
||||||
label="Grundstueckgewinnsteuer (%)"
|
|
||||||
step={1}
|
|
||||||
value={num(td.saleTaxRate, 20)}
|
|
||||||
onChange={(v) => setT({ saleTaxRate: v })}
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@@ -346,4 +362,77 @@ export function ElementDetail({ element, context, phaseData, transitionData, onS
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setP(patch: Partial<PhaseData>) {
|
||||||
|
setPd((prev) => ({ ...prev, ...patch }));
|
||||||
|
}
|
||||||
|
function setT(patch: Partial<TransitionData>) {
|
||||||
|
setTd((prev) => ({ ...prev, ...patch }));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div>
|
||||||
|
<div className="text-xs font-semibold uppercase tracking-wide text-accent-soft-fg">
|
||||||
|
{CATEGORY_LABELS[element.category]}
|
||||||
|
{isTransition ? " · Uebergang" : ""}
|
||||||
|
</div>
|
||||||
|
<div className="text-base font-semibold text-fg">{element.name}</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onDeleteElement}
|
||||||
|
className="flex items-center gap-1 rounded-lg border border-border px-2 py-1 text-xs text-muted hover:border-danger hover:bg-danger-soft hover:text-danger"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" /> Element loeschen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
|
{isTransition ? (
|
||||||
|
<ElementTransitionFields element={element} context={context} td={td} setT={setT} />
|
||||||
|
) : (
|
||||||
|
<ElementPhaseFields element={element} context={context} pd={pd} setP={setP} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="text-sm text-danger">{error}</p>}
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={saving}
|
||||||
|
onClick={save}
|
||||||
|
className="rounded-lg bg-accent px-4 py-2 text-sm font-medium text-accent-fg shadow-sm hover:bg-accent-hover disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{saving ? "Speichern..." : "Speichern"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,14 +3,14 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { ChevronDown, ChevronUp } from "lucide-react";
|
import { ChevronDown, ChevronUp } from "lucide-react";
|
||||||
import { InfoBubble } from "@/components/InfoBubble";
|
import { InfoBubble } from "@/components/InfoBubble";
|
||||||
import { floorToThousand, formatChf, parseChfInput } from "@/lib/format";
|
import { formatChf, parseChfInput } from "@/lib/format";
|
||||||
|
|
||||||
const baseInputClass =
|
const baseInputClass =
|
||||||
"w-full rounded-lg border border-zinc-300 bg-white px-2.5 py-1.5 text-sm text-zinc-900 shadow-sm transition-colors focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/20 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100";
|
"w-full rounded-lg border border-border bg-input px-2.5 py-1.5 text-sm text-fg shadow-sm transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/25";
|
||||||
|
|
||||||
export function FieldLabel({ label, help }: { label: string; help?: string }) {
|
export function FieldLabel({ label, help }: { label: string; help?: string }) {
|
||||||
return (
|
return (
|
||||||
<label className="mb-1 flex items-center text-xs font-medium text-zinc-600 dark:text-zinc-400">
|
<label className="mb-1 flex items-center text-xs font-medium text-muted">
|
||||||
{label}
|
{label}
|
||||||
{help && <InfoBubble text={help} />}
|
{help && <InfoBubble text={help} />}
|
||||||
</label>
|
</label>
|
||||||
@@ -44,32 +44,34 @@ export function NumberField({
|
|||||||
step={step ?? "any"}
|
step={step ?? "any"}
|
||||||
min={min}
|
min={min}
|
||||||
max={max}
|
max={max}
|
||||||
onFocus={(e) => e.target.select()}
|
onFocus={(e) => e.currentTarget.select()}
|
||||||
onChange={(e) => onChange(e.target.valueAsNumber || 0)}
|
onChange={(e) => onChange(e.target.valueAsNumber || 0)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Roher Betrags-Input ohne Label (fuer kompakte Tabellenzellen o.ae.). Zeigt den Wert
|
// Ganzzahliges Betragsfeld. Zeigt den Wert unfokussiert mit 1'000er-Trennzeichen an,
|
||||||
// formatiert mit 1'000er-Trennzeichen an, solange das Feld nicht fokussiert ist, rundet
|
// akzeptiert fokussiert beliebige ganze Zahlen (keine Nachkommastellen) und bietet
|
||||||
// beim Verlassen des Feldes auf ein Vielfaches von 1'000 ABwaerts (siehe lib/format.ts)
|
// Pfeil-Buttons mit Klick-und-Halten-BESCHLEUNIGUNG (1 -> 10 -> 100 -> 1'000 -> ...).
|
||||||
// und bietet Pfeil-Buttons zum Erhoehen/Verringern in 1'000er-Schritten.
|
// Optionales `max` (und `min`) klammern die Eingabe hart (Live-Cap).
|
||||||
// Optionales `max` kappt Eingaben live auf das verfuegbare Budget (z. B. Sparquote).
|
|
||||||
export function MoneyInput({
|
export function MoneyInput({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
className,
|
className,
|
||||||
|
min = 0,
|
||||||
max,
|
max,
|
||||||
}: {
|
}: {
|
||||||
value: number;
|
value: number;
|
||||||
onChange: (value: number) => void;
|
onChange: (value: number) => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
min?: number;
|
||||||
max?: number;
|
max?: number;
|
||||||
}) {
|
}) {
|
||||||
const [focused, setFocused] = useState(false);
|
const [focused, setFocused] = useState(false);
|
||||||
const [text, setText] = useState(() => String(Math.floor(value || 0)));
|
const [text, setText] = useState(() => String(Math.round(value || 0)));
|
||||||
const valueRef = useRef(value);
|
const valueRef = useRef(value);
|
||||||
|
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
valueRef.current = value;
|
valueRef.current = value;
|
||||||
}, [value]);
|
}, [value]);
|
||||||
@@ -77,13 +79,14 @@ export function MoneyInput({
|
|||||||
const holdInterval = useRef<ReturnType<typeof setInterval> | null>(null);
|
const holdInterval = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
|
||||||
function clamp(v: number): number {
|
function clamp(v: number): number {
|
||||||
let result = Math.max(0, v);
|
let result = Math.round(v);
|
||||||
if (max != null) result = Math.min(result, Math.max(0, floorToThousand(max)));
|
if (min != null) result = Math.max(min, result);
|
||||||
|
if (max != null) result = Math.min(result, Math.round(max));
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
function step(delta: number) {
|
function stepBy(delta: number) {
|
||||||
onChange(clamp(floorToThousand(valueRef.current) + delta));
|
onChange(clamp(Math.round(valueRef.current) + delta));
|
||||||
}
|
}
|
||||||
|
|
||||||
function stopHold() {
|
function stopHold() {
|
||||||
@@ -97,72 +100,91 @@ export function MoneyInput({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Klick-und-Halten: sofortiger erster Schritt, nach kurzer Verzoegerung
|
// Klick = 1 Schritt. Halten: nach 400 ms Wiederholung im 70-ms-Takt, wobei die
|
||||||
// fortlaufende Wiederholung, bis losgelassen wird.
|
// Schrittweite mit der Haltedauer waechst (immer schneller).
|
||||||
function startHold(delta: number) {
|
function startHold(sign: number) {
|
||||||
step(delta);
|
stepBy(sign);
|
||||||
|
const start = Date.now();
|
||||||
holdTimeout.current = setTimeout(() => {
|
holdTimeout.current = setTimeout(() => {
|
||||||
holdInterval.current = setInterval(() => step(delta), 100);
|
holdInterval.current = setInterval(() => {
|
||||||
|
const s = (Date.now() - start) / 1000;
|
||||||
|
const mag = s < 1.5 ? 1 : s < 3 ? 10 : s < 4.5 ? 100 : s < 6 ? 1000 : 10000;
|
||||||
|
stepBy(sign * mag);
|
||||||
|
}, 70);
|
||||||
}, 400);
|
}, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => stopHold, []);
|
useEffect(() => stopHold, []);
|
||||||
|
|
||||||
|
const arrowBtn =
|
||||||
|
"flex flex-1 items-center justify-center text-faint hover:bg-accent-soft hover:text-accent-soft-fg";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`relative ${className ?? "w-full"}`}>
|
<div className={`relative ${className ?? "w-full"}`}>
|
||||||
<input
|
<input
|
||||||
|
ref={inputRef}
|
||||||
type="text"
|
type="text"
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
className={`${baseInputClass} w-full pr-6`}
|
className={`${baseInputClass} w-full pr-6`}
|
||||||
value={focused ? text : formatChf(value)}
|
value={focused ? text : formatChf(value)}
|
||||||
onFocus={() => {
|
onFocus={() => {
|
||||||
setFocused(true);
|
setFocused(true);
|
||||||
// Default-0 sofort leeren, damit man direkt lostippen kann.
|
const current = Math.round(value || 0);
|
||||||
const current = Math.floor(value || 0);
|
// Default-0 sofort leeren; sonst Wert markieren, damit man ihn ueberschreiben kann.
|
||||||
setText(current === 0 ? "" : String(current));
|
setText(current === 0 ? "" : String(current));
|
||||||
|
requestAnimationFrame(() => inputRef.current?.select());
|
||||||
|
}}
|
||||||
|
onChange={(e) => setText(e.target.value.replace(min < 0 ? /[^0-9-]/g : /[^0-9]/g, ""))}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "ArrowUp") {
|
||||||
|
e.preventDefault();
|
||||||
|
stepBy(e.shiftKey ? 100 : 1);
|
||||||
|
} else if (e.key === "ArrowDown") {
|
||||||
|
e.preventDefault();
|
||||||
|
stepBy(e.shiftKey ? -100 : -1);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
onChange={(e) => setText(e.target.value.replace(/[^0-9-]/g, ""))}
|
|
||||||
onBlur={() => {
|
onBlur={() => {
|
||||||
setFocused(false);
|
setFocused(false);
|
||||||
onChange(clamp(floorToThousand(parseChfInput(text))));
|
onChange(clamp(parseChfInput(text)));
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-y-0 right-0 flex w-6 flex-col overflow-hidden rounded-r-lg border-l border-zinc-300 dark:border-zinc-700">
|
<div className="absolute inset-y-0 right-0 flex w-6 flex-col overflow-hidden rounded-r-lg border-l border-border">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
aria-label="Um 1'000 erhoehen"
|
aria-label="Erhoehen"
|
||||||
onMouseDown={(e) => {
|
onMouseDown={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
startHold(1000);
|
startHold(1);
|
||||||
}}
|
}}
|
||||||
onMouseUp={stopHold}
|
onMouseUp={stopHold}
|
||||||
onMouseLeave={stopHold}
|
onMouseLeave={stopHold}
|
||||||
onTouchStart={(e) => {
|
onTouchStart={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
startHold(1000);
|
startHold(1);
|
||||||
}}
|
}}
|
||||||
onTouchEnd={stopHold}
|
onTouchEnd={stopHold}
|
||||||
className="flex flex-1 items-center justify-center text-zinc-500 hover:bg-indigo-50 hover:text-indigo-600 dark:text-zinc-400 dark:hover:bg-zinc-700 dark:hover:text-indigo-400"
|
className={arrowBtn}
|
||||||
>
|
>
|
||||||
<ChevronUp className="h-3 w-3" />
|
<ChevronUp className="h-3 w-3" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
aria-label="Um 1'000 verringern"
|
aria-label="Verringern"
|
||||||
onMouseDown={(e) => {
|
onMouseDown={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
startHold(-1000);
|
startHold(-1);
|
||||||
}}
|
}}
|
||||||
onMouseUp={stopHold}
|
onMouseUp={stopHold}
|
||||||
onMouseLeave={stopHold}
|
onMouseLeave={stopHold}
|
||||||
onTouchStart={(e) => {
|
onTouchStart={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
startHold(-1000);
|
startHold(-1);
|
||||||
}}
|
}}
|
||||||
onTouchEnd={stopHold}
|
onTouchEnd={stopHold}
|
||||||
className="flex flex-1 items-center justify-center border-t border-zinc-300 text-zinc-500 hover:bg-indigo-50 hover:text-indigo-600 dark:border-zinc-700 dark:text-zinc-400 dark:hover:bg-zinc-700 dark:hover:text-indigo-400"
|
className={`${arrowBtn} border-t border-border`}
|
||||||
>
|
>
|
||||||
<ChevronDown className="h-3 w-3" />
|
<ChevronDown className="h-3 w-3" />
|
||||||
</button>
|
</button>
|
||||||
@@ -176,18 +198,20 @@ export function MoneyField({
|
|||||||
help,
|
help,
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
|
min,
|
||||||
max,
|
max,
|
||||||
}: {
|
}: {
|
||||||
label: string;
|
label: string;
|
||||||
help?: string;
|
help?: string;
|
||||||
value: number;
|
value: number;
|
||||||
onChange: (value: number) => void;
|
onChange: (value: number) => void;
|
||||||
|
min?: number;
|
||||||
max?: number;
|
max?: number;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<FieldLabel label={label} help={help} />
|
<FieldLabel label={label} help={help} />
|
||||||
<MoneyInput value={value} onChange={onChange} max={max} />
|
<MoneyInput value={value} onChange={onChange} min={min} max={max} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,105 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import { NumberField, SelectField } from "@/components/FormField";
|
|
||||||
import { api } from "@/lib/api-client";
|
|
||||||
import type { HouseholdInput, HouseholdType } from "@/lib/types";
|
|
||||||
|
|
||||||
export function HouseholdSettings({
|
|
||||||
household,
|
|
||||||
onUpdated,
|
|
||||||
onClose,
|
|
||||||
}: {
|
|
||||||
household: HouseholdInput;
|
|
||||||
onUpdated: (household: HouseholdInput) => void;
|
|
||||||
onClose: () => void;
|
|
||||||
}) {
|
|
||||||
const [householdType, setHouseholdType] = useState<HouseholdType>(household.householdType);
|
|
||||||
const [inflationRateDefault, setInflationRateDefault] = useState(household.inflationRateDefault);
|
|
||||||
const [persons, setPersons] = useState(
|
|
||||||
household.persons.map((p) => ({ role: p.role, age: p.age, retirementAge: p.retirementAge }))
|
|
||||||
);
|
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
function handleTypeChange(type: HouseholdType) {
|
|
||||||
setHouseholdType(type);
|
|
||||||
if (type === "SINGLE") {
|
|
||||||
setPersons((p) => p.slice(0, 1));
|
|
||||||
} else if (persons.length < 2) {
|
|
||||||
setPersons((p) => [...p, { role: "PERSON_B" as const, age: 35, retirementAge: 65 }]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSubmit() {
|
|
||||||
setSaving(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
const { household: updated } = await api.patch<{ household: HouseholdInput }>("/api/household", {
|
|
||||||
householdType,
|
|
||||||
inflationRateDefault,
|
|
||||||
persons,
|
|
||||||
});
|
|
||||||
onUpdated(updated);
|
|
||||||
onClose();
|
|
||||||
} catch (e) {
|
|
||||||
setError(e instanceof Error ? e.message : "Speichern fehlgeschlagen.");
|
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-4 rounded-xl border border-zinc-200/70 bg-white p-4 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
|
|
||||||
<SelectField
|
|
||||||
label="Haushaltsform"
|
|
||||||
value={householdType}
|
|
||||||
onChange={handleTypeChange}
|
|
||||||
options={[
|
|
||||||
{ value: "SINGLE", label: "Einzelperson" },
|
|
||||||
{ value: "COUPLE", label: "Paar (zwei Personen)" },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
{persons.map((person, index) => (
|
|
||||||
<div key={person.role} className="grid grid-cols-2 gap-3">
|
|
||||||
<NumberField
|
|
||||||
label={`Alter (${person.role === "PERSON_A" ? "Person A" : "Person B"})`}
|
|
||||||
value={person.age}
|
|
||||||
onChange={(v) => setPersons((prev) => prev.map((p, i) => (i === index ? { ...p, age: v } : p)))}
|
|
||||||
/>
|
|
||||||
<NumberField
|
|
||||||
label="Geplantes Pensionierungsalter"
|
|
||||||
value={person.retirementAge}
|
|
||||||
onChange={(v) =>
|
|
||||||
setPersons((prev) => prev.map((p, i) => (i === index ? { ...p, retirementAge: v } : p)))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
<NumberField
|
|
||||||
label="Erwartete Inflationsrate (%)"
|
|
||||||
value={inflationRateDefault}
|
|
||||||
step={0.1}
|
|
||||||
onChange={setInflationRateDefault}
|
|
||||||
/>
|
|
||||||
{error && <p className="text-sm text-red-600 dark:text-red-400">{error}</p>}
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={saving}
|
|
||||||
onClick={handleSubmit}
|
|
||||||
className="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-500 disabled:opacity-50 dark:bg-indigo-500 dark:hover:bg-indigo-400"
|
|
||||||
>
|
|
||||||
{saving ? "Speichern..." : "Speichern"}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onClose}
|
|
||||||
className="rounded-lg border border-zinc-300 px-4 py-2 text-sm font-medium text-zinc-700 hover:bg-zinc-50 dark:border-zinc-700 dark:text-zinc-200 dark:hover:bg-zinc-800"
|
|
||||||
>
|
|
||||||
Abbrechen
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -14,12 +14,12 @@ export function InfoBubble({ text }: { text: string }) {
|
|||||||
onMouseEnter={() => setOpen(true)}
|
onMouseEnter={() => setOpen(true)}
|
||||||
onMouseLeave={() => setOpen(false)}
|
onMouseLeave={() => setOpen(false)}
|
||||||
onClick={() => setOpen((o) => !o)}
|
onClick={() => setOpen((o) => !o)}
|
||||||
className="flex h-4 w-4 items-center justify-center rounded-full bg-indigo-100 text-indigo-500 hover:bg-indigo-200 dark:bg-indigo-500/20 dark:text-indigo-300 dark:hover:bg-indigo-500/30"
|
className="flex h-4 w-4 items-center justify-center rounded-full bg-accent-soft text-accent-soft-fg hover:opacity-80"
|
||||||
>
|
>
|
||||||
<Info className="h-2.5 w-2.5" strokeWidth={2.5} />
|
<Info className="h-2.5 w-2.5" strokeWidth={2.5} />
|
||||||
</button>
|
</button>
|
||||||
{open && (
|
{open && (
|
||||||
<span className="absolute left-1/2 top-6 z-20 w-64 -translate-x-1/2 rounded-lg border border-zinc-200 bg-white p-2.5 text-xs leading-snug text-zinc-700 shadow-lg dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-200">
|
<span className="absolute left-1/2 top-6 z-20 w-64 -translate-x-1/2 rounded-lg border border-border bg-surface p-2.5 text-xs leading-snug text-fg shadow-lg">
|
||||||
{text}
|
{text}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,119 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import { PiggyBank } from "lucide-react";
|
|
||||||
import { api } from "@/lib/api-client";
|
|
||||||
import { NumberField, SelectField } from "@/components/FormField";
|
|
||||||
import type { HouseholdInput, HouseholdType, PersonRole } from "@/lib/types";
|
|
||||||
|
|
||||||
interface PersonDraft {
|
|
||||||
role: PersonRole;
|
|
||||||
age: number;
|
|
||||||
retirementAge: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Onboarding({ onDone }: { onDone: (household: HouseholdInput) => void }) {
|
|
||||||
const [householdType, setHouseholdType] = useState<HouseholdType>("SINGLE");
|
|
||||||
const [inflationRateDefault, setInflationRateDefault] = useState(1.5);
|
|
||||||
const [persons, setPersons] = useState<PersonDraft[]>([
|
|
||||||
{ role: "PERSON_A", age: 35, retirementAge: 65 },
|
|
||||||
]);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
|
|
||||||
function handleTypeChange(type: HouseholdType) {
|
|
||||||
setHouseholdType(type);
|
|
||||||
if (type === "SINGLE") {
|
|
||||||
setPersons((p) => p.slice(0, 1));
|
|
||||||
} else if (persons.length < 2) {
|
|
||||||
setPersons((p) => [...p, { role: "PERSON_B", age: 35, retirementAge: 65 }]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function updatePerson(index: number, patch: Partial<PersonDraft>) {
|
|
||||||
setPersons((prev) => prev.map((p, i) => (i === index ? { ...p, ...patch } : p)));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSubmit() {
|
|
||||||
setSaving(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
const { household } = await api.post<{ household: HouseholdInput }>("/api/household", {
|
|
||||||
householdType,
|
|
||||||
inflationRateDefault,
|
|
||||||
persons,
|
|
||||||
});
|
|
||||||
onDone(household);
|
|
||||||
} catch (e) {
|
|
||||||
setError(e instanceof Error ? e.message : "Unbekannter Fehler.");
|
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mx-auto flex w-full max-w-xl flex-1 flex-col justify-center px-6 py-16">
|
|
||||||
<div className="mb-2 flex items-center gap-2">
|
|
||||||
<PiggyBank className="h-7 w-7 text-indigo-600 dark:text-indigo-400" />
|
|
||||||
<h1 className="text-2xl font-semibold text-zinc-900 dark:text-zinc-50">
|
|
||||||
Willkommen beim Financial Planning Tool
|
|
||||||
</h1>
|
|
||||||
</div>
|
|
||||||
<p className="mb-8 text-sm text-zinc-600 dark:text-zinc-400">
|
|
||||||
Bevor es losgeht, brauchen wir ein paar Eckdaten zu Ihrem Haushalt.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-5 rounded-xl border border-zinc-200/70 bg-white p-6 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
|
|
||||||
<SelectField
|
|
||||||
label="Haushaltsform"
|
|
||||||
help="Waehlen Sie, ob Sie alleine oder gemeinsam mit einer Partnerin / einem Partner planen."
|
|
||||||
value={householdType}
|
|
||||||
onChange={handleTypeChange}
|
|
||||||
options={[
|
|
||||||
{ value: "SINGLE", label: "Einzelperson" },
|
|
||||||
{ value: "COUPLE", label: "Paar (zwei Personen)" },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{persons.map((person, index) => (
|
|
||||||
<div key={person.role} className="grid grid-cols-2 gap-3 rounded-xl border border-zinc-100 bg-zinc-50/60 p-3 dark:border-zinc-800 dark:bg-zinc-800/30">
|
|
||||||
<div className="col-span-2 text-xs font-semibold uppercase tracking-wide text-zinc-500">
|
|
||||||
{householdType === "COUPLE" ? (person.role === "PERSON_A" ? "Person A" : "Person B") : "Ihre Angaben"}
|
|
||||||
</div>
|
|
||||||
<NumberField
|
|
||||||
label="Aktuelles Alter"
|
|
||||||
help="Ihr heutiges Alter in vollen Jahren."
|
|
||||||
value={person.age}
|
|
||||||
onChange={(v) => updatePerson(index, { age: v })}
|
|
||||||
/>
|
|
||||||
<NumberField
|
|
||||||
label="Geplantes Pensionierungsalter"
|
|
||||||
help="Das Alter, in dem Sie voraussichtlich in Rente gehen moechten. Dient nur der groben Orientierung bei der Phasenplanung."
|
|
||||||
value={person.retirementAge}
|
|
||||||
onChange={(v) => updatePerson(index, { retirementAge: v })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<NumberField
|
|
||||||
label="Erwartete Inflationsrate (%)"
|
|
||||||
help="Langfristige Annahme zur jaehrlichen Teuerung. Kann pro Lebensphase individuell ueberschrieben werden."
|
|
||||||
value={inflationRateDefault}
|
|
||||||
step={0.1}
|
|
||||||
onChange={setInflationRateDefault}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{error && <p className="text-sm text-red-600 dark:text-red-400">{error}</p>}
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={saving}
|
|
||||||
onClick={handleSubmit}
|
|
||||||
className="mt-2 rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-500 disabled:opacity-50 dark:bg-indigo-500 dark:hover:bg-indigo-400"
|
|
||||||
>
|
|
||||||
{saving ? "Speichern..." : "Weiter"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -4,20 +4,20 @@ import { useState } from "react";
|
|||||||
import { Trash2 } from "lucide-react";
|
import { Trash2 } from "lucide-react";
|
||||||
import { NumberField, TextField } from "@/components/FormField";
|
import { NumberField, TextField } from "@/components/FormField";
|
||||||
import { api } from "@/lib/api-client";
|
import { api } from "@/lib/api-client";
|
||||||
import type { HouseholdInput, PhaseInput } from "@/lib/types";
|
import type { PhaseInput } from "@/lib/types";
|
||||||
|
|
||||||
export function PhaseDetail({
|
export function PhaseDetail({
|
||||||
phase,
|
phase,
|
||||||
maxDurationYears,
|
maxDurationYears,
|
||||||
isLast,
|
isLast,
|
||||||
household,
|
inflationDefault,
|
||||||
onSaved,
|
onSaved,
|
||||||
onDeleted,
|
onDeleted,
|
||||||
}: {
|
}: {
|
||||||
phase: PhaseInput;
|
phase: PhaseInput;
|
||||||
maxDurationYears: number | null;
|
maxDurationYears: number | null;
|
||||||
isLast: boolean;
|
isLast: boolean;
|
||||||
household: HouseholdInput;
|
inflationDefault: number;
|
||||||
onSaved: () => void;
|
onSaved: () => void;
|
||||||
onDeleted: () => void;
|
onDeleted: () => void;
|
||||||
}) {
|
}) {
|
||||||
@@ -55,14 +55,14 @@ export function PhaseDetail({
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="text-xs font-semibold uppercase tracking-wide text-indigo-600 dark:text-indigo-400">
|
<div className="text-xs font-semibold uppercase tracking-wide text-accent-soft-fg">
|
||||||
Lebensphase
|
Lebensphase
|
||||||
</div>
|
</div>
|
||||||
{isLast && (
|
{isLast && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={remove}
|
onClick={remove}
|
||||||
className="flex items-center gap-1 rounded-lg border border-zinc-300 px-2 py-1 text-xs text-zinc-500 hover:border-red-200 hover:bg-red-50 hover:text-red-600 dark:border-zinc-700 dark:hover:bg-red-950"
|
className="flex items-center gap-1 rounded-lg border border-border px-2 py-1 text-xs text-muted hover:border-danger hover:bg-danger-soft hover:text-danger"
|
||||||
>
|
>
|
||||||
<Trash2 className="h-3.5 w-3.5" /> Phase loeschen
|
<Trash2 className="h-3.5 w-3.5" /> Phase loeschen
|
||||||
</button>
|
</button>
|
||||||
@@ -81,20 +81,20 @@ export function PhaseDetail({
|
|||||||
/>
|
/>
|
||||||
<NumberField
|
<NumberField
|
||||||
label="Inflationsrate (%)"
|
label="Inflationsrate (%)"
|
||||||
help="Ueberschreibt die Standardannahme aus dem Grundprofil."
|
help="Ueberschreibt die Standardannahme aus dem Plan-Grundprofil."
|
||||||
value={inflationRate ?? household.inflationRateDefault}
|
value={inflationRate ?? inflationDefault}
|
||||||
step={0.1}
|
step={0.1}
|
||||||
onChange={setInflationRate}
|
onChange={setInflationRate}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && <p className="text-sm text-red-600 dark:text-red-400">{error}</p>}
|
{error && <p className="text-sm text-danger">{error}</p>}
|
||||||
<div>
|
<div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
onClick={save}
|
onClick={save}
|
||||||
className="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-500 disabled:opacity-50 dark:bg-indigo-500 dark:hover:bg-indigo-400"
|
className="rounded-lg bg-accent px-4 py-2 text-sm font-medium text-accent-fg shadow-sm hover:bg-accent-hover disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{saving ? "Speichern..." : "Speichern"}
|
{saving ? "Speichern..." : "Speichern"}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { NumberField, SelectField } from "@/components/FormField";
|
||||||
|
import type { HouseholdType, PersonRole } from "@/lib/types";
|
||||||
|
|
||||||
|
export interface ProfileDraft {
|
||||||
|
householdType: HouseholdType;
|
||||||
|
inflationRateDefault: number;
|
||||||
|
persons: { role: PersonRole; age: number; retirementAge: number }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function emptyProfileDraft(): ProfileDraft {
|
||||||
|
return {
|
||||||
|
householdType: "SINGLE",
|
||||||
|
inflationRateDefault: 1.5,
|
||||||
|
persons: [{ role: "PERSON_A", age: 35, retirementAge: 65 }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gemeinsame Formularfelder fuer das Grundprofil eines Plans (Haushaltsform, Personen,
|
||||||
|
// Inflation). Wird beim Plan-Erstellen und in den Plan-Einstellungen verwendet.
|
||||||
|
export function PlanProfileFields({
|
||||||
|
draft,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
draft: ProfileDraft;
|
||||||
|
onChange: (next: ProfileDraft) => void;
|
||||||
|
}) {
|
||||||
|
function setType(type: HouseholdType) {
|
||||||
|
if (type === "SINGLE") {
|
||||||
|
onChange({ ...draft, householdType: type, persons: draft.persons.slice(0, 1) });
|
||||||
|
} else {
|
||||||
|
const persons =
|
||||||
|
draft.persons.length < 2
|
||||||
|
? [...draft.persons, { role: "PERSON_B" as PersonRole, age: 35, retirementAge: 65 }]
|
||||||
|
: draft.persons;
|
||||||
|
onChange({ ...draft, householdType: type, persons });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePerson(index: number, patch: Partial<ProfileDraft["persons"][number]>) {
|
||||||
|
onChange({
|
||||||
|
...draft,
|
||||||
|
persons: draft.persons.map((p, i) => (i === index ? { ...p, ...patch } : p)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<SelectField
|
||||||
|
label="Haushaltsform"
|
||||||
|
help="Waehlen Sie, ob Sie alleine oder gemeinsam mit einer Partnerin / einem Partner planen."
|
||||||
|
value={draft.householdType}
|
||||||
|
onChange={setType}
|
||||||
|
options={[
|
||||||
|
{ value: "SINGLE", label: "Einzelperson" },
|
||||||
|
{ value: "COUPLE", label: "Paar (zwei Personen)" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{draft.persons.map((person, index) => (
|
||||||
|
<div key={person.role} className="grid grid-cols-2 gap-3 rounded-xl border border-border bg-surface-2 p-3">
|
||||||
|
<div className="col-span-2 text-xs font-semibold uppercase tracking-wide text-faint">
|
||||||
|
{draft.householdType === "COUPLE" ? (person.role === "PERSON_A" ? "Person A" : "Person B") : "Ihre Angaben"}
|
||||||
|
</div>
|
||||||
|
<NumberField
|
||||||
|
label="Aktuelles Alter"
|
||||||
|
value={person.age}
|
||||||
|
min={0}
|
||||||
|
max={120}
|
||||||
|
onChange={(v) => updatePerson(index, { age: Math.round(v) })}
|
||||||
|
/>
|
||||||
|
<NumberField
|
||||||
|
label="Pensionierungsalter"
|
||||||
|
help="Steuert die Ableitung des Phasentyps (Erwerb/Pension)."
|
||||||
|
value={person.retirementAge}
|
||||||
|
min={30}
|
||||||
|
max={100}
|
||||||
|
onChange={(v) => updatePerson(index, { retirementAge: Math.round(v) })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<NumberField
|
||||||
|
label="Erwartete Inflationsrate (%)"
|
||||||
|
help="Langfristige Annahme zur jaehrlichen Teuerung. Kann pro Lebensphase individuell ueberschrieben werden."
|
||||||
|
value={draft.inflationRateDefault}
|
||||||
|
step={0.1}
|
||||||
|
onChange={(v) => onChange({ ...draft, inflationRateDefault: v })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+580
-147
@@ -12,13 +12,21 @@ import {
|
|||||||
Landmark,
|
Landmark,
|
||||||
PiggyBank,
|
PiggyBank,
|
||||||
Plus,
|
Plus,
|
||||||
|
Settings2,
|
||||||
ShoppingCart,
|
ShoppingCart,
|
||||||
TrendingUp,
|
TrendingUp,
|
||||||
Wallet,
|
Wallet,
|
||||||
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Timeline } from "@/components/Timeline";
|
import { Timeline } from "@/components/Timeline";
|
||||||
import { ElementDetail, type CellContext } from "@/components/ElementDetail";
|
import {
|
||||||
|
ElementDetail,
|
||||||
|
ElementPhaseFields,
|
||||||
|
ElementTransitionFields,
|
||||||
|
type CellContext,
|
||||||
|
} from "@/components/ElementDetail";
|
||||||
import { PhaseDetail } from "@/components/PhaseDetail";
|
import { PhaseDetail } from "@/components/PhaseDetail";
|
||||||
|
import { PlanProfileFields, type ProfileDraft } from "@/components/PlanProfileFields";
|
||||||
import { api } from "@/lib/api-client";
|
import { api } from "@/lib/api-client";
|
||||||
import { formatChf } from "@/lib/format";
|
import { formatChf } from "@/lib/format";
|
||||||
import {
|
import {
|
||||||
@@ -27,12 +35,10 @@ import {
|
|||||||
PERSON_ONLY_CATEGORIES,
|
PERSON_ONLY_CATEGORIES,
|
||||||
num,
|
num,
|
||||||
type ElementCategory,
|
type ElementCategory,
|
||||||
|
type PhaseData,
|
||||||
} from "@/lib/elements";
|
} from "@/lib/elements";
|
||||||
import { resolveRetirementAge, type PhaseComputed, type PlanComputed } from "@/lib/calculations";
|
import type { PhaseComputed, PlanComputed } from "@/lib/calculations";
|
||||||
import type { ElementInput, HouseholdInput, PlanInput } from "@/lib/types";
|
import type { ElementInput, PlanInput } from "@/lib/types";
|
||||||
|
|
||||||
const PERSON_A_COLOR = "#4f46e5";
|
|
||||||
const PERSON_B_COLOR = "#0ea5e9";
|
|
||||||
|
|
||||||
const CATEGORY_ICON: Record<ElementCategory, React.ReactNode> = {
|
const CATEGORY_ICON: Record<ElementCategory, React.ReactNode> = {
|
||||||
INCOME: <Wallet className="h-4 w-4" />,
|
INCOME: <Wallet className="h-4 w-4" />,
|
||||||
@@ -53,6 +59,14 @@ const TRANSITION_CATEGORIES: ElementCategory[] = [
|
|||||||
"OTHER_DEBT",
|
"OTHER_DEBT",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const VALUE_CATEGORIES: ElementCategory[] = [
|
||||||
|
"PENSION_FUND",
|
||||||
|
"PILLAR_3A",
|
||||||
|
"REAL_ESTATE",
|
||||||
|
"OTHER_ASSET",
|
||||||
|
"OTHER_DEBT",
|
||||||
|
];
|
||||||
|
|
||||||
type Column =
|
type Column =
|
||||||
| { kind: "phase"; phase: PhaseComputed }
|
| { kind: "phase"; phase: PhaseComputed }
|
||||||
| { kind: "transition"; fromPhase: PhaseComputed; toPhase: PhaseComputed };
|
| { kind: "transition"; fromPhase: PhaseComputed; toPhase: PhaseComputed };
|
||||||
@@ -64,18 +78,19 @@ type Selection =
|
|||||||
|
|
||||||
export function PlanView({
|
export function PlanView({
|
||||||
plan,
|
plan,
|
||||||
household,
|
|
||||||
computed,
|
computed,
|
||||||
onChanged,
|
onChanged,
|
||||||
}: {
|
}: {
|
||||||
plan: PlanInput;
|
plan: PlanInput;
|
||||||
household: HouseholdInput;
|
|
||||||
computed: PlanComputed;
|
computed: PlanComputed;
|
||||||
onChanged: () => void;
|
onChanged: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [selected, setSelected] = useState<Selection | null>(null);
|
const [selected, setSelected] = useState<Selection | null>(null);
|
||||||
const [collapsedCats, setCollapsedCats] = useState<Set<ElementCategory>>(new Set());
|
const [collapsedCats, setCollapsedCats] = useState<Set<ElementCategory>>(new Set());
|
||||||
const [showAdd, setShowAdd] = useState(false);
|
const [showAdd, setShowAdd] = useState(false);
|
||||||
|
const [showAddPhase, setShowAddPhase] = useState(false);
|
||||||
|
const [showSettings, setShowSettings] = useState(false);
|
||||||
|
const [reviewFromPhaseId, setReviewFromPhaseId] = useState<string | null>(null);
|
||||||
|
|
||||||
const columns = useMemo<Column[]>(() => {
|
const columns = useMemo<Column[]>(() => {
|
||||||
const cols: Column[] = [];
|
const cols: Column[] = [];
|
||||||
@@ -88,12 +103,12 @@ export function PlanView({
|
|||||||
return cols;
|
return cols;
|
||||||
}, [computed.phases]);
|
}, [computed.phases]);
|
||||||
|
|
||||||
const personAxes = household.persons.map((p) => ({
|
const personAxes = plan.persons.map((p) => ({
|
||||||
role: p.role,
|
role: p.role,
|
||||||
label: p.role === "PERSON_A" ? "Person A" : "Person B",
|
label: p.role === "PERSON_A" ? "Person A" : "Person B",
|
||||||
currentAge: p.age,
|
currentAge: p.age,
|
||||||
retirementAge: resolveRetirementAge(p.role, plan, p.retirementAge),
|
retirementAge: p.retirementAge,
|
||||||
color: p.role === "PERSON_A" ? PERSON_A_COLOR : PERSON_B_COLOR,
|
color: p.role === "PERSON_A" ? "var(--person-a)" : "var(--person-b)",
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const elementsByCategory = useMemo(() => {
|
const elementsByCategory = useMemo(() => {
|
||||||
@@ -116,48 +131,117 @@ export function PlanView({
|
|||||||
return !!before?.working && !!after && !after.working;
|
return !!before?.working && !!after && !after.working;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleAddPhase() {
|
// Baut den Kontext (inkl. Live-Caps) fuer eine Phasenzelle.
|
||||||
await api.post(`/api/plans/${plan.id}/phases`, {});
|
function buildPhaseContext(phase: PhaseComputed, element: ElementInput): CellContext {
|
||||||
|
const ce = computedElement(phase.id, element.id);
|
||||||
|
const ownerWorking =
|
||||||
|
element.ownerRole && element.ownerRole !== "HOUSEHOLD"
|
||||||
|
? phase.persons.find((p) => p.role === element.ownerRole)?.working ?? false
|
||||||
|
: phase.type !== "PENSION";
|
||||||
|
const otherQuota = phase.quotaAllocated - (ce?.quotaUse ?? 0);
|
||||||
|
const quotaRateMax = Math.max(0, Math.abs(phase.quota) - otherQuota);
|
||||||
|
const capitalMax =
|
||||||
|
phase.availableCapital == null
|
||||||
|
? undefined
|
||||||
|
: Math.max(0, phase.availableCapital - (phase.availableCapitalUsed - (ce?.capitalUse ?? 0)));
|
||||||
|
const derivedStart = (ce?.startValue ?? 0) - (ce?.capitalUse ?? 0);
|
||||||
|
return {
|
||||||
|
kind: "phase",
|
||||||
|
phaseId: phase.id,
|
||||||
|
ownerWorking,
|
||||||
|
isConsumption: phase.isConsumption,
|
||||||
|
durationYears: phase.durationYears,
|
||||||
|
isRetirementTransition: false,
|
||||||
|
carriedEndValue: ce?.endValue ?? 0,
|
||||||
|
carried: ce?.carried ?? false,
|
||||||
|
derivedStart,
|
||||||
|
quotaRateMax,
|
||||||
|
capitalMax,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTransitionContext(fromPhase: PhaseComputed, toPhase: PhaseComputed | undefined, element: ElementInput): CellContext {
|
||||||
|
const ce = computedElement(fromPhase.id, element.id);
|
||||||
|
return {
|
||||||
|
kind: "transition",
|
||||||
|
phaseId: fromPhase.id,
|
||||||
|
ownerWorking: true,
|
||||||
|
isConsumption: fromPhase.isConsumption,
|
||||||
|
durationYears: fromPhase.durationYears,
|
||||||
|
isRetirementTransition: toPhase ? isRetirementTransition(element, fromPhase, toPhase) : false,
|
||||||
|
carriedEndValue: ce?.endValue ?? 0,
|
||||||
|
carried: ce?.carried ?? false,
|
||||||
|
derivedStart: 0,
|
||||||
|
quotaRateMax: 0,
|
||||||
|
capitalMax: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Anzahl offener (noch nicht getroffener) Uebergangs-Entscheide an einer Grenze.
|
||||||
|
function transitionOpenCount(fromPhase: PhaseComputed, toPhase: PhaseComputed): number {
|
||||||
|
let n = 0;
|
||||||
|
for (const el of plan.elements) {
|
||||||
|
if (!TRANSITION_CATEGORIES.includes(el.category)) continue;
|
||||||
|
const ce = computedElement(fromPhase.id, el.id);
|
||||||
|
if (ce && ce.status !== "ACTIVE") continue;
|
||||||
|
const td = el.transitionValues[fromPhase.id] ?? {};
|
||||||
|
if (el.category === "REAL_ESTATE" || el.category === "OTHER_ASSET") {
|
||||||
|
if (td.decision === undefined) n++;
|
||||||
|
} else if (el.category === "PENSION_FUND" && isRetirementTransition(el, fromPhase, toPhase)) {
|
||||||
|
if (td.payoutMode === undefined) n++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
function transitionElements(fromPhase: PhaseComputed): ElementInput[] {
|
||||||
|
return plan.elements
|
||||||
|
.filter((el) => TRANSITION_CATEGORIES.includes(el.category))
|
||||||
|
.filter((el) => {
|
||||||
|
const ce = computedElement(fromPhase.id, el.id);
|
||||||
|
return !ce || ce.status === "ACTIVE";
|
||||||
|
})
|
||||||
|
.sort((a, b) => a.orderIndex - b.orderIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAddPhase(payload: { name?: string; durationYears?: number; inflationRate?: number | null }) {
|
||||||
|
await api.post(`/api/plans/${plan.id}/phases`, payload);
|
||||||
|
setShowAddPhase(false);
|
||||||
onChanged();
|
onChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasPhases = computed.phases.length > 0;
|
const hasPhases = computed.phases.length > 0;
|
||||||
|
const firstPhase = computed.phases[0] ?? null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-5">
|
<div className="flex flex-col gap-5">
|
||||||
<Timeline phases={computed.phases} persons={personAxes} />
|
<Timeline phases={computed.phases} persons={personAxes} />
|
||||||
|
|
||||||
{/* Pensionsalter-Overrides */}
|
{/* Plan-Profil */}
|
||||||
<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">
|
<div className="flex flex-wrap items-center gap-3 rounded-xl border border-border bg-surface px-4 py-3 text-sm shadow-sm">
|
||||||
<span className="text-xs font-semibold uppercase tracking-wide text-zinc-400">Pensionsalter (Plan)</span>
|
<span className="text-xs font-semibold uppercase tracking-wide text-faint">Grundprofil (Plan)</span>
|
||||||
{household.persons.map((p) => (
|
{plan.persons.map((p) => (
|
||||||
<label key={p.role} className="flex items-center gap-1.5 text-xs text-zinc-600 dark:text-zinc-300">
|
<span key={p.role} className="text-xs text-muted">
|
||||||
{p.role === "PERSON_A" ? "Person A" : "Person B"}:
|
{plan.householdType === "COUPLE" ? (p.role === "PERSON_A" ? "Person A" : "Person B") : "Person"}: {p.age} J., Pension {p.retirementAge}
|
||||||
<input
|
</span>
|
||||||
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>
|
<span className="text-xs text-muted">Inflation {plan.inflationRateDefault}%</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowSettings(true)}
|
||||||
|
className="ml-auto flex items-center gap-1.5 rounded-lg border border-border px-2.5 py-1 text-xs font-medium text-muted hover:bg-surface-2"
|
||||||
|
>
|
||||||
|
<Settings2 className="h-3.5 w-3.5" /> Einstellungen
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!hasPhases && (
|
{!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">
|
<div className="rounded-xl border border-dashed border-border bg-surface p-8 text-center">
|
||||||
<p className="text-sm text-zinc-500">Dieser Plan hat noch keine Lebensphasen.</p>
|
<p className="text-sm text-muted">Dieser Plan hat noch keine Lebensphasen.</p>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleAddPhase}
|
onClick={() => setShowAddPhase(true)}
|
||||||
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"
|
className="mt-3 inline-flex items-center gap-1.5 rounded-lg bg-accent px-4 py-2 text-sm font-medium text-accent-fg hover:bg-accent-hover"
|
||||||
>
|
>
|
||||||
<Plus className="h-4 w-4" /> Erste Lebensphase
|
<Plus className="h-4 w-4" /> Erste Lebensphase
|
||||||
</button>
|
</button>
|
||||||
@@ -169,14 +253,14 @@ export function PlanView({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowAdd(true)}
|
onClick={() => setShowAdd(true)}
|
||||||
className="flex items-center gap-1.5 rounded-lg bg-indigo-600 px-3 py-1.5 text-sm font-medium text-white shadow-sm hover:bg-indigo-500 dark:bg-indigo-500 dark:hover:bg-indigo-400"
|
className="flex items-center gap-1.5 rounded-lg bg-accent px-3 py-1.5 text-sm font-medium text-accent-fg shadow-sm hover:bg-accent-hover"
|
||||||
>
|
>
|
||||||
<Plus className="h-4 w-4" /> Finanzielles Element
|
<Plus className="h-4 w-4" /> Finanzielles Element
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleAddPhase}
|
onClick={() => setShowAddPhase(true)}
|
||||||
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"
|
className="flex items-center gap-1.5 rounded-lg border border-dashed border-accent bg-accent-soft px-3 py-1.5 text-sm font-medium text-accent-soft-fg hover:bg-accent-soft"
|
||||||
>
|
>
|
||||||
<Plus className="h-4 w-4" /> Lebensphase
|
<Plus className="h-4 w-4" /> Lebensphase
|
||||||
</button>
|
</button>
|
||||||
@@ -185,11 +269,11 @@ export function PlanView({
|
|||||||
|
|
||||||
{/* Matrix */}
|
{/* Matrix */}
|
||||||
{hasPhases && (
|
{hasPhases && (
|
||||||
<div className="overflow-x-auto rounded-xl border border-zinc-200/70 bg-white shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
|
<div className="overflow-x-auto rounded-xl border border-border bg-surface shadow-sm">
|
||||||
<table className="w-full border-collapse text-sm">
|
<table className="w-full border-collapse text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th className="sticky left-0 z-20 min-w-44 border-b border-r border-zinc-200 bg-zinc-50 px-3 py-2 text-left text-xs font-semibold uppercase tracking-wide text-zinc-500 dark:border-zinc-800 dark:bg-zinc-800/60">
|
<th className="sticky left-0 z-20 min-w-44 border-b border-r border-border bg-surface-2 px-3 py-2 text-left text-xs font-semibold uppercase tracking-wide text-faint">
|
||||||
Finanzielle Elemente
|
Finanzielle Elemente
|
||||||
</th>
|
</th>
|
||||||
{columns.map((col) =>
|
{columns.map((col) =>
|
||||||
@@ -201,12 +285,11 @@ export function PlanView({
|
|||||||
active={selected?.type === "phase" && selected.phaseId === col.phase.id}
|
active={selected?.type === "phase" && selected.phaseId === col.phase.id}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<th
|
<TransitionHeader
|
||||||
key={`t-${col.fromPhase.id}`}
|
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"
|
openCount={transitionOpenCount(col.fromPhase, col.toPhase)}
|
||||||
>
|
onClick={() => setReviewFromPhaseId(col.fromPhase.id)}
|
||||||
Uebergang
|
/>
|
||||||
</th>
|
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
</tr>
|
</tr>
|
||||||
@@ -218,9 +301,9 @@ export function PlanView({
|
|||||||
const collapsed = collapsedCats.has(cat);
|
const collapsed = collapsedCats.has(cat);
|
||||||
return (
|
return (
|
||||||
<FragmentRows key={cat}>
|
<FragmentRows key={cat}>
|
||||||
<tr className="bg-zinc-50/60 dark:bg-zinc-800/30">
|
<tr className="bg-surface-2">
|
||||||
<td
|
<td
|
||||||
className="sticky left-0 z-10 cursor-pointer border-b border-r border-zinc-200 bg-zinc-50/90 px-3 py-1.5 dark:border-zinc-800 dark:bg-zinc-800/60"
|
className="sticky left-0 z-10 cursor-pointer border-b border-r border-border bg-surface-2 px-3 py-1.5"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
setCollapsedCats((prev) => {
|
setCollapsedCats((prev) => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
@@ -230,21 +313,21 @@ export function PlanView({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<span className="flex items-center gap-1.5 text-xs font-semibold text-zinc-600 dark:text-zinc-300">
|
<span className="flex items-center gap-1.5 text-xs font-semibold text-muted">
|
||||||
{collapsed ? <ChevronRight className="h-3.5 w-3.5" /> : <ChevronDown className="h-3.5 w-3.5" />}
|
{collapsed ? <ChevronRight className="h-3.5 w-3.5" /> : <ChevronDown className="h-3.5 w-3.5" />}
|
||||||
<span className="text-indigo-500 dark:text-indigo-400">{CATEGORY_ICON[cat]}</span>
|
<span className="text-accent">{CATEGORY_ICON[cat]}</span>
|
||||||
{CATEGORY_LABELS[cat]}
|
{CATEGORY_LABELS[cat]}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td colSpan={columns.length} className="border-b border-zinc-200 dark:border-zinc-800" />
|
<td colSpan={columns.length} className="border-b border-border" />
|
||||||
</tr>
|
</tr>
|
||||||
{!collapsed &&
|
{!collapsed &&
|
||||||
els.map((el) => (
|
els.map((el) => (
|
||||||
<tr key={el.id} className="hover:bg-zinc-50/50 dark:hover:bg-zinc-800/20">
|
<tr key={el.id} className="hover:bg-surface-2">
|
||||||
<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">
|
<td className="sticky left-0 z-10 border-b border-r border-border bg-surface px-3 py-1.5">
|
||||||
<div className="truncate text-xs font-medium text-zinc-800 dark:text-zinc-200">{el.name}</div>
|
<div className="truncate text-xs font-medium text-fg">{el.name}</div>
|
||||||
{el.ownerRole && el.ownerRole !== "HOUSEHOLD" && (
|
{el.ownerRole && el.ownerRole !== "HOUSEHOLD" && (
|
||||||
<div className="text-[10px] text-zinc-400">
|
<div className="text-[10px] text-faint">
|
||||||
{el.ownerRole === "PERSON_A" ? "Person A" : "Person B"}
|
{el.ownerRole === "PERSON_A" ? "Person A" : "Person B"}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -260,11 +343,11 @@ export function PlanView({
|
|||||||
<td
|
<td
|
||||||
key={col.phase.id}
|
key={col.phase.id}
|
||||||
onClick={() => setSelected({ type: "phaseCell", elementId: el.id, phaseId: 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 ${
|
className={`cursor-pointer border-b border-r border-border px-2 py-1.5 text-center text-xs ${
|
||||||
isSel ? "bg-indigo-100 dark:bg-indigo-500/20" : ""
|
isSel ? "bg-accent-soft" : ""
|
||||||
} ${ce?.locked ? "text-zinc-400" : "text-zinc-700 dark:text-zinc-200"}`}
|
} ${ce?.locked ? "text-faint" : "text-fg"}`}
|
||||||
>
|
>
|
||||||
{ce?.summary ?? "–"}
|
{phaseCellContent(ce)}
|
||||||
</td>
|
</td>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -280,9 +363,9 @@ export function PlanView({
|
|||||||
canTransition &&
|
canTransition &&
|
||||||
setSelected({ type: "transitionCell", elementId: el.id, fromPhaseId: col.fromPhase.id })
|
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 ${
|
className={`border-b border-r border-border px-2 py-1.5 text-center text-[11px] ${
|
||||||
canTransition ? "cursor-pointer text-indigo-500" : "text-zinc-300 dark:text-zinc-600"
|
canTransition ? "cursor-pointer text-accent" : "text-faint"
|
||||||
} ${isSel ? "bg-indigo-100 dark:bg-indigo-500/20" : "bg-indigo-50/30 dark:bg-indigo-500/5"}`}
|
} ${isSel ? "bg-accent-soft" : "bg-accent-soft/40"}`}
|
||||||
>
|
>
|
||||||
{canTransition ? transitionSummary(el, col.fromPhase, col.toPhase) : "→"}
|
{canTransition ? transitionSummary(el, col.fromPhase, col.toPhase) : "→"}
|
||||||
</td>
|
</td>
|
||||||
@@ -295,7 +378,7 @@ export function PlanView({
|
|||||||
})}
|
})}
|
||||||
{plan.elements.length === 0 && (
|
{plan.elements.length === 0 && (
|
||||||
<tr>
|
<tr>
|
||||||
<td className="sticky left-0 bg-white px-3 py-4 text-xs text-zinc-400 dark:bg-zinc-900" colSpan={columns.length + 1}>
|
<td className="sticky left-0 bg-surface px-3 py-4 text-xs text-faint" colSpan={columns.length + 1}>
|
||||||
Noch keine finanziellen Elemente. Fuegen Sie oben Ihr erstes Element hinzu.
|
Noch keine finanziellen Elemente. Fuegen Sie oben Ihr erstes Element hinzu.
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -307,25 +390,74 @@ export function PlanView({
|
|||||||
|
|
||||||
{/* Detail-Panel */}
|
{/* Detail-Panel */}
|
||||||
{selected && (
|
{selected && (
|
||||||
<div className="rounded-xl border border-indigo-200 bg-white p-4 shadow-sm dark:border-indigo-500/30 dark:bg-zinc-900">
|
<div className="rounded-xl border border-accent bg-surface p-4 shadow-sm">{renderDetail()}</div>
|
||||||
{renderDetail()}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showAdd && (
|
{showAdd && firstPhase && (
|
||||||
<AddElementDialog
|
<AddElementDialog
|
||||||
household={household}
|
plan={plan}
|
||||||
|
firstPhase={firstPhase}
|
||||||
onClose={() => setShowAdd(false)}
|
onClose={() => setShowAdd(false)}
|
||||||
onCreate={async (payload) => {
|
onCreated={() => {
|
||||||
await api.post(`/api/plans/${plan.id}/elements`, payload);
|
|
||||||
setShowAdd(false);
|
setShowAdd(false);
|
||||||
onChanged();
|
onChanged();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{showAddPhase && (
|
||||||
|
<AddPhaseDialog
|
||||||
|
maxDurationYears={nextPhaseCap()}
|
||||||
|
onClose={() => setShowAddPhase(false)}
|
||||||
|
onCreate={handleAddPhase}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showSettings && (
|
||||||
|
<PlanSettingsDialog
|
||||||
|
plan={plan}
|
||||||
|
onClose={() => setShowSettings(false)}
|
||||||
|
onSaved={() => {
|
||||||
|
setShowSettings(false);
|
||||||
|
onChanged();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{reviewFromPhaseId && (() => {
|
||||||
|
const fromPhase = computed.phases.find((p) => p.id === reviewFromPhaseId);
|
||||||
|
if (!fromPhase) return null;
|
||||||
|
const toIndex = computed.phases.findIndex((p) => p.id === fromPhase.id) + 1;
|
||||||
|
const toPhase = computed.phases[toIndex];
|
||||||
|
const els = transitionElements(fromPhase);
|
||||||
|
return (
|
||||||
|
<TransitionReviewDialog
|
||||||
|
fromPhase={fromPhase}
|
||||||
|
toPhase={toPhase}
|
||||||
|
elements={els}
|
||||||
|
buildContext={(el) => buildTransitionContext(fromPhase, toPhase, el)}
|
||||||
|
isRetirement={(el) => (toPhase ? isRetirementTransition(el, fromPhase, toPhase) : false)}
|
||||||
|
onClose={() => setReviewFromPhaseId(null)}
|
||||||
|
onSaved={() => {
|
||||||
|
setReviewFromPhaseId(null);
|
||||||
|
onChanged();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Naechste Phasen-Kappung (fuer das Phase-Popup).
|
||||||
|
function nextPhaseCap(): number | null {
|
||||||
|
// Simpel aus den Personen ableiten (Jahre nach Planbeginn = Summe der Dauern).
|
||||||
|
const yearsBefore = plan.phases.reduce((s, p) => s + p.durationYears, 0);
|
||||||
|
const caps = plan.persons
|
||||||
|
.map((p) => p.retirementAge - (p.age + yearsBefore))
|
||||||
|
.filter((d) => d > 0);
|
||||||
|
return caps.length > 0 ? Math.min(...caps) : null;
|
||||||
|
}
|
||||||
|
|
||||||
function renderDetail() {
|
function renderDetail() {
|
||||||
if (!selected) return null;
|
if (!selected) return null;
|
||||||
|
|
||||||
@@ -339,7 +471,7 @@ export function PlanView({
|
|||||||
phase={phaseInput}
|
phase={phaseInput}
|
||||||
maxDurationYears={phase.maxDurationYears}
|
maxDurationYears={phase.maxDurationYears}
|
||||||
isLast={isLast}
|
isLast={isLast}
|
||||||
household={household}
|
inflationDefault={plan.inflationRateDefault}
|
||||||
onSaved={onChanged}
|
onSaved={onChanged}
|
||||||
onDeleted={() => {
|
onDeleted={() => {
|
||||||
setSelected(null);
|
setSelected(null);
|
||||||
@@ -354,19 +486,7 @@ export function PlanView({
|
|||||||
|
|
||||||
if (selected.type === "phaseCell") {
|
if (selected.type === "phaseCell") {
|
||||||
const phase = computed.phases.find((p) => p.id === selected.phaseId)!;
|
const phase = computed.phases.find((p) => p.id === selected.phaseId)!;
|
||||||
const ownerWorking = element.ownerRole && element.ownerRole !== "HOUSEHOLD"
|
const context = buildPhaseContext(phase, element);
|
||||||
? 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 (
|
return (
|
||||||
<ElementDetail
|
<ElementDetail
|
||||||
element={element}
|
element={element}
|
||||||
@@ -383,16 +503,7 @@ export function PlanView({
|
|||||||
const fromPhase = computed.phases.find((p) => p.id === selected.fromPhaseId)!;
|
const fromPhase = computed.phases.find((p) => p.id === selected.fromPhaseId)!;
|
||||||
const toIndex = computed.phases.findIndex((p) => p.id === fromPhase.id) + 1;
|
const toIndex = computed.phases.findIndex((p) => p.id === fromPhase.id) + 1;
|
||||||
const toPhase = computed.phases[toIndex];
|
const toPhase = computed.phases[toIndex];
|
||||||
const ce = computedElement(fromPhase.id, element.id);
|
const context = buildTransitionContext(fromPhase, toPhase, element);
|
||||||
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 (
|
return (
|
||||||
<ElementDetail
|
<ElementDetail
|
||||||
element={element}
|
element={element}
|
||||||
@@ -417,10 +528,10 @@ export function PlanView({
|
|||||||
switch (el.category) {
|
switch (el.category) {
|
||||||
case "REAL_ESTATE":
|
case "REAL_ESTATE":
|
||||||
case "OTHER_ASSET":
|
case "OTHER_ASSET":
|
||||||
return td.decision === "SELL" ? "Verkauf" : "Halten";
|
return td.decision === "SELL" ? "Verkauf" : td.decision === "HOLD" ? "Halten" : "?";
|
||||||
case "PENSION_FUND":
|
case "PENSION_FUND":
|
||||||
if (isRetirementTransition(el, fromPhase, toPhase)) {
|
if (isRetirementTransition(el, fromPhase, toPhase)) {
|
||||||
return td.payoutMode === "CAPITAL" ? "Kapital" : td.payoutMode === "COMBI" ? "Kombi" : "Rente";
|
return td.payoutMode === "CAPITAL" ? "Kapital" : td.payoutMode === "COMBI" ? "Kombi" : td.payoutMode === "PENSION" ? "Rente" : "?";
|
||||||
}
|
}
|
||||||
return num(td.withdrawal) > 0 ? `−${formatChf(num(td.withdrawal))}` : "→";
|
return num(td.withdrawal) > 0 ? `−${formatChf(num(td.withdrawal))}` : "→";
|
||||||
case "PILLAR_3A":
|
case "PILLAR_3A":
|
||||||
@@ -434,63 +545,108 @@ export function PlanView({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Zellinhalt: Start- UND Zielwert fuer wertbehaftete Elemente, sonst die Kennzahl.
|
||||||
|
function phaseCellContent(ce: ReturnType<PhaseComputed["elements"]["find"]> | undefined): React.ReactNode {
|
||||||
|
if (!ce) return "–";
|
||||||
|
if (ce.note) return ce.note;
|
||||||
|
if (VALUE_CATEGORIES.includes(ce.category) && (ce.startValue !== 0 || ce.endValue !== 0)) {
|
||||||
|
return (
|
||||||
|
<span className="whitespace-nowrap">
|
||||||
|
{formatChf(ce.startValue)} <span className="text-faint">→</span> {formatChf(ce.endValue)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return ce.summary || "–";
|
||||||
|
}
|
||||||
|
|
||||||
function PhaseHeader({ phase, onClick, active }: { phase: PhaseComputed; onClick: () => void; active: boolean }) {
|
function PhaseHeader({ phase, onClick, active }: { phase: PhaseComputed; onClick: () => void; active: boolean }) {
|
||||||
const quotaLabel = phase.isConsumption ? "Verzehr" : "Sparquote";
|
const quotaLabel = phase.isConsumption ? "Verzehr" : "Sparquote";
|
||||||
|
const quotaRemaining = Math.max(0, phase.quotaRemaining);
|
||||||
return (
|
return (
|
||||||
<th
|
<th
|
||||||
onClick={onClick}
|
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 ${
|
className={`min-w-40 cursor-pointer border-b border-r border-border px-2 py-2 text-left align-top ${
|
||||||
active ? "bg-indigo-100 dark:bg-indigo-500/20" : "bg-white dark:bg-zinc-900"
|
active ? "bg-accent-soft" : "bg-surface"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<span className="truncate text-xs font-semibold text-zinc-800 dark:text-zinc-100">{phase.name}</span>
|
<span className="truncate text-xs font-semibold text-fg">{phase.name}</span>
|
||||||
{phase.incomplete ? (
|
{phase.incomplete ? (
|
||||||
<AlertCircle className="h-3.5 w-3.5 shrink-0 text-red-500" />
|
<AlertCircle className="h-3.5 w-3.5 shrink-0 text-danger" />
|
||||||
) : (
|
) : (
|
||||||
<CheckCircle2 className="h-3.5 w-3.5 shrink-0 text-emerald-500" />
|
<CheckCircle2 className="h-3.5 w-3.5 shrink-0 text-success" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-0.5 flex flex-wrap gap-1">
|
<div className="mt-0.5 flex flex-wrap gap-1">
|
||||||
<span className="rounded bg-zinc-100 px-1 text-[10px] text-zinc-500 dark:bg-zinc-800">
|
<span className="rounded bg-surface-2 px-1 text-[10px] text-muted">
|
||||||
{phase.type === "ERWERB" ? "Erwerb" : phase.type === "PENSION" ? "Pension" : "Misch"}
|
{phase.type === "ERWERB" ? "Erwerb" : phase.type === "PENSION" ? "Pension" : "Misch"}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[10px] text-zinc-400">{phase.durationYears} J.</span>
|
<span className="text-[10px] text-faint">{phase.durationYears} J.</span>
|
||||||
<span className="text-[10px] text-zinc-400">Alter {phase.persons.map((p) => p.startAge).join("/")}</span>
|
<span className="text-[10px] text-faint">Alter {phase.persons.map((p) => p.startAge).join("/")}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 space-y-0.5 text-[10px] leading-tight text-zinc-500 dark:text-zinc-400">
|
<div className="mt-1 space-y-0.5 text-[10px] leading-tight text-muted">
|
||||||
<div>Einkommen {formatChf(phase.incomeTotal)}</div>
|
<div>Einkommen {formatChf(phase.incomeTotal)}</div>
|
||||||
<div>Ausgaben {formatChf(phase.expenseTotal)}</div>
|
<div>Ausgaben {formatChf(phase.expenseTotal)}</div>
|
||||||
<div className={phase.quotaComplete ? "text-emerald-600 dark:text-emerald-400" : "text-red-600 dark:text-red-400"}>
|
<div className={phase.quotaComplete ? "text-success" : "text-danger"}>
|
||||||
{quotaLabel} {formatChf(Math.abs(phase.quota))}
|
{quotaLabel} {formatChf(Math.abs(phase.quota))}
|
||||||
|
{!phase.quotaComplete && <span> · offen {formatChf(quotaRemaining)}</span>}
|
||||||
</div>
|
</div>
|
||||||
<div className={phase.availableCapitalComplete ? "" : "text-red-600 dark:text-red-400"}>
|
<div className={phase.availableCapitalComplete ? "" : "text-danger"}>
|
||||||
Kapital {phase.availableCapital === null ? "n.a." : formatChf(phase.availableCapital)}
|
Kapital {phase.availableCapital === null ? "n.a." : formatChf(phase.availableCapital)}
|
||||||
|
{phase.availableCapital !== null && !phase.availableCapitalComplete && (
|
||||||
|
<span> · offen {formatChf(Math.max(0, phase.availableCapitalRemaining))}</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</th>
|
</th>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function TransitionHeader({ openCount, onClick }: { openCount: number; onClick: () => void }) {
|
||||||
|
return (
|
||||||
|
<th
|
||||||
|
onClick={onClick}
|
||||||
|
className={`cursor-pointer border-b border-r border-border px-2 py-2 text-center align-top text-[11px] font-medium ${
|
||||||
|
openCount > 0 ? "bg-accent text-accent-fg" : "bg-accent-soft text-accent-soft-fg"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div>Uebergang</div>
|
||||||
|
{openCount > 0 ? (
|
||||||
|
<div className="mt-1 rounded-full bg-accent-fg/20 px-1.5 py-0.5 text-[10px] font-semibold">
|
||||||
|
{openCount} offen
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="mt-1 text-[10px] opacity-80">pruefen</div>
|
||||||
|
)}
|
||||||
|
</th>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function FragmentRows({ children }: { children: React.ReactNode }) {
|
function FragmentRows({ children }: { children: React.ReactNode }) {
|
||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Dialog: neues finanzielles Element mit Direkteingabe der Phase-1-Werte ---
|
||||||
function AddElementDialog({
|
function AddElementDialog({
|
||||||
household,
|
plan,
|
||||||
|
firstPhase,
|
||||||
onClose,
|
onClose,
|
||||||
onCreate,
|
onCreated,
|
||||||
}: {
|
}: {
|
||||||
household: HouseholdInput;
|
plan: PlanInput;
|
||||||
|
firstPhase: PhaseComputed;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onCreate: (payload: { category: ElementCategory; name: string; ownerRole: string | null }) => void;
|
onCreated: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [category, setCategory] = useState<ElementCategory>("INCOME");
|
const [category, setCategory] = useState<ElementCategory>("INCOME");
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [ownerRole, setOwnerRole] = useState<string>(household.householdType === "COUPLE" ? "PERSON_A" : "PERSON_A");
|
const [ownerRole, setOwnerRole] = useState<string>("PERSON_A");
|
||||||
|
const [pd, setPd] = useState<PhaseData>({});
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const needsPerson = PERSON_ONLY_CATEGORIES.includes(category);
|
const needsPerson = PERSON_ONLY_CATEGORIES.includes(category);
|
||||||
const isCouple = household.householdType === "COUPLE";
|
const isCouple = plan.householdType === "COUPLE";
|
||||||
|
|
||||||
const ownerOptions = needsPerson
|
const ownerOptions = needsPerson
|
||||||
? isCouple
|
? isCouple
|
||||||
@@ -510,24 +666,62 @@ function AddElementDialog({
|
|||||||
{ value: "PERSON_A", label: "Person A" },
|
{ value: "PERSON_A", label: "Person A" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Kontext fuer die Phase-1-Felder des neuen Elements.
|
||||||
|
const owner = needsPerson || ownerRole !== "HOUSEHOLD" ? ownerRole : null;
|
||||||
|
const ownerWorking =
|
||||||
|
owner && owner !== "HOUSEHOLD"
|
||||||
|
? firstPhase.persons.find((p) => p.role === owner)?.working ?? false
|
||||||
|
: firstPhase.type !== "PENSION";
|
||||||
|
const context: CellContext = {
|
||||||
|
kind: "phase",
|
||||||
|
phaseId: firstPhase.id,
|
||||||
|
ownerWorking,
|
||||||
|
isConsumption: firstPhase.isConsumption,
|
||||||
|
durationYears: firstPhase.durationYears,
|
||||||
|
isRetirementTransition: false,
|
||||||
|
carriedEndValue: 0,
|
||||||
|
carried: false,
|
||||||
|
derivedStart: 0,
|
||||||
|
quotaRateMax: Math.max(0, Math.abs(firstPhase.quota) - firstPhase.quotaAllocated),
|
||||||
|
capitalMax: firstPhase.availableCapital == null ? undefined : Math.max(0, firstPhase.availableCapital - firstPhase.availableCapitalUsed),
|
||||||
|
};
|
||||||
|
|
||||||
|
async function create() {
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const { element } = await api.post<{ element: { id: string } }>(`/api/plans/${plan.id}/elements`, {
|
||||||
|
category,
|
||||||
|
name: name.trim() || CATEGORY_LABELS[category],
|
||||||
|
ownerRole,
|
||||||
|
});
|
||||||
|
// Ist-Zustand direkt in Phase 1 speichern (sofern Felder ausgefuellt).
|
||||||
|
if (Object.keys(pd).length > 0) {
|
||||||
|
await api.put(`/api/elements/${element.id}/phase/${firstPhase.id}`, pd);
|
||||||
|
}
|
||||||
|
onCreated();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "Erstellen fehlgeschlagen.");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-40 flex items-center justify-center bg-black/40 px-4" onClick={onClose}>
|
<DialogShell title="Finanzielles Element" onClose={onClose} wide>
|
||||||
<div
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
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>
|
<div>
|
||||||
<label className="mb-1 block text-xs font-medium text-zinc-600 dark:text-zinc-400">Kategorie</label>
|
<label className="mb-1 block text-xs font-medium text-muted">Kategorie</label>
|
||||||
<select
|
<select
|
||||||
value={category}
|
value={category}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const c = e.target.value as ElementCategory;
|
const c = e.target.value as ElementCategory;
|
||||||
setCategory(c);
|
setCategory(c);
|
||||||
|
setPd({});
|
||||||
if (PERSON_ONLY_CATEGORIES.includes(c) && ownerRole === "HOUSEHOLD") setOwnerRole("PERSON_A");
|
if (PERSON_ONLY_CATEGORIES.includes(c) && ownerRole === "HOUSEHOLD") setOwnerRole("PERSON_A");
|
||||||
if (!name) setName(CATEGORY_LABELS[c]);
|
if (!name) setName(CATEGORY_LABELS[c]);
|
||||||
}}
|
}}
|
||||||
className="w-full rounded-lg border border-zinc-300 px-3 py-2 text-sm focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/20 dark:border-zinc-600 dark:bg-zinc-950"
|
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-fg focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/25"
|
||||||
>
|
>
|
||||||
{CATEGORY_ORDER.map((c) => (
|
{CATEGORY_ORDER.map((c) => (
|
||||||
<option key={c} value={c}>
|
<option key={c} value={c}>
|
||||||
@@ -537,20 +731,11 @@ function AddElementDialog({
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 block text-xs font-medium text-zinc-600 dark:text-zinc-400">Bezeichnung</label>
|
<label className="mb-1 block text-xs font-medium text-muted">Zuordnung</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
|
<select
|
||||||
value={ownerRole}
|
value={ownerRole}
|
||||||
onChange={(e) => setOwnerRole(e.target.value)}
|
onChange={(e) => setOwnerRole(e.target.value)}
|
||||||
className="w-full rounded-lg border border-zinc-300 px-3 py-2 text-sm focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/20 dark:border-zinc-600 dark:bg-zinc-950"
|
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-fg focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/25"
|
||||||
>
|
>
|
||||||
{ownerOptions.map((o) => (
|
{ownerOptions.map((o) => (
|
||||||
<option key={o.value} value={o.value}>
|
<option key={o.value} value={o.value}>
|
||||||
@@ -559,23 +744,271 @@ function AddElementDialog({
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="sm:col-span-2">
|
||||||
<button
|
<label className="mb-1 block text-xs font-medium text-muted">Bezeichnung</label>
|
||||||
type="button"
|
<input
|
||||||
onClick={() => onCreate({ category, name: name.trim() || CATEGORY_LABELS[category], ownerRole })}
|
value={name}
|
||||||
className="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-500 dark:bg-indigo-500 dark:hover:bg-indigo-400"
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder={CATEGORY_LABELS[category]}
|
||||||
|
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-fg focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/25"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-1 border-t border-border pt-3">
|
||||||
|
<div className="mb-2 text-xs font-semibold uppercase tracking-wide text-faint">Werte (erste Lebensphase)</div>
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
|
<ElementPhaseFields
|
||||||
|
element={{ category }}
|
||||||
|
context={context}
|
||||||
|
pd={pd}
|
||||||
|
setP={(patch) => setPd((prev) => ({ ...prev, ...patch }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="text-sm text-danger">{error}</p>}
|
||||||
|
<DialogActions saving={saving} onConfirm={create} onClose={onClose} confirmLabel="Erstellen" />
|
||||||
|
</DialogShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Dialog: neue Lebensphase ---
|
||||||
|
function AddPhaseDialog({
|
||||||
|
maxDurationYears,
|
||||||
|
onClose,
|
||||||
|
onCreate,
|
||||||
|
}: {
|
||||||
|
maxDurationYears: number | null;
|
||||||
|
onClose: () => void;
|
||||||
|
onCreate: (payload: { name?: string; durationYears?: number; inflationRate?: number | null }) => void;
|
||||||
|
}) {
|
||||||
|
const cap = maxDurationYears;
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [durationYears, setDurationYears] = useState(cap ?? 10);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DialogShell title="Neue Lebensphase" onClose={onClose}>
|
||||||
|
<div className="grid grid-cols-1 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs font-medium text-muted">Bezeichnung (optional)</label>
|
||||||
|
<input
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="automatisch (Erwerb/Pension)"
|
||||||
|
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-fg focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/25"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs font-medium text-muted">
|
||||||
|
Dauer (Jahre){cap != null ? ` · max. ${cap}` : ""}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={durationYears}
|
||||||
|
min={1}
|
||||||
|
max={cap ?? undefined}
|
||||||
|
onFocus={(e) => e.currentTarget.select()}
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = e.target.valueAsNumber || 1;
|
||||||
|
setDurationYears(cap != null ? Math.min(v, cap) : v);
|
||||||
|
}}
|
||||||
|
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-fg focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/25"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogActions
|
||||||
|
saving={saving}
|
||||||
|
onConfirm={() => {
|
||||||
|
setSaving(true);
|
||||||
|
onCreate({ name: name.trim() || undefined, durationYears });
|
||||||
|
}}
|
||||||
|
onClose={onClose}
|
||||||
|
confirmLabel="Erstellen"
|
||||||
|
/>
|
||||||
|
</DialogShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Dialog: Plan-Einstellungen (Grundprofil bearbeiten) ---
|
||||||
|
function PlanSettingsDialog({ plan, onClose, onSaved }: { plan: PlanInput; onClose: () => void; onSaved: () => void }) {
|
||||||
|
const [draft, setDraft] = useState<ProfileDraft>({
|
||||||
|
householdType: plan.householdType,
|
||||||
|
inflationRateDefault: plan.inflationRateDefault,
|
||||||
|
persons: plan.persons.map((p) => ({ role: p.role, age: p.age, retirementAge: p.retirementAge })),
|
||||||
|
});
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await api.patch(`/api/plans/${plan.id}`, draft);
|
||||||
|
onSaved();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "Speichern fehlgeschlagen.");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DialogShell title="Plan-Einstellungen" onClose={onClose}>
|
||||||
|
<PlanProfileFields draft={draft} onChange={setDraft} />
|
||||||
|
{error && <p className="text-sm text-danger">{error}</p>}
|
||||||
|
<DialogActions saving={saving} onConfirm={save} onClose={onClose} confirmLabel="Speichern" />
|
||||||
|
</DialogShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Dialog: geführter Übergang ---
|
||||||
|
function TransitionReviewDialog({
|
||||||
|
fromPhase,
|
||||||
|
toPhase,
|
||||||
|
elements,
|
||||||
|
buildContext,
|
||||||
|
isRetirement,
|
||||||
|
onClose,
|
||||||
|
onSaved,
|
||||||
|
}: {
|
||||||
|
fromPhase: PhaseComputed;
|
||||||
|
toPhase: PhaseComputed | undefined;
|
||||||
|
elements: ElementInput[];
|
||||||
|
buildContext: (el: ElementInput) => CellContext;
|
||||||
|
isRetirement: (el: ElementInput) => boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSaved: () => void;
|
||||||
|
}) {
|
||||||
|
const [tds, setTds] = useState<Record<string, import("@/lib/elements").TransitionData>>(() =>
|
||||||
|
Object.fromEntries(elements.map((e) => [e.id, { ...(e.transitionValues[fromPhase.id] ?? {}) }]))
|
||||||
|
);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
async function saveAll() {
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
for (const e of elements) {
|
||||||
|
await api.put(`/api/elements/${e.id}/transition/${fromPhase.id}`, tds[e.id] ?? {});
|
||||||
|
}
|
||||||
|
onSaved();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Speichern fehlgeschlagen.");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DialogShell title={`Übergang prüfen: ${fromPhase.name} → ${toPhase?.name ?? "Ende"}`} onClose={onClose} wide>
|
||||||
|
<p className="text-sm text-muted">
|
||||||
|
Gehen Sie die Positionen durch und treffen Sie je Element den Übergangs-Entscheid (Halten, Verkaufen,
|
||||||
|
Bezug). Danach werden gehaltene Werte automatisch in die nächste Phase fortgeschrieben.
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{elements.length === 0 && (
|
||||||
|
<p className="rounded-lg border border-dashed border-border bg-surface-2 p-3 text-sm text-muted">
|
||||||
|
An diesem Übergang gibt es keine zu entscheidenden Positionen.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{elements.map((el) => {
|
||||||
|
const ctx = buildContext(el);
|
||||||
|
const retire = isRetirement(el);
|
||||||
|
const hint =
|
||||||
|
(el.category === "PENSION_FUND" || el.category === "PILLAR_3A") && !retire
|
||||||
|
? "Hier könnten Sie optional Kapital beziehen."
|
||||||
|
: el.category === "PENSION_FUND" && retire
|
||||||
|
? "Pensionierung: Bezugsart wählen (Rente / Kapital / Kombination)."
|
||||||
|
: el.category === "PILLAR_3A" && retire
|
||||||
|
? "Wird bei Pensionierung vollständig bezogen."
|
||||||
|
: null;
|
||||||
|
return (
|
||||||
|
<div key={el.id} className="rounded-xl border border-border bg-surface-2 p-3">
|
||||||
|
<div className="mb-2 flex items-center gap-2">
|
||||||
|
<span className="text-accent">{CATEGORY_ICON[el.category]}</span>
|
||||||
|
<span className="text-sm font-semibold text-fg">{el.name}</span>
|
||||||
|
<span className="text-xs text-faint">{CATEGORY_LABELS[el.category]}</span>
|
||||||
|
</div>
|
||||||
|
{hint && <p className="mb-2 text-xs text-accent-soft-fg">{hint}</p>}
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
|
<ElementTransitionFields
|
||||||
|
element={el}
|
||||||
|
context={ctx}
|
||||||
|
td={tds[el.id] ?? {}}
|
||||||
|
setT={(patch) => setTds((prev) => ({ ...prev, [el.id]: { ...prev[el.id], ...patch } }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{error && <p className="text-sm text-danger">{error}</p>}
|
||||||
|
<DialogActions saving={saving} onConfirm={saveAll} onClose={onClose} confirmLabel="Alle speichern" />
|
||||||
|
</DialogShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- gemeinsame Dialog-Bausteine ---
|
||||||
|
function DialogShell({
|
||||||
|
title,
|
||||||
|
onClose,
|
||||||
|
children,
|
||||||
|
wide,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
onClose: () => void;
|
||||||
|
children: React.ReactNode;
|
||||||
|
wide?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-40 flex items-start justify-center overflow-y-auto bg-black/40 px-4 py-8" onClick={onClose}>
|
||||||
|
<div
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className={`flex w-full ${wide ? "max-w-2xl" : "max-w-md"} flex-col gap-3 rounded-2xl border border-border bg-surface p-6 shadow-xl`}
|
||||||
>
|
>
|
||||||
Erstellen
|
<div className="flex items-center justify-between">
|
||||||
</button>
|
<h2 className="text-base font-semibold text-fg">{title}</h2>
|
||||||
<button
|
<button type="button" onClick={onClose} aria-label="Schliessen" className="rounded-md p-1 text-faint hover:bg-surface-2">
|
||||||
type="button"
|
<X className="h-4 w-4" />
|
||||||
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function DialogActions({
|
||||||
|
saving,
|
||||||
|
onConfirm,
|
||||||
|
onClose,
|
||||||
|
confirmLabel,
|
||||||
|
}: {
|
||||||
|
saving: boolean;
|
||||||
|
onConfirm: () => void;
|
||||||
|
onClose: () => void;
|
||||||
|
confirmLabel: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex gap-2 pt-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={saving}
|
||||||
|
onClick={onConfirm}
|
||||||
|
className="rounded-lg bg-accent px-4 py-2 text-sm font-medium text-accent-fg shadow-sm hover:bg-accent-hover disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{saving ? "..." : confirmLabel}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="rounded-lg border border-border px-4 py-2 text-sm font-medium text-muted hover:bg-surface-2"
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,59 +1,77 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { KeyRound, LogOut, Settings, UserCircle2 } from "lucide-react";
|
import { KeyRound, LogOut, Palette, UserCircle2 } from "lucide-react";
|
||||||
import { api } from "@/lib/api-client";
|
import { api } from "@/lib/api-client";
|
||||||
|
import { getEffectiveTheme, setTheme, THEMES, type Theme } from "@/lib/theme";
|
||||||
|
|
||||||
export function ProfileMenu({
|
export function ProfileMenu({ username }: { username: string }) {
|
||||||
username,
|
|
||||||
onOpenHouseholdSettings,
|
|
||||||
}: {
|
|
||||||
username: string;
|
|
||||||
onOpenHouseholdSettings: () => void;
|
|
||||||
}) {
|
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [showPasswordDialog, setShowPasswordDialog] = useState(false);
|
const [showPasswordDialog, setShowPasswordDialog] = useState(false);
|
||||||
|
const [theme, setThemeState] = useState<Theme>("light");
|
||||||
const menuRef = useRef<HTMLDivElement | null>(null);
|
const menuRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setThemeState(getEffectiveTheme());
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function handleClickOutside(e: MouseEvent) {
|
function handleClickOutside(e: MouseEvent) {
|
||||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
if (menuRef.current && !menuRef.current.contains(e.target as Node)) setOpen(false);
|
||||||
setOpen(false);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
document.addEventListener("mousedown", handleClickOutside);
|
document.addEventListener("mousedown", handleClickOutside);
|
||||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
function chooseTheme(t: Theme) {
|
||||||
|
setTheme(t);
|
||||||
|
setThemeState(t);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative" ref={menuRef}>
|
<div className="relative" ref={menuRef}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setOpen((v) => !v)}
|
onClick={() => setOpen((v) => !v)}
|
||||||
className="flex items-center gap-2 rounded-full border border-zinc-200 bg-white py-1 pl-1 pr-3 text-sm shadow-sm hover:bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-800 dark:hover:bg-zinc-700"
|
className="flex items-center gap-2 rounded-full border border-border bg-surface py-1 pl-1 pr-3 text-sm shadow-sm hover:bg-surface-2"
|
||||||
>
|
>
|
||||||
<span className="flex h-7 w-7 items-center justify-center rounded-full bg-indigo-100 text-xs font-semibold uppercase text-indigo-700 dark:bg-indigo-500/20 dark:text-indigo-300">
|
<span className="flex h-7 w-7 items-center justify-center rounded-full bg-accent-soft text-xs font-semibold uppercase text-accent-soft-fg">
|
||||||
{username.slice(0, 2)}
|
{username.slice(0, 2)}
|
||||||
</span>
|
</span>
|
||||||
<span className="hidden font-medium text-zinc-700 sm:inline dark:text-zinc-200">{username}</span>
|
<span className="hidden font-medium text-fg sm:inline">{username}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{open && (
|
{open && (
|
||||||
<div className="absolute right-0 top-11 z-30 w-56 overflow-hidden rounded-xl border border-zinc-200 bg-white shadow-lg dark:border-zinc-700 dark:bg-zinc-800">
|
<div className="absolute right-0 top-11 z-30 w-60 overflow-hidden rounded-xl border border-border bg-surface shadow-lg">
|
||||||
<div className="border-b border-zinc-100 px-4 py-3 dark:border-zinc-700">
|
<div className="border-b border-border px-4 py-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<UserCircle2 className="h-4 w-4 text-indigo-500 dark:text-indigo-400" />
|
<UserCircle2 className="h-4 w-4 text-accent" />
|
||||||
<span className="text-sm font-medium text-zinc-800 dark:text-zinc-100">{username}</span>
|
<span className="text-sm font-medium text-fg">{username}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<MenuItem
|
|
||||||
icon={<Settings className="h-4 w-4" />}
|
<div className="border-b border-border px-4 py-3">
|
||||||
label="Grundprofil bearbeiten"
|
<div className="mb-2 flex items-center gap-2 text-xs font-medium text-muted">
|
||||||
onClick={() => {
|
<Palette className="h-3.5 w-3.5" /> Farbschema
|
||||||
setOpen(false);
|
</div>
|
||||||
onOpenHouseholdSettings();
|
<div className="grid grid-cols-3 gap-1">
|
||||||
}}
|
{THEMES.map((t) => (
|
||||||
/>
|
<button
|
||||||
|
key={t.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => chooseTheme(t.value)}
|
||||||
|
className={`rounded-lg border px-2 py-1.5 text-xs font-medium ${
|
||||||
|
theme === t.value
|
||||||
|
? "border-accent bg-accent-soft text-accent-soft-fg"
|
||||||
|
: "border-border text-muted hover:bg-surface-2"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<MenuItem
|
<MenuItem
|
||||||
icon={<KeyRound className="h-4 w-4" />}
|
icon={<KeyRound className="h-4 w-4" />}
|
||||||
label="Passwort aendern"
|
label="Passwort aendern"
|
||||||
@@ -78,20 +96,12 @@ export function ProfileMenu({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function MenuItem({
|
function MenuItem({ icon, label, onClick }: { icon: React.ReactNode; label: string; onClick: () => void }) {
|
||||||
icon,
|
|
||||||
label,
|
|
||||||
onClick,
|
|
||||||
}: {
|
|
||||||
icon: React.ReactNode;
|
|
||||||
label: string;
|
|
||||||
onClick: () => void;
|
|
||||||
}) {
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className="flex w-full items-center gap-2.5 px-4 py-2.5 text-left text-sm text-zinc-700 hover:bg-indigo-50 hover:text-indigo-700 dark:text-zinc-200 dark:hover:bg-indigo-500/10 dark:hover:text-indigo-300"
|
className="flex w-full items-center gap-2.5 px-4 py-2.5 text-left text-sm text-muted hover:bg-accent-soft hover:text-accent-soft-fg"
|
||||||
>
|
>
|
||||||
{icon}
|
{icon}
|
||||||
{label}
|
{label}
|
||||||
@@ -127,55 +137,26 @@ function ChangePasswordDialog({ onClose }: { onClose: () => void }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const inputClass =
|
const inputClass =
|
||||||
"w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/20 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100";
|
"w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-fg shadow-sm focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/25";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-40 flex items-center justify-center bg-black/40 px-4" onClick={onClose}>
|
<div className="fixed inset-0 z-40 flex items-center justify-center bg-black/40 px-4" onClick={onClose}>
|
||||||
<form
|
<form
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
className="flex w-full max-w-sm flex-col gap-3 rounded-2xl border border-zinc-200 bg-white p-6 shadow-xl dark:border-zinc-700 dark:bg-zinc-900"
|
className="flex w-full max-w-sm flex-col gap-3 rounded-2xl border border-border bg-surface p-6 shadow-xl"
|
||||||
>
|
>
|
||||||
<h2 className="text-base font-semibold text-zinc-900 dark:text-zinc-50">Passwort aendern</h2>
|
<h2 className="text-base font-semibold text-fg">Passwort aendern</h2>
|
||||||
<input
|
<input type="password" placeholder="Aktuelles Passwort" autoComplete="current-password" value={currentPassword} onChange={(e) => setCurrentPassword(e.target.value)} className={inputClass} />
|
||||||
type="password"
|
<input type="password" placeholder="Neues Passwort" autoComplete="new-password" value={newPassword} onChange={(e) => setNewPassword(e.target.value)} className={inputClass} />
|
||||||
placeholder="Aktuelles Passwort"
|
<input type="password" placeholder="Neues Passwort bestaetigen" autoComplete="new-password" value={newPasswordConfirm} onChange={(e) => setNewPasswordConfirm(e.target.value)} className={inputClass} />
|
||||||
autoComplete="current-password"
|
{error && <p className="text-sm text-danger">{error}</p>}
|
||||||
value={currentPassword}
|
{done && <p className="text-sm text-success">Passwort geaendert.</p>}
|
||||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
|
||||||
className={inputClass}
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
placeholder="Neues Passwort"
|
|
||||||
autoComplete="new-password"
|
|
||||||
value={newPassword}
|
|
||||||
onChange={(e) => setNewPassword(e.target.value)}
|
|
||||||
className={inputClass}
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
placeholder="Neues Passwort bestaetigen"
|
|
||||||
autoComplete="new-password"
|
|
||||||
value={newPasswordConfirm}
|
|
||||||
onChange={(e) => setNewPasswordConfirm(e.target.value)}
|
|
||||||
className={inputClass}
|
|
||||||
/>
|
|
||||||
{error && <p className="text-sm text-red-600 dark:text-red-400">{error}</p>}
|
|
||||||
{done && <p className="text-sm text-emerald-600 dark:text-emerald-400">Passwort geaendert.</p>}
|
|
||||||
<div className="flex gap-2 pt-1">
|
<div className="flex gap-2 pt-1">
|
||||||
<button
|
<button type="submit" disabled={saving} className="rounded-lg bg-accent px-4 py-2 text-sm font-medium text-accent-fg shadow-sm hover:bg-accent-hover disabled:opacity-50">
|
||||||
type="submit"
|
|
||||||
disabled={saving}
|
|
||||||
className="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-500 disabled:opacity-50 dark:bg-indigo-500 dark:hover:bg-indigo-400"
|
|
||||||
>
|
|
||||||
{saving ? "..." : "Speichern"}
|
{saving ? "..." : "Speichern"}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button type="button" onClick={onClose} className="rounded-lg border border-border px-4 py-2 text-sm font-medium text-muted hover:bg-surface-2">
|
||||||
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
|
Abbrechen
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -32,10 +32,10 @@ export function Timeline({ phases, persons }: { phases: PhaseComputed[]; persons
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border border-zinc-200/70 bg-white p-4 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
|
<div className="rounded-xl border border-border bg-surface p-4 shadow-sm">
|
||||||
<div className="mb-3 flex items-center justify-between">
|
<div className="mb-3 flex items-center justify-between">
|
||||||
<h3 className="text-sm font-semibold text-zinc-800 dark:text-zinc-100">Zeitachse</h3>
|
<h3 className="text-sm font-semibold text-fg">Zeitachse</h3>
|
||||||
<div className="flex gap-3 text-xs text-zinc-500">
|
<div className="flex gap-3 text-xs text-muted">
|
||||||
{persons.map((p) => (
|
{persons.map((p) => (
|
||||||
<span key={p.role} className="flex items-center gap-1">
|
<span key={p.role} className="flex items-center gap-1">
|
||||||
<span className="inline-block h-2 w-2 rounded-full" style={{ backgroundColor: p.color }} />
|
<span className="inline-block h-2 w-2 rounded-full" style={{ backgroundColor: p.color }} />
|
||||||
@@ -65,18 +65,18 @@ export function Timeline({ phases, persons }: { phases: PhaseComputed[]; persons
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Achse */}
|
{/* Achse */}
|
||||||
<div className="relative h-2 w-full rounded-full bg-gradient-to-r from-indigo-200 to-indigo-400 dark:from-indigo-500/30 dark:to-indigo-500/60">
|
<div className="relative h-2 w-full rounded-full bg-gradient-to-r from-accent-soft to-accent">
|
||||||
{boundaries.slice(1).map((b) => (
|
{boundaries.slice(1).map((b) => (
|
||||||
<div
|
<div
|
||||||
key={b.year}
|
key={b.year}
|
||||||
className="absolute top-0 h-2 w-px bg-white/70 dark:bg-zinc-900/70"
|
className="absolute top-0 h-2 w-px bg-surface/70"
|
||||||
style={{ left: pct(minAge + b.year) }}
|
style={{ left: pct(minAge + b.year) }}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Alters-Beschriftung */}
|
{/* Alters-Beschriftung */}
|
||||||
<div className="mt-1 flex justify-between text-[11px] text-zinc-500">
|
<div className="mt-1 flex justify-between text-[11px] text-muted">
|
||||||
<span>{minAge} J.</span>
|
<span>{minAge} J.</span>
|
||||||
<span>{maxAge} J.</span>
|
<span>{maxAge} J.</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export interface TimelineSeries {
|
|||||||
// ueberlagerte Plaene fuer den Szenario-Vergleich.
|
// 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-muted">Noch keine Phasen vorhanden.</p>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Datenpunkte je Phasen-Index; X-Achse = Phasenname des Hauptplans.
|
// Datenpunkte je Phasen-Index; X-Achse = Phasenname des Hauptplans.
|
||||||
@@ -46,7 +46,7 @@ export function WealthChart({ series }: { series: TimelineSeries[] }) {
|
|||||||
<div className="h-80 w-full">
|
<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-border" />
|
||||||
<XAxis dataKey="phase" tick={{ fontSize: 11 }} />
|
<XAxis dataKey="phase" tick={{ fontSize: 11 }} />
|
||||||
<YAxis
|
<YAxis
|
||||||
tick={{ fontSize: 11 }}
|
tick={{ fontSize: 11 }}
|
||||||
|
|||||||
+75
-74
@@ -3,10 +3,9 @@ import {
|
|||||||
AHV_FULL_CONTRIBUTION_YEARS,
|
AHV_FULL_CONTRIBUTION_YEARS,
|
||||||
AHV_MAX_ANNUAL_SINGLE,
|
AHV_MAX_ANNUAL_SINGLE,
|
||||||
} from "@/lib/constants";
|
} from "@/lib/constants";
|
||||||
import { floorToThousand } from "@/lib/format";
|
|
||||||
import { num } from "@/lib/elements";
|
import { num } from "@/lib/elements";
|
||||||
import type { ElementCategory } from "@/lib/elements";
|
import type { ElementCategory } from "@/lib/elements";
|
||||||
import type { HouseholdInput, PersonRole, PlanInput } from "@/lib/types";
|
import type { PersonRole, PlanInput } from "@/lib/types";
|
||||||
|
|
||||||
export type PhaseType = "ERWERB" | "PENSION" | "MIXED";
|
export type PhaseType = "ERWERB" | "PENSION" | "MIXED";
|
||||||
export type ElementStatus = "ACTIVE" | "SOLD" | "SETTLED";
|
export type ElementStatus = "ACTIVE" | "SOLD" | "SETTLED";
|
||||||
@@ -28,11 +27,12 @@ export interface ElementPhaseComputed {
|
|||||||
ownerRole: string | null;
|
ownerRole: string | null;
|
||||||
status: ElementStatus;
|
status: ElementStatus;
|
||||||
locked: boolean; // verkauft/getilgt -> in dieser Phase nicht mehr editierbar
|
locked: boolean; // verkauft/getilgt -> in dieser Phase nicht mehr editierbar
|
||||||
|
carried: boolean; // Startwert wird aus der Vorphase fortgeschrieben (Phase >= 2)
|
||||||
startValue: number; // Netto-Wert zu Phasenbeginn (Aktiven +, Schulden -)
|
startValue: number; // Netto-Wert zu Phasenbeginn (Aktiven +, Schulden -)
|
||||||
endValue: number; // Netto-Wert am Phasenende
|
endValue: number; // Netto-Wert am Phasenende
|
||||||
incomeContribution: number; // Beitrag zum Phasen-Einkommen
|
incomeContribution: number; // Beitrag zum Phasen-Einkommen
|
||||||
expenseContribution: number; // Beitrag zu den Phasen-Ausgaben
|
expenseContribution: number; // Beitrag zu den Phasen-Ausgaben
|
||||||
quotaUse: number; // Betrag, der Spar-/Verzehrquote verbraucht (3a/Sonstiges Vermoegen)
|
quotaUse: number; // Betrag, der Spar-/Verzehrquote verbraucht (3a/Vermoegen/Amort./Tilgung)
|
||||||
capitalUse: number; // verbrauchtes verfuegbares Startkapital (Aufstockung/Neuinvestition)
|
capitalUse: number; // verbrauchtes verfuegbares Startkapital (Aufstockung/Neuinvestition)
|
||||||
summary: string; // Kennzahl fuer die eingeklappte Zelle
|
summary: string; // Kennzahl fuer die eingeklappte Zelle
|
||||||
note: string | null; // z. B. "Verkauft", "Getilgt", "Vollstaendig bezogen"
|
note: string | null; // z. B. "Verkauft", "Getilgt", "Vollstaendig bezogen"
|
||||||
@@ -51,9 +51,11 @@ export interface PhaseComputed {
|
|||||||
quota: number; // Einkommen - Ausgaben (Sparquote wenn >=0, Verzehrquote wenn <0)
|
quota: number; // Einkommen - Ausgaben (Sparquote wenn >=0, Verzehrquote wenn <0)
|
||||||
isConsumption: boolean;
|
isConsumption: boolean;
|
||||||
quotaAllocated: number; // verteilte Sparbeitraege bzw. gedeckte Verzehr-Bezuege
|
quotaAllocated: number; // verteilte Sparbeitraege bzw. gedeckte Verzehr-Bezuege
|
||||||
|
quotaRemaining: number; // |quota| - quotaAllocated (offener Rest, kann negativ = ueberzogen)
|
||||||
quotaComplete: boolean;
|
quotaComplete: boolean;
|
||||||
availableCapital: number | null; // null in der ersten Phase
|
availableCapital: number | null; // null in der ersten Phase
|
||||||
availableCapitalUsed: number;
|
availableCapitalUsed: number;
|
||||||
|
availableCapitalRemaining: number; // 0 in der ersten Phase
|
||||||
availableCapitalComplete: boolean;
|
availableCapitalComplete: boolean;
|
||||||
incomplete: boolean; // roter Status (Quote/Kapital nicht vollstaendig verteilt)
|
incomplete: boolean; // roter Status (Quote/Kapital nicht vollstaendig verteilt)
|
||||||
elements: ElementPhaseComputed[];
|
elements: ElementPhaseComputed[];
|
||||||
@@ -68,28 +70,16 @@ export interface PlanComputed {
|
|||||||
nachlass: number;
|
nachlass: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Loest das effektive Pensionsalter einer Person auf (Plan-Override vor Profil-Default).
|
|
||||||
export function resolveRetirementAge(
|
|
||||||
role: PersonRole,
|
|
||||||
plan: { retirementAgeA: number | null; retirementAgeB: number | null },
|
|
||||||
profileDefault: number
|
|
||||||
): number {
|
|
||||||
const override = role === "PERSON_A" ? plan.retirementAgeA : plan.retirementAgeB;
|
|
||||||
return override ?? profileDefault;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Maximale Dauer einer neuen Phase, die yearsBefore Jahre nach Planbeginn startet:
|
// Maximale Dauer einer neuen Phase, die yearsBefore Jahre nach Planbeginn startet:
|
||||||
// bis zum naechsten Pensionsereignis einer noch erwerbenden Person (null = unbegrenzt).
|
// bis zum naechsten Pensionsereignis einer noch erwerbenden Person (null = unbegrenzt).
|
||||||
export function maxPhaseDuration(
|
export function maxPhaseDuration(
|
||||||
persons: { role: PersonRole; age: number; retirementAge: number }[],
|
persons: { role: PersonRole; age: number; retirementAge: number }[],
|
||||||
plan: { retirementAgeA: number | null; retirementAgeB: number | null },
|
|
||||||
yearsBefore: number
|
yearsBefore: number
|
||||||
): number | null {
|
): number | null {
|
||||||
const caps: number[] = [];
|
const caps: number[] = [];
|
||||||
for (const p of persons) {
|
for (const p of persons) {
|
||||||
const ra = resolveRetirementAge(p.role, plan, p.retirementAge);
|
|
||||||
const startAge = p.age + yearsBefore;
|
const startAge = p.age + yearsBefore;
|
||||||
if (startAge < ra) caps.push(ra - startAge);
|
if (startAge < p.retirementAge) caps.push(p.retirementAge - startAge);
|
||||||
}
|
}
|
||||||
return caps.length > 0 ? Math.min(...caps) : null;
|
return caps.length > 0 ? Math.min(...caps) : null;
|
||||||
}
|
}
|
||||||
@@ -108,23 +98,23 @@ function emptyCarry(): Carry {
|
|||||||
return { status: "ACTIVE", value: 0, mortgage: 0, owed: 0, pkPensionAnnual: 0, hasCarry: false };
|
return { status: "ACTIVE", value: 0, mortgage: 0, owed: 0, pkPensionAnnual: 0, hasCarry: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Zinseszins mit jaehrlichem Beitrag; kein Zwischen-Runden mehr (1'000er-Konzept entfernt),
|
||||||
|
// nur das Endresultat wird auf ganze Franken gerundet.
|
||||||
function growAsset(startValue: number, expectedReturn: number, annual: number, years: number): number {
|
function growAsset(startValue: number, expectedReturn: number, annual: number, years: number): number {
|
||||||
let v = floorToThousand(startValue);
|
let v = startValue;
|
||||||
for (let y = 0; y < years; y++) {
|
for (let y = 0; y < years; y++) {
|
||||||
v = floorToThousand(v * (1 + expectedReturn / 100) + annual);
|
v = v * (1 + expectedReturn / 100) + annual;
|
||||||
}
|
}
|
||||||
return Math.max(0, v);
|
return Math.max(0, Math.round(v));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function computePlan(plan: PlanInput, household: HouseholdInput): PlanComputed {
|
export function computePlan(plan: PlanInput): PlanComputed {
|
||||||
const phases = [...plan.phases].sort((a, b) => a.sequenceNumber - b.sequenceNumber);
|
const phases = [...plan.phases].sort((a, b) => a.sequenceNumber - b.sequenceNumber);
|
||||||
const persons = household.persons;
|
const persons = plan.persons;
|
||||||
|
|
||||||
// Pensionsalter je Person (aufgeloest).
|
// Pensionsalter je Person (liegt direkt am plan-eigenen Personensatz).
|
||||||
const retirementAge = new Map<string, number>();
|
const retirementAge = new Map<string, number>();
|
||||||
for (const p of persons) {
|
for (const p of persons) retirementAge.set(p.id, p.retirementAge);
|
||||||
retirementAge.set(p.id, resolveRetirementAge(p.role, plan, p.retirementAge));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Kumulierte AHV-Ausfalljahre je Person (ueber die Erwerbsphasen aufsummiert).
|
// Kumulierte AHV-Ausfalljahre je Person (ueber die Erwerbsphasen aufsummiert).
|
||||||
const gapYearsByPerson = new Map<string, number>();
|
const gapYearsByPerson = new Map<string, number>();
|
||||||
@@ -185,14 +175,14 @@ export function computePlan(plan: PlanInput, household: HouseholdInput): PlanCom
|
|||||||
if (!owner || workingByPerson.get(owner.id)) continue; // nur pensionierte Personen
|
if (!owner || workingByPerson.get(owner.id)) continue; // nur pensionierte Personen
|
||||||
const gap = gapYearsByPerson.get(owner.id) ?? 0;
|
const gap = gapYearsByPerson.get(owner.id) ?? 0;
|
||||||
const factor = Math.max(0, (AHV_FULL_CONTRIBUTION_YEARS - gap) / AHV_FULL_CONTRIBUTION_YEARS);
|
const factor = Math.max(0, (AHV_FULL_CONTRIBUTION_YEARS - gap) / AHV_FULL_CONTRIBUTION_YEARS);
|
||||||
ahvUncapped.set(owner.id, floorToThousand(AHV_MAX_ANNUAL_SINGLE * factor));
|
ahvUncapped.set(owner.id, Math.round(AHV_MAX_ANNUAL_SINGLE * factor));
|
||||||
}
|
}
|
||||||
const ahvFinal = new Map(ahvUncapped);
|
const ahvFinal = new Map(ahvUncapped);
|
||||||
if (household.householdType === "COUPLE" && ahvUncapped.size === 2) {
|
if (plan.householdType === "COUPLE" && ahvUncapped.size === 2) {
|
||||||
const sum = [...ahvUncapped.values()].reduce((a, b) => a + b, 0);
|
const sum = [...ahvUncapped.values()].reduce((a, b) => a + b, 0);
|
||||||
const cap = AHV_MAX_ANNUAL_SINGLE * AHV_COUPLE_CAP_FACTOR;
|
const cap = AHV_MAX_ANNUAL_SINGLE * AHV_COUPLE_CAP_FACTOR;
|
||||||
if (sum > cap && sum > 0) {
|
if (sum > cap && sum > 0) {
|
||||||
for (const [pid, v] of ahvUncapped) ahvFinal.set(pid, floorToThousand(v * (cap / sum)));
|
for (const [pid, v] of ahvUncapped) ahvFinal.set(pid, Math.round(v * (cap / sum)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -217,6 +207,7 @@ export function computePlan(plan: PlanInput, household: HouseholdInput): PlanCom
|
|||||||
ownerRole: e.ownerRole,
|
ownerRole: e.ownerRole,
|
||||||
status: carry.status,
|
status: carry.status,
|
||||||
locked: carry.status !== "ACTIVE",
|
locked: carry.status !== "ACTIVE",
|
||||||
|
carried: carry.hasCarry,
|
||||||
startValue: 0,
|
startValue: 0,
|
||||||
endValue: 0,
|
endValue: 0,
|
||||||
incomeContribution: 0,
|
incomeContribution: 0,
|
||||||
@@ -242,14 +233,14 @@ export function computePlan(plan: PlanInput, household: HouseholdInput): PlanCom
|
|||||||
|
|
||||||
switch (e.category) {
|
switch (e.category) {
|
||||||
case "INCOME": {
|
case "INCOME": {
|
||||||
const amount = floorToThousand(num(pd.amount));
|
const amount = Math.round(num(pd.amount));
|
||||||
ec.incomeContribution = amount;
|
ec.incomeContribution = amount;
|
||||||
incomeTotal += amount;
|
incomeTotal += amount;
|
||||||
ec.summary = fmt(amount);
|
ec.summary = fmt(amount);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "EXPENSE": {
|
case "EXPENSE": {
|
||||||
const amount = floorToThousand(num(pd.amount));
|
const amount = Math.round(num(pd.amount));
|
||||||
ec.expenseContribution = amount;
|
ec.expenseContribution = amount;
|
||||||
expenseTotal += amount;
|
expenseTotal += amount;
|
||||||
ec.summary = fmt(amount);
|
ec.summary = fmt(amount);
|
||||||
@@ -278,13 +269,15 @@ export function computePlan(plan: PlanInput, household: HouseholdInput): PlanCom
|
|||||||
ec.note = "Vollstaendig bezogen";
|
ec.note = "Vollstaendig bezogen";
|
||||||
ec.summary = "Bezogen";
|
ec.summary = "Bezogen";
|
||||||
} else {
|
} else {
|
||||||
const start = floorToThousand(num(pd.currentValue));
|
const base = carry.hasCarry ? carry.value : Math.round(num(pd.currentValue));
|
||||||
const contribution = floorToThousand(num(pd.annualContribution));
|
const topUp = carry.hasCarry ? Math.round(num(pd.additionalInvestment)) : 0;
|
||||||
|
const start = base + topUp;
|
||||||
|
const contribution = Math.round(num(pd.annualContribution));
|
||||||
const r = num(pd.expectedReturn);
|
const r = num(pd.expectedReturn);
|
||||||
ec.startValue = start;
|
ec.startValue = start;
|
||||||
ec.endValue = growAsset(start, r, contribution, phase.durationYears);
|
ec.endValue = growAsset(start, r, contribution, phase.durationYears);
|
||||||
ec.capitalUse = Math.max(0, start - carry.value);
|
ec.capitalUse = topUp;
|
||||||
capitalUsed += ec.capitalUse;
|
capitalUsed += topUp;
|
||||||
// PK-Beitraege zaehlen NICHT gegen die Sparquote (in Ausgaben enthalten).
|
// PK-Beitraege zaehlen NICHT gegen die Sparquote (in Ausgaben enthalten).
|
||||||
ec.summary = fmt(ec.endValue);
|
ec.summary = fmt(ec.endValue);
|
||||||
}
|
}
|
||||||
@@ -295,13 +288,15 @@ export function computePlan(plan: PlanInput, household: HouseholdInput): PlanCom
|
|||||||
ec.note = "Vollstaendig bezogen";
|
ec.note = "Vollstaendig bezogen";
|
||||||
ec.summary = "Bezogen";
|
ec.summary = "Bezogen";
|
||||||
} else {
|
} else {
|
||||||
const start = floorToThousand(num(pd.currentValue));
|
const base = carry.hasCarry ? carry.value : Math.round(num(pd.currentValue));
|
||||||
const contribution = roundToHundred(num(pd.annualContribution));
|
const topUp = carry.hasCarry ? Math.round(num(pd.additionalInvestment)) : 0;
|
||||||
|
const start = base + topUp;
|
||||||
|
const contribution = Math.round(num(pd.annualContribution));
|
||||||
const r = num(pd.expectedReturn);
|
const r = num(pd.expectedReturn);
|
||||||
ec.startValue = start;
|
ec.startValue = start;
|
||||||
ec.endValue = growAsset(start, r, contribution, phase.durationYears);
|
ec.endValue = growAsset(start, r, contribution, phase.durationYears);
|
||||||
ec.capitalUse = Math.max(0, start - carry.value);
|
ec.capitalUse = topUp;
|
||||||
capitalUsed += ec.capitalUse;
|
capitalUsed += topUp;
|
||||||
ec.quotaUse = contribution; // zaehlt gegen die Sparquote
|
ec.quotaUse = contribution; // zaehlt gegen die Sparquote
|
||||||
quotaAllocated += contribution;
|
quotaAllocated += contribution;
|
||||||
ec.summary = fmt(ec.endValue);
|
ec.summary = fmt(ec.endValue);
|
||||||
@@ -309,15 +304,16 @@ export function computePlan(plan: PlanInput, household: HouseholdInput): PlanCom
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "OTHER_ASSET": {
|
case "OTHER_ASSET": {
|
||||||
const start = floorToThousand(num(pd.startValue));
|
const base = carry.hasCarry ? carry.value : Math.round(num(pd.startValue));
|
||||||
const contribution = floorToThousand(num(pd.annualContribution));
|
const topUp = carry.hasCarry ? Math.round(num(pd.additionalInvestment)) : 0;
|
||||||
|
const start = base + topUp;
|
||||||
|
const contribution = Math.round(num(pd.annualContribution));
|
||||||
const r = num(pd.expectedReturn);
|
const r = num(pd.expectedReturn);
|
||||||
ec.startValue = start;
|
ec.startValue = start;
|
||||||
ec.capitalUse = Math.max(0, start - carry.value);
|
ec.capitalUse = topUp;
|
||||||
capitalUsed += ec.capitalUse;
|
capitalUsed += topUp;
|
||||||
// In Erwerbsphasen (Sparen) wird eingezahlt, in Verzehrphasen bezogen -- das
|
// In Erwerbsphasen Sparbeitrag, in Verzehrphasen Bezugsrate -- beides zaehlt gegen
|
||||||
// Vorzeichen ergibt sich aus der Phasenquote (siehe unten). Hier immer als
|
// die Quote (Vorzeichen ergibt sich aus der Phasenquote).
|
||||||
// Beitrag verbucht; die Verzehr-Logik nutzt denselben Betrag als Bezug.
|
|
||||||
ec.quotaUse = contribution;
|
ec.quotaUse = contribution;
|
||||||
quotaAllocated += contribution;
|
quotaAllocated += contribution;
|
||||||
ec.endValue = growAsset(start, r, contribution, phase.durationYears);
|
ec.endValue = growAsset(start, r, contribution, phase.durationYears);
|
||||||
@@ -325,9 +321,9 @@ export function computePlan(plan: PlanInput, household: HouseholdInput): PlanCom
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "REAL_ESTATE": {
|
case "REAL_ESTATE": {
|
||||||
const purchase = floorToThousand(num(pd.purchasePrice));
|
const purchase = Math.round(num(pd.purchasePrice));
|
||||||
const mortgageStart = carry.hasCarry ? carry.mortgage : floorToThousand(num(pd.mortgage));
|
const mortgageStart = carry.hasCarry ? carry.mortgage : Math.round(num(pd.mortgage));
|
||||||
const amort = floorToThousand(num(pd.amortization));
|
const amort = Math.round(num(pd.amortization));
|
||||||
const mortgageEnd = Math.max(0, mortgageStart - amort * phase.durationYears);
|
const mortgageEnd = Math.max(0, mortgageStart - amort * phase.durationYears);
|
||||||
ec.startValue = purchase - mortgageStart;
|
ec.startValue = purchase - mortgageStart;
|
||||||
ec.endValue = purchase - mortgageEnd;
|
ec.endValue = purchase - mortgageEnd;
|
||||||
@@ -335,17 +331,23 @@ export function computePlan(plan: PlanInput, household: HouseholdInput): PlanCom
|
|||||||
ec.capitalUse = Math.max(0, purchase - mortgageStart); // Eigenkapital bei Neukauf
|
ec.capitalUse = Math.max(0, purchase - mortgageStart); // Eigenkapital bei Neukauf
|
||||||
capitalUsed += ec.capitalUse;
|
capitalUsed += ec.capitalUse;
|
||||||
}
|
}
|
||||||
|
// Amortisation ist quotenwirksam (jaehrlicher Budgetbetrag).
|
||||||
|
ec.quotaUse = amort;
|
||||||
|
quotaAllocated += amort;
|
||||||
carry.mortgage = mortgageEnd; // fuer Uebergang
|
carry.mortgage = mortgageEnd; // fuer Uebergang
|
||||||
ec.summary = fmt(ec.endValue);
|
ec.summary = fmt(ec.endValue);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "OTHER_DEBT": {
|
case "OTHER_DEBT": {
|
||||||
const owedStart = carry.hasCarry ? carry.owed : floorToThousand(num(pd.startValue));
|
const owedStart = carry.hasCarry ? carry.owed : Math.round(num(pd.startValue));
|
||||||
const repay = floorToThousand(num(pd.annualRepayment));
|
const repay = Math.round(num(pd.annualRepayment));
|
||||||
const owedEnd = Math.max(0, owedStart - repay * phase.durationYears);
|
const owedEnd = Math.max(0, owedStart - repay * phase.durationYears);
|
||||||
ec.startValue = -owedStart;
|
ec.startValue = -owedStart;
|
||||||
ec.endValue = -owedEnd;
|
ec.endValue = -owedEnd;
|
||||||
carry.owed = owedEnd;
|
carry.owed = owedEnd;
|
||||||
|
// Tilgung ist quotenwirksam (jaehrlicher Budgetbetrag).
|
||||||
|
ec.quotaUse = repay;
|
||||||
|
quotaAllocated += repay;
|
||||||
ec.summary = fmt(ec.endValue);
|
ec.summary = fmt(ec.endValue);
|
||||||
if (owedEnd === 0) ec.note = "Wird getilgt";
|
if (owedEnd === 0) ec.note = "Wird getilgt";
|
||||||
break;
|
break;
|
||||||
@@ -361,16 +363,18 @@ export function computePlan(plan: PlanInput, household: HouseholdInput): PlanCom
|
|||||||
// Sparphase: alles verteilt, wenn quotaAllocated == quota. Verzehrphase: gedeckt,
|
// Sparphase: alles verteilt, wenn quotaAllocated == quota. Verzehrphase: gedeckt,
|
||||||
// wenn Bezuege (quotaAllocated) den Fehlbetrag decken.
|
// wenn Bezuege (quotaAllocated) den Fehlbetrag decken.
|
||||||
const quotaTarget = Math.abs(quota);
|
const quotaTarget = Math.abs(quota);
|
||||||
const quotaComplete = Math.abs(quotaTarget - quotaAllocated) < 1;
|
const quotaRemaining = quotaTarget - quotaAllocated;
|
||||||
|
const quotaComplete = Math.abs(quotaRemaining) < 1;
|
||||||
|
|
||||||
const availableCapital = incomingCapital;
|
const availableCapital = incomingCapital;
|
||||||
const availableCapitalUsed = capitalUsed;
|
const availableCapitalUsed = capitalUsed;
|
||||||
|
const availableCapitalRemaining = availableCapital === null ? 0 : availableCapital - availableCapitalUsed;
|
||||||
const availableCapitalComplete =
|
const availableCapitalComplete =
|
||||||
availableCapital === null || Math.abs(availableCapital - availableCapitalUsed) < 1;
|
availableCapital === null || Math.abs(availableCapitalRemaining) < 1;
|
||||||
|
|
||||||
const incomplete = !quotaComplete || !availableCapitalComplete;
|
const incomplete = !quotaComplete || !availableCapitalComplete;
|
||||||
|
|
||||||
const inflationRate = phase.inflationRate ?? household.inflationRateDefault;
|
const inflationRate = phase.inflationRate ?? plan.inflationRateDefault;
|
||||||
cumulativeInflation = cumulativeInflation * (1 + inflationRate / 100);
|
cumulativeInflation = cumulativeInflation * (1 + inflationRate / 100);
|
||||||
|
|
||||||
const startWealthNominal = elementsComputed.reduce((s, ec) => s + ec.startValue, 0);
|
const startWealthNominal = elementsComputed.reduce((s, ec) => s + ec.startValue, 0);
|
||||||
@@ -389,9 +393,11 @@ export function computePlan(plan: PlanInput, household: HouseholdInput): PlanCom
|
|||||||
quota,
|
quota,
|
||||||
isConsumption,
|
isConsumption,
|
||||||
quotaAllocated,
|
quotaAllocated,
|
||||||
|
quotaRemaining,
|
||||||
quotaComplete,
|
quotaComplete,
|
||||||
availableCapital,
|
availableCapital,
|
||||||
availableCapitalUsed,
|
availableCapitalUsed,
|
||||||
|
availableCapitalRemaining,
|
||||||
availableCapitalComplete,
|
availableCapitalComplete,
|
||||||
incomplete,
|
incomplete,
|
||||||
elements: elementsComputed,
|
elements: elementsComputed,
|
||||||
@@ -409,7 +415,7 @@ export function computePlan(plan: PlanInput, household: HouseholdInput): PlanCom
|
|||||||
const td = e.transitionValues[phase.id] ?? {};
|
const td = e.transitionValues[phase.id] ?? {};
|
||||||
const owner = e.ownerRole && e.ownerRole !== "HOUSEHOLD" ? personByRole(persons, e.ownerRole) : null;
|
const owner = e.ownerRole && e.ownerRole !== "HOUSEHOLD" ? personByRole(persons, e.ownerRole) : null;
|
||||||
const ownerRetiresNext =
|
const ownerRetiresNext =
|
||||||
!!owner && !!nextPhase && workingByPerson.get(owner.id) === true && retiresInPhase(owner.id, nextPhase, persons, retirementAge, yearsBefore + phase.durationYears);
|
!!owner && !!nextPhase && workingByPerson.get(owner.id) === true && retiresInPhase(owner.id, persons, retirementAge, yearsBefore + phase.durationYears);
|
||||||
|
|
||||||
if (carry.status !== "ACTIVE") {
|
if (carry.status !== "ACTIVE") {
|
||||||
carry.hasCarry = true;
|
carry.hasCarry = true;
|
||||||
@@ -422,22 +428,22 @@ export function computePlan(plan: PlanInput, household: HouseholdInput): PlanCom
|
|||||||
const value = ec.endValue;
|
const value = ec.endValue;
|
||||||
const mode = td.payoutMode ?? "PENSION";
|
const mode = td.payoutMode ?? "PENSION";
|
||||||
if (mode === "CAPITAL") {
|
if (mode === "CAPITAL") {
|
||||||
const net = floorToThousand(value * (1 - num(td.capitalTaxRate) / 100));
|
const net = Math.round(value * (1 - num(td.capitalTaxRate) / 100));
|
||||||
outgoing += net;
|
outgoing += net;
|
||||||
carry.value = 0;
|
carry.value = 0;
|
||||||
carry.pkPensionAnnual = 0;
|
carry.pkPensionAnnual = 0;
|
||||||
} else if (mode === "PENSION") {
|
} else if (mode === "PENSION") {
|
||||||
carry.pkPensionAnnual = floorToThousand((value * num(td.conversionRate)) / 100);
|
carry.pkPensionAnnual = Math.round((value * num(td.conversionRate)) / 100);
|
||||||
carry.value = 0;
|
carry.value = 0;
|
||||||
} else {
|
} else {
|
||||||
const capital = Math.min(value, floorToThousand(num(td.capitalAmount)));
|
const capital = Math.min(value, Math.round(num(td.capitalAmount)));
|
||||||
const net = floorToThousand(capital * (1 - num(td.capitalTaxRate) / 100));
|
const net = Math.round(capital * (1 - num(td.capitalTaxRate) / 100));
|
||||||
outgoing += net;
|
outgoing += net;
|
||||||
carry.pkPensionAnnual = floorToThousand(((value - capital) * num(td.conversionRate)) / 100);
|
carry.pkPensionAnnual = Math.round(((value - capital) * num(td.conversionRate)) / 100);
|
||||||
carry.value = 0;
|
carry.value = 0;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const withdrawal = Math.min(ec.endValue, floorToThousand(num(td.withdrawal)));
|
const withdrawal = Math.min(ec.endValue, Math.round(num(td.withdrawal)));
|
||||||
carry.value = ec.endValue - withdrawal;
|
carry.value = ec.endValue - withdrawal;
|
||||||
outgoing += withdrawal;
|
outgoing += withdrawal;
|
||||||
}
|
}
|
||||||
@@ -445,11 +451,11 @@ export function computePlan(plan: PlanInput, household: HouseholdInput): PlanCom
|
|||||||
}
|
}
|
||||||
case "PILLAR_3A": {
|
case "PILLAR_3A": {
|
||||||
if (ownerRetiresNext) {
|
if (ownerRetiresNext) {
|
||||||
const net = floorToThousand(ec.endValue * (1 - num(td.capitalTaxRate) / 100));
|
const net = Math.round(ec.endValue * (1 - num(td.capitalTaxRate) / 100));
|
||||||
outgoing += net;
|
outgoing += net;
|
||||||
carry.value = 0;
|
carry.value = 0;
|
||||||
} else {
|
} else {
|
||||||
const withdrawal = Math.min(ec.endValue, floorToThousand(num(td.withdrawal)));
|
const withdrawal = Math.min(ec.endValue, Math.round(num(td.withdrawal)));
|
||||||
carry.value = ec.endValue - withdrawal;
|
carry.value = ec.endValue - withdrawal;
|
||||||
outgoing += withdrawal;
|
outgoing += withdrawal;
|
||||||
}
|
}
|
||||||
@@ -466,18 +472,18 @@ export function computePlan(plan: PlanInput, household: HouseholdInput): PlanCom
|
|||||||
}
|
}
|
||||||
case "REAL_ESTATE": {
|
case "REAL_ESTATE": {
|
||||||
if (td.decision === "SELL") {
|
if (td.decision === "SELL") {
|
||||||
const purchase = floorToThousand(num(e.phaseValues[phase.id]?.purchasePrice));
|
const purchase = Math.round(num(e.phaseValues[phase.id]?.purchasePrice));
|
||||||
const salePrice = floorToThousand(num(td.salePrice));
|
const salePrice = Math.round(num(td.salePrice));
|
||||||
const gain = Math.max(0, salePrice - purchase);
|
const gain = Math.max(0, salePrice - purchase);
|
||||||
const tax = gain * (num(td.saleTaxRate) / 100);
|
const tax = gain * (num(td.saleTaxRate) / 100);
|
||||||
outgoing += floorToThousand(salePrice - carry.mortgage - tax);
|
outgoing += Math.round(salePrice - carry.mortgage - tax);
|
||||||
carry.status = "SOLD";
|
carry.status = "SOLD";
|
||||||
}
|
}
|
||||||
// HOLD: carry.mortgage bereits gesetzt.
|
// HOLD: carry.mortgage bereits gesetzt.
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "OTHER_DEBT": {
|
case "OTHER_DEBT": {
|
||||||
const immediate = Math.min(carry.owed, floorToThousand(num(td.immediateRepayment)));
|
const immediate = Math.min(carry.owed, Math.round(num(td.immediateRepayment)));
|
||||||
if (immediate > 0) {
|
if (immediate > 0) {
|
||||||
carry.owed = Math.max(0, carry.owed - immediate);
|
carry.owed = Math.max(0, carry.owed - immediate);
|
||||||
outgoing -= immediate; // sofortige Tilgung mindert das verfuegbare Kapital
|
outgoing -= immediate; // sofortige Tilgung mindert das verfuegbare Kapital
|
||||||
@@ -491,7 +497,7 @@ export function computePlan(plan: PlanInput, household: HouseholdInput): PlanCom
|
|||||||
carry.hasCarry = true;
|
carry.hasCarry = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
incomingCapital = nextPhase ? floorToThousand(outgoing) : null;
|
incomingCapital = nextPhase ? Math.round(outgoing) : null;
|
||||||
yearsBefore += phase.durationYears;
|
yearsBefore += phase.durationYears;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -503,11 +509,10 @@ function personByRole(persons: { id: string; role: PersonRole }[], role: string)
|
|||||||
return persons.find((p) => p.role === role) ?? null;
|
return persons.find((p) => p.role === role) ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prueft, ob eine Person in der gegebenen Phase (mit gegebenem Jahres-Offset) pensioniert ist,
|
// Prueft, ob eine Person mit dem gegebenen Jahres-Offset (zu Beginn der Folgephase) pensioniert
|
||||||
// obwohl sie in der Vorphase noch erwerbend war.
|
// ist, obwohl sie in der Vorphase noch erwerbend war.
|
||||||
function retiresInPhase(
|
function retiresInPhase(
|
||||||
personId: string,
|
personId: string,
|
||||||
phase: { durationYears: number },
|
|
||||||
persons: { id: string; role: PersonRole; age: number }[],
|
persons: { id: string; role: PersonRole; age: number }[],
|
||||||
retirementAge: Map<string, number>,
|
retirementAge: Map<string, number>,
|
||||||
yearsBeforeNext: number
|
yearsBeforeNext: number
|
||||||
@@ -519,10 +524,6 @@ function retiresInPhase(
|
|||||||
return startAgeNext >= ra;
|
return startAgeNext >= ra;
|
||||||
}
|
}
|
||||||
|
|
||||||
function roundToHundred(v: number): number {
|
|
||||||
return Math.round((v || 0) / 100) * 100;
|
|
||||||
}
|
|
||||||
|
|
||||||
function fmt(v: number): string {
|
function fmt(v: number): string {
|
||||||
const rounded = Math.round(v || 0);
|
const rounded = Math.round(v || 0);
|
||||||
const sign = rounded < 0 ? "-" : "";
|
const sign = rounded < 0 ? "-" : "";
|
||||||
|
|||||||
@@ -59,6 +59,9 @@ export interface PhaseData {
|
|||||||
startValue?: number;
|
startValue?: number;
|
||||||
expectedReturn?: number;
|
expectedReturn?: number;
|
||||||
annualContribution?: number;
|
annualContribution?: number;
|
||||||
|
// PENSION_FUND / PILLAR_3A / OTHER_ASSET (ab Phase 2): zusaetzliche Einlage aus dem
|
||||||
|
// verfuegbaren Kapital der Phase. Der Basis-Startwert wird aus der Vorphase fortgeschrieben.
|
||||||
|
additionalInvestment?: number;
|
||||||
// REAL_ESTATE
|
// REAL_ESTATE
|
||||||
purchasePrice?: number;
|
purchasePrice?: number;
|
||||||
mortgage?: number;
|
mortgage?: number;
|
||||||
@@ -99,6 +102,7 @@ export const phaseDataSchema = z
|
|||||||
startValue: nonNeg.optional(),
|
startValue: nonNeg.optional(),
|
||||||
expectedReturn: z.number().min(-50).max(100).optional(),
|
expectedReturn: z.number().min(-50).max(100).optional(),
|
||||||
annualContribution: nonNeg.optional(),
|
annualContribution: nonNeg.optional(),
|
||||||
|
additionalInvestment: nonNeg.optional(),
|
||||||
purchasePrice: nonNeg.optional(),
|
purchasePrice: nonNeg.optional(),
|
||||||
mortgage: nonNeg.optional(),
|
mortgage: nonNeg.optional(),
|
||||||
amortization: nonNeg.optional(),
|
amortization: nonNeg.optional(),
|
||||||
|
|||||||
@@ -10,14 +10,6 @@ export function formatChf(value: number): string {
|
|||||||
return sign + withSeparators;
|
return sign + withSeparators;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rundet ABwaerts auf ein Vielfaches von 1'000. Bewusst floor statt round: Betraege wie
|
|
||||||
// z. B. eine verfuegbare Sparquote von 1'450 CHF liessen sich sonst nicht vollstaendig
|
|
||||||
// auf Wertschriften verteilen (nur 1'000er-Schritte moeglich) -- durch Abrunden bleibt
|
|
||||||
// der angezeigte/verplanbare Betrag immer tatsaechlich erreichbar.
|
|
||||||
export function floorToThousand(value: number): number {
|
|
||||||
return Math.floor((value || 0) / 1000) * 1000;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseChfInput(text: string): number {
|
export function parseChfInput(text: string): number {
|
||||||
const cleaned = text.replace(/[^0-9-]/g, "");
|
const cleaned = text.replace(/[^0-9-]/g, "");
|
||||||
const parsed = parseInt(cleaned, 10);
|
const parsed = parseInt(cleaned, 10);
|
||||||
|
|||||||
+14
-27
@@ -2,9 +2,10 @@ import { Prisma } from "@/generated/prisma/client";
|
|||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { phaseDataSchema, transitionDataSchema } from "@/lib/elements";
|
import { phaseDataSchema, transitionDataSchema } from "@/lib/elements";
|
||||||
import type { PhaseData, TransitionData } from "@/lib/elements";
|
import type { PhaseData, TransitionData } from "@/lib/elements";
|
||||||
import type { HouseholdInput, PlanInput } from "@/lib/types";
|
import type { PlanInput } from "@/lib/types";
|
||||||
|
|
||||||
export const planInclude = {
|
export const planInclude = {
|
||||||
|
persons: { orderBy: { role: "asc" } },
|
||||||
phases: { orderBy: { sequenceNumber: "asc" } },
|
phases: { orderBy: { sequenceNumber: "asc" } },
|
||||||
elements: {
|
elements: {
|
||||||
orderBy: { orderIndex: "asc" },
|
orderBy: { orderIndex: "asc" },
|
||||||
@@ -13,21 +14,6 @@ export const planInclude = {
|
|||||||
} satisfies Prisma.PlanInclude;
|
} satisfies Prisma.PlanInclude;
|
||||||
|
|
||||||
export type PlanWithRelations = Prisma.PlanGetPayload<{ include: typeof planInclude }>;
|
export type PlanWithRelations = Prisma.PlanGetPayload<{ include: typeof planInclude }>;
|
||||||
export type HouseholdWithPersons = Prisma.HouseholdGetPayload<{ include: { persons: true } }>;
|
|
||||||
|
|
||||||
export function toHouseholdInput(household: HouseholdWithPersons): HouseholdInput {
|
|
||||||
return {
|
|
||||||
id: household.id,
|
|
||||||
householdType: household.householdType,
|
|
||||||
inflationRateDefault: household.inflationRateDefault,
|
|
||||||
persons: household.persons.map((p) => ({
|
|
||||||
id: p.id,
|
|
||||||
role: p.role,
|
|
||||||
age: p.age,
|
|
||||||
retirementAge: p.retirementAge,
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function parsePhaseData(raw: unknown): PhaseData {
|
function parsePhaseData(raw: unknown): PhaseData {
|
||||||
const parsed = phaseDataSchema.safeParse(raw);
|
const parsed = phaseDataSchema.safeParse(raw);
|
||||||
@@ -43,8 +29,14 @@ export function toPlanInput(plan: PlanWithRelations): PlanInput {
|
|||||||
return {
|
return {
|
||||||
id: plan.id,
|
id: plan.id,
|
||||||
name: plan.name,
|
name: plan.name,
|
||||||
retirementAgeA: plan.retirementAgeA,
|
householdType: plan.householdType,
|
||||||
retirementAgeB: plan.retirementAgeB,
|
inflationRateDefault: plan.inflationRateDefault,
|
||||||
|
persons: plan.persons.map((p) => ({
|
||||||
|
id: p.id,
|
||||||
|
role: p.role,
|
||||||
|
age: p.age,
|
||||||
|
retirementAge: p.retirementAge,
|
||||||
|
})),
|
||||||
phases: plan.phases.map((phase) => ({
|
phases: plan.phases.map((phase) => ({
|
||||||
id: phase.id,
|
id: phase.id,
|
||||||
sequenceNumber: phase.sequenceNumber,
|
sequenceNumber: phase.sequenceNumber,
|
||||||
@@ -70,15 +62,10 @@ export function toPlanInput(plan: PlanWithRelations): PlanInput {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Liefert den Haushalt des eingeloggten Benutzers (pro Konto genau einer).
|
// Laedt einen Plan inkl. Profil + Phasen + Elemente, aber nur wenn er dem Benutzer gehoert.
|
||||||
export async function getHouseholdOrNull(userId: string): Promise<HouseholdWithPersons | null> {
|
|
||||||
return prisma.household.findFirst({ where: { userId }, include: { persons: true } });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Laedt einen Plan inkl. Phasen + Elemente, aber nur wenn er dem Benutzer gehoert.
|
|
||||||
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, userId },
|
||||||
include: planInclude,
|
include: planInclude,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -86,13 +73,13 @@ export async function getOwnedPlan(planId: string, userId: string) {
|
|||||||
// Laedt eine Phase (Basisdaten), aber nur wenn sie dem Benutzer gehoert.
|
// Laedt eine Phase (Basisdaten), aber nur wenn sie dem Benutzer gehoert.
|
||||||
export async function getOwnedPhase(phaseId: string, userId: string) {
|
export async function getOwnedPhase(phaseId: string, userId: string) {
|
||||||
return prisma.phase.findFirst({
|
return prisma.phase.findFirst({
|
||||||
where: { id: phaseId, plan: { household: { userId } } },
|
where: { id: phaseId, plan: { userId } },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Laedt ein Element (Basisdaten), aber nur wenn es dem Benutzer gehoert.
|
// Laedt ein Element (Basisdaten), aber nur wenn es dem Benutzer gehoert.
|
||||||
export async function getOwnedElement(elementId: string, userId: string) {
|
export async function getOwnedElement(elementId: string, userId: string) {
|
||||||
return prisma.financialElement.findFirst({
|
return prisma.financialElement.findFirst({
|
||||||
where: { id: elementId, plan: { household: { userId } } },
|
where: { id: elementId, plan: { userId } },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
// Theme-Verwaltung: drei waehlbare Schemata, persistiert in localStorage und als
|
||||||
|
// data-theme am <html> gesetzt. Ohne gespeicherte Wahl folgt die Oberflaeche der
|
||||||
|
// OS-Einstellung (siehe globals.css, prefers-color-scheme).
|
||||||
|
|
||||||
|
export type Theme = "light" | "dark" | "warm";
|
||||||
|
|
||||||
|
export const THEMES: { value: Theme; label: string }[] = [
|
||||||
|
{ value: "light", label: "Hell" },
|
||||||
|
{ value: "dark", label: "Dunkel" },
|
||||||
|
{ value: "warm", label: "Warm" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const STORAGE_KEY = "fpt-theme";
|
||||||
|
|
||||||
|
export function getStoredTheme(): Theme | null {
|
||||||
|
if (typeof window === "undefined") return null;
|
||||||
|
const v = window.localStorage.getItem(STORAGE_KEY);
|
||||||
|
return v === "light" || v === "dark" || v === "warm" ? v : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Das effektiv aktive Theme (gespeicherte Wahl oder OS-Ableitung).
|
||||||
|
export function getEffectiveTheme(): Theme {
|
||||||
|
const stored = getStoredTheme();
|
||||||
|
if (stored) return stored;
|
||||||
|
if (typeof window !== "undefined" && window.matchMedia("(prefers-color-scheme: dark)").matches) {
|
||||||
|
return "dark";
|
||||||
|
}
|
||||||
|
return "light";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setTheme(theme: Theme): void {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
window.localStorage.setItem(STORAGE_KEY, theme);
|
||||||
|
document.documentElement.setAttribute("data-theme", theme);
|
||||||
|
}
|
||||||
+7
-10
@@ -1,5 +1,7 @@
|
|||||||
// Domain-Typen fuer Berechnungslogik und API-Payloads. Entkoppelt von den generierten
|
// Domain-Typen fuer Berechnungslogik und API-Payloads. Entkoppelt von den generierten
|
||||||
// Prisma-Typen, damit die Berechnung unabhaengig testbar bleibt.
|
// Prisma-Typen, damit die Berechnung unabhaengig testbar bleibt.
|
||||||
|
//
|
||||||
|
// V3-Rework: Das Grundprofil (Haushaltsform, Personen, Inflation) liegt neu direkt am Plan.
|
||||||
|
|
||||||
import type { ElementCategory, OwnerRole, PhaseData, TransitionData } from "@/lib/elements";
|
import type { ElementCategory, OwnerRole, PhaseData, TransitionData } from "@/lib/elements";
|
||||||
|
|
||||||
@@ -10,17 +12,9 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HouseholdInput {
|
|
||||||
id: string;
|
|
||||||
householdType: HouseholdType;
|
|
||||||
inflationRateDefault: number;
|
|
||||||
persons: PersonInput[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PhaseInput {
|
export interface PhaseInput {
|
||||||
id: string;
|
id: string;
|
||||||
sequenceNumber: number;
|
sequenceNumber: number;
|
||||||
@@ -40,11 +34,14 @@ export interface ElementInput {
|
|||||||
transitionValues: Record<string, TransitionData>;
|
transitionValues: Record<string, TransitionData>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ein Plan ist selbsttragend: er traegt sein eigenes Grundprofil (Haushaltsform, Personen,
|
||||||
|
// Inflationsannahme) plus die Phasenkette und die finanziellen Elemente.
|
||||||
export interface PlanInput {
|
export interface PlanInput {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
retirementAgeA: number | null;
|
householdType: HouseholdType;
|
||||||
retirementAgeB: number | null;
|
inflationRateDefault: number;
|
||||||
|
persons: PersonInput[];
|
||||||
phases: PhaseInput[];
|
phases: PhaseInput[];
|
||||||
elements: ElementInput[];
|
elements: ElementInput[];
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user