UI-Gesamtumbau: Du-Form, Inspector-Panel, Onboarding-Assistent, Tour, Palette
Deploy App / deploy (push) Successful in 1m1s
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:
@@ -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 }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 } },
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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,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 });
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user