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:
@@ -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
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)",
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user