Major rework: multi-user accounts (register/login, per-user data isolation), new layout with sidebar/dashboard/profile menu, matrix phase view with collapsible category columns, life timeline with ages per phase, live budget capping, collapsible transitions, mobile support
Deploy App / deploy (push) Successful in 1m47s
Deploy App / deploy (push) Successful in 1m47s
This commit is contained in:
@@ -0,0 +1,25 @@
|
|||||||
|
-- Multi-User-Umbau mit Fresh-Start: bestehende Testdaten werden verworfen
|
||||||
|
-- (abgestimmt am 11.07.2026). Household haengt neu an einem User-Konto.
|
||||||
|
|
||||||
|
-- Bestehende Daten loeschen (Cascade raeumt Persons, Plans, Phases, ... mit ab)
|
||||||
|
DELETE FROM "Household";
|
||||||
|
DROP TABLE "AppCredential";
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "User" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"username" TEXT NOT NULL,
|
||||||
|
"passwordHash" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "User_username_key" ON "User"("username");
|
||||||
|
|
||||||
|
-- AlterTable (Tabelle ist leer, daher NOT NULL ohne Default moeglich)
|
||||||
|
ALTER TABLE "Household" ADD COLUMN "userId" TEXT NOT NULL;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Household" ADD CONSTRAINT "Household_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
+10
-4
@@ -10,12 +10,16 @@ datasource db {
|
|||||||
provider = "postgresql"
|
provider = "postgresql"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Zugriffsschutz der Webapplikation: kein vorkonfiguriertes Passwort -- der
|
// Benutzerkonto: offene Registrierung mit Benutzername + Passwort (bcrypt-Hash).
|
||||||
// Benutzer legt es beim allerersten Login selbst fest (genau eine Zeile).
|
// Jeder Benutzer hat seinen eigenen Haushalt samt Plaenen -- Daten sind strikt
|
||||||
model AppCredential {
|
// pro Konto isoliert.
|
||||||
|
model User {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
|
username String @unique
|
||||||
passwordHash String
|
passwordHash String
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
households Household[]
|
||||||
}
|
}
|
||||||
|
|
||||||
enum HouseholdType {
|
enum HouseholdType {
|
||||||
@@ -54,9 +58,11 @@ enum PositionType {
|
|||||||
REAL_ESTATE
|
REAL_ESTATE
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ein Haushalt (1 oder 2 Personen) - Wurzel-Objekt
|
// Ein Haushalt (1 oder 2 Personen) - Wurzel-Objekt, gehoert genau einem Benutzer
|
||||||
model Household {
|
model Household {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
|
userId String
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
householdType HouseholdType
|
householdType HouseholdType
|
||||||
inflationRateDefault Float
|
inflationRateDefault Float
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { getCurrentUserId } from "@/lib/session";
|
||||||
|
import { changeUserPassword } from "@/lib/users";
|
||||||
|
|
||||||
|
const changePasswordSchema = z.object({
|
||||||
|
currentPassword: z.string().min(1),
|
||||||
|
newPassword: z.string().min(6, "Das neue Passwort muss mindestens 6 Zeichen lang sein."),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const userId = await getCurrentUserId();
|
||||||
|
if (!userId) {
|
||||||
|
return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const parsed = changePasswordSchema.safeParse(body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
const message = parsed.error.issues[0]?.message ?? "Ungueltige Eingabe.";
|
||||||
|
return NextResponse.json({ error: message }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await changeUserPassword(userId, parsed.data.currentPassword, parsed.data.newPassword);
|
||||||
|
} catch (e) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: e instanceof Error ? e.message : "Passwort aendern fehlgeschlagen." },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
@@ -1,24 +1,27 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
import { createSessionToken, SESSION_COOKIE_NAME } from "@/lib/auth";
|
import { createSessionToken, SESSION_COOKIE_NAME } from "@/lib/auth";
|
||||||
import { getAppCredential, verifyAppPassword } from "@/lib/credentials";
|
import { verifyUserCredentials } from "@/lib/users";
|
||||||
|
|
||||||
|
const loginSchema = z.object({
|
||||||
|
username: z.string().min(1),
|
||||||
|
password: z.string().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
const { password } = await request.json();
|
const body = await request.json();
|
||||||
|
const parsed = loginSchema.safeParse(body);
|
||||||
const credential = await getAppCredential();
|
if (!parsed.success) {
|
||||||
if (!credential) {
|
return NextResponse.json({ error: "Bitte Benutzername und Passwort angeben." }, { status: 400 });
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Es ist noch kein Passwort gesetzt. Bitte zuerst ein Passwort festlegen." },
|
|
||||||
{ status: 409 }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof password !== "string" || password.length === 0 || !(await verifyAppPassword(password))) {
|
const user = await verifyUserCredentials(parsed.data.username, parsed.data.password);
|
||||||
return NextResponse.json({ error: "Falsches Passwort." }, { status: 401 });
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: "Benutzername oder Passwort falsch." }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const token = await createSessionToken();
|
const token = await createSessionToken(user.id);
|
||||||
const response = NextResponse.json({ ok: true });
|
const response = NextResponse.json({ ok: true, username: user.username });
|
||||||
response.cookies.set(SESSION_COOKIE_NAME, token, {
|
response.cookies.set(SESSION_COOKIE_NAME, token, {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
secure: process.env.NODE_ENV === "production",
|
secure: process.env.NODE_ENV === "production",
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { getCurrentUserId } from "@/lib/session";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const userId = await getCurrentUserId();
|
||||||
|
if (!userId) {
|
||||||
|
return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
|
||||||
|
}
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: userId },
|
||||||
|
select: { id: true, username: true, createdAt: true },
|
||||||
|
});
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: "Benutzer nicht gefunden." }, { status: 404 });
|
||||||
|
}
|
||||||
|
return NextResponse.json({ user });
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { createSessionToken, SESSION_COOKIE_NAME } from "@/lib/auth";
|
||||||
|
import { registerUser, validateUsername } from "@/lib/users";
|
||||||
|
|
||||||
|
const registerSchema = z.object({
|
||||||
|
username: z.string().min(1),
|
||||||
|
password: z.string().min(6, "Das Passwort muss mindestens 6 Zeichen lang sein."),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const body = await request.json();
|
||||||
|
const parsed = registerSchema.safeParse(body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
const message = parsed.error.issues[0]?.message ?? "Ungueltige Eingabe.";
|
||||||
|
return NextResponse.json({ error: message }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const usernameError = validateUsername(parsed.data.username);
|
||||||
|
if (usernameError) {
|
||||||
|
return NextResponse.json({ error: usernameError }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let user;
|
||||||
|
try {
|
||||||
|
user = await registerUser(parsed.data.username, parsed.data.password);
|
||||||
|
} catch (e) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: e instanceof Error ? e.message : "Registrierung fehlgeschlagen." },
|
||||||
|
{ status: 409 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = await createSessionToken(user.id);
|
||||||
|
const response = NextResponse.json({ ok: true, username: user.username }, { status: 201 });
|
||||||
|
response.cookies.set(SESSION_COOKIE_NAME, token, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === "production",
|
||||||
|
sameSite: "lax",
|
||||||
|
path: "/",
|
||||||
|
maxAge: 60 * 60 * 24 * 30,
|
||||||
|
});
|
||||||
|
return response;
|
||||||
|
}
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { createSessionToken, SESSION_COOKIE_NAME } from "@/lib/auth";
|
|
||||||
import { getAppCredential, setAppPassword } from "@/lib/credentials";
|
|
||||||
|
|
||||||
const setupSchema = z.object({
|
|
||||||
password: z.string().min(4, "Das Passwort muss mindestens 4 Zeichen lang sein."),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Legt das Login-Passwort einmalig fest. Nur solange noch keine AppCredential-Zeile
|
|
||||||
// existiert (d. h. beim allerersten Login) erreichbar -- danach ausschliesslich
|
|
||||||
// ueber /api/auth/login.
|
|
||||||
export async function POST(request: NextRequest) {
|
|
||||||
const existing = await getAppCredential();
|
|
||||||
if (existing) {
|
|
||||||
return NextResponse.json({ error: "Es ist bereits ein Passwort gesetzt." }, { status: 409 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = await request.json();
|
|
||||||
const parsed = setupSchema.safeParse(body);
|
|
||||||
if (!parsed.success) {
|
|
||||||
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
await setAppPassword(parsed.data.password);
|
|
||||||
|
|
||||||
const token = await createSessionToken();
|
|
||||||
const response = NextResponse.json({ ok: true });
|
|
||||||
response.cookies.set(SESSION_COOKIE_NAME, token, {
|
|
||||||
httpOnly: true,
|
|
||||||
secure: process.env.NODE_ENV === "production",
|
|
||||||
sameSite: "lax",
|
|
||||||
path: "/",
|
|
||||||
maxAge: 60 * 60 * 24 * 30,
|
|
||||||
});
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
import { NextResponse } from "next/server";
|
|
||||||
import { getAppCredential } from "@/lib/credentials";
|
|
||||||
|
|
||||||
export async function GET() {
|
|
||||||
const credential = await getAppCredential();
|
|
||||||
return NextResponse.json({ passwordSet: credential != null });
|
|
||||||
}
|
|
||||||
@@ -2,6 +2,7 @@ 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, toHouseholdInput } from "@/lib/queries";
|
import { getHouseholdOrNull, toHouseholdInput } from "@/lib/queries";
|
||||||
|
import { getCurrentUserId } from "@/lib/session";
|
||||||
|
|
||||||
const personSchema = z.object({
|
const personSchema = z.object({
|
||||||
role: z.enum(["PERSON_A", "PERSON_B"]),
|
role: z.enum(["PERSON_A", "PERSON_B"]),
|
||||||
@@ -26,12 +27,21 @@ function validatePersonsForType(data: z.infer<typeof householdSchema>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
const household = await getHouseholdOrNull();
|
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 });
|
return NextResponse.json({ household: household ? toHouseholdInput(household) : null });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
const existing = await getHouseholdOrNull();
|
const userId = await getCurrentUserId();
|
||||||
|
if (!userId) {
|
||||||
|
return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await getHouseholdOrNull(userId);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Es existiert bereits ein Haushalt. Bitte PATCH verwenden, um ihn zu bearbeiten." },
|
{ error: "Es existiert bereits ein Haushalt. Bitte PATCH verwenden, um ihn zu bearbeiten." },
|
||||||
@@ -51,6 +61,7 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
const household = await prisma.household.create({
|
const household = await prisma.household.create({
|
||||||
data: {
|
data: {
|
||||||
|
userId,
|
||||||
householdType: parsed.data.householdType,
|
householdType: parsed.data.householdType,
|
||||||
inflationRateDefault: parsed.data.inflationRateDefault,
|
inflationRateDefault: parsed.data.inflationRateDefault,
|
||||||
persons: { create: parsed.data.persons },
|
persons: { create: parsed.data.persons },
|
||||||
@@ -62,7 +73,12 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function PATCH(request: NextRequest) {
|
export async function PATCH(request: NextRequest) {
|
||||||
const existing = await getHouseholdOrNull();
|
const userId = await getCurrentUserId();
|
||||||
|
if (!userId) {
|
||||||
|
return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await getHouseholdOrNull(userId);
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
return NextResponse.json({ error: "Kein Haushalt vorhanden." }, { status: 404 });
|
return NextResponse.json({ error: "Kein Haushalt vorhanden." }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { phaseInclude } from "@/lib/queries";
|
import { phaseInclude, getOwnedPhase } from "@/lib/queries";
|
||||||
|
import { getCurrentUserId } from "@/lib/session";
|
||||||
|
|
||||||
const incomeEntrySchema = z.object({
|
const incomeEntrySchema = z.object({
|
||||||
personId: z.string().nullable().optional(),
|
personId: z.string().nullable().optional(),
|
||||||
@@ -61,6 +62,10 @@ export async function PUT(
|
|||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
{ params }: { params: Promise<{ phaseId: string }> }
|
{ params }: { params: Promise<{ phaseId: string }> }
|
||||||
) {
|
) {
|
||||||
|
const userId = await getCurrentUserId();
|
||||||
|
if (!userId) {
|
||||||
|
return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
|
||||||
|
}
|
||||||
const { phaseId } = await params;
|
const { phaseId } = await params;
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const parsed = updatePhaseSchema.safeParse(body);
|
const parsed = updatePhaseSchema.safeParse(body);
|
||||||
@@ -69,7 +74,7 @@ export async function PUT(
|
|||||||
}
|
}
|
||||||
const data = parsed.data;
|
const data = parsed.data;
|
||||||
|
|
||||||
const existing = await prisma.phase.findUnique({ where: { id: phaseId } });
|
const existing = await getOwnedPhase(phaseId, userId);
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
return NextResponse.json({ error: "Phase nicht gefunden." }, { status: 404 });
|
return NextResponse.json({ error: "Phase nicht gefunden." }, { status: 404 });
|
||||||
}
|
}
|
||||||
@@ -111,8 +116,12 @@ export async function DELETE(
|
|||||||
_request: NextRequest,
|
_request: NextRequest,
|
||||||
{ params }: { params: Promise<{ phaseId: string }> }
|
{ params }: { params: Promise<{ phaseId: string }> }
|
||||||
) {
|
) {
|
||||||
|
const userId = await getCurrentUserId();
|
||||||
|
if (!userId) {
|
||||||
|
return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
|
||||||
|
}
|
||||||
const { phaseId } = await params;
|
const { phaseId } = await params;
|
||||||
const phase = await prisma.phase.findUnique({ where: { id: phaseId } });
|
const phase = await getOwnedPhase(phaseId, userId);
|
||||||
if (!phase) {
|
if (!phase) {
|
||||||
return NextResponse.json({ error: "Phase nicht gefunden." }, { status: 404 });
|
return NextResponse.json({ error: "Phase nicht gefunden." }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { z } from "zod";
|
|||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { computeMortgageYearly, computeSecurityYearlyValues } from "@/lib/calculations";
|
import { computeMortgageYearly, computeSecurityYearlyValues } from "@/lib/calculations";
|
||||||
import { floorToThousand } from "@/lib/format";
|
import { floorToThousand } from "@/lib/format";
|
||||||
|
import { getCurrentUserId } from "@/lib/session";
|
||||||
|
|
||||||
const transitionItemSchema = z.object({
|
const transitionItemSchema = z.object({
|
||||||
positionType: z.enum(["SECURITY", "REAL_ESTATE"]),
|
positionType: z.enum(["SECURITY", "REAL_ESTATE"]),
|
||||||
@@ -25,9 +26,13 @@ export async function GET(
|
|||||||
_request: NextRequest,
|
_request: NextRequest,
|
||||||
{ params }: { params: Promise<{ phaseId: string }> }
|
{ params }: { params: Promise<{ phaseId: string }> }
|
||||||
) {
|
) {
|
||||||
|
const userId = await getCurrentUserId();
|
||||||
|
if (!userId) {
|
||||||
|
return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
|
||||||
|
}
|
||||||
const { phaseId } = await params;
|
const { phaseId } = await params;
|
||||||
const phase = await prisma.phase.findUnique({
|
const phase = await prisma.phase.findFirst({
|
||||||
where: { id: phaseId },
|
where: { id: phaseId, plan: { household: { userId } } },
|
||||||
include: { securities: true, realEstates: true },
|
include: { securities: true, realEstates: true },
|
||||||
});
|
});
|
||||||
if (!phase) {
|
if (!phase) {
|
||||||
@@ -61,6 +66,10 @@ export async function PUT(
|
|||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
{ params }: { params: Promise<{ phaseId: string }> }
|
{ params }: { params: Promise<{ phaseId: string }> }
|
||||||
) {
|
) {
|
||||||
|
const userId = await getCurrentUserId();
|
||||||
|
if (!userId) {
|
||||||
|
return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
|
||||||
|
}
|
||||||
const { phaseId } = await params;
|
const { phaseId } = await params;
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const parsed = putTransitionSchema.safeParse(body);
|
const parsed = putTransitionSchema.safeParse(body);
|
||||||
@@ -68,8 +77,8 @@ export async function PUT(
|
|||||||
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
|
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const phase = await prisma.phase.findUnique({
|
const phase = await prisma.phase.findFirst({
|
||||||
where: { id: phaseId },
|
where: { id: phaseId, plan: { household: { userId } } },
|
||||||
include: { securities: true, realEstates: true },
|
include: { securities: true, realEstates: true },
|
||||||
});
|
});
|
||||||
if (!phase) {
|
if (!phase) {
|
||||||
|
|||||||
@@ -1,19 +1,23 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { prisma } from "@/lib/db";
|
import { toHouseholdInput, toPlanInput, getHouseholdOrNull, getOwnedPlan } from "@/lib/queries";
|
||||||
import { planInclude, toHouseholdInput, toPlanInput, getHouseholdOrNull } from "@/lib/queries";
|
import { getCurrentUserId } from "@/lib/session";
|
||||||
import { computePlan, planToCsv } from "@/lib/calculations";
|
import { computePlan, planToCsv } from "@/lib/calculations";
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
_request: NextRequest,
|
_request: NextRequest,
|
||||||
{ params }: { params: Promise<{ planId: string }> }
|
{ params }: { params: Promise<{ planId: string }> }
|
||||||
) {
|
) {
|
||||||
|
const userId = await getCurrentUserId();
|
||||||
|
if (!userId) {
|
||||||
|
return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
|
||||||
|
}
|
||||||
const { planId } = await params;
|
const { planId } = await params;
|
||||||
const household = await getHouseholdOrNull();
|
const household = await getHouseholdOrNull(userId);
|
||||||
if (!household) {
|
if (!household) {
|
||||||
return NextResponse.json({ error: "Kein Haushalt vorhanden." }, { status: 400 });
|
return NextResponse.json({ error: "Kein Haushalt vorhanden." }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const plan = await prisma.plan.findUnique({ where: { id: planId }, include: planInclude });
|
const plan = await getOwnedPlan(planId, userId);
|
||||||
if (!plan) {
|
if (!plan) {
|
||||||
return NextResponse.json({ error: "Plan nicht gefunden." }, { status: 404 });
|
return NextResponse.json({ error: "Plan nicht gefunden." }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { phaseInclude } from "@/lib/queries";
|
import { phaseInclude } from "@/lib/queries";
|
||||||
|
import { getCurrentUserId } from "@/lib/session";
|
||||||
|
|
||||||
const createPhaseSchema = z.object({
|
const createPhaseSchema = z.object({
|
||||||
name: z.string().min(1).max(120),
|
name: z.string().min(1).max(120),
|
||||||
@@ -16,6 +17,10 @@ export async function POST(
|
|||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
{ params }: { params: Promise<{ planId: string }> }
|
{ params }: { params: Promise<{ planId: string }> }
|
||||||
) {
|
) {
|
||||||
|
const userId = await getCurrentUserId();
|
||||||
|
if (!userId) {
|
||||||
|
return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
|
||||||
|
}
|
||||||
const { planId } = await params;
|
const { planId } = await params;
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const parsed = createPhaseSchema.safeParse(body);
|
const parsed = createPhaseSchema.safeParse(body);
|
||||||
@@ -23,7 +28,7 @@ export async function POST(
|
|||||||
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
|
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const plan = await prisma.plan.findUnique({ where: { id: planId } });
|
const plan = await prisma.plan.findFirst({ where: { id: planId, household: { userId } } });
|
||||||
if (!plan) {
|
if (!plan) {
|
||||||
return NextResponse.json({ error: "Plan nicht gefunden." }, { status: 404 });
|
return NextResponse.json({ error: "Plan nicht gefunden." }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,24 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { planInclude, toHouseholdInput, toPlanInput, getHouseholdOrNull } from "@/lib/queries";
|
import { toHouseholdInput, toPlanInput, getHouseholdOrNull, getOwnedPlan } from "@/lib/queries";
|
||||||
|
import { getCurrentUserId } from "@/lib/session";
|
||||||
import { computePlan } from "@/lib/calculations";
|
import { computePlan } from "@/lib/calculations";
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
_request: NextRequest,
|
_request: NextRequest,
|
||||||
{ params }: { params: Promise<{ planId: string }> }
|
{ params }: { params: Promise<{ planId: string }> }
|
||||||
) {
|
) {
|
||||||
|
const userId = await getCurrentUserId();
|
||||||
|
if (!userId) {
|
||||||
|
return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
|
||||||
|
}
|
||||||
const { planId } = await params;
|
const { planId } = await params;
|
||||||
const household = await getHouseholdOrNull();
|
const household = await getHouseholdOrNull(userId);
|
||||||
if (!household) {
|
if (!household) {
|
||||||
return NextResponse.json({ error: "Kein Haushalt vorhanden." }, { status: 400 });
|
return NextResponse.json({ error: "Kein Haushalt vorhanden." }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const plan = await prisma.plan.findUnique({ where: { id: planId }, include: planInclude });
|
const plan = await getOwnedPlan(planId, userId);
|
||||||
if (!plan) {
|
if (!plan) {
|
||||||
return NextResponse.json({ error: "Plan nicht gefunden." }, { status: 404 });
|
return NextResponse.json({ error: "Plan nicht gefunden." }, { status: 404 });
|
||||||
}
|
}
|
||||||
@@ -29,7 +34,15 @@ export async function DELETE(
|
|||||||
_request: NextRequest,
|
_request: NextRequest,
|
||||||
{ params }: { params: Promise<{ planId: string }> }
|
{ params }: { params: Promise<{ planId: string }> }
|
||||||
) {
|
) {
|
||||||
|
const userId = await getCurrentUserId();
|
||||||
|
if (!userId) {
|
||||||
|
return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
|
||||||
|
}
|
||||||
const { planId } = await params;
|
const { planId } = await params;
|
||||||
await prisma.plan.delete({ where: { id: planId } });
|
const plan = await prisma.plan.findFirst({ where: { id: planId, household: { userId } } });
|
||||||
|
if (!plan) {
|
||||||
|
return NextResponse.json({ error: "Plan nicht gefunden." }, { status: 404 });
|
||||||
|
}
|
||||||
|
await prisma.plan.delete({ where: { id: plan.id } });
|
||||||
return NextResponse.json({ ok: true });
|
return NextResponse.json({ ok: true });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { phaseInclude, planInclude } from "@/lib/queries";
|
import { phaseInclude, getOwnedPlan } from "@/lib/queries";
|
||||||
|
import { getCurrentUserId } from "@/lib/session";
|
||||||
|
|
||||||
const scenarioSchema = z.object({
|
const scenarioSchema = z.object({
|
||||||
name: z.string().min(1).max(120),
|
name: z.string().min(1).max(120),
|
||||||
@@ -15,6 +16,10 @@ export async function POST(
|
|||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
{ params }: { params: Promise<{ planId: string }> }
|
{ params }: { params: Promise<{ planId: string }> }
|
||||||
) {
|
) {
|
||||||
|
const userId = await getCurrentUserId();
|
||||||
|
if (!userId) {
|
||||||
|
return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
|
||||||
|
}
|
||||||
const { planId } = await params;
|
const { planId } = await params;
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const parsed = scenarioSchema.safeParse(body);
|
const parsed = scenarioSchema.safeParse(body);
|
||||||
@@ -22,7 +27,7 @@ export async function POST(
|
|||||||
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
|
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const sourcePlan = await prisma.plan.findUnique({ where: { id: planId }, include: planInclude });
|
const sourcePlan = await getOwnedPlan(planId, userId);
|
||||||
if (!sourcePlan) {
|
if (!sourcePlan) {
|
||||||
return NextResponse.json({ error: "Ursprungsplan nicht gefunden." }, { status: 404 });
|
return NextResponse.json({ error: "Ursprungsplan nicht gefunden." }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,13 +2,18 @@ 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 { getHouseholdOrNull } from "@/lib/queries";
|
||||||
|
import { getCurrentUserId } from "@/lib/session";
|
||||||
|
|
||||||
const createPlanSchema = z.object({
|
const createPlanSchema = z.object({
|
||||||
name: z.string().min(1).max(120),
|
name: z.string().min(1).max(120),
|
||||||
});
|
});
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
const household = await getHouseholdOrNull();
|
const userId = await getCurrentUserId();
|
||||||
|
if (!userId) {
|
||||||
|
return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
|
||||||
|
}
|
||||||
|
const household = await getHouseholdOrNull(userId);
|
||||||
if (!household) {
|
if (!household) {
|
||||||
return NextResponse.json({ plans: [] });
|
return NextResponse.json({ plans: [] });
|
||||||
}
|
}
|
||||||
@@ -31,7 +36,11 @@ export async function GET() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
const household = await getHouseholdOrNull();
|
const userId = await getCurrentUserId();
|
||||||
|
if (!userId) {
|
||||||
|
return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
|
||||||
|
}
|
||||||
|
const household = await getHouseholdOrNull(userId);
|
||||||
if (!household) {
|
if (!household) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Bitte zuerst das Grundprofil (Onboarding) anlegen." },
|
{ error: "Bitte zuerst das Grundprofil (Onboarding) anlegen." },
|
||||||
|
|||||||
+64
-35
@@ -1,39 +1,36 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Suspense, useEffect, useState } from "react";
|
import { Suspense, useState } from "react";
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import { Lock, PiggyBank } from "lucide-react";
|
import { Lock, PiggyBank, User } from "lucide-react";
|
||||||
|
|
||||||
|
type Mode = "login" | "register";
|
||||||
|
|
||||||
function LoginForm() {
|
function LoginForm() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const [passwordSet, setPasswordSet] = useState<boolean | undefined>(undefined);
|
const [mode, setMode] = useState<Mode>("login");
|
||||||
|
const [username, setUsername] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [passwordConfirm, setPasswordConfirm] = useState("");
|
const [passwordConfirm, setPasswordConfirm] = useState("");
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetch("/api/auth/status")
|
|
||||||
.then((r) => r.json())
|
|
||||||
.then((data) => setPasswordSet(Boolean(data.passwordSet)));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent) {
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
if (!passwordSet && password !== passwordConfirm) {
|
if (mode === "register" && password !== passwordConfirm) {
|
||||||
setError("Die Passwoerter stimmen nicht ueberein.");
|
setError("Die Passwoerter stimmen nicht ueberein.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const response = await fetch(passwordSet ? "/api/auth/login" : "/api/auth/setup", {
|
const response = await fetch(mode === "login" ? "/api/auth/login" : "/api/auth/register", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ password }),
|
body: JSON.stringify({ username, password }),
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const body = await response.json().catch(() => ({}));
|
const body = await response.json().catch(() => ({}));
|
||||||
@@ -48,50 +45,81 @@ function LoginForm() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (passwordSet === undefined) {
|
const inputClass =
|
||||||
return (
|
"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";
|
||||||
<div className="flex flex-1 items-center justify-center">
|
|
||||||
<p className="text-sm text-zinc-500">Laedt…</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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="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">
|
||||||
|
<PiggyBank className="h-7 w-7 text-white" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-xl font-semibold text-zinc-900 dark:text-zinc-50">
|
||||||
|
Financial Planning Tool
|
||||||
|
</h1>
|
||||||
|
<p className="text-xs text-zinc-500 dark:text-zinc-400">
|
||||||
|
Persoenliche Finanzplanung ueber Lebensphasen
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<form
|
<form
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
className="flex w-full max-w-sm flex-col gap-4 rounded-xl 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-zinc-200/70 bg-white p-6 shadow-sm dark:border-zinc-800 dark:bg-zinc-900"
|
||||||
>
|
>
|
||||||
<h1 className="flex items-center gap-2 text-lg font-semibold text-zinc-900 dark:text-zinc-50">
|
<div className="flex rounded-lg bg-zinc-100 p-1 dark:bg-zinc-800">
|
||||||
<PiggyBank className="h-5 w-5 text-indigo-600 dark:text-indigo-400" />
|
{(["login", "register"] as Mode[]).map((m) => (
|
||||||
{passwordSet ? "FPT — Anmelden" : "FPT — Passwort festlegen"}
|
<button
|
||||||
</h1>
|
key={m}
|
||||||
{!passwordSet && (
|
type="button"
|
||||||
<p className="text-xs text-zinc-500 dark:text-zinc-400">
|
onClick={() => {
|
||||||
Es ist noch kein Passwort eingerichtet. Legen Sie hier Ihr persoenliches Passwort fest,
|
setMode(m);
|
||||||
um den Zugriff auf Ihre Finanzplanung zu schuetzen.
|
setError(null);
|
||||||
</p>
|
}}
|
||||||
)}
|
className={`flex-1 rounded-md px-3 py-1.5 text-sm font-medium transition-colors ${
|
||||||
|
mode === m
|
||||||
|
? "bg-white text-indigo-600 shadow-sm dark:bg-zinc-900 dark:text-indigo-400"
|
||||||
|
: "text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{m === "login" ? "Anmelden" : "Registrieren"}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
autoFocus
|
||||||
|
autoComplete="username"
|
||||||
|
placeholder="Benutzername"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</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-zinc-400" />
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
autoFocus
|
autoComplete={mode === "login" ? "current-password" : "new-password"}
|
||||||
placeholder="Passwort"
|
placeholder="Passwort"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
className="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"
|
className={inputClass}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{!passwordSet && (
|
{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-zinc-400" />
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
placeholder="Passwort bestaetigen"
|
placeholder="Passwort bestaetigen"
|
||||||
value={passwordConfirm}
|
value={passwordConfirm}
|
||||||
onChange={(e) => setPasswordConfirm(e.target.value)}
|
onChange={(e) => setPasswordConfirm(e.target.value)}
|
||||||
className="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"
|
className={inputClass}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -101,10 +129,11 @@ function LoginForm() {
|
|||||||
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-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"
|
||||||
>
|
>
|
||||||
{loading ? "..." : passwordSet ? "Anmelden" : "Passwort festlegen"}
|
{loading ? "..." : mode === "login" ? "Anmelden" : "Konto erstellen"}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+13
-3
@@ -8,10 +8,20 @@ import type { HouseholdInput } from "@/lib/types";
|
|||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const [household, setHousehold] = useState<HouseholdInput | null | undefined>(undefined);
|
const [household, setHousehold] = useState<HouseholdInput | null | undefined>(undefined);
|
||||||
|
const [username, setUsername] = useState<string>("");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.get<{ household: HouseholdInput | null }>("/api/household").then((data) => {
|
Promise.all([
|
||||||
setHousehold(data.household);
|
api.get<{ household: HouseholdInput | null }>("/api/household"),
|
||||||
|
api.get<{ user: { username: string } }>("/api/auth/me"),
|
||||||
|
])
|
||||||
|
.then(([householdData, meData]) => {
|
||||||
|
setUsername(meData.user.username);
|
||||||
|
setHousehold(householdData.household);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// Session abgelaufen/ungueltig -- Middleware leitet beim naechsten Request um.
|
||||||
|
window.location.href = "/login";
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -27,5 +37,5 @@ export default function Home() {
|
|||||||
return <Onboarding onDone={setHousehold} />;
|
return <Onboarding onDone={setHousehold} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <AppShell initialHousehold={household} />;
|
return <AppShell initialHousehold={household} username={username} />;
|
||||||
}
|
}
|
||||||
|
|||||||
+348
-141
@@ -1,11 +1,20 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { LogOut, PiggyBank, Plus, Settings, X } from "lucide-react";
|
import {
|
||||||
import { PhaseCard } from "@/components/PhaseCard";
|
FolderKanban,
|
||||||
|
LayoutDashboard,
|
||||||
|
Menu,
|
||||||
|
PiggyBank,
|
||||||
|
Plus,
|
||||||
|
Trash2,
|
||||||
|
X,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { PhaseCard, formatAges } from "@/components/PhaseCard";
|
||||||
import { TransitionPanel } from "@/components/TransitionPanel";
|
import { TransitionPanel } from "@/components/TransitionPanel";
|
||||||
import { Dashboard } from "@/components/Dashboard";
|
import { Dashboard } from "@/components/Dashboard";
|
||||||
import { HouseholdSettings } from "@/components/HouseholdSettings";
|
import { HouseholdSettings } from "@/components/HouseholdSettings";
|
||||||
|
import { ProfileMenu } from "@/components/ProfileMenu";
|
||||||
import { api } from "@/lib/api-client";
|
import { api } from "@/lib/api-client";
|
||||||
import type { HouseholdInput, PlanInput } from "@/lib/types";
|
import type { HouseholdInput, PlanInput } from "@/lib/types";
|
||||||
import type { PlanComputed } from "@/lib/calculations";
|
import type { PlanComputed } from "@/lib/calculations";
|
||||||
@@ -18,13 +27,20 @@ interface PlanListItem {
|
|||||||
phases: { id: string; name: string; sequenceNumber: number }[];
|
phases: { id: string; name: string; sequenceNumber: number }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AppShell({ initialHousehold }: { initialHousehold: HouseholdInput }) {
|
export function AppShell({
|
||||||
|
initialHousehold,
|
||||||
|
username,
|
||||||
|
}: {
|
||||||
|
initialHousehold: HouseholdInput;
|
||||||
|
username: string;
|
||||||
|
}) {
|
||||||
const [household, setHousehold] = useState(initialHousehold);
|
const [household, setHousehold] = useState(initialHousehold);
|
||||||
const [showSettings, setShowSettings] = useState(false);
|
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);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
const [showNewPlan, setShowNewPlan] = useState(false);
|
const [showNewPlan, setShowNewPlan] = useState(false);
|
||||||
const [showScenario, setShowScenario] = useState(false);
|
const [showScenario, setShowScenario] = useState(false);
|
||||||
|
|
||||||
@@ -33,11 +49,8 @@ export function AppShell({ initialHousehold }: { initialHousehold: HouseholdInpu
|
|||||||
setPlans(data.plans);
|
setPlans(data.plans);
|
||||||
if (preferId) {
|
if (preferId) {
|
||||||
setSelectedPlanId(preferId);
|
setSelectedPlanId(preferId);
|
||||||
} else if (!selectedPlanId && data.plans.length > 0) {
|
|
||||||
setSelectedPlanId(data.plans[0].id);
|
|
||||||
}
|
}
|
||||||
return data.plans;
|
return data.plans;
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const loadDetail = useCallback(async (planId: string) => {
|
const loadDetail = useCallback(async (planId: string) => {
|
||||||
@@ -51,17 +64,16 @@ export function AppShell({ initialHousehold }: { initialHousehold: HouseholdInpu
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- asynchroner Datenabruf beim Mount, kein synchrones setState
|
// eslint-disable-next-line react-hooks/set-state-in-effect -- asynchroner Datenabruf beim Mount
|
||||||
loadPlans();
|
loadPlans().finally(() => setLoading(false));
|
||||||
}, [loadPlans]);
|
}, [loadPlans]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedPlanId) {
|
if (selectedPlanId) {
|
||||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- asynchroner Datenabruf, kein synchrones setState
|
// eslint-disable-next-line react-hooks/set-state-in-effect -- asynchroner Datenabruf
|
||||||
loadDetail(selectedPlanId);
|
loadDetail(selectedPlanId);
|
||||||
} else {
|
} else {
|
||||||
setDetail(null);
|
setDetail(null);
|
||||||
setLoading(false);
|
|
||||||
}
|
}
|
||||||
}, [selectedPlanId, loadDetail]);
|
}, [selectedPlanId, loadDetail]);
|
||||||
|
|
||||||
@@ -70,10 +82,9 @@ export function AppShell({ initialHousehold }: { initialHousehold: HouseholdInpu
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleAddPhase() {
|
async function handleAddPhase() {
|
||||||
if (!selectedPlanId) return;
|
if (!selectedPlanId || !detail) return;
|
||||||
const lastPhase = detail?.plan.phases[detail.plan.phases.length - 1];
|
|
||||||
await api.post(`/api/plans/${selectedPlanId}/phases`, {
|
await api.post(`/api/plans/${selectedPlanId}/phases`, {
|
||||||
name: lastPhase ? `Neue Phase ${detail!.plan.phases.length + 1}` : "Erste Lebensphase",
|
name: detail.plan.phases.length === 0 ? "Erste Lebensphase" : `Neue Phase ${detail.plan.phases.length + 1}`,
|
||||||
durationYears: 10,
|
durationYears: 10,
|
||||||
incomeMode: "HOUSEHOLD",
|
incomeMode: "HOUSEHOLD",
|
||||||
});
|
});
|
||||||
@@ -83,145 +94,198 @@ export function AppShell({ initialHousehold }: { initialHousehold: HouseholdInpu
|
|||||||
async function handleDeletePlan(id: string) {
|
async function handleDeletePlan(id: string) {
|
||||||
if (!confirm("Diesen Plan wirklich loeschen?")) return;
|
if (!confirm("Diesen Plan wirklich loeschen?")) return;
|
||||||
await api.delete(`/api/plans/${id}`);
|
await api.delete(`/api/plans/${id}`);
|
||||||
const remaining = await loadPlans();
|
await loadPlans();
|
||||||
if (selectedPlanId === id) {
|
if (selectedPlanId === id) {
|
||||||
setSelectedPlanId(remaining[0]?.id ?? null);
|
setSelectedPlanId(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
const activePlan = plans.find((p) => p.id === selectedPlanId) ?? null;
|
||||||
<div className="mx-auto flex w-full max-w-5xl flex-1 flex-col gap-6 px-4 py-8">
|
|
||||||
<header className="flex items-center justify-between">
|
const sidebar = (
|
||||||
<h1 className="flex items-center gap-2 text-xl font-semibold text-zinc-900 dark:text-zinc-50">
|
<div className="flex h-full flex-col">
|
||||||
<PiggyBank className="h-6 w-6 text-indigo-600 dark:text-indigo-400" />
|
<div className="flex items-center gap-2 px-4 py-4">
|
||||||
Financial Planning Tool
|
<div className="flex h-8 w-8 items-center justify-center rounded-xl bg-indigo-600 dark:bg-indigo-500">
|
||||||
</h1>
|
<PiggyBank className="h-5 w-5 text-white" />
|
||||||
<div className="flex items-center gap-2">
|
</div>
|
||||||
|
<span className="text-sm font-semibold text-zinc-900 dark:text-zinc-50">FPT</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="flex flex-1 flex-col gap-1 overflow-y-auto px-3 pb-4">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowSettings((v) => !v)}
|
onClick={() => {
|
||||||
className="flex items-center gap-1.5 rounded-lg border border-zinc-300 bg-white px-3 py-1.5 text-xs font-medium text-zinc-700 shadow-sm hover:bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-200 dark:hover:bg-zinc-700"
|
setSelectedPlanId(null);
|
||||||
>
|
setSidebarOpen(false);
|
||||||
<Settings className="h-3.5 w-3.5" />
|
|
||||||
Grundprofil
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={async () => {
|
|
||||||
await api.post("/api/auth/logout");
|
|
||||||
window.location.href = "/login";
|
|
||||||
}}
|
}}
|
||||||
className="flex items-center gap-1.5 rounded-lg border border-zinc-300 bg-white px-3 py-1.5 text-xs font-medium text-zinc-700 shadow-sm hover:bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-200 dark:hover:bg-zinc-700"
|
className={`flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium ${
|
||||||
|
selectedPlanId === null
|
||||||
|
? "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"
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<LogOut className="h-3.5 w-3.5" />
|
<LayoutDashboard className="h-4 w-4" />
|
||||||
Abmelden
|
Uebersicht
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowNewPlan(true)}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{plans.length === 0 && (
|
||||||
|
<p className="px-3 py-2 text-xs text-zinc-400">Noch keine Plaene.</p>
|
||||||
|
)}
|
||||||
|
{plans.map((p) => (
|
||||||
|
<button
|
||||||
|
key={p.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedPlanId(p.id);
|
||||||
|
setSidebarOpen(false);
|
||||||
|
}}
|
||||||
|
className={`flex items-center gap-2 rounded-lg px-3 py-2 text-left text-sm ${
|
||||||
|
selectedPlanId === p.id
|
||||||
|
? "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" />
|
||||||
|
<span className="min-w-0 flex-1 truncate">{p.name}</span>
|
||||||
|
<span className="text-[11px] text-zinc-400">{p.phases.length}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen w-full">
|
||||||
|
{/* 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">
|
||||||
|
{sidebar}
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* Sidebar Mobile (Overlay) */}
|
||||||
|
{sidebarOpen && (
|
||||||
|
<div className="fixed inset-0 z-40 lg:hidden">
|
||||||
|
<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">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSidebarOpen(false)}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
{sidebar}
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Hauptbereich */}
|
||||||
|
<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">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSidebarOpen(true)}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<Menu className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<h1 className="min-w-0 flex-1 truncate text-base font-semibold text-zinc-900 dark:text-zinc-50">
|
||||||
|
{activePlan ? activePlan.name : "Uebersicht"}
|
||||||
|
</h1>
|
||||||
|
<ProfileMenu username={username} onOpenHouseholdSettings={() => setShowSettings(true)} />
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<main className="flex-1 px-4 py-6 lg:px-8">
|
||||||
{showSettings && (
|
{showSettings && (
|
||||||
|
<div className="mb-6">
|
||||||
<HouseholdSettings
|
<HouseholdSettings
|
||||||
household={household}
|
household={household}
|
||||||
onUpdated={setHousehold}
|
onUpdated={setHousehold}
|
||||||
onClose={() => setShowSettings(false)}
|
onClose={() => setShowSettings(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Tab-Leiste */}
|
|
||||||
<div className="flex flex-wrap items-center gap-2 border-b border-zinc-200 pb-2 dark:border-zinc-800">
|
|
||||||
{plans.map((p) => (
|
|
||||||
<div key={p.id} className="flex items-center">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setSelectedPlanId(p.id)}
|
|
||||||
className={`rounded-t-lg px-3 py-1.5 text-sm font-medium transition-colors ${
|
|
||||||
selectedPlanId === p.id
|
|
||||||
? "bg-indigo-600 text-white shadow-sm dark:bg-indigo-500"
|
|
||||||
: "text-zinc-600 hover:bg-zinc-100 dark:text-zinc-300 dark:hover:bg-zinc-800"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{p.name}
|
|
||||||
</button>
|
|
||||||
{selectedPlanId === p.id && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => handleDeletePlan(p.id)}
|
|
||||||
className="ml-1 text-zinc-400 hover:text-red-600"
|
|
||||||
aria-label="Plan loeschen"
|
|
||||||
>
|
|
||||||
<X className="h-3.5 w-3.5" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
<div className="relative">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowNewPlan((v) => !v)}
|
|
||||||
className="flex items-center gap-1 rounded-lg px-2 py-1.5 text-sm text-indigo-600 hover:bg-indigo-50 dark:text-indigo-400 dark:hover:bg-indigo-500/10"
|
|
||||||
>
|
|
||||||
<Plus className="h-3.5 w-3.5" />
|
|
||||||
Plan
|
|
||||||
</button>
|
|
||||||
{showNewPlan && (
|
|
||||||
<NewPlanPopover
|
|
||||||
onCreate={async (name) => {
|
|
||||||
const { plan } = await api.post<{ plan: { id: string } }>("/api/plans", { name });
|
|
||||||
setShowNewPlan(false);
|
|
||||||
await loadPlans(plan.id);
|
|
||||||
}}
|
|
||||||
onClose={() => setShowNewPlan(false)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{detail && detail.plan.phases.length > 0 && (
|
|
||||||
<div className="relative">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowScenario((v) => !v)}
|
|
||||||
className="flex items-center gap-1 rounded-lg px-2 py-1.5 text-sm text-indigo-600 hover:bg-indigo-50 dark:text-indigo-400 dark:hover:bg-indigo-500/10"
|
|
||||||
>
|
|
||||||
<Plus className="h-3.5 w-3.5" />
|
|
||||||
Szenario
|
|
||||||
</button>
|
|
||||||
{showScenario && (
|
|
||||||
<ScenarioPopover
|
|
||||||
phases={detail.plan.phases}
|
|
||||||
onCreate={async (name, branchFromPhaseId) => {
|
|
||||||
const { planId } = await api.post<{ planId: string }>(
|
|
||||||
`/api/plans/${selectedPlanId}/scenario`,
|
|
||||||
{ name, branchFromPhaseId }
|
|
||||||
);
|
|
||||||
setShowScenario(false);
|
|
||||||
await loadPlans(planId);
|
|
||||||
}}
|
|
||||||
onClose={() => setShowScenario(false)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
|
|
||||||
{loading && <p className="text-sm text-zinc-500">Laedt…</p>}
|
{loading && <p className="text-sm text-zinc-500">Laedt…</p>}
|
||||||
|
|
||||||
{!loading && plans.length === 0 && (
|
{!loading && selectedPlanId === null && (
|
||||||
<p className="text-sm text-zinc-500">
|
<DashboardHome
|
||||||
Noch kein Plan vorhanden. Erstellen Sie oben Ihren ersten Plan.
|
username={username}
|
||||||
</p>
|
plans={plans}
|
||||||
|
onSelect={setSelectedPlanId}
|
||||||
|
onCreate={() => setShowNewPlan(true)}
|
||||||
|
onDelete={handleDeletePlan}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!loading && detail && (
|
{!loading && detail && selectedPlanId && (
|
||||||
<>
|
<div className="flex flex-col gap-6">
|
||||||
<div className="flex flex-col gap-3">
|
{/* Plan-Kopf mit Aktionen */}
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleAddPhase}
|
||||||
|
className="flex items-center gap-1.5 rounded-lg border border-dashed border-indigo-300 bg-indigo-50/50 px-3 py-1.5 text-sm font-medium text-indigo-700 hover:bg-indigo-50 dark:border-indigo-500/30 dark:bg-indigo-500/10 dark:text-indigo-300 dark:hover:bg-indigo-500/20"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
Phase hinzufuegen
|
||||||
|
</button>
|
||||||
|
{detail.plan.phases.length > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
Szenario
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
Plan loeschen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Phasen mit Lebenslinie */}
|
||||||
|
<div className="flex flex-col">
|
||||||
{detail.plan.phases.map((phase, i) => {
|
{detail.plan.phases.map((phase, i) => {
|
||||||
const computedPhase = detail.computed.phases.find((c) => c.id === phase.id)!;
|
const computedPhase = detail.computed.phases.find((c) => c.id === phase.id)!;
|
||||||
const nextPhase = detail.plan.phases[i + 1];
|
const nextPhase = detail.plan.phases[i + 1];
|
||||||
|
const startAges = computedPhase.ages.map((a) => a.startAge).join("·");
|
||||||
return (
|
return (
|
||||||
<div key={phase.id} className="flex flex-col gap-3">
|
<div key={phase.id} className="flex gap-3">
|
||||||
|
{/* Lebenslinie */}
|
||||||
|
<div className="hidden w-12 flex-col items-center sm:flex">
|
||||||
|
<div
|
||||||
|
title={`Alter zu Beginn: ${formatAges(computedPhase)}`}
|
||||||
|
className="flex h-9 w-12 items-center justify-center rounded-full border border-indigo-200 bg-indigo-50 text-[11px] font-semibold text-indigo-700 dark:border-indigo-500/30 dark:bg-indigo-500/15 dark:text-indigo-300"
|
||||||
|
>
|
||||||
|
{startAges}
|
||||||
|
</div>
|
||||||
|
<div className="w-px flex-1 bg-indigo-200 dark:bg-indigo-500/30" />
|
||||||
|
</div>
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col gap-3 pb-3">
|
||||||
<PhaseCard
|
<PhaseCard
|
||||||
household={household}
|
household={household}
|
||||||
phase={phase}
|
phase={phase}
|
||||||
computed={computedPhase}
|
computed={computedPhase}
|
||||||
|
isFirst={i === 0}
|
||||||
isLast={i === detail.plan.phases.length - 1}
|
isLast={i === detail.plan.phases.length - 1}
|
||||||
onChanged={refreshCurrent}
|
onChanged={refreshCurrent}
|
||||||
/>
|
/>
|
||||||
@@ -234,34 +298,161 @@ export function AppShell({ initialHousehold }: { initialHousehold: HouseholdInpu
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
{detail.computed.phases.length > 0 && (
|
||||||
|
<div className="hidden w-12 flex-col items-center sm:flex">
|
||||||
|
<div
|
||||||
|
title="Alter am Ende der letzten Phase"
|
||||||
|
className="flex h-9 w-12 items-center justify-center rounded-full border border-zinc-300 bg-white text-[11px] font-semibold text-zinc-600 dark:border-zinc-600 dark:bg-zinc-800 dark:text-zinc-300"
|
||||||
|
>
|
||||||
|
{detail.computed.phases[detail.computed.phases.length - 1].ages
|
||||||
|
.map((a) => a.endAge)
|
||||||
|
.join("·")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
{detail.plan.phases.length === 0 && (
|
||||||
type="button"
|
<p className="text-sm text-zinc-500">
|
||||||
onClick={handleAddPhase}
|
Dieser Plan hat noch keine Phasen. Fuegen Sie oben die erste Lebensphase hinzu.
|
||||||
className="flex items-center gap-1.5 self-start rounded-lg border border-dashed border-indigo-300 bg-indigo-50/50 px-3 py-2 text-sm font-medium text-indigo-700 hover:bg-indigo-50 dark:border-indigo-500/30 dark:bg-indigo-500/10 dark:text-indigo-300 dark:hover:bg-indigo-500/20"
|
</p>
|
||||||
>
|
)}
|
||||||
<Plus className="h-4 w-4" />
|
|
||||||
Phase hinzufuegen
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{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>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Dialoge */}
|
||||||
|
{showNewPlan && (
|
||||||
|
<PlanDialog
|
||||||
|
title="Neuen Plan erstellen"
|
||||||
|
defaultName="Basisplan"
|
||||||
|
onCreate={async (name) => {
|
||||||
|
const { plan } = await api.post<{ plan: { id: string } }>("/api/plans", { name });
|
||||||
|
setShowNewPlan(false);
|
||||||
|
await loadPlans(plan.id);
|
||||||
|
}}
|
||||||
|
onClose={() => setShowNewPlan(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{showScenario && detail && selectedPlanId && (
|
||||||
|
<ScenarioDialog
|
||||||
|
phases={detail.plan.phases}
|
||||||
|
onCreate={async (name, branchFromPhaseId) => {
|
||||||
|
const { planId } = await api.post<{ planId: string }>(`/api/plans/${selectedPlanId}/scenario`, {
|
||||||
|
name,
|
||||||
|
branchFromPhaseId,
|
||||||
|
});
|
||||||
|
setShowScenario(false);
|
||||||
|
await loadPlans(planId);
|
||||||
|
}}
|
||||||
|
onClose={() => setShowScenario(false)}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function NewPlanPopover({ onCreate, onClose }: { onCreate: (name: string) => void; onClose: () => void }) {
|
// Startansicht: Begruessung + Plan-Kacheln.
|
||||||
const [name, setName] = useState("Basisplan");
|
function DashboardHome({
|
||||||
|
username,
|
||||||
|
plans,
|
||||||
|
onSelect,
|
||||||
|
onCreate,
|
||||||
|
onDelete,
|
||||||
|
}: {
|
||||||
|
username: string;
|
||||||
|
plans: PlanListItem[];
|
||||||
|
onSelect: (id: string) => void;
|
||||||
|
onCreate: () => void;
|
||||||
|
onDelete: (id: string) => void;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="absolute left-0 top-10 z-10 w-64 rounded-lg border border-zinc-200 bg-white p-3 shadow-lg dark:border-zinc-700 dark:bg-zinc-800">
|
<div className="flex flex-col gap-6">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-semibold text-zinc-900 dark:text-zinc-50">
|
||||||
|
Willkommen, {username}
|
||||||
|
</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.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||||
|
{plans.map((p) => (
|
||||||
|
<div
|
||||||
|
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"
|
||||||
|
onClick={() => onSelect(p.id)}
|
||||||
|
>
|
||||||
|
<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">
|
||||||
|
<FolderKanban className="h-5 w-5 text-indigo-600 dark:text-indigo-300" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="truncate font-medium text-zinc-900 dark:text-zinc-100">{p.name}</div>
|
||||||
|
<div className="text-xs text-zinc-500">
|
||||||
|
{p.phases.length} {p.phases.length === 1 ? "Phase" : "Phasen"}
|
||||||
|
{p.parentPlanId ? " · Szenario" : ""}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Plan loeschen"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
Neuer Plan
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PlanDialog({
|
||||||
|
title,
|
||||||
|
defaultName,
|
||||||
|
onCreate,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
defaultName: string;
|
||||||
|
onCreate: (name: string) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const [name, setName] = useState(defaultName);
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-40 flex items-center justify-center bg-black/40 px-4" onClick={onClose}>
|
||||||
|
<div
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="flex w-full max-w-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"
|
||||||
|
>
|
||||||
|
<h2 className="text-base font-semibold text-zinc-900 dark:text-zinc-50">{title}</h2>
|
||||||
<input
|
<input
|
||||||
className="mb-2 w-full 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-600 dark:bg-zinc-900"
|
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"
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(e) => setName(e.target.value)}
|
onChange={(e) => setName(e.target.value)}
|
||||||
placeholder="Name des Plans"
|
placeholder="Name des Plans"
|
||||||
@@ -270,19 +461,24 @@ function NewPlanPopover({ onCreate, onClose }: { onCreate: (name: string) => voi
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onCreate(name)}
|
onClick={() => onCreate(name)}
|
||||||
className="rounded-lg bg-indigo-600 px-2 py-1 text-xs font-medium text-white hover:bg-indigo-500 dark:bg-indigo-500 dark:hover:bg-indigo-400"
|
className="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-500 dark:bg-indigo-500 dark:hover:bg-indigo-400"
|
||||||
>
|
>
|
||||||
Erstellen
|
Erstellen
|
||||||
</button>
|
</button>
|
||||||
<button type="button" onClick={onClose} className="text-xs text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300">
|
<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
|
Abbrechen
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ScenarioPopover({
|
function ScenarioDialog({
|
||||||
phases,
|
phases,
|
||||||
onCreate,
|
onCreate,
|
||||||
onClose,
|
onClose,
|
||||||
@@ -294,16 +490,22 @@ function ScenarioPopover({
|
|||||||
const [name, setName] = useState("Neues Szenario");
|
const [name, setName] = useState("Neues Szenario");
|
||||||
const [branchFromPhaseId, setBranchFromPhaseId] = useState(phases[phases.length - 1]?.id ?? "");
|
const [branchFromPhaseId, setBranchFromPhaseId] = useState(phases[phases.length - 1]?.id ?? "");
|
||||||
return (
|
return (
|
||||||
<div className="absolute left-0 top-10 z-10 w-72 rounded-lg border border-zinc-200 bg-white p-3 shadow-lg dark:border-zinc-700 dark:bg-zinc-800">
|
<div className="fixed inset-0 z-40 flex items-center justify-center bg-black/40 px-4" onClick={onClose}>
|
||||||
|
<div
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="flex w-full max-w-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"
|
||||||
|
>
|
||||||
|
<h2 className="text-base font-semibold text-zinc-900 dark:text-zinc-50">Szenario erstellen</h2>
|
||||||
<input
|
<input
|
||||||
className="mb-2 w-full 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-600 dark:bg-zinc-900"
|
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"
|
||||||
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="mb-1 block text-xs text-zinc-500">Verzweigen ab Phase</label>
|
<label className="text-xs text-zinc-500">Verzweigen ab Phase</label>
|
||||||
<select
|
<select
|
||||||
className="mb-2 w-full 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-600 dark:bg-zinc-900"
|
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"
|
||||||
value={branchFromPhaseId}
|
value={branchFromPhaseId}
|
||||||
onChange={(e) => setBranchFromPhaseId(e.target.value)}
|
onChange={(e) => setBranchFromPhaseId(e.target.value)}
|
||||||
>
|
>
|
||||||
@@ -317,14 +519,19 @@ function ScenarioPopover({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onCreate(name, branchFromPhaseId)}
|
onClick={() => onCreate(name, branchFromPhaseId)}
|
||||||
className="rounded-lg bg-indigo-600 px-2 py-1 text-xs font-medium text-white hover:bg-indigo-500 dark:bg-indigo-500 dark:hover:bg-indigo-400"
|
className="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-500 dark:bg-indigo-500 dark:hover:bg-indigo-400"
|
||||||
>
|
>
|
||||||
Erstellen
|
Erstellen
|
||||||
</button>
|
</button>
|
||||||
<button type="button" onClick={onClose} className="text-xs text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300">
|
<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
|
Abbrechen
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,14 +54,17 @@ export function NumberField({
|
|||||||
// formatiert mit 1'000er-Trennzeichen an, solange das Feld nicht fokussiert ist, rundet
|
// formatiert mit 1'000er-Trennzeichen an, solange das Feld nicht fokussiert ist, rundet
|
||||||
// beim Verlassen des Feldes auf ein Vielfaches von 1'000 ABwaerts (siehe lib/format.ts)
|
// beim Verlassen des Feldes auf ein Vielfaches von 1'000 ABwaerts (siehe lib/format.ts)
|
||||||
// und bietet Pfeil-Buttons zum Erhoehen/Verringern in 1'000er-Schritten.
|
// und bietet Pfeil-Buttons zum Erhoehen/Verringern in 1'000er-Schritten.
|
||||||
|
// Optionales `max` kappt Eingaben live auf das verfuegbare Budget (z. B. Sparquote).
|
||||||
export function MoneyInput({
|
export function MoneyInput({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
className,
|
className,
|
||||||
|
max,
|
||||||
}: {
|
}: {
|
||||||
value: number;
|
value: number;
|
||||||
onChange: (value: number) => void;
|
onChange: (value: number) => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
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.floor(value || 0)));
|
||||||
@@ -72,8 +75,14 @@ export function MoneyInput({
|
|||||||
const holdTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const holdTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const holdInterval = useRef<ReturnType<typeof setInterval> | null>(null);
|
const holdInterval = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
|
||||||
|
function clamp(v: number): number {
|
||||||
|
let result = Math.max(0, v);
|
||||||
|
if (max != null) result = Math.min(result, Math.max(0, floorToThousand(max)));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
function step(delta: number) {
|
function step(delta: number) {
|
||||||
onChange(floorToThousand(valueRef.current) + delta);
|
onChange(clamp(floorToThousand(valueRef.current) + delta));
|
||||||
}
|
}
|
||||||
|
|
||||||
function stopHold() {
|
function stopHold() {
|
||||||
@@ -107,12 +116,14 @@ export function MoneyInput({
|
|||||||
value={focused ? text : formatChf(value)}
|
value={focused ? text : formatChf(value)}
|
||||||
onFocus={() => {
|
onFocus={() => {
|
||||||
setFocused(true);
|
setFocused(true);
|
||||||
setText(String(Math.floor(value || 0)));
|
// Default-0 sofort leeren, damit man direkt lostippen kann.
|
||||||
|
const current = Math.floor(value || 0);
|
||||||
|
setText(current === 0 ? "" : String(current));
|
||||||
}}
|
}}
|
||||||
onChange={(e) => setText(e.target.value.replace(/[^0-9-]/g, ""))}
|
onChange={(e) => setText(e.target.value.replace(/[^0-9-]/g, ""))}
|
||||||
onBlur={() => {
|
onBlur={() => {
|
||||||
setFocused(false);
|
setFocused(false);
|
||||||
onChange(floorToThousand(parseChfInput(text)));
|
onChange(clamp(floorToThousand(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-zinc-300 dark:border-zinc-700">
|
||||||
@@ -164,16 +175,18 @@ export function MoneyField({
|
|||||||
help,
|
help,
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
|
max,
|
||||||
}: {
|
}: {
|
||||||
label: string;
|
label: string;
|
||||||
help?: string;
|
help?: string;
|
||||||
value: number;
|
value: number;
|
||||||
onChange: (value: number) => void;
|
onChange: (value: number) => void;
|
||||||
|
max?: number;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<FieldLabel label={label} help={help} />
|
<FieldLabel label={label} help={help} />
|
||||||
<MoneyInput value={value} onChange={onChange} />
|
<MoneyInput value={value} onChange={onChange} max={max} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,23 +2,38 @@
|
|||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { LineChart, Line, ResponsiveContainer } from "recharts";
|
import { LineChart, Line, ResponsiveContainer } from "recharts";
|
||||||
import { AlertTriangle, ChevronDown, ChevronRight, Trash2 } from "lucide-react";
|
import { AlertTriangle, ChevronDown, ChevronRight, Trash2, Users } from "lucide-react";
|
||||||
import { PhaseForm } from "@/components/PhaseForm";
|
import { PhaseForm } from "@/components/PhaseForm";
|
||||||
import { api } from "@/lib/api-client";
|
import { api } from "@/lib/api-client";
|
||||||
import { formatChf } from "@/lib/format";
|
import { formatChf } from "@/lib/format";
|
||||||
import type { HouseholdInput, PhaseInput } from "@/lib/types";
|
import type { HouseholdInput, PhaseInput } from "@/lib/types";
|
||||||
import type { PhaseComputed } from "@/lib/calculations";
|
import type { PhaseComputed } from "@/lib/calculations";
|
||||||
|
|
||||||
|
// Formatiert die Altersspannen der Personen einer Phase, z. B. "35–45" (Single)
|
||||||
|
// oder "A 35–45 · B 33–43" (Paar).
|
||||||
|
export function formatAges(computed: PhaseComputed): string {
|
||||||
|
if (computed.ages.length === 0) return "";
|
||||||
|
if (computed.ages.length === 1) {
|
||||||
|
const a = computed.ages[0];
|
||||||
|
return `${a.startAge}–${a.endAge}`;
|
||||||
|
}
|
||||||
|
return computed.ages
|
||||||
|
.map((a) => `${a.role === "PERSON_A" ? "A" : "B"} ${a.startAge}–${a.endAge}`)
|
||||||
|
.join(" · ");
|
||||||
|
}
|
||||||
|
|
||||||
export function PhaseCard({
|
export function PhaseCard({
|
||||||
household,
|
household,
|
||||||
phase,
|
phase,
|
||||||
computed,
|
computed,
|
||||||
|
isFirst,
|
||||||
isLast,
|
isLast,
|
||||||
onChanged,
|
onChanged,
|
||||||
}: {
|
}: {
|
||||||
household: HouseholdInput;
|
household: HouseholdInput;
|
||||||
phase: PhaseInput;
|
phase: PhaseInput;
|
||||||
computed: PhaseComputed;
|
computed: PhaseComputed;
|
||||||
|
isFirst: boolean;
|
||||||
isLast: boolean;
|
isLast: boolean;
|
||||||
onChanged: () => void;
|
onChanged: () => void;
|
||||||
}) {
|
}) {
|
||||||
@@ -48,15 +63,19 @@ export function PhaseCard({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setExpanded((v) => !v)}
|
onClick={() => setExpanded((v) => !v)}
|
||||||
className="flex w-full items-center gap-4 px-4 py-3 text-left hover:bg-zinc-50 dark:hover:bg-zinc-800/50"
|
className="flex w-full items-center gap-3 px-4 py-3 text-left hover:bg-zinc-50 sm:gap-4 dark:hover:bg-zinc-800/50"
|
||||||
>
|
>
|
||||||
<span className="text-indigo-500 dark:text-indigo-400">
|
<span className="text-indigo-500 dark:text-indigo-400">
|
||||||
{expanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
{expanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-baseline gap-2">
|
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
|
||||||
<span className="font-medium text-zinc-900 dark:text-zinc-100">{phase.name}</span>
|
<span className="font-medium text-zinc-900 dark:text-zinc-100">{phase.name}</span>
|
||||||
<span className="text-xs text-zinc-500">{phase.durationYears} Jahre</span>
|
<span className="text-xs text-zinc-500">{phase.durationYears} Jahre</span>
|
||||||
|
<span className="flex items-center gap-1 text-xs text-indigo-600 dark:text-indigo-400">
|
||||||
|
<Users className="h-3 w-3" />
|
||||||
|
Alter {formatAges(computed)}
|
||||||
|
</span>
|
||||||
{computed.savingsWarning && (
|
{computed.savingsWarning && (
|
||||||
<span className="flex items-center gap-1 text-xs text-amber-600 dark:text-amber-400">
|
<span className="flex items-center gap-1 text-xs text-amber-600 dark:text-amber-400">
|
||||||
<AlertTriangle className="h-3 w-3" /> Sparquote ueberschritten
|
<AlertTriangle className="h-3 w-3" /> Sparquote ueberschritten
|
||||||
@@ -67,7 +86,7 @@ export function PhaseCard({
|
|||||||
Start {formatChf(computed.startWealthNominal)} CHF → Ende {formatChf(computed.endWealthNominal)} CHF (nominal)
|
Start {formatChf(computed.startWealthNominal)} CHF → Ende {formatChf(computed.endWealthNominal)} CHF (nominal)
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-8 w-24">
|
<div className="hidden h-8 w-24 sm:block">
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<LineChart data={sparklineData}>
|
<LineChart data={sparklineData}>
|
||||||
<Line type="monotone" dataKey="value" stroke="#4f46e5" strokeWidth={1.5} dot={false} />
|
<Line type="monotone" dataKey="value" stroke="#4f46e5" strokeWidth={1.5} dot={false} />
|
||||||
@@ -91,9 +110,10 @@ export function PhaseCard({
|
|||||||
</button>
|
</button>
|
||||||
{expanded && (
|
{expanded && (
|
||||||
<PhaseForm
|
<PhaseForm
|
||||||
key={`${phase.id}:${phase.securities.length}:${phase.incomingCapital}`}
|
key={`${phase.id}:${phase.securities.length}:${phase.realEstates.length}:${phase.incomingCapital}`}
|
||||||
household={household}
|
household={household}
|
||||||
phase={phase}
|
phase={phase}
|
||||||
|
isFirstPhase={isFirst}
|
||||||
onSaved={() => {
|
onSaved={() => {
|
||||||
onChanged();
|
onChanged();
|
||||||
}}
|
}}
|
||||||
|
|||||||
+169
-111
@@ -4,11 +4,12 @@ import { useState } from "react";
|
|||||||
import {
|
import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronRight,
|
||||||
Gift,
|
Gift,
|
||||||
Home,
|
Home,
|
||||||
Plus,
|
Plus,
|
||||||
PiggyBank,
|
PiggyBank,
|
||||||
ShoppingCart,
|
|
||||||
TrendingUp,
|
TrendingUp,
|
||||||
Wallet,
|
Wallet,
|
||||||
X,
|
X,
|
||||||
@@ -47,11 +48,12 @@ function personLabel(household: HouseholdInput, personId: string | null) {
|
|||||||
interface Props {
|
interface Props {
|
||||||
household: HouseholdInput;
|
household: HouseholdInput;
|
||||||
phase: PhaseInput;
|
phase: PhaseInput;
|
||||||
|
isFirstPhase: boolean;
|
||||||
onSaved: () => void;
|
onSaved: () => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PhaseForm({ household, phase, onSaved, onCancel }: Props) {
|
export function PhaseForm({ household, phase, isFirstPhase, onSaved, onCancel }: Props) {
|
||||||
const [name, setName] = useState(phase.name);
|
const [name, setName] = useState(phase.name);
|
||||||
const [durationYears, setDurationYears] = useState(phase.durationYears);
|
const [durationYears, setDurationYears] = useState(phase.durationYears);
|
||||||
const [inflationRate, setInflationRate] = useState<number | null>(phase.inflationRate);
|
const [inflationRate, setInflationRate] = useState<number | null>(phase.inflationRate);
|
||||||
@@ -71,11 +73,10 @@ export function PhaseForm({ household, phase, onSaved, onCancel }: Props) {
|
|||||||
const totalExpense = expenseEntries.reduce((s, e) => s + e.amount, 0);
|
const totalExpense = expenseEntries.reduce((s, e) => s + e.amount, 0);
|
||||||
const savingsQuota = totalIncome - totalExpense;
|
const savingsQuota = totalIncome - totalExpense;
|
||||||
// Amortisationsraten aller Immobilien zaehlen gleichwertig mit den Sparbeitraegen der
|
// Amortisationsraten aller Immobilien zaehlen gleichwertig mit den Sparbeitraegen der
|
||||||
// Wertschriften gegen dieselbe verfuegbare Sparquote.
|
// Wertschriften gegen dieselbe verfuegbare Sparquote (ein gemeinsamer Topf).
|
||||||
const allocated =
|
const allocated =
|
||||||
securities.reduce((s, sec) => s + sec.annualContribution, 0) +
|
securities.reduce((s, sec) => s + sec.annualContribution, 0) +
|
||||||
realEstates.reduce((s, re) => s + re.amortization, 0);
|
realEstates.reduce((s, re) => s + re.amortization, 0);
|
||||||
const overAllocated = allocated > savingsQuota;
|
|
||||||
const savingsRemaining = savingsQuota - allocated > 0.5;
|
const savingsRemaining = savingsQuota - allocated > 0.5;
|
||||||
|
|
||||||
const allocatedStartCapital = securities.reduce(
|
const allocatedStartCapital = securities.reduce(
|
||||||
@@ -84,6 +85,19 @@ export function PhaseForm({ household, phase, onSaved, onCancel }: Props) {
|
|||||||
);
|
);
|
||||||
const startCapitalRemaining = phase.incomingCapital - allocatedStartCapital > 0.5;
|
const startCapitalRemaining = phase.incomingCapital - allocatedStartCapital > 0.5;
|
||||||
|
|
||||||
|
// Live-Kappung: pro Feld das noch verfuegbare Budget (eigener Anteil zaehlt nicht
|
||||||
|
// gegen sich selbst, damit man einen bestehenden Wert wieder erhoehen/senken kann).
|
||||||
|
function maxContributionFor(current: number): number {
|
||||||
|
return Math.max(0, savingsQuota - (allocated - current));
|
||||||
|
}
|
||||||
|
function maxStartValueFor(sec: SecurityInput): number | undefined {
|
||||||
|
// In der ersten Phase wird der Ist-Bestand frei erfasst -- kein Limit.
|
||||||
|
if (isFirstPhase) return undefined;
|
||||||
|
const ownExtra = Math.max(0, sec.startValue - sec.carriedBaseValue);
|
||||||
|
const remaining = Math.max(0, phase.incomingCapital - (allocatedStartCapital - ownExtra));
|
||||||
|
return sec.carriedBaseValue + remaining;
|
||||||
|
}
|
||||||
|
|
||||||
async function handleSave() {
|
async function handleSave() {
|
||||||
const missingPurchasePrice = realEstates.find((re) => !re.purchasePrice || re.purchasePrice <= 0);
|
const missingPurchasePrice = realEstates.find((re) => !re.purchasePrice || re.purchasePrice <= 0);
|
||||||
if (missingPurchasePrice) {
|
if (missingPurchasePrice) {
|
||||||
@@ -120,10 +134,10 @@ export function PhaseForm({ household, phase, onSaved, onCancel }: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6 border-t border-zinc-200 p-4 dark:border-zinc-700">
|
<div className="flex flex-col gap-5 border-t border-zinc-200 p-4 dark:border-zinc-800">
|
||||||
{/* Basis */}
|
{/* Basis-Kopfzeile */}
|
||||||
<section className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
<section className="grid grid-cols-2 gap-3 lg:grid-cols-4">
|
||||||
<div className="col-span-2 sm:col-span-2">
|
<div className="col-span-2 lg:col-span-1">
|
||||||
<TextField
|
<TextField
|
||||||
label="Bezeichnung der Lebensphase"
|
label="Bezeichnung der Lebensphase"
|
||||||
help="Ein frei waehlbarer Name, z. B. 'Kinder zuhause' oder 'Fruehpensionierung'."
|
help="Ein frei waehlbarer Name, z. B. 'Kinder zuhause' oder 'Fruehpensionierung'."
|
||||||
@@ -139,7 +153,7 @@ export function PhaseForm({ household, phase, onSaved, onCancel }: Props) {
|
|||||||
onChange={setDurationYears}
|
onChange={setDurationYears}
|
||||||
/>
|
/>
|
||||||
<NumberField
|
<NumberField
|
||||||
label="Inflationsrate dieser Phase (%)"
|
label="Inflationsrate (%)"
|
||||||
help="Ueberschreibt fuer diese Phase die im Grundprofil hinterlegte Standardannahme."
|
help="Ueberschreibt fuer diese Phase die im Grundprofil hinterlegte Standardannahme."
|
||||||
value={inflationRate ?? household.inflationRateDefault}
|
value={inflationRate ?? household.inflationRateDefault}
|
||||||
step={0.1}
|
step={0.1}
|
||||||
@@ -159,10 +173,18 @@ export function PhaseForm({ household, phase, onSaved, onCancel }: Props) {
|
|||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Einkommen */}
|
{/* Matrix: Kategorien als Spalten */}
|
||||||
<Section title="Einkommen" icon={<Wallet className="h-4 w-4" />}>
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-5">
|
||||||
|
{/* Einkommen & Ausgaben */}
|
||||||
|
<CollapsibleColumn
|
||||||
|
title="Einkommen & Ausgaben"
|
||||||
|
icon={<Wallet className="h-4 w-4" />}
|
||||||
|
summary={`${formatChf(totalIncome)} / ${formatChf(totalExpense)}`}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="text-[11px] font-semibold uppercase tracking-wide text-zinc-400">Einkommen</div>
|
||||||
{incomeEntries.map((entry, i) => (
|
{incomeEntries.map((entry, i) => (
|
||||||
<div key={entry.id} className="grid grid-cols-[1fr_1fr_auto] items-end gap-2">
|
<EntryCard key={entry.id} onRemove={() => setIncomeEntries((prev) => prev.filter((_, idx) => idx !== i))}>
|
||||||
{incomeMode === "PER_PERSON" ? (
|
{incomeMode === "PER_PERSON" ? (
|
||||||
<SelectField
|
<SelectField
|
||||||
label="Person"
|
label="Person"
|
||||||
@@ -185,15 +207,14 @@ export function PhaseForm({ household, phase, onSaved, onCancel }: Props) {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<MoneyField
|
<MoneyField
|
||||||
label="Geschaetztes Jahreseinkommen (CHF)"
|
label="Jahreseinkommen (CHF)"
|
||||||
help="Ihr erwartetes Bruttoeinkommen pro Jahr waehrend dieser Lebensphase."
|
help="Ihr erwartetes Bruttoeinkommen pro Jahr waehrend dieser Lebensphase."
|
||||||
value={entry.amount}
|
value={entry.amount}
|
||||||
onChange={(v) =>
|
onChange={(v) =>
|
||||||
setIncomeEntries((prev) => prev.map((e, idx) => (idx === i ? { ...e, amount: v } : e)))
|
setIncomeEntries((prev) => prev.map((e, idx) => (idx === i ? { ...e, amount: v } : e)))
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<RemoveButton onClick={() => setIncomeEntries((prev) => prev.filter((_, idx) => idx !== i))} />
|
</EntryCard>
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
<AddButton
|
<AddButton
|
||||||
label="Einkommensposten"
|
label="Einkommensposten"
|
||||||
@@ -204,37 +225,36 @@ export function PhaseForm({ household, phase, onSaved, onCancel }: Props) {
|
|||||||
])
|
])
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</Section>
|
|
||||||
|
|
||||||
{/* Ausgaben */}
|
<div className="mt-2 text-[11px] font-semibold uppercase tracking-wide text-zinc-400">Ausgaben</div>
|
||||||
<Section title="Ausgaben" icon={<ShoppingCart className="h-4 w-4" />}>
|
|
||||||
{expenseEntries.map((entry, i) => (
|
{expenseEntries.map((entry, i) => (
|
||||||
<div key={entry.id} className="grid grid-cols-[1fr_auto] items-end gap-2">
|
<EntryCard key={entry.id} onRemove={() => setExpenseEntries((prev) => prev.filter((_, idx) => idx !== i))}>
|
||||||
<MoneyField
|
<MoneyField
|
||||||
label="Geschaetzte Gesamtausgaben (CHF/Jahr)"
|
label="Gesamtausgaben (CHF/Jahr)"
|
||||||
help="Saemtliche laufenden Kosten des Haushalts pro Jahr - inkl. Lebenshaltung, Hypothekarzinsen, laufende Einkommens- und Vermoegenssteuern. Keine separate Kategorisierung noetig."
|
help="Saemtliche laufenden Kosten des Haushalts pro Jahr - inkl. Lebenshaltung, Hypothekarzinsen, laufende Einkommens- und Vermoegenssteuern."
|
||||||
value={entry.amount}
|
value={entry.amount}
|
||||||
onChange={(v) =>
|
onChange={(v) =>
|
||||||
setExpenseEntries((prev) => prev.map((e, idx) => (idx === i ? { ...e, amount: v } : e)))
|
setExpenseEntries((prev) => prev.map((e, idx) => (idx === i ? { ...e, amount: v } : e)))
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<RemoveButton onClick={() => setExpenseEntries((prev) => prev.filter((_, idx) => idx !== i))} />
|
</EntryCard>
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
<AddButton
|
<AddButton
|
||||||
label="Ausgabenposten"
|
label="Ausgabenposten"
|
||||||
onClick={() => setExpenseEntries((prev) => [...prev, { id: tempId(), label: null, amount: 0 }])}
|
onClick={() => setExpenseEntries((prev) => [...prev, { id: tempId(), label: null, amount: 0 }])}
|
||||||
/>
|
/>
|
||||||
<div className="rounded-xl bg-indigo-50/60 px-3 py-2 text-sm text-zinc-600 dark:bg-indigo-500/10 dark:text-zinc-300">
|
|
||||||
Verfuegbare Sparquote (CHF/Jahr): <strong>{formatChf(savingsQuota)}</strong>
|
|
||||||
{" "}(Details und Verteilung siehe Wertschriften weiter unten)
|
|
||||||
</div>
|
</div>
|
||||||
</Section>
|
</CollapsibleColumn>
|
||||||
|
|
||||||
{/* Wertschriften */}
|
{/* Wertschriften */}
|
||||||
<Section title="Wertschriften" icon={<TrendingUp className="h-4 w-4" />}>
|
<CollapsibleColumn
|
||||||
|
title="Wertschriften"
|
||||||
|
icon={<TrendingUp className="h-4 w-4" />}
|
||||||
|
summary={`${securities.length}`}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
{securities.map((s, i) => (
|
{securities.map((s, i) => (
|
||||||
<div key={s.id} className="grid grid-cols-2 gap-2 rounded-xl border border-zinc-100 bg-zinc-50/60 p-3 sm:grid-cols-5 dark:border-zinc-800 dark:bg-zinc-800/30">
|
<EntryCard key={s.id} onRemove={() => setSecurities((prev) => prev.filter((_, idx) => idx !== i))}>
|
||||||
<TextField
|
<TextField
|
||||||
label="Name"
|
label="Name"
|
||||||
help="Frei waehlbare Bezeichnung, z. B. 'Globaler ETF' oder 'Schweizer Aktien'."
|
help="Frei waehlbare Bezeichnung, z. B. 'Globaler ETF' oder 'Schweizer Aktien'."
|
||||||
@@ -243,25 +263,29 @@ export function PhaseForm({ household, phase, onSaved, onCancel }: Props) {
|
|||||||
/>
|
/>
|
||||||
<MoneyField
|
<MoneyField
|
||||||
label="Startwert (CHF)"
|
label="Startwert (CHF)"
|
||||||
help="Wert dieser Position zu Beginn der Phase."
|
help={
|
||||||
|
isFirstPhase
|
||||||
|
? "Wert dieser Position zu Beginn der Phase."
|
||||||
|
: "Wert zu Beginn der Phase. Erhoehungen gegenueber dem uebernommenen Wert werden vom verfuegbaren Startkapital abgezogen."
|
||||||
|
}
|
||||||
value={s.startValue}
|
value={s.startValue}
|
||||||
|
max={maxStartValueFor(s)}
|
||||||
onChange={(v) => setSecurities((prev) => prev.map((x, idx) => (idx === i ? { ...x, startValue: v } : x)))}
|
onChange={(v) => setSecurities((prev) => prev.map((x, idx) => (idx === i ? { ...x, startValue: v } : x)))}
|
||||||
/>
|
/>
|
||||||
<NumberField
|
<NumberField
|
||||||
label="Erwartete Rendite (%/Jahr)"
|
label="Rendite (%/Jahr)"
|
||||||
help="Ihre Annahme zur durchschnittlichen jaehrlichen Wertentwicklung dieser Anlage."
|
help="Ihre Annahme zur durchschnittlichen jaehrlichen Wertentwicklung dieser Anlage."
|
||||||
step={0.1}
|
step={0.1}
|
||||||
value={s.expectedReturn}
|
value={s.expectedReturn}
|
||||||
onChange={(v) => setSecurities((prev) => prev.map((x, idx) => (idx === i ? { ...x, expectedReturn: v } : x)))}
|
onChange={(v) => setSecurities((prev) => prev.map((x, idx) => (idx === i ? { ...x, expectedReturn: v } : x)))}
|
||||||
/>
|
/>
|
||||||
<MoneyField
|
<MoneyField
|
||||||
label="Jaehrlicher Sparbeitrag (CHF)"
|
label="Sparbeitrag (CHF/Jahr)"
|
||||||
help="Der Betrag aus Ihrer verfuegbaren Sparquote, den Sie jaehrlich in diese Position investieren moechten."
|
help="Der Betrag aus Ihrer verfuegbaren Sparquote, den Sie jaehrlich in diese Position investieren. Wird automatisch auf die verbleibende Sparquote begrenzt."
|
||||||
value={s.annualContribution}
|
value={s.annualContribution}
|
||||||
|
max={maxContributionFor(s.annualContribution)}
|
||||||
onChange={(v) => setSecurities((prev) => prev.map((x, idx) => (idx === i ? { ...x, annualContribution: v } : x)))}
|
onChange={(v) => setSecurities((prev) => prev.map((x, idx) => (idx === i ? { ...x, annualContribution: v } : x)))}
|
||||||
/>
|
/>
|
||||||
<div className="flex items-end gap-2">
|
|
||||||
<div className="flex-1">
|
|
||||||
<SelectField
|
<SelectField
|
||||||
label="Gehoert zu"
|
label="Gehoert zu"
|
||||||
help="Rein informativ: Person A, Person B oder gemeinsam. Hat keinen Einfluss auf die Berechnung."
|
help="Rein informativ: Person A, Person B oder gemeinsam. Hat keinen Einfluss auf die Berechnung."
|
||||||
@@ -273,10 +297,7 @@ export function PhaseForm({ household, phase, onSaved, onCancel }: Props) {
|
|||||||
{ value: "PERSON_B", label: "Person B" },
|
{ value: "PERSON_B", label: "Person B" },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</div>
|
</EntryCard>
|
||||||
<RemoveButton onClick={() => setSecurities((prev) => prev.filter((_, idx) => idx !== i))} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
<AddButton
|
<AddButton
|
||||||
label="Wertschrift"
|
label="Wertschrift"
|
||||||
@@ -296,30 +317,14 @@ export function PhaseForm({ household, phase, onSaved, onCancel }: Props) {
|
|||||||
])
|
])
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<div className="flex flex-col gap-1.5 rounded-xl bg-indigo-50/60 px-3 py-2 text-sm dark:bg-indigo-500/10">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<StatusDot ok={!startCapitalRemaining} />
|
|
||||||
Verfuegbares Startkapital (aus Verkaeufen der Vorphase): <strong>{formatChf(phase.incomingCapital)}</strong> CHF
|
|
||||||
{" "}— zugewiesen: {formatChf(allocatedStartCapital)}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
</CollapsibleColumn>
|
||||||
<StatusDot ok={!savingsRemaining} />
|
|
||||||
Verfuegbare Sparquote (CHF/Jahr): <strong>{formatChf(savingsQuota)}</strong>
|
|
||||||
{" "}— zugewiesen: {formatChf(allocated)}
|
|
||||||
</div>
|
|
||||||
{overAllocated && (
|
|
||||||
<div className="flex items-center gap-1.5 text-amber-700 dark:text-amber-400">
|
|
||||||
<AlertTriangle className="h-3.5 w-3.5 shrink-0" />
|
|
||||||
Die zugewiesenen Sparbeitraege uebersteigen die verfuegbare Sparquote.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
{/* Immobilien */}
|
{/* Immobilien */}
|
||||||
<Section title="Immobilien" icon={<Home className="h-4 w-4" />}>
|
<CollapsibleColumn title="Immobilien" icon={<Home className="h-4 w-4" />} summary={`${realEstates.length}`}>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
{realEstates.map((re, i) => (
|
{realEstates.map((re, i) => (
|
||||||
<div key={re.id} className="grid grid-cols-2 gap-2 rounded-xl border border-zinc-100 bg-zinc-50/60 p-3 sm:grid-cols-4 dark:border-zinc-800 dark:bg-zinc-800/30">
|
<EntryCard key={re.id} onRemove={() => setRealEstates((prev) => prev.filter((_, idx) => idx !== i))}>
|
||||||
<TextField
|
<TextField
|
||||||
label="Bezeichnung"
|
label="Bezeichnung"
|
||||||
help="Z. B. 'Eigenheim' oder 'Ferienwohnung'."
|
help="Z. B. 'Eigenheim' oder 'Ferienwohnung'."
|
||||||
@@ -328,7 +333,7 @@ export function PhaseForm({ household, phase, onSaved, onCancel }: Props) {
|
|||||||
/>
|
/>
|
||||||
<MoneyField
|
<MoneyField
|
||||||
label="Kaufpreis (CHF)"
|
label="Kaufpreis (CHF)"
|
||||||
help="Pflichtfeld. Der Kaufpreis bleibt ueber die ganze Haltedauer fix -- es wird keine Wertsteigerung angenommen, nur die Hypothek sinkt durch Amortisation."
|
help="Pflichtfeld. Der Kaufpreis bleibt ueber die ganze Haltedauer fix -- nur die Hypothek sinkt durch Amortisation."
|
||||||
value={re.purchasePrice}
|
value={re.purchasePrice}
|
||||||
onChange={(v) => setRealEstates((prev) => prev.map((x, idx) => (idx === i ? { ...x, purchasePrice: v } : x)))}
|
onChange={(v) => setRealEstates((prev) => prev.map((x, idx) => (idx === i ? { ...x, purchasePrice: v } : x)))}
|
||||||
/>
|
/>
|
||||||
@@ -339,15 +344,13 @@ export function PhaseForm({ household, phase, onSaved, onCancel }: Props) {
|
|||||||
onChange={(v) => setRealEstates((prev) => prev.map((x, idx) => (idx === i ? { ...x, mortgage: v } : x)))}
|
onChange={(v) => setRealEstates((prev) => prev.map((x, idx) => (idx === i ? { ...x, mortgage: v } : x)))}
|
||||||
/>
|
/>
|
||||||
<MoneyField
|
<MoneyField
|
||||||
label="Amortisationsrate (CHF/Jahr)"
|
label="Amortisation (CHF/Jahr)"
|
||||||
help="Betrag, um den die Hypothek pro Jahr reduziert wird. Zaehlt zusammen mit den Sparbeitraegen der Wertschriften gegen die verfuegbare Sparquote."
|
help="Betrag, um den die Hypothek pro Jahr reduziert wird. Zaehlt zusammen mit den Sparbeitraegen gegen die verfuegbare Sparquote."
|
||||||
value={re.amortization}
|
value={re.amortization}
|
||||||
|
max={maxContributionFor(re.amortization)}
|
||||||
onChange={(v) => setRealEstates((prev) => prev.map((x, idx) => (idx === i ? { ...x, amortization: v } : x)))}
|
onChange={(v) => setRealEstates((prev) => prev.map((x, idx) => (idx === i ? { ...x, amortization: v } : x)))}
|
||||||
/>
|
/>
|
||||||
<div className="flex items-end">
|
</EntryCard>
|
||||||
<RemoveButton onClick={() => setRealEstates((prev) => prev.filter((_, idx) => idx !== i))} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
<AddButton
|
<AddButton
|
||||||
label="Immobilie"
|
label="Immobilie"
|
||||||
@@ -358,17 +361,25 @@ export function PhaseForm({ household, phase, onSaved, onCancel }: Props) {
|
|||||||
])
|
])
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</Section>
|
</div>
|
||||||
|
</CollapsibleColumn>
|
||||||
|
|
||||||
{/* Sondereinnahmen/-ausgaben */}
|
{/* Sondereinnahmen / -ausgaben */}
|
||||||
<Section title="Sondereinnahmen / -ausgaben" icon={<Gift className="h-4 w-4" />}>
|
<CollapsibleColumn
|
||||||
|
title="Sondereinnahmen / -ausgaben"
|
||||||
|
icon={<Gift className="h-4 w-4" />}
|
||||||
|
summary={`${oneTimeEvents.length}`}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
{oneTimeEvents.map((ev, i) => (
|
{oneTimeEvents.map((ev, i) => (
|
||||||
<div key={ev.id} className="grid grid-cols-[auto_1fr_2fr_auto] items-end gap-2">
|
<EntryCard key={ev.id} onRemove={() => setOneTimeEvents((prev) => prev.filter((_, idx) => idx !== i))}>
|
||||||
<SelectField
|
<SelectField
|
||||||
label="Art"
|
label="Art"
|
||||||
help="Einmalige Einnahme (z. B. Erbschaft) oder einmalige Ausgabe (z. B. Poolbau)."
|
help="Einmalige Einnahme (z. B. Erbschaft) oder einmalige Ausgabe (z. B. Poolbau)."
|
||||||
value={ev.type}
|
value={ev.type}
|
||||||
onChange={(v: OneTimeEventType) => setOneTimeEvents((prev) => prev.map((x, idx) => (idx === i ? { ...x, type: v } : x)))}
|
onChange={(v: OneTimeEventType) =>
|
||||||
|
setOneTimeEvents((prev) => prev.map((x, idx) => (idx === i ? { ...x, type: v } : x)))
|
||||||
|
}
|
||||||
options={[
|
options={[
|
||||||
{ value: "INCOME", label: "Einnahme" },
|
{ value: "INCOME", label: "Einnahme" },
|
||||||
{ value: "EXPENSE", label: "Ausgabe" },
|
{ value: "EXPENSE", label: "Ausgabe" },
|
||||||
@@ -382,21 +393,30 @@ export function PhaseForm({ household, phase, onSaved, onCancel }: Props) {
|
|||||||
<TextField
|
<TextField
|
||||||
label="Beschreibung"
|
label="Beschreibung"
|
||||||
value={ev.description ?? ""}
|
value={ev.description ?? ""}
|
||||||
onChange={(v) => setOneTimeEvents((prev) => prev.map((x, idx) => (idx === i ? { ...x, description: v || null } : x)))}
|
onChange={(v) =>
|
||||||
|
setOneTimeEvents((prev) => prev.map((x, idx) => (idx === i ? { ...x, description: v || null } : x)))
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<RemoveButton onClick={() => setOneTimeEvents((prev) => prev.filter((_, idx) => idx !== i))} />
|
</EntryCard>
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
<AddButton
|
<AddButton
|
||||||
label="Sondereinnahme/-ausgabe"
|
label="Sondereintrag"
|
||||||
onClick={() => setOneTimeEvents((prev) => [...prev, { id: tempId(), type: "EXPENSE", amount: 0, description: null }])}
|
onClick={() =>
|
||||||
|
setOneTimeEvents((prev) => [...prev, { id: tempId(), type: "EXPENSE", amount: 0, description: null }])
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</Section>
|
</div>
|
||||||
|
</CollapsibleColumn>
|
||||||
|
|
||||||
{/* Pensionierung */}
|
{/* Pensionierung */}
|
||||||
<Section title="Pensionierung (optional)" icon={<PiggyBank className="h-4 w-4" />}>
|
<CollapsibleColumn
|
||||||
|
title="Pensionierung"
|
||||||
|
icon={<PiggyBank className="h-4 w-4" />}
|
||||||
|
summary={`${retirementInfos.length}`}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
{retirementInfos.map((r, i) => (
|
{retirementInfos.map((r, i) => (
|
||||||
<div key={r.id} className="grid grid-cols-2 gap-2 rounded-xl border border-zinc-100 bg-zinc-50/60 p-3 sm:grid-cols-4 dark:border-zinc-800 dark:bg-zinc-800/30">
|
<EntryCard key={r.id} onRemove={() => setRetirementInfos((prev) => prev.filter((_, idx) => idx !== i))}>
|
||||||
<SelectField
|
<SelectField
|
||||||
label="Person"
|
label="Person"
|
||||||
value={r.personId}
|
value={r.personId}
|
||||||
@@ -413,24 +433,27 @@ export function PhaseForm({ household, phase, onSaved, onCancel }: Props) {
|
|||||||
label="PK-Rente (CHF/Jahr)"
|
label="PK-Rente (CHF/Jahr)"
|
||||||
help="Pensionskassenrente (2. Saeule)."
|
help="Pensionskassenrente (2. Saeule)."
|
||||||
value={r.pkPensionAmount}
|
value={r.pkPensionAmount}
|
||||||
onChange={(v) => setRetirementInfos((prev) => prev.map((x, idx) => (idx === i ? { ...x, pkPensionAmount: v } : x)))}
|
onChange={(v) =>
|
||||||
|
setRetirementInfos((prev) => prev.map((x, idx) => (idx === i ? { ...x, pkPensionAmount: v } : x)))
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<MoneyField
|
<MoneyField
|
||||||
label="Kapitalbezug brutto (CHF)"
|
label="Kapitalbezug brutto (CHF)"
|
||||||
help="Zusammengesetzt aus Saeule 3a und/oder Kapitalbezug aus der Pensionskasse."
|
help="Zusammengesetzt aus Saeule 3a und/oder Kapitalbezug aus der Pensionskasse."
|
||||||
value={r.lumpSumAmount}
|
value={r.lumpSumAmount}
|
||||||
onChange={(v) => setRetirementInfos((prev) => prev.map((x, idx) => (idx === i ? { ...x, lumpSumAmount: v } : x)))}
|
onChange={(v) =>
|
||||||
|
setRetirementInfos((prev) => prev.map((x, idx) => (idx === i ? { ...x, lumpSumAmount: v } : x)))
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<NumberField
|
<NumberField
|
||||||
label="Geschaetzte Kapitalbezugssteuer (%)"
|
label="Kapitalbezugssteuer (%)"
|
||||||
help="Realistische Bandbreite: ca. 3-15% des Bruttobetrags."
|
help="Realistische Bandbreite: ca. 3-15% des Bruttobetrags."
|
||||||
value={r.lumpSumTaxRate}
|
value={r.lumpSumTaxRate}
|
||||||
onChange={(v) => setRetirementInfos((prev) => prev.map((x, idx) => (idx === i ? { ...x, lumpSumTaxRate: v } : x)))}
|
onChange={(v) =>
|
||||||
|
setRetirementInfos((prev) => prev.map((x, idx) => (idx === i ? { ...x, lumpSumTaxRate: v } : x)))
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<div className="flex items-end">
|
</EntryCard>
|
||||||
<RemoveButton onClick={() => setRetirementInfos((prev) => prev.filter((_, idx) => idx !== i))} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
<AddButton
|
<AddButton
|
||||||
label="Pensionierungsangaben"
|
label="Pensionierungsangaben"
|
||||||
@@ -448,7 +471,25 @@ export function PhaseForm({ household, phase, onSaved, onCancel }: Props) {
|
|||||||
])
|
])
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</Section>
|
</div>
|
||||||
|
</CollapsibleColumn>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Budget-Status */}
|
||||||
|
<div className="flex flex-col gap-1.5 rounded-xl bg-indigo-50/60 px-3 py-2 text-sm dark:bg-indigo-500/10">
|
||||||
|
{!isFirstPhase && (
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<StatusDot ok={!startCapitalRemaining} />
|
||||||
|
Verfuegbares Startkapital (aus Verkaeufen der Vorphase): <strong>{formatChf(phase.incomingCapital)}</strong> CHF
|
||||||
|
{" "}— zugewiesen: {formatChf(allocatedStartCapital)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<StatusDot ok={!savingsRemaining} />
|
||||||
|
Verfuegbare Sparquote (CHF/Jahr): <strong>{formatChf(savingsQuota)}</strong>
|
||||||
|
{" "}— zugewiesen: {formatChf(allocated)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<p className="flex items-center gap-1.5 text-sm text-red-600 dark:text-red-400">
|
<p className="flex items-center gap-1.5 text-sm text-red-600 dark:text-red-400">
|
||||||
@@ -478,26 +519,59 @@ export function PhaseForm({ household, phase, onSaved, onCancel }: Props) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Section({
|
// Eine einklappbare Kategorien-Spalte der Matrix (Phase x Kategorie).
|
||||||
|
function CollapsibleColumn({
|
||||||
title,
|
title,
|
||||||
icon,
|
icon,
|
||||||
|
summary,
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
title: string;
|
title: string;
|
||||||
icon: React.ReactNode;
|
icon: React.ReactNode;
|
||||||
|
summary?: string;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
|
const [open, setOpen] = useState(true);
|
||||||
return (
|
return (
|
||||||
<section className="flex flex-col gap-2">
|
<section className="flex flex-col self-start rounded-xl border border-zinc-100 bg-zinc-50/60 dark:border-zinc-800 dark:bg-zinc-800/30">
|
||||||
<h4 className="flex items-center gap-1.5 text-sm font-semibold text-zinc-800 dark:text-zinc-100">
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
className="flex w-full items-center gap-1.5 px-3 py-2.5 text-left"
|
||||||
|
>
|
||||||
<span className="text-indigo-500 dark:text-indigo-400">{icon}</span>
|
<span className="text-indigo-500 dark:text-indigo-400">{icon}</span>
|
||||||
{title}
|
<span className="flex-1 text-sm font-semibold text-zinc-800 dark:text-zinc-100">{title}</span>
|
||||||
</h4>
|
{summary != null && (
|
||||||
{children}
|
<span className="rounded-full bg-indigo-100 px-2 py-0.5 text-[11px] font-medium text-indigo-700 dark:bg-indigo-500/20 dark:text-indigo-300">
|
||||||
|
{summary}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="text-zinc-400">
|
||||||
|
{open ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{open && <div className="px-3 pb-3">{children}</div>}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Kompakte Karte fuer einen einzelnen Eintrag (Felder vertikal gestapelt).
|
||||||
|
function EntryCard({ children, onRemove }: { children: React.ReactNode; onRemove: () => void }) {
|
||||||
|
return (
|
||||||
|
<div className="relative flex flex-col gap-2 rounded-lg border border-zinc-200/70 bg-white p-2.5 pr-8 shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onRemove}
|
||||||
|
aria-label="Entfernen"
|
||||||
|
className="absolute right-1.5 top-1.5 rounded-md p-1 text-zinc-400 hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-950"
|
||||||
|
>
|
||||||
|
<X className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function AddButton({ label, onClick }: { label: string; onClick: () => void }) {
|
function AddButton({ label, onClick }: { label: string; onClick: () => void }) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -513,24 +587,8 @@ function AddButton({ label, onClick }: { label: string; onClick: () => void }) {
|
|||||||
|
|
||||||
function StatusDot({ ok }: { ok: boolean }) {
|
function StatusDot({ ok }: { ok: boolean }) {
|
||||||
return ok ? (
|
return ok ? (
|
||||||
<CheckCircle2
|
<CheckCircle2 className="h-4 w-4 shrink-0 text-emerald-500" aria-label="Vollstaendig verteilt" />
|
||||||
className="h-4 w-4 shrink-0 text-emerald-500"
|
|
||||||
aria-label="Vollstaendig verteilt"
|
|
||||||
/>
|
|
||||||
) : (
|
) : (
|
||||||
<XCircle className="h-4 w-4 shrink-0 text-red-500" aria-label="Noch nicht vollstaendig verteilt" />
|
<XCircle className="h-4 w-4 shrink-0 text-red-500" aria-label="Noch nicht vollstaendig verteilt" />
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function RemoveButton({ onClick }: { onClick: () => void }) {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onClick}
|
|
||||||
aria-label="Entfernen"
|
|
||||||
className="rounded-lg border border-zinc-300 px-2 py-1.5 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"
|
|
||||||
>
|
|
||||||
<X className="h-3.5 w-3.5" />
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,185 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { KeyRound, LogOut, Settings, UserCircle2 } from "lucide-react";
|
||||||
|
import { api } from "@/lib/api-client";
|
||||||
|
|
||||||
|
export function ProfileMenu({
|
||||||
|
username,
|
||||||
|
onOpenHouseholdSettings,
|
||||||
|
}: {
|
||||||
|
username: string;
|
||||||
|
onOpenHouseholdSettings: () => void;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [showPasswordDialog, setShowPasswordDialog] = useState(false);
|
||||||
|
const menuRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function handleClickOutside(e: MouseEvent) {
|
||||||
|
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener("mousedown", handleClickOutside);
|
||||||
|
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative" ref={menuRef}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<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">
|
||||||
|
{username.slice(0, 2)}
|
||||||
|
</span>
|
||||||
|
<span className="hidden font-medium text-zinc-700 sm:inline dark:text-zinc-200">{username}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{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="border-b border-zinc-100 px-4 py-3 dark:border-zinc-700">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<UserCircle2 className="h-4 w-4 text-indigo-500 dark:text-indigo-400" />
|
||||||
|
<span className="text-sm font-medium text-zinc-800 dark:text-zinc-100">{username}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<MenuItem
|
||||||
|
icon={<Settings className="h-4 w-4" />}
|
||||||
|
label="Grundprofil bearbeiten"
|
||||||
|
onClick={() => {
|
||||||
|
setOpen(false);
|
||||||
|
onOpenHouseholdSettings();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<MenuItem
|
||||||
|
icon={<KeyRound className="h-4 w-4" />}
|
||||||
|
label="Passwort aendern"
|
||||||
|
onClick={() => {
|
||||||
|
setOpen(false);
|
||||||
|
setShowPasswordDialog(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<MenuItem
|
||||||
|
icon={<LogOut className="h-4 w-4" />}
|
||||||
|
label="Abmelden"
|
||||||
|
onClick={async () => {
|
||||||
|
await api.post("/api/auth/logout");
|
||||||
|
window.location.href = "/login";
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showPasswordDialog && <ChangePasswordDialog onClose={() => setShowPasswordDialog(false)} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenuItem({
|
||||||
|
icon,
|
||||||
|
label,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
icon: React.ReactNode;
|
||||||
|
label: string;
|
||||||
|
onClick: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChangePasswordDialog({ onClose }: { onClose: () => void }) {
|
||||||
|
const [currentPassword, setCurrentPassword] = useState("");
|
||||||
|
const [newPassword, setNewPassword] = useState("");
|
||||||
|
const [newPasswordConfirm, setNewPasswordConfirm] = useState("");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [done, setDone] = useState(false);
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
if (newPassword !== newPasswordConfirm) {
|
||||||
|
setError("Die neuen Passwoerter stimmen nicht ueberein.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await api.post("/api/auth/change-password", { currentPassword, newPassword });
|
||||||
|
setDone(true);
|
||||||
|
setTimeout(onClose, 1200);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Passwort aendern fehlgeschlagen.");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-40 flex items-center justify-center bg-black/40 px-4" onClick={onClose}>
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<h2 className="text-base font-semibold text-zinc-900 dark:text-zinc-50">Passwort aendern</h2>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
placeholder="Aktuelles Passwort"
|
||||||
|
autoComplete="current-password"
|
||||||
|
value={currentPassword}
|
||||||
|
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">
|
||||||
|
<button
|
||||||
|
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"}
|
||||||
|
</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>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { ArrowDown, CheckCircle2 } from "lucide-react";
|
import { ArrowDown, CheckCircle2, ChevronDown, ChevronRight } from "lucide-react";
|
||||||
import { MoneyInput } from "@/components/FormField";
|
import { MoneyInput } from "@/components/FormField";
|
||||||
import { api } from "@/lib/api-client";
|
import { api } from "@/lib/api-client";
|
||||||
import { floorToThousand, formatChf } from "@/lib/format";
|
import { floorToThousand, formatChf } from "@/lib/format";
|
||||||
@@ -36,6 +36,7 @@ export function TransitionPanel({
|
|||||||
}) {
|
}) {
|
||||||
const [items, setItems] = useState<ItemDraft[]>([]);
|
const [items, setItems] = useState<ItemDraft[]>([]);
|
||||||
const [loaded, setLoaded] = useState(false);
|
const [loaded, setLoaded] = useState(false);
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [saved, setSaved] = useState(false);
|
const [saved, setSaved] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -167,11 +168,23 @@ export function TransitionPanel({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-2 flex flex-col gap-3 rounded-xl border border-dashed border-indigo-200 bg-indigo-50/40 p-4 dark:border-indigo-500/30 dark:bg-indigo-500/5">
|
<div className="mx-2 flex flex-col rounded-xl border border-dashed border-indigo-200 bg-indigo-50/40 dark:border-indigo-500/30 dark:bg-indigo-500/5">
|
||||||
<div className="flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-indigo-600 dark:text-indigo-400">
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setExpanded((v) => !v)}
|
||||||
|
className="flex w-full flex-wrap items-center gap-1.5 px-4 py-2.5 text-left text-xs font-semibold uppercase tracking-wide text-indigo-600 dark:text-indigo-400"
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
{expanded ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
|
||||||
|
</span>
|
||||||
<ArrowDown className="h-3.5 w-3.5" />
|
<ArrowDown className="h-3.5 w-3.5" />
|
||||||
Uebergang → {nextPhaseName}
|
<span className="flex-1">Uebergang → {nextPhaseName}</span>
|
||||||
</div>
|
<span className="normal-case tracking-normal text-zinc-500 dark:text-zinc-400">
|
||||||
|
Startkapital aus Verkaeufen: <strong className="text-indigo-600 dark:text-indigo-400">{formatChf(totalAvailableCapital)} CHF</strong>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{expanded && (
|
||||||
|
<div className="flex flex-col gap-3 px-4 pb-4">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="text-left text-xs text-zinc-500">
|
<tr className="text-left text-xs text-zinc-500">
|
||||||
@@ -261,5 +274,7 @@ export function TransitionPanel({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-5
@@ -11,20 +11,21 @@ function getSecretKey() {
|
|||||||
return new TextEncoder().encode(secret);
|
return new TextEncoder().encode(secret);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createSessionToken(): Promise<string> {
|
export async function createSessionToken(userId: string): Promise<string> {
|
||||||
return new SignJWT({ auth: true })
|
return new SignJWT({ userId })
|
||||||
.setProtectedHeader({ alg: "HS256" })
|
.setProtectedHeader({ alg: "HS256" })
|
||||||
.setIssuedAt()
|
.setIssuedAt()
|
||||||
.setExpirationTime(SESSION_DURATION)
|
.setExpirationTime(SESSION_DURATION)
|
||||||
.sign(getSecretKey());
|
.sign(getSecretKey());
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function verifySessionToken(token: string): Promise<boolean> {
|
// Liefert die User-ID aus einem gueltigen Session-Token, sonst null.
|
||||||
|
export async function verifySessionToken(token: string): Promise<string | null> {
|
||||||
try {
|
try {
|
||||||
const { payload } = await jwtVerify(token, getSecretKey());
|
const { payload } = await jwtVerify(token, getSecretKey());
|
||||||
return payload.auth === true;
|
return typeof payload.userId === "string" ? payload.userId : null;
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+23
-2
@@ -59,6 +59,16 @@ export interface PhaseComputed {
|
|||||||
endWealthReal: number;
|
endWealthReal: number;
|
||||||
yearlyNominal: number[]; // Laenge durationYears, Werte am Ende von Jahr 1..durationYears
|
yearlyNominal: number[]; // Laenge durationYears, Werte am Ende von Jahr 1..durationYears
|
||||||
yearlyReal: number[];
|
yearlyReal: number[];
|
||||||
|
// Alter der Personen zu Beginn und am Ende dieser Phase (Grundprofil-Alter +
|
||||||
|
// kumulierte Dauer der Vorphasen).
|
||||||
|
ages: PersonAgeRange[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PersonAgeRange {
|
||||||
|
personId: string;
|
||||||
|
role: string; // PERSON_A | PERSON_B
|
||||||
|
startAge: number;
|
||||||
|
endAge: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PlanComputed {
|
export interface PlanComputed {
|
||||||
@@ -136,8 +146,16 @@ function computeRetirement(
|
|||||||
function computePhase(
|
function computePhase(
|
||||||
phase: PhaseInput,
|
phase: PhaseInput,
|
||||||
household: HouseholdInput,
|
household: HouseholdInput,
|
||||||
cumulativeInflationStart: number
|
cumulativeInflationStart: number,
|
||||||
|
yearsBeforePhase: number
|
||||||
): PhaseComputed {
|
): PhaseComputed {
|
||||||
|
const ages: PersonAgeRange[] = household.persons.map((p) => ({
|
||||||
|
personId: p.id,
|
||||||
|
role: p.role,
|
||||||
|
startAge: p.age + yearsBeforePhase,
|
||||||
|
endAge: p.age + yearsBeforePhase + phase.durationYears,
|
||||||
|
}));
|
||||||
|
|
||||||
const incomeFromEntries = phase.incomeEntries.reduce((sum, e) => sum + e.amount, 0);
|
const incomeFromEntries = phase.incomeEntries.reduce((sum, e) => sum + e.amount, 0);
|
||||||
const expenseTotal = phase.expenseEntries.reduce((sum, e) => sum + e.amount, 0);
|
const expenseTotal = phase.expenseEntries.reduce((sum, e) => sum + e.amount, 0);
|
||||||
const retirement = computeRetirement(household, phase);
|
const retirement = computeRetirement(household, phase);
|
||||||
@@ -241,6 +259,7 @@ function computePhase(
|
|||||||
endWealthReal: endWealthNominal / cumulativeInflationEnd,
|
endWealthReal: endWealthNominal / cumulativeInflationEnd,
|
||||||
yearlyNominal,
|
yearlyNominal,
|
||||||
yearlyReal,
|
yearlyReal,
|
||||||
|
ages,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -248,10 +267,12 @@ export function computePlan(plan: PlanInput, household: HouseholdInput): PlanCom
|
|||||||
const orderedPhases = [...plan.phases].sort((a, b) => a.sequenceNumber - b.sequenceNumber);
|
const orderedPhases = [...plan.phases].sort((a, b) => a.sequenceNumber - b.sequenceNumber);
|
||||||
|
|
||||||
let cumulativeInflation = 1;
|
let cumulativeInflation = 1;
|
||||||
|
let yearsBefore = 0;
|
||||||
const phases: PhaseComputed[] = [];
|
const phases: PhaseComputed[] = [];
|
||||||
for (const phase of orderedPhases) {
|
for (const phase of orderedPhases) {
|
||||||
const computed = computePhase(phase, household, cumulativeInflation);
|
const computed = computePhase(phase, household, cumulativeInflation, yearsBefore);
|
||||||
cumulativeInflation = computed.cumulativeInflationEnd;
|
cumulativeInflation = computed.cumulativeInflationEnd;
|
||||||
|
yearsBefore += phase.durationYears;
|
||||||
phases.push(computed);
|
phases.push(computed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
import bcrypt from "bcryptjs";
|
|
||||||
import { prisma } from "@/lib/db";
|
|
||||||
|
|
||||||
// Nur von API-Routes (Node.js-Runtime) verwendet -- niemals von middleware.ts
|
|
||||||
// importieren, da dort (Edge-Runtime) kein Datenbankzugriff moeglich ist.
|
|
||||||
|
|
||||||
// Es gibt genau eine AppCredential-Zeile. Solange keine existiert, ist die App
|
|
||||||
// "unconfigured" und der naechste Login-Versuch legt das Passwort fest.
|
|
||||||
export async function getAppCredential() {
|
|
||||||
return prisma.appCredential.findFirst();
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function setAppPassword(password: string) {
|
|
||||||
const existing = await getAppCredential();
|
|
||||||
if (existing) {
|
|
||||||
// Sollte durch die UI (Passwort-Setup nur beim ersten Login sichtbar) nicht
|
|
||||||
// vorkommen, wird aber sicherheitshalber serverseitig verhindert.
|
|
||||||
throw new Error("Es ist bereits ein Passwort gesetzt.");
|
|
||||||
}
|
|
||||||
const passwordHash = await bcrypt.hash(password, 12);
|
|
||||||
return prisma.appCredential.create({ data: { passwordHash } });
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function verifyAppPassword(password: string): Promise<boolean> {
|
|
||||||
const credential = await getAppCredential();
|
|
||||||
if (!credential) return false;
|
|
||||||
return bcrypt.compare(password, credential.passwordHash);
|
|
||||||
}
|
|
||||||
+18
-2
@@ -96,6 +96,22 @@ export function toPlanInput(plan: PlanWithRelations): PlanInput {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getHouseholdOrNull(): Promise<HouseholdWithPersons | null> {
|
// Liefert den Haushalt des eingeloggten Benutzers (pro Konto genau einer).
|
||||||
return prisma.household.findFirst({ include: { persons: true } });
|
export async function getHouseholdOrNull(userId: string): Promise<HouseholdWithPersons | null> {
|
||||||
|
return prisma.household.findFirst({ where: { userId }, include: { persons: true } });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Laedt einen Plan inkl. aller Phasen, aber nur wenn er dem Benutzer gehoert.
|
||||||
|
export async function getOwnedPlan(planId: string, userId: string) {
|
||||||
|
return prisma.plan.findFirst({
|
||||||
|
where: { id: planId, household: { userId } },
|
||||||
|
include: planInclude,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Laedt eine Phase (Basisdaten), aber nur wenn sie dem Benutzer gehoert.
|
||||||
|
export async function getOwnedPhase(phaseId: string, userId: string) {
|
||||||
|
return prisma.phase.findFirst({
|
||||||
|
where: { id: phaseId, plan: { household: { userId } } },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { cookies } from "next/headers";
|
||||||
|
import { SESSION_COOKIE_NAME, verifySessionToken } from "@/lib/auth";
|
||||||
|
|
||||||
|
// Liest die User-ID des eingeloggten Benutzers aus dem Session-Cookie.
|
||||||
|
// Fuer API-Routen (Node.js-Runtime); die Middleware schuetzt die Routen bereits,
|
||||||
|
// dies ist die zweite Verteidigungslinie und liefert die ID fuer Ownership-Checks.
|
||||||
|
export async function getCurrentUserId(): Promise<string | null> {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const token = cookieStore.get(SESSION_COOKIE_NAME)?.value;
|
||||||
|
if (!token) return null;
|
||||||
|
return verifySessionToken(token);
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import bcrypt from "bcryptjs";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
|
||||||
|
// Nur von API-Routen (Node.js-Runtime) verwendet -- niemals von middleware.ts
|
||||||
|
// importieren, da dort (Edge-Runtime) kein Datenbankzugriff moeglich ist.
|
||||||
|
|
||||||
|
const USERNAME_PATTERN = /^[a-zA-Z0-9._-]{3,32}$/;
|
||||||
|
|
||||||
|
export function validateUsername(username: string): string | null {
|
||||||
|
if (!USERNAME_PATTERN.test(username)) {
|
||||||
|
return "Benutzername: 3-32 Zeichen, nur Buchstaben, Zahlen, Punkt, Unterstrich, Bindestrich.";
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function registerUser(username: string, password: string) {
|
||||||
|
const existing = await prisma.user.findUnique({ where: { username } });
|
||||||
|
if (existing) {
|
||||||
|
throw new Error("Dieser Benutzername ist bereits vergeben.");
|
||||||
|
}
|
||||||
|
const passwordHash = await bcrypt.hash(password, 12);
|
||||||
|
return prisma.user.create({ data: { username, passwordHash } });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyUserCredentials(username: string, password: string) {
|
||||||
|
const user = await prisma.user.findUnique({ where: { username } });
|
||||||
|
if (!user) return null;
|
||||||
|
const valid = await bcrypt.compare(password, user.passwordHash);
|
||||||
|
return valid ? user : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function changeUserPassword(userId: string, currentPassword: string, newPassword: string) {
|
||||||
|
const user = await prisma.user.findUnique({ where: { id: userId } });
|
||||||
|
if (!user) {
|
||||||
|
throw new Error("Benutzer nicht gefunden.");
|
||||||
|
}
|
||||||
|
const valid = await bcrypt.compare(currentPassword, user.passwordHash);
|
||||||
|
if (!valid) {
|
||||||
|
throw new Error("Das aktuelle Passwort ist falsch.");
|
||||||
|
}
|
||||||
|
const passwordHash = await bcrypt.hash(newPassword, 12);
|
||||||
|
return prisma.user.update({ where: { id: userId }, data: { passwordHash } });
|
||||||
|
}
|
||||||
+3
-3
@@ -1,7 +1,7 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { SESSION_COOKIE_NAME, verifySessionToken } from "@/lib/auth";
|
import { SESSION_COOKIE_NAME, verifySessionToken } from "@/lib/auth";
|
||||||
|
|
||||||
const PUBLIC_PATHS = ["/login", "/api/auth/login", "/api/auth/setup", "/api/auth/status"];
|
const PUBLIC_PATHS = ["/login", "/api/auth/login", "/api/auth/register"];
|
||||||
|
|
||||||
export async function middleware(request: NextRequest) {
|
export async function middleware(request: NextRequest) {
|
||||||
const { pathname } = request.nextUrl;
|
const { pathname } = request.nextUrl;
|
||||||
@@ -15,9 +15,9 @@ export async function middleware(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const token = request.cookies.get(SESSION_COOKIE_NAME)?.value;
|
const token = request.cookies.get(SESSION_COOKIE_NAME)?.value;
|
||||||
const isAuthenticated = token ? await verifySessionToken(token) : false;
|
const userId = token ? await verifySessionToken(token) : null;
|
||||||
|
|
||||||
if (!isAuthenticated) {
|
if (!userId) {
|
||||||
if (pathname.startsWith("/api")) {
|
if (pathname.startsWith("/api")) {
|
||||||
return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
|
return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user