UI-Gesamtumbau: Du-Form, Inspector-Panel, Onboarding-Assistent, Tour, Palette
Deploy App / deploy (push) Successful in 1m1s
Deploy App / deploy (push) Successful in 1m1s
Rein an der Oberflaeche -- Berechnung, Datenmodell und API-Semantik unveraendert (103 Tests unveraendert gruen). Paket A (Fundament): - durchgehend Du-Form und echte Umlaute in allen sichtbaren Texten, inkl. API-Fehlermeldungen (vorher Mix aus Sie/Du und ae/oe/ue) - neue UI-Primitiven (ui.tsx): Button, Modal mit ESC/Fokus-Falle/Animation, Bestaetigungs-Dialog statt window.confirm, Toasts statt alert, Skeleton-Loader, EmptyState - eigene Attention-Farbe (Amber) fuer offene Entscheide, getrennt vom Akzent - Micro-Interactions mit prefers-reduced-motion-Fallback Paket B (Onboarding, Roadmap Nr. 10): - gefuehrter Plan-Assistent in 5 Schritten; Einkommen bewusst pro Person (raeumt die 9.9-AHV-Falle aus); reine Orchestrierung bestehender Endpunkte - Beispielplan mit einem Klick; Uebergaenge absichtlich offen - interaktive Tour ueber die Planansicht (localStorage, jederzeit neu startbar) - abgeleitete "Naechste Schritte"-Karte (offene Entscheide, fehlende Elemente, fehlende Pensionsphase, Ruin -> Einflussfaktoren) Paket C (Struktur): - Inspector-Panel rechts statt Modals fuer alle Einzel-Bearbeitungen; Matrix bleibt sichtbar, Zellklick wechselt den Inhalt - Phasenkopf auf vier Kern-Infos entschlackt (Rest in der 0.11-Detailansicht) - Matrix mit eigenem Scrollbereich, Koepfe beidachsig fixiert - Sidebar-Gruppen "Meine Plaene" / "Wissen"; "So rechnet FPT" statt SPEZIFIKATION; "Szenario-Profil" statt "Plan-Einstellungen" - Aktions-Icons ohne Hover sichtbar (Touch) Paket D (Extras): - Sparklines je Element-Zeile aus den 0.11-Verlaufswerten - Befehls-Palette (Ctrl/Cmd+K) - Ruin-Banner verlinkt auf die Einflussfaktoren Nebenbei: der ProfileMenu-Lint-Fehler und der Selection-Rest (9.17) sind behoben -- npm run lint laeuft erstmals fehlerfrei. SPEZIFIKATION auf 0.13: neue Kapitel 3.2.8, 3.7.6-3.7.9, 9.23, 9.24; 3.6.3 und 3.7.1 ueberarbeitet, 9.17 bereinigt. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+411
-218
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
BarChart3,
|
||||
BookOpen,
|
||||
@@ -13,7 +13,9 @@ import {
|
||||
Menu,
|
||||
PiggyBank,
|
||||
Plus,
|
||||
Search,
|
||||
SlidersHorizontal,
|
||||
Sparkles,
|
||||
Tornado,
|
||||
Trash2,
|
||||
X,
|
||||
@@ -27,8 +29,21 @@ import { SystemParametersView } from "@/components/SystemParametersView";
|
||||
import { PlanTraceDialog } from "@/components/DetailView";
|
||||
import { computePlan } from "@/lib/calculations";
|
||||
import { ProfileMenu } from "@/components/ProfileMenu";
|
||||
import { PlanWizard } from "@/components/PlanWizard";
|
||||
import { CommandPalette, type PaletteAction } from "@/components/CommandPalette";
|
||||
import { PlanProfileFields, emptyProfileDraft, type ProfileDraft } from "@/components/PlanProfileFields";
|
||||
import {
|
||||
Button,
|
||||
ConfirmProvider,
|
||||
EmptyState,
|
||||
Modal,
|
||||
PlanSkeleton,
|
||||
ToastProvider,
|
||||
useConfirm,
|
||||
useToast,
|
||||
} from "@/components/ui";
|
||||
import { api } from "@/lib/api-client";
|
||||
import { createDemoPlan } from "@/lib/demoplan";
|
||||
import { computeScenarioDiff } from "@/lib/diff";
|
||||
import type { PlanInput, PlanListItem, ScenarioMeta } from "@/lib/types";
|
||||
import type { PlanComputed } from "@/lib/calculations";
|
||||
@@ -40,13 +55,31 @@ interface ScenarioDetail {
|
||||
meta: ScenarioMeta & { planName: string };
|
||||
}
|
||||
|
||||
// Die Provider (Toast, Bestätigung) müssen UM die Shell liegen, damit deren Hooks
|
||||
// innerhalb funktionieren.
|
||||
export function AppShell({ username }: { username: string }) {
|
||||
return (
|
||||
<ToastProvider>
|
||||
<ConfirmProvider>
|
||||
<AppShellInner username={username} />
|
||||
</ConfirmProvider>
|
||||
</ToastProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function AppShellInner({ username }: { username: string }) {
|
||||
const toast = useToast();
|
||||
const confirm = useConfirm();
|
||||
|
||||
const [plans, setPlans] = useState<PlanListItem[]>([]);
|
||||
const [selectedScenarioId, setSelectedScenarioId] = useState<string | null>(null);
|
||||
const [detail, setDetail] = useState<ScenarioDetail | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const [showNewPlan, setShowNewPlan] = useState(false);
|
||||
const [showNewPlanChooser, setShowNewPlanChooser] = useState(false);
|
||||
const [showWizard, setShowWizard] = useState(false);
|
||||
const [showEmptyPlanDialog, setShowEmptyPlanDialog] = useState(false);
|
||||
const [creatingDemo, setCreatingDemo] = useState(false);
|
||||
const [copyFrom, setCopyFrom] = useState<ScenarioMeta | null>(null);
|
||||
const [showSpec, setShowSpec] = useState(false);
|
||||
const [showCharts, setShowCharts] = useState(false);
|
||||
@@ -54,6 +87,7 @@ export function AppShell({ username }: { username: string }) {
|
||||
const [showSensitivity, setShowSensitivity] = useState(false);
|
||||
const [showSystemParams, setShowSystemParams] = useState(false);
|
||||
const [showPlanTraces, setShowPlanTraces] = useState(false);
|
||||
const [showPalette, setShowPalette] = useState(false);
|
||||
// Sprungmarke in die SPEZIFIKATION, gesetzt aus einem Rechenweg heraus.
|
||||
const [specAnchor, setSpecAnchor] = useState<string | null>(null);
|
||||
|
||||
@@ -71,7 +105,7 @@ export function AppShell({ username }: { username: string }) {
|
||||
}, []);
|
||||
|
||||
// silent = Hintergrund-Refresh ohne Loading-Umschaltung: die PlanView bleibt montiert,
|
||||
// damit die Scrollposition (z. B. nach dem Schliessen eines Popups) erhalten bleibt.
|
||||
// damit die Scrollposition (z. B. nach dem Schliessen eines Panels) erhalten bleibt.
|
||||
const loadDetail = useCallback(async (scenarioId: string, silent = false) => {
|
||||
if (!silent) setLoading(true);
|
||||
try {
|
||||
@@ -95,6 +129,18 @@ export function AppShell({ username }: { username: string }) {
|
||||
}
|
||||
}, [selectedScenarioId, loadDetail]);
|
||||
|
||||
// Cmd/Ctrl+K öffnet die Befehls-Palette.
|
||||
useEffect(() => {
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
|
||||
e.preventDefault();
|
||||
setShowPalette((v) => !v);
|
||||
}
|
||||
}
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, []);
|
||||
|
||||
function refreshCurrent() {
|
||||
if (selectedScenarioId) loadDetail(selectedScenarioId, true);
|
||||
}
|
||||
@@ -107,8 +153,16 @@ export function AppShell({ username }: { username: string }) {
|
||||
}
|
||||
|
||||
async function handleDeletePlan(id: string) {
|
||||
if (!confirm("Diesen Plan mit ALLEN Szenarien wirklich loeschen?")) return;
|
||||
const plan = plans.find((p) => p.id === id);
|
||||
const ok = await confirm({
|
||||
title: "Plan löschen?",
|
||||
message: `«${plan?.name ?? "Plan"}» wird mit allen Szenarien unwiderruflich gelöscht.`,
|
||||
confirmLabel: "Endgültig löschen",
|
||||
danger: true,
|
||||
});
|
||||
if (!ok) return;
|
||||
await api.delete(`/api/plans/${id}`);
|
||||
toast("success", "Plan gelöscht.");
|
||||
const rest = await loadPlans();
|
||||
if (!rest.some((p) => p.scenarios.some((s) => s.id === selectedScenarioId))) {
|
||||
setSelectedScenarioId(null);
|
||||
@@ -116,19 +170,61 @@ export function AppShell({ username }: { username: string }) {
|
||||
}
|
||||
|
||||
async function handleDeleteScenario(s: ScenarioMeta) {
|
||||
if (!confirm(`Szenario "${s.name}" wirklich loeschen?`)) return;
|
||||
const ok = await confirm({
|
||||
title: "Szenario löschen?",
|
||||
message: `«${s.name}» wird unwiderruflich gelöscht. Untergeordnete Szenarien bleiben bestehen.`,
|
||||
confirmLabel: "Endgültig löschen",
|
||||
danger: true,
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
await api.delete(`/api/scenarios/${s.id}`);
|
||||
toast("success", "Szenario gelöscht.");
|
||||
} catch (e) {
|
||||
alert(e instanceof Error ? e.message : "Loeschen fehlgeschlagen.");
|
||||
toast("error", e instanceof Error ? e.message : "Löschen fehlgeschlagen.");
|
||||
return;
|
||||
}
|
||||
await loadPlans();
|
||||
if (selectedScenarioId === s.id) setSelectedScenarioId(null);
|
||||
}
|
||||
|
||||
async function handleCreateDemo() {
|
||||
setCreatingDemo(true);
|
||||
try {
|
||||
const sid = await createDemoPlan();
|
||||
await loadPlans();
|
||||
setShowNewPlanChooser(false);
|
||||
openScenario(sid);
|
||||
toast("success", "Beispielplan angelegt – schau dich um!");
|
||||
} catch (e) {
|
||||
toast("error", e instanceof Error ? e.message : "Beispielplan konnte nicht angelegt werden.");
|
||||
} finally {
|
||||
setCreatingDemo(false);
|
||||
}
|
||||
}
|
||||
|
||||
const activePlan = plans.find((p) => p.scenarios.some((s) => s.id === selectedScenarioId)) ?? null;
|
||||
|
||||
// Aktionen der Befehls-Palette -- kontextabhängig (Analysen nur bei offenem Szenario).
|
||||
const paletteActions = useMemo<PaletteAction[]>(() => {
|
||||
const base: PaletteAction[] = [
|
||||
{ id: "a-new", label: "Neuen Plan erstellen", hint: "Aktion", run: () => setShowNewPlanChooser(true) },
|
||||
{ id: "a-home", label: "Übersicht öffnen", hint: "Aktion", run: () => { setSelectedScenarioId(null); setShowSpec(false); setShowSystemParams(false); } },
|
||||
{ id: "a-spec", label: "So rechnet FPT (Spezifikation)", hint: "Aktion", run: () => { setShowSpec(true); setSpecAnchor(null); setShowSystemParams(false); setSelectedScenarioId(null); } },
|
||||
{ id: "a-params", label: "Systemparameter", hint: "Aktion", run: () => { setShowSystemParams(true); setShowSpec(false); setSelectedScenarioId(null); } },
|
||||
];
|
||||
if (detail && selectedScenarioId) {
|
||||
base.unshift(
|
||||
{ id: "a-charts", label: "Grafiken öffnen", hint: "Aktion", run: () => setShowCharts(true) },
|
||||
{ id: "a-mc", label: "Monte-Carlo-Simulation", hint: "Aktion", run: () => setShowMonteCarlo(true) },
|
||||
{ id: "a-sens", label: "Einflussfaktoren berechnen", hint: "Aktion", run: () => setShowSensitivity(true) },
|
||||
{ id: "a-traces", label: "Rechenwege anzeigen", hint: "Aktion", run: () => setShowPlanTraces(true) },
|
||||
{ id: "a-copy", label: "Neues Szenario aus diesem", hint: "Aktion", run: () => setCopyFrom(detail.meta) }
|
||||
);
|
||||
}
|
||||
return base;
|
||||
}, [detail, selectedScenarioId]);
|
||||
|
||||
const sidebar = (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex items-center gap-2 px-4 py-4">
|
||||
@@ -136,6 +232,15 @@ export function AppShell({ username }: { username: string }) {
|
||||
<PiggyBank className="h-5 w-5 text-accent-fg" />
|
||||
</div>
|
||||
<span className="text-sm font-semibold text-fg">FPT</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPalette(true)}
|
||||
title="Suchen und springen (Ctrl+K)"
|
||||
className="ml-auto flex items-center gap-1 rounded-lg border border-border px-2 py-1 text-[10px] text-faint transition-colors hover:bg-surface-2 hover:text-fg"
|
||||
>
|
||||
<Search className="h-3 w-3" />
|
||||
Ctrl K
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<nav className="flex flex-1 flex-col gap-1 overflow-y-auto px-3 pb-4">
|
||||
@@ -147,26 +252,26 @@ export function AppShell({ username }: { username: string }) {
|
||||
setShowSystemParams(false);
|
||||
setSidebarOpen(false);
|
||||
}}
|
||||
className={`flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium ${
|
||||
className={`flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium transition-colors ${
|
||||
selectedScenarioId === null && !showSpec && !showSystemParams ? "bg-accent-soft text-accent-soft-fg" : "text-muted hover:bg-surface-2"
|
||||
}`}
|
||||
>
|
||||
<LayoutDashboard className="h-4 w-4" />
|
||||
Uebersicht
|
||||
Übersicht
|
||||
</button>
|
||||
|
||||
<div className="mt-4 flex items-center justify-between px-3">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wide text-faint">Plaene</span>
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wide text-faint">Meine Pläne</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowNewPlan(true)}
|
||||
onClick={() => setShowNewPlanChooser(true)}
|
||||
aria-label="Neuen Plan erstellen"
|
||||
className="rounded-md p-1 text-accent hover:bg-accent-soft"
|
||||
className="rounded-md p-1 text-accent transition-colors hover:bg-accent-soft"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
{plans.length === 0 && <p className="px-3 py-2 text-xs text-faint">Noch keine Plaene.</p>}
|
||||
{plans.length === 0 && <p className="px-3 py-2 text-xs text-faint">Noch keine Pläne.</p>}
|
||||
|
||||
{plans.map((p) => (
|
||||
<div key={p.id} className="mb-1">
|
||||
@@ -175,9 +280,9 @@ export function AppShell({ username }: { username: string }) {
|
||||
<span className="min-w-0 flex-1 truncate" title={p.name}>{p.name}</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Plan loeschen"
|
||||
aria-label="Plan löschen"
|
||||
onClick={() => handleDeletePlan(p.id)}
|
||||
className="rounded p-0.5 text-faint opacity-0 hover:bg-danger-soft hover:text-danger group-hover:opacity-100"
|
||||
className="rounded p-0.5 text-faint opacity-60 transition-opacity hover:bg-danger-soft hover:text-danger group-hover:opacity-100"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
@@ -195,21 +300,7 @@ export function AppShell({ username }: { username: string }) {
|
||||
))}
|
||||
|
||||
<div className="mt-4 flex flex-col gap-1 border-t border-border pt-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowSystemParams(true);
|
||||
setShowSpec(false);
|
||||
setSelectedScenarioId(null);
|
||||
setSidebarOpen(false);
|
||||
}}
|
||||
className={`flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm font-medium ${
|
||||
showSystemParams ? "bg-accent-soft text-accent-soft-fg" : "text-muted hover:bg-surface-2"
|
||||
}`}
|
||||
>
|
||||
<SlidersHorizontal className="h-4 w-4 shrink-0" />
|
||||
Systemparameter
|
||||
</button>
|
||||
<span className="px-3 pb-1 text-[11px] font-semibold uppercase tracking-wide text-faint">Wissen</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
@@ -219,12 +310,27 @@ export function AppShell({ username }: { username: string }) {
|
||||
setSelectedScenarioId(null);
|
||||
setSidebarOpen(false);
|
||||
}}
|
||||
className={`flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm font-medium ${
|
||||
className={`flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm font-medium transition-colors ${
|
||||
showSpec ? "bg-accent-soft text-accent-soft-fg" : "text-muted hover:bg-surface-2"
|
||||
}`}
|
||||
>
|
||||
<FileText className="h-4 w-4 shrink-0" />
|
||||
SPEZIFIKATION
|
||||
So rechnet FPT
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowSystemParams(true);
|
||||
setShowSpec(false);
|
||||
setSelectedScenarioId(null);
|
||||
setSidebarOpen(false);
|
||||
}}
|
||||
className={`flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm font-medium transition-colors ${
|
||||
showSystemParams ? "bg-accent-soft text-accent-soft-fg" : "text-muted hover:bg-surface-2"
|
||||
}`}
|
||||
>
|
||||
<SlidersHorizontal className="h-4 w-4 shrink-0" />
|
||||
Systemparameter
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
@@ -239,12 +345,12 @@ export function AppShell({ username }: { username: string }) {
|
||||
|
||||
{sidebarOpen && (
|
||||
<div className="fixed inset-0 z-40 lg:hidden">
|
||||
<div className="absolute inset-0 bg-black/40" onClick={() => setSidebarOpen(false)} />
|
||||
<aside className="absolute left-0 top-0 h-full w-72 border-r border-border bg-surface shadow-xl">
|
||||
<div className="ui-fade absolute inset-0 bg-black/40" onClick={() => setSidebarOpen(false)} />
|
||||
<aside className="ui-slide-in absolute left-0 top-0 h-full w-72 border-r border-border bg-surface shadow-xl">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
aria-label="Menue schliessen"
|
||||
aria-label="Menü schliessen"
|
||||
className="absolute right-2 top-3 rounded-md p-1.5 text-faint hover:bg-surface-2"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
@@ -259,7 +365,7 @@ export function AppShell({ username }: { username: string }) {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSidebarOpen(true)}
|
||||
aria-label="Menue oeffnen"
|
||||
aria-label="Menü öffnen"
|
||||
className="rounded-lg border border-border p-2 text-muted lg:hidden"
|
||||
>
|
||||
<Menu className="h-4 w-4" />
|
||||
@@ -268,10 +374,10 @@ export function AppShell({ username }: { username: string }) {
|
||||
{showSystemParams
|
||||
? "Systemparameter"
|
||||
: showSpec
|
||||
? "Spezifikation"
|
||||
? "So rechnet FPT"
|
||||
: detail
|
||||
? `${detail.meta.planName} · ${detail.meta.name}`
|
||||
: "Uebersicht"}
|
||||
: "Übersicht"}
|
||||
</h1>
|
||||
<ProfileMenu username={username} />
|
||||
</header>
|
||||
@@ -281,82 +387,64 @@ export function AppShell({ username }: { username: string }) {
|
||||
|
||||
{showSpec && <SpecView anchor={specAnchor} />}
|
||||
|
||||
{!showSpec && !showSystemParams && loading && <p className="text-sm text-muted">Laedt…</p>}
|
||||
{!showSpec && !showSystemParams && loading && <PlanSkeleton />}
|
||||
|
||||
{!showSpec && !showSystemParams && !loading && selectedScenarioId === null && (
|
||||
<DashboardHome
|
||||
username={username}
|
||||
plans={plans}
|
||||
creatingDemo={creatingDemo}
|
||||
onSelect={openScenario}
|
||||
onCreate={() => setShowNewPlan(true)}
|
||||
onCreateGuided={() => setShowWizard(true)}
|
||||
onCreateChooser={() => setShowNewPlanChooser(true)}
|
||||
onCreateDemo={handleCreateDemo}
|
||||
onDelete={handleDeletePlan}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!showSpec && !showSystemParams && !loading && detail && selectedScenarioId && (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCopyFrom(detail.meta)}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-border px-3 py-1.5 text-sm font-medium text-muted hover:bg-surface-2"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2" data-tour="analysen">
|
||||
<Button variant="secondary" onClick={() => setCopyFrom(detail.meta)}>
|
||||
<Copy className="h-4 w-4" />
|
||||
Neues Szenario aus diesem
|
||||
</button>
|
||||
</Button>
|
||||
{!detail.meta.isBase && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDeleteScenario(detail.meta)}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-border px-3 py-1.5 text-sm font-medium text-muted hover:border-danger hover:bg-danger-soft hover:text-danger"
|
||||
>
|
||||
<Button variant="danger" onClick={() => handleDeleteScenario(detail.meta)}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
Szenario loeschen
|
||||
</button>
|
||||
Szenario löschen
|
||||
</Button>
|
||||
)}
|
||||
{detail.plan.phases.length > 0 && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCharts(true)}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-border px-3 py-1.5 text-sm font-medium text-muted hover:bg-surface-2"
|
||||
>
|
||||
<Button variant="secondary" onClick={() => setShowCharts(true)}>
|
||||
<BarChart3 className="h-4 w-4" />
|
||||
Grafiken
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowMonteCarlo(true)}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-border px-3 py-1.5 text-sm font-medium text-muted hover:bg-surface-2"
|
||||
>
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={() => setShowMonteCarlo(true)}>
|
||||
<Dices className="h-4 w-4" />
|
||||
Monte-Carlo-Simulation
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowSensitivity(true)}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-border px-3 py-1.5 text-sm font-medium text-muted hover:bg-surface-2"
|
||||
>
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={() => setShowSensitivity(true)}>
|
||||
<Tornado className="h-4 w-4" />
|
||||
Einflussfaktoren berechnen
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
Einflussfaktoren
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => setShowPlanTraces(true)}
|
||||
title="Wie wird gerechnet? Plan-weite Grössen wie Deflatoren, AHV-Karriere und Ruinalter"
|
||||
className="flex items-center gap-1.5 rounded-lg border border-border px-3 py-1.5 text-sm font-medium text-muted hover:bg-surface-2"
|
||||
>
|
||||
<BookOpen className="h-4 w-4" />
|
||||
Rechenwege
|
||||
</button>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{diff && detail.base && (
|
||||
<span className="ml-auto flex items-center gap-1.5 rounded-lg border border-diff bg-diff-soft px-3 py-1.5 text-xs font-medium text-diff">
|
||||
<GitBranch className="h-3.5 w-3.5" />
|
||||
{diff.total === 0
|
||||
? "Unveraendert gegenueber der Vorlage"
|
||||
: `${diff.total} Abweichung${diff.total === 1 ? "" : "en"} gegenueber der Vorlage`}
|
||||
? "Unverändert gegenüber der Vorlage"
|
||||
: `${diff.total} Abweichung${diff.total === 1 ? "" : "en"} gegenüber der Vorlage`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -367,24 +455,54 @@ export function AppShell({ username }: { username: string }) {
|
||||
diff={diff}
|
||||
onChanged={refreshCurrent}
|
||||
onOpenSpec={openSpecAt}
|
||||
onOpenSensitivity={() => setShowSensitivity(true)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{showNewPlan && (
|
||||
{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();
|
||||
openScenario(sid);
|
||||
toast("success", "Plan erstellt – die Tour zeigt dir gleich die wichtigsten Stellen.");
|
||||
}}
|
||||
onClose={() => setShowWizard(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showEmptyPlanDialog && (
|
||||
<PlanDialog
|
||||
onCreate={async (name, profile) => {
|
||||
const { scenario } = await api.post<{ plan: { id: string }; scenario: { id: string } }>(
|
||||
"/api/plans",
|
||||
{ name, ...profile }
|
||||
);
|
||||
setShowNewPlan(false);
|
||||
setShowEmptyPlanDialog(false);
|
||||
await loadPlans();
|
||||
openScenario(scenario.id);
|
||||
toast("success", "Plan erstellt.");
|
||||
}}
|
||||
onClose={() => setShowNewPlan(false)}
|
||||
onClose={() => setShowEmptyPlanDialog(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -432,14 +550,90 @@ export function AppShell({ username }: { username: string }) {
|
||||
setCopyFrom(null);
|
||||
await loadPlans();
|
||||
openScenario(scenarioId);
|
||||
toast("success", "Szenario erstellt.");
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<CommandPalette
|
||||
open={showPalette}
|
||||
onClose={() => setShowPalette(false)}
|
||||
plans={plans}
|
||||
actions={paletteActions}
|
||||
onOpenScenario={openScenario}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Breiter Dialog fuer den Analyse-Bereich (Grafiken).
|
||||
// Auswahl beim Anlegen eines Plans: geführt (empfohlen), leer oder Beispielplan.
|
||||
function NewPlanChooser({
|
||||
creatingDemo,
|
||||
onGuided,
|
||||
onEmpty,
|
||||
onDemo,
|
||||
onClose,
|
||||
}: {
|
||||
creatingDemo: boolean;
|
||||
onGuided: () => void;
|
||||
onEmpty: () => void;
|
||||
onDemo: () => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const options = [
|
||||
{
|
||||
icon: <Sparkles className="h-5 w-5" />,
|
||||
title: "Geführt erstellen",
|
||||
badge: "Empfohlen",
|
||||
text: "Der Assistent fragt Schritt für Schritt – in fünf Minuten steht dein Grundgerüst.",
|
||||
run: onGuided,
|
||||
},
|
||||
{
|
||||
icon: <Plus className="h-5 w-5" />,
|
||||
title: "Leer starten",
|
||||
badge: null,
|
||||
text: "Nur das Grundprofil erfassen, alles Weitere selbst aufbauen – für geübte Nutzer.",
|
||||
run: onEmpty,
|
||||
},
|
||||
{
|
||||
icon: <PiggyBank className="h-5 w-5" />,
|
||||
title: creatingDemo ? "Beispielplan wird angelegt…" : "Beispielplan ansehen",
|
||||
badge: null,
|
||||
text: "Ein fertig ausgefüllter, fiktiver Plan zum Erkunden – so siehst du das Tool zuerst in Aktion.",
|
||||
run: onDemo,
|
||||
},
|
||||
];
|
||||
return (
|
||||
<Modal title="Neuer Plan" onClose={onClose}>
|
||||
<div className="flex flex-col gap-2">
|
||||
{options.map((o) => (
|
||||
<button
|
||||
key={o.title}
|
||||
type="button"
|
||||
disabled={creatingDemo}
|
||||
onClick={o.run}
|
||||
className="flex items-start gap-3 rounded-xl border border-border p-3.5 text-left transition-colors hover:border-accent hover:bg-accent-soft/30 disabled:opacity-60"
|
||||
>
|
||||
<span className="mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-accent-soft text-accent-soft-fg">
|
||||
{o.icon}
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="flex items-center gap-2 text-sm font-semibold text-fg">
|
||||
{o.title}
|
||||
{o.badge && (
|
||||
<span className="rounded bg-accent px-1.5 py-0.5 text-[9px] font-semibold uppercase text-accent-fg">{o.badge}</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="mt-0.5 block text-xs text-muted">{o.text}</span>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
// Breiter Dialog für den Analyse-Bereich (Grafiken).
|
||||
function ChartsDialog({
|
||||
title,
|
||||
children,
|
||||
@@ -450,10 +644,10 @@ function ChartsDialog({
|
||||
onClose: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-40 flex items-start justify-center overflow-y-auto bg-black/40 px-4 py-8" onClick={onClose}>
|
||||
<div className="ui-fade fixed inset-0 z-40 flex items-start justify-center overflow-y-auto bg-black/40 px-4 py-8" onClick={onClose}>
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="flex w-full max-w-5xl flex-col gap-4 rounded-2xl border border-border bg-surface p-6 shadow-xl"
|
||||
className="ui-pop flex w-full max-w-5xl flex-col gap-4 rounded-2xl border border-border bg-surface p-6 shadow-xl"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="flex items-center gap-2 text-base font-semibold text-fg">
|
||||
@@ -469,7 +663,9 @@ function ChartsDialog({
|
||||
);
|
||||
}
|
||||
|
||||
// Rekursiver Szenario-Baum: Kinder werden eingerueckt, damit Sub-Szenarien sichtbar sind.
|
||||
// Rekursiver Szenario-Baum: Kinder werden eingerückt, damit Sub-Szenarien sichtbar sind.
|
||||
// Die Aktions-Icons sind immer leicht sichtbar (nicht nur bei Hover) -- auf Touch-Geräten
|
||||
// gibt es kein Hover.
|
||||
function ScenarioTree({
|
||||
scenarios,
|
||||
parentId,
|
||||
@@ -494,7 +690,7 @@ function ScenarioTree({
|
||||
{level.map((s) => (
|
||||
<div key={s.id}>
|
||||
<div
|
||||
className={`group flex items-center gap-1.5 rounded-lg py-1.5 pr-1.5 text-sm ${
|
||||
className={`group flex items-center gap-1.5 rounded-lg py-1.5 pr-1.5 text-sm transition-colors ${
|
||||
selectedId === s.id ? "bg-accent-soft font-medium text-accent-soft-fg" : "text-muted hover:bg-surface-2"
|
||||
}`}
|
||||
style={{ paddingLeft: `${12 + depth * 14}px` }}
|
||||
@@ -512,16 +708,16 @@ function ScenarioTree({
|
||||
aria-label="Kopie erstellen"
|
||||
title="Neues Szenario aus diesem"
|
||||
onClick={() => onCopy(s)}
|
||||
className="rounded p-0.5 text-faint opacity-0 hover:bg-accent-soft hover:text-accent group-hover:opacity-100"
|
||||
className="rounded p-0.5 text-faint opacity-60 transition-opacity hover:bg-accent-soft hover:text-accent group-hover:opacity-100"
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
{!s.isBase && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Szenario loeschen"
|
||||
aria-label="Szenario löschen"
|
||||
onClick={() => onDelete(s)}
|
||||
className="rounded p-0.5 text-faint opacity-0 hover:bg-danger-soft hover:text-danger group-hover:opacity-100"
|
||||
className="rounded p-0.5 text-faint opacity-60 transition-opacity hover:bg-danger-soft hover:text-danger group-hover:opacity-100"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
@@ -542,18 +738,24 @@ function ScenarioTree({
|
||||
);
|
||||
}
|
||||
|
||||
// Startansicht: Begruessung + Plan-Kacheln (Klick oeffnet das Basisszenario).
|
||||
// Startansicht: Begrüssung + Plan-Kacheln (Klick öffnet das Basisszenario).
|
||||
function DashboardHome({
|
||||
username,
|
||||
plans,
|
||||
creatingDemo,
|
||||
onSelect,
|
||||
onCreate,
|
||||
onCreateGuided,
|
||||
onCreateChooser,
|
||||
onCreateDemo,
|
||||
onDelete,
|
||||
}: {
|
||||
username: string;
|
||||
plans: PlanListItem[];
|
||||
creatingDemo: boolean;
|
||||
onSelect: (scenarioId: string) => void;
|
||||
onCreate: () => void;
|
||||
onCreateGuided: () => void;
|
||||
onCreateChooser: () => void;
|
||||
onCreateDemo: () => void;
|
||||
onDelete: (id: string) => void;
|
||||
}) {
|
||||
return (
|
||||
@@ -561,56 +763,71 @@ function DashboardHome({
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-fg">Willkommen, {username}</h2>
|
||||
<p className="mt-1 text-sm text-muted">
|
||||
Waehlen Sie einen Plan oder erstellen Sie einen neuen. Jeder Plan enthaelt ein Basisszenario
|
||||
und beliebig viele Varianten davon.
|
||||
Wähle einen Plan oder erstelle einen neuen. Jeder Plan enthält ein Basisszenario und beliebig
|
||||
viele Varianten davon.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{plans.map((p) => {
|
||||
const base = p.scenarios.find((s) => s.isBase) ?? p.scenarios[0];
|
||||
const others = p.scenarios.length - 1;
|
||||
return (
|
||||
<div
|
||||
key={p.id}
|
||||
className="group relative flex cursor-pointer flex-col gap-2 rounded-xl border border-border bg-surface p-4 shadow-sm transition-shadow hover:shadow-md"
|
||||
onClick={() => base && onSelect(base.id)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-accent-soft">
|
||||
<FolderKanban className="h-5 w-5 text-accent-soft-fg" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate font-medium text-fg">{p.name}</div>
|
||||
<div className="text-xs text-muted">
|
||||
Basisszenario{others > 0 ? ` + ${others} Variante${others === 1 ? "" : "n"}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Plan loeschen"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete(p.id);
|
||||
}}
|
||||
className="rounded-md p-1.5 text-faint opacity-0 transition-opacity hover:bg-danger-soft hover:text-danger group-hover:opacity-100"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCreate}
|
||||
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 hover:bg-accent-soft"
|
||||
{plans.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<Sparkles className="h-6 w-6" />}
|
||||
title="Deine erste Finanzplanung"
|
||||
text="Am schnellsten geht es geführt: Der Assistent fragt dich Schritt für Schritt und baut dein Grundgerüst – in rund fünf Minuten. Oder schau dir zuerst den Beispielplan an."
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Neuer Plan
|
||||
</button>
|
||||
</div>
|
||||
<Button onClick={onCreateGuided}>
|
||||
<Sparkles className="h-4 w-4" /> Geführt starten
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={onCreateDemo} disabled={creatingDemo}>
|
||||
<PiggyBank className="h-4 w-4" /> {creatingDemo ? "Wird angelegt…" : "Beispielplan ansehen"}
|
||||
</Button>
|
||||
</EmptyState>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{plans.map((p) => {
|
||||
const base = p.scenarios.find((s) => s.isBase) ?? p.scenarios[0];
|
||||
const others = p.scenarios.length - 1;
|
||||
return (
|
||||
<div
|
||||
key={p.id}
|
||||
className="group relative flex cursor-pointer flex-col gap-2 rounded-xl border border-border bg-surface p-4 shadow-sm transition-shadow hover:shadow-md"
|
||||
onClick={() => base && onSelect(base.id)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-accent-soft">
|
||||
<FolderKanban className="h-5 w-5 text-accent-soft-fg" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate font-medium text-fg">{p.name}</div>
|
||||
<div className="text-xs text-muted">
|
||||
Basisszenario{others > 0 ? ` + ${others} Variante${others === 1 ? "" : "n"}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Plan löschen"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete(p.id);
|
||||
}}
|
||||
className="rounded-md p-1.5 text-faint opacity-0 transition-opacity hover:bg-danger-soft hover:text-danger group-hover:opacity-100"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCreateChooser}
|
||||
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" />
|
||||
Neuer Plan
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -627,49 +844,37 @@ function PlanDialog({
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-40 flex items-start justify-center overflow-y-auto bg-black/40 px-4 py-8" onClick={onClose}>
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="flex w-full max-w-md flex-col gap-4 rounded-2xl border border-border bg-surface p-6 shadow-xl"
|
||||
>
|
||||
<h2 className="text-base font-semibold text-fg">Neuen Plan erstellen</h2>
|
||||
<p className="text-xs text-muted">
|
||||
Es wird automatisch ein <strong className="text-fg">Basisszenario</strong> angelegt. Weitere
|
||||
Szenarien entstehen spaeter als Kopien davon.
|
||||
</p>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-muted">Name des Plans</label>
|
||||
<input
|
||||
autoFocus
|
||||
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-fg focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/25"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Name des Plans"
|
||||
/>
|
||||
</div>
|
||||
<PlanProfileFields draft={draft} onChange={setDraft} />
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={saving}
|
||||
onClick={() => {
|
||||
setSaving(true);
|
||||
onCreate(name.trim() || "Plan", draft);
|
||||
}}
|
||||
className="rounded-lg bg-accent px-4 py-2 text-sm font-medium text-accent-fg shadow-sm hover:bg-accent-hover disabled:opacity-50"
|
||||
>
|
||||
{saving ? "..." : "Erstellen"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-lg border border-border px-4 py-2 text-sm font-medium text-muted hover:bg-surface-2"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
<Modal title="Neuen Plan erstellen" onClose={onClose}>
|
||||
<p className="text-xs text-muted">
|
||||
Es wird automatisch ein <strong className="text-fg">Basisszenario</strong> angelegt. Weitere
|
||||
Szenarien entstehen später als Kopien davon.
|
||||
</p>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-muted">Name des Plans</label>
|
||||
<input
|
||||
autoFocus
|
||||
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-fg focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/25"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Name des Plans"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<PlanProfileFields draft={draft} onChange={setDraft} />
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
disabled={saving}
|
||||
onClick={() => {
|
||||
setSaving(true);
|
||||
onCreate(name.trim() || "Plan", draft);
|
||||
}}
|
||||
>
|
||||
{saving ? "…" : "Erstellen"}
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={onClose}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -685,44 +890,32 @@ function CopyScenarioDialog({
|
||||
const [name, setName] = useState(`${source.name} – Variante`);
|
||||
const [saving, setSaving] = useState(false);
|
||||
return (
|
||||
<div className="fixed inset-0 z-40 flex items-center justify-center bg-black/40 px-4" onClick={onClose}>
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="flex w-full max-w-sm flex-col gap-3 rounded-2xl border border-border bg-surface p-6 shadow-xl"
|
||||
>
|
||||
<h2 className="text-base font-semibold text-fg">Neues Szenario</h2>
|
||||
<p className="text-xs text-muted">
|
||||
Vollstaendige Kopie von <strong className="text-fg">{source.name}</strong>. Aenderungen darin
|
||||
werden anschliessend farblich hervorgehoben.
|
||||
</p>
|
||||
<input
|
||||
autoFocus
|
||||
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-fg focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/25"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Name des Szenarios"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={saving}
|
||||
onClick={() => {
|
||||
setSaving(true);
|
||||
onCreate(name.trim() || "Szenario");
|
||||
}}
|
||||
className="rounded-lg bg-accent px-4 py-2 text-sm font-medium text-accent-fg shadow-sm hover:bg-accent-hover disabled:opacity-50"
|
||||
>
|
||||
{saving ? "..." : "Erstellen"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-lg border border-border px-4 py-2 text-sm font-medium text-muted hover:bg-surface-2"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
<Modal title="Neues Szenario" onClose={onClose}>
|
||||
<p className="text-xs text-muted">
|
||||
Vollständige Kopie von <strong className="text-fg">{source.name}</strong>. Änderungen darin
|
||||
werden anschliessend farblich hervorgehoben.
|
||||
</p>
|
||||
<input
|
||||
autoFocus
|
||||
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-fg focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/25"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Name des Szenarios"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
disabled={saving}
|
||||
onClick={() => {
|
||||
setSaving(true);
|
||||
onCreate(name.trim() || "Szenario");
|
||||
}}
|
||||
>
|
||||
{saving ? "…" : "Erstellen"}
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={onClose}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
"use client";
|
||||
|
||||
// Befehls-Palette (Cmd/Ctrl+K): schnelles Springen zwischen Plänen und Szenarien plus
|
||||
// die wichtigsten Aktionen -- der Effizienz-Pfad für geübte Nutzer, ohne der Maus-
|
||||
// Bedienung etwas wegzunehmen.
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { CornerDownLeft, Search } from "lucide-react";
|
||||
import type { PlanListItem } from "@/lib/types";
|
||||
|
||||
export interface PaletteAction {
|
||||
id: string;
|
||||
label: string;
|
||||
hint?: string;
|
||||
run: () => void;
|
||||
}
|
||||
|
||||
export function CommandPalette({
|
||||
open,
|
||||
onClose,
|
||||
plans,
|
||||
actions,
|
||||
onOpenScenario,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
plans: PlanListItem[];
|
||||
actions: PaletteAction[];
|
||||
onOpenScenario: (id: string) => void;
|
||||
}) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [index, setIndex] = useState(0);
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const items = useMemo<PaletteAction[]>(() => {
|
||||
const scenarioItems: PaletteAction[] = plans.flatMap((p) =>
|
||||
p.scenarios.map((s) => ({
|
||||
id: `s-${s.id}`,
|
||||
label: `${p.name} · ${s.name}`,
|
||||
hint: "Szenario öffnen",
|
||||
run: () => onOpenScenario(s.id),
|
||||
}))
|
||||
);
|
||||
const all = [...actions, ...scenarioItems];
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return all;
|
||||
return all.filter((i) => i.label.toLowerCase().includes(q));
|
||||
}, [plans, actions, query, onOpenScenario]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- Reset beim Öffnen
|
||||
setQuery("");
|
||||
setIndex(0);
|
||||
requestAnimationFrame(() => inputRef.current?.focus());
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- Auswahl folgt der Eingabe
|
||||
setIndex(0);
|
||||
}, [query]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
function choose(item: PaletteAction | undefined) {
|
||||
if (!item) return;
|
||||
onClose();
|
||||
item.run();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ui-fade fixed inset-0 z-50 flex items-start justify-center bg-black/40 px-4 pt-[15vh]" onClick={onClose}>
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="ui-pop w-full max-w-lg overflow-hidden rounded-2xl border border-border bg-surface shadow-2xl"
|
||||
>
|
||||
<div className="flex items-center gap-2 border-b border-border px-4 py-3">
|
||||
<Search className="h-4 w-4 shrink-0 text-faint" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setIndex((i) => Math.min(i + 1, items.length - 1));
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setIndex((i) => Math.max(i - 1, 0));
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
choose(items[index]);
|
||||
} else if (e.key === "Escape") {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
placeholder="Plan, Szenario oder Aktion suchen…"
|
||||
className="w-full bg-transparent text-sm text-fg outline-none placeholder:text-faint"
|
||||
/>
|
||||
<kbd className="rounded border border-border bg-surface-2 px-1.5 py-0.5 text-[10px] text-faint">ESC</kbd>
|
||||
</div>
|
||||
<div className="max-h-80 overflow-y-auto py-1">
|
||||
{items.length === 0 && <p className="px-4 py-3 text-sm text-muted">Nichts gefunden.</p>}
|
||||
{items.slice(0, 40).map((item, i) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onMouseEnter={() => setIndex(i)}
|
||||
onClick={() => choose(item)}
|
||||
className={`flex w-full items-center justify-between gap-3 px-4 py-2 text-left text-sm ${
|
||||
i === index ? "bg-accent-soft text-accent-soft-fg" : "text-fg"
|
||||
}`}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate">{item.label}</span>
|
||||
<span className="flex items-center gap-1.5 text-[10px] text-faint">
|
||||
{item.hint}
|
||||
{i === index && <CornerDownLeft className="h-3 w-3" />}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import { formatChf } from "@/lib/format";
|
||||
import type { PlanComputed } from "@/lib/calculations";
|
||||
import type { PlanInput } from "@/lib/types";
|
||||
|
||||
const PALETTE = ["#4f46e5", "#0ea5e9", "#16a34a", "#d97706", "#dc2626", "#7c3aed"];
|
||||
const PALETTE = ["#4f46e5", "#0ea5e9", "#16a34a", "#d97706", "#dc2626", "#7c3äd"];
|
||||
|
||||
interface PlanListItem {
|
||||
id: string;
|
||||
@@ -24,7 +24,7 @@ export function Dashboard({
|
||||
}: {
|
||||
plan: PlanInput;
|
||||
computed: PlanComputed;
|
||||
// Die uebrigen Szenarien desselben Plans -- nur die sind sinnvoll vergleichbar.
|
||||
// Die übrigen Szenarien desselben Plans -- nur die sind sinnvoll vergleichbar.
|
||||
siblings: PlanListItem[];
|
||||
}) {
|
||||
const [compareIds, setCompareIds] = useState<string[]>([]);
|
||||
@@ -94,8 +94,8 @@ export function Dashboard({
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<StatCard label="Endvermoegen (nominal)" value={lastPhase ? lastPhase.endWealthNominal : 0} />
|
||||
<StatCard label="Endvermoegen (real, kaufkraftbereinigt)" value={lastPhase ? lastPhase.endWealthReal : 0} />
|
||||
<StatCard label="Endvermögen (nominal)" value={lastPhase ? lastPhase.endWealthNominal : 0} />
|
||||
<StatCard label="Endvermögen (real, kaufkraftbereinigt)" value={lastPhase ? lastPhase.endWealthReal : 0} />
|
||||
</div>
|
||||
|
||||
<section className="rounded-xl border border-border bg-surface p-4 shadow-sm">
|
||||
@@ -104,7 +104,7 @@ export function Dashboard({
|
||||
Einkommen vs. Ausgaben pro Jahr
|
||||
</h3>
|
||||
<p className="mb-3 text-xs text-muted">
|
||||
Die Flaeche zwischen Einkommen und nominalen Ausgaben ist die Spar- (gruen) bzw. Verzehrquote (rot).
|
||||
Die Fläche zwischen Einkommen und nominalen Ausgaben ist die Spar- (grün) bzw. Verzehrquote (rot).
|
||||
Die blasse Linie sind die realen Ausgaben – der Abstand zur nominalen Linie ist der Inflationsanteil.
|
||||
</p>
|
||||
<SparquoteChart computed={computed} />
|
||||
@@ -114,7 +114,7 @@ export function Dashboard({
|
||||
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||
<h3 className="flex items-center gap-1.5 text-sm font-semibold text-fg">
|
||||
<LineChartIcon className="h-4 w-4 text-accent" />
|
||||
Vermoegensverlauf nach Alter
|
||||
Vermögensverlauf nach Alter
|
||||
</h3>
|
||||
<a
|
||||
href={`/api/scenarios/${plan.id}/export`}
|
||||
@@ -145,11 +145,11 @@ export function Dashboard({
|
||||
<section className="rounded-xl border border-border bg-surface p-4 shadow-sm">
|
||||
<h3 className="mb-1 flex items-center gap-1.5 text-sm font-semibold text-fg">
|
||||
<BarChart3 className="h-4 w-4 text-accent" />
|
||||
Vermoegensaufteilung pro Phase (Beginn & Ende)
|
||||
Vermögensaufteilung pro Phase (Beginn & Ende)
|
||||
</h3>
|
||||
<p className="mb-3 text-xs text-muted">
|
||||
Je Phase links die Aufteilung zu Beginn, rechts am Ende. Das Ende einer Phase entspricht im
|
||||
Gesamtvolumen dem Beginn der naechsten – die Aufteilung kann durch Umschichtung abweichen.
|
||||
Gesamtvolumen dem Beginn der nächsten – die Aufteilung kann durch Umschichtung abweichen.
|
||||
</p>
|
||||
<div className="h-72 w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
|
||||
@@ -30,18 +30,18 @@ import type {
|
||||
// --- Wasserfall ---------------------------------------------------------------------------
|
||||
// Bewusst NICHT mit Recharts, sondern als eigene HTML/CSS-Darstellung. Ein Wasserfall lebt
|
||||
// von drei Dingen, die Recharts hier nicht hergibt: Verbindungslinien zwischen den Balken
|
||||
// (ohne sie sieht man nicht, dass jeder Balken dort ansetzt, wo der vorherige aufhoert),
|
||||
// Wertbeschriftung an jedem Balken, und eine klare optische Trennung von Zwischenstaenden
|
||||
// und Veraenderungen.
|
||||
// (ohne sie sieht man nicht, dass jeder Balken dort ansetzt, wo der vorherige aufhört),
|
||||
// Wertbeschriftung an jedem Balken, und eine klare optische Trennung von Zwischenständen
|
||||
// und Veränderungen.
|
||||
//
|
||||
// Liegend statt stehend: Die Beschriftungen sind lang ("Wertsteigerung Immobilie"), stehend
|
||||
// muessten sie gedreht werden. Liegend ist es ausserdem konsistent zum Tornado.
|
||||
// müssten sie gedreht werden. Liegend ist es ausserdem konsistent zum Tornado.
|
||||
|
||||
interface WaterfallItem {
|
||||
label: string;
|
||||
value: number;
|
||||
total?: boolean; // Zwischen-/Endsumme: absoluter Stand statt Veraenderung
|
||||
section?: string; // optionale Abschnitts-Ueberschrift VOR diesem Eintrag
|
||||
total?: boolean; // Zwischen-/Endsumme: absoluter Stand statt Veränderung
|
||||
section?: string; // optionale Abschnitts-Überschrift VOR diesem Eintrag
|
||||
}
|
||||
|
||||
interface WaterfallRow {
|
||||
@@ -130,7 +130,7 @@ function Waterfall({ items }: { items: WaterfallItem[] }) {
|
||||
}}
|
||||
title={`${r.label}: ${formatChf(r.value)}`}
|
||||
/>
|
||||
{/* Verbindungslinie zum naechsten Balken: auf dem Stand NACH diesem Schritt */}
|
||||
{/* Verbindungslinie zum nächsten Balken: auf dem Stand NACH diesem Schritt */}
|
||||
{!isLast && (
|
||||
<div
|
||||
className="absolute border-l border-dotted border-faint"
|
||||
@@ -155,8 +155,8 @@ function Waterfall({ items }: { items: WaterfallItem[] }) {
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Zahlen mit laufendem Zwischenstand -- bei stark unterschiedlichen Groessenordnungen
|
||||
ist die Tabelle der Grafik ueberlegen. */}
|
||||
{/* Zahlen mit laufendem Zwischenstand -- bei stark unterschiedlichen Grössenordnungen
|
||||
ist die Tabelle der Grafik überlegen. */}
|
||||
<details className="mt-2">
|
||||
<summary className="cursor-pointer text-[11px] text-muted hover:text-fg">Zahlen mit Zwischenstand anzeigen</summary>
|
||||
<div className="mt-2 overflow-x-auto rounded-lg border border-border">
|
||||
@@ -190,9 +190,9 @@ function Waterfall({ items }: { items: WaterfallItem[] }) {
|
||||
);
|
||||
}
|
||||
|
||||
// Kontrollgroesse: Ist die Zerlegung vollstaendig, muss die Differenz zwischen Endwert und
|
||||
// Kontrollgrösse: Ist die Zerlegung vollständig, muss die Differenz zwischen Endwert und
|
||||
// der Summe der Schritte 0 sein. Sichtbar machen statt verstecken -- ein Wasserfall, der
|
||||
// nicht aufgeht, ist ein Fehler und kein Schoenheitsproblem.
|
||||
// nicht aufgeht, ist ein Fehler und kein Schönheitsproblem.
|
||||
function ResidualNote({ residual }: { residual: number }) {
|
||||
if (Math.abs(residual) <= 2) return null;
|
||||
return (
|
||||
@@ -367,7 +367,7 @@ export function ElementDetailDialog({
|
||||
onClose: () => void;
|
||||
onOpenSpec?: (anchor: string) => void;
|
||||
}) {
|
||||
// Verlauf ueber ALLE Phasen zusammensetzen.
|
||||
// Verlauf über ALLE Phasen zusammensetzen.
|
||||
const points = useMemo(() => {
|
||||
const out: ElementYearPoint[] = [];
|
||||
for (const ph of computed.phases) {
|
||||
@@ -479,7 +479,7 @@ export function ElementDetailDialog({
|
||||
// --- Phasen-Detailansicht ------------------------------------------------------------------
|
||||
|
||||
const ASSET_CATS: ElementCategory[] = ["PENSION_FUND", "PILLAR_3A", "REAL_ESTATE", "OTHER_ASSET"];
|
||||
const ALLOC_PALETTE = ["#4f46e5", "#0ea5e9", "#16a34a", "#d97706", "#dc2626", "#7c3aed"];
|
||||
const ALLOC_PALETTE = ["#4f46e5", "#0ea5e9", "#16a34a", "#d97706", "#dc2626", "#7c3äd"];
|
||||
|
||||
export function PhaseDetailDialog({
|
||||
phase,
|
||||
@@ -504,7 +504,7 @@ export function PhaseDetailDialog({
|
||||
[phase]
|
||||
);
|
||||
|
||||
const uebersicht = (
|
||||
const übersicht = (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<Kpi label="Typ" text={phase.type === "ERWERB" ? "Erwerb" : phase.type === "PENSION" ? "Pension" : "Misch"} />
|
||||
@@ -578,7 +578,7 @@ export function PhaseDetailDialog({
|
||||
subtitle={`Lebensphase ${phase.sequenceNumber} · ${phase.durationYears} Jahre`}
|
||||
onClose={onClose}
|
||||
tabs={[
|
||||
{ key: "uebersicht", label: "Übersicht", content: uebersicht },
|
||||
{ key: "übersicht", label: "Übersicht", content: übersicht },
|
||||
{ key: "wasserfall", label: "Wasserfall", content: wasserfall },
|
||||
{ key: "rechenweg", label: "Rechenweg", content: rechenweg },
|
||||
]}
|
||||
|
||||
@@ -29,18 +29,18 @@ export interface CellContext {
|
||||
isConsumption: boolean;
|
||||
durationYears: number;
|
||||
isRetirementTransition: boolean;
|
||||
carriedEndValue: number; // Endwert des Elements in der (Vor-)Phase, fuer Bezugs-Maxima
|
||||
carriedEndValue: number; // Endwert des Elements in der (Vor-)Phase, für Bezugs-Maxima
|
||||
carried: boolean; // Phase >= 2: Basiswert wird aus der Vorphase fortgeschrieben
|
||||
derivedStart: number; // fortgeschriebener Basiswert (read-only Anzeige)
|
||||
derivedMortgage: number; // nur Immobilie: fortgeschriebene Resthypothek zu Phasenbeginn
|
||||
mortgageEnd: number; // nur Immobilie: Resthypothek am Phasenende (fuer die Sonderamortisation)
|
||||
mortgageEnd: number; // nur Immobilie: Resthypothek am Phasenende (für die Sonderamortisation)
|
||||
// nur Immobilie: modellierter VERKEHRSWERT am Phasenende (Eigenkapital + Resthypothek),
|
||||
// inkl. aufgelaufener Wertsteigerung. Vorbelegung und Vergleichswert fuer den Verkaufspreis.
|
||||
// inkl. aufgelaufener Wertsteigerung. Vorbelegung und Vergleichswert für den Verkaufspreis.
|
||||
propertyValueEnd: number;
|
||||
deflatorStart: number; // Kaufkraft-Deflator zu Phasenbeginn (real <-> nominal, erstes Jahr)
|
||||
// Warnhinweis: Anzahl Phasen NACH dieser (Aenderungen schreiben sich dorthin fort).
|
||||
// Warnhinweis: Anzahl Phasen NACH dieser (Änderungen schreiben sich dorthin fort).
|
||||
laterPhaseCount: number;
|
||||
// AHV-Beitragskarriere des Element-Besitzers (fuer die Pruefung am Pensions-Uebergang).
|
||||
// AHV-Beitragskarriere des Element-Besitzers (für die Prüfung am Pensions-Übergang).
|
||||
ahvCareer: AhvCareer | null;
|
||||
}
|
||||
|
||||
@@ -54,8 +54,8 @@ interface Props {
|
||||
}
|
||||
|
||||
// Warnhinweis beim Bearbeiten einer Phase, der noch Phasen folgen. Seit V3 werden Werte live
|
||||
// fortgeschrieben (Endwert = Startwert der Folgephase) -- eine Aenderung hier wirkt also bis
|
||||
// ans Planende durch, was ohne Hinweis leicht uebersehen wird.
|
||||
// fortgeschrieben (Endwert = Startwert der Folgephase) -- eine Änderung hier wirkt also bis
|
||||
// ans Planende durch, was ohne Hinweis leicht übersehen wird.
|
||||
export function CarryWarning({ laterPhaseCount }: { laterPhaseCount: number }) {
|
||||
if (laterPhaseCount <= 0) return null;
|
||||
const phasen = laterPhaseCount === 1 ? "die nachfolgende Lebensphase" : `die ${laterPhaseCount} nachfolgenden Lebensphasen`;
|
||||
@@ -64,8 +64,8 @@ export function CarryWarning({ laterPhaseCount }: { laterPhaseCount: number }) {
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<span>
|
||||
<strong>Achtung: Diese Lebensphase ist nicht die letzte.</strong> Werte werden fortgeschrieben
|
||||
(Endwert = Startwert der Folgephase). Eine Aenderung hier wirkt sich auf {phasen} aus und
|
||||
veraendert deren Startwerte, Kennzahlen sowie Endvermoegen, Cash-Verlauf und Ruinalter.
|
||||
(Endwert = Startwert der Folgephase). Eine Änderung hier wirkt sich auf {phasen} aus und
|
||||
verändert deren Startwerte, Kennzahlen sowie Endvermögen, Cash-Verlauf und Ruinalter.
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
@@ -83,7 +83,7 @@ function DerivedField({ label, value, help }: { label: string; value: number; he
|
||||
);
|
||||
}
|
||||
|
||||
// PK/3a-Bezugs-Entscheid im normalen Uebergang (Vorbezug): Kein Bezug / Bezug (+ Betrag +
|
||||
// PK/3a-Bezugs-Entscheid im normalen Übergang (Vorbezug): Kein Bezug / Bezug (+ Betrag +
|
||||
// Kapitalbezugssteuer). Der Bezugsbetrag wird brutto dem Kapital entnommen; ins Cash fliesst
|
||||
// der Betrag nach Abzug der Steuer.
|
||||
function WithdrawalDecision({
|
||||
@@ -130,7 +130,7 @@ function WithdrawalDecision({
|
||||
<DerivedField
|
||||
label="Auszahlung netto (ins Cash)"
|
||||
value={Math.round(gross * (1 - taxRate / 100))}
|
||||
help="Bruttobezug abzueglich Kapitalbezugssteuer. Wird automatisch berechnet."
|
||||
help="Bruttobezug abzüglich Kapitalbezugssteuer. Wird automatisch berechnet."
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
@@ -139,9 +139,9 @@ function WithdrawalDecision({
|
||||
}
|
||||
|
||||
// Vorbelegung expliziter Entscheide, damit ein blosses "Speichern" den sichtbaren Default
|
||||
// (Halten / Kein Bezug / Rente) auch tatsaechlich persistiert.
|
||||
// AHV-Pruefung am Pensions-Uebergang: geplantes Durchschnittseinkommen aus dem Plan plus --
|
||||
// nur falls der Plan nicht bis zum Beitragsbeginn (Alter 21) zurueckreicht -- die Jahre davor.
|
||||
// (Halten / Kein Bezug / Rente) auch tatsächlich persistiert.
|
||||
// AHV-Prüfung am Pensions-Übergang: geplantes Durchschnittseinkommen aus dem Plan plus --
|
||||
// nur falls der Plan nicht bis zum Beitragsbeginn (Alter 21) zurückreicht -- die Jahre davor.
|
||||
export function AhvReviewFields({
|
||||
career,
|
||||
td,
|
||||
@@ -162,39 +162,39 @@ export function AhvReviewFields({
|
||||
return (
|
||||
<>
|
||||
<p className="col-span-full rounded-lg bg-surface-2 px-3 py-2 text-xs text-muted">
|
||||
Die AHV-Rente haengt vom <strong className="text-fg">massgebenden durchschnittlichen Jahreseinkommen</strong>{" "}
|
||||
ueber die ganze Beitragsdauer (ab Alter 21) ab. Massgebend ist der <strong className="text-fg">Bruttolohn</strong>;
|
||||
das Tool rechnet die im Plan erfassten Nettoeinkommen dafuer automatisch hoch. Alle Betraege sind
|
||||
Die AHV-Rente hängt vom <strong className="text-fg">massgebenden durchschnittlichen Jahreseinkommen</strong>{" "}
|
||||
über die ganze Beitragsdauer (ab Alter 21) ab. Massgebend ist der <strong className="text-fg">Bruttolohn</strong>;
|
||||
das Tool rechnet die im Plan erfassten Nettöinkommen dafür automatisch hoch. Alle Beträge sind
|
||||
REAL (heutige Kaufkraft) – die AHV wertet vergangene Einkommen auf und indexiert die Schwellen,
|
||||
was sich real weitgehend aufhebt.
|
||||
</p>
|
||||
|
||||
<DerivedField
|
||||
label="Geplantes Bruttoeinkommen (aus dem Plan)"
|
||||
label="Geplantes Bruttöinkommen (aus dem Plan)"
|
||||
value={Math.round(career.plannedAvgGrossIncome)}
|
||||
help={`Reales Mittel ueber ${career.planYears} Beitragsjahre der Erwerbsphasen dieses Plans, hochgerechnet vom Netto- auf den Bruttolohn. Nur Einkommen, die dieser Person zugeordnet sind.`}
|
||||
help={`Reales Mittel über ${career.planYears} Beitragsjahre der Erwerbsphasen dieses Plans, hochgerechnet vom Netto- auf den Bruttolohn. Nur Einkommen, die dieser Person zugeordnet sind.`}
|
||||
/>
|
||||
<DerivedField
|
||||
label="Beitragsjahre im Plan"
|
||||
value={career.planYears}
|
||||
help="Dauer der Erwerbsphasen abzueglich der dort erfassten Ausfalljahre."
|
||||
help="Dauer der Erwerbsphasen abzüglich der dort erfassten Ausfalljahre."
|
||||
/>
|
||||
|
||||
{brauchtVorgeschichte && (
|
||||
<>
|
||||
<p className="col-span-full rounded-lg border border-dashed border-border px-3 py-2 text-xs text-muted">
|
||||
Der Plan beginnt erst im Alter {career.yearsBeforePlan + 21}. Die {career.yearsBeforePlan} Beitragsjahre
|
||||
davor kennt das Tool nicht – bitte ergaenzen.
|
||||
davor kennt das Tool nicht – bitte ergänzen.
|
||||
</p>
|
||||
<MoneyField
|
||||
label="Durchschnittliches Bruttoeinkommen vor Planbeginn (real)"
|
||||
help="BRUTTO und in HEUTIGER Kaufkraft. Am besten aus der AHV-Rentenvorausberechnung uebernehmen – dort ist die Aufwertung bereits enthalten. Die Rohbetraege im Kontoauszug sind historisch-nominal und daher zu tief."
|
||||
label="Durchschnittliches Bruttöinkommen vor Planbeginn (real)"
|
||||
help="BRUTTO und in HEUTIGER Kaufkraft. Am besten aus der AHV-Rentenvorausberechnung übernehmen – dort ist die Aufwertung bereits enthalten. Die Rohbeträge im Kontoauszug sind historisch-nominal und daher zu tief."
|
||||
value={avgBefore}
|
||||
onChange={(v) => setT({ avgIncomeBefore: v })}
|
||||
/>
|
||||
<NumberField
|
||||
label="Ausfalljahre vor Planbeginn"
|
||||
help="Jahre ohne AHV-Beitraege zwischen Alter 21 und Planbeginn (z. B. Ausland, Studium). Jedes kuerzt die Rente um 1/44."
|
||||
help="Jahre ohne AHV-Beiträge zwischen Alter 21 und Planbeginn (z. B. Ausland, Studium). Jedes kürzt die Rente um 1/44."
|
||||
value={gapBefore}
|
||||
min={0}
|
||||
max={career.yearsBeforePlan}
|
||||
@@ -206,12 +206,12 @@ export function AhvReviewFields({
|
||||
<DerivedField
|
||||
label="Massgebendes durchschnittliches Jahreseinkommen"
|
||||
value={Math.round(mdJE)}
|
||||
help="Gewichtetes Mittel ueber alle Beitragsjahre (vor Planbeginn und im Plan). Ab 90'720 gibt es die Maximalrente."
|
||||
help="Gewichtetes Mittel über alle Beitragsjahre (vor Planbeginn und im Plan). Ab 90'720 gibt es die Maximalrente."
|
||||
/>
|
||||
<DerivedField
|
||||
label="Resultierende AHV-Rente pro Jahr"
|
||||
value={rente}
|
||||
help="Vollrente zum mdJE (Skala 44), inkl. 13. Altersrente, gekuerzt um die Ausfalljahre. Bei Ehepaaren zusaetzlich plafoniert."
|
||||
help="Vollrente zum mdJE (Skala 44), inkl. 13. Altersrente, gekürzt um die Ausfalljahre. Bei Ehepaaren zusätzlich plafoniert."
|
||||
/>
|
||||
</>
|
||||
);
|
||||
@@ -220,7 +220,7 @@ export function AhvReviewFields({
|
||||
export function withTransitionDefaults(category: ElementCategory, isRetirement: boolean, td: TransitionData): TransitionData {
|
||||
const out = { ...td };
|
||||
if (category === "AHV") {
|
||||
// Ein Speichern der Pruefung markiert sie als erledigt (gleiches Muster wie bei PK/3a).
|
||||
// Ein Speichern der Prüfung markiert sie als erledigt (gleiches Muster wie bei PK/3a).
|
||||
if (isRetirement && out.reviewed === undefined) out.reviewed = true;
|
||||
} else if (category === "REAL_ESTATE" || category === "OTHER_ASSET") {
|
||||
if (out.decision === undefined) out.decision = "HOLD";
|
||||
@@ -236,7 +236,7 @@ export function withTransitionDefaults(category: ElementCategory, isRetirement:
|
||||
return out;
|
||||
}
|
||||
|
||||
// --- Cash-Uebergang: einmalige Sonderein-/ausgaben ---
|
||||
// --- Cash-Übergang: einmalige Sonderein-/ausgaben ---
|
||||
|
||||
export function withCashTransitionDefaults(ct: CashTransitionData): CashTransitionData {
|
||||
return ct.mode === undefined ? { ...ct, mode: "NONE" } : { ...ct };
|
||||
@@ -246,7 +246,7 @@ export function isCashTransitionAnswered(ct: CashTransitionData): boolean {
|
||||
return ct.mode !== undefined;
|
||||
}
|
||||
|
||||
// Kurzfassung fuer die Uebergangszelle der Cash-Zeile.
|
||||
// Kurzfassung für die Übergangszelle der Cash-Zeile.
|
||||
export function cashTransitionSummary(ct: CashTransitionData): string {
|
||||
const mode = ct.mode;
|
||||
if (mode === undefined) return "?";
|
||||
@@ -264,7 +264,7 @@ export function cashTransitionSummary(ct: CashTransitionData): string {
|
||||
}
|
||||
}
|
||||
|
||||
// Eingabefelder fuer den Cash-Entscheid. Erfassungs-Konventionen bewusst wie bei den
|
||||
// Eingabefelder für den Cash-Entscheid. Erfassungs-Konventionen bewusst wie bei den
|
||||
// laufenden Flows: Zufluss nominal (wie Einkommen), Kosten real (wie Ausgaben).
|
||||
export function CashTransitionFields({
|
||||
ct,
|
||||
@@ -287,8 +287,8 @@ export function CashTransitionFields({
|
||||
<>
|
||||
<div className="sm:col-span-2">
|
||||
<SelectField
|
||||
label="Entscheid fuer das Cash-Konto"
|
||||
help="Was passiert beim Uebergang in die naechste Lebensphase mit dem Cash-Bestand?"
|
||||
label="Entscheid für das Cash-Konto"
|
||||
help="Was passiert beim Übergang in die nächste Lebensphase mit dem Cash-Bestand?"
|
||||
value={mode}
|
||||
onChange={(v: CashTransitionMode) =>
|
||||
setC(
|
||||
@@ -302,7 +302,7 @@ export function CashTransitionFields({
|
||||
)
|
||||
}
|
||||
options={[
|
||||
{ value: "NONE", label: "1:1 uebernehmen" },
|
||||
{ value: "NONE", label: "1:1 übernehmen" },
|
||||
{ value: "INFLOW", label: "Einmaliger Zufluss" },
|
||||
{ value: "OUTFLOW", label: "Einmalige Kosten" },
|
||||
{ value: "BOTH", label: "Zufluss und Kosten" },
|
||||
@@ -314,7 +314,7 @@ export function CashTransitionFields({
|
||||
<>
|
||||
<div className="sm:col-span-2 rounded-lg bg-surface-2 px-3 py-2 text-xs text-muted">
|
||||
<strong className="text-fg">Einmaliger Zufluss</strong> (z. B. Erbschaft). Wird NOMINAL erfasst –
|
||||
der Betrag, der zu diesem Zeitpunkt tatsaechlich aufs Konto kommt.
|
||||
der Betrag, der zu diesem Zeitpunkt tatsächlich aufs Konto kommt.
|
||||
</div>
|
||||
<TextField
|
||||
label="Bezeichnung"
|
||||
@@ -337,13 +337,13 @@ export function CashTransitionFields({
|
||||
<DerivedField
|
||||
label="≈ real (heutige Kaufkraft)"
|
||||
value={Math.round(inflowNet / d)}
|
||||
help="Netto-Zufluss nach Steuer, zurueckgerechnet auf die Kaufkraft bei Planbeginn. Nur zur Info."
|
||||
help="Netto-Zufluss nach Steuer, zurückgerechnet auf die Kaufkraft bei Planbeginn. Nur zur Info."
|
||||
/>
|
||||
{taxRate > 0 && (
|
||||
<DerivedField
|
||||
label="Netto aufs Cash (nominal)"
|
||||
value={inflowNet}
|
||||
help="Betrag abzueglich Steuer. Wird automatisch berechnet."
|
||||
help="Betrag abzüglich Steuer. Wird automatisch berechnet."
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
@@ -369,7 +369,7 @@ export function CashTransitionFields({
|
||||
<DerivedField
|
||||
label="≈ nominal (zum Zeitpunkt)"
|
||||
value={Math.round(num(ct.outflowAmount) * d)}
|
||||
help="Der Betrag, der zu diesem Zeitpunkt tatsaechlich faellig ist. Nur zur Info."
|
||||
help="Der Betrag, der zu diesem Zeitpunkt tatsächlich fällig ist. Nur zur Info."
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
@@ -381,7 +381,7 @@ export function CashTransitionFields({
|
||||
export function isTransitionAnswered(category: ElementCategory, isRetirement: boolean, td: TransitionData): boolean {
|
||||
switch (category) {
|
||||
case "AHV":
|
||||
// Nur bei der Pensionierung ist eine Pruefung der Beitragskarriere noetig.
|
||||
// Nur bei der Pensionierung ist eine Prüfung der Beitragskarriere nötig.
|
||||
return isRetirement ? td.reviewed === true : true;
|
||||
case "REAL_ESTATE":
|
||||
case "OTHER_ASSET":
|
||||
@@ -395,7 +395,7 @@ export function isTransitionAnswered(category: ElementCategory, isRetirement: bo
|
||||
}
|
||||
}
|
||||
|
||||
// --- Wiederverwendbare Feldgruppen (Detail-Panel, Erstell-Popup, Uebergangs-Review) ---
|
||||
// --- Wiederverwendbare Feldgruppen (Detail-Panel, Erstell-Popup, Übergangs-Review) ---
|
||||
|
||||
export function ElementPhaseFields({
|
||||
element,
|
||||
@@ -414,7 +414,7 @@ export function ElementPhaseFields({
|
||||
case "EXPENSE": {
|
||||
const isIncome = element.category === "INCOME";
|
||||
// Basiswert (erstes Jahr). Ab Phase 2 mit dem fortgeschriebenen Wert der Vorphase
|
||||
// vorbelegt, aber bewusst aenderbar (Teilzeit, Befoerderung, Jobwechsel …).
|
||||
// vorbelegt, aber bewusst änderbar (Teilzeit, Beförderung, Jobwechsel …).
|
||||
const baseValue = typeof pd.amount === "number" ? pd.amount : carried ? context.derivedStart : 0;
|
||||
const rate = num(pd.teuerungsausgleich, 0);
|
||||
const d = context.deflatorStart || 1;
|
||||
@@ -424,12 +424,12 @@ export function ElementPhaseFields({
|
||||
<>
|
||||
<p className="col-span-2 rounded-lg bg-surface-2 px-3 py-2 text-xs text-muted">
|
||||
{isIncome
|
||||
? "Bitte das NETTO-Einkommen erfassen: der Betrag, der nach allen Lohnabzuegen (AHV/ALV, Pensionskasse, NBU) tatsaechlich aufs Konto kommt – nicht der Bruttolohn. Erfasst wird NOMINAL; der reale Wert (heutige Kaufkraft) erscheint nur zur Info. Fuer die AHV-Rente rechnet das Tool intern auf den Bruttolohn zurueck."
|
||||
? "Bitte das NETTO-Einkommen erfassen: der Betrag, der nach allen Lohnabzügen (AHV/ALV, Pensionskasse, NBU) tatsächlich aufs Konto kommt – nicht der Bruttolohn. Erfasst wird NOMINAL; der reale Wert (heutige Kaufkraft) erscheint nur zur Info. Für die AHV-Rente rechnet das Tool intern auf den Bruttolohn zurück."
|
||||
: "Ausgaben werden REAL erfasst (in heutiger Kaufkraft). Die Inflation (plan-weit) rechnet daraus automatisch die nominalen Ausgaben – nur zur Info."}
|
||||
</p>
|
||||
<MoneyField
|
||||
label={isIncome ? "Jahreseinkommen NETTO, nominal (erstes Jahr)" : "Jahresausgaben REAL (erstes Jahr, heutige Kaufkraft)"}
|
||||
help={carried ? "Vorbelegt mit dem fortgeschriebenen Wert der Vorphase; hier bewusst aenderbar." : undefined}
|
||||
help={carried ? "Vorbelegt mit dem fortgeschriebenen Wert der Vorphase; hier bewusst änderbar." : undefined}
|
||||
value={baseValue}
|
||||
onChange={(v) => setP({ amount: v })}
|
||||
/>
|
||||
@@ -439,11 +439,11 @@ export function ElementPhaseFields({
|
||||
help="Nur zur Info, wird automatisch berechnet."
|
||||
/>
|
||||
<NumberField
|
||||
label={isIncome ? "Nominale Lohnerhoehung (%/Jahr)" : "Reale Mehrausgaben (%/Jahr)"}
|
||||
label={isIncome ? "Nominale Lohnerhöhung (%/Jahr)" : "Reale Mehrausgaben (%/Jahr)"}
|
||||
help={
|
||||
isIncome
|
||||
? "Geschaetzte jaehrliche Lohnerhoehung. 0% = Lohn bleibt nominal gleich und verliert real an Kaufkraft."
|
||||
: "Zusaetzliche reale Mehrausgaben pro Jahr (z. B. weil man sich mehr goennt). Die Inflation kommt separat automatisch dazu. 0% = gleicher Lebensstandard."
|
||||
? "Geschätzte jährliche Lohnerhöhung. 0% = Lohn bleibt nominal gleich und verliert real an Kaufkraft."
|
||||
: "Zusätzliche reale Mehrausgaben pro Jahr (z. B. weil man sich mehr gönnt). Die Inflation kommt separat automatisch dazu. 0% = gleicher Lebensstandard."
|
||||
}
|
||||
step={0.1}
|
||||
value={rate}
|
||||
@@ -454,25 +454,25 @@ export function ElementPhaseFields({
|
||||
}
|
||||
case "AHV":
|
||||
if (!context.ownerWorking) {
|
||||
// Sonderfall: bei Planbeginn bereits pensioniert -> es gibt keinen Pensions-Uebergang,
|
||||
// an dem die Karriere geprueft werden koennte. Dann hier erfassen (nur erste Phase).
|
||||
// Sonderfall: bei Planbeginn bereits pensioniert -> es gibt keinen Pensions-Übergang,
|
||||
// an dem die Karriere geprüft werden könnte. Dann hier erfassen (nur erste Phase).
|
||||
if (!carried && context.ahvCareer && context.ahvCareer.planYears === 0) {
|
||||
return (
|
||||
<>
|
||||
<p className="col-span-2 rounded-lg bg-surface-2 px-3 py-2 text-xs text-muted">
|
||||
Diese Person ist bei Planbeginn bereits pensioniert. Die AHV-Rente haengt vom
|
||||
massgebenden durchschnittlichen Jahreseinkommen ueber die ganze Beitragsdauer ab –
|
||||
Diese Person ist bei Planbeginn bereits pensioniert. Die AHV-Rente hängt vom
|
||||
massgebenden durchschnittlichen Jahreseinkommen über die ganze Beitragsdauer ab –
|
||||
bitte hier erfassen (REAL, heutige Kaufkraft).
|
||||
</p>
|
||||
<MoneyField
|
||||
label="Durchschnittliches Bruttoeinkommen der Beitragsjahre (real)"
|
||||
help="BRUTTO und in heutiger Kaufkraft. Am besten aus der AHV-Rentenvorausberechnung bzw. der Rentenverfuegung. Ab 90'720 gibt es die Maximalrente."
|
||||
label="Durchschnittliches Bruttöinkommen der Beitragsjahre (real)"
|
||||
help="BRUTTO und in heutiger Kaufkraft. Am besten aus der AHV-Rentenvorausberechnung bzw. der Rentenverfügung. Ab 90'720 gibt es die Maximalrente."
|
||||
value={num(pd.avgIncomeBefore)}
|
||||
onChange={(v) => setP({ avgIncomeBefore: v })}
|
||||
/>
|
||||
<NumberField
|
||||
label="Ausfalljahre total"
|
||||
help="Jahre ohne AHV-Beitraege. Jedes kuerzt die Rente um 1/44."
|
||||
help="Jahre ohne AHV-Beiträge. Jedes kürzt die Rente um 1/44."
|
||||
value={num(pd.gapYearsBefore)}
|
||||
min={0}
|
||||
onChange={(v) => setP({ gapYearsBefore: Math.max(0, Math.round(v)) })}
|
||||
@@ -480,14 +480,14 @@ export function ElementPhaseFields({
|
||||
<DerivedField
|
||||
label="Resultierende AHV-Rente pro Jahr"
|
||||
value={ahvAnnualPension(num(pd.avgIncomeBefore), num(pd.gapYearsBefore))}
|
||||
help="Inkl. 13. Altersrente. Bei Ehepaaren zusaetzlich plafoniert."
|
||||
help="Inkl. 13. Altersrente. Bei Ehepaaren zusätzlich plafoniert."
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<p className="col-span-2 text-sm text-muted">
|
||||
Die AHV-Rente wird aus der beim Pensions-Uebergang geprueften Beitragskarriere berechnet
|
||||
Die AHV-Rente wird aus der beim Pensions-Übergang geprüften Beitragskarriere berechnet
|
||||
(massgebendes Durchschnittseinkommen und Ausfalljahre). Bei Ehepaaren greift die
|
||||
Plafonierung auf 150% der Maximalrente.
|
||||
</p>
|
||||
@@ -496,7 +496,7 @@ export function ElementPhaseFields({
|
||||
return (
|
||||
<NumberField
|
||||
label="Geplante Ausfalljahre"
|
||||
help="Jahre ohne AHV-Beitraege in dieser Phase. Jedes Ausfalljahr kuerzt die spaetere Rente um 1/44."
|
||||
help="Jahre ohne AHV-Beiträge in dieser Phase. Jedes Ausfalljahr kürzt die spätere Rente um 1/44."
|
||||
value={num(pd.gapYears)}
|
||||
min={0}
|
||||
max={context.durationYears}
|
||||
@@ -507,8 +507,8 @@ export function ElementPhaseFields({
|
||||
if (!context.ownerWorking) {
|
||||
return (
|
||||
<p className="col-span-2 text-sm text-muted">
|
||||
Die PK-Rente wird aus dem beim Pensions-Uebergang gewaehlten Umwandlungssatz berechnet (siehe
|
||||
Kennzahl). Bei reinem Kapitalbezug erscheint hier "Vollstaendig bezogen".
|
||||
Die PK-Rente wird aus dem beim Pensions-Übergang gewählten Umwandlungssatz berechnet (siehe
|
||||
Kennzahl). Bei reinem Kapitalbezug erscheint hier "Vollständig bezogen".
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -519,7 +519,7 @@ export function ElementPhaseFields({
|
||||
<DerivedField label="Startwert (fortgeschrieben)" value={context.derivedStart} help="Endwert der Vorphase, fortgeschrieben." />
|
||||
<MoneyField
|
||||
label="Zusatzeinlage aus Kapital (CHF)"
|
||||
help="Aufstockung aus dem verfuegbaren Kapital dieser Phase."
|
||||
help="Aufstockung aus dem verfügbaren Kapital dieser Phase."
|
||||
value={num(pd.additionalInvestment)}
|
||||
|
||||
onChange={(v) => setP({ additionalInvestment: v })}
|
||||
@@ -529,8 +529,8 @@ export function ElementPhaseFields({
|
||||
<MoneyField label="Aktueller PK-Wert (CHF)" value={num(pd.currentValue)} onChange={(v) => setP({ currentValue: v })} />
|
||||
)}
|
||||
<MoneyField
|
||||
label="Jaehrliche Einzahlung (CHF)"
|
||||
help="Arbeitnehmer- und Arbeitgeberbeitraege. Fliesst NICHT in die Sparquote ein (bereits in den Ausgaben beruecksichtigt)."
|
||||
label="Jährliche Einzahlung (CHF)"
|
||||
help="Arbeitnehmer- und Arbeitgeberbeiträge. Fliesst NICHT in die Sparquote ein (bereits in den Ausgaben berücksichtigt)."
|
||||
value={num(pd.annualContribution)}
|
||||
onChange={(v) => setP({ annualContribution: v })}
|
||||
/>
|
||||
@@ -539,7 +539,7 @@ export function ElementPhaseFields({
|
||||
);
|
||||
case "PILLAR_3A":
|
||||
if (!context.ownerWorking) {
|
||||
return <p className="col-span-2 text-sm text-muted">Die Saeule 3a wird beim Pensions-Uebergang vollstaendig bezogen.</p>;
|
||||
return <p className="col-span-2 text-sm text-muted">Die Säule 3a wird beim Pensions-Übergang vollständig bezogen.</p>;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
@@ -548,7 +548,7 @@ export function ElementPhaseFields({
|
||||
<DerivedField label="Startwert (fortgeschrieben)" value={context.derivedStart} help="Endwert der Vorphase, fortgeschrieben." />
|
||||
<MoneyField
|
||||
label="Zusatzeinlage aus Kapital (CHF)"
|
||||
help="Aufstockung aus dem verfuegbaren Kapital dieser Phase."
|
||||
help="Aufstockung aus dem verfügbaren Kapital dieser Phase."
|
||||
value={num(pd.additionalInvestment)}
|
||||
|
||||
onChange={(v) => setP({ additionalInvestment: v })}
|
||||
@@ -558,8 +558,8 @@ export function ElementPhaseFields({
|
||||
<MoneyField label="Aktueller 3a-Wert (CHF)" value={num(pd.currentValue)} onChange={(v) => setP({ currentValue: v })} />
|
||||
)}
|
||||
<MoneyField
|
||||
label="Jaehrliche Einzahlung (CHF)"
|
||||
help={`Maximal CHF ${PILLAR_3A_MAX_ANNUAL.toLocaleString("de-CH")} (2026, mit PK) und hoechstens die Sparquote. Wird von der Sparquote abgezogen.`}
|
||||
label="Jährliche Einzahlung (CHF)"
|
||||
help={`Maximal CHF ${PILLAR_3A_MAX_ANNUAL.toLocaleString("de-CH")} (2026, mit PK) und höchstens die Sparquote. Wird von der Sparquote abgezogen.`}
|
||||
value={num(pd.annualContribution)}
|
||||
max={PILLAR_3A_MAX_ANNUAL}
|
||||
onChange={(v) => setP({ annualContribution: v })}
|
||||
@@ -594,7 +594,7 @@ export function ElementPhaseFields({
|
||||
)}
|
||||
<MoneyField
|
||||
label="Amortisation (CHF/Jahr)"
|
||||
help="Jaehrliche Reduktion der Hypothek. Zaehlt gegen die Sparquote und endet, sobald die Hypothek abbezahlt ist."
|
||||
help="Jährliche Reduktion der Hypothek. Zählt gegen die Sparquote und endet, sobald die Hypothek abbezahlt ist."
|
||||
value={num(pd.amortization)}
|
||||
onChange={(v) => setP({ amortization: v })}
|
||||
/>
|
||||
@@ -615,7 +615,7 @@ export function ElementPhaseFields({
|
||||
</div>
|
||||
</div>
|
||||
<NumberField
|
||||
label="Geschaetzte Wertsteigerung (%/Jahr)"
|
||||
label="Geschätzte Wertsteigerung (%/Jahr)"
|
||||
help="Wirkt auf den Wert der LIEGENSCHAFT, nicht auf das Eigenkapital. 1% von 1 Mio sind 10'000 pro Jahr – bei 100'000 Eigenkapital also 10% darauf (Hebel)."
|
||||
step={0.1}
|
||||
value={num(pd.valueGrowth)}
|
||||
@@ -624,12 +624,12 @@ export function ElementPhaseFields({
|
||||
<div className="sm:col-span-2">
|
||||
<SelectField
|
||||
label="Sind die Hypothekarzinsen bereits in den Ausgaben enthalten?"
|
||||
help="Damit die Zinsen nicht doppelt zaehlen. Standard: bereits enthalten – so verhalten sich bestehende Plaene unveraendert."
|
||||
help="Damit die Zinsen nicht doppelt zählen. Standard: bereits enthalten – so verhalten sich bestehende Pläne unverändert."
|
||||
value={handling}
|
||||
onChange={(v: "INCLUDED" | "ADD") => setP({ interestHandling: v })}
|
||||
options={[
|
||||
{ value: "INCLUDED", label: "Ja – bereits im Ausgaben-Element beruecksichtigt" },
|
||||
{ value: "ADD", label: "Nein – bitte zu den Ausgaben dazuzaehlen" },
|
||||
{ value: "INCLUDED", label: "Ja – bereits im Ausgaben-Element berücksichtigt" },
|
||||
{ value: "ADD", label: "Nein – bitte zu den Ausgaben dazuzählen" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
@@ -644,7 +644,7 @@ export function ElementPhaseFields({
|
||||
<DerivedField label="Startwert (fortgeschrieben)" value={context.derivedStart} help="Endwert der Vorphase, fortgeschrieben." />
|
||||
<MoneyField
|
||||
label="Zusatzinvestition aus Kapital (CHF)"
|
||||
help="Neuinvestition aus dem verfuegbaren Kapital dieser Phase."
|
||||
help="Neuinvestition aus dem verfügbaren Kapital dieser Phase."
|
||||
value={num(pd.additionalInvestment)}
|
||||
|
||||
onChange={(v) => setP({ additionalInvestment: v })}
|
||||
@@ -655,14 +655,14 @@ export function ElementPhaseFields({
|
||||
)}
|
||||
<NumberField label="Erwartete Rendite (%/Jahr)" step={0.1} value={num(pd.expectedReturn)} onChange={(v) => setP({ expectedReturn: v })} />
|
||||
<MoneyField
|
||||
label="Jaehrlicher Sparbeitrag (CHF)"
|
||||
help="Flacher Jahresbetrag, der ins Vermoegen fliesst und vom Cash abgezogen wird (geplante Sparrate)."
|
||||
label="Jährlicher Sparbeitrag (CHF)"
|
||||
help="Flacher Jahresbetrag, der ins Vermögen fliesst und vom Cash abgezogen wird (geplante Sparrate)."
|
||||
value={num(pd.annualContribution)}
|
||||
onChange={(v) => setP({ annualContribution: v })}
|
||||
/>
|
||||
<MoneyField
|
||||
label="Jaehrliche Bezugsrate (CHF)"
|
||||
help="Entnahme aus dem Vermoegen (z. B. laufende Renten-Entnahme im Alter). Mindert das Vermoegen und fliesst jaehrlich ins Cash (geplante Verzehrrate)."
|
||||
label="Jährliche Bezugsrate (CHF)"
|
||||
help="Entnahme aus dem Vermögen (z. B. laufende Renten-Entnahme im Alter). Mindert das Vermögen und fliesst jährlich ins Cash (geplante Verzehrrate)."
|
||||
value={num(pd.annualWithdrawal)}
|
||||
onChange={(v) => setP({ annualWithdrawal: v })}
|
||||
/>
|
||||
@@ -677,8 +677,8 @@ export function ElementPhaseFields({
|
||||
<MoneyField label="Restschuld (CHF)" value={num(pd.startValue)} onChange={(v) => setP({ startValue: v })} />
|
||||
)}
|
||||
<MoneyField
|
||||
label="Jaehrliche Tilgung (CHF)"
|
||||
help="Zaehlt gegen die Sparquote (max. die Sparquote)."
|
||||
label="Jährliche Tilgung (CHF)"
|
||||
help="Zählt gegen die Sparquote (max. die Sparquote)."
|
||||
value={num(pd.annualRepayment)}
|
||||
|
||||
onChange={(v) => setP({ annualRepayment: v })}
|
||||
@@ -704,8 +704,8 @@ export function ElementTransitionFields({
|
||||
case "EXPENSE":
|
||||
return (
|
||||
<p className="col-span-2 text-sm text-muted">
|
||||
Fuer diese Kategorie gibt es im Uebergang keine Eingaben. Die Werte werden 1:1 in die naechste
|
||||
Lebensphase uebernommen und koennen dort angepasst werden.
|
||||
Für diese Kategorie gibt es im Übergang keine Eingaben. Die Werte werden 1:1 in die nächste
|
||||
Lebensphase übernommen und können dort angepasst werden.
|
||||
</p>
|
||||
);
|
||||
case "AHV":
|
||||
@@ -714,7 +714,7 @@ export function ElementTransitionFields({
|
||||
}
|
||||
return (
|
||||
<p className="col-span-2 text-sm text-muted">
|
||||
Die Beitragskarriere wird erst beim Uebergang in die Pensionierung geprueft.
|
||||
Die Beitragskarriere wird erst beim Übergang in die Pensionierung geprüft.
|
||||
</p>
|
||||
);
|
||||
case "PENSION_FUND":
|
||||
@@ -735,7 +735,7 @@ export function ElementTransitionFields({
|
||||
{(mode === "PENSION" || mode === "COMBI") && (
|
||||
<NumberField
|
||||
label="Umwandlungssatz (%)"
|
||||
help="Jaehrliche Rente = verrentetes Kapital x Umwandlungssatz."
|
||||
help="Jährliche Rente = verrentetes Kapital x Umwandlungssatz."
|
||||
step={0.1}
|
||||
value={num(td.conversionRate, DEFAULT_PK_CONVERSION_RATE)}
|
||||
onChange={(v) => setT({ conversionRate: v })}
|
||||
@@ -762,7 +762,7 @@ export function ElementTransitionFields({
|
||||
return (
|
||||
<NumberField
|
||||
label="Kapitalbezugssteuer (%)"
|
||||
help="Die Saeule 3a wird bei Pensionierung vollstaendig bezogen."
|
||||
help="Die Säule 3a wird bei Pensionierung vollständig bezogen."
|
||||
step={0.5}
|
||||
value={num(td.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE)}
|
||||
onChange={(v) => setT({ capitalTaxRate: v })}
|
||||
@@ -775,7 +775,7 @@ export function ElementTransitionFields({
|
||||
const marktwert = Math.round(context.propertyValueEnd);
|
||||
const preis = num(td.salePrice);
|
||||
// Abweichung zwischen erfasstem Verkaufspreis und modelliertem Verkehrswert. Beide
|
||||
// Groessen sind unabhaengig erfassbar -- ohne diesen Vergleich koennte man 2 %
|
||||
// Grössen sind unabhängig erfassbar -- ohne diesen Vergleich könnte man 2 %
|
||||
// Wertsteigerung annehmen und trotzdem zum Kaufpreis verkaufen, ohne es zu merken.
|
||||
const abweichung = preis - marktwert;
|
||||
const abweichungPct = marktwert > 0 ? (abweichung / marktwert) * 100 : 0;
|
||||
@@ -833,13 +833,13 @@ export function ElementTransitionFields({
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
<NumberField label="Grundstueckgewinnsteuer (%)" step={1} value={num(td.saleTaxRate, DEFAULT_PROPERTY_GAINS_TAX_RATE)} onChange={(v) => setT({ saleTaxRate: v })} />
|
||||
<NumberField label="Grundstückgewinnsteuer (%)" step={1} value={num(td.saleTaxRate, DEFAULT_PROPERTY_GAINS_TAX_RATE)} onChange={(v) => setT({ saleTaxRate: v })} />
|
||||
</>
|
||||
)}
|
||||
{decision !== "SELL" && context.mortgageEnd > 0 && (
|
||||
<MoneyField
|
||||
label="Sonderamortisation (CHF)"
|
||||
help={`Einmaltilgung der Hypothek aus dem Cash der naechsten Phase (z. B. aus einem 3a-Bezug). Senkt Restschuld und kuenftige Zinsen. Maximal ${formatChf(context.mortgageEnd)} (Resthypothek am Phasenende).`}
|
||||
help={`Einmaltilgung der Hypothek aus dem Cash der nächsten Phase (z. B. aus einem 3a-Bezug). Senkt Restschuld und künftige Zinsen. Maximal ${formatChf(context.mortgageEnd)} (Resthypothek am Phasenende).`}
|
||||
value={num(td.extraAmortization)}
|
||||
max={context.mortgageEnd}
|
||||
onChange={(v) => setT({ extraAmortization: v })}
|
||||
@@ -865,7 +865,7 @@ export function ElementTransitionFields({
|
||||
{decision === "PARTIAL" && (
|
||||
<MoneyField
|
||||
label="Teilverkauf (CHF)"
|
||||
help={`Betrag, der am Uebergang ins Cash fliesst. Der Rest bleibt investiert. Maximal ${formatChf(context.carriedEndValue)} (Endwert der Vorphase).`}
|
||||
help={`Betrag, der am Übergang ins Cash fliesst. Der Rest bleibt investiert. Maximal ${formatChf(context.carriedEndValue)} (Endwert der Vorphase).`}
|
||||
value={num(td.partialSaleAmount)}
|
||||
max={context.carriedEndValue}
|
||||
onChange={(v) => setT({ partialSaleAmount: v })}
|
||||
@@ -878,7 +878,7 @@ export function ElementTransitionFields({
|
||||
return (
|
||||
<MoneyField
|
||||
label="Sofortige Tilgung (CHF)"
|
||||
help="Wird sofort getilgt und vom verfuegbaren Kapital der naechsten Phase abgezogen."
|
||||
help="Wird sofort getilgt und vom verfügbaren Kapital der nächsten Phase abgezogen."
|
||||
value={num(td.immediateRepayment)}
|
||||
onChange={(v) => setT({ immediateRepayment: v })}
|
||||
/>
|
||||
@@ -903,8 +903,8 @@ export function ElementDetail({ element, context, phaseData, transitionData, onS
|
||||
} else {
|
||||
const payload: PhaseData = { ...pd };
|
||||
// Einkommen/Ausgaben ab Phase 2: entspricht der Basiswert dem fortgeschriebenen Wert
|
||||
// der Vorphase, KEINEN Override speichern -> Wert bleibt live vererbt (Aenderungen in
|
||||
// frueheren Phasen wirken sich weiter aus). Nur ein bewusst abweichender Wert wird fix.
|
||||
// der Vorphase, KEINEN Override speichern -> Wert bleibt live vererbt (Änderungen in
|
||||
// früheren Phasen wirken sich weiter aus). Nur ein bewusst abweichender Wert wird fix.
|
||||
if (
|
||||
(element.category === "INCOME" || element.category === "EXPENSE") &&
|
||||
context.carried &&
|
||||
@@ -936,7 +936,7 @@ export function ElementDetail({ element, context, phaseData, transitionData, onS
|
||||
<div>
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-accent-soft-fg">
|
||||
{CATEGORY_LABELS[element.category]}
|
||||
{isTransition ? " · Uebergang" : ""}
|
||||
{isTransition ? " · Übergang" : ""}
|
||||
</div>
|
||||
<div className="text-base font-semibold text-fg">{element.name}</div>
|
||||
</div>
|
||||
@@ -945,12 +945,12 @@ export function ElementDetail({ element, context, phaseData, transitionData, onS
|
||||
onClick={onDeleteElement}
|
||||
className="flex items-center gap-1 rounded-lg border border-border px-2 py-1 text-xs text-muted hover:border-danger hover:bg-danger-soft hover:text-danger"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" /> Element loeschen
|
||||
<Trash2 className="h-3.5 w-3.5" /> Element löschen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
{/* Punkt 4: Warnung, dass Aenderungen sich in die Folgephasen fortschreiben. */}
|
||||
{/* Punkt 4: Warnung, dass Änderungen sich in die Folgephasen fortschreiben. */}
|
||||
{!isTransition && <CarryWarning laterPhaseCount={context.laterPhaseCount} />}
|
||||
{isTransition ? (
|
||||
<ElementTransitionFields element={element} context={context} td={td} setT={setT} />
|
||||
|
||||
@@ -51,10 +51,10 @@ export function NumberField({
|
||||
);
|
||||
}
|
||||
|
||||
// Pflicht-Zahlenfeld, das wirklich LEER sein kann (NumberField erzwingt eine Zahl und haette
|
||||
// Pflicht-Zahlenfeld, das wirklich LEER sein kann (NumberField erzwingt eine Zahl und hätte
|
||||
// damit immer einen Default -- genau das soll bei Annahmen vermieden werden, siehe
|
||||
// SPEZIFIKATION 9.15/9.18: ein stiller Default wird nicht hinterfragt). Der Wert wird als
|
||||
// String gefuehrt; die Validierung liegt beim Aufrufer.
|
||||
// String geführt; die Validierung liegt beim Aufrufer.
|
||||
export function RequiredNumberField({
|
||||
label,
|
||||
help,
|
||||
@@ -148,7 +148,7 @@ export function MoneyInput({
|
||||
}
|
||||
|
||||
// Klick = 1 Schritt. Halten: nach 400 ms Wiederholung im 70-ms-Takt, wobei die
|
||||
// Schrittweite mit der Haltedauer waechst (immer schneller).
|
||||
// Schrittweite mit der Haltedauer wächst (immer schneller).
|
||||
function startHold(sign: number) {
|
||||
stepBy(sign);
|
||||
const start = Date.now();
|
||||
@@ -177,7 +177,7 @@ export function MoneyInput({
|
||||
onFocus={() => {
|
||||
setFocused(true);
|
||||
const current = Math.round(value || 0);
|
||||
// Default-0 sofort leeren; sonst Wert markieren, damit man ihn ueberschreiben kann.
|
||||
// Default-0 sofort leeren; sonst Wert markieren, damit man ihn überschreiben kann.
|
||||
setText(current === 0 ? "" : String(current));
|
||||
requestAnimationFrame(() => inputRef.current?.select());
|
||||
}}
|
||||
@@ -200,7 +200,7 @@ export function MoneyInput({
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
aria-label="Erhoehen"
|
||||
aria-label="Erhöhen"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
startHold(1);
|
||||
|
||||
@@ -27,8 +27,8 @@ import { CATEGORY_LABELS } from "@/lib/elements";
|
||||
import type { PlanInput, ScenarioMeta } from "@/lib/types";
|
||||
import type { PlanComputed } from "@/lib/calculations";
|
||||
|
||||
// Farben der Szenario-Serien -- wie im Vermoegensverlauf, damit die Zuordnung vertraut bleibt.
|
||||
const PALETTE = ["#4f46e5", "#0ea5e9", "#16a34a", "#d97706", "#dc2626", "#7c3aed"];
|
||||
// Farben der Szenario-Serien -- wie im Vermögensverlauf, damit die Zuordnung vertraut bleibt.
|
||||
const PALETTE = ["#4f46e5", "#0ea5e9", "#16a34a", "#d97706", "#dc2626", "#7c3äd"];
|
||||
|
||||
const RETURN_LEVEL_OPTIONS: { value: ReturnVolatilityLevel; label: string }[] = [
|
||||
{ value: "sehr_niedrig", label: "Sehr niedrig" },
|
||||
@@ -92,8 +92,8 @@ export function MonteCarloDialog({
|
||||
plan: PlanInput;
|
||||
computed: PlanComputed;
|
||||
meta: ScenarioMeta;
|
||||
// Alle Szenarien dieses Plans (auch nicht ausgewaehlte) -- sie werden geladen, damit sich
|
||||
// die Herkunfts-Kette der Elemente auch ueber uebersprungene Zwischen-Szenarien aufloest.
|
||||
// Alle Szenarien dieses Plans (auch nicht ausgewählte) -- sie werden geladen, damit sich
|
||||
// die Herkunfts-Kette der Elemente auch über übersprungene Zwischen-Szenarien auflöst.
|
||||
scenarios: ScenarioMeta[];
|
||||
onClose: () => void;
|
||||
}) {
|
||||
@@ -119,8 +119,8 @@ export function MonteCarloDialog({
|
||||
|
||||
const scenarioKey = scenarios.map((s) => s.id).join(",");
|
||||
|
||||
// Die uebrigen Szenarien einmalig nachladen. Ein Plan hat realistisch eine Handvoll
|
||||
// Szenarien -- gegenueber tausenden Simulationslaeufen faellt das nicht ins Gewicht.
|
||||
// Die übrigen Szenarien einmalig nachladen. Ein Plan hat realistisch eine Handvoll
|
||||
// Szenarien -- gegenüber tausenden Simulationsläufen fällt das nicht ins Gewicht.
|
||||
useEffect(() => {
|
||||
const missing = scenarios.filter((s) => s.id !== meta.id);
|
||||
if (missing.length === 0) return;
|
||||
@@ -156,7 +156,7 @@ export function MonteCarloDialog({
|
||||
[scenarios, loaded]
|
||||
);
|
||||
|
||||
// Ein Parametersatz je LOGISCHEM Element (ueber die sourceElementId-Kette zusammengefasst).
|
||||
// Ein Parametersatz je LOGISCHEM Element (über die sourceElementId-Kette zusammengefasst).
|
||||
const groups = useMemo(
|
||||
() => buildElementGroups(allLoaded, selectedIds),
|
||||
[allLoaded, selectedIds]
|
||||
@@ -195,8 +195,8 @@ export function MonteCarloDialog({
|
||||
return [g.rootId, { mean: Number(d.mean) || 0, sigma: returnSigma(d), floor: floorFor(g.category) }];
|
||||
})
|
||||
);
|
||||
// EIN Seed fuer alle Szenarien: strukturgleiche Szenarien teilen damit dieselben
|
||||
// Marktpfade, und die Unterschiede sind strukturell statt zufaellig.
|
||||
// EIN Seed für alle Szenarien: strukturgleiche Szenarien teilen damit dieselben
|
||||
// Marktpfade, und die Unterschiede sind strukturell statt zufällig.
|
||||
const seed = (Math.random() * 2 ** 32) >>> 0;
|
||||
const runInputs: ScenarioRunInput[] = selected.map((s) => ({
|
||||
scenarioId: s.id,
|
||||
@@ -239,7 +239,7 @@ export function MonteCarloDialog({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Erklaerung */}
|
||||
{/* Erklärung */}
|
||||
<div className="rounded-xl border border-border bg-surface-2 p-4 text-xs leading-relaxed text-muted">
|
||||
<p className="mb-2">
|
||||
<strong className="text-fg">Was ist das?</strong> Dein Plan rechnet mit einer festen Rendite und Inflation
|
||||
@@ -479,7 +479,7 @@ function MonteCarloResults({
|
||||
}) {
|
||||
const single = results.length === 1;
|
||||
|
||||
// Einzelnes Szenario: der gewohnte Faecher (Band + Median + Planungslinie).
|
||||
// Einzelnes Szenario: der gewohnte Fächer (Band + Median + Planungslinie).
|
||||
const singleData = useMemo(() => {
|
||||
if (!single) return [];
|
||||
return results[0].bands.map((b) => ({
|
||||
@@ -490,7 +490,7 @@ function MonteCarloResults({
|
||||
}));
|
||||
}, [single, results, detPoints]);
|
||||
|
||||
// Mehrere Szenarien: nur die Median-Linien -- uebereinandergelegte Baender waeren Farbbrei.
|
||||
// Mehrere Szenarien: nur die Median-Linien -- übereinandergelegte Bänder wären Farbbrei.
|
||||
const compareData = useMemo(() => {
|
||||
if (single) return [];
|
||||
const ages = Array.from(new Set(results.flatMap((r) => r.bands.map((b) => b.age)))).sort((a, b) => a - b);
|
||||
|
||||
@@ -43,12 +43,12 @@ export function PhaseDetail({
|
||||
}
|
||||
|
||||
async function remove() {
|
||||
if (!confirm(`Phase "${phase.name}" wirklich loeschen?`)) return;
|
||||
if (!confirm(`Phase "${phase.name}" wirklich löschen?`)) return;
|
||||
try {
|
||||
await api.delete(`/api/phases/${phase.id}`);
|
||||
onDeleted();
|
||||
} catch (e) {
|
||||
alert(e instanceof Error ? e.message : "Loeschen fehlgeschlagen.");
|
||||
alert(e instanceof Error ? e.message : "Löschen fehlgeschlagen.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,18 +64,18 @@ export function PhaseDetail({
|
||||
onClick={remove}
|
||||
className="flex items-center gap-1 rounded-lg border border-border px-2 py-1 text-xs text-muted hover:border-danger hover:bg-danger-soft hover:text-danger"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" /> Phase loeschen
|
||||
<Trash2 className="h-3.5 w-3.5" /> Phase löschen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
{/* Eine geaenderte Dauer verschiebt alle Folgephasen (Alter, Renten, Vermoegen). */}
|
||||
{/* Eine geänderte Dauer verschiebt alle Folgephasen (Alter, Renten, Vermögen). */}
|
||||
<CarryWarning laterPhaseCount={laterPhaseCount} />
|
||||
<TextField label="Bezeichnung" value={name} onChange={setName} />
|
||||
<NumberField
|
||||
label={`Dauer (Jahre)${cap != null ? ` · max. ${cap}` : ""}`}
|
||||
help={cap != null ? "Die Dauer ist ans naechste Pensionsereignis gekappt." : undefined}
|
||||
help={cap != null ? "Die Dauer ist ans nächste Pensionsereignis gekappt." : undefined}
|
||||
value={durationYears}
|
||||
min={1}
|
||||
max={cap ?? undefined}
|
||||
|
||||
@@ -19,7 +19,7 @@ export function emptyProfileDraft(): ProfileDraft {
|
||||
};
|
||||
}
|
||||
|
||||
// Gemeinsame Formularfelder fuer 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.
|
||||
export function PlanProfileFields({
|
||||
draft,
|
||||
@@ -51,7 +51,7 @@ export function PlanProfileFields({
|
||||
<div className="flex flex-col gap-4">
|
||||
<SelectField
|
||||
label="Haushaltsform"
|
||||
help="Waehlen Sie, ob Sie alleine oder gemeinsam mit einer Partnerin / einem Partner planen."
|
||||
help="Planst du alleine oder gemeinsam mit einer Partnerin / einem Partner?"
|
||||
value={draft.householdType}
|
||||
onChange={setType}
|
||||
options={[
|
||||
@@ -63,7 +63,7 @@ export function PlanProfileFields({
|
||||
{draft.persons.map((person, index) => (
|
||||
<div key={person.role} className="grid grid-cols-2 gap-3 rounded-xl border border-border bg-surface-2 p-3">
|
||||
<div className="col-span-2 text-xs font-semibold uppercase tracking-wide text-faint">
|
||||
{draft.householdType === "COUPLE" ? (person.role === "PERSON_A" ? "Person A" : "Person B") : "Ihre Angaben"}
|
||||
{draft.householdType === "COUPLE" ? (person.role === "PERSON_A" ? "Person A" : "Person B") : "Deine Angaben"}
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<TextField
|
||||
@@ -102,7 +102,7 @@ export function PlanProfileFields({
|
||||
|
||||
<NumberField
|
||||
label="Erwartete Inflationsrate (%)"
|
||||
help="Langfristige Annahme zur jaehrlichen Teuerung. Gilt plan-weit fuer alle Lebensphasen."
|
||||
help="Langfristige Annahme zur jährlichen Teuerung. Gilt plan-weit für alle Lebensphasen."
|
||||
value={draft.inflationRateDefault}
|
||||
step={0.1}
|
||||
onChange={(v) => onChange({ ...draft, inflationRateDefault: v })}
|
||||
|
||||
+468
-341
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,419 @@
|
||||
"use client";
|
||||
|
||||
// Geführter Plan-Assistent (Roadmap Nr. 10): "Schritt für Schritt statt leerer Matrix".
|
||||
// Der Wizard fragt in Alltagssprache und erzeugt am Ende Plan, Phasen und Elemente über
|
||||
// die BESTEHENDEN API-Endpunkte -- reine Orchestrierung, keine Änderung an Berechnung
|
||||
// oder Datenmodell.
|
||||
//
|
||||
// Reihenfolge beim Anlegen (wichtig): erst Phase 1, dann Elemente samt Phase-1-Werten,
|
||||
// dann die Folgephasen -- so belegt die Phasen-Route die Folgephasen korrekt vor.
|
||||
//
|
||||
// Ehrliche Grenze: Bricht die Sequenz mittendrin ab (Netzfehler), existiert ein Teil-Plan.
|
||||
// Der ist normal weiterbearbeitbar; ein Rollback bräuchte Backend-Unterstützung und ist
|
||||
// bewusst nicht gebaut (siehe SPEZIFIKATION 9.23).
|
||||
|
||||
import { useState } from "react";
|
||||
import { ArrowLeft, ArrowRight, Sparkles } from "lucide-react";
|
||||
import { Modal, Button } from "@/components/ui";
|
||||
import { MoneyField, NumberField, TextField } from "@/components/FormField";
|
||||
import { InfoBubble } from "@/components/InfoBubble";
|
||||
import { PlanProfileFields, emptyProfileDraft, type ProfileDraft } from "@/components/PlanProfileFields";
|
||||
import { api } from "@/lib/api-client";
|
||||
import { maxPhaseDuration } from "@/lib/calculations";
|
||||
import { PILLAR_3A_MAX_ANNUAL } from "@/lib/constants";
|
||||
import type { PhaseData } from "@/lib/elements";
|
||||
|
||||
interface PhaseDraft {
|
||||
name: string;
|
||||
durationYears: number;
|
||||
}
|
||||
|
||||
interface AssetToggles {
|
||||
pk: boolean;
|
||||
p3a: boolean;
|
||||
etf: boolean;
|
||||
realEstate: boolean;
|
||||
debt: boolean;
|
||||
}
|
||||
|
||||
const STEP_TITLES = ["Über dich", "Deine Lebensphasen", "Einkommen & Ausgaben", "Vorsorge & Vermögen", "Fertig"];
|
||||
|
||||
// Vorschlag für die Phasenkette: Erwerbsphase bis zum nächsten Pensionsereignis
|
||||
// (dieselbe Kappungslogik wie beim manuellen Anlegen), danach eine Pensionsphase bis ~90.
|
||||
function suggestPhases(draft: ProfileDraft): PhaseDraft[] {
|
||||
const persons = draft.persons.map((p) => ({ role: p.role, age: p.age, retirementAge: p.retirementAge }));
|
||||
const cap = maxPhaseDuration(persons, 0);
|
||||
const phases: PhaseDraft[] = [];
|
||||
let elapsed = 0;
|
||||
if (cap != null && cap > 0) {
|
||||
phases.push({ name: "Erwerbsphase", durationYears: cap });
|
||||
elapsed = cap;
|
||||
// Bei Paaren mit unterschiedlichem Pensionsalter folgt eine Mischphase bis zur
|
||||
// zweiten Pensionierung.
|
||||
const cap2 = maxPhaseDuration(persons, elapsed);
|
||||
if (cap2 != null && cap2 > 0) {
|
||||
phases.push({ name: "Übergangsphase", durationYears: cap2 });
|
||||
elapsed += cap2;
|
||||
}
|
||||
}
|
||||
const oldest = Math.max(...draft.persons.map((p) => p.age));
|
||||
const pensionYears = Math.max(5, 90 - (oldest + elapsed));
|
||||
phases.push({ name: "Pensionsphase", durationYears: pensionYears });
|
||||
return phases;
|
||||
}
|
||||
|
||||
export function PlanWizard({
|
||||
onCreated,
|
||||
onClose,
|
||||
}: {
|
||||
onCreated: (scenarioId: string) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [step, setStep] = useState(0);
|
||||
const [planName, setPlanName] = useState("Meine Planung");
|
||||
const [profile, setProfile] = useState<ProfileDraft>(emptyProfileDraft);
|
||||
const [phases, setPhases] = useState<PhaseDraft[] | null>(null);
|
||||
// Einkommen bewusst PRO PERSON: In Paar-Plänen zählt "Gemeinsam" nicht für die
|
||||
// AHV-Rente (SPEZIFIKATION 9.9) -- der Assistent räumt diese Falle von Anfang an aus.
|
||||
const [incomes, setIncomes] = useState<Record<string, number>>({ PERSON_A: 0 });
|
||||
const [expenses, setExpenses] = useState(0);
|
||||
const [initialCash, setInitialCash] = useState(0);
|
||||
const [toggles, setToggles] = useState<AssetToggles>({ pk: false, p3a: false, etf: false, realEstate: false, debt: false });
|
||||
const [pk, setPk] = useState({ value: 0, contribution: 0 });
|
||||
const [p3a, setP3a] = useState({ value: 0, contribution: 0 });
|
||||
const [etf, setEtf] = useState({ value: 0, contribution: 0 });
|
||||
const [re, setRe] = useState({ price: 0, mortgage: 0, amortization: 0, interest: 1.5 });
|
||||
const [debt, setDebt] = useState({ value: 0, repayment: 0 });
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const personLabel = (role: string) => {
|
||||
const p = profile.persons.find((x) => x.role === role);
|
||||
return p?.name?.trim() || (role === "PERSON_A" ? "Person A" : "Person B");
|
||||
};
|
||||
|
||||
function goNext() {
|
||||
if (step === 0) {
|
||||
// Phasenvorschlag aus dem (evtl. geänderten) Profil neu ableiten.
|
||||
setPhases(suggestPhases(profile));
|
||||
const inc: Record<string, number> = {};
|
||||
for (const p of profile.persons) inc[p.role] = incomes[p.role] ?? 0;
|
||||
setIncomes(inc);
|
||||
}
|
||||
setStep((s) => Math.min(s + 1, STEP_TITLES.length - 1));
|
||||
}
|
||||
|
||||
async function create() {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const { scenario } = await api.post<{ plan: { id: string }; scenario: { id: string } }>("/api/plans", {
|
||||
name: planName.trim() || "Meine Planung",
|
||||
householdType: profile.householdType,
|
||||
inflationRateDefault: profile.inflationRateDefault,
|
||||
startYear: profile.startYear,
|
||||
persons: profile.persons.map((p) => ({ role: p.role, name: p.name.trim() || null, age: p.age, retirementAge: p.retirementAge })),
|
||||
});
|
||||
const sid = scenario.id;
|
||||
if (initialCash > 0) await api.patch(`/api/scenarios/${sid}`, { initialCash });
|
||||
|
||||
const phaseList = phases ?? suggestPhases(profile);
|
||||
const first = await api.post<{ phase: { id: string } }>(`/api/scenarios/${sid}/phases`, {
|
||||
name: phaseList[0].name,
|
||||
durationYears: phaseList[0].durationYears,
|
||||
});
|
||||
const firstPhaseId = first.phase.id;
|
||||
|
||||
async function addEl(category: string, name: string, ownerRole: string, values?: PhaseData) {
|
||||
const { element } = await api.post<{ element: { id: string } }>(`/api/scenarios/${sid}/elements`, {
|
||||
category,
|
||||
name,
|
||||
ownerRole,
|
||||
});
|
||||
if (values) await api.put(`/api/elements/${element.id}/phase/${firstPhaseId}`, values);
|
||||
}
|
||||
|
||||
for (const p of profile.persons) {
|
||||
const amount = incomes[p.role] ?? 0;
|
||||
if (amount > 0) await addEl("INCOME", `Lohn ${personLabel(p.role)}`, p.role, { amount, teuerungsausgleich: 0 });
|
||||
}
|
||||
if (expenses > 0) await addEl("EXPENSE", "Lebenshaltung", "HOUSEHOLD", { amount: expenses, teuerungsausgleich: 0 });
|
||||
for (const p of profile.persons) {
|
||||
await addEl("AHV", profile.persons.length > 1 ? `AHV ${personLabel(p.role)}` : "AHV", p.role, { gapYears: 0 });
|
||||
}
|
||||
if (toggles.pk) {
|
||||
await addEl("PENSION_FUND", "Pensionskasse", "PERSON_A", {
|
||||
currentValue: pk.value,
|
||||
annualContribution: pk.contribution,
|
||||
expectedReturn: 1.5,
|
||||
});
|
||||
}
|
||||
if (toggles.p3a) {
|
||||
await addEl("PILLAR_3A", "Säule 3a", "PERSON_A", {
|
||||
currentValue: p3a.value,
|
||||
annualContribution: Math.min(p3a.contribution, PILLAR_3A_MAX_ANNUAL),
|
||||
expectedReturn: 2,
|
||||
});
|
||||
}
|
||||
if (toggles.etf) {
|
||||
await addEl("OTHER_ASSET", "Wertschriften / ETF", "HOUSEHOLD", {
|
||||
startValue: etf.value,
|
||||
annualContribution: etf.contribution,
|
||||
expectedReturn: 5,
|
||||
});
|
||||
}
|
||||
if (toggles.realEstate) {
|
||||
await addEl("REAL_ESTATE", "Eigenheim", "HOUSEHOLD", {
|
||||
purchasePrice: re.price,
|
||||
mortgage: re.mortgage,
|
||||
amortization: re.amortization,
|
||||
interestRate: re.interest,
|
||||
});
|
||||
}
|
||||
if (toggles.debt) {
|
||||
await addEl("OTHER_DEBT", "Kredit", "HOUSEHOLD", { startValue: debt.value, annualRepayment: debt.repayment });
|
||||
}
|
||||
|
||||
for (const ph of phaseList.slice(1)) {
|
||||
await api.post(`/api/scenarios/${sid}/phases`, { name: ph.name, durationYears: ph.durationYears });
|
||||
}
|
||||
|
||||
onCreated(sid);
|
||||
} catch (e) {
|
||||
setError(
|
||||
(e instanceof Error ? e.message : "Erstellen fehlgeschlagen.") +
|
||||
" – Falls der Plan teilweise angelegt wurde, findest du ihn in der Seitenleiste und kannst dort normal weiterarbeiten."
|
||||
);
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
const workingPersons = profile.persons.filter((p) => p.age < p.retirementAge);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="Plan erstellen – geführt"
|
||||
subtitle={`Schritt ${step + 1} von ${STEP_TITLES.length}: ${STEP_TITLES[step]}`}
|
||||
onClose={onClose}
|
||||
wide
|
||||
>
|
||||
{/* Fortschritt */}
|
||||
<div className="flex gap-1">
|
||||
{STEP_TITLES.map((_, i) => (
|
||||
<div key={i} className={`h-1 flex-1 rounded-full ${i <= step ? "bg-accent" : "bg-surface-2"}`} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{step === 0 && (
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="text-sm text-muted">
|
||||
Ein paar Angaben zu dir – daraus baut das Tool dein Grundgerüst. Alles lässt sich später ändern.
|
||||
</p>
|
||||
<TextField label="Name des Plans" value={planName} onChange={setPlanName} placeholder="z. B. Meine Planung" />
|
||||
<PlanProfileFields draft={profile} onChange={setProfile} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 1 && phases && (
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="text-sm text-muted">
|
||||
Deine Planung besteht aus <strong className="text-fg">Lebensphasen</strong> – Abschnitten, in denen deine
|
||||
Finanzen ähnlich laufen. Das Tool schlägt sie dir anhand deines Pensionsalters vor; die Dauer kannst du
|
||||
anpassen.
|
||||
</p>
|
||||
{/* Mini-Vorschau der Kette */}
|
||||
<div className="flex w-full overflow-hidden rounded-lg border border-border">
|
||||
{phases.map((ph, i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{ width: `${(ph.durationYears / phases.reduce((s, x) => s + x.durationYears, 0)) * 100}%` }}
|
||||
className={`px-2 py-1.5 text-[10px] font-semibold ${i === 0 && workingPersons.length > 0 ? "bg-accent/70 text-accent-fg" : "bg-accent-soft text-accent-soft-fg"} ${i > 0 ? "border-l border-border" : ""}`}
|
||||
>
|
||||
<span className="block truncate">{ph.name}</span>
|
||||
<span className="opacity-80">{ph.durationYears} J.</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
{phases.map((ph, i) => (
|
||||
<NumberField
|
||||
key={i}
|
||||
label={`${ph.name} – Dauer (Jahre)`}
|
||||
value={ph.durationYears}
|
||||
min={1}
|
||||
max={80}
|
||||
onChange={(v) =>
|
||||
setPhases((prev) => prev!.map((x, j) => (j === i ? { ...x, durationYears: Math.max(1, Math.round(v)) } : x)))
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-[11px] text-faint">
|
||||
Eine Phase darf keine Pensionierung überspannen – die Dauer der Erwerbsphase ist deshalb ans Pensionsalter
|
||||
gekoppelt und wird beim Anlegen automatisch begrenzt.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 2 && (
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="text-sm text-muted">Die zwei wichtigsten Zahlen deiner Planung – grob geschätzt reicht für den Anfang.</p>
|
||||
{profile.persons.map((p) => (
|
||||
<MoneyField
|
||||
key={p.role}
|
||||
label={`Was verdient ${personLabel(p.role)} netto pro Jahr? (CHF)`}
|
||||
help="Der Betrag, der nach allen Lohnabzügen tatsächlich aufs Konto kommt – nicht der Bruttolohn. Für die AHV-Rente rechnet das Tool intern hoch."
|
||||
value={incomes[p.role] ?? 0}
|
||||
onChange={(v) => setIncomes((prev) => ({ ...prev, [p.role]: v }))}
|
||||
/>
|
||||
))}
|
||||
<MoneyField
|
||||
label="Was gebt ihr heute pro Jahr aus? (CHF)"
|
||||
help="Alle Lebenshaltungskosten in heutiger Kaufkraft – Miete/Wohnen, Essen, Mobilität, Versicherungen, Steuern, Freizeit. Die Inflation rechnet das Tool automatisch dazu."
|
||||
value={expenses}
|
||||
onChange={setExpenses}
|
||||
/>
|
||||
<MoneyField
|
||||
label="Wie viel liegt auf dem Konto? (CHF)"
|
||||
help="Dein heutiger Kontostand (Cash). Er ist der Startwert des Ausgleichskontos."
|
||||
value={initialCash}
|
||||
onChange={setInitialCash}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 3 && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-sm text-muted">
|
||||
Was besitzt du? Hake an, was auf dich zutrifft – nur das Angehakte fragt nach Details.
|
||||
{profile.persons.length > 1 && " Vorsorge (PK/3a) wird Person A zugeordnet – weitere Konten kannst du später ergänzen."}
|
||||
</p>
|
||||
<WizardToggle
|
||||
label="Pensionskasse"
|
||||
help="Dein PK-Guthaben laut Vorsorgeausweis und die jährlichen Einzahlungen (Arbeitnehmer + Arbeitgeber)."
|
||||
checked={toggles.pk}
|
||||
onChange={(v) => setToggles((t) => ({ ...t, pk: v }))}
|
||||
>
|
||||
<MoneyField label="Aktuelles Guthaben (CHF)" value={pk.value} onChange={(v) => setPk((s) => ({ ...s, value: v }))} />
|
||||
<MoneyField label="Einzahlung pro Jahr (CHF)" value={pk.contribution} onChange={(v) => setPk((s) => ({ ...s, contribution: v }))} />
|
||||
</WizardToggle>
|
||||
<WizardToggle
|
||||
label="Säule 3a"
|
||||
help={`Dein 3a-Guthaben und die jährliche Einzahlung (max. ${PILLAR_3A_MAX_ANNUAL.toLocaleString("de-CH")} CHF).`}
|
||||
checked={toggles.p3a}
|
||||
onChange={(v) => setToggles((t) => ({ ...t, p3a: v }))}
|
||||
>
|
||||
<MoneyField label="Aktuelles Guthaben (CHF)" value={p3a.value} onChange={(v) => setP3a((s) => ({ ...s, value: v }))} />
|
||||
<MoneyField label="Einzahlung pro Jahr (CHF)" value={p3a.contribution} max={PILLAR_3A_MAX_ANNUAL} onChange={(v) => setP3a((s) => ({ ...s, contribution: v }))} />
|
||||
</WizardToggle>
|
||||
<WizardToggle
|
||||
label="Wertschriften / ETF / Sparen"
|
||||
help="Depots, Fonds, Sparkonten – alles, was Rendite bringt und nicht Vorsorge ist."
|
||||
checked={toggles.etf}
|
||||
onChange={(v) => setToggles((t) => ({ ...t, etf: v }))}
|
||||
>
|
||||
<MoneyField label="Aktueller Wert (CHF)" value={etf.value} onChange={(v) => setEtf((s) => ({ ...s, value: v }))} />
|
||||
<MoneyField label="Sparbeitrag pro Jahr (CHF)" value={etf.contribution} onChange={(v) => setEtf((s) => ({ ...s, contribution: v }))} />
|
||||
</WizardToggle>
|
||||
<WizardToggle
|
||||
label="Wohneigentum"
|
||||
help="Kaufpreis, Hypothek und jährliche Amortisation gemäss Hypothekarvertrag."
|
||||
checked={toggles.realEstate}
|
||||
onChange={(v) => setToggles((t) => ({ ...t, realEstate: v }))}
|
||||
>
|
||||
<MoneyField label="Kaufpreis (CHF)" value={re.price} onChange={(v) => setRe((s) => ({ ...s, price: v }))} />
|
||||
<MoneyField label="Hypothek (CHF)" value={re.mortgage} onChange={(v) => setRe((s) => ({ ...s, mortgage: v }))} />
|
||||
<MoneyField label="Amortisation pro Jahr (CHF)" value={re.amortization} onChange={(v) => setRe((s) => ({ ...s, amortization: v }))} />
|
||||
<NumberField label="Hypothekarzins (%)" step={0.1} value={re.interest} onChange={(v) => setRe((s) => ({ ...s, interest: v }))} />
|
||||
</WizardToggle>
|
||||
<WizardToggle
|
||||
label="Schulden / Kredite"
|
||||
help="Privatkredite, Autoleasing-Restwert und Ähnliches – ohne die Hypothek (die gehört zum Wohneigentum)."
|
||||
checked={toggles.debt}
|
||||
onChange={(v) => setToggles((t) => ({ ...t, debt: v }))}
|
||||
>
|
||||
<MoneyField label="Restschuld (CHF)" value={debt.value} onChange={(v) => setDebt((s) => ({ ...s, value: v }))} />
|
||||
<MoneyField label="Rückzahlung pro Jahr (CHF)" value={debt.repayment} onChange={(v) => setDebt((s) => ({ ...s, repayment: v }))} />
|
||||
</WizardToggle>
|
||||
<p className="mt-1 text-[11px] text-faint">
|
||||
Die AHV wird automatisch für {profile.persons.length > 1 ? "beide Personen" : "dich"} angelegt – die Rente
|
||||
berechnet das Tool aus deinem Einkommen.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 4 && (
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="text-sm text-muted">Das wird angelegt:</p>
|
||||
<ul className="flex flex-col gap-1.5 rounded-xl border border-border bg-surface-2 p-4 text-sm text-fg">
|
||||
<li>📋 Plan «{planName.trim() || "Meine Planung"}» mit Basisszenario</li>
|
||||
<li>📅 {(phases ?? []).map((p) => `${p.name} (${p.durationYears} J.)`).join(" → ")}</li>
|
||||
<li>
|
||||
💰 {profile.persons.filter((p) => (incomes[p.role] ?? 0) > 0).length > 0 ? "Einkommen, " : ""}
|
||||
{expenses > 0 ? "Ausgaben, " : ""}AHV
|
||||
{toggles.pk ? ", Pensionskasse" : ""}
|
||||
{toggles.p3a ? ", Säule 3a" : ""}
|
||||
{toggles.etf ? ", Wertschriften" : ""}
|
||||
{toggles.realEstate ? ", Wohneigentum" : ""}
|
||||
{toggles.debt ? ", Schulden" : ""}
|
||||
</li>
|
||||
</ul>
|
||||
<p className="text-xs text-muted">
|
||||
Danach zeigt dir eine kurze Tour die Matrix – und die offenen{" "}
|
||||
<strong className="text-fg">Übergangs-Entscheide</strong> (z. B. was bei der Pensionierung mit der PK
|
||||
passiert) gehst du direkt in der Ansicht durch.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <p className="text-sm text-danger">{error}</p>}
|
||||
|
||||
<div className="flex items-center justify-between border-t border-border pt-3">
|
||||
<Button variant="ghost" size="sm" onClick={onClose}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<div className="flex gap-2">
|
||||
{step > 0 && (
|
||||
<Button variant="secondary" onClick={() => setStep(step - 1)} disabled={saving}>
|
||||
<ArrowLeft className="h-4 w-4" /> Zurück
|
||||
</Button>
|
||||
)}
|
||||
{step < STEP_TITLES.length - 1 ? (
|
||||
<Button onClick={goNext}>
|
||||
Weiter <ArrowRight className="h-4 w-4" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={create} disabled={saving}>
|
||||
<Sparkles className="h-4 w-4" /> {saving ? "Wird angelegt…" : "Plan erstellen"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function WizardToggle({
|
||||
label,
|
||||
help,
|
||||
checked,
|
||||
onChange,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
help: string;
|
||||
checked: boolean;
|
||||
onChange: (v: boolean) => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className={`rounded-xl border p-3 transition-colors ${checked ? "border-accent bg-accent-soft/30" : "border-border bg-surface-2"}`}>
|
||||
<label className="flex cursor-pointer items-center gap-2 text-sm font-medium text-fg">
|
||||
<input type="checkbox" checked={checked} onChange={(e) => onChange(e.target.checked)} />
|
||||
{label}
|
||||
<InfoBubble text={help} />
|
||||
</label>
|
||||
{checked && <div className="mt-3 grid grid-cols-1 gap-3 sm:grid-cols-2">{children}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,7 @@ export function ProfileMenu({ username }: { username: string }) {
|
||||
const menuRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- einmalige Übernahme aus localStorage
|
||||
setThemeState(getEffectiveTheme());
|
||||
}, []);
|
||||
|
||||
@@ -74,7 +75,7 @@ export function ProfileMenu({ username }: { username: string }) {
|
||||
|
||||
<MenuItem
|
||||
icon={<KeyRound className="h-4 w-4" />}
|
||||
label="Passwort aendern"
|
||||
label="Passwort ändern"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
setShowPasswordDialog(true);
|
||||
@@ -121,7 +122,7 @@ function ChangePasswordDialog({ onClose }: { onClose: () => void }) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
if (newPassword !== newPasswordConfirm) {
|
||||
setError("Die neuen Passwoerter stimmen nicht ueberein.");
|
||||
setError("Die neuen Passwörter stimmen nicht überein.");
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
@@ -130,7 +131,7 @@ function ChangePasswordDialog({ onClose }: { onClose: () => void }) {
|
||||
setDone(true);
|
||||
setTimeout(onClose, 1200);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Passwort aendern fehlgeschlagen.");
|
||||
setError(err instanceof Error ? err.message : "Passwort ändern fehlgeschlagen.");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -146,12 +147,12 @@ function ChangePasswordDialog({ onClose }: { onClose: () => void }) {
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="flex w-full max-w-sm flex-col gap-3 rounded-2xl border border-border bg-surface p-6 shadow-xl"
|
||||
>
|
||||
<h2 className="text-base font-semibold text-fg">Passwort aendern</h2>
|
||||
<h2 className="text-base font-semibold text-fg">Passwort ändern</h2>
|
||||
<input type="password" placeholder="Aktuelles Passwort" autoComplete="current-password" value={currentPassword} onChange={(e) => setCurrentPassword(e.target.value)} className={inputClass} />
|
||||
<input type="password" placeholder="Neues Passwort" autoComplete="new-password" value={newPassword} onChange={(e) => setNewPassword(e.target.value)} className={inputClass} />
|
||||
<input type="password" placeholder="Neues Passwort bestaetigen" autoComplete="new-password" value={newPasswordConfirm} onChange={(e) => setNewPasswordConfirm(e.target.value)} className={inputClass} />
|
||||
<input type="password" placeholder="Neues Passwort bestätigen" autoComplete="new-password" value={newPasswordConfirm} onChange={(e) => setNewPasswordConfirm(e.target.value)} className={inputClass} />
|
||||
{error && <p className="text-sm text-danger">{error}</p>}
|
||||
{done && <p className="text-sm text-success">Passwort geaendert.</p>}
|
||||
{done && <p className="text-sm text-success">Passwort geändert.</p>}
|
||||
<div className="flex gap-2 pt-1">
|
||||
<button type="submit" disabled={saving} className="rounded-lg bg-accent px-4 py-2 text-sm font-medium text-accent-fg shadow-sm hover:bg-accent-hover disabled:opacity-50">
|
||||
{saving ? "..." : "Speichern"}
|
||||
|
||||
@@ -23,9 +23,9 @@ interface RangeDraft {
|
||||
high: string;
|
||||
}
|
||||
|
||||
// Bewusst OHNE Defaults: Die Balkenlaenge haengt direkt von der eingegebenen Bandbreite ab.
|
||||
// Ein stiller Default wuerde nicht hinterfragt und das Ranking waere dann eine Aussage ueber
|
||||
// unsere Vorgabe statt ueber den Plan (gleiche Begruendung wie bei den Monte-Carlo-Mittelwerten).
|
||||
// Bewusst OHNE Defaults: Die Balkenlänge hängt direkt von der eingegebenen Bandbreite ab.
|
||||
// Ein stiller Default würde nicht hinterfragt und das Ranking wäre dann eine Aussage über
|
||||
// unsere Vorgabe statt über den Plan (gleiche Begründung wie bei den Monte-Carlo-Mittelwerten).
|
||||
const emptyDraft: RangeDraft = { checked: false, low: "", high: "" };
|
||||
|
||||
function formatRange(low: number, high: number, unit: DriverDef["unit"]): string {
|
||||
@@ -60,7 +60,7 @@ export function SensitivityDialog({ plan, onClose }: { plan: PlanInput; onClose:
|
||||
});
|
||||
|
||||
// Mindestens zwei Treiber: Ein Tornado ist eine RANGLISTE. Mit einem einzigen Balken gibt
|
||||
// es nichts zu ordnen und die Grafik wuerde eine Aussage suggerieren, die sie nicht hat.
|
||||
// es nichts zu ordnen und die Grafik würde eine Aussage suggerieren, die sie nicht hat.
|
||||
const canRun =
|
||||
checked.length >= 2 && incomplete.length === 0 && degenerate.length === 0 && plan.phases.length > 0;
|
||||
|
||||
@@ -79,7 +79,7 @@ export function SensitivityDialog({ plan, onClose }: { plan: PlanInput; onClose:
|
||||
|
||||
const chartData = useMemo(() => {
|
||||
if (!result) return [];
|
||||
// Von unten nach oben gezeichnet -> fuer die Trichterform (groesster Balken zuoberst)
|
||||
// Von unten nach oben gezeichnet -> für die Trichterform (grösster Balken zuoberst)
|
||||
// muss die Reihenfolge umgekehrt in die Grafik.
|
||||
return [...result.bars].reverse().map((b) => ({
|
||||
label: b.shortLabel,
|
||||
@@ -103,7 +103,7 @@ export function SensitivityDialog({ plan, onClose }: { plan: PlanInput; onClose:
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Erklaerung */}
|
||||
{/* Erklärung */}
|
||||
<div className="rounded-xl border border-border bg-surface-2 p-4 text-xs leading-relaxed text-muted">
|
||||
<p className="mb-2">
|
||||
<strong className="text-fg">Was ist das?</strong> Nicht «wie viel Geld habe ich am Schluss», sondern
|
||||
@@ -130,7 +130,7 @@ export function SensitivityDialog({ plan, onClose }: { plan: PlanInput; onClose:
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Zielgroesse */}
|
||||
{/* Zielgrösse */}
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||
<SelectField
|
||||
label="Zielgrösse"
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
"use client";
|
||||
|
||||
// Mini-Verlaufskurve für die Matrix-Zeilen: der Trend eines Elements über alle Planjahre
|
||||
// auf einen Blick. Die per-Jahr-Werte existieren seit 0.11 (ElementPhaseComputed.yearly) --
|
||||
// hier werden sie nur gezeichnet, nicht neu gerechnet. Reines SVG, keine Chart-Bibliothek.
|
||||
|
||||
export function Sparkline({
|
||||
values,
|
||||
width = 64,
|
||||
height = 16,
|
||||
className = "",
|
||||
}: {
|
||||
values: number[];
|
||||
width?: number;
|
||||
height?: number;
|
||||
className?: string;
|
||||
}) {
|
||||
if (values.length < 2) return null;
|
||||
const min = Math.min(...values, 0);
|
||||
const max = Math.max(...values, 0);
|
||||
if (max === min) return null; // flache Linie trägt keine Information
|
||||
|
||||
const pad = 1.5;
|
||||
const x = (i: number) => pad + (i / (values.length - 1)) * (width - 2 * pad);
|
||||
const y = (v: number) => pad + (1 - (v - min) / (max - min)) * (height - 2 * pad);
|
||||
const points = values.map((v, i) => `${x(i).toFixed(1)},${y(v).toFixed(1)}`).join(" ");
|
||||
|
||||
return (
|
||||
<svg
|
||||
width={width}
|
||||
height={height}
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
aria-hidden="true"
|
||||
className={`shrink-0 ${className}`}
|
||||
>
|
||||
{/* Nulllinie, falls die Werte das Vorzeichen wechseln (z. B. Schulden). */}
|
||||
{min < 0 && max > 0 && (
|
||||
<line x1={0} x2={width} y1={y(0)} y2={y(0)} stroke="var(--border-strong)" strokeWidth="1" strokeDasharray="2 2" />
|
||||
)}
|
||||
<polyline points={points} fill="none" stroke="var(--accent)" strokeWidth="1.5" strokeLinejoin="round" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -18,8 +18,8 @@ const INCOME_COLOR = "#16a34a";
|
||||
const EXPENSE_COLOR = "#dc2626";
|
||||
const REAL_COLOR = "#9ca3af";
|
||||
|
||||
// Verlauf pro Jahr: Einkommen (nominal, inkl. Renten) vs. nominale Ausgaben; die Flaeche
|
||||
// dazwischen ist die Spar-/Verzehrquote (gruen = Sparen, rot = Verzehr). Reale Ausgaben als
|
||||
// Verlauf pro Jahr: Einkommen (nominal, inkl. Renten) vs. nominale Ausgaben; die Fläche
|
||||
// dazwischen ist die Spar-/Verzehrquote (grün = Sparen, rot = Verzehr). Reale Ausgaben als
|
||||
// blasse Referenzlinie -> der Keil zu den nominalen Ausgaben ist "das, was die Inflation frisst".
|
||||
export function SparquoteChart({ computed }: { computed: PlanComputed }) {
|
||||
if (computed.yearly.length === 0) return <p className="text-sm text-muted">Noch keine Phasen vorhanden.</p>;
|
||||
@@ -31,7 +31,7 @@ export function SparquoteChart({ computed }: { computed: PlanComputed }) {
|
||||
income: y.income,
|
||||
expenseNominal: y.expenseNominal,
|
||||
expenseReal: y.expenseReal,
|
||||
// Range-Flaechen [unten, oben]; nur dort gesetzt, wo zutreffend (sonst Luecke).
|
||||
// Range-Flächen [unten, oben]; nur dort gesetzt, wo zutreffend (sonst Lücke).
|
||||
sparBand: surplus ? [y.expenseNominal, y.income] : undefined,
|
||||
verzehrBand: surplus ? undefined : [y.income, y.expenseNominal],
|
||||
};
|
||||
|
||||
@@ -7,8 +7,8 @@ import rehypeSlug from "rehype-slug";
|
||||
import { FileText } from "lucide-react";
|
||||
import { api } from "@/lib/api-client";
|
||||
|
||||
// Rendert SPEZIFIKATION.md (via /api/spec) als lesbares Dokument. Das Styling laeuft ueber
|
||||
// die Klasse .md-doc in globals.css und folgt damit dem gewaehlten Farbschema.
|
||||
// Rendert SPEZIFIKATION.md (via /api/spec) als lesbares Dokument. Das Styling läuft über
|
||||
// die Klasse .md-doc in globals.css und folgt damit dem gewählten Farbschema.
|
||||
export function SpecView({ anchor }: { anchor?: string | null }) {
|
||||
const [markdown, setMarkdown] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -21,7 +21,7 @@ export function SpecView({ anchor }: { anchor?: string | null }) {
|
||||
}, []);
|
||||
|
||||
// Sprungmarke aus einem Rechenweg: erst nach dem Rendern des Markdowns existiert die
|
||||
// Ueberschrift mit der von rehype-slug erzeugten id.
|
||||
// Überschrift mit der von rehype-slug erzeugten id.
|
||||
useEffect(() => {
|
||||
if (!anchor || markdown === null) return;
|
||||
const el = document.getElementById(anchor);
|
||||
@@ -29,7 +29,7 @@ export function SpecView({ anchor }: { anchor?: string | null }) {
|
||||
}, [anchor, markdown]);
|
||||
|
||||
if (error) return <p className="text-sm text-danger">{error}</p>;
|
||||
if (markdown === null) return <p className="text-sm text-muted">Laedt…</p>;
|
||||
if (markdown === null) return <p className="text-sm text-muted">Lädt…</p>;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
|
||||
@@ -38,7 +38,7 @@ function formatValue(p: SystemParameter): string {
|
||||
}
|
||||
|
||||
// Zeigt die Systemparameter mit Wert, Bedeutung, Herleitung und Quelle (Roadmap Nr. 41).
|
||||
// Die Eintraege stammen direkt aus constants.ts -- dieselbe Datenquelle, aus der auch
|
||||
// Die Einträge stammen direkt aus constants.ts -- dieselbe Datenquelle, aus der auch
|
||||
// gerechnet wird. Ein Abdriften zwischen Anzeige und Rechnung ist damit ausgeschlossen.
|
||||
export function SystemParametersView() {
|
||||
return (
|
||||
|
||||
@@ -13,7 +13,7 @@ interface PersonAxis {
|
||||
|
||||
// Horizontale Zeitachse: die Lebensphasen als aneinandergereihte Segmente (Breite = Dauer),
|
||||
// darunter Alter und -- sofern ein Planstart gesetzt ist -- die Kalenderjahre. Pensionsalter
|
||||
// je Person und ein allfaelliges Ruinalter sind als Marker eingezeichnet.
|
||||
// je Person und ein allfälliges Ruinalter sind als Marker eingezeichnet.
|
||||
export function Timeline({
|
||||
phases,
|
||||
persons,
|
||||
@@ -35,7 +35,7 @@ export function Timeline({
|
||||
const pct = (age: number) => `${(Math.max(0, Math.min(span, age - minAge)) / span) * 100}%`;
|
||||
const yearOf = (yearsFromStart: number) => (startYear ? startYear + yearsFromStart : null);
|
||||
|
||||
// Segmente: Start-/Endjahr (relativ zum Planbeginn) je Phase -- ohne Mutation waehrend
|
||||
// Segmente: Start-/Endjahr (relativ zum Planbeginn) je Phase -- ohne Mutation während
|
||||
// des Renderns, deshalb die kumulierte Summe der vorangehenden Dauern.
|
||||
const segments = phases.map((p, i) => {
|
||||
const from = phases.slice(0, i).reduce((s, x) => s + x.durationYears, 0);
|
||||
@@ -91,7 +91,7 @@ export function Timeline({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Phasen-Segmente: Breite proportional zur Dauer, Einfaerbung nach Phasentyp. */}
|
||||
{/* Phasen-Segmente: Breite proportional zur Dauer, Einfärbung nach Phasentyp. */}
|
||||
<div className="flex w-full overflow-hidden rounded-lg border border-border">
|
||||
{segments.map((s, i) => {
|
||||
const soft = s.phase.type === "PENSION" || s.phase.type === "MIXED";
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
"use client";
|
||||
|
||||
// Interaktive Kurz-Tour über die Planansicht (Teil von Roadmap Nr. 10). Bewusst simpel
|
||||
// gehalten: eine feste Karte unten rechts plus ein pulsierender Rahmen um das jeweilige
|
||||
// Ziel-Element (per data-tour-Attribut) -- kein Spotlight-Overlay, keine Positionierungs-
|
||||
// Engine. Ziele, die es im aktuellen Plan nicht gibt (z. B. Übergänge bei nur einer
|
||||
// Phase), werden zur Laufzeit übersprungen.
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Lightbulb, X } from "lucide-react";
|
||||
import { Button } from "@/components/ui";
|
||||
|
||||
export const TOUR_DONE_KEY = "fpt-tour-done";
|
||||
|
||||
interface TourStep {
|
||||
target: string; // data-tour="…"
|
||||
title: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
const STEPS: TourStep[] = [
|
||||
{
|
||||
target: "profil",
|
||||
title: "Dein Grundprofil",
|
||||
text: "Alter, Pensionsalter und Inflationsannahme. Alles hier gilt für dieses Szenario – über «Profil» änderst du es jederzeit.",
|
||||
},
|
||||
{
|
||||
target: "timeline",
|
||||
title: "Die Zeitachse",
|
||||
text: "Deine Lebensphasen im Überblick: Breite = Dauer, Fähnchen = Pensionierung. Die ganze Planung denkt in diesen Phasen.",
|
||||
},
|
||||
{
|
||||
target: "matrix",
|
||||
title: "Die Matrix – das Herzstück",
|
||||
text: "Zeilen sind deine Finanzen, Spalten deine Lebensphasen. Klick auf eine Zelle: rechts öffnet sich das Bearbeiten-Panel, die Matrix bleibt sichtbar.",
|
||||
},
|
||||
{
|
||||
target: "transition",
|
||||
title: "Übergänge",
|
||||
text: "Zwischen zwei Phasen triffst du einmalige Entscheide – verkaufen, beziehen, verrenten. Orange heisst: noch offen. Klick auf den Spaltenkopf führt dich durch alle Entscheide.",
|
||||
},
|
||||
{
|
||||
target: "cash",
|
||||
title: "Das Cash-Konto",
|
||||
text: "Dein Ausgleichskonto: Was übrig bleibt, landet hier – was fehlt, macht es rot. Negativ = Liquiditätslücke.",
|
||||
},
|
||||
{
|
||||
target: "analysen",
|
||||
title: "Analysen",
|
||||
text: "Grafiken, Monte-Carlo-Simulation und Einflussfaktoren: Wie sicher ist dein Plan, und welche Annahme entscheidet wirklich?",
|
||||
},
|
||||
];
|
||||
|
||||
export function Tour({ onClose }: { onClose: () => void }) {
|
||||
// Nur Schritte, deren Ziel im DOM existiert (hängt vom Plan ab).
|
||||
const steps = useMemo(
|
||||
() => STEPS.filter((s) => document.querySelector(`[data-tour="${s.target}"]`) !== null),
|
||||
[]
|
||||
);
|
||||
const [index, setIndex] = useState(0);
|
||||
const step = steps[index];
|
||||
|
||||
useEffect(() => {
|
||||
if (!step) return;
|
||||
const el = document.querySelector(`[data-tour="${step.target}"]`);
|
||||
if (!el) return;
|
||||
el.classList.add("tour-highlight");
|
||||
el.scrollIntoView({ block: "center", behavior: "smooth" });
|
||||
return () => el.classList.remove("tour-highlight");
|
||||
}, [step]);
|
||||
|
||||
if (!step) return null;
|
||||
|
||||
function finish() {
|
||||
try {
|
||||
localStorage.setItem(TOUR_DONE_KEY, "1");
|
||||
} catch {
|
||||
/* localStorage nicht verfügbar -> Tour erscheint beim nächsten Mal erneut */
|
||||
}
|
||||
onClose();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ui-pop fixed bottom-4 left-1/2 z-40 w-[26rem] max-w-[calc(100vw-2rem)] -translate-x-1/2 rounded-2xl border border-border bg-surface p-4 shadow-2xl">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="flex h-7 w-7 items-center justify-center rounded-lg bg-accent-soft text-accent-soft-fg">
|
||||
<Lightbulb className="h-4 w-4" />
|
||||
</span>
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-fg">{step.title}</div>
|
||||
<div className="text-[11px] text-faint">
|
||||
Schritt {index + 1} von {steps.length}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={finish}
|
||||
aria-label="Tour beenden"
|
||||
className="rounded-md p-1 text-faint hover:bg-surface-2 hover:text-fg"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-2 text-sm leading-relaxed text-muted">{step.text}</p>
|
||||
<div className="mt-3 flex items-center justify-between">
|
||||
<div className="flex gap-1">
|
||||
{steps.map((_, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className={`h-1.5 w-1.5 rounded-full ${i === index ? "bg-accent" : "bg-border-strong"}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{index > 0 && (
|
||||
<Button variant="secondary" size="sm" onClick={() => setIndex(index - 1)}>
|
||||
Zurück
|
||||
</Button>
|
||||
)}
|
||||
{index < steps.length - 1 ? (
|
||||
<Button size="sm" onClick={() => setIndex(index + 1)}>
|
||||
Weiter
|
||||
</Button>
|
||||
) : (
|
||||
<Button size="sm" onClick={finish}>
|
||||
Fertig
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -20,7 +20,7 @@ export interface TimelineSeries {
|
||||
}
|
||||
|
||||
// Datenpunkte je Serie: JEDES Planjahr (nicht nur die Phasengrenzen), verortet auf dem Alter
|
||||
// der Referenzperson (Person A). Der erste Punkt ist das Startvermoegen vor Jahr 1.
|
||||
// der Referenzperson (Person A). Der erste Punkt ist das Startvermögen vor Jahr 1.
|
||||
function pointsFor(computed: PlanComputed) {
|
||||
const phases = computed.phases;
|
||||
if (phases.length === 0) return [] as { age: number; nominal: number; real: number }[];
|
||||
@@ -33,8 +33,8 @@ function pointsFor(computed: PlanComputed) {
|
||||
return pts;
|
||||
}
|
||||
|
||||
// Liniendiagramm: Gesamtvermoegen (nominal + real) ueber das Alter. Unterstuetzt mehrere
|
||||
// ueberlagerte Plaene fuer den Szenario-Vergleich.
|
||||
// Liniendiagramm: Gesamtvermögen (nominal + real) über das Alter. Unterstützt mehrere
|
||||
// überlagerte Pläne für den Szenario-Vergleich.
|
||||
export function WealthChart({ series }: { series: TimelineSeries[] }) {
|
||||
if (series.length === 0 || series[0].computed.phases.length === 0) {
|
||||
return <p className="text-sm text-muted">Noch keine Phasen vorhanden.</p>;
|
||||
|
||||
@@ -0,0 +1,381 @@
|
||||
"use client";
|
||||
|
||||
// UI-Primitiven des Tools. Ein einziger Ort für Buttons, Dialoge, Bestätigungen, Toasts
|
||||
// und Ladezustände -- damit Verhalten (ESC, Fokus-Falle, Animation) und Optik überall
|
||||
// identisch sind, statt in jeder Komponente von Hand nachgebaut zu werden.
|
||||
//
|
||||
// Ersetzt insbesondere window.confirm()/alert(): Browser-Systemdialoge folgen keinem der
|
||||
// drei Farbschemata und wirken wie ein Fremdkörper.
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { AlertTriangle, CheckCircle2, X } from "lucide-react";
|
||||
|
||||
// --- ESC-Verwaltung ----------------------------------------------------------------------
|
||||
// Mehrere Schichten können gleichzeitig offen sein (Inspector + Dialog). ESC soll nur die
|
||||
// OBERSTE schliessen. Jede Schicht registriert sich beim Öffnen; ESC trifft die letzte.
|
||||
const escStack: (() => void)[] = [];
|
||||
let escListenerAttached = false;
|
||||
|
||||
function ensureEscListener() {
|
||||
if (escListenerAttached || typeof window === "undefined") return;
|
||||
escListenerAttached = true;
|
||||
window.addEventListener("keydown", (e) => {
|
||||
if (e.key !== "Escape" || escStack.length === 0) return;
|
||||
e.stopPropagation();
|
||||
escStack[escStack.length - 1]();
|
||||
});
|
||||
}
|
||||
|
||||
function useEscClose(onClose: () => void) {
|
||||
const closeRef = useRef(onClose);
|
||||
useEffect(() => {
|
||||
closeRef.current = onClose;
|
||||
}, [onClose]);
|
||||
useEffect(() => {
|
||||
ensureEscListener();
|
||||
const handler = () => closeRef.current();
|
||||
escStack.push(handler);
|
||||
return () => {
|
||||
const i = escStack.indexOf(handler);
|
||||
if (i >= 0) escStack.splice(i, 1);
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
|
||||
// Einfache Fokus-Falle: beim Öffnen den ersten fokussierbaren Inhalt fokussieren,
|
||||
// Tab bleibt innerhalb der Schicht, beim Schliessen kehrt der Fokus zurück.
|
||||
function useFocusTrap(ref: React.RefObject<HTMLElement | null>) {
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
const previous = document.activeElement as HTMLElement | null;
|
||||
const focusables = () =>
|
||||
[...el.querySelectorAll<HTMLElement>(
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
||||
)].filter((x) => !x.hasAttribute("disabled"));
|
||||
(focusables()[0] ?? el).focus({ preventScroll: true });
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key !== "Tab") return;
|
||||
const f = focusables();
|
||||
if (f.length === 0) return;
|
||||
const first = f[0];
|
||||
const last = f[f.length - 1];
|
||||
if (e.shiftKey && document.activeElement === first) {
|
||||
e.preventDefault();
|
||||
last.focus();
|
||||
} else if (!e.shiftKey && document.activeElement === last) {
|
||||
e.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}
|
||||
el.addEventListener("keydown", onKey);
|
||||
return () => {
|
||||
el.removeEventListener("keydown", onKey);
|
||||
previous?.focus?.({ preventScroll: true });
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
}
|
||||
|
||||
// --- Button ------------------------------------------------------------------------------
|
||||
|
||||
type ButtonVariant = "primary" | "secondary" | "danger" | "ghost";
|
||||
|
||||
const BUTTON_CLASSES: Record<ButtonVariant, string> = {
|
||||
primary:
|
||||
"bg-accent text-accent-fg shadow-sm hover:bg-accent-hover active:scale-[0.98]",
|
||||
secondary:
|
||||
"border border-border text-muted hover:bg-surface-2 hover:text-fg active:scale-[0.98]",
|
||||
danger:
|
||||
"border border-border text-muted hover:border-danger hover:bg-danger-soft hover:text-danger active:scale-[0.98]",
|
||||
ghost: "text-muted hover:bg-surface-2 hover:text-fg",
|
||||
};
|
||||
|
||||
export function Button({
|
||||
variant = "primary",
|
||||
size = "md",
|
||||
className = "",
|
||||
children,
|
||||
...rest
|
||||
}: React.ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||
variant?: ButtonVariant;
|
||||
size?: "sm" | "md";
|
||||
}) {
|
||||
const sizeClass = size === "sm" ? "px-2.5 py-1 text-xs" : "px-4 py-2 text-sm";
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
{...rest}
|
||||
className={`inline-flex items-center justify-center gap-1.5 rounded-lg font-medium transition-all duration-150 disabled:pointer-events-none disabled:opacity-50 ${BUTTON_CLASSES[variant]} ${sizeClass} ${className}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Modal -------------------------------------------------------------------------------
|
||||
|
||||
export function Modal({
|
||||
title,
|
||||
subtitle,
|
||||
onClose,
|
||||
children,
|
||||
wide,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
onClose: () => void;
|
||||
children: React.ReactNode;
|
||||
wide?: boolean;
|
||||
}) {
|
||||
const panelRef = useRef<HTMLDivElement | null>(null);
|
||||
useEscClose(onClose);
|
||||
useFocusTrap(panelRef);
|
||||
return (
|
||||
<div
|
||||
className="ui-fade fixed inset-0 z-40 flex items-start justify-center overflow-y-auto bg-black/40 px-4 py-8"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
ref={panelRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={title}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className={`ui-pop flex w-full ${wide ? "max-w-2xl" : "max-w-md"} flex-col gap-3 rounded-2xl border border-border bg-surface p-6 shadow-xl`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-base font-semibold text-fg">{title}</h2>
|
||||
{subtitle && <p className="mt-0.5 text-xs text-muted">{subtitle}</p>}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Schliessen"
|
||||
className="rounded-md p-1 text-faint transition-colors hover:bg-surface-2 hover:text-fg"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Inspector ---------------------------------------------------------------------------
|
||||
// Rechtes Seitenpanel für Einzel-Bearbeitungen (Zellen, Übergänge, Phase, Profil).
|
||||
// Bewusst OHNE Backdrop: Die Matrix bleibt sichtbar und klickbar -- ein Klick auf eine
|
||||
// andere Zelle wechselt einfach den Panel-Inhalt. Das hält den Kontext, den Modals nehmen.
|
||||
// Auf schmalen Screens deckt das Panel die volle Breite ab.
|
||||
|
||||
export function InspectorShell({
|
||||
title,
|
||||
subtitle,
|
||||
onClose,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
onClose: () => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const panelRef = useRef<HTMLDivElement | null>(null);
|
||||
useEscClose(onClose);
|
||||
return (
|
||||
<aside
|
||||
ref={panelRef}
|
||||
role="complementary"
|
||||
aria-label={title}
|
||||
className="ui-slide-in fixed inset-y-0 right-0 z-30 flex w-full flex-col border-l border-border bg-surface shadow-2xl sm:w-[26rem]"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3 border-b border-border px-5 py-4">
|
||||
<div className="min-w-0">
|
||||
<h2 className="truncate text-base font-semibold text-fg">{title}</h2>
|
||||
{subtitle && <p className="mt-0.5 text-xs text-muted">{subtitle}</p>}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Panel schliessen"
|
||||
className="rounded-md p-1 text-faint transition-colors hover:bg-surface-2 hover:text-fg"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto px-5 py-4">{children}</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Bestätigungs-Dialog (ersetzt window.confirm) ---------------------------------------
|
||||
|
||||
interface ConfirmOptions {
|
||||
title: string;
|
||||
message: string;
|
||||
confirmLabel?: string;
|
||||
danger?: boolean;
|
||||
}
|
||||
|
||||
const ConfirmContext = createContext<((opts: ConfirmOptions) => Promise<boolean>) | null>(null);
|
||||
|
||||
export function useConfirm() {
|
||||
const fn = useContext(ConfirmContext);
|
||||
if (!fn) throw new Error("useConfirm ausserhalb des ConfirmProviders");
|
||||
return fn;
|
||||
}
|
||||
|
||||
export function ConfirmProvider({ children }: { children: React.ReactNode }) {
|
||||
const [pending, setPending] = useState<{
|
||||
opts: ConfirmOptions;
|
||||
resolve: (ok: boolean) => void;
|
||||
} | null>(null);
|
||||
|
||||
const confirm = useCallback(
|
||||
(opts: ConfirmOptions) =>
|
||||
new Promise<boolean>((resolve) => setPending({ opts, resolve })),
|
||||
[]
|
||||
);
|
||||
|
||||
function settle(ok: boolean) {
|
||||
pending?.resolve(ok);
|
||||
setPending(null);
|
||||
}
|
||||
|
||||
return (
|
||||
<ConfirmContext.Provider value={confirm}>
|
||||
{children}
|
||||
{pending && (
|
||||
<Modal title={pending.opts.title} onClose={() => settle(false)}>
|
||||
<p className="text-sm text-muted">{pending.opts.message}</p>
|
||||
<div className="flex gap-2 pt-1">
|
||||
<Button
|
||||
variant={pending.opts.danger ? "danger" : "primary"}
|
||||
autoFocus
|
||||
onClick={() => settle(true)}
|
||||
className={pending.opts.danger ? "border-danger bg-danger-soft text-danger" : ""}
|
||||
>
|
||||
{pending.opts.confirmLabel ?? "Bestätigen"}
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={() => settle(false)}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
</ConfirmContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Toasts (ersetzt alert und stumme Erfolge) -------------------------------------------
|
||||
|
||||
interface ToastItem {
|
||||
id: number;
|
||||
kind: "success" | "error";
|
||||
text: string;
|
||||
}
|
||||
|
||||
const ToastContext = createContext<((kind: ToastItem["kind"], text: string) => void) | null>(null);
|
||||
|
||||
export function useToast() {
|
||||
const fn = useContext(ToastContext);
|
||||
if (!fn) throw new Error("useToast ausserhalb des ToastProviders");
|
||||
return fn;
|
||||
}
|
||||
|
||||
export function ToastProvider({ children }: { children: React.ReactNode }) {
|
||||
const [toasts, setToasts] = useState<ToastItem[]>([]);
|
||||
const nextId = useRef(1);
|
||||
|
||||
const push = useCallback((kind: ToastItem["kind"], text: string) => {
|
||||
const id = nextId.current++;
|
||||
setToasts((prev) => [...prev, { id, kind, text }]);
|
||||
// Fehler bleiben länger stehen als Erfolge.
|
||||
setTimeout(() => setToasts((prev) => prev.filter((t) => t.id !== id)), kind === "error" ? 6000 : 3200);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={push}>
|
||||
{children}
|
||||
<div className="pointer-events-none fixed bottom-4 right-4 z-50 flex w-80 flex-col gap-2">
|
||||
{toasts.map((t) => (
|
||||
<div
|
||||
key={t.id}
|
||||
className={`ui-pop pointer-events-auto flex items-start gap-2 rounded-xl border px-3.5 py-2.5 text-sm shadow-lg ${
|
||||
t.kind === "error"
|
||||
? "border-danger bg-danger-soft text-danger"
|
||||
: "border-border bg-surface text-fg"
|
||||
}`}
|
||||
>
|
||||
{t.kind === "error" ? (
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
) : (
|
||||
<CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0 text-success" />
|
||||
)}
|
||||
<span className="min-w-0 flex-1">{t.text}</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Meldung schliessen"
|
||||
onClick={() => setToasts((prev) => prev.filter((x) => x.id !== t.id))}
|
||||
className="rounded p-0.5 text-faint hover:text-fg"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Ladezustände und leere Zustände ---------------------------------------------------
|
||||
|
||||
export function Skeleton({ className = "" }: { className?: string }) {
|
||||
return <div className={`animate-pulse rounded-lg bg-surface-2 ${className}`} />;
|
||||
}
|
||||
|
||||
// Platzhalter während die Planansicht lädt -- deutet die Struktur an, statt nur
|
||||
// "Lädt…" zu schreiben (wahrgenommene Geschwindigkeit).
|
||||
export function PlanSkeleton() {
|
||||
return (
|
||||
<div className="flex flex-col gap-5" aria-busy="true" aria-label="Lädt">
|
||||
<Skeleton className="h-8 w-64" />
|
||||
<Skeleton className="h-24 w-full" />
|
||||
<Skeleton className="h-12 w-full" />
|
||||
<Skeleton className="h-72 w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function EmptyState({
|
||||
icon,
|
||||
title,
|
||||
text,
|
||||
children,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
text: string;
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-3 rounded-xl border border-dashed border-border bg-surface p-10 text-center">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-accent-soft text-accent-soft-fg">
|
||||
{icon}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-fg">{title}</h3>
|
||||
<p className="mx-auto mt-1 max-w-md text-sm text-muted">{text}</p>
|
||||
</div>
|
||||
{children && <div className="mt-1 flex flex-wrap justify-center gap-2">{children}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user