UI-Gesamtumbau: Du-Form, Inspector-Panel, Onboarding-Assistent, Tour, Palette
Deploy App / deploy (push) Successful in 1m1s

Rein an der Oberflaeche -- Berechnung, Datenmodell und API-Semantik unveraendert
(103 Tests unveraendert gruen).

Paket A (Fundament):
- durchgehend Du-Form und echte Umlaute in allen sichtbaren Texten,
  inkl. API-Fehlermeldungen (vorher Mix aus Sie/Du und ae/oe/ue)
- neue UI-Primitiven (ui.tsx): Button, Modal mit ESC/Fokus-Falle/Animation,
  Bestaetigungs-Dialog statt window.confirm, Toasts statt alert,
  Skeleton-Loader, EmptyState
- eigene Attention-Farbe (Amber) fuer offene Entscheide, getrennt vom Akzent
- Micro-Interactions mit prefers-reduced-motion-Fallback

Paket B (Onboarding, Roadmap Nr. 10):
- gefuehrter Plan-Assistent in 5 Schritten; Einkommen bewusst pro Person
  (raeumt die 9.9-AHV-Falle aus); reine Orchestrierung bestehender Endpunkte
- Beispielplan mit einem Klick; Uebergaenge absichtlich offen
- interaktive Tour ueber die Planansicht (localStorage, jederzeit neu startbar)
- abgeleitete "Naechste Schritte"-Karte (offene Entscheide, fehlende Elemente,
  fehlende Pensionsphase, Ruin -> Einflussfaktoren)

Paket C (Struktur):
- Inspector-Panel rechts statt Modals fuer alle Einzel-Bearbeitungen;
  Matrix bleibt sichtbar, Zellklick wechselt den Inhalt
- Phasenkopf auf vier Kern-Infos entschlackt (Rest in der 0.11-Detailansicht)
- Matrix mit eigenem Scrollbereich, Koepfe beidachsig fixiert
- Sidebar-Gruppen "Meine Plaene" / "Wissen"; "So rechnet FPT" statt
  SPEZIFIKATION; "Szenario-Profil" statt "Plan-Einstellungen"
- Aktions-Icons ohne Hover sichtbar (Touch)

Paket D (Extras):
- Sparklines je Element-Zeile aus den 0.11-Verlaufswerten
- Befehls-Palette (Ctrl/Cmd+K)
- Ruin-Banner verlinkt auf die Einflussfaktoren

Nebenbei: der ProfileMenu-Lint-Fehler und der Selection-Rest (9.17) sind
behoben -- npm run lint laeuft erstmals fehlerfrei.

SPEZIFIKATION auf 0.13: neue Kapitel 3.2.8, 3.7.6-3.7.9, 9.23, 9.24;
3.6.3 und 3.7.1 ueberarbeitet, 9.17 bereinigt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-19 07:40:42 +02:00
parent e1f74fca95
commit c2fb82b0de
60 changed files with 2915 additions and 1210 deletions
+2 -2
View File
@@ -17,7 +17,7 @@ export async function POST(request: NextRequest) {
const body = await request.json();
const parsed = changePasswordSchema.safeParse(body);
if (!parsed.success) {
const message = parsed.error.issues[0]?.message ?? "Ungueltige Eingabe.";
const message = parsed.error.issues[0]?.message ?? "Ungültige Eingabe.";
return NextResponse.json({ error: message }, { status: 400 });
}
@@ -25,7 +25,7 @@ export async function POST(request: NextRequest) {
await changeUserPassword(userId, parsed.data.currentPassword, parsed.data.newPassword);
} catch (e) {
return NextResponse.json(
{ error: e instanceof Error ? e.message : "Passwort aendern fehlgeschlagen." },
{ error: e instanceof Error ? e.message : "Passwort ändern fehlgeschlagen." },
{ status: 400 }
);
}
+1 -1
View File
@@ -12,7 +12,7 @@ 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.";
const message = parsed.error.issues[0]?.message ?? "Ungültige Eingabe.";
return NextResponse.json({ error: message }, { status: 400 });
}
@@ -21,7 +21,7 @@ export async function PUT(
const body = await request.json();
const parsed = phaseDataSchema.safeParse(body);
if (!parsed.success) return NextResponse.json({ error: "Ungueltige Eingabe." }, { status: 400 });
if (!parsed.success) return NextResponse.json({ error: "Ungültige Eingabe." }, { status: 400 });
await prisma.elementPhaseValue.upsert({
where: { elementId_phaseId: { elementId, phaseId } },
+1 -1
View File
@@ -19,7 +19,7 @@ export async function PATCH(
const body = await request.json();
const parsed = patchSchema.safeParse(body);
if (!parsed.success) return NextResponse.json({ error: "Ungueltige Eingabe." }, { status: 400 });
if (!parsed.success) return NextResponse.json({ error: "Ungültige Eingabe." }, { status: 400 });
await prisma.financialElement.update({ where: { id: element.id }, data: { name: parsed.data.name } });
return NextResponse.json({ ok: true });
@@ -4,7 +4,7 @@ import { getOwnedElement } from "@/lib/queries";
import { getCurrentUserId } from "@/lib/session";
import { transitionDataSchema } from "@/lib/elements";
// Speichert den Uebergangs-Entscheid eines Elements nach der Phase fromPhase (Upsert).
// Speichert den Übergangs-Entscheid eines Elements nach der Phase fromPhase (Upsert).
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ elementId: string; fromPhaseId: string }> }
@@ -21,7 +21,7 @@ export async function PUT(
const body = await request.json();
const parsed = transitionDataSchema.safeParse(body);
if (!parsed.success) return NextResponse.json({ error: "Ungueltige Eingabe." }, { status: 400 });
if (!parsed.success) return NextResponse.json({ error: "Ungültige Eingabe." }, { status: 400 });
await prisma.elementTransitionValue.upsert({
where: { elementId_fromPhaseId: { elementId, fromPhaseId } },
@@ -4,7 +4,7 @@ import { getOwnedPhase } from "@/lib/queries";
import { getCurrentUserId } from "@/lib/session";
import { cashTransitionSchema } from "@/lib/elements";
// Speichert den Cash-Entscheid beim UEBERGANG nach dieser Phase: 1:1 uebernehmen oder
// Speichert den Cash-Entscheid beim UEBERGANG nach dieser Phase: 1:1 übernehmen oder
// einmaliger Zufluss / einmalige Kosten (Roadmap Nr. 1). Cash ist kein FinancialElement,
// deshalb liegt der Entscheid direkt an der Von-Phase.
export async function PUT(
@@ -20,7 +20,7 @@ export async function PUT(
const body = await request.json();
const parsed = cashTransitionSchema.safeParse(body);
if (!parsed.success) return NextResponse.json({ error: "Ungueltige Eingabe." }, { status: 400 });
if (!parsed.success) return NextResponse.json({ error: "Ungültige Eingabe." }, { status: 400 });
await prisma.phase.update({
where: { id: phase.id },
+4 -4
View File
@@ -23,11 +23,11 @@ export async function PUT(
const body = await request.json();
const parsed = updatePhaseSchema.safeParse(body);
if (!parsed.success) return NextResponse.json({ error: "Ungueltige Eingabe." }, { status: 400 });
if (!parsed.success) return NextResponse.json({ error: "Ungültige Eingabe." }, { status: 400 });
let duration = parsed.data.durationYears;
if (duration != null) {
// Dauer ans naechste Pensionsereignis kappen (Jahre vor dieser Phase).
// Dauer ans nächste Pensionsereignis kappen (Jahre vor dieser Phase).
const scenario = await getOwnedScenario(existing.scenarioId, userId);
if (scenario) {
const planInput = toPlanInput(scenario);
@@ -50,7 +50,7 @@ export async function PUT(
return NextResponse.json({ phase: { id: phase.id } });
}
// Nur die letzte Phase kann geloescht werden (Verkettung bleibt intakt).
// Nur die letzte Phase kann gelöscht werden (Verkettung bleibt intakt).
export async function DELETE(
_request: NextRequest,
{ params }: { params: Promise<{ phaseId: string }> }
@@ -66,7 +66,7 @@ export async function DELETE(
where: { scenarioId: phase.scenarioId, sequenceNumber: { gt: phase.sequenceNumber } },
});
if (later) {
return NextResponse.json({ error: "Nur die letzte Phase kann geloescht werden." }, { status: 400 });
return NextResponse.json({ error: "Nur die letzte Phase kann gelöscht werden." }, { status: 400 });
}
await prisma.phase.delete({ where: { id: phaseId } });
+3 -3
View File
@@ -3,7 +3,7 @@ import { z } from "zod";
import { prisma } from "@/lib/db";
import { getCurrentUserId } from "@/lib/session";
// Der Plan ist nur noch der Behaelter: er traegt den Namen; die Finanzdaten liegen im Szenario.
// Der Plan ist nur noch der Behälter: er trägt den Namen; die Finanzdaten liegen im Szenario.
const patchSchema = z.object({ name: z.string().min(1).max(120) });
export async function PATCH(request: NextRequest, { params }: { params: Promise<{ planId: string }> }) {
@@ -15,13 +15,13 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
if (!plan) return NextResponse.json({ error: "Plan nicht gefunden." }, { status: 404 });
const parsed = patchSchema.safeParse(await request.json());
if (!parsed.success) return NextResponse.json({ error: "Ungueltige Eingabe." }, { status: 400 });
if (!parsed.success) return NextResponse.json({ error: "Ungültige Eingabe." }, { status: 400 });
const updated = await prisma.plan.update({ where: { id: plan.id }, data: { name: parsed.data.name } });
return NextResponse.json({ plan: { id: updated.id, name: updated.name } });
}
// Loescht den Plan inkl. aller Szenarien (Cascade).
// Löscht den Plan inkl. aller Szenarien (Cascade).
export async function DELETE(_request: NextRequest, { params }: { params: Promise<{ planId: string }> }) {
const userId = await getCurrentUserId();
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
+4 -4
View File
@@ -23,15 +23,15 @@ const createPlanSchema = z.object({ name: z.string().min(1).max(120) }).and(scen
export function validatePersonsForType(data: z.infer<typeof scenarioProfileSchema>): string | null {
if (data.householdType === "SINGLE" && data.persons.length !== 1) {
return "Einzelperson-Plan benoetigt genau eine Person.";
return "Einzelperson-Plan benötigt genau eine Person.";
}
if (data.householdType === "COUPLE" && data.persons.length !== 2) {
return "Paar-Plan benoetigt genau zwei Personen (Person A und Person B).";
return "Paar-Plan benötigt genau zwei Personen (Person A und Person B).";
}
return null;
}
// Liste der Plaene mit ihrem Szenario-Baum (nur Kopfdaten).
// Liste der Pläne mit ihrem Szenario-Baum (nur Kopfdaten).
export async function GET() {
const userId = await getCurrentUserId();
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
@@ -52,7 +52,7 @@ export async function GET() {
return NextResponse.json({ plans });
}
// Legt einen Plan an -- zusammen mit seinem Basisszenario, das das Grundprofil traegt.
// Legt einen Plan an -- zusammen mit seinem Basisszenario, das das Grundprofil trägt.
export async function POST(request: NextRequest) {
const userId = await getCurrentUserId();
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
@@ -6,17 +6,17 @@ import { getCurrentUserId } from "@/lib/session";
const copySchema = z.object({ name: z.string().min(1).max(120) });
// Erstellt ein neues Szenario als vollstaendige Kopie eines bestehenden. Das Original wird
// zum Elternteil -- damit haengt der Baum in der Seitenleiste und der Diff hat seine Basis.
// Jede kopierte Phase/jedes kopierte Element traegt einen Herkunfts-Verweis auf sein
// Gegenstueck im Original; darauf beruht die Abweichungs-Markierung im UI.
// Erstellt ein neues Szenario als vollständige Kopie eines bestehenden. Das Original wird
// zum Elternteil -- damit hängt der Baum in der Seitenleiste und der Diff hat seine Basis.
// Jede kopierte Phase/jedes kopierte Element trägt einen Herkunfts-Verweis auf sein
// Gegenstück im Original; darauf beruht die Abweichungs-Markierung im UI.
export async function POST(request: NextRequest, { params }: { params: Promise<{ scenarioId: string }> }) {
const userId = await getCurrentUserId();
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
const { scenarioId } = await params;
const parsed = copySchema.safeParse(await request.json());
if (!parsed.success) return NextResponse.json({ error: "Ungueltige Eingabe." }, { status: 400 });
if (!parsed.success) return NextResponse.json({ error: "Ungültige Eingabe." }, { status: 400 });
const source = await getOwnedScenario(scenarioId, userId);
if (!source) return NextResponse.json({ error: "Ursprungs-Szenario nicht gefunden." }, { status: 404 });
@@ -43,7 +43,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
},
});
// Phasen kopieren (alte -> neue Id merken, fuer die Werte-Zuordnung).
// Phasen kopieren (alte -> neue Id merken, für die Werte-Zuordnung).
const phaseIdMap = new Map<string, string>();
for (const phase of source.phases) {
const p = await tx.phase.create({
@@ -59,7 +59,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
phaseIdMap.set(phase.id, p.id);
}
// Elemente inkl. Phasen- und Uebergangswerten kopieren.
// Elemente inkl. Phasen- und Übergangswerten kopieren.
for (const el of source.elements) {
const newEl = await tx.financialElement.create({
data: {
@@ -34,7 +34,7 @@ export async function POST(
const body = await request.json();
const parsed = createSchema.safeParse(body);
if (!parsed.success) return NextResponse.json({ error: "Ungueltige Eingabe." }, { status: 400 });
if (!parsed.success) return NextResponse.json({ error: "Ungültige Eingabe." }, { status: 400 });
const { category, name } = parsed.data;
let ownerRole = parsed.data.ownerRole ?? null;
@@ -12,8 +12,8 @@ const createPhaseSchema = z.object({
durationYears: z.number().int().min(1).max(80).optional(),
});
// Legt eine neue Lebensphase am Ende der Kette an. Die Dauer wird ans naechste
// Pensionsereignis gekappt. Fuer bestehende Elemente werden die editierbaren Felder
// Legt eine neue Lebensphase am Ende der Kette an. Die Dauer wird ans nächste
// Pensionsereignis gekappt. Für bestehende Elemente werden die editierbaren Felder
// vorbelegt; die Startwerte werden in der Berechnung live aus der Vorphase fortgeschrieben.
export async function POST(
request: NextRequest,
@@ -28,7 +28,7 @@ export async function POST(
const body = await request.json().catch(() => ({}));
const parsed = createPhaseSchema.safeParse(body);
if (!parsed.success) return NextResponse.json({ error: "Ungueltige Eingabe." }, { status: 400 });
if (!parsed.success) return NextResponse.json({ error: "Ungültige Eingabe." }, { status: 400 });
const planInput = toPlanInput(scenario);
const yearsBefore = planInput.phases.reduce((s, p) => s + p.durationYears, 0);
@@ -40,12 +40,12 @@ export async function POST(
const nextSequence = planInput.phases.length + 1;
// Phasentyp der neuen Phase fuer den Default-Namen bestimmen.
// Phasentyp der neuen Phase für den Default-Namen bestimmen.
const anyRetiredAtStart = planInput.persons.some((p) => p.age + yearsBefore >= p.retirementAge);
const defaultName =
parsed.data.name ?? (nextSequence === 1 ? "Erste Lebensphase" : anyRetiredAtStart ? "Pensionsphase" : "Erwerbsphase");
// Status der Elemente in der bisher letzten Phase (verkauft/getilgt nicht fortfuehren).
// Status der Elemente in der bisher letzten Phase (verkauft/getilgt nicht fortführen).
const prevComputed = planInput.phases.length > 0 ? computePlan(planInput) : null;
const lastPhaseId = planInput.phases.at(-1)?.id;
const prevPhase = prevComputed?.phases.find((p) => p.id === lastPhaseId) ?? null;
@@ -77,14 +77,14 @@ export async function POST(
return NextResponse.json({ phase: { id: phase.id } }, { status: 201 });
}
// Vorbelegung fuer eine neue Phase: nur die editierbaren Felder werden uebernommen. Start-
// bzw. Restwerte (PK/3a/Vermoegen/Hypothek/Schuld) werden in der Berechnung live aus der
// Vorbelegung für eine neue Phase: nur die editierbaren Felder werden übernommen. Start-
// bzw. Restwerte (PK/3a/Vermögen/Hypothek/Schuld) werden in der Berechnung live aus der
// Vorphase fortgeschrieben und deshalb hier NICHT als Snapshot gespeichert.
function buildCarryData(category: string, prev: PhaseData): PhaseData {
switch (category) {
case "INCOME":
case "EXPENSE":
// Basis wird live indexiert fortgeschrieben; nur der Teuerungsausgleich wird uebernommen.
// Basis wird live indexiert fortgeschrieben; nur der Teuerungsausgleich wird übernommen.
return prev.teuerungsausgleich != null ? { teuerungsausgleich: prev.teuerungsausgleich } : {};
case "AHV":
return { gapYears: 0 };
+4 -4
View File
@@ -6,7 +6,7 @@ import { getCurrentUserId } from "@/lib/session";
import { computePlan } from "@/lib/calculations";
import { scenarioProfileSchema, validatePersonsForType } from "@/app/api/plans/route";
// Liefert das Szenario samt Berechnung -- und zusaetzlich das ELTERN-Szenario als
// Liefert das Szenario samt Berechnung -- und zusätzlich das ELTERN-Szenario als
// Vergleichsbasis, damit das UI die Abweichungen markieren kann (Basisszenario: null).
export async function GET(_request: NextRequest, { params }: { params: Promise<{ scenarioId: string }> }) {
const userId = await getCurrentUserId();
@@ -58,7 +58,7 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
if (!scenario) return NextResponse.json({ error: "Szenario nicht gefunden." }, { status: 404 });
const parsed = patchSchema.safeParse(await request.json());
if (!parsed.success) return NextResponse.json({ error: "Ungueltige Eingabe." }, { status: 400 });
if (!parsed.success) return NextResponse.json({ error: "Ungültige Eingabe." }, { status: 400 });
const data = parsed.data;
if ("householdType" in data) {
@@ -91,7 +91,7 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
return NextResponse.json({ scenario: { id: updated.id, name: updated.name } });
}
// Loescht ein Szenario. Das Basisszenario kann nicht geloescht werden (dafuer den Plan loeschen).
// Löscht ein Szenario. Das Basisszenario kann nicht gelöscht werden (dafür den Plan löschen).
export async function DELETE(_request: NextRequest, { params }: { params: Promise<{ scenarioId: string }> }) {
const userId = await getCurrentUserId();
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
@@ -101,7 +101,7 @@ export async function DELETE(_request: NextRequest, { params }: { params: Promis
if (!scenario) return NextResponse.json({ error: "Szenario nicht gefunden." }, { status: 404 });
if (scenario.isBase) {
return NextResponse.json(
{ error: "Das Basisszenario kann nicht geloescht werden. Loeschen Sie stattdessen den Plan." },
{ error: "Das Basisszenario kann nicht gelöscht werden. Löschen Sie stattdessen den Plan." },
{ status: 400 }
);
}