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
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)",
};