UI-Gesamtumbau: Du-Form, Inspector-Panel, Onboarding-Assistent, Tour, Palette
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:
2026-07-19 07:40:42 +02:00
parent e1f74fca95
commit c2fb82b0de
60 changed files with 2915 additions and 1210 deletions
+411 -218
View File
@@ -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>
);
}
+126
View File
@@ -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>
);
}
+8 -8
View File
@@ -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 &ndash; 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 &amp; Ende)
Vermögensaufteilung pro Phase (Beginn &amp; 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 &ndash; die Aufteilung kann durch Umschichtung abweichen.
Gesamtvolumen dem Beginn der nächsten &ndash; die Aufteilung kann durch Umschichtung abweichen.
</p>
<div className="h-72 w-full">
<ResponsiveContainer width="100%" height="100%">
+15 -15
View File
@@ -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 },
]}
+91 -91
View File
@@ -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 &quot;Vollstaendig bezogen&quot;.
Die PK-Rente wird aus dem beim Pensions-Übergang gewählten Umwandlungssatz berechnet (siehe
Kennzahl). Bei reinem Kapitalbezug erscheint hier &quot;Vollständig bezogen&quot;.
</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} />
+5 -5
View File
@@ -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);
+12 -12
View File
@@ -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);
+5 -5
View File
@@ -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}
+4 -4
View File
@@ -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 })}
File diff suppressed because it is too large Load Diff
+419
View File
@@ -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>
);
}
+7 -6
View File
@@ -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"}
+7 -7
View File
@@ -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"
+43
View File
@@ -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>
);
}
+3 -3
View File
@@ -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],
};
+4 -4
View File
@@ -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">
+1 -1
View File
@@ -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 (
+3 -3
View File
@@ -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";
+135
View File
@@ -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>
);
}
+3 -3
View File
@@ -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>;
+381
View File
@@ -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>
);
}