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 });
|
||||||
|
}
|
||||||
+24
-187
@@ -3,7 +3,6 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
BarChart3,
|
BarChart3,
|
||||||
BookOpen,
|
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Copy,
|
Copy,
|
||||||
@@ -12,9 +11,6 @@ import {
|
|||||||
FolderKanban,
|
FolderKanban,
|
||||||
CalendarClock,
|
CalendarClock,
|
||||||
GitBranch,
|
GitBranch,
|
||||||
HelpCircle,
|
|
||||||
History,
|
|
||||||
Download,
|
|
||||||
Layers,
|
Layers,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
Menu,
|
Menu,
|
||||||
@@ -43,7 +39,6 @@ import { SystemParametersView } from "@/components/SystemParametersView";
|
|||||||
import { PlanTraceDialog } from "@/components/DetailView";
|
import { PlanTraceDialog } from "@/components/DetailView";
|
||||||
import { computePlan } from "@/lib/calculations";
|
import { computePlan } from "@/lib/calculations";
|
||||||
import { ProfileMenu } from "@/components/ProfileMenu";
|
import { ProfileMenu } from "@/components/ProfileMenu";
|
||||||
import { PlanWizard } from "@/components/PlanWizard";
|
|
||||||
import { Tour, TOUR_DONE_KEY } from "@/components/Tour";
|
import { Tour, TOUR_DONE_KEY } from "@/components/Tour";
|
||||||
import { CommandPalette, type PaletteAction } from "@/components/CommandPalette";
|
import { CommandPalette, type PaletteAction } from "@/components/CommandPalette";
|
||||||
import { PlanProfileFields, emptyProfileDraft, type ProfileDraft } from "@/components/PlanProfileFields";
|
import { PlanProfileFields, emptyProfileDraft, type ProfileDraft } from "@/components/PlanProfileFields";
|
||||||
@@ -58,7 +53,6 @@ import {
|
|||||||
useToast,
|
useToast,
|
||||||
} from "@/components/ui";
|
} from "@/components/ui";
|
||||||
import { api } from "@/lib/api-client";
|
import { api } from "@/lib/api-client";
|
||||||
import { createDemoPlan } from "@/lib/demoplan";
|
|
||||||
import { computeScenarioDiff } from "@/lib/diff";
|
import { computeScenarioDiff } from "@/lib/diff";
|
||||||
import type { PlanInput, PlanListItem, ScenarioMeta } from "@/lib/types";
|
import type { PlanInput, PlanListItem, ScenarioMeta } from "@/lib/types";
|
||||||
import type { PlanComputed } from "@/lib/calculations";
|
import type { PlanComputed } from "@/lib/calculations";
|
||||||
@@ -95,10 +89,7 @@ function AppShellInner({ username }: { username: string }) {
|
|||||||
const [detail, setDetail] = useState<ScenarioDetail | null>(null);
|
const [detail, setDetail] = useState<ScenarioDetail | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
const [showNewPlanChooser, setShowNewPlanChooser] = useState(false);
|
|
||||||
const [showWizard, setShowWizard] = useState(false);
|
|
||||||
const [showEmptyPlanDialog, setShowEmptyPlanDialog] = useState(false);
|
const [showEmptyPlanDialog, setShowEmptyPlanDialog] = useState(false);
|
||||||
const [creatingDemo, setCreatingDemo] = useState(false);
|
|
||||||
const [copyFrom, setCopyFrom] = useState<ScenarioMeta | null>(null);
|
const [copyFrom, setCopyFrom] = useState<ScenarioMeta | null>(null);
|
||||||
const [showSpec, setShowSpec] = useState(false);
|
const [showSpec, setShowSpec] = useState(false);
|
||||||
const [showCharts, setShowCharts] = useState(false);
|
const [showCharts, setShowCharts] = useState(false);
|
||||||
@@ -284,22 +275,6 @@ function AppShellInner({ username }: { username: string }) {
|
|||||||
if (selectedScenarioId === s.id) setSelectedScenarioId(null);
|
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 =
|
const activePlan =
|
||||||
plans.find((p) => p.scenarios.some((s) => s.id === selectedScenarioId)) ??
|
plans.find((p) => p.scenarios.some((s) => s.id === selectedScenarioId)) ??
|
||||||
(planNav ? plans.find((p) => p.id === planNav.planId) ?? null : null);
|
(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).
|
// Aktionen der Befehls-Palette -- kontextabhängig (Analysen nur bei offenem Szenario).
|
||||||
const paletteActions = useMemo<PaletteAction[]>(() => {
|
const paletteActions = useMemo<PaletteAction[]>(() => {
|
||||||
const base: 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-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-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); } },
|
{ 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>
|
<span className="text-[11px] font-semibold uppercase tracking-wide text-faint">Meine Pläne</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowNewPlanChooser(true)}
|
onClick={() => setShowEmptyPlanDialog(true)}
|
||||||
aria-label="Neuen Plan erstellen"
|
aria-label="Neuen Plan erstellen"
|
||||||
className="rounded-md p-1 text-accent transition-colors hover:bg-accent-soft"
|
className="rounded-md p-1 text-accent transition-colors hover:bg-accent-soft"
|
||||||
>
|
>
|
||||||
@@ -631,58 +606,20 @@ function AppShellInner({ username }: { username: string }) {
|
|||||||
<DashboardHome
|
<DashboardHome
|
||||||
username={username}
|
username={username}
|
||||||
plans={plans}
|
plans={plans}
|
||||||
creatingDemo={creatingDemo}
|
|
||||||
onOpenPlan={(id) => openPlanTab(id, "dashboard")}
|
onOpenPlan={(id) => openPlanTab(id, "dashboard")}
|
||||||
onCreateGuided={() => setShowWizard(true)}
|
onCreatePlan={() => setShowEmptyPlanDialog(true)}
|
||||||
onCreateChooser={() => setShowNewPlanChooser(true)}
|
|
||||||
onCreateEmpty={() => setShowEmptyPlanDialog(true)}
|
|
||||||
onCreateDemo={handleCreateDemo}
|
|
||||||
onDelete={handleDeletePlan}
|
onDelete={handleDeletePlan}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!showSpec && !showSystemParams && !loading && detail && selectedScenarioId && (
|
{!showSpec && !showSystemParams && !loading && detail && selectedScenarioId && (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
{/* Funktions-Buttons der Szenario-Ansicht. Grafiken/Effektive Werte/Live-Sim/
|
{/* Nur noch, was NICHT zum Szenario-Inhalt gehört: das Löschen des Szenarios
|
||||||
Monte-Carlo/Einflussfaktoren sind hier bewusst NICHT mehr -- sie laufen über
|
und der Hinweis auf Abweichungen zur Vorlage. Die inhaltlichen Aktionen
|
||||||
die eigenen Menüpunkte (Analysen / Effektive Werte). Hier bleiben die reinen
|
(Historie, Tour, Kopie, Rechenwege, Export) sitzen seit 0.36 als Kachel im
|
||||||
Szenario-Aktionen plus Tour und CSV-Export. */}
|
Szenario-Screen -- dort, wo man sie braucht. */}
|
||||||
<div className="flex flex-wrap items-center gap-2" data-tour="toolbar">
|
{(!detail.meta.isBase || (diff && detail.base)) && (
|
||||||
{detail.plan.phases.length > 0 && (
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<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 && (
|
{!detail.meta.isBase && (
|
||||||
<Button variant="danger" onClick={() => handleDeleteScenario(detail.meta)}>
|
<Button variant="danger" onClick={() => handleDeleteScenario(detail.meta)}>
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
@@ -698,6 +635,7 @@ function AppShellInner({ username }: { username: string }) {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<PlanView
|
<PlanView
|
||||||
plan={detail.plan}
|
plan={detail.plan}
|
||||||
@@ -708,40 +646,18 @@ function AppShellInner({ username }: { username: string }) {
|
|||||||
onChanged={refreshCurrent}
|
onChanged={refreshCurrent}
|
||||||
onOpenSpec={openSpecAt}
|
onOpenSpec={openSpecAt}
|
||||||
onOpenSensitivity={() => setShowSensitivity(true)}
|
onOpenSensitivity={() => setShowSensitivity(true)}
|
||||||
|
onOpenHistory={() => setShowHistory(true)}
|
||||||
|
onStartTour={() => setShowTour(true)}
|
||||||
|
onCopyScenario={() => setCopyFrom(detail.meta)}
|
||||||
|
onOpenTraces={() => setShowPlanTraces(true)}
|
||||||
|
exportHref={`/api/scenarios/${detail.meta.id}/export`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</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 && (
|
{showEmptyPlanDialog && (
|
||||||
<PlanDialog
|
<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).
|
// Breiter Dialog für den Analyse-Bereich (Grafiken).
|
||||||
function ChartsDialog({
|
function ChartsDialog({
|
||||||
title,
|
title,
|
||||||
@@ -1056,22 +905,14 @@ function ScenarioTree({
|
|||||||
function DashboardHome({
|
function DashboardHome({
|
||||||
username,
|
username,
|
||||||
plans,
|
plans,
|
||||||
creatingDemo,
|
|
||||||
onOpenPlan,
|
onOpenPlan,
|
||||||
onCreateGuided,
|
onCreatePlan,
|
||||||
onCreateChooser,
|
|
||||||
onCreateDemo,
|
|
||||||
onCreateEmpty,
|
|
||||||
onDelete,
|
onDelete,
|
||||||
}: {
|
}: {
|
||||||
username: string;
|
username: string;
|
||||||
plans: PlanListItem[];
|
plans: PlanListItem[];
|
||||||
creatingDemo: boolean;
|
|
||||||
onOpenPlan: (planId: string) => void;
|
onOpenPlan: (planId: string) => void;
|
||||||
onCreateGuided: () => void;
|
onCreatePlan: () => void;
|
||||||
onCreateChooser: () => void;
|
|
||||||
onCreateDemo: () => void;
|
|
||||||
onCreateEmpty: () => void;
|
|
||||||
onDelete: (id: string) => void;
|
onDelete: (id: string) => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
@@ -1085,20 +926,16 @@ function DashboardHome({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{plans.length === 0 ? (
|
{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
|
<EmptyState
|
||||||
icon={<Sparkles className="h-6 w-6" />}
|
icon={<Sparkles className="h-6 w-6" />}
|
||||||
title="Deine erste Finanzplanung"
|
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}>
|
<Button onClick={onCreatePlan}>
|
||||||
<Sparkles className="h-4 w-4" /> Geführt starten
|
<Plus className="h-4 w-4" /> Meinen ersten Finanzplan anlegen
|
||||||
</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>
|
</Button>
|
||||||
</EmptyState>
|
</EmptyState>
|
||||||
) : (
|
) : (
|
||||||
@@ -1151,7 +988,7 @@ function DashboardHome({
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
type="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"
|
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" />
|
<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";
|
"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";
|
import type { HouseholdType, PersonRole } from "@/lib/types";
|
||||||
|
|
||||||
export interface ProfileDraft {
|
export interface ProfileDraft {
|
||||||
@@ -15,23 +16,28 @@ export function emptyProfileDraft(): ProfileDraft {
|
|||||||
householdType: "SINGLE",
|
householdType: "SINGLE",
|
||||||
inflationRateDefault: 1.5,
|
inflationRateDefault: 1.5,
|
||||||
startYear: new Date().getFullYear(),
|
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,
|
// 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({
|
export function PlanProfileFields({
|
||||||
draft,
|
draft,
|
||||||
onChange,
|
onChange,
|
||||||
// Sobald Lebensphasen bestehen, liegt jede Pensionierung auf einer Phasengrenze. Ein frei
|
// Blendet den Hinweis aus, dass diese Angaben plan-weit gelten -- beim Anlegen eines Plans
|
||||||
// änderbares Alter würde diese Grenze zerreissen -- deshalb übernimmt dort die eigene
|
// gibt es noch keine anderen Szenarien, die betroffen sein könnten.
|
||||||
// Bedienung «Pensionsalter anpassen» (Roadmap Nr. 44), und das Feld ist nur noch Anzeige.
|
hideScopeNote = false,
|
||||||
lockRetirement = false,
|
|
||||||
}: {
|
}: {
|
||||||
draft: ProfileDraft;
|
draft: ProfileDraft;
|
||||||
onChange: (next: ProfileDraft) => void;
|
onChange: (next: ProfileDraft) => void;
|
||||||
lockRetirement?: boolean;
|
hideScopeNote?: boolean;
|
||||||
}) {
|
}) {
|
||||||
function setType(type: HouseholdType) {
|
function setType(type: HouseholdType) {
|
||||||
if (type === "SINGLE") {
|
if (type === "SINGLE") {
|
||||||
@@ -39,7 +45,7 @@ export function PlanProfileFields({
|
|||||||
} else {
|
} else {
|
||||||
const persons =
|
const persons =
|
||||||
draft.persons.length < 2
|
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;
|
: draft.persons;
|
||||||
onChange({ ...draft, householdType: type, 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
|
{/* 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
|
ALLE Szenarien. Das muss sichtbar sein -- sonst ändert man beim Bearbeiten eines
|
||||||
Nebenszenarios unbemerkt auch alle anderen. */}
|
Nebenszenarios unbemerkt auch alle anderen. */}
|
||||||
|
{!hideScopeNote && (
|
||||||
<p className="rounded-lg border border-attention bg-attention-soft px-3 py-2 text-xs text-attention-soft-fg">
|
<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
|
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{" "}
|
Plans. Unterscheiden sie sich, ist es ein anderer Plan.
|
||||||
<strong>Pensionsalter</strong> und die <strong>Inflation</strong>.
|
|
||||||
</p>
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
<SelectField
|
<SelectField
|
||||||
label="Haushaltsform (plan-weit)"
|
label="Haushaltsform (plan-weit)"
|
||||||
@@ -94,26 +101,7 @@ export function PlanProfileFields({
|
|||||||
max={120}
|
max={120}
|
||||||
onChange={(v) => updatePerson(index, { age: Math.round(v) })}
|
onChange={(v) => updatePerson(index, { age: Math.round(v) })}
|
||||||
/>
|
/>
|
||||||
{lockRetirement ? (
|
{/* Das Pensionsalter steht bewusst NICHT hier -- siehe Kommentar oben. */}
|
||||||
<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) })}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
|||||||
+289
-235
@@ -3,7 +3,6 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import {
|
import {
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
ArrowRight,
|
|
||||||
Building2,
|
Building2,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
@@ -11,13 +10,15 @@ import {
|
|||||||
CreditCard,
|
CreditCard,
|
||||||
Home,
|
Home,
|
||||||
Landmark,
|
Landmark,
|
||||||
ListTodo,
|
|
||||||
Pencil,
|
Pencil,
|
||||||
Maximize2,
|
Maximize2,
|
||||||
PiggyBank,
|
PiggyBank,
|
||||||
Table2,
|
Copy,
|
||||||
|
HelpCircle,
|
||||||
|
History as HistoryIcon,
|
||||||
|
BookOpen,
|
||||||
|
Download,
|
||||||
Plus,
|
Plus,
|
||||||
Settings2,
|
|
||||||
ShoppingCart,
|
ShoppingCart,
|
||||||
Tornado,
|
Tornado,
|
||||||
Trash2,
|
Trash2,
|
||||||
@@ -35,15 +36,22 @@ import {
|
|||||||
type DecisionCounts,
|
type DecisionCounts,
|
||||||
transitionInactive as inactiveAtTransition,
|
transitionInactive as inactiveAtTransition,
|
||||||
TRANSITION_CATEGORIES,
|
TRANSITION_CATEGORIES,
|
||||||
retirementConfirmed,
|
|
||||||
} from "@/lib/decisions";
|
} from "@/lib/decisions";
|
||||||
import { AHV_REFERENCE_AGE } from "@/lib/constants";
|
import { AHV_REFERENCE_AGE } from "@/lib/constants";
|
||||||
import {
|
import {
|
||||||
RETIREMENT_CATEGORIES,
|
|
||||||
withRetirementDefaults,
|
withRetirementDefaults,
|
||||||
type RetirementDecision,
|
type RetirementDecision,
|
||||||
} from "@/lib/retirement-decision";
|
} from "@/lib/retirement-decision";
|
||||||
import { RetirementFields } from "@/components/RetirementFields";
|
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 { RetirementPanel } from "@/components/RetirementPanel";
|
||||||
import { Button, EmptyState, InspectorShell, Modal, useConfirm, useToast } from "@/components/ui";
|
import { Button, EmptyState, InspectorShell, Modal, useConfirm, useToast } from "@/components/ui";
|
||||||
import { ElementDetailDialog, PhaseDetailDialog } from "@/components/DetailView";
|
import { ElementDetailDialog, PhaseDetailDialog } from "@/components/DetailView";
|
||||||
@@ -80,7 +88,7 @@ import {
|
|||||||
} from "@/lib/elements";
|
} from "@/lib/elements";
|
||||||
import { computePlan, type PhaseComputed, type PlanComputed } from "@/lib/calculations";
|
import { computePlan, type PhaseComputed, type PlanComputed } from "@/lib/calculations";
|
||||||
import type { ScenarioDiff } from "@/lib/diff";
|
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> = {
|
const CATEGORY_ICON: Record<ElementCategory, React.ReactNode> = {
|
||||||
INCOME: <Wallet className="h-4 w-4" />,
|
INCOME: <Wallet className="h-4 w-4" />,
|
||||||
@@ -128,6 +136,11 @@ export function PlanView({
|
|||||||
onChanged,
|
onChanged,
|
||||||
onOpenSpec,
|
onOpenSpec,
|
||||||
onOpenSensitivity,
|
onOpenSensitivity,
|
||||||
|
onOpenHistory,
|
||||||
|
onStartTour,
|
||||||
|
onCopyScenario,
|
||||||
|
onOpenTraces,
|
||||||
|
exportHref,
|
||||||
}: {
|
}: {
|
||||||
plan: PlanInput;
|
plan: PlanInput;
|
||||||
computed: PlanComputed;
|
computed: PlanComputed;
|
||||||
@@ -140,6 +153,13 @@ export function PlanView({
|
|||||||
onChanged: () => void;
|
onChanged: () => void;
|
||||||
onOpenSpec?: (anchor: string) => void;
|
onOpenSpec?: (anchor: string) => void;
|
||||||
onOpenSensitivity?: () => 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
|
// 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
|
// 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.
|
// weil beide mehrere Elemente auf einmal bearbeiten.
|
||||||
const [distribute, setDistribute] = useState<{ kind: "capital" | "rates"; phaseId: string } | null>(null);
|
const [distribute, setDistribute] = useState<{ kind: "capital" | "rates"; phaseId: string } | null>(null);
|
||||||
const [valueMode, setValueMode] = useState<ValueMode>("nominal");
|
const [valueMode, setValueMode] = useState<ValueMode>("nominal");
|
||||||
// Matrix oder Pensionierung. Kein URL-Zustand: Es ist eine Arbeitsansicht, kein Ort.
|
// Offener Assistenten-Schritt (Index) bzw. offene Pensionsplanung (Rolle).
|
||||||
const [view, setView] = useState<"matrix" | "retirement">("matrix");
|
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
|
// 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.
|
// 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.
|
// Nur-Lese-Detailansicht (Roadmap Nr. 43). Der Rechenweg wird erst beim Öffnen erzeugt.
|
||||||
@@ -366,72 +388,40 @@ export function PlanView({
|
|||||||
onChanged();
|
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 hasPhases = computed.phases.length > 0;
|
||||||
const firstPhase = computed.phases[0] ?? null;
|
const firstPhase = computed.phases[0] ?? null;
|
||||||
const lastPhase = computed.phases[computed.phases.length - 1] ?? null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-5">
|
<div className="flex flex-col gap-5">
|
||||||
<NextSteps
|
{/* 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}
|
plan={plan}
|
||||||
computed={computed}
|
computed={computed}
|
||||||
columns={columns}
|
diffChanged={!!diff?.profileChanged}
|
||||||
openCountFor={transitionOpenCount}
|
onEdit={() => setPanel({ kind: "profile" })}
|
||||||
onReview={(fromPhaseId) => setReviewFromPhaseId(fromPhaseId)}
|
onRetirement={(role) => setRetirementRole(role)}
|
||||||
onAddElement={(cat) => {
|
/>
|
||||||
setAddCategory(cat);
|
<FiguresTile plan={plan} computed={computed} />
|
||||||
setShowAdd(true);
|
<QuickActionsTile
|
||||||
}}
|
onHistory={onOpenHistory}
|
||||||
onAddPhase={() => setShowAddPhase(true)}
|
onTour={onStartTour}
|
||||||
onOpenSensitivity={onOpenSensitivity}
|
onNewScenario={onCopyScenario}
|
||||||
|
onTraces={onOpenTraces}
|
||||||
|
exportHref={exportHref}
|
||||||
|
/>
|
||||||
|
<Assistant
|
||||||
|
plan={plan}
|
||||||
|
progress={progress}
|
||||||
|
onOpenStep={(i) => setAssistantStep(i)}
|
||||||
|
onChanged={onChanged}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Zeitachse */}
|
<div data-tour="timeline" className="mt-3">
|
||||||
<div data-tour="timeline" className="lg:col-span-2">
|
|
||||||
<Timeline
|
<Timeline
|
||||||
phases={computed.phases}
|
phases={computed.phases}
|
||||||
persons={personAxes}
|
persons={personAxes}
|
||||||
@@ -440,70 +430,11 @@ export function PlanView({
|
|||||||
startYear={plan.startYear}
|
startYear={plan.startYear}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
|
||||||
<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
|
{/* Anzeige-Umschalter (nominal/real, Plan/Ist) -- direkt über der Matrix, weil er nur
|
||||||
deren Zahlen steuert. */}
|
deren Zahlen steuert. */}
|
||||||
{hasPhases && view === "matrix" && (
|
{hasPhases && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-xs text-muted">Anzeige</span>
|
<span className="text-xs text-muted">Anzeige</span>
|
||||||
<div className="inline-flex rounded-lg border border-border bg-surface p-0.5 text-xs">
|
<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)
|
{/* Matrix: eigener Scrollbereich, damit Phasen-Köpfe (oben) UND Elementnamen (links)
|
||||||
beim Scrollen sichtbar bleiben. */}
|
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">
|
<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
|
{/* Feste Spaltenbreiten: Alle Phasenspalten sind gleich breit -- bei einer einzigen
|
||||||
Phase bleibt die Tabelle dadurch schmal, bei vielen wird horizontal gescrollt.
|
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 && (() => {
|
{reviewFromPhaseId && (() => {
|
||||||
const fromPhase = computed.phases.find((p) => p.id === reviewFromPhaseId);
|
const fromPhase = computed.phases.find((p) => p.id === reviewFromPhaseId);
|
||||||
if (!fromPhase) return null;
|
if (!fromPhase) return null;
|
||||||
@@ -1563,114 +1531,6 @@ function FragmentRows({ children }: { children: React.ReactNode }) {
|
|||||||
return <>{children}</>;
|
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 ---
|
// --- Dialog: neues finanzielles Element mit Direkteingabe der Phase-1-Werte ---
|
||||||
function AddElementDialog({
|
function AddElementDialog({
|
||||||
plan,
|
plan,
|
||||||
@@ -1932,7 +1792,7 @@ function ProfilePanel({ plan, onClose, onSaved }: { plan: PlanInput; onClose: ()
|
|||||||
return (
|
return (
|
||||||
<InspectorShell title="Szenario-Profil" subtitle="Haushalt, Personen, Planstart und Inflation dieses Szenarios" onClose={onClose}>
|
<InspectorShell title="Szenario-Profil" subtitle="Haushalt, Personen, Planstart und Inflation dieses Szenarios" onClose={onClose}>
|
||||||
<div className="flex flex-col gap-3">
|
<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>}
|
{error && <p className="text-sm text-danger">{error}</p>}
|
||||||
<div className="flex gap-2 pt-1">
|
<div className="flex gap-2 pt-1">
|
||||||
<Button disabled={saving} onClick={save}>{saving ? "…" : "Speichern"}</Button>
|
<Button disabled={saving} onClick={save}>{saving ? "…" : "Speichern"}</Button>
|
||||||
@@ -2432,3 +2292,197 @@ function ElementMetaPanel({
|
|||||||
</div>
|
</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 { api } from "@/lib/api-client";
|
||||||
import { Button, useToast } from "@/components/ui";
|
import { Button, useToast } from "@/components/ui";
|
||||||
import { InfoBubble } from "@/components/InfoBubble";
|
import { InfoBubble } from "@/components/InfoBubble";
|
||||||
import { NumberField } from "@/components/FormField";
|
|
||||||
import { RetirementAdjuster } from "@/components/RetirementAdjuster";
|
import { RetirementAdjuster } from "@/components/RetirementAdjuster";
|
||||||
import { RetirementFields } from "@/components/RetirementFields";
|
import { RetirementFields } from "@/components/RetirementFields";
|
||||||
import { formatChf } from "@/lib/format";
|
import { formatChf } from "@/lib/format";
|
||||||
import { ownerLabel } from "@/lib/elements";
|
import { ownerLabel } from "@/lib/elements";
|
||||||
import {
|
import { AHV_REFERENCE_AGE, PK_MIN_RETIREMENT_AGE } from "@/lib/constants";
|
||||||
AHV_REFERENCE_AGE,
|
|
||||||
MAX_PLANNING_HORIZON_AGE,
|
|
||||||
MIN_PLANNING_HORIZON_AGE,
|
|
||||||
PK_MIN_RETIREMENT_AGE,
|
|
||||||
} from "@/lib/constants";
|
|
||||||
import { withRetirementDefaults, type RetirementDecision } from "@/lib/retirement-decision";
|
import { withRetirementDefaults, type RetirementDecision } from "@/lib/retirement-decision";
|
||||||
import type { PlanComputed, RetirementPersonSummary } from "@/lib/calculations";
|
import type { PlanComputed, RetirementPersonSummary } from "@/lib/calculations";
|
||||||
import type { ElementInput, PersonRole, PlanInput } from "@/lib/types";
|
import type { ElementInput, PersonRole, PlanInput } from "@/lib/types";
|
||||||
@@ -43,10 +37,14 @@ type Draft = Record<string, RetirementDecision>;
|
|||||||
export function RetirementPanel({
|
export function RetirementPanel({
|
||||||
plan,
|
plan,
|
||||||
computed,
|
computed,
|
||||||
|
role,
|
||||||
onSaved,
|
onSaved,
|
||||||
}: {
|
}: {
|
||||||
plan: PlanInput;
|
plan: PlanInput;
|
||||||
computed: PlanComputed;
|
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;
|
onSaved: () => void;
|
||||||
}) {
|
}) {
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
@@ -93,29 +91,20 @@ export function RetirementPanel({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-5">
|
<div className="flex flex-col gap-5">
|
||||||
<div>
|
<p className="text-sm text-muted">
|
||||||
<h2 className="text-lg font-semibold text-fg">Pensionierung</h2>
|
Was du hier festlegst, steht auch in der Matrix am Pensions-Übergang – es ist derselbe Entscheid, nur
|
||||||
<p className="mt-1 text-sm text-muted">
|
anders angeschaut.
|
||||||
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>
|
</p>
|
||||||
</div>
|
|
||||||
|
|
||||||
{plan.persons.map((person) => {
|
|
||||||
const summary = computed.retirement.perPerson.find((s) => s.role === person.role);
|
|
||||||
return (
|
|
||||||
<PersonBlock
|
<PersonBlock
|
||||||
key={person.role}
|
|
||||||
plan={plan}
|
plan={plan}
|
||||||
computed={computed}
|
computed={computed}
|
||||||
role={person.role}
|
role={role}
|
||||||
summary={summary}
|
summary={computed.retirement.perPerson.find((s) => s.role === role)}
|
||||||
effective={effective}
|
effective={effective}
|
||||||
patch={patch}
|
patch={patch}
|
||||||
onSaved={onSaved}
|
onSaved={onSaved}
|
||||||
/>
|
/>
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
{error && <p className="text-sm text-danger">{error}</p>}
|
{error && <p className="text-sm text-danger">{error}</p>}
|
||||||
{dirty && (
|
{dirty && (
|
||||||
@@ -174,9 +163,8 @@ function PersonBlock({
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</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} />
|
<RetirementAdjuster plan={plan} onSaved={onSaved} />
|
||||||
<HorizonControl plan={plan} role={role} onSaved={onSaved} />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<GapBox plan={plan} computed={computed} summary={summary} />
|
<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]/actuals/[setId]": "dito (Löschen eines Ist-Satzes)",
|
||||||
"plans/[planId]/analyses": "gespeicherte Analysen sind read-only Momentaufnahmen – kein Szenario betroffen",
|
"plans/[planId]/analyses": "gespeicherte Analysen sind read-only Momentaufnahmen – kein Szenario betroffen",
|
||||||
"plans/[planId]/analyses/[analysisId]": "dito (Öffnen/Löschen einer Analyse)",
|
"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": "Berichte sind erzeugte Dokumente – sie verändern kein Szenario",
|
||||||
"plans/[planId]/reports/[reportId]": "dito (Herunterladen/Löschen eines Berichts)",
|
"plans/[planId]/reports/[reportId]": "dito (Herunterladen/Löschen eines Berichts)",
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user