From 79fb2b9ca6cdf2315cfe6931376f30738f07d142 Mon Sep 17 00:00:00 2001 From: kelle Date: Sun, 26 Jul 2026 22:23:13 +0200 Subject: [PATCH] Szenario-Screen mit vier Kacheln, FPT-Assistent, Onboarding auf einen Knopf Co-Authored-By: Claude Opus 5 --- .../api/elements/[elementId]/base/route.ts | 35 + .../scenarios/[scenarioId]/assistant/route.ts | 34 + src/components/AppShell.tsx | 241 +--- src/components/Assistant.tsx | 131 ++ src/components/AssistantStepDialog.tsx | 297 ++++ src/components/AssistantSteps.tsx | 917 ++++++++++++ src/components/PlanProfileFields.tsx | 56 +- src/components/PlanView.tsx | 530 +++---- src/components/PlanWizard.tsx | 1229 ----------------- src/components/RetirementPanel.tsx | 110 +- src/lib/demoplan.ts | 96 -- src/lib/versioning-coverage.test.ts | 2 + 12 files changed, 1788 insertions(+), 1890 deletions(-) create mode 100644 src/app/api/elements/[elementId]/base/route.ts create mode 100644 src/app/api/scenarios/[scenarioId]/assistant/route.ts create mode 100644 src/components/Assistant.tsx create mode 100644 src/components/AssistantStepDialog.tsx create mode 100644 src/components/AssistantSteps.tsx delete mode 100644 src/components/PlanWizard.tsx delete mode 100644 src/lib/demoplan.ts diff --git a/src/app/api/elements/[elementId]/base/route.ts b/src/app/api/elements/[elementId]/base/route.ts new file mode 100644 index 0000000..00ba8d2 --- /dev/null +++ b/src/app/api/elements/[elementId]/base/route.ts @@ -0,0 +1,35 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/db"; +import { getOwnedElement } from "@/lib/queries"; +import { getCurrentUserId } from "@/lib/session"; +import { touchScenario } from "@/lib/versioning-db"; +import { phaseDataSchema } from "@/lib/elements"; + +// Stammdaten eines Elements: der Bestand bei PLANBEGINN und die Ausgangs-Annahmen. +// +// Bewusst ohne Phasenbezug. Zwei Gründe: Ein Startwert ist nicht «phase-1-spezifisch», +// sondern schlicht der Stand am Anfang -- und Elemente lassen sich damit erfassen, BEVOR es +// Lebensphasen gibt. Genau das braucht der erste Schritt des Assistenten: eine +// Bestandsaufnahme, die noch keine Zeitachse voraussetzt. +export async function PUT( + request: NextRequest, + { params }: { params: Promise<{ elementId: string }> } +) { + const userId = await getCurrentUserId(); + if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 }); + const { elementId } = await params; + + const element = await getOwnedElement(elementId, userId); + if (!element) return NextResponse.json({ error: "Element nicht gefunden." }, { status: 404 }); + + const parsed = phaseDataSchema.safeParse(await request.json()); + if (!parsed.success) return NextResponse.json({ error: "Ungültige Eingabe." }, { status: 400 }); + + await prisma.financialElement.update({ + where: { id: elementId }, + data: { baseData: parsed.data }, + }); + + await touchScenario(element.scenarioId, userId); + return NextResponse.json({ ok: true }); +} diff --git a/src/app/api/scenarios/[scenarioId]/assistant/route.ts b/src/app/api/scenarios/[scenarioId]/assistant/route.ts new file mode 100644 index 0000000..718d1c6 --- /dev/null +++ b/src/app/api/scenarios/[scenarioId]/assistant/route.ts @@ -0,0 +1,34 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { prisma } from "@/lib/db"; +import { getOwnedScenario } from "@/lib/queries"; +import { getCurrentUserId } from "@/lib/session"; +import { ASSISTANT_STEP_COUNT, normalizeProgress } from "@/lib/assistant"; + +// Fortschritt des FPT-Assistenten setzen (ein Haken je Schritt). +// +// Bewusst OHNE `touchScenario`: Das Abhaken ist eine Notiz des Benutzers über sich selbst, +// keine Änderung am Plan. Eine Version dafür anzulegen würde die Historie mit Einträgen +// fluten, die inhaltlich nichts unterscheiden. +const bodySchema = z.object({ + step: z.number().int().min(0).max(ASSISTANT_STEP_COUNT - 1), + done: z.boolean(), +}); + +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 scenario = await getOwnedScenario(scenarioId, userId); + if (!scenario) return NextResponse.json({ error: "Szenario nicht gefunden." }, { status: 404 }); + + const parsed = bodySchema.safeParse(await request.json().catch(() => ({}))); + if (!parsed.success) return NextResponse.json({ error: "Ungültige Eingabe." }, { status: 400 }); + + const progress = normalizeProgress(scenario.assistantProgress); + progress[parsed.data.step] = parsed.data.done; + + await prisma.scenario.update({ where: { id: scenarioId }, data: { assistantProgress: progress } }); + return NextResponse.json({ ok: true, progress }); +} diff --git a/src/components/AppShell.tsx b/src/components/AppShell.tsx index 34f19d7..311c517 100644 --- a/src/components/AppShell.tsx +++ b/src/components/AppShell.tsx @@ -3,7 +3,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { BarChart3, - BookOpen, ChevronDown, ChevronRight, Copy, @@ -12,9 +11,6 @@ import { FolderKanban, CalendarClock, GitBranch, - HelpCircle, - History, - Download, Layers, LayoutDashboard, Menu, @@ -43,7 +39,6 @@ import { SystemParametersView } from "@/components/SystemParametersView"; import { PlanTraceDialog } from "@/components/DetailView"; import { computePlan } from "@/lib/calculations"; import { ProfileMenu } from "@/components/ProfileMenu"; -import { PlanWizard } from "@/components/PlanWizard"; import { Tour, TOUR_DONE_KEY } from "@/components/Tour"; import { CommandPalette, type PaletteAction } from "@/components/CommandPalette"; import { PlanProfileFields, emptyProfileDraft, type ProfileDraft } from "@/components/PlanProfileFields"; @@ -58,7 +53,6 @@ import { useToast, } from "@/components/ui"; import { api } from "@/lib/api-client"; -import { createDemoPlan } from "@/lib/demoplan"; import { computeScenarioDiff } from "@/lib/diff"; import type { PlanInput, PlanListItem, ScenarioMeta } from "@/lib/types"; import type { PlanComputed } from "@/lib/calculations"; @@ -95,10 +89,7 @@ function AppShellInner({ username }: { username: string }) { const [detail, setDetail] = useState(null); const [loading, setLoading] = useState(true); const [sidebarOpen, setSidebarOpen] = useState(false); - const [showNewPlanChooser, setShowNewPlanChooser] = useState(false); - const [showWizard, setShowWizard] = useState(false); const [showEmptyPlanDialog, setShowEmptyPlanDialog] = useState(false); - const [creatingDemo, setCreatingDemo] = useState(false); const [copyFrom, setCopyFrom] = useState(null); const [showSpec, setShowSpec] = useState(false); const [showCharts, setShowCharts] = useState(false); @@ -284,22 +275,6 @@ function AppShellInner({ username }: { username: string }) { if (selectedScenarioId === s.id) setSelectedScenarioId(null); } - async function handleCreateDemo() { - setCreatingDemo(true); - try { - const sid = await createDemoPlan(); - await loadPlans(); - setShowNewPlanChooser(false); - pendingTourRef.current = true; // Tour bei jeder Plan-Erstellung (F14) - openScenario(sid); - toast("success", "Beispielplan angelegt – die Tour zeigt dir gleich die wichtigsten Stellen."); - } catch (e) { - toast("error", e instanceof Error ? e.message : "Beispielplan konnte nicht angelegt werden."); - } finally { - setCreatingDemo(false); - } - } - const activePlan = plans.find((p) => p.scenarios.some((s) => s.id === selectedScenarioId)) ?? (planNav ? plans.find((p) => p.id === planNav.planId) ?? null : null); @@ -315,7 +290,7 @@ function AppShellInner({ username }: { username: string }) { // Aktionen der Befehls-Palette -- kontextabhängig (Analysen nur bei offenem Szenario). const paletteActions = useMemo(() => { const base: PaletteAction[] = [ - { id: "a-new", label: "Neuen Plan erstellen", hint: "Aktion", run: () => setShowNewPlanChooser(true) }, + { id: "a-new", label: "Neuen Plan erstellen", hint: "Aktion", run: () => setShowEmptyPlanDialog(true) }, { id: "a-home", label: "Übersicht öffnen", hint: "Aktion", run: () => { setSelectedScenarioId(null); setPlanNav(null); setShowSpec(false); setShowSystemParams(false); } }, { id: "a-spec", label: "So rechnet FPT (Spezifikation)", hint: "Aktion", run: () => { setShowSpec(true); setSpecAnchor(null); setShowSystemParams(false); setSelectedScenarioId(null); setPlanNav(null); } }, { id: "a-params", label: "Systemparameter", hint: "Aktion", run: () => { setShowSystemParams(true); setShowSpec(false); setSelectedScenarioId(null); setPlanNav(null); } }, @@ -372,7 +347,7 @@ function AppShellInner({ username }: { username: string }) { Meine Pläne - )} - {detail.plan.phases.length > 0 && ( - - )} - - {detail.plan.phases.length > 0 && ( - - )} - {detail.plan.phases.length > 0 && ( - - - CSV-Export - - )} - {!detail.meta.isBase && ( - - )} - {diff && detail.base && ( - - - {diff.total === 0 - ? "Unverändert gegenüber der Vorlage" - : `${diff.total} Abweichung${diff.total === 1 ? "" : "en"} gegenüber der Vorlage`} - - )} - + {/* Nur noch, was NICHT zum Szenario-Inhalt gehört: das Löschen des Szenarios + und der Hinweis auf Abweichungen zur Vorlage. Die inhaltlichen Aktionen + (Historie, Tour, Kopie, Rechenwege, Export) sitzen seit 0.36 als Kachel im + Szenario-Screen -- dort, wo man sie braucht. */} + {(!detail.meta.isBase || (diff && detail.base)) && ( +
+ {!detail.meta.isBase && ( + + )} + {diff && detail.base && ( + + + {diff.total === 0 + ? "Unverändert gegenüber der Vorlage" + : `${diff.total} Abweichung${diff.total === 1 ? "" : "en"} gegenüber der Vorlage`} + + )} +
+ )} setShowSensitivity(true)} + onOpenHistory={() => setShowHistory(true)} + onStartTour={() => setShowTour(true)} + onCopyScenario={() => setCopyFrom(detail.meta)} + onOpenTraces={() => setShowPlanTraces(true)} + exportHref={`/api/scenarios/${detail.meta.id}/export`} /> )} - {showNewPlanChooser && ( - { - setShowNewPlanChooser(false); - setShowWizard(true); - }} - onEmpty={() => { - setShowNewPlanChooser(false); - setShowEmptyPlanDialog(true); - }} - onDemo={handleCreateDemo} - onClose={() => setShowNewPlanChooser(false)} - /> - )} - {showWizard && ( - { - setShowWizard(false); - await loadPlans(); - pendingTourRef.current = true; // Tour bei jeder Plan-Erstellung (F14) - openScenario(sid); - toast("success", "Plan erstellt – die Tour zeigt dir gleich die wichtigsten Stellen."); - }} - onClose={() => setShowWizard(false)} - /> - )} {showEmptyPlanDialog && ( void; - onEmpty: () => void; - onDemo: () => void; - onClose: () => void; -}) { - const options = [ - { - icon: , - title: "Geführt erstellen", - badge: "Empfohlen", - text: "Der Assistent fragt Schritt für Schritt – in fünf Minuten steht dein Grundgerüst.", - run: onGuided, - }, - { - icon: , - title: "Leer starten", - badge: null, - text: "Nur das Grundprofil erfassen, alles Weitere selbst aufbauen – für geübte Nutzer.", - run: onEmpty, - }, - { - icon: , - title: creatingDemo ? "Beispielplan wird angelegt…" : "Beispielplan ansehen", - badge: null, - text: "Ein fertig ausgefüllter, fiktiver Plan zum Erkunden – so siehst du das Tool zuerst in Aktion.", - run: onDemo, - }, - ]; - return ( - -
- {options.map((o) => ( - - ))} -
-
- ); -} - // Breiter Dialog für den Analyse-Bereich (Grafiken). function ChartsDialog({ title, @@ -1056,22 +905,14 @@ function ScenarioTree({ function DashboardHome({ username, plans, - creatingDemo, onOpenPlan, - onCreateGuided, - onCreateChooser, - onCreateDemo, - onCreateEmpty, + onCreatePlan, onDelete, }: { username: string; plans: PlanListItem[]; - creatingDemo: boolean; onOpenPlan: (planId: string) => void; - onCreateGuided: () => void; - onCreateChooser: () => void; - onCreateDemo: () => void; - onCreateEmpty: () => void; + onCreatePlan: () => void; onDelete: (id: string) => void; }) { return ( @@ -1085,20 +926,16 @@ function DashboardHome({ {plans.length === 0 ? ( + /* Genau EIN Weg hinein. Bis 0.35 standen hier drei Knöpfe (geführt / Beispielplan / + leer) -- eine Wahl, die niemand treffen kann, der das Tool noch nicht kennt. Der + Assistent führt jetzt IM Plan statt davor; die Tour zeigt vorab den Aufbau. */ } title="Deine erste Finanzplanung" - text="Am schnellsten geht es geführt: Der Assistent fragt dich Schritt für Schritt und baut dein Grundgerüst – in rund fünf Minuten. Oder schau dir zuerst den Beispielplan an." + text="Ein paar Angaben zu dir – danach zeigt dir eine kurze Tour, wie FPT aufgebaut ist, und der Assistent führt dich Schritt für Schritt durch die Planung." > - - - {/* Dritter Weg -- sonst gaebe es "Leer starten" nur ueber das Plus in der Seitenleiste. */} - ) : ( @@ -1151,7 +988,7 @@ function DashboardHome({ + + + + ); + })} + + + {allDone && ( +

+ Alle Schritte erledigt. Dein Plan steht – verfeinere ihn jederzeit über die Matrix. +

+ )} + + ); +} diff --git a/src/components/AssistantStepDialog.tsx b/src/components/AssistantStepDialog.tsx new file mode 100644 index 0000000..d269732 --- /dev/null +++ b/src/components/AssistantStepDialog.tsx @@ -0,0 +1,297 @@ +"use client"; + +// Das Popup eines Assistenten-Schritts. +// +// Immer gleich aufgebaut: SEITE 1 erklärt, worum es geht -- welche Fragen man sich stellt, +// welche Möglichkeiten das Tool bietet, worauf es ankommt. Erst danach folgt das Werkzeug. +// +// Warum die Erklärseite nicht übersprungen wird: Finanzplanung scheitert selten an der +// Bedienung und fast immer daran, dass unklar ist, was der Schritt eigentlich bezweckt. Wer +// es schon weiss, klickt unten links auf «Selbst erledigen» und ist in zwei Sekunden draussen. + +import { useState } from "react"; +import { ArrowLeft, ArrowRight, Check, Lock } from "lucide-react"; +import { Button, Modal } from "@/components/ui"; +import { ASSISTANT_STEPS, stepBlockedReason } from "@/lib/assistant"; +import type { PlanInput } from "@/lib/types"; + +export interface StepExplainer { + // Kurze Einordnung ganz oben. + lead: string; + // Die Fragen, die dieser Schritt beantwortet. + questions: string[]; + // Was man wissen sollte, bevor man loslegt. + notes: { title: string; text: string }[]; +} + +export const STEP_EXPLAINERS: StepExplainer[] = [ + { + lead: + "Bevor du planst, hältst du fest, was heute da ist. Alles Weitere baut darauf auf – ohne Bestandsaufnahme rechnet das Tool ins Leere.", + questions: [ + "Was verdienst du, was gibst du aus?", + "Welche Guthaben hast du – Pensionskasse, Säule 3a, Wertschriften, Konto?", + "Besitzt du Wohneigentum? Wie hoch ist die Hypothek?", + "Hast du Schulden ausserhalb der Hypothek?", + ], + notes: [ + { + title: "Nur Bestände, noch keine Raten", + text: "Wie viel du jährlich sparst oder amortisierst, kommt später – das hängt von deinen Lebensphasen ab. Hier zählt nur der Stand von heute.", + }, + { + title: "Lieber grob als gar nicht", + text: "Ein geschätzter Wert ist besser als ein leeres Feld. Du kannst jede Zahl später überall im Tool korrigieren.", + }, + { + title: "Getrennt nach Person", + text: "Pensionskasse und Säule 3a gehören immer einer Person. Wertschriften, Immobilien und Schulden können gemeinsam sein.", + }, + ], + }, + { + lead: + "Jetzt legst du die Zeitachse fest: wie weit du planst und wann welche Rente oder welches Kapital zu fliessen beginnt. Das bestimmt, wo deine Lebensphasen zwingend beginnen müssen.", + questions: [ + "Über wie viele Jahre soll die Planung laufen?", + "Wann hörst du auf zu arbeiten?", + "Wann beziehst du die AHV – früher, mit 65, oder später?", + "Wann beziehst du Pensionskasse und Säule 3a?", + ], + notes: [ + { + title: "Noch keine Beträge", + text: "Hier geht es nur um Zeitpunkte. Ob Rente oder Kapital, und wohin das Geld fliesst, entscheidest du in Schritt 5.", + }, + { + title: "Jeder Bezug erzeugt eine Phasengrenze", + text: "Beziehst du die AHV mit 63 und die Säule 3a mit 65, sind das zwei Fixpunkte. FPT rechnet phasenweise – deshalb muss dort jeweils eine neue Lebensphase beginnen.", + }, + { + title: "Früher kostet, später bringt", + text: "Ein AHV-Vorbezug kürzt die Rente lebenslang um 6,8 % pro Jahr, ein Aufschub erhöht sie um bis zu 31,5 %. Das Tool rechnet beides für dich aus.", + }, + ], + }, + { + lead: + "Deine Planung zerfällt in Abschnitte mit gleichbleibenden Verhältnissen. Die Fixpunkte aus Schritt 2 stehen fest – dazwischen teilst du frei ein.", + questions: [ + "Gibt es Jahre, die sich deutlich voneinander unterscheiden?", + "Planst du einen Hauskauf, eine Weiterbildung, ein Sabbatical?", + "Wie fein willst du planen – wenige grosse Phasen oder viele kleine?", + ], + notes: [ + { + title: "Eine Phase = gleichbleibende Verhältnisse", + text: "Innerhalb einer Lebensphase laufen Einkommen, Ausgaben und Sparraten gleichmässig weiter. Ändert sich etwas grundlegend, beginnt eine neue Phase.", + }, + { + title: "Weniger ist oft mehr", + text: "Drei bis sechs Phasen reichen für die meisten Planungen. Jede zusätzliche Phase bedeutet mehr Entscheide, die du treffen musst.", + }, + ], + }, + { + lead: + "Für die Jahre, in denen du noch arbeitest: Wohin fliesst das Geld, das übrig bleibt – und was passiert an den Übergängen dazwischen?", + questions: [ + "Wie viel sparst du pro Jahr, und wohin?", + "Zahlst du in die Säule 3a ein? Amortisierst du die Hypothek?", + "Verkaufst du unterwegs etwas, oder kommt Geld herein?", + ], + notes: [ + { + title: "Der Rest bleibt auf dem Konto", + text: "Was du nicht zuteilst, sammelt sich als Cash an. Das ist kein Fehler – aber meist nicht die Absicht.", + }, + { + title: "Übergänge sind Entscheidungspunkte", + text: "Zwischen zwei Lebensphasen entscheidest du, was mit Immobilien, Schulden und einmaligen Ereignissen geschieht.", + }, + ], + }, + { + lead: + "Der wichtigste Schritt: Was passiert mit deinem Altersguthaben? Hier entscheidest du über Rente oder Kapital – und darüber, wovon du in der Pension lebst.", + questions: [ + "Rente oder Kapital – oder eine Mischung?", + "Wenn Kapital: wohin damit? Hypothek amortisieren, anlegen, liegen lassen?", + "Reicht das Renteneinkommen für deine Ausgaben?", + ], + notes: [ + { + title: "Rente ist sicher, Kapital ist flexibel", + text: "Eine Rente fliesst lebenslang, unabhängig von den Märkten. Kapital bleibt verfügbar und vererbbar, trägt aber das Anlagerisiko – und muss reichen.", + }, + { + title: "Die Rentenlücke ist normal", + text: "Fast niemand deckt seine Ausgaben allein aus AHV und Pensionskasse. Entscheidend ist nicht, ob eine Lücke besteht, sondern wie lange dein Vermögen sie trägt.", + }, + { + title: "Steuern beim Kapitalbezug", + text: "Ein Kapitalbezug wird einmalig und getrennt vom Einkommen besteuert. Alle Bezüge desselben Jahres werden zusammengezählt – gestaffelt über mehrere Jahre zahlst du weniger.", + }, + ], + }, + { + lead: + "Die Jahre nach der Pensionierung: Kein Lohn mehr, dafür Renten – und das Vermögen, das du aufgebaut hast. Jetzt legst du fest, wovon du lebst.", + questions: [ + "Wie viel entnimmst du jährlich aus deinem Vermögen?", + "Ändern sich deine Ausgaben im Alter – Reisen zuerst, Pflege später?", + "Verkaufst du irgendwann die Immobilie?", + ], + notes: [ + { + title: "Verzehr statt Sparen", + text: "In diesen Phasen ist die Quote meist negativ: Du gibst mehr aus, als hereinkommt. Die Differenz kommt aus dem Vermögen – das ist der Zweck des Sparens.", + }, + { + title: "Achte auf das Ruinalter", + text: "Reicht das Vermögen nicht bis zum Planungshorizont, meldet FPT das. Dann helfen: später aufhören, weniger ausgeben, oder anders anlegen.", + }, + ], + }, + { + lead: + "Dein Plan steht. Was jetzt noch möglich ist – und wo du es findest.", + questions: [], + notes: [ + { + title: "Werte je Lebensphase anpassen", + text: "Willst du für eine einzelne Phase eine andere Rendite oder Sparrate? Klicke die Zelle in der Matrix an. Leere Felder erben automatisch von der Vorphase – so gilt eine Änderung ab dort weiter.", + }, + { + title: "Pensionsplanung zentral ändern", + text: "Alle Entscheide zu AHV, Pensionskasse und Säule 3a stehen gebündelt unter «Pensionsplanung» in den Grundeinstellungen – für jede Person einzeln.", + }, + { + title: "Varianten vergleichen", + text: "Lege ein zweites Szenario an (Schnellaktionen), ändere dort etwa das Pensionsalter, und vergleiche beide. Deine Entscheide werden mitkopiert.", + }, + { + title: "Unsicherheiten prüfen", + text: "Unter «Analysen» findest du die Einflussfaktoren (welcher Hebel wirkt am stärksten), die Live-Simulation (an Reglern drehen) und die Monte-Carlo-Simulation (wie robust ist der Plan).", + }, + { + title: "Nachvollziehen, was gerechnet wird", + text: "«Rechenwege» in den Schnellaktionen zeigt zu jeder Zahl den vollständigen Weg – inklusive der Vereinfachungen, die FPT bewusst macht.", + }, + ], + }, +]; + +export function AssistantStepDialog({ + plan, + stepIndex, + isDone, + onToggleDone, + onClose, + children, +}: { + plan: PlanInput; + stepIndex: number; + isDone: boolean; + onToggleDone: (done: boolean) => void; + onClose: () => void; + // Das Werkzeug des Schritts. Fehlt es, ist der Schritt reine Information. + children?: React.ReactNode; +}) { + const step = ASSISTANT_STEPS[stepIndex]; + const explainer = STEP_EXPLAINERS[stepIndex]; + const blocked = stepBlockedReason(plan, stepIndex); + const [showTool, setShowTool] = useState(false); + + const canGuide = step.guided && !blocked && !!children; + + return ( + + {showTool ? ( +
+ {children} +
+ + +
+
+ ) : ( +
+

{explainer.lead}

+ + {explainer.questions.length > 0 && ( +
+

+ Diese Fragen beantwortest du hier +

+
    + {explainer.questions.map((q) => ( +
  • + + {q} +
  • + ))} +
+
+ )} + +
+ {explainer.notes.map((n) => ( +
+

{n.title}

+

{n.text}

+
+ ))} +
+ + {blocked && ( +

+ + {blocked} +

+ )} + +
+ + {canGuide ? ( + + ) : ( + + )} +
+
+ )} +
+ ); +} diff --git a/src/components/AssistantSteps.tsx b/src/components/AssistantSteps.tsx new file mode 100644 index 0000000..a1cc712 --- /dev/null +++ b/src/components/AssistantSteps.tsx @@ -0,0 +1,917 @@ +"use client"; + +// Die Werkzeuge der Assistenten-Schritte. +// +// Alle arbeiten ausschliesslich über die bestehenden Endpunkte -- der Assistent ist +// Orchestrierung, kein zweiter Datenpfad. Was er schreibt, hätte man auch von Hand über die +// Matrix schreiben können; er nimmt einem nur das Suchen ab. + +import { useState } from "react"; +import { AlertTriangle, ArrowLeft, ArrowRight, Check, Plus, Trash2 } from "lucide-react"; +import { api } from "@/lib/api-client"; +import { Button } from "@/components/ui"; +import { MoneyField, NumberField, SelectField, TextField } from "@/components/FormField"; +import { RetirementFields } from "@/components/RetirementFields"; +import { CapitalDistributionDialog, RateDistributionDialog } from "@/components/DistributionDialogs"; +import { formatChf } from "@/lib/format"; +import { CATEGORY_LABELS, ownerLabel, num, type PhaseData } from "@/lib/elements"; +import { + DEFAULT_PLANNING_HORIZON_YEARS, + MAX_PLANNING_HORIZON_YEARS, + MIN_PLANNING_HORIZON_YEARS, + PILLAR_3A_MAX_ANNUAL, +} from "@/lib/constants"; +import { planSegments, defaultOpenDuration, type PlanSegment } from "@/lib/phaseplan"; +import { fixpointPersonsOf, type PlanComputed } from "@/lib/calculations"; +import type { PlanInput, PersonRole } from "@/lib/types"; + +// ============================================================================================ +// Schritt 1: Bestandsaufnahme +// ============================================================================================ +// +// Legt Elemente an und schreibt ihre STAMMDATEN -- ohne dass Lebensphasen existieren müssen. +// Das ist der Grund, warum es `baseData` gibt: Eine Bestandsaufnahme ist keine Aussage über +// eine Phase, sondern über den Stand von heute. + +type Scope = "HOUSEHOLD" | "PERSON_A" | "PERSON_B"; + +interface Kind { + key: string; + category: string; + label: string; + hint: string; + personOnly?: boolean; + fields: (v: PhaseData, set: (p: Partial) => void, plan: PlanInput) => React.ReactNode; +} + +const KINDS: Kind[] = [ + { + key: "income", + category: "INCOME", + label: "Erwerbseinkommen", + hint: "Dein Nettolohn – also das, was auf dem Konto ankommt.", + personOnly: true, + fields: (v, set) => ( + <> + set({ amount: x })} + /> + set({ teuerungsausgleich: x })} + /> + + ), + }, + { + key: "expense", + category: "EXPENSE", + label: "Lebenshaltung", + hint: "Alles, was du im Jahr ausgibst – Wohnen, Essen, Versicherungen, Freizeit.", + fields: (v, set) => ( + set({ amount: x })} + /> + ), + }, + { + key: "pk", + category: "PENSION_FUND", + label: "Pensionskasse", + hint: "Dein Altersguthaben laut PK-Ausweis.", + personOnly: true, + fields: (v, set) => ( + <> + set({ currentValue: x })} + /> + set({ expectedReturn: x })} + /> + + ), + }, + { + key: "p3a", + category: "PILLAR_3A", + label: "Säule 3a", + hint: "Ein Konto oder Depot der gebundenen Vorsorge. Mehrere Konten legst du einzeln an.", + personOnly: true, + fields: (v, set) => ( + <> + set({ currentValue: x })} + /> + set({ expectedReturn: x })} + /> + + ), + }, + { + key: "etf", + category: "OTHER_ASSET", + label: "Wertschriften und Sparguthaben", + hint: "Depot, ETF, Sparkonto – alles freie Vermögen ausser dem Cash-Konto.", + fields: (v, set) => ( + <> + set({ startValue: x })} /> + set({ expectedReturn: x })} + /> + + ), + }, + { + key: "re", + category: "REAL_ESTATE", + label: "Wohneigentum", + hint: "Haus oder Wohnung samt Hypothek.", + fields: (v, set) => ( + <> + set({ purchasePrice: x })} + /> + set({ mortgage: x })} + /> + set({ interestRate: x })} + /> + set({ valueGrowth: x })} + /> +
+ set({ interestHandling: x })} + options={[ + { value: "INCLUDED", label: "Ja, sind in der Lebenshaltung enthalten" }, + { value: "ADD", label: "Nein, bitte zusätzlich rechnen" }, + ]} + /> +
+ + ), + }, + { + key: "debt", + category: "OTHER_DEBT", + label: "Schulden", + hint: "Kredite ausserhalb der Hypothek.", + fields: (v, set) => ( + set({ startValue: x })} /> + ), + }, +]; + +export function Step1Elements({ plan, onChanged }: { plan: PlanInput; onChanged: () => void }) { + const scopes: Scope[] = + plan.householdType === "COUPLE" ? ["HOUSEHOLD", "PERSON_A", "PERSON_B"] : ["PERSON_A"]; + const [page, setPage] = useState(0); + const scope = scopes[page]; + + return ( +
+ {scopes.length > 1 && ( +
+ {scopes.map((sc, i) => ( + + ))} +
+ )} + + + + {scopes.length > 1 && ( +
+ + +
+ )} +
+ ); +} + +function ScopeElements({ + plan, + scope, + onChanged, +}: { + plan: PlanInput; + scope: Scope; + onChanged: () => void; +}) { + const [busy, setBusy] = useState(null); + const [error, setError] = useState(null); + const kinds = KINDS.filter((k) => (scope === "HOUSEHOLD" ? !k.personOnly : true)); + const mine = plan.elements.filter((e) => (e.ownerRole ?? "HOUSEHOLD") === scope); + + async function add(kind: Kind) { + setBusy(kind.key); + setError(null); + try { + const label = plan.householdType === "COUPLE" && scope !== "HOUSEHOLD" ? ` ${ownerLabel(plan.persons, scope)}` : ""; + await api.post(`/api/scenarios/${plan.id}/elements`, { + category: kind.category, + name: `${kind.label}${label}`, + ownerRole: scope, + }); + onChanged(); + } catch (e) { + setError(e instanceof Error ? e.message : "Anlegen fehlgeschlagen."); + } finally { + setBusy(null); + } + } + + return ( +
+
+ {kinds.map((k) => ( + + ))} +
+ + {mine.length === 0 ? ( +

+ Noch nichts erfasst. Wähle oben, was zutrifft – du kannst jederzeit weitere Positionen ergänzen. +

+ ) : ( +
+ {mine.map((el) => ( + + ))} +
+ )} + {error &&

{error}

} +
+ ); +} + +function BaseDataCard({ + plan, + elementId, + onChanged, +}: { + plan: PlanInput; + elementId: string; + onChanged: () => void; +}) { + const el = plan.elements.find((x) => x.id === elementId)!; + const kind = KINDS.find((k) => k.category === el.category); + const [draft, setDraft] = useState(el.baseData ?? {}); + const [saving, setSaving] = useState(false); + const [saved, setSaved] = useState(false); + const [name, setName] = useState(el.name); + + async function save() { + setSaving(true); + try { + if (name.trim() && name.trim() !== el.name) { + await api.patch(`/api/elements/${el.id}`, { name: name.trim() }); + } + await api.put(`/api/elements/${el.id}/base`, draft); + setSaved(true); + onChanged(); + } finally { + setSaving(false); + } + } + + async function remove() { + await api.delete(`/api/elements/${el.id}`); + onChanged(); + } + + const set = (p: Partial) => { + setDraft((d) => ({ ...d, ...p })); + setSaved(false); + }; + + return ( +
+
+ + {CATEGORY_LABELS[el.category]} + + +
+
+
+ +
+ {kind?.fields(draft, set, plan)} + {el.category === "PILLAR_3A" && ( +

+ Hinweis: Der jährliche Höchstbetrag liegt bei {formatChf(PILLAR_3A_MAX_ANNUAL)}. Wie viel du einzahlst, + legst du in Schritt 4 fest. +

+ )} +
+
+ + {saved && } +
+
+ ); +} + +// ============================================================================================ +// Schritt 2: Eckdaten und Pensionsplanung +// ============================================================================================ + +export function Step2Timeline({ + plan, + computed, + onChanged, +}: { + plan: PlanInput; + computed: PlanComputed; + onChanged: () => void; +}) { + const [page, setPage] = useState(0); + const pages = ["HORIZON", ...plan.persons.map((p) => p.role)]; + + return ( +
+
+ {pages.map((pg, i) => ( + + ))} +
+ + {page === 0 ? ( + + ) : ( + + )} + +
+ + +
+
+ ); +} + +function HorizonStep({ plan, onChanged }: { plan: PlanInput; onChanged: () => void }) { + const [years, setYears] = useState(plan.planningHorizonYears ?? DEFAULT_PLANNING_HORIZON_YEARS); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + async function save() { + setBusy(true); + setError(null); + try { + await api.post(`/api/scenarios/${plan.id}/horizon`, { horizonYears: Math.round(years) }); + onChanged(); + } catch (e) { + setError(e instanceof Error ? e.message : "Nicht möglich."); + } finally { + setBusy(false); + } + } + + return ( +
+

+ Über wie viele Jahre soll gerechnet werden? Der Horizont sollte deutlich über der Lebenserwartung liegen – + eine zu kurze Planung sieht tragfähig aus, obwohl das Geld nur nicht lange genug reichen muss. +

+
+
+ +
+ +
+ +
+

Daraus ergibt sich

+
+ {plan.persons.map((p) => ( +
+ {ownerLabel(plan.persons, p.role)} am Planende + {p.age + Math.round(years)} Jahre +
+ ))} + {plan.startYear && ( +
+ Letztes Planjahr + {plan.startYear + Math.round(years) - 1} +
+ )} +
+
+ {error &&

{error}

} +
+ ); +} + +function PersonTimingStep({ + plan, + computed, + role, + onChanged, +}: { + plan: PlanInput; + computed: PlanComputed; + role: PersonRole; + onChanged: () => void; +}) { + const person = plan.persons.find((p) => p.role === role)!; + const own = plan.elements.filter( + (e) => e.ownerRole === role && ["AHV", "PENSION_FUND", "PILLAR_3A"].includes(e.category) + ); + const summary = computed.retirement.perPerson.find((x) => x.role === role); + const [age, setAge] = useState(person.retirementAge); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [draft, setDraft] = useState>>({}); + + async function saveAge() { + setBusy(true); + setError(null); + try { + await api.patch(`/api/scenarios/${plan.id}`, { + persons: plan.persons.map((p) => ({ + role: p.role, + retirementAge: p.role === role ? Math.round(age) : p.retirementAge, + })), + }); + onChanged(); + } catch (e) { + setError(e instanceof Error ? e.message : "Nicht möglich."); + } finally { + setBusy(false); + } + } + + async function saveDecisions() { + setBusy(true); + try { + for (const [id, patch] of Object.entries(draft)) { + const el = plan.elements.find((x) => x.id === id); + await api.put(`/api/elements/${id}/retirement`, { ...(el?.retirementDecision ?? {}), ...patch }); + } + setDraft({}); + onChanged(); + } finally { + setBusy(false); + } + } + + return ( +
+
+
+
+ +
+ +
+ {plan.startYear && ( +

+ Das ist das Jahr {plan.startYear + (Math.round(age) - person.age)}. +

+ )} +
+ + {own.length === 0 ? ( +

+ Für {ownerLabel(plan.persons, role)} sind noch keine Vorsorge-Elemente erfasst. Gehe zurück zu Schritt 1 + und lege AHV, Pensionskasse und Säule 3a an – dann kannst du hier die Bezugszeitpunkte festlegen. +

+ ) : ( + <> +

+ Wann soll welches Guthaben zu fliessen beginnen? Jeder dieser Zeitpunkte erzeugt in Schritt 3 eine + Phasengrenze – FPT rechnet phasenweise, deshalb muss ein Bezug immer am Anfang einer Lebensphase stehen. +

+
+ {own.map((el) => ( + + setDraft((d) => ({ ...d, [id]: { ...(d[id] ?? {}), ...patch } })) + } + summary={summary} + /> + ))} +
+ {Object.keys(draft).length > 0 && ( + + )} + + )} + {error &&

{error}

} +
+ ); +} + +// ============================================================================================ +// Schritt 3: Lebensphasen +// ============================================================================================ + +export function Step3Phases({ plan, onChanged }: { plan: PlanInput; onChanged: () => void }) { + const segments = planSegments(fixpointPersonsOf(plan)); + const horizon = plan.planningHorizonYears ?? DEFAULT_PLANNING_HORIZON_YEARS; + const [draft, setDraft] = useState<{ name: string; years: number }[][]>(() => + segments.map((seg, i) => [ + { + name: defaultName(seg, i), + years: seg.fixedYears ?? defaultOpenDuration(plan.persons, seg.fromYear, horizon), + }, + ]) + ); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + const sums = draft.map((phs) => phs.reduce((s, p) => s + Math.max(0, p.years), 0)); + const valid = segments.every((seg, i) => seg.fixedYears === null || sums[i] === seg.fixedYears); + + async function create() { + setBusy(true); + setError(null); + try { + for (const phs of draft) { + for (const ph of phs) { + await api.post(`/api/scenarios/${plan.id}/phases`, { + name: ph.name.trim() || "Lebensphase", + durationYears: Math.max(1, Math.round(ph.years)), + }); + } + } + onChanged(); + } catch (e) { + setError(e instanceof Error ? e.message : "Anlegen fehlgeschlagen."); + } finally { + setBusy(false); + } + } + + if (plan.phases.length > 0) { + return ( +

+ Dieses Szenario hat bereits {plan.phases.length} Lebensphasen. Weitere legst du direkt in der Matrix an + («+ Lebensphase» in der Ecke oben links) – dort siehst du auch gleich, was sich dadurch ändert. +

+ ); + } + + return ( +
+

+ Die dick umrandeten Abschnitte sind gesetzt – sie ergeben sich aus deinen Bezugszeitpunkten. Innerhalb + eines Abschnitts kannst du beliebig fein unterteilen, die Summe muss aber genau aufgehen. +

+ + {segments.map((seg, i) => ( +
+
+ + {seg.type === "ERWERB" ? "Erwerbsphase" : seg.type === "MIXED" ? "Übergangszeit" : "Ruhestand"} + + + ab Jahr {seg.fromYear + 1} + {seg.fixedYears !== null ? ` · ${seg.fixedYears} Jahre fest` : " · Dauer frei"} + + {seg.endedBy.length > 0 && ( + endet mit: {seg.endedBy.map((f) => f.label).join(", ")} + )} + {seg.fixedYears !== null && ( + + {sums[i]} / {seg.fixedYears} Jahre + + )} +
+ +
+ {draft[i].map((ph, j) => ( +
+
+ + setDraft((d) => d.map((x, xi) => (xi === i ? x.map((y, yj) => (yj === j ? { ...y, name: v } : y)) : x))) + } + /> +
+
+ + setDraft((d) => d.map((x, xi) => (xi === i ? x.map((y, yj) => (yj === j ? { ...y, years: Math.round(v) } : y)) : x))) + } + /> +
+ {draft[i].length > 1 && ( + + )} +
+ ))} + +
+
+ ))} + + {!valid && ( +

+ + In mindestens einem festen Abschnitt geht die Summe der Jahre nicht auf. +

+ )} + {error &&

{error}

} + +
+ ); +} + +function defaultName(seg: PlanSegment, i: number): string { + if (seg.type === "ERWERB") return i === 0 ? "Erwerbsjahre" : "Erwerbsjahre II"; + if (seg.type === "MIXED") return "Übergangszeit"; + return "Ruhestand"; +} + +// ============================================================================================ +// Schritte 4 bis 6: durch die Phasen und Übergänge führen +// ============================================================================================ +// +// Alle drei arbeiten gleich -- sie unterscheiden sich nur im Ausschnitt der Zeitachse. Statt +// drei fast gleicher Komponenten gibt es eine mit einem Filter. + +export type WalkScope = "WORK" | "RETIRE" | "PENSION"; + +export function StepWalkthrough({ + plan, + computed, + scope, + onChanged, +}: { + plan: PlanInput; + computed: PlanComputed; + scope: WalkScope; + onChanged: () => void; +}) { + const [pos, setPos] = useState(0); + const [open, setOpen] = useState<"capital" | "rates" | null>(null); + + // Die Stationen: Phasen und die Übergänge dazwischen, gefiltert auf den Ausschnitt. + const stations = computed.phases.flatMap((ph, i) => { + const next = computed.phases[i + 1]; + const kind = phaseKind(ph.type, next?.type); + const out: { phaseId: string; label: string; type: "phase" | "transition"; toName?: string }[] = []; + if (inScope(scope, ph.type === "ERWERB" ? "WORK" : ph.type === "MIXED" ? "RETIRE" : "PENSION")) + out.push({ phaseId: ph.id, label: ph.name, type: "phase" }); + if (next && inScope(scope, kind)) + out.push({ phaseId: ph.id, label: `Übergang nach ${next.name}`, type: "transition", toName: next.name }); + return out; + }); + + if (stations.length === 0) { + return ( +

+ In diesem Abschnitt deiner Planung gibt es nichts zu entscheiden. +

+ ); + } + + const st = stations[Math.min(pos, stations.length - 1)]; + const phase = computed.phases.find((p) => p.id === st.phaseId)!; + + return ( +
+
+ + Station {pos + 1} von {stations.length} + +
+ {stations.map((_, i) => ( +
+ ))} +
+
+ +
+

{st.label}

+

+ {st.type === "phase" + ? `${phase.durationYears} Jahre · ${phase.isConsumption ? "Verzehrquote" : "Sparquote"} ${formatChf(phase.quotaStart)} im ersten Jahr` + : "Was geschieht beim Wechsel in die nächste Lebensphase?"} +

+ +
+ {st.type === "phase" ? ( + <> + + + + ) : ( +

+ Die Entscheide dieses Übergangs triffst du in der Matrix – klicke dort auf die Übergangsspalte + nach «{phase.name}». +

+ )} +
+
+ +
+ + +
+ + {open === "capital" && ( + setOpen(null)} + onSaved={() => { + setOpen(null); + onChanged(); + }} + /> + )} + {open === "rates" && ( + setOpen(null)} + onSaved={() => { + setOpen(null); + onChanged(); + }} + /> + )} +
+ ); +} + +// Zu welchem Abschnitt gehört ein Übergang? Massgeblich ist, WOHIN er führt. +function phaseKind(from: string, to?: string): WalkScope { + if (!to) return from === "ERWERB" ? "WORK" : from === "MIXED" ? "RETIRE" : "PENSION"; + if (to === "ERWERB") return "WORK"; + if (to === "MIXED") return "RETIRE"; + return from === "PENSION" ? "PENSION" : "RETIRE"; +} + +function inScope(scope: WalkScope, kind: WalkScope): boolean { + return scope === kind; +} + diff --git a/src/components/PlanProfileFields.tsx b/src/components/PlanProfileFields.tsx index fed1eda..a363054 100644 --- a/src/components/PlanProfileFields.tsx +++ b/src/components/PlanProfileFields.tsx @@ -1,6 +1,7 @@ "use client"; -import { FieldLabel, NumberField, SelectField, TextField } from "@/components/FormField"; +import { NumberField, SelectField, TextField } from "@/components/FormField"; +import { AHV_REFERENCE_AGE } from "@/lib/constants"; import type { HouseholdType, PersonRole } from "@/lib/types"; export interface ProfileDraft { @@ -15,23 +16,28 @@ export function emptyProfileDraft(): ProfileDraft { householdType: "SINGLE", inflationRateDefault: 1.5, startYear: new Date().getFullYear(), - persons: [{ role: "PERSON_A", name: "", age: 35, retirementAge: 65 }], + // Das Pensionsalter wird beim Anlegen nicht gefragt: Es ist der erste Entscheid der + // Pensionsplanung. Bis dahin gilt das Referenzalter. + persons: [{ role: "PERSON_A", name: "", age: 35, retirementAge: AHV_REFERENCE_AGE }], }; } // Gemeinsame Formularfelder für das Grundprofil eines Plans (Haushaltsform, Personen, -// Inflation). Wird beim Plan-Erstellen und in den Plan-Einstellungen verwendet. +// Startjahr, Inflation). Wird beim Plan-Erstellen und in den Grundeinstellungen verwendet. +// +// Das PENSIONSALTER fehlt hier seit 0.36 bewusst. Es ist kein Stammdatum, sondern der erste +// Entscheid der Pensionsplanung -- und es erzeugt eine Phasengrenze. Es wird deshalb dort +// gesetzt («Pensionsplanung» je Person) und hier höchstens angezeigt. export function PlanProfileFields({ draft, onChange, - // Sobald Lebensphasen bestehen, liegt jede Pensionierung auf einer Phasengrenze. Ein frei - // änderbares Alter würde diese Grenze zerreissen -- deshalb übernimmt dort die eigene - // Bedienung «Pensionsalter anpassen» (Roadmap Nr. 44), und das Feld ist nur noch Anzeige. - lockRetirement = false, + // Blendet den Hinweis aus, dass diese Angaben plan-weit gelten -- beim Anlegen eines Plans + // gibt es noch keine anderen Szenarien, die betroffen sein könnten. + hideScopeNote = false, }: { draft: ProfileDraft; onChange: (next: ProfileDraft) => void; - lockRetirement?: boolean; + hideScopeNote?: boolean; }) { function setType(type: HouseholdType) { if (type === "SINGLE") { @@ -39,7 +45,7 @@ export function PlanProfileFields({ } else { const persons = draft.persons.length < 2 - ? [...draft.persons, { role: "PERSON_B" as PersonRole, name: "", age: 35, retirementAge: 65 }] + ? [...draft.persons, { role: "PERSON_B" as PersonRole, name: "", age: 35, retirementAge: AHV_REFERENCE_AGE }] : draft.persons; onChange({ ...draft, householdType: type, persons }); } @@ -57,11 +63,12 @@ export function PlanProfileFields({ {/* Seit V7 beschreiben Haushaltsform, Personen und Startjahr den PLAN und gelten für ALLE Szenarien. Das muss sichtbar sein -- sonst ändert man beim Bearbeiten eines Nebenszenarios unbemerkt auch alle anderen. */} -

- Haushaltsform, Personen und Planstart gelten für alle Szenarien dieses - Plans. Unterscheiden sie sich, ist es ein anderer Plan. Szenario-eigen sind nur das{" "} - Pensionsalter und die Inflation. -

+ {!hideScopeNote && ( +

+ Haushaltsform, Personen und Planstart gelten für alle Szenarien dieses + Plans. Unterscheiden sie sich, ist es ein anderer Plan. +

+ )} updatePerson(index, { age: Math.round(v) })} /> - {lockRetirement ? ( -
- -
- {person.retirementAge} Jahre -
-
- ) : ( - updatePerson(index, { retirementAge: Math.round(v) })} - /> - )} + {/* Das Pensionsalter steht bewusst NICHT hier -- siehe Kommentar oben. */}
))} diff --git a/src/components/PlanView.tsx b/src/components/PlanView.tsx index 9139d3e..f6324a1 100644 --- a/src/components/PlanView.tsx +++ b/src/components/PlanView.tsx @@ -3,7 +3,6 @@ import { useEffect, useMemo, useState } from "react"; import { AlertCircle, - ArrowRight, Building2, CheckCircle2, ChevronDown, @@ -11,13 +10,15 @@ import { CreditCard, Home, Landmark, - ListTodo, Pencil, Maximize2, PiggyBank, - Table2, + Copy, + HelpCircle, + History as HistoryIcon, + BookOpen, + Download, Plus, - Settings2, ShoppingCart, Tornado, Trash2, @@ -35,15 +36,22 @@ import { type DecisionCounts, transitionInactive as inactiveAtTransition, TRANSITION_CATEGORIES, - retirementConfirmed, } from "@/lib/decisions"; import { AHV_REFERENCE_AGE } from "@/lib/constants"; import { - RETIREMENT_CATEGORIES, withRetirementDefaults, type RetirementDecision, } from "@/lib/retirement-decision"; import { RetirementFields } from "@/components/RetirementFields"; +import { Assistant } from "@/components/Assistant"; +import { AssistantStepDialog } from "@/components/AssistantStepDialog"; +import { + Step1Elements, + Step2Timeline, + Step3Phases, + StepWalkthrough, +} from "@/components/AssistantSteps"; +import { normalizeProgress } from "@/lib/assistant"; import { RetirementPanel } from "@/components/RetirementPanel"; import { Button, EmptyState, InspectorShell, Modal, useConfirm, useToast } from "@/components/ui"; import { ElementDetailDialog, PhaseDetailDialog } from "@/components/DetailView"; @@ -80,7 +88,7 @@ import { } from "@/lib/elements"; import { computePlan, type PhaseComputed, type PlanComputed } from "@/lib/calculations"; import type { ScenarioDiff } from "@/lib/diff"; -import type { ElementInput, PlanInput } from "@/lib/types"; +import type { ElementInput, PersonRole, PlanInput } from "@/lib/types"; const CATEGORY_ICON: Record = { INCOME: , @@ -128,6 +136,11 @@ export function PlanView({ onChanged, onOpenSpec, onOpenSensitivity, + onOpenHistory, + onStartTour, + onCopyScenario, + onOpenTraces, + exportHref, }: { plan: PlanInput; computed: PlanComputed; @@ -140,6 +153,13 @@ export function PlanView({ onChanged: () => void; onOpenSpec?: (anchor: string) => void; onOpenSensitivity?: () => void; + // Schnellaktionen -- sie liegen beim Aufrufer, weil sie Dialoge ausserhalb dieser Ansicht + // oeffnen. Die Kachel dafuer sitzt seit 0.36 hier im Screen. + onOpenHistory?: () => void; + onStartTour?: () => void; + onCopyScenario?: () => void; + onOpenTraces?: () => void; + exportHref?: string; }) { // Planzahlen oder effektive Zahlen (inkl. Abweichung). Bewusst zwei Möglichkeiten statt // dreier: Plan UND Ist als Rohwerte nebeneinander wären mit nominal/real acht Zahlen je @@ -182,8 +202,10 @@ export function PlanView({ // weil beide mehrere Elemente auf einmal bearbeiten. const [distribute, setDistribute] = useState<{ kind: "capital" | "rates"; phaseId: string } | null>(null); const [valueMode, setValueMode] = useState("nominal"); - // Matrix oder Pensionierung. Kein URL-Zustand: Es ist eine Arbeitsansicht, kein Ort. - const [view, setView] = useState<"matrix" | "retirement">("matrix"); + // Offener Assistenten-Schritt (Index) bzw. offene Pensionsplanung (Rolle). + const [assistantStep, setAssistantStep] = useState(null); + const [retirementRole, setRetirementRole] = useState(null); + const progress = normalizeProgress(plan.assistantProgress); // Die Tour (Start-Knopf, Auto-Start bei Plan-Erstellung, Rendering) liegt seit dem // Layout-Umbau in AppShell -- sie liest die data-tour-Ziele im DOM dieser Ansicht. // Nur-Lese-Detailansicht (Roadmap Nr. 43). Der Rechenweg wird erst beim Öffnen erzeugt. @@ -366,72 +388,40 @@ export function PlanView({ onChanged(); } - // Kurzfassung der Pensionierung fuer die Matrix-Ansicht: die eine Zahl, die zaehlt, plus - // der Hinweis auf ungepruefte Vorgaben. Ein Klick fuehrt in den Bildschirm. - const retirementSummaryText = (() => { - const r = computed.retirement; - if (r.gapAnnual === null) return null; - const unconfirmed = plan.elements.filter( - (e) => RETIREMENT_CATEGORIES.includes(e.category) && !retirementConfirmed(e) - ).length; - const luecke = r.gapAnnual < 0 ? `Rentenluecke ${formatChf(r.gapAnnual)}/Jahr` : `Ueberschuss ${formatChf(r.gapAnnual)}/Jahr`; - return unconfirmed > 0 ? `${luecke} · ${unconfirmed} Vorgaben ungeprueft` : luecke; - })(); - const hasPhases = computed.phases.length > 0; const firstPhase = computed.phases[0] ?? null; - const lastPhase = computed.phases[computed.phases.length - 1] ?? null; return (
- setReviewFromPhaseId(fromPhaseId)} - onAddElement={(cat) => { - setAddCategory(cat); - setShowAdd(true); - }} - onAddPhase={() => setShowAddPhase(true)} - onOpenSensitivity={onOpenSensitivity} - /> - - {/* Kompakter Klotz: Grundprofil (25 %) · Zeitachse (50 %) · Kennzahlen (25 %). Auf - schmalen Screens stapeln die drei. */} -
- {/* Grundprofil -- Daten untereinander, «Bearbeiten» oben rechts. */} -
-
- - Grundprofil{diff?.profileChanged ? " · abweichend" : ""} - - -
-
- {plan.persons.map((p) => ( - - {personLabel(p.role)}: {p.age} J., Pension {p.retirementAge} - - ))} - Inflation {plan.inflationRateDefault}% -
+ {/* OBERER BEREICH -- die Steuerung. Farblich abgesetzt vom unteren, damit sichtbar + ist: hier stellst du ein, unten siehst du das Ergebnis. Vier Kacheln nebeneinander, + darunter die Zeitachse über die volle Breite. */} +
+
+ setPanel({ kind: "profile" })} + onRetirement={(role) => setRetirementRole(role)} + /> + + + setAssistantStep(i)} + onChanged={onChanged} + />
- {/* Zeitachse */} -
+
- - {/* Kennzahlen: Endvermögen nominal + real (+ Ruin, falls vorhanden). */} -
- Endvermögen -
-
Nominal
-
- {lastPhase ? formatChf(Math.round(lastPhase.endWealthNominal)) : "—"} -
-
-
-
Real (heutige Kaufkraft)
-
- {lastPhase ? formatChf(Math.round(lastPhase.endWealthReal)) : "—"} -
-
- {computed.ruinAge !== null && ( -
- Kapital reicht nur bis Alter {computed.ruinAge} -
- )} -
- {/* Ansicht: Matrix oder Pensionierung. Die Pensionierung steht bewusst gleichrangig - NEBEN der Matrix und nicht in ihr: Sie ist keine Zelle, sondern eine eigene Frage -- - und die einzige, die man zwischen Szenarien systematisch variiert. */} - {hasPhases && ( -
-
- {(["matrix", "retirement"] as const).map((v) => ( - - ))} -
- {view === "matrix" && retirementSummaryText && ( - - )} -
- )} - - {view === "retirement" && ( - - )} - {/* Anzeige-Umschalter (nominal/real, Plan/Ist) -- direkt über der Matrix, weil er nur deren Zahlen steuert. */} - {hasPhases && view === "matrix" && ( + {hasPhases && (
Anzeige
@@ -617,7 +548,7 @@ export function PlanView({ {/* Matrix: eigener Scrollbereich, damit Phasen-Köpfe (oben) UND Elementnamen (links) beim Scrollen sichtbar bleiben. */} - {hasPhases && view === "matrix" && ( + {hasPhases && (
{/* Feste Spaltenbreiten: Alle Phasenspalten sind gleich breit -- bei einer einzigen Phase bleibt die Tabelle dadurch schmal, bei vielen wird horizontal gescrollt. @@ -935,6 +866,43 @@ export function PlanView({ /> )} + {assistantStep !== null && ( + { + await api.post(`/api/scenarios/${plan.id}/assistant`, { step: assistantStep, done }); + onChanged(); + }} + onClose={() => setAssistantStep(null)} + > + {assistantStep === 0 && } + {assistantStep === 1 && } + {assistantStep === 2 && } + {assistantStep === 3 && ( + + )} + {assistantStep === 4 && ( + + )} + {assistantStep === 5 && ( + + )} + + )} + + {retirementRole && ( + setRetirementRole(null)} + xwide + > + + + )} + {reviewFromPhaseId && (() => { const fromPhase = computed.phases.find((p) => p.id === reviewFromPhaseId); if (!fromPhase) return null; @@ -1563,114 +1531,6 @@ function FragmentRows({ children }: { children: React.ReactNode }) { return <>{children}; } -// --- "Nächste Schritte": abgeleitete Checkliste ------------------------------------------ -// Führt neue (und wiederkehrende) Nutzer zum jeweils nächsten sinnvollen Schritt, statt -// sie vor der Matrix raten zu lassen. Alles wird aus den vorhandenen Daten abgeleitet -- -// kein eigener Zustand, kein API-Aufruf, verschwindet von selbst, wenn nichts offen ist. -function NextSteps({ - plan, - computed, - columns, - openCountFor, - onReview, - onAddElement, - onAddPhase, - onOpenSensitivity, -}: { - plan: PlanInput; - computed: PlanComputed; - columns: Column[]; - openCountFor: (fromPhase: PhaseComputed, toPhase: PhaseComputed) => DecisionCounts; - onReview: (fromPhaseId: string) => void; - onAddElement: (category: ElementCategory) => void; - onAddPhase: () => void; - onOpenSensitivity?: () => void; -}) { - if (computed.phases.length === 0) return null; - - const items: { key: string; text: string; action: string; run: () => void }[] = []; - - // Offene Übergangs-Entscheide (inkl. Cash) -- der wichtigste Handlungsbedarf. - let openTotal = 0; - let firstOpenPhaseId: string | null = null; - for (const col of columns) { - if (col.kind !== "transition") continue; - const n = openCountFor(col.fromPhase, col.toPhase); - openTotal += n.open; - if (n.open > 0 && firstOpenPhaseId === null) firstOpenPhaseId = col.fromPhase.id; - } - if (openTotal > 0 && firstOpenPhaseId) { - const target = firstOpenPhaseId; - items.push({ - key: "transitions", - text: `${openTotal} Übergangs-Entscheid${openTotal === 1 ? "" : "e"} offen – z. B. ob eine Immobilie verkauft wird.`, - action: "Jetzt durchgehen", - run: () => onReview(target), - }); - } - - if (!plan.elements.some((e) => e.category === "INCOME")) { - items.push({ - key: "income", - text: "Noch kein Einkommen erfasst – ohne Einkommen gibt es keine Sparquote und keine AHV-Rente.", - action: "Einkommen erfassen", - run: () => onAddElement("INCOME"), - }); - } - if (!plan.elements.some((e) => e.category === "EXPENSE")) { - items.push({ - key: "expense", - text: "Noch keine Ausgaben erfasst – sie sind erfahrungsgemäss der stärkste Hebel der ganzen Planung.", - action: "Ausgaben erfassen", - run: () => onAddElement("EXPENSE"), - }); - } - - // Plan endet vor der Pensionierung: die spannendste Phase fehlt noch. - const totalYears = computed.phases.reduce((s, p) => s + p.durationYears, 0); - if (plan.persons.some((p) => p.age + totalYears < p.retirementAge)) { - items.push({ - key: "pension-phase", - text: "Dein Plan endet vor der Pensionierung – die entscheidende Phase fehlt noch.", - action: "Phase anlegen", - run: onAddPhase, - }); - } - - if (computed.ruinAge !== null && onOpenSensitivity) { - items.push({ - key: "ruin", - text: `Das Kapital reicht nicht bis ans Planende (Ruin mit ${computed.ruinAge}). Finde heraus, welche Annahme am meisten bewegt.`, - action: "Einflussfaktoren", - run: onOpenSensitivity, - }); - } - - if (items.length === 0) return null; - - return ( -
-
- Nächste Schritte -
-
    - {items.map((it) => ( -
  • - {it.text} - -
  • - ))} -
-
- ); -} - // --- Dialog: neues finanzielles Element mit Direkteingabe der Phase-1-Werte --- function AddElementDialog({ plan, @@ -1932,7 +1792,7 @@ function ProfilePanel({ plan, onClose, onSaved }: { plan: PlanInput; onClose: () return (
- 0} /> + {error &&

{error}

}
@@ -2432,3 +2292,197 @@ function ElementMetaPanel({
); } + +// --- Kacheln des oberen Bereichs ----------------------------------------------------------- +// +// Vier gleichrangige Blöcke: was gilt (Grundeinstellungen), was herauskommt (Kennzahlen), +// was man tun kann (Schnellaktionen) und was als Nächstes ansteht (Assistent). Darunter die +// Zeitachse über die volle Breite -- sie gehört zu allen vieren. + +function Tile({ + title, + children, + action, + tour, +}: { + title: string; + children: React.ReactNode; + action?: React.ReactNode; + tour?: string; +}) { + return ( +
+
+ {title} + {action} +
+
{children}
+
+ ); +} + +function SettingsTile({ + plan, + computed, + diffChanged, + onEdit, + onRetirement, +}: { + plan: PlanInput; + computed: PlanComputed; + diffChanged: boolean; + onEdit: () => void; + onRetirement: (role: PersonRole) => void; +}) { + const horizon = plan.planningHorizonYears ?? null; + const endYear = plan.startYear && horizon ? plan.startYear + horizon - 1 : null; + + return ( + + + + } + > + {/* Plan-weit: gilt für alle Szenarien. */} +
+ {plan.persons.map((p) => ( +
+ {ownerLabel(plan.persons, p.role)} · {p.age} J. +
+ ))} +
Start {plan.startYear ?? "—"} · Inflation {plan.inflationRateDefault} %
+
+ + {/* Szenario-eigen: das, was man zwischen Varianten unterscheidet. */} +
+
+ Horizont {horizon ? `${horizon} Jahre` : "—"} + {endYear ? ` · bis ${endYear}` : ""} +
+ {plan.persons.map((p) => ( +
+ + Pension {p.retirementAge} + {horizon ? ` · Ende ${p.age + horizon}` : ""} + + +
+ ))} +
+ {computed.retirement.gapAnnual !== null && ( +
+ Rentenlücke {formatChf(computed.retirement.gapAnnual)}/Jahr +
+ )} +
+ ); +} + +function FiguresTile({ plan, computed }: { plan: PlanInput; computed: PlanComputed }) { + const last = computed.phases[computed.phases.length - 1] ?? null; + // «Vermögen heute» ist die Summe der erfassten Bestände. Sie steht hier, weil sie die + // einzige Kennzahl ist, die schon VOR jeder Zeitplanung etwas aussagt -- und damit die + // erste Rückmeldung auf die Bestandsaufnahme. + const today = plan.elements.reduce((sum, e) => { + const b = e.baseData ?? {}; + if (e.category === "OTHER_ASSET") return sum + num(b.startValue); + if (e.category === "PENSION_FUND" || e.category === "PILLAR_3A") return sum + num(b.currentValue); + if (e.category === "REAL_ESTATE") return sum + num(b.purchasePrice) - num(b.mortgage); + if (e.category === "OTHER_DEBT") return sum - num(b.startValue); + return sum; + }, plan.initialCash || 0); + + return ( + +
+
+
+
+ {computed.ruinAge !== null ? ( +
+ Kapital reicht nur bis Alter {computed.ruinAge} +
+ ) : ( + computed.phases.length > 0 && ( +
+ Kapital reicht über die ganze Planung +
+ ) + )} +
+ + ); +} + +function Figure({ label, value }: { label: string; value: number | null }) { + return ( +
+ {label} + + {value === null ? "—" : formatChf(value)} + +
+ ); +} + +function QuickActionsTile({ + onHistory, + onTour, + onNewScenario, + onTraces, + exportHref, +}: { + onHistory?: () => void; + onTour?: () => void; + onNewScenario?: () => void; + onTraces?: () => void; + exportHref?: string; +}) { + const cls = + "flex w-full items-center gap-1.5 rounded-lg border border-border px-2 py-1 text-[11px] font-medium text-muted transition-colors hover:bg-surface-2 hover:text-fg"; + return ( + + {onNewScenario && ( + + )} + {onTour && ( + + )} + {onHistory && ( + + )} + {onTraces && ( + + )} + {exportHref && ( + + CSV-Export + + )} + + ); +} diff --git a/src/components/PlanWizard.tsx b/src/components/PlanWizard.tsx deleted file mode 100644 index 0730599..0000000 --- a/src/components/PlanWizard.tsx +++ /dev/null @@ -1,1229 +0,0 @@ -"use client"; - -// Geführter Plan-Assistent (Roadmap Nr. 10): "Schritt für Schritt statt leerer Matrix". -// Der Wizard fragt in Alltagssprache und erzeugt am Ende Plan, Phasen und Elemente über -// die BESTEHENDEN API-Endpunkte -- reine Orchestrierung, keine Änderung an Berechnung -// oder Datenmodell. -// -// Schritt 2 (Lebensphasen) ist an den fixen Pensionierungszeitpunkten ausgerichtet: Die -// Lebenslinie zerfällt in Abschnitte (Erwerb / Misch / Pension, siehe lib/phaseplan). In den -// FESTEN Abschnitten (durch eine Pensionierung begrenzt) verteilt der Nutzer beliebig viele -// Phasen, deren Dauer exakt aufgehen muss; der letzte Pensions-Abschnitt ist offen. -// -// Reihenfolge beim Anlegen (wichtig): erst Phase 1, dann Elemente samt Phase-1-Werten, dann -// die Folgephasen -- so belegt die Phasen-Route die Folgephasen korrekt vor. -// -// Ehrliche Grenze: Bricht die Sequenz mittendrin ab (Netzfehler), existiert ein Teil-Plan -// (siehe SPEZIFIKATION 9.23). - -import { useMemo, useRef, useState } from "react"; -import { ArrowLeft, ArrowRight, CalendarRange, Check, Coins, Flag, PiggyBank, Plus, Sparkles, Trash2, User, Wallet } from "lucide-react"; -import { Modal, Button } from "@/components/ui"; -import { MoneyField, NumberField, TextField } from "@/components/FormField"; -import { InfoBubble } from "@/components/InfoBubble"; -import { PlanProfileFields, emptyProfileDraft, type ProfileDraft } from "@/components/PlanProfileFields"; -import { api } from "@/lib/api-client"; -import { planSegments, defaultOpenDuration, type PlanSegment, type SegmentType } from "@/lib/phaseplan"; -import { PILLAR_3A_MAX_ANNUAL, PILLAR_3A_MAX_SELF_EMPLOYED } from "@/lib/constants"; -import { computePlan } from "@/lib/calculations"; -import { formatChf } from "@/lib/format"; -import { RetirementFields } from "@/components/RetirementFields"; -import { RETIREMENT_CATEGORIES, type RetirementDecision } from "@/lib/retirement-decision"; -import type { ElementCategory, PhaseData } from "@/lib/elements"; -import type { ElementInput, PlanInput } from "@/lib/types"; - -// Ein anzulegendes Element. Dient zugleich als Bauplan für die rechenbare Vorschau, damit -// Vorschau und erzeugter Plan nicht auseinanderlaufen können. -interface ElementSpec { - category: string; - name: string; - ownerRole: string; - values?: PhaseData; -} - -interface PhaseDraft { - name: string; - durationYears: number; -} - -const STEP_TITLES = [ - "Über dich", - "Deine Lebensphasen", - "Einkommen & Ausgaben", - "Vorsorge & Vermögen", - "Sparen & Verteilen", - "Deine Pensionierung", - "Fertig", -]; - -// Die fünf «Tu-was»-Schritte für den Willkommens-Screen und die persistente Schritt-Leiste. -// «Fertig» (Zusammenfassung) ist der Abschluss und trägt keine eigene Nummer. -const WIZARD_OVERVIEW: { icon: React.ComponentType<{ className?: string }>; title: string; desc: string }[] = [ - { icon: User, title: "Über dich", desc: "Haushalt, Alter und Pensionsalter." }, - { icon: CalendarRange, title: "Lebensphasen", desc: "Die Abschnitte deiner Planung – bis zur Pension und danach." }, - { icon: Wallet, title: "Einkommen & Ausgaben", desc: "Was reinkommt, was rausgeht, was auf dem Konto liegt." }, - { icon: PiggyBank, title: "Vorsorge & Vermögen", desc: "Was du heute besitzt – PK, 3a, Wertschriften, Wohneigentum." }, - { icon: Coins, title: "Sparen & Verteilen", desc: "Deine Sparquote auf die Sparziele aufteilen." }, - { icon: Flag, title: "Deine Pensionierung", desc: "Was am Ende herauskommt – und was du daran drehen kannst." }, -]; - -const SEGMENT_META: Record = { - ERWERB: { - label: "Erwerb", - phase: "Erwerbsphase", - def: "Alle arbeiten noch – Einkommen fliesst, Vorsorge wächst.", - color: "bg-accent/70 text-accent-fg", - soft: "border-accent", - }, - MIXED: { - label: "Misch", - phase: "Übergangsphase", - def: "Eine Person ist pensioniert, die andere arbeitet noch.", - color: "bg-accent/40 text-accent-fg", - soft: "border-accent", - }, - PENSION: { - label: "Pension", - phase: "Pensionsphase", - def: "Alle sind pensioniert – gelebt wird aus Renten und Vermögen.", - color: "bg-accent-soft text-accent-soft-fg", - soft: "border-accent", - }, -}; - -function personsSignature(draft: ProfileDraft): string { - return draft.householdType + "|" + draft.persons.map((p) => `${p.age}/${p.retirementAge}`).join(","); -} - -// Eine Phase je Abschnitt als Startpunkt: feste Länge in den begrenzten Abschnitten, eine -// sinnvolle Vorgabe im offenen Pensions-Abschnitt. -function defaultSegPhases(segments: PlanSegment[], persons: { age: number }[]): PhaseDraft[][] { - const typeCount: Record = { ERWERB: 0, MIXED: 0, PENSION: 0 }; - return segments.map((seg) => { - typeCount[seg.type] += 1; - const dur = seg.fixedYears ?? defaultOpenDuration(persons, seg.fromYear); - return [{ name: SEGMENT_META[seg.type].phase, durationYears: Math.max(1, dur) }]; - }); -} - -// --- Vermögens-/Vorsorge-Erfassung je Eigentümer-Bereich --------------------------------- - -type Scope = "SELF" | "HOUSEHOLD" | "PERSON_A" | "PERSON_B"; -type Category = "pk" | "p3a" | "etf" | "re" | "debt"; - -interface ScopeAssets { - pk: { on: boolean; value: number; contribution: number; ret: number }; - // selfEmployed: «grosse Säule 3a» ohne PK -> höhere Obergrenze (Roadmap-Feedback C7c). - p3a: { on: boolean; value: number; contribution: number; selfEmployed: boolean; ret: number }; - etf: { on: boolean; value: number; contribution: number; ret: number }; - // growth = Wertsteigerung %/Jahr; interestIncluded = Zinsen bereits in den Ausgaben. - re: { on: boolean; price: number; mortgage: number; amortization: number; interest: number; growth: number; interestIncluded: boolean }; - debt: { on: boolean; value: number; repayment: number }; -} - -function emptyScope(): ScopeAssets { - return { - pk: { on: false, value: 0, contribution: 0, ret: 1.5 }, - p3a: { on: false, value: 0, contribution: 0, selfEmployed: false, ret: 2 }, - etf: { on: false, value: 0, contribution: 0, ret: 5 }, - re: { on: false, price: 0, mortgage: 0, amortization: 0, interest: 1.5, growth: 1, interestIncluded: true }, - debt: { on: false, value: 0, repayment: 0 }, - }; -} - -export function PlanWizard({ - onCreated, - onClose, -}: { - onCreated: (scenarioId: string) => void; - onClose: () => void; -}) { - const [started, setStarted] = useState(false); - const [step, setStep] = useState(0); - const [planName, setPlanName] = useState("Meine Planung"); - const [profile, setProfile] = useState(emptyProfileDraft); - - // Abschnitte der Lebenslinie und die je Abschnitt geplanten Phasen (parallel indexiert). - const segments = useMemo(() => planSegments(profile.persons), [profile.persons]); - const [segPhases, setSegPhases] = useState([]); - const segSig = useRef(""); - - // Einkommen bewusst PRO PERSON: In Paar-Plänen zählt "Gemeinsam" nicht für die AHV-Rente - // (SPEZIFIKATION 9.9) -- der Assistent räumt diese Falle von Anfang an aus. - const [incomes, setIncomes] = useState>({ PERSON_A: 0 }); - const [expenses, setExpenses] = useState(0); - const [initialCash, setInitialCash] = useState(0); - - const [assets, setAssets] = useState>({ - SELF: emptyScope(), - HOUSEHOLD: emptyScope(), - PERSON_A: emptyScope(), - PERSON_B: emptyScope(), - }); - - const [saving, setSaving] = useState(false); - const [error, setError] = useState(null); - - const isCouple = profile.householdType === "COUPLE"; - const personLabel = (role: string) => { - const p = profile.persons.find((x) => x.role === role); - return p?.name?.trim() || (role === "PERSON_A" ? "Person A" : "Person B"); - }; - - function setScope(scope: Scope, cat: Category, patch: Record) { - setAssets((prev) => ({ ...prev, [scope]: { ...prev[scope], [cat]: { ...prev[scope][cat], ...patch } } })); - } - - function goNext() { - if (step === 0) { - // Abschnitte neu ableiten; Phasen nur zurücksetzen, wenn sich das Profil strukturell - // geändert hat (sonst blieben Bearbeitungen des Nutzers erhalten). - const sig = personsSignature(profile); - if (sig !== segSig.current || segPhases.length === 0) { - setSegPhases(defaultSegPhases(segments, profile.persons)); - segSig.current = sig; - } - const inc: Record = {}; - for (const p of profile.persons) inc[p.role] = incomes[p.role] ?? 0; - setIncomes(inc); - } - setStep((s) => Math.min(s + 1, STEP_TITLES.length - 1)); - } - - // --- Validierung Schritt 1 --- - const segSums = segPhases.map((phs) => phs.reduce((s, p) => s + Math.max(0, p.durationYears), 0)); - const step1Valid = - segPhases.length === segments.length && - segments.every((seg, i) => { - const phs = segPhases[i] ?? []; - if (phs.length === 0) return false; - if (phs.some((p) => p.durationYears < 1)) return false; - return seg.fixedYears === null ? segSums[i] >= 1 : segSums[i] === seg.fixedYears; - }); - - // Pensionierungs-Entscheide je Person, im Assistenten getroffen. Sie werden erst NACH dem - // Anlegen geschrieben -- vorher gibt es keine Element-Ids, an denen sie hängen könnten. - const [retireDraft, setRetireDraft] = useState>({}); - - // Flache, geordnete Phasenliste (für Vorschau, Timeline und Anlegen). - const flatPhases: PhaseDraft[] = segPhases.flat(); - - - function updatePhase(segIdx: number, phaseIdx: number, patch: Partial) { - setSegPhases((prev) => - prev.map((phs, i) => (i === segIdx ? phs.map((p, j) => (j === phaseIdx ? { ...p, ...patch } : p)) : phs)) - ); - } - function addPhase(segIdx: number) { - const seg = segments[segIdx]; - const rest = seg.fixedYears === null ? 5 : Math.max(0, seg.fixedYears - segSums[segIdx]); - setSegPhases((prev) => - prev.map((phs, i) => - i === segIdx - ? [...phs, { name: `${SEGMENT_META[seg.type].phase} ${phs.length + 1}`, durationYears: Math.max(1, rest) }] - : phs - ) - ); - } - function removePhase(segIdx: number, phaseIdx: number) { - setSegPhases((prev) => prev.map((phs, i) => (i === segIdx ? phs.filter((_, j) => j !== phaseIdx) : phs))); - } - - async function create() { - setSaving(true); - setError(null); - try { - const { scenario } = await api.post<{ plan: { id: string }; scenario: { id: string } }>("/api/plans", { - name: planName.trim() || "Meine Planung", - householdType: profile.householdType, - inflationRateDefault: profile.inflationRateDefault, - startYear: profile.startYear, - persons: profile.persons.map((p) => ({ role: p.role, name: p.name.trim() || null, age: p.age, retirementAge: p.retirementAge })), - }); - const sid = scenario.id; - if (initialCash > 0) await api.patch(`/api/scenarios/${sid}`, { initialCash }); - - const first = await api.post<{ phase: { id: string } }>(`/api/scenarios/${sid}/phases`, { - name: flatPhases[0].name, - durationYears: flatPhases[0].durationYears, - }); - const firstPhaseId = first.phase.id; - - async function addEl(category: string, name: string, ownerRole: string, values?: PhaseData) { - const { element } = await api.post<{ element: { id: string } }>(`/api/scenarios/${sid}/elements`, { - category, - name, - ownerRole, - }); - if (values) await api.put(`/api/elements/${element.id}/phase/${firstPhaseId}`, values); - return element.id; - } - - // Genau die Liste, aus der auch die Vorschau gerechnet wurde. - for (const spec of elementSpecs) { - const id = await addEl(spec.category, spec.name, spec.ownerRole, spec.values); - // Pensionierungs-Entscheid nur dort, wo er hingehört -- und nur, wenn der Nutzer im - // Überblicksschritt etwas verändert hat. - const rd = retireDraft[spec.ownerRole]; - if (id && rd && Object.keys(rd).length > 0 && RETIREMENT_CATEGORIES.includes(spec.category as ElementCategory)) { - await api.put(`/api/elements/${id}/retirement`, rd); - } - } - - // Vermögen/Vorsorge je Eigentümer-Bereich anlegen. - for (const scope of renderedScopes) { - const a = assets[scope.id]; - const suffix = scope.nameSuffix; // z. B. " Anna" oder " (gemeinsam)" - if (scope.showVorsorge) { - if (a.pk.on) { - await addEl("PENSION_FUND", `Pensionskasse${suffix}`, scope.vorsorgeRole!, { - currentValue: a.pk.value, - annualContribution: a.pk.contribution, - expectedReturn: a.pk.ret, - }); - } - if (a.p3a.on) { - await addEl("PILLAR_3A", `Säule 3a${suffix}`, scope.vorsorgeRole!, { - currentValue: a.p3a.value, - annualContribution: Math.min(a.p3a.contribution, max3aFor(scope.id)), - expectedReturn: a.p3a.ret, - selfEmployed3a: a.p3a.selfEmployed, - }); - } - } - if (a.etf.on) { - await addEl("OTHER_ASSET", `Wertschriften${suffix}`, scope.otherRole, { - startValue: a.etf.value, - annualContribution: a.etf.contribution, - expectedReturn: a.etf.ret, - }); - } - if (a.re.on) { - await addEl("REAL_ESTATE", `Wohneigentum${suffix}`, scope.otherRole, { - purchasePrice: a.re.price, - mortgage: a.re.mortgage, - amortization: a.re.amortization, - interestRate: a.re.interest, - valueGrowth: a.re.growth, - interestHandling: a.re.interestIncluded ? "INCLUDED" : "ADD", - }); - } - if (a.debt.on) { - await addEl("OTHER_DEBT", `Schulden${suffix}`, scope.otherRole, { - startValue: a.debt.value, - annualRepayment: a.debt.repayment, - }); - } - } - - for (const ph of flatPhases.slice(1)) { - await api.post(`/api/scenarios/${sid}/phases`, { name: ph.name, durationYears: ph.durationYears }); - } - - onCreated(sid); - } catch (e) { - setError( - (e instanceof Error ? e.message : "Erstellen fehlgeschlagen.") + - " – Falls der Plan teilweise angelegt wurde, findest du ihn in der Seitenleiste und kannst dort normal weiterarbeiten." - ); - setSaving(false); - } - } - - // Welche Eigentümer-Bereiche in Schritt 4 gezeigt werden. - const renderedScopes: { - id: Scope; - label: string; - nameSuffix: string; - showVorsorge: boolean; - vorsorgeRole: string | null; - otherRole: string; - cats: Category[]; - }[] = isCouple - ? [ - { id: "HOUSEHOLD", label: "Gemeinsam", nameSuffix: " (gemeinsam)", showVorsorge: false, vorsorgeRole: null, otherRole: "HOUSEHOLD", cats: ["etf", "re", "debt"] }, - { id: "PERSON_A", label: personLabel("PERSON_A"), nameSuffix: ` ${personLabel("PERSON_A")}`, showVorsorge: true, vorsorgeRole: "PERSON_A", otherRole: "PERSON_A", cats: ["pk", "p3a", "etf", "re", "debt"] }, - { id: "PERSON_B", label: personLabel("PERSON_B"), nameSuffix: ` ${personLabel("PERSON_B")}`, showVorsorge: true, vorsorgeRole: "PERSON_B", otherRole: "PERSON_B", cats: ["pk", "p3a", "etf", "re", "debt"] }, - ] - : [ - { id: "SELF", label: "Deine Angaben", nameSuffix: "", showVorsorge: true, vorsorgeRole: "PERSON_A", otherRole: "HOUSEHOLD", cats: ["pk", "p3a", "etf", "re", "debt"] }, - ]; - - const enabledAssetCount = renderedScopes.reduce((n, sc) => { - const a = assets[sc.id]; - return n + sc.cats.filter((c) => a[c].on).length; - }, 0); - - // Die anzulegenden Elemente -- EINE Liste, die sowohl die Pensionierungs-Vorschau als auch - // das Anlegen speist. Zwei Aufbauten desselben Plans würden garantiert auseinanderlaufen, - // und die Vorschau zeigte dann Zahlen, die der erzeugte Plan nie hat. - const elementSpecs = useMemo(() => { - const out: ElementSpec[] = []; - for (const person of profile.persons) { - const amount = incomes[person.role] ?? 0; - // 1 % nominale Lohnerhöhung als dezenter Default -- 0 % liesse den Lohn real verlieren. - if (amount > 0) - out.push({ - category: "INCOME", - name: `Lohn ${personLabel(person.role)}`, - ownerRole: person.role, - values: { amount, teuerungsausgleich: 1 }, - }); - } - if (expenses > 0) - out.push({ - category: "EXPENSE", - name: "Lebenshaltung", - ownerRole: "HOUSEHOLD", - values: { amount: expenses, teuerungsausgleich: 0 }, - }); - for (const person of profile.persons) - out.push({ - category: "AHV", - name: isCouple ? `AHV ${personLabel(person.role)}` : "AHV", - ownerRole: person.role, - values: { gapYears: 0 }, - }); - - for (const scope of renderedScopes) { - const a = assets[scope.id]; - const suffix = scope.nameSuffix; - if (scope.showVorsorge) { - if (a.pk.on) - out.push({ - category: "PENSION_FUND", - name: `Pensionskasse${suffix}`, - ownerRole: scope.vorsorgeRole!, - values: { currentValue: a.pk.value, annualContribution: a.pk.contribution, expectedReturn: a.pk.ret }, - }); - if (a.p3a.on) - out.push({ - category: "PILLAR_3A", - name: `Säule 3a${suffix}`, - ownerRole: scope.vorsorgeRole!, - values: { - currentValue: a.p3a.value, - annualContribution: Math.min(a.p3a.contribution, max3aFor(scope.id)), - expectedReturn: a.p3a.ret, - selfEmployed3a: a.p3a.selfEmployed, - }, - }); - } - if (a.etf.on) - out.push({ - category: "OTHER_ASSET", - name: `Wertschriften${suffix}`, - ownerRole: scope.otherRole, - values: { startValue: a.etf.value, annualContribution: a.etf.contribution, expectedReturn: a.etf.ret }, - }); - if (a.re.on) - out.push({ - category: "REAL_ESTATE", - name: `Wohneigentum${suffix}`, - ownerRole: scope.otherRole, - values: { - purchasePrice: a.re.price, - mortgage: a.re.mortgage, - amortization: a.re.amortization, - interestRate: a.re.interest, - valueGrowth: a.re.growth, - interestHandling: a.re.interestIncluded ? "INCLUDED" : "ADD", - }, - }); - if (a.debt.on) - out.push({ - category: "OTHER_DEBT", - name: `Schulden${suffix}`, - ownerRole: scope.otherRole, - values: { startValue: a.debt.value, annualRepayment: a.debt.repayment }, - }); - } - return out; - // eslint-disable-next-line react-hooks/exhaustive-deps -- renderedScopes/max3aFor folgen denselben Zuständen - }, [profile, incomes, expenses, assets, isCouple]); - - // Rechenbare Vorschau OHNE dass der Plan existiert. computePlan ist rein und läuft im - // Browser in Bruchteilen einer Millisekunde -- genau dieselbe Rechnung wie später. - const previewPlan: PlanInput = { - id: "preview", - name: planName.trim() || "Meine Planung", - householdType: profile.householdType, - inflationRateDefault: profile.inflationRateDefault, - initialCash, - startYear: profile.startYear, - persons: profile.persons.map((x) => ({ - id: x.role, - role: x.role, - name: x.name.trim() || null, - age: x.age, - retirementAge: x.retirementAge, - planningHorizonAge: null, - })), - phases: flatPhases.map((ph, i) => ({ - id: `ph${i}`, - sequenceNumber: i + 1, - name: ph.name, - durationYears: ph.durationYears, - cashTransition: {}, - })), - elements: elementSpecs.map((spec, i) => ({ - id: `el${i}`, - category: spec.category as ElementCategory, - name: spec.name, - ownerRole: spec.ownerRole as ElementInput["ownerRole"], - orderIndex: i, - // Werte gelten für Phase 1; die Folgephasen erben live (Punkt A). - phaseValues: { ph0: spec.values ?? {} }, - transitionValues: {}, - retirementDecision: retireDraft[spec.ownerRole] ?? {}, - })), - }; - - // Eine unvollständige Zwischeneingabe darf den Assistenten nicht abstürzen lassen. - let previewComputed: ReturnType | null = null; - try { - previewComputed = computePlan(previewPlan); - } catch { - previewComputed = null; - } - - // --- Sparquote (Schritt "Sparen & Verteilen") --- - // Die Sparquote entsteht aus Nettoeinkommen minus Ausgaben. PK-Beiträge kommen VOM - // Bruttolohn (vor dem Netto) und reduzieren sie deshalb NICHT -- genau wie im Rechenkern. - const totalIncome = profile.persons.reduce((s, p) => s + (incomes[p.role] ?? 0), 0); - // Hypothekarzinsen, die NICHT bereits in den Ausgaben stecken, werden hier den Ausgaben - // zugerechnet -- so wie sie der Rechenkern bei "interestHandling: ADD" behandelt. Sonst - // zeigte die Vorschau eine zu hohe Sparquote. - const addedInterest = renderedScopes.reduce((sum, sc) => { - const re = assets[sc.id].re; - return sum + (re.on && !re.interestIncluded ? Math.round((re.mortgage * re.interest) / 100) : 0); - }, 0); - const effectiveExpenses = expenses + addedInterest; - const sparBase = totalIncome - effectiveExpenses; - const allocatedFromQuote = renderedScopes.reduce((sum, sc) => { - const a = assets[sc.id]; - return ( - sum + - (a.p3a.on ? a.p3a.contribution : 0) + - (a.etf.on ? a.etf.contribution : 0) + - (a.re.on ? a.re.amortization : 0) + - (a.debt.on ? a.debt.repayment : 0) - ); - }, 0); - const restToCash = sparBase - allocatedFromQuote; - const anyPk = renderedScopes.some((sc) => assets[sc.id].pk.on); - const anyQuoteTarget = renderedScopes.some((sc) => { - const a = assets[sc.id]; - return a.p3a.on || a.etf.on || a.re.on || a.debt.on; - }); - const scopeTag = (sc: { label: string }) => (isCouple ? ` · ${sc.label}` : ""); - const max3aFor = (sc: Scope) => (assets[sc].p3a.selfEmployed ? PILLAR_3A_MAX_SELF_EMPLOYED : PILLAR_3A_MAX_ANNUAL); - - // Willkommens-Screen vor dem ersten Schritt: das Gesamtbild der fünf Schritte, damit der - // Nutzer von Anfang an weiss, was auf ihn zukommt (Roadmap-Feedback). - if (!started) { - return ( - -
-

- Erstelle deinen Finanzplan in fünf einfachen Schritten – das dauert nur ein paar Minuten, und alles lässt - sich später jederzeit ändern. -

-
- {WIZARD_OVERVIEW.map((s, i) => ( -
- - - -
-
- {i + 1}. {s.title} -
-

{s.desc}

-
-
- ))} -
-
- - -
-
-
- ); - } - - return ( - -
- {/* Persistente Schritt-Leiste (Desktop): der Nutzer sieht immer, wo er steht und was folgt. */} - - -
- {/* Fortschritt (Mobile, wo die Leiste ausgeblendet ist). */} -
- {WIZARD_OVERVIEW.map((_, i) => ( -
- ))} -
- - {step === 0 && ( -
-

- Ein paar Angaben zu dir – daraus baut das Tool dein Grundgerüst. Alles lässt sich später ändern. -

- - -
- )} - - {step === 1 && ( -
-

- Deine Planung besteht aus Lebensphasen. Das Pensionsalter setzt feste - Grenzen auf der Lebenslinie – dazwischen kannst du beliebig viele Phasen einplanen. -

- - - - {segments.map((seg, segIdx) => { - const meta = SEGMENT_META[seg.type]; - const sum = segSums[segIdx] ?? 0; - const fixed = seg.fixedYears; - const ok = fixed === null ? sum >= 1 : sum === fixed; - return ( -
-
- - Teil {segIdx + 1}: {meta.label} - - {fixed === null ? ( - - Dauer frei · aktuell {sum} J. - - ) : ( - - Summe {sum} / {fixed} Jahre{ok ? " ✓" : ""} - - )} -
-

{meta.def}

- -
- {(segPhases[segIdx] ?? []).map((ph, phaseIdx) => ( -
- updatePhase(segIdx, phaseIdx, { name: v })} - /> - updatePhase(segIdx, phaseIdx, { durationYears: Math.max(1, Math.round(v)) })} - /> - -
- ))} -
- - - - {fixed !== null && !ok && ( -

- Dieser Teil ist durch die Pensionierung auf genau {fixed} Jahre festgelegt. Die Phasendauern müssen - zusammen {fixed} ergeben (aktuell {sum}). -

- )} -
- ); - })} -
- )} - - {step === 2 && ( -
-

Die wichtigsten Zahlen deiner Planung – grob geschätzt reicht für den Anfang.

- {profile.persons.map((p) => { - const nm = profile.persons.find((x) => x.role === p.role)?.name?.trim(); - return ( - setIncomes((prev) => ({ ...prev, [p.role]: v }))} - /> - ); - })} - - -
- )} - - {step === 3 && ( -
-

- {isCouple ? "Was besitzt ihr?" : "Was besitzt du?"} Hake an, was zutrifft – nur das Angehakte fragt nach Details. - Hier geht es nur um die heutigen Werte; die jährlichen Einzahlungen und - Amortisationen verteilst du im nächsten Schritt aus deiner Sparquote. - {isCouple && " Pensionskasse und Säule 3a sind immer persönlich; Wertschriften, Wohneigentum und Schulden könnt ihr gemeinsam oder je Person erfassen."} -

- {renderedScopes.map((sc) => ( -
- {isCouple && ( -
{sc.label}
- )} -
- {sc.cats.includes("pk") && ( - setScope(sc.id, "pk", { on: v })} - > - setScope(sc.id, "pk", { value: v })} /> - setScope(sc.id, "pk", { ret: v })} /> - - )} - {sc.cats.includes("p3a") && ( - setScope(sc.id, "p3a", { on: v })} - > - setScope(sc.id, "p3a", { value: v })} /> - setScope(sc.id, "p3a", { ret: v })} /> - - )} - {sc.cats.includes("etf") && ( - setScope(sc.id, "etf", { on: v })} - > - setScope(sc.id, "etf", { value: v })} /> - setScope(sc.id, "etf", { ret: v })} /> - - )} - {sc.cats.includes("re") && ( - setScope(sc.id, "re", { on: v })} - > - setScope(sc.id, "re", { price: v })} /> - setScope(sc.id, "re", { mortgage: v })} /> - setScope(sc.id, "re", { interest: v })} /> - setScope(sc.id, "re", { growth: v })} /> - - - )} - {sc.cats.includes("debt") && ( - setScope(sc.id, "debt", { on: v })} - > - setScope(sc.id, "debt", { value: v })} /> - - )} -
-
- ))} -

- Die AHV wird automatisch für {isCouple ? "beide Personen" : "dich"} angelegt – die Rente berechnet das Tool - aus dem Einkommen. -

-
- )} - - {step === 4 && ( -
- {/* Sparquote-Karte: das Kernmodell des Tools zum Anfassen. */} -
= 0 ? "border-accent bg-accent-soft/30" : "border-danger bg-danger-soft"}`}> -
Deine jährliche Sparquote
-
- {totalIncome.toLocaleString("de-CH")} CHF Einkommen - - {effectiveExpenses.toLocaleString("de-CH")} CHF Ausgaben - = - = 0 ? "text-fg" : "text-danger"}`}> - {sparBase.toLocaleString("de-CH")} CHF / Jahr - -
- {addedInterest > 0 && ( -

- Enthält {addedInterest.toLocaleString("de-CH")} CHF Hypothekarzinsen, die du im vorigen Schritt als «noch - nicht in den Ausgaben» markiert hast. -

- )} -

- {sparBase >= 0 - ? "Dieser Betrag bleibt dir jährlich zum Sparen, Amortisieren oder Anlegen. Verteile ihn unten – was übrig bleibt, wächst auf dem Cash-Konto." - : "Du gibst mehr aus, als du einnimmst – die Differenz wird laufend aus deinem Vermögen bzw. Cash gedeckt. Einzahlungen unten sind dann nur sinnvoll, wenn genügend Cash vorhanden ist."} -

-
- - {/* PK-Einzahlung: bewusst SEPARAT, weil sie vom Bruttolohn kommt und die Sparquote - nicht schmälert -- exakt wie der Rechenkern es behandelt. */} - {anyPk && ( -
-
- Pensionskassen-Einzahlung -
-

- Wird direkt vom Bruttolohn bezahlt – also bevor dein - Nettoeinkommen entsteht. Sie schmälert deine Sparquote deshalb nicht. -

-
- {renderedScopes.filter((sc) => assets[sc.id].pk.on).map((sc) => ( - setScope(sc.id, "pk", { contribution: v })} - /> - ))} -
-
- )} - - {/* "Noch zu verteilen" prominent -- direkt unter der PK, ueber der Verteilung. */} - {anyQuoteTarget && ( -
- Noch nicht verteilt (bleibt auf dem Cash-Konto) - - {restToCash.toLocaleString("de-CH")} CHF / Jahr - -
- )} - {anyQuoteTarget && restToCash < 0 && ( -

- Du verteilst mehr, als deine Sparquote hergibt – die Differenz muss aus dem Cash-Bestand kommen und kann ihn - ins Minus ziehen. -

- )} - - {/* Verteilung der Sparquote -- gruppiert nach Eigentuemer-Bereich (Gemeinsam/A/B). */} - {anyQuoteTarget ? ( -
- {renderedScopes - .filter((sc) => { - const a = assets[sc.id]; - return a.p3a.on || a.etf.on || a.re.on || a.debt.on; - }) - .map((sc) => ( -
-
- {isCouple ? sc.label : "Wohin fliesst deine Sparquote?"} -
-
- {assets[sc.id].p3a.on && ( -
- -
- setScope(sc.id, "p3a", { contribution: v })} - /> -
-
- )} - {assets[sc.id].etf.on && ( - setScope(sc.id, "etf", { contribution: v })} - /> - )} - {assets[sc.id].re.on && ( - setScope(sc.id, "re", { amortization: v })} - /> - )} - {assets[sc.id].debt.on && ( - setScope(sc.id, "debt", { repayment: v })} - /> - )} -
-
- ))} -
- ) : ( -

- Du hast im vorigen Schritt keine Spar-/Vorsorgeposten angehakt, in die deine Sparquote fliessen könnte. Der - ganze Betrag wächst dann auf dem Cash-Konto. Zurück im vorigen Schritt kannst du z. B. Wertschriften oder die - Säule 3a ergänzen. -

- )} -
- )} - - {/* Überblick statt Erfassung: Zu diesem Zeitpunkt weiss niemand, wie hoch seine PK-Rente - sein wird -- danach zu fragen hiesse, eine unbeantwortbare Frage zu stellen. Die - Antwort zu ZEIGEN ist dagegen der stärkste Moment im ganzen Onboarding. Was hier - steht, sind die Vorgaben; ändern kann man sie, muss aber nicht. */} - {step === 5 && ( -
-

- So sieht deine Pensionierung mit den bisherigen Angaben aus. Das Tool rechnet mit sinnvollen Vorgaben – - du kannst sie jetzt anpassen oder später jederzeit im Bildschirm «Pensionierung». -

- - {previewComputed && previewComputed.retirement.gapAnnual !== null ? ( - <> -
-
-
- {previewComputed.retirement.perPerson.map((r) => ( -
- - {personLabel(r.role)} · AHV + PK - (ab {r.retirementAge}) - - {formatChf(r.ahvAnnual + r.pkPensionAnnual)} -
- ))} -
-
- Renteneinkommen - - {formatChf(previewComputed.retirement.pensionIncome ?? 0)} - -
-
- Ausgaben - - {formatChf(previewComputed.retirement.expenses ?? 0)} - -
-
- -
-
-
- Rentenlücke - -
-
- {formatChf(previewComputed.retirement.gapAnnual ?? 0)} / Jahr -
-
-
-
Vermögen reicht
-
- {previewComputed.ruinAge === null - ? "über die ganze Planung" - : `bis Alter ${previewComputed.ruinAge}`} -
-
-
-
-
- - {/* Dieselben Bausteine wie im Pensionierungs-Bildschirm -- eine Implementierung. */} -
- {previewPlan.elements - .filter((e) => RETIREMENT_CATEGORIES.includes(e.category)) - .map((e) => ( - - setRetireDraft((prev) => ({ - ...prev, - [e.ownerRole ?? "HOUSEHOLD"]: { ...(prev[e.ownerRole ?? "HOUSEHOLD"] ?? {}), ...patch }, - })) - } - summary={previewComputed.retirement.perPerson.find((x) => x.role === e.ownerRole)} - /> - ))} -
- - ) : ( -

- Für eine Vorschau fehlen noch Angaben – lege mindestens eine Lebensphase nach der Pensionierung sowie - Einkommen und Ausgaben an. Du kannst den Plan trotzdem erstellen und alles später ergänzen. -

- )} -
- )} - - {step === 6 && ( -
-

Das wird angelegt:

-
    -
  • 📋 Plan «{planName.trim() || "Meine Planung"}» mit Basisszenario
  • -
  • 📅 {flatPhases.map((p) => `${p.name} (${p.durationYears} J.)`).join(" → ")}
  • -
  • - 💰 {profile.persons.filter((p) => (incomes[p.role] ?? 0) > 0).length > 0 ? "Einkommen, " : ""} - {expenses > 0 ? "Ausgaben, " : ""}AHV - {enabledAssetCount > 0 ? ` + ${enabledAssetCount} Vermögens-/Vorsorgeposten` : ""} -
  • -
-

- Danach zeigt dir eine kurze Tour die Matrix – und die offenen{" "} - Übergangs-Entscheide (z. B. was bei der Pensionierung mit der PK - passiert) gehst du direkt in der Ansicht durch. -

-
- )} - - {error &&

{error}

} - -
- -
- {/* Zurück führt in Schritt 0 auf den Willkommens-Screen. */} - - {step < STEP_TITLES.length - 1 ? ( - - ) : ( - - )} -
-
-
-
- - ); -} - -// Zeitachse: proportionaler Balken mit nummerierten Phasen; Pensionierungs-Fixpunkte als -// Flaggen darüber; die Beschriftung steht als nummerierte Liste UNTER dem Balken, damit auch -// kurze Phasen sauber lesbar sind. -function WizardTimeline({ - segments, - segPhases, - profile, -}: { - segments: PlanSegment[]; - segPhases: PhaseDraft[][]; - profile: ProfileDraft; -}) { - const flat = segments.flatMap((seg, segIdx) => - (segPhases[segIdx] ?? []).map((ph) => ({ ...ph, type: seg.type })) - ); - const total = flat.reduce((s, p) => s + Math.max(1, p.durationYears), 0); - if (total <= 0 || flat.length === 0) return null; - - const minAge = Math.min(...profile.persons.map((p) => p.age)); - const markers = profile.persons - .map((p) => ({ label: p.name?.trim() || (p.role === "PERSON_A" ? "A" : "B"), year: Math.max(0, p.retirementAge - p.age), age: p.retirementAge, role: p.role })) - .filter((m) => m.year > 0 && m.year < total); - - const durs = flat.map((ph) => Math.max(1, ph.durationYears)); - const numbered = flat.map((ph, i) => { - const from = durs.slice(0, i).reduce((s, d) => s + d, 0); - return { ...ph, index: i + 1, from, to: from + durs[i] }; - }); - - return ( -
-
- {/* Pensionierungs-Fixpunkte */} - {markers.map((m) => ( -
- - {m.label} · {m.age} - -
-
- ))} - - {/* Balken mit nummerierten Segmenten */} -
- {numbered.map((ph, i) => ( -
0 ? "border-l border-border/50" : ""}`} - > - {ph.index} -
- ))} -
- - {/* Alters-Achse */} -
- {minAge} J. - {minAge + total} J. -
-
- - {/* Beschriftung unter dem Balken */} -
- {numbered.map((ph, i) => ( - - - {ph.index} - - {ph.name} · {ph.durationYears} J. - - ))} -
-
- ); -} - -function WizardToggle({ - label, - help, - checked, - onChange, - children, -}: { - label: string; - help: string; - checked: boolean; - onChange: (v: boolean) => void; - children: React.ReactNode; -}) { - return ( -
- - {checked &&
{children}
} -
- ); -} diff --git a/src/components/RetirementPanel.tsx b/src/components/RetirementPanel.tsx index b720c04..8b7bd8e 100644 --- a/src/components/RetirementPanel.tsx +++ b/src/components/RetirementPanel.tsx @@ -23,17 +23,11 @@ import { AlertTriangle } from "lucide-react"; import { api } from "@/lib/api-client"; import { Button, useToast } from "@/components/ui"; import { InfoBubble } from "@/components/InfoBubble"; -import { NumberField } from "@/components/FormField"; import { RetirementAdjuster } from "@/components/RetirementAdjuster"; import { RetirementFields } from "@/components/RetirementFields"; import { formatChf } from "@/lib/format"; import { ownerLabel } from "@/lib/elements"; -import { - AHV_REFERENCE_AGE, - MAX_PLANNING_HORIZON_AGE, - MIN_PLANNING_HORIZON_AGE, - PK_MIN_RETIREMENT_AGE, -} from "@/lib/constants"; +import { AHV_REFERENCE_AGE, PK_MIN_RETIREMENT_AGE } from "@/lib/constants"; import { withRetirementDefaults, type RetirementDecision } from "@/lib/retirement-decision"; import type { PlanComputed, RetirementPersonSummary } from "@/lib/calculations"; import type { ElementInput, PersonRole, PlanInput } from "@/lib/types"; @@ -43,10 +37,14 @@ type Draft = Record; export function RetirementPanel({ plan, computed, + role, onSaved, }: { plan: PlanInput; computed: PlanComputed; + // Seit 0.36 immer GENAU eine Person: Der Bildschirm wird über den Knopf «Pensionsplanung» + // in den Grundeinstellungen geöffnet, und der steht je Person. + role: PersonRole; onSaved: () => void; }) { const toast = useToast(); @@ -93,29 +91,20 @@ export function RetirementPanel({ return (
-
-

Pensionierung

-

- Alle Entscheide zu AHV, Pensionskasse und Säule 3a an einem Ort. Was du hier festlegst, steht auch in der - Matrix am Pensions-Übergang – es ist derselbe Entscheid, nur anders angeschaut. -

-
+

+ Was du hier festlegst, steht auch in der Matrix am Pensions-Übergang – es ist derselbe Entscheid, nur + anders angeschaut. +

- {plan.persons.map((person) => { - const summary = computed.retirement.perPerson.find((s) => s.role === person.role); - return ( - - ); - })} + s.role === role)} + effective={effective} + patch={patch} + onSaved={onSaved} + /> {error &&

{error}

} {dirty && ( @@ -174,9 +163,8 @@ function PersonBlock({
-
+
-
@@ -276,63 +264,3 @@ function Line({ label, value, note, strong }: { label: string; value: number; no ); } -// --- Planungshorizont ---------------------------------------------------------------------- - -function HorizonControl({ plan, role, onSaved }: { plan: PlanInput; role: PersonRole; onSaved: () => void }) { - const person = plan.persons.find((p) => p.role === role)!; - const [value, setValue] = useState(person.planningHorizonAge ?? null); - const [busy, setBusy] = useState(false); - const [error, setError] = useState(null); - const toast = useToast(); - // Ohne erfassten Horizont: das Alter, bei dem der Plan heute faktisch endet. - const implied = person.age + plan.phases.reduce((s, p) => s + p.durationYears, 0); - - async function submit(next: number) { - setBusy(true); - setError(null); - try { - await api.post(`/api/scenarios/${plan.id}/horizon`, { role, horizonAge: next }); - toast("success", `Planungshorizont auf Alter ${next} gesetzt.`); - onSaved(); - } catch (e) { - setError(e instanceof Error ? e.message : "Nicht möglich."); - setValue(person.planningHorizonAge ?? null); - } finally { - setBusy(false); - } - } - - return ( -
-

- Planungshorizont - -

-
-
- -
- -
- {person.planningHorizonAge === null && ( -

- Noch nicht gesetzt – der Plan endet aktuell rechnerisch mit Alter {implied}. -

- )} - {error &&

{error}

} -
- ); -} - diff --git a/src/lib/demoplan.ts b/src/lib/demoplan.ts deleted file mode 100644 index 9ac8f08..0000000 --- a/src/lib/demoplan.ts +++ /dev/null @@ -1,96 +0,0 @@ -// Beispielplan mit einem Klick (Teil von Roadmap Nr. 10). Erzeugt einen realistischen, -// vollständig ausgefuellten Plan über die BESTEHENDEN API-Endpunkte -- reine Orchestrierung, -// kein neuer Endpunkt, keine Änderung an der Berechnung. -// -// Didaktischer Entscheid: Die Übergangs-Entscheide bleiben absichtlich OFFEN. Der neue -// Nutzer sieht dadurch die Ampel ("N offen") in Aktion und lernt das wichtigste Konzept des -// Tools am Beispiel, statt es erklärt zu bekommen. -// -// WICHTIG zur Reihenfolge: erst Phase 1, dann die Elemente samt Phase-1-Werten, dann die -// Folgephase. Die Phasen-Route belegt beim Anlegen einer Phase die editierbaren Felder aus -// der Vorphase vor -- existieren die Elemente noch nicht, bleibt die Folgephase leer -// (Renditen/Beiträge wären dort 0). - -import { api } from "@/lib/api-client"; -import type { PhaseData } from "@/lib/elements"; - -async function addElement( - scenarioId: string, - firstPhaseId: string, - category: string, - name: string, - ownerRole: string, - values?: PhaseData -): Promise { - const { element } = await api.post<{ element: { id: string } }>( - `/api/scenarios/${scenarioId}/elements`, - { category, name, ownerRole } - ); - if (values && Object.keys(values).length > 0) { - await api.put(`/api/elements/${element.id}/phase/${firstPhaseId}`, values); - } -} - -// Liefert die Szenario-Id des angelegten Beispielplans. -export async function createDemoPlan(): Promise { - const currentYear = new Date().getFullYear(); - - const { scenario } = await api.post<{ plan: { id: string }; scenario: { id: string } }>( - "/api/plans", - { - name: "Beispiel: Alex Muster", - householdType: "SINGLE", - inflationRateDefault: 1.5, - startYear: currentYear, - persons: [{ role: "PERSON_A", name: "Alex", age: 45, retirementAge: 65 }], - } - ); - const sid = scenario.id; - - await api.patch(`/api/scenarios/${sid}`, { initialCash: 40000 }); - - // Phase 1: Erwerbsphase bis zur Pension (die Route kappt die Dauer ohnehin am - // Pensionsereignis; 20 Jahre = 45 -> 65). - const p1 = await api.post<{ phase: { id: string } }>(`/api/scenarios/${sid}/phases`, { - name: "Erwerbsphase", - durationYears: 20, - }); - const firstPhaseId = p1.phase.id; - - await addElement(sid, firstPhaseId, "INCOME", "Lohn Alex", "PERSON_A", { - amount: 110000, - teuerungsausgleich: 1, - }); - await addElement(sid, firstPhaseId, "EXPENSE", "Lebenshaltung", "HOUSEHOLD", { - amount: 78000, - teuerungsausgleich: 0, - }); - await addElement(sid, firstPhaseId, "AHV", "AHV Alex", "PERSON_A", { gapYears: 0 }); - await addElement(sid, firstPhaseId, "PENSION_FUND", "Pensionskasse", "PERSON_A", { - currentValue: 380000, - annualContribution: 14000, - expectedReturn: 1.5, - }); - await addElement(sid, firstPhaseId, "PILLAR_3A", "Säule 3a", "PERSON_A", { - currentValue: 75000, - annualContribution: 7000, - expectedReturn: 2, - }); - await addElement(sid, firstPhaseId, "OTHER_ASSET", "ETF-Depot", "HOUSEHOLD", { - startValue: 120000, - annualContribution: 12000, - expectedReturn: 5, - }); - await addElement(sid, firstPhaseId, "REAL_ESTATE", "Eigenheim", "HOUSEHOLD", { - purchasePrice: 900000, - mortgage: 600000, - amortization: 12000, - interestRate: 1.5, - valueGrowth: 1, - }); - - // Pensionsphase 65 -> 90. Die Route belegt die editierbaren Felder aus Phase 1 vor. - await api.post(`/api/scenarios/${sid}/phases`, { name: "Pensionsphase", durationYears: 25 }); - - return sid; -} diff --git a/src/lib/versioning-coverage.test.ts b/src/lib/versioning-coverage.test.ts index 06afc9c..6b8233d 100644 --- a/src/lib/versioning-coverage.test.ts +++ b/src/lib/versioning-coverage.test.ts @@ -34,6 +34,8 @@ const EXEMPT: Record = { "plans/[planId]/actuals/[setId]": "dito (Löschen eines Ist-Satzes)", "plans/[planId]/analyses": "gespeicherte Analysen sind read-only Momentaufnahmen – kein Szenario betroffen", "plans/[planId]/analyses/[analysisId]": "dito (Öffnen/Löschen einer Analyse)", + "scenarios/[scenarioId]/assistant": + "das Abhaken eines Assistenten-Schritts ist eine Notiz des Benutzers über sich selbst, keine Planänderung – eine Version dafür würde die Historie mit inhaltsgleichen Einträgen fluten", "plans/[planId]/reports": "Berichte sind erzeugte Dokumente – sie verändern kein Szenario", "plans/[planId]/reports/[reportId]": "dito (Herunterladen/Löschen eines Berichts)", };