Szenario-Screen mit vier Kacheln, FPT-Assistent, Onboarding auf einen Knopf

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-26 22:23:13 +02:00
co-authored by Claude Opus 5
parent 2f762175d2
commit 79fb2b9ca6
12 changed files with 1788 additions and 1890 deletions
@@ -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 });
}
@@ -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 });
}
+39 -202
View File
@@ -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<ScenarioDetail | null>(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<ScenarioMeta | null>(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<PaletteAction[]>(() => {
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 }) {
<span className="text-[11px] font-semibold uppercase tracking-wide text-faint">Meine Pläne</span>
<button
type="button"
onClick={() => setShowNewPlanChooser(true)}
onClick={() => setShowEmptyPlanDialog(true)}
aria-label="Neuen Plan erstellen"
className="rounded-md p-1 text-accent transition-colors hover:bg-accent-soft"
>
@@ -631,73 +606,36 @@ function AppShellInner({ username }: { username: string }) {
<DashboardHome
username={username}
plans={plans}
creatingDemo={creatingDemo}
onOpenPlan={(id) => openPlanTab(id, "dashboard")}
onCreateGuided={() => setShowWizard(true)}
onCreateChooser={() => setShowNewPlanChooser(true)}
onCreateEmpty={() => setShowEmptyPlanDialog(true)}
onCreateDemo={handleCreateDemo}
onCreatePlan={() => setShowEmptyPlanDialog(true)}
onDelete={handleDeletePlan}
/>
)}
{!showSpec && !showSystemParams && !loading && detail && selectedScenarioId && (
<div className="flex flex-col gap-6">
{/* Funktions-Buttons der Szenario-Ansicht. Grafiken/Effektive Werte/Live-Sim/
Monte-Carlo/Einflussfaktoren sind hier bewusst NICHT mehr -- sie laufen über
die eigenen Menüpunkte (Analysen / Effektive Werte). Hier bleiben die reinen
Szenario-Aktionen plus Tour und CSV-Export. */}
<div className="flex flex-wrap items-center gap-2" data-tour="toolbar">
{detail.plan.phases.length > 0 && (
<Button variant="secondary" onClick={() => setShowHistory(true)}>
<History className="h-4 w-4" />
Änderungshistorie
</Button>
)}
{detail.plan.phases.length > 0 && (
<Button variant="secondary" onClick={() => setShowTour(true)} title="Kurze Tour durch die Ansicht">
<HelpCircle className="h-4 w-4" />
Tour
</Button>
)}
<Button variant="secondary" onClick={() => setCopyFrom(detail.meta)}>
<Copy className="h-4 w-4" />
Neues Szenario aus diesem
</Button>
{detail.plan.phases.length > 0 && (
<Button
variant="secondary"
onClick={() => setShowPlanTraces(true)}
title="Wie wird gerechnet? Plan-weite Grössen wie Deflatoren, AHV-Karriere und Ruinalter"
>
<BookOpen className="h-4 w-4" />
Rechenwege
</Button>
)}
{detail.plan.phases.length > 0 && (
<a
href={`/api/scenarios/${detail.meta.id}/export`}
className="inline-flex items-center justify-center gap-1.5 rounded-lg border border-border bg-surface px-4 py-2 text-sm font-medium text-muted transition-all duration-150 hover:bg-surface-2 hover:text-fg"
>
<Download className="h-4 w-4" />
CSV-Export
</a>
)}
{!detail.meta.isBase && (
<Button variant="danger" onClick={() => handleDeleteScenario(detail.meta)}>
<Trash2 className="h-4 w-4" />
Szenario löschen
</Button>
)}
{diff && detail.base && (
<span className="ml-auto flex items-center gap-1.5 rounded-lg border border-diff bg-diff-soft px-3 py-1.5 text-xs font-medium text-diff">
<GitBranch className="h-3.5 w-3.5" />
{diff.total === 0
? "Unverändert gegenüber der Vorlage"
: `${diff.total} Abweichung${diff.total === 1 ? "" : "en"} gegenüber der Vorlage`}
</span>
)}
</div>
{/* 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)) && (
<div className="flex flex-wrap items-center gap-2">
{!detail.meta.isBase && (
<Button variant="danger" onClick={() => handleDeleteScenario(detail.meta)}>
<Trash2 className="h-4 w-4" />
Szenario löschen
</Button>
)}
{diff && detail.base && (
<span className="ml-auto flex items-center gap-1.5 rounded-lg border border-diff bg-diff-soft px-3 py-1.5 text-xs font-medium text-diff">
<GitBranch className="h-3.5 w-3.5" />
{diff.total === 0
? "Unverändert gegenüber der Vorlage"
: `${diff.total} Abweichung${diff.total === 1 ? "" : "en"} gegenüber der Vorlage`}
</span>
)}
</div>
)}
<PlanView
plan={detail.plan}
@@ -708,40 +646,18 @@ function AppShellInner({ username }: { username: string }) {
onChanged={refreshCurrent}
onOpenSpec={openSpecAt}
onOpenSensitivity={() => setShowSensitivity(true)}
onOpenHistory={() => setShowHistory(true)}
onStartTour={() => setShowTour(true)}
onCopyScenario={() => setCopyFrom(detail.meta)}
onOpenTraces={() => setShowPlanTraces(true)}
exportHref={`/api/scenarios/${detail.meta.id}/export`}
/>
</div>
)}
</main>
</div>
{showNewPlanChooser && (
<NewPlanChooser
creatingDemo={creatingDemo}
onGuided={() => {
setShowNewPlanChooser(false);
setShowWizard(true);
}}
onEmpty={() => {
setShowNewPlanChooser(false);
setShowEmptyPlanDialog(true);
}}
onDemo={handleCreateDemo}
onClose={() => setShowNewPlanChooser(false)}
/>
)}
{showWizard && (
<PlanWizard
onCreated={async (sid) => {
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 && (
<PlanDialog
@@ -870,73 +786,6 @@ function AppShellInner({ username }: { username: string }) {
);
}
// Auswahl beim Anlegen eines Plans: geführt (empfohlen), leer oder Beispielplan.
function NewPlanChooser({
creatingDemo,
onGuided,
onEmpty,
onDemo,
onClose,
}: {
creatingDemo: boolean;
onGuided: () => void;
onEmpty: () => void;
onDemo: () => void;
onClose: () => void;
}) {
const options = [
{
icon: <Sparkles className="h-5 w-5" />,
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: <Plus className="h-5 w-5" />,
title: "Leer starten",
badge: null,
text: "Nur das Grundprofil erfassen, alles Weitere selbst aufbauen – für geübte Nutzer.",
run: onEmpty,
},
{
icon: <PiggyBank className="h-5 w-5" />,
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 (
<Modal title="Neuer Plan" onClose={onClose}>
<div className="flex flex-col gap-2">
{options.map((o) => (
<button
key={o.title}
type="button"
disabled={creatingDemo}
onClick={o.run}
className="flex items-start gap-3 rounded-xl border border-border p-3.5 text-left transition-colors hover:border-accent hover:bg-accent-soft/30 disabled:opacity-60"
>
<span className="mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-accent-soft text-accent-soft-fg">
{o.icon}
</span>
<span className="min-w-0">
<span className="flex items-center gap-2 text-sm font-semibold text-fg">
{o.title}
{o.badge && (
<span className="rounded bg-accent px-1.5 py-0.5 text-[9px] font-semibold uppercase text-accent-fg">{o.badge}</span>
)}
</span>
<span className="mt-0.5 block text-xs text-muted">{o.text}</span>
</span>
</button>
))}
</div>
</Modal>
);
}
// 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({
</div>
{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. */
<EmptyState
icon={<Sparkles className="h-6 w-6" />}
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."
>
<Button onClick={onCreateGuided}>
<Sparkles className="h-4 w-4" /> Geführt starten
</Button>
<Button variant="secondary" onClick={onCreateDemo} disabled={creatingDemo}>
<PiggyBank className="h-4 w-4" /> {creatingDemo ? "Wird angelegt…" : "Beispielplan ansehen"}
</Button>
{/* Dritter Weg -- sonst gaebe es "Leer starten" nur ueber das Plus in der Seitenleiste. */}
<Button variant="ghost" onClick={onCreateEmpty}>
<Plus className="h-4 w-4" /> Leer starten
<Button onClick={onCreatePlan}>
<Plus className="h-4 w-4" /> Meinen ersten Finanzplan anlegen
</Button>
</EmptyState>
) : (
@@ -1151,7 +988,7 @@ function DashboardHome({
<button
type="button"
onClick={onCreateChooser}
onClick={onCreatePlan}
className="flex min-h-20 items-center justify-center gap-2 rounded-xl border border-dashed border-accent bg-accent-soft text-sm font-medium text-accent-soft-fg transition-colors hover:bg-accent-soft"
>
<Plus className="h-4 w-4" />
+131
View File
@@ -0,0 +1,131 @@
"use client";
// Der FPT-Assistent -- das Herzstück der Anwendung.
//
// Er ersetzt die frühere Karte «Nächste Schritte». Der Unterschied ist nicht kosmetisch: Die
// alte Karte leitete AB, was zu tun wäre, und liess einen damit allein. Hier führt jeder
// Schritt sein eigenes Werkzeug mit sich -- und davor eine Seite, die erklärt, worum es
// überhaupt geht.
//
// Zwei Gestaltungsentscheide, die zusammengehören:
//
// 1. Der Haken ist MANUELL. Das Tool masst sich nicht an zu wissen, wann jemand mit einem
// Schritt fertig ist -- «genug geplant» ist eine Einschätzung, keine Messgrösse.
// 2. Daneben steht der ABGELEITETE Stand («0 Lebensphasen»). Ein abgehakter Schritt ohne
// Substanz fällt so auf, ohne dass das Tool den Haken verweigert.
//
// Erledigte Schritte rutschen nach unten und werden blass -- oben steht immer das, was als
// Nächstes ansteht.
import { useState } from "react";
import { Check, ChevronRight, Lock, Sparkles } from "lucide-react";
import { api } from "@/lib/api-client";
import { ASSISTANT_STEPS, stepBlockedReason, stepStatus, type AssistantProgress } from "@/lib/assistant";
import type { PlanInput } from "@/lib/types";
export function Assistant({
plan,
progress,
onOpenStep,
onChanged,
}: {
plan: PlanInput;
progress: AssistantProgress;
onOpenStep: (index: number) => void;
onChanged: () => void;
}) {
const [busy, setBusy] = useState<number | null>(null);
const done = progress.filter(Boolean).length;
const allDone = done === ASSISTANT_STEPS.length;
async function toggle(index: number, next: boolean) {
setBusy(index);
try {
await api.post(`/api/scenarios/${plan.id}/assistant`, { step: index, done: next });
onChanged();
} finally {
setBusy(null);
}
}
// Offene zuerst, erledigte darunter -- die Reihenfolge innerhalb der Gruppen bleibt.
const ordered = [...ASSISTANT_STEPS].sort((a, b) => {
const da = progress[a.index] ? 1 : 0;
const db = progress[b.index] ? 1 : 0;
return da - db || a.index - b.index;
});
return (
<div
data-tour="assistant"
className={`flex h-full flex-col rounded-xl border px-3 py-2.5 shadow-sm transition-colors ${
allDone ? "border-success bg-success-soft" : "border-attention bg-attention-soft"
}`}
>
<div className="mb-2 flex items-center gap-2">
<Sparkles className={`h-4 w-4 ${allDone ? "text-success" : "text-attention-fg"}`} />
<span className="text-xs font-semibold uppercase tracking-wide text-fg">FPT-Assistent</span>
<span className={`ml-auto text-[11px] font-semibold ${allDone ? "text-success" : "text-muted"}`}>
{done} / {ASSISTANT_STEPS.length}
</span>
</div>
<div className="flex min-h-0 flex-1 flex-col gap-1 overflow-auto">
{ordered.map((step) => {
const isDone = progress[step.index] === true;
const blocked = stepBlockedReason(plan, step.index);
const status = stepStatus(plan, step.index);
return (
<div
key={step.index}
className={`flex items-start gap-2 rounded-lg px-1.5 py-1 transition-colors ${
isDone ? "opacity-50" : "hover:bg-surface/60"
}`}
>
<button
type="button"
aria-label={isDone ? "Als offen markieren" : "Als erledigt markieren"}
disabled={busy === step.index}
onClick={() => toggle(step.index, !isDone)}
className={`mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded border transition-colors ${
isDone ? "border-success bg-success text-white" : "border-border bg-surface hover:border-accent"
}`}
>
{isDone && <Check className="h-3 w-3" />}
</button>
<button
type="button"
onClick={() => onOpenStep(step.index)}
className="min-w-0 flex-1 text-left"
>
<span
className={`flex items-center gap-1 text-xs font-semibold ${
isDone ? "text-muted line-through" : "text-fg"
}`}
>
<span className="truncate">
{step.index + 1}. {step.title}
</span>
{blocked && !isDone && <Lock className="h-3 w-3 shrink-0 text-faint" />}
<ChevronRight className="h-3 w-3 shrink-0 text-faint" />
</span>
{!isDone && (
<span className="mt-0.5 block truncate text-[11px] text-muted">
{status || step.short}
</span>
)}
</button>
</div>
);
})}
</div>
{allDone && (
<p className="mt-2 border-t border-success/30 pt-2 text-[11px] text-success">
Alle Schritte erledigt. Dein Plan steht – verfeinere ihn jederzeit über die Matrix.
</p>
)}
</div>
);
}
+297
View File
@@ -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 (
<Modal
title={`Schritt ${stepIndex + 1}: ${step.title}`}
subtitle={showTool ? undefined : step.lead}
onClose={onClose}
xwide
>
{showTool ? (
<div className="flex flex-col gap-4">
{children}
<div className="flex items-center justify-between border-t border-border pt-3">
<Button variant="ghost" onClick={() => setShowTool(false)}>
<ArrowLeft className="h-4 w-4" /> Zurück zur Erklärung
</Button>
<Button
onClick={() => {
onToggleDone(true);
onClose();
}}
>
<Check className="h-4 w-4" /> Schritt abschliessen
</Button>
</div>
</div>
) : (
<div className="flex flex-col gap-4">
<p className="text-sm text-fg">{explainer.lead}</p>
{explainer.questions.length > 0 && (
<div className="rounded-xl border border-border bg-surface-2 p-4">
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-faint">
Diese Fragen beantwortest du hier
</p>
<ul className="flex flex-col gap-1.5">
{explainer.questions.map((q) => (
<li key={q} className="flex items-start gap-2 text-sm text-fg">
<ArrowRight className="mt-0.5 h-3.5 w-3.5 shrink-0 text-accent" />
{q}
</li>
))}
</ul>
</div>
)}
<div className="grid gap-3 sm:grid-cols-2">
{explainer.notes.map((n) => (
<div key={n.title} className="rounded-xl border border-border p-3">
<p className="text-sm font-semibold text-fg">{n.title}</p>
<p className="mt-1 text-xs leading-relaxed text-muted">{n.text}</p>
</div>
))}
</div>
{blocked && (
<p className="flex items-start gap-2 rounded-lg border border-attention bg-attention-soft px-3 py-2 text-xs text-attention-soft-fg">
<Lock className="mt-0.5 h-3.5 w-3.5 shrink-0" />
{blocked}
</p>
)}
<div className="flex items-center justify-between border-t border-border pt-3">
<Button
variant="ghost"
onClick={() => {
onToggleDone(!isDone);
onClose();
}}
>
{isDone ? "Wieder als offen markieren" : "Selbst erledigen"}
</Button>
{canGuide ? (
<Button onClick={() => setShowTool(true)}>
Schritt für Schritt <ArrowRight className="h-4 w-4" />
</Button>
) : (
<Button
onClick={() => {
onToggleDone(true);
onClose();
}}
>
<Check className="h-4 w-4" /> Verstanden
</Button>
)}
</div>
</div>
)}
</Modal>
);
}
+917
View File
@@ -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<PhaseData>) => 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) => (
<>
<MoneyField
label="Nettolohn pro Jahr"
help="Was tatsächlich aufs Konto kommt, nach allen Abzügen. FPT rechnet für die AHV intern auf den Bruttolohn hoch."
value={num(v.amount)}
onChange={(x) => set({ amount: x })}
/>
<NumberField
label="Jährliche Lohnerhöhung (%)"
help="Nominal, also inklusive Teuerung. 1 % ist ein vorsichtiger Ausgangswert."
step={0.5}
value={num(v.teuerungsausgleich, 1)}
onChange={(x) => set({ teuerungsausgleich: x })}
/>
</>
),
},
{
key: "expense",
category: "EXPENSE",
label: "Lebenshaltung",
hint: "Alles, was du im Jahr ausgibst – Wohnen, Essen, Versicherungen, Freizeit.",
fields: (v, set) => (
<MoneyField
label="Ausgaben pro Jahr"
help="In heutiger Kaufkraft. Die Teuerung rechnet FPT selbst dazu."
value={num(v.amount)}
onChange={(x) => set({ amount: x })}
/>
),
},
{
key: "pk",
category: "PENSION_FUND",
label: "Pensionskasse",
hint: "Dein Altersguthaben laut PK-Ausweis.",
personOnly: true,
fields: (v, set) => (
<>
<MoneyField
label="Altersguthaben heute"
help="Steht auf deinem Vorsorgeausweis."
value={num(v.currentValue)}
onChange={(x) => set({ currentValue: x })}
/>
<NumberField
label="Verzinsung (%/Jahr)"
help="Der BVG-Mindestzins liegt aktuell tief; viele Kassen verzinsen etwas darüber."
step={0.25}
value={num(v.expectedReturn, 1.25)}
onChange={(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) => (
<>
<MoneyField
label="Guthaben heute"
value={num(v.currentValue)}
onChange={(x) => set({ currentValue: x })}
/>
<NumberField
label="Erwartete Rendite (%/Jahr)"
help="Auf einem Konto praktisch 0, in einer Wertschriftenlösung deutlich mehr."
step={0.5}
value={num(v.expectedReturn, 2)}
onChange={(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) => (
<>
<MoneyField label="Wert heute" value={num(v.startValue)} onChange={(x) => set({ startValue: x })} />
<NumberField
label="Erwartete Rendite (%/Jahr)"
help="Langfristiger Durchschnitt. Für ein gemischtes Portfolio sind 3 bis 5 % ein üblicher Ansatz."
step={0.5}
value={num(v.expectedReturn, 4)}
onChange={(x) => set({ expectedReturn: x })}
/>
</>
),
},
{
key: "re",
category: "REAL_ESTATE",
label: "Wohneigentum",
hint: "Haus oder Wohnung samt Hypothek.",
fields: (v, set) => (
<>
<MoneyField
label="Kaufpreis"
help="Der ursprüngliche Kaufpreis – Grundlage der Grundstückgewinnsteuer bei einem späteren Verkauf."
value={num(v.purchasePrice)}
onChange={(x) => set({ purchasePrice: x })}
/>
<MoneyField
label="Hypothek heute"
value={num(v.mortgage)}
onChange={(x) => set({ mortgage: x })}
/>
<NumberField
label="Hypothekarzins (%)"
step={0.25}
value={num(v.interestRate, 2)}
onChange={(x) => set({ interestRate: x })}
/>
<NumberField
label="Wertsteigerung (%/Jahr)"
help="Auf den Wert der Liegenschaft, nicht auf dein Eigenkapital – dort wirkt sie gehebelt."
step={0.25}
value={num(v.valueGrowth, 1)}
onChange={(x) => set({ valueGrowth: x })}
/>
<div className="sm:col-span-2">
<SelectField
label="Sind die Hypothekarzinsen in deinen Ausgaben schon enthalten?"
help="Wenn ja, rechnet FPT sie nicht noch einmal dazu – sonst zählten sie doppelt."
value={v.interestHandling ?? "INCLUDED"}
onChange={(x: "INCLUDED" | "ADD") => set({ interestHandling: x })}
options={[
{ value: "INCLUDED", label: "Ja, sind in der Lebenshaltung enthalten" },
{ value: "ADD", label: "Nein, bitte zusätzlich rechnen" },
]}
/>
</div>
</>
),
},
{
key: "debt",
category: "OTHER_DEBT",
label: "Schulden",
hint: "Kredite ausserhalb der Hypothek.",
fields: (v, set) => (
<MoneyField label="Restschuld heute" value={num(v.startValue)} onChange={(x) => 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 (
<div className="flex flex-col gap-4">
{scopes.length > 1 && (
<div className="flex gap-1 rounded-lg border border-border bg-surface-2 p-1 text-xs">
{scopes.map((sc, i) => (
<button
key={sc}
type="button"
onClick={() => setPage(i)}
className={`flex-1 rounded-md px-2 py-1 font-medium ${
page === i ? "bg-accent text-accent-fg" : "text-muted hover:bg-surface"
}`}
>
{sc === "HOUSEHOLD" ? "Gemeinsam" : ownerLabel(plan.persons, sc)}
</button>
))}
</div>
)}
<ScopeElements plan={plan} scope={scope} onChanged={onChanged} />
{scopes.length > 1 && (
<div className="flex justify-between">
<Button variant="ghost" disabled={page === 0} onClick={() => setPage((p) => p - 1)}>
<ArrowLeft className="h-4 w-4" /> Zurück
</Button>
<Button
variant="secondary"
disabled={page === scopes.length - 1}
onClick={() => setPage((p) => p + 1)}
>
Weiter <ArrowRight className="h-4 w-4" />
</Button>
</div>
)}
</div>
);
}
function ScopeElements({
plan,
scope,
onChanged,
}: {
plan: PlanInput;
scope: Scope;
onChanged: () => void;
}) {
const [busy, setBusy] = useState<string | null>(null);
const [error, setError] = useState<string | null>(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 (
<div className="flex flex-col gap-3">
<div className="flex flex-wrap gap-2">
{kinds.map((k) => (
<button
key={k.key}
type="button"
disabled={busy === k.key}
onClick={() => add(k)}
className="flex items-center gap-1.5 rounded-lg border border-dashed border-border px-2.5 py-1.5 text-xs font-medium text-muted transition-colors hover:border-accent hover:text-accent-soft-fg"
>
<Plus className="h-3.5 w-3.5" /> {k.label}
</button>
))}
</div>
{mine.length === 0 ? (
<p className="rounded-lg border border-dashed border-border bg-surface-2 p-3 text-sm text-muted">
Noch nichts erfasst. Wähle oben, was zutrifft – du kannst jederzeit weitere Positionen ergänzen.
</p>
) : (
<div className="flex flex-col gap-2">
{mine.map((el) => (
<BaseDataCard key={el.id} plan={plan} elementId={el.id} onChanged={onChanged} />
))}
</div>
)}
{error && <p className="text-sm text-danger">{error}</p>}
</div>
);
}
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<PhaseData>(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<PhaseData>) => {
setDraft((d) => ({ ...d, ...p }));
setSaved(false);
};
return (
<div className="rounded-xl border border-border p-3">
<div className="mb-2 flex items-center gap-2">
<span className="text-xs font-semibold uppercase tracking-wide text-faint">
{CATEGORY_LABELS[el.category]}
</span>
<button
type="button"
onClick={remove}
className="ml-auto rounded p-1 text-faint hover:bg-danger-soft hover:text-danger"
aria-label="Entfernen"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div className="sm:col-span-2">
<TextField label="Bezeichnung" value={name} onChange={setName} />
</div>
{kind?.fields(draft, set, plan)}
{el.category === "PILLAR_3A" && (
<p className="sm:col-span-2 text-xs text-faint">
Hinweis: Der jährliche Höchstbetrag liegt bei {formatChf(PILLAR_3A_MAX_ANNUAL)}. Wie viel du einzahlst,
legst du in Schritt 4 fest.
</p>
)}
</div>
<div className="mt-3 flex items-center gap-2">
<Button size="sm" disabled={saving} onClick={save}>
{saving ? "…" : saved ? "Gespeichert" : "Speichern"}
</Button>
{saved && <Check className="h-4 w-4 text-success" />}
</div>
</div>
);
}
// ============================================================================================
// 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 (
<div className="flex flex-col gap-4">
<div className="flex gap-1 rounded-lg border border-border bg-surface-2 p-1 text-xs">
{pages.map((pg, i) => (
<button
key={pg}
type="button"
onClick={() => setPage(i)}
className={`flex-1 rounded-md px-2 py-1 font-medium ${
page === i ? "bg-accent text-accent-fg" : "text-muted hover:bg-surface"
}`}
>
{pg === "HORIZON" ? "Planungshorizont" : `Pensionierung ${ownerLabel(plan.persons, pg)}`}
</button>
))}
</div>
{page === 0 ? (
<HorizonStep plan={plan} onChanged={onChanged} />
) : (
<PersonTimingStep
plan={plan}
computed={computed}
role={pages[page] as PersonRole}
onChanged={onChanged}
/>
)}
<div className="flex justify-between">
<Button variant="ghost" disabled={page === 0} onClick={() => setPage((p) => p - 1)}>
<ArrowLeft className="h-4 w-4" /> Zurück
</Button>
<Button variant="secondary" disabled={page === pages.length - 1} onClick={() => setPage((p) => p + 1)}>
Weiter <ArrowRight className="h-4 w-4" />
</Button>
</div>
</div>
);
}
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<string | null>(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 (
<div className="flex flex-col gap-3">
<p className="text-sm text-muted">
Ü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.
</p>
<div className="flex items-end gap-3">
<div className="w-40">
<NumberField
label="Planungshorizont (Jahre)"
value={years}
min={MIN_PLANNING_HORIZON_YEARS}
max={MAX_PLANNING_HORIZON_YEARS}
step={1}
onChange={setYears}
/>
</div>
<Button disabled={busy} onClick={save}>
{busy ? "…" : "Übernehmen"}
</Button>
</div>
<div className="rounded-xl border border-border bg-surface-2 p-3">
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-faint">Daraus ergibt sich</p>
<div className="flex flex-col gap-1 text-sm">
{plan.persons.map((p) => (
<div key={p.role} className="flex justify-between gap-3">
<span className="text-muted">{ownerLabel(plan.persons, p.role)} am Planende</span>
<span className="tabular-nums text-fg">{p.age + Math.round(years)} Jahre</span>
</div>
))}
{plan.startYear && (
<div className="flex justify-between gap-3 border-t border-border pt-1">
<span className="text-muted">Letztes Planjahr</span>
<span className="tabular-nums text-fg">{plan.startYear + Math.round(years) - 1}</span>
</div>
)}
</div>
</div>
{error && <p className="text-sm text-danger">{error}</p>}
</div>
);
}
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<string | null>(null);
const [draft, setDraft] = useState<Record<string, Record<string, unknown>>>({});
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 (
<div className="flex flex-col gap-4">
<div className="rounded-xl border border-border p-3">
<div className="flex items-end gap-3">
<div className="w-44">
<NumberField
label="Erwerbstätigkeit endet mit"
help="Ab diesem Alter fliesst kein Lohn mehr. Das ist nicht zwingend dasselbe Alter, in dem deine Renten beginnen."
value={age}
min={40}
max={75}
step={1}
onChange={setAge}
/>
</div>
<Button disabled={busy || Math.round(age) === person.retirementAge} onClick={saveAge}>
Übernehmen
</Button>
</div>
{plan.startYear && (
<p className="mt-1 text-xs text-faint">
Das ist das Jahr {plan.startYear + (Math.round(age) - person.age)}.
</p>
)}
</div>
{own.length === 0 ? (
<p className="rounded-lg border border-dashed border-border bg-surface-2 p-3 text-sm text-muted">
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.
</p>
) : (
<>
<p className="text-sm text-muted">
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.
</p>
<div className="flex flex-col gap-2">
{own.map((el) => (
<RetirementFields
key={el.id}
plan={plan}
el={el}
defaultOpen
patch={(id, patch) =>
setDraft((d) => ({ ...d, [id]: { ...(d[id] ?? {}), ...patch } }))
}
summary={summary}
/>
))}
</div>
{Object.keys(draft).length > 0 && (
<Button disabled={busy} onClick={saveDecisions}>
{busy ? "…" : "Bezugszeitpunkte speichern"}
</Button>
)}
</>
)}
{error && <p className="text-sm text-danger">{error}</p>}
</div>
);
}
// ============================================================================================
// 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<string | null>(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 (
<p className="rounded-lg border border-dashed border-border bg-surface-2 p-3 text-sm text-muted">
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.
</p>
);
}
return (
<div className="flex flex-col gap-4">
<p className="text-sm text-muted">
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.
</p>
{segments.map((seg, i) => (
<div key={i} className="rounded-xl border border-border p-3">
<div className="mb-2 flex flex-wrap items-baseline gap-2">
<span className="text-sm font-semibold text-fg">
{seg.type === "ERWERB" ? "Erwerbsphase" : seg.type === "MIXED" ? "Übergangszeit" : "Ruhestand"}
</span>
<span className="text-xs text-muted">
ab Jahr {seg.fromYear + 1}
{seg.fixedYears !== null ? ` · ${seg.fixedYears} Jahre fest` : " · Dauer frei"}
</span>
{seg.endedBy.length > 0 && (
<span className="text-xs text-faint">endet mit: {seg.endedBy.map((f) => f.label).join(", ")}</span>
)}
{seg.fixedYears !== null && (
<span
className={`ml-auto text-xs font-semibold ${
sums[i] === seg.fixedYears ? "text-success" : "text-danger"
}`}
>
{sums[i]} / {seg.fixedYears} Jahre
</span>
)}
</div>
<div className="flex flex-col gap-2">
{draft[i].map((ph, j) => (
<div key={j} className="flex items-end gap-2">
<div className="flex-1">
<TextField
label="Bezeichnung"
value={ph.name}
onChange={(v) =>
setDraft((d) => d.map((x, xi) => (xi === i ? x.map((y, yj) => (yj === j ? { ...y, name: v } : y)) : x)))
}
/>
</div>
<div className="w-28">
<NumberField
label="Jahre"
value={ph.years}
min={1}
step={1}
onChange={(v) =>
setDraft((d) => d.map((x, xi) => (xi === i ? x.map((y, yj) => (yj === j ? { ...y, years: Math.round(v) } : y)) : x)))
}
/>
</div>
{draft[i].length > 1 && (
<button
type="button"
onClick={() => setDraft((d) => d.map((x, xi) => (xi === i ? x.filter((_, yj) => yj !== j) : x)))}
className="mb-1 rounded p-1.5 text-faint hover:bg-danger-soft hover:text-danger"
aria-label="Phase entfernen"
>
<Trash2 className="h-4 w-4" />
</button>
)}
</div>
))}
<button
type="button"
onClick={() =>
setDraft((d) =>
d.map((x, xi) => (xi === i ? [...x, { name: `Abschnitt ${x.length + 1}`, years: 1 }] : x))
)
}
className="flex w-fit items-center gap-1 rounded-lg border border-dashed border-border px-2 py-1 text-xs text-muted hover:border-accent"
>
<Plus className="h-3.5 w-3.5" /> Weiter unterteilen
</button>
</div>
</div>
))}
{!valid && (
<p className="flex items-start gap-2 text-xs text-danger">
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
In mindestens einem festen Abschnitt geht die Summe der Jahre nicht auf.
</p>
)}
{error && <p className="text-sm text-danger">{error}</p>}
<Button disabled={busy || !valid} onClick={create}>
{busy ? "…" : "Lebensphasen anlegen"}
</Button>
</div>
);
}
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 (
<p className="rounded-lg border border-dashed border-border bg-surface-2 p-3 text-sm text-muted">
In diesem Abschnitt deiner Planung gibt es nichts zu entscheiden.
</p>
);
}
const st = stations[Math.min(pos, stations.length - 1)];
const phase = computed.phases.find((p) => p.id === st.phaseId)!;
return (
<div className="flex flex-col gap-4">
<div className="flex items-center gap-2">
<span className="text-xs text-muted">
Station {pos + 1} von {stations.length}
</span>
<div className="flex flex-1 gap-1">
{stations.map((_, i) => (
<div key={i} className={`h-1 flex-1 rounded-full ${i <= pos ? "bg-accent" : "bg-surface-2"}`} />
))}
</div>
</div>
<div className="rounded-xl border border-border p-4">
<h4 className="text-base font-semibold text-fg">{st.label}</h4>
<p className="mt-1 text-xs text-muted">
{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?"}
</p>
<div className="mt-3 flex flex-wrap gap-2">
{st.type === "phase" ? (
<>
<Button size="sm" onClick={() => setOpen("rates")}>
{phase.isConsumption ? "Bezüge verteilen" : "Sparquote verteilen"}
</Button>
<Button size="sm" variant="secondary" onClick={() => setOpen("capital")}>
Kapital verteilen
</Button>
</>
) : (
<p className="text-sm text-muted">
Die Entscheide dieses Übergangs triffst du in der Matrix – klicke dort auf die Übergangsspalte
nach «{phase.name}».
</p>
)}
</div>
</div>
<div className="flex justify-between">
<Button variant="ghost" disabled={pos === 0} onClick={() => setPos((p) => p - 1)}>
<ArrowLeft className="h-4 w-4" /> Vorherige
</Button>
<Button
variant="secondary"
disabled={pos >= stations.length - 1}
onClick={() => setPos((p) => p + 1)}
>
Nächste <ArrowRight className="h-4 w-4" />
</Button>
</div>
{open === "capital" && (
<CapitalDistributionDialog
plan={plan}
computed={computed}
phaseId={st.phaseId}
onClose={() => setOpen(null)}
onSaved={() => {
setOpen(null);
onChanged();
}}
/>
)}
{open === "rates" && (
<RateDistributionDialog
plan={plan}
computed={computed}
phaseId={st.phaseId}
onClose={() => setOpen(null)}
onSaved={() => {
setOpen(null);
onChanged();
}}
/>
)}
</div>
);
}
// 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;
}
+22 -34
View File
@@ -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. */}
<p className="rounded-lg border border-attention bg-attention-soft px-3 py-2 text-xs text-attention-soft-fg">
Haushaltsform, Personen und Planstart gelten für <strong>alle Szenarien</strong> dieses
Plans. Unterscheiden sie sich, ist es ein anderer Plan. Szenario-eigen sind nur das{" "}
<strong>Pensionsalter</strong> und die <strong>Inflation</strong>.
</p>
{!hideScopeNote && (
<p className="rounded-lg border border-attention bg-attention-soft px-3 py-2 text-xs text-attention-soft-fg">
Haushaltsform, Personen und Planstart gelten für <strong>alle Szenarien</strong> dieses
Plans. Unterscheiden sie sich, ist es ein anderer Plan.
</p>
)}
<SelectField
label="Haushaltsform (plan-weit)"
@@ -94,26 +101,7 @@ export function PlanProfileFields({
max={120}
onChange={(v) => updatePerson(index, { age: Math.round(v) })}
/>
{lockRetirement ? (
<div>
<FieldLabel
label="Pensionierungsalter"
help="Liegt immer auf einer Phasengrenze und wird deshalb unten unter «Pensionsalter anpassen» verschoben -- dabei wandern die angrenzenden Phasendauern mit."
/>
<div className="mt-1 rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-muted">
{person.retirementAge} Jahre
</div>
</div>
) : (
<NumberField
label="Pensionierungsalter (nur dieses Szenario)"
help="Steuert die Ableitung des Phasentyps (Erwerb/Pension). Als einzige Personenangabe szenario-eigen -- das ist der Kern jedes Früh-/Spätpensionierungs-Szenarios."
value={person.retirementAge}
min={30}
max={100}
onChange={(v) => updatePerson(index, { retirementAge: Math.round(v) })}
/>
)}
{/* Das Pensionsalter steht bewusst NICHT hier -- siehe Kommentar oben. */}
</div>
))}
+292 -238
View File
@@ -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<ElementCategory, React.ReactNode> = {
INCOME: <Wallet className="h-4 w-4" />,
@@ -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<ValueMode>("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<number | null>(null);
const [retirementRole, setRetirementRole] = useState<PersonRole | null>(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 (
<div className="flex flex-col gap-5">
<NextSteps
plan={plan}
computed={computed}
columns={columns}
openCountFor={transitionOpenCount}
onReview={(fromPhaseId) => 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. */}
<div className="grid grid-cols-1 gap-4 lg:grid-cols-4">
{/* Grundprofil -- Daten untereinander, «Bearbeiten» oben rechts. */}
<div
data-tour="profil"
className={`flex flex-col gap-2 rounded-xl border px-4 py-3 shadow-sm lg:col-span-1 ${
diff?.profileChanged ? "border-diff bg-diff-soft" : "border-border bg-surface"
}`}
>
<div className="flex items-start justify-between gap-2">
<span className="text-xs font-semibold uppercase tracking-wide text-faint">
Grundprofil{diff?.profileChanged ? " · abweichend" : ""}
</span>
<button
type="button"
onClick={() => setPanel({ kind: "profile" })}
className="flex shrink-0 items-center gap-1 rounded-lg border border-border px-2 py-0.5 text-xs font-medium text-muted hover:bg-surface-2"
>
<Settings2 className="h-3.5 w-3.5" /> Bearbeiten
</button>
</div>
<div className="flex flex-col gap-1">
{plan.persons.map((p) => (
<span key={p.role} className="text-xs text-muted">
{personLabel(p.role)}: {p.age} J., Pension {p.retirementAge}
</span>
))}
<span className="text-xs text-muted">Inflation {plan.inflationRateDefault}%</span>
</div>
{/* 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. */}
<div className="rounded-2xl border border-border bg-surface-2/60 p-4 shadow-sm">
<div className="grid grid-cols-1 gap-3 lg:grid-cols-4">
<SettingsTile
plan={plan}
computed={computed}
diffChanged={!!diff?.profileChanged}
onEdit={() => setPanel({ kind: "profile" })}
onRetirement={(role) => setRetirementRole(role)}
/>
<FiguresTile plan={plan} computed={computed} />
<QuickActionsTile
onHistory={onOpenHistory}
onTour={onStartTour}
onNewScenario={onCopyScenario}
onTraces={onOpenTraces}
exportHref={exportHref}
/>
<Assistant
plan={plan}
progress={progress}
onOpenStep={(i) => setAssistantStep(i)}
onChanged={onChanged}
/>
</div>
{/* Zeitachse */}
<div data-tour="timeline" className="lg:col-span-2">
<div data-tour="timeline" className="mt-3">
<Timeline
phases={computed.phases}
persons={personAxes}
@@ -440,70 +430,11 @@ export function PlanView({
startYear={plan.startYear}
/>
</div>
{/* Kennzahlen: Endvermögen nominal + real (+ Ruin, falls vorhanden). */}
<div data-tour="endwealth" className="flex flex-col justify-center gap-2 rounded-xl border border-border bg-surface px-4 py-3 shadow-sm lg:col-span-1">
<span className="text-xs font-semibold uppercase tracking-wide text-faint">Endvermögen</span>
<div>
<div className="text-[11px] text-muted">Nominal</div>
<div className="text-lg font-bold text-fg">
{lastPhase ? formatChf(Math.round(lastPhase.endWealthNominal)) : "—"}
</div>
</div>
<div>
<div className="text-[11px] text-muted">Real (heutige Kaufkraft)</div>
<div className="text-lg font-bold text-fg">
{lastPhase ? formatChf(Math.round(lastPhase.endWealthReal)) : "—"}
</div>
</div>
{computed.ruinAge !== null && (
<div className="mt-0.5 rounded-lg bg-danger-soft px-2 py-1 text-xs font-medium text-danger">
Kapital reicht nur bis Alter {computed.ruinAge}
</div>
)}
</div>
</div>
{/* 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 && (
<div className="flex flex-wrap items-center gap-2">
<div className="inline-flex rounded-lg border border-border bg-surface p-0.5 text-sm">
{(["matrix", "retirement"] as const).map((v) => (
<button
key={v}
type="button"
onClick={() => setView(v)}
className={`flex items-center gap-1.5 rounded-md px-3 py-1 font-medium ${
view === v ? "bg-accent text-accent-fg" : "text-muted hover:bg-surface-2"
}`}
>
{v === "matrix" ? <Table2 className="h-4 w-4" /> : <PiggyBank className="h-4 w-4" />}
{v === "matrix" ? "Matrix" : "Pensionierung"}
</button>
))}
</div>
{view === "matrix" && retirementSummaryText && (
<button
type="button"
onClick={() => setView("retirement")}
className="truncate text-xs text-muted underline decoration-dotted underline-offset-2 hover:text-fg"
title="Zur Pensionierung"
>
{retirementSummaryText}
</button>
)}
</div>
)}
{view === "retirement" && (
<RetirementPanel plan={plan} computed={computed} onSaved={onChanged} />
)}
{/* Anzeige-Umschalter (nominal/real, Plan/Ist) -- direkt über der Matrix, weil er nur
deren Zahlen steuert. */}
{hasPhases && view === "matrix" && (
{hasPhases && (
<div className="flex items-center gap-2">
<span className="text-xs text-muted">Anzeige</span>
<div className="inline-flex rounded-lg border border-border bg-surface p-0.5 text-xs">
@@ -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 && (
<div data-tour="matrix" className="max-h-[75vh] overflow-auto rounded-xl border border-border bg-surface shadow-sm">
{/* 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 && (
<AssistantStepDialog
plan={plan}
stepIndex={assistantStep}
isDone={progress[assistantStep] === true}
onToggleDone={async (done) => {
await api.post(`/api/scenarios/${plan.id}/assistant`, { step: assistantStep, done });
onChanged();
}}
onClose={() => setAssistantStep(null)}
>
{assistantStep === 0 && <Step1Elements plan={plan} onChanged={onChanged} />}
{assistantStep === 1 && <Step2Timeline plan={plan} computed={computed} onChanged={onChanged} />}
{assistantStep === 2 && <Step3Phases plan={plan} onChanged={onChanged} />}
{assistantStep === 3 && (
<StepWalkthrough plan={plan} computed={computed} scope="WORK" onChanged={onChanged} />
)}
{assistantStep === 4 && (
<StepWalkthrough plan={plan} computed={computed} scope="RETIRE" onChanged={onChanged} />
)}
{assistantStep === 5 && (
<StepWalkthrough plan={plan} computed={computed} scope="PENSION" onChanged={onChanged} />
)}
</AssistantStepDialog>
)}
{retirementRole && (
<Modal
title={`Pensionsplanung · ${ownerLabel(plan.persons, retirementRole)}`}
subtitle="Alle Entscheide zu AHV, Pensionskasse und Säule 3a an einem Ort"
onClose={() => setRetirementRole(null)}
xwide
>
<RetirementPanel plan={plan} computed={computed} role={retirementRole} onSaved={onChanged} />
</Modal>
)}
{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 (
<div className="rounded-xl border border-attention-soft-fg/30 bg-attention-soft px-4 py-3">
<div className="mb-2 flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-attention-soft-fg">
<ListTodo className="h-4 w-4" /> Nächste Schritte
</div>
<ul className="flex flex-col gap-1.5">
{items.map((it) => (
<li key={it.key} className="flex flex-wrap items-center gap-2 text-sm text-fg">
<span className="min-w-0 flex-1">{it.text}</span>
<button
type="button"
onClick={it.run}
className="flex shrink-0 items-center gap-1 rounded-lg border border-attention-soft-fg/40 px-2.5 py-1 text-xs font-semibold text-attention-soft-fg transition-colors hover:bg-attention hover:text-attention-fg"
>
{it.action} <ArrowRight className="h-3 w-3" />
</button>
</li>
))}
</ul>
</div>
);
}
// --- 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 (
<InspectorShell title="Szenario-Profil" subtitle="Haushalt, Personen, Planstart und Inflation dieses Szenarios" onClose={onClose}>
<div className="flex flex-col gap-3">
<PlanProfileFields draft={draft} onChange={setDraft} lockRetirement={plan.phases.length > 0} />
<PlanProfileFields draft={draft} onChange={setDraft} />
{error && <p className="text-sm text-danger">{error}</p>}
<div className="flex gap-2 pt-1">
<Button disabled={saving} onClick={save}>{saving ? "…" : "Speichern"}</Button>
@@ -2432,3 +2292,197 @@ function ElementMetaPanel({
</div>
);
}
// --- 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 (
<div data-tour={tour} className="flex h-full flex-col rounded-xl border border-border bg-surface px-3 py-2.5 shadow-sm">
<div className="mb-2 flex items-start justify-between gap-2">
<span className="text-xs font-semibold uppercase tracking-wide text-faint">{title}</span>
{action}
</div>
<div className="flex min-h-0 flex-1 flex-col gap-1">{children}</div>
</div>
);
}
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 (
<Tile
tour="profil"
title={`Grundeinstellungen${diffChanged ? " · abweichend" : ""}`}
action={
<button
type="button"
onClick={onEdit}
className="flex shrink-0 items-center gap-1 rounded-lg border border-border px-1.5 py-0.5 text-[11px] font-medium text-muted hover:bg-surface-2"
title="Grundeinstellungen bearbeiten"
>
<Pencil className="h-3 w-3" />
</button>
}
>
{/* Plan-weit: gilt für alle Szenarien. */}
<div className="text-[11px] text-muted">
{plan.persons.map((p) => (
<div key={p.role} className="truncate">
{ownerLabel(plan.persons, p.role)} · {p.age} J.
</div>
))}
<div>Start {plan.startYear ?? "—"} · Inflation {plan.inflationRateDefault} %</div>
</div>
{/* Szenario-eigen: das, was man zwischen Varianten unterscheidet. */}
<div className="mt-1 border-t border-border pt-1 text-[11px] text-muted">
<div>
Horizont {horizon ? `${horizon} Jahre` : "—"}
{endYear ? ` · bis ${endYear}` : ""}
</div>
{plan.persons.map((p) => (
<div key={p.role} className="mt-0.5 flex items-center gap-1">
<span className="truncate">
Pension {p.retirementAge}
{horizon ? ` · Ende ${p.age + horizon}` : ""}
</span>
<button
type="button"
onClick={() => onRetirement(p.role)}
className="ml-auto shrink-0 rounded border border-border px-1.5 py-0.5 text-[10px] font-medium text-muted hover:bg-surface-2"
title={`Pensionsplanung ${ownerLabel(plan.persons, p.role)}`}
>
Pensionsplanung
</button>
</div>
))}
</div>
{computed.retirement.gapAnnual !== null && (
<div className="mt-1 text-[11px] text-faint">
Rentenlücke {formatChf(computed.retirement.gapAnnual)}/Jahr
</div>
)}
</Tile>
);
}
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 (
<Tile tour="endwealth" title="Kennzahlen">
<Figure label="Vermögen heute" value={today} />
<Figure label="Endvermögen nominal" value={last ? Math.round(last.endWealthNominal) : null} />
<Figure label="Endvermögen real" value={last ? Math.round(last.endWealthReal) : null} />
<div className="mt-auto pt-1">
{computed.ruinAge !== null ? (
<div className="rounded-lg bg-danger-soft px-2 py-1 text-[11px] font-medium text-danger">
Kapital reicht nur bis Alter {computed.ruinAge}
</div>
) : (
computed.phases.length > 0 && (
<div className="rounded-lg bg-success-soft px-2 py-1 text-[11px] font-medium text-success">
Kapital reicht über die ganze Planung
</div>
)
)}
</div>
</Tile>
);
}
function Figure({ label, value }: { label: string; value: number | null }) {
return (
<div className="flex items-baseline justify-between gap-2">
<span className="truncate text-[11px] text-muted">{label}</span>
<span className="shrink-0 text-sm font-semibold tabular-nums text-fg">
{value === null ? "—" : formatChf(value)}
</span>
</div>
);
}
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 (
<Tile tour="toolbar" title="Schnellaktionen">
{onNewScenario && (
<button type="button" onClick={onNewScenario} className={cls}>
<Copy className="h-3.5 w-3.5" /> Neues Szenario
</button>
)}
{onTour && (
<button type="button" onClick={onTour} className={cls}>
<HelpCircle className="h-3.5 w-3.5" /> Tour
</button>
)}
{onHistory && (
<button type="button" onClick={onHistory} className={cls}>
<HistoryIcon className="h-3.5 w-3.5" /> Änderungshistorie
</button>
)}
{onTraces && (
<button type="button" onClick={onTraces} className={cls}>
<BookOpen className="h-3.5 w-3.5" /> Rechenwege
</button>
)}
{exportHref && (
<a href={exportHref} className={cls}>
<Download className="h-3.5 w-3.5" /> CSV-Export
</a>
)}
</Tile>
);
}
File diff suppressed because it is too large Load Diff
+19 -91
View File
@@ -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<string, RetirementDecision>;
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 (
<div className="flex flex-col gap-5">
<div>
<h2 className="text-lg font-semibold text-fg">Pensionierung</h2>
<p className="mt-1 text-sm text-muted">
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.
</p>
</div>
<p className="text-sm text-muted">
Was du hier festlegst, steht auch in der Matrix am Pensions-Übergang – es ist derselbe Entscheid, nur
anders angeschaut.
</p>
{plan.persons.map((person) => {
const summary = computed.retirement.perPerson.find((s) => s.role === person.role);
return (
<PersonBlock
key={person.role}
plan={plan}
computed={computed}
role={person.role}
summary={summary}
effective={effective}
patch={patch}
onSaved={onSaved}
/>
);
})}
<PersonBlock
plan={plan}
computed={computed}
role={role}
summary={computed.retirement.perPerson.find((s) => s.role === role)}
effective={effective}
patch={patch}
onSaved={onSaved}
/>
{error && <p className="text-sm text-danger">{error}</p>}
{dirty && (
@@ -174,9 +163,8 @@ function PersonBlock({
</div>
</header>
<div className="grid gap-4 px-4 py-4 md:grid-cols-2">
<div className="px-4 py-4">
<RetirementAdjuster plan={plan} onSaved={onSaved} />
<HorizonControl plan={plan} role={role} onSaved={onSaved} />
</div>
<GapBox plan={plan} computed={computed} summary={summary} />
@@ -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<string | null>(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 (
<div className="rounded-xl border border-border p-3">
<h4 className="mb-2 flex items-center text-sm font-semibold text-fg">
Planungshorizont
<InfoBubble text="Bis zu welchem Alter gerechnet wird. Die letzte Lebensphase passt sich an. Ohne diesen Wert ergibt sich das Planende stillschweigend aus der Summe der Phasendauern – zwei Szenarien rechnen dann womöglich unbemerkt verschieden weit und sind nicht vergleichbar." />
</h4>
<div className="flex items-end gap-2">
<div className="flex-1">
<NumberField
label="Alter"
value={value ?? implied}
min={MIN_PLANNING_HORIZON_AGE}
max={MAX_PLANNING_HORIZON_AGE}
step={1}
onChange={setValue}
/>
</div>
<Button
disabled={busy || value === null || value === person.planningHorizonAge}
onClick={() => value !== null && submit(value)}
>
Setzen
</Button>
</div>
{person.planningHorizonAge === null && (
<p className="mt-1 text-xs text-faint">
Noch nicht gesetzt – der Plan endet aktuell rechnerisch mit Alter {implied}.
</p>
)}
{error && <p className="mt-1 text-xs text-danger">{error}</p>}
</div>
);
}
-96
View File
@@ -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<void> {
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<string> {
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;
}
+2
View File
@@ -34,6 +34,8 @@ const EXEMPT: Record<string, string> = {
"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)",
};