Files
FPT/src/components/AppShell.tsx
T
admGitAICDS 96fdd00d5c
Deploy App / deploy (push) Successful in 1m1s
Tour-Korrekturen (Spotlight, Schritte) + 3a-Schalter nach Schritt 5
Tour:
- Spotlight neu aus vier fixed-Abdunkelflaechen um die Bounding-Box statt
  box-shadow -- funktioniert jetzt unabhaengig von z-index (sticky
  Matrix-Koepfe blieben hell) und overflow (im Matrix-Scrollbereich blieb
  fast alles hell); folgt dem Ziel per requestAnimationFrame
- Schritte ueberarbeitet: Zeitachse + Endvermoegen neu erklaert; der
  vormalige "Analysen"-Schritt beschreibt jetzt die obere Funktions-Leiste;
  neuer Schritt fuer das linke Menue (Analysen/Berichte/Effektive Werte);
  unsichtbare Ziele werden uebersprungen

Wizard:
- 3a-Schalter "Selbststaendig ohne PK" von Schritt 4 nach Schritt 5
  (er betrifft die Einzahlung / Sparraten-Verteilung)

SPEZIFIKATION 0.30 (9.24 neu gefasst). 267 Tests unveraendert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 22:59:51 +02:00

1206 lines
47 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
BarChart3,
BookOpen,
ChevronDown,
ChevronRight,
Copy,
FileText,
FileSpreadsheet,
FolderKanban,
CalendarClock,
GitBranch,
HelpCircle,
History,
Download,
Layers,
LayoutDashboard,
Menu,
PiggyBank,
Plus,
Search,
SlidersHorizontal,
Sparkles,
Trash2,
X,
} from "lucide-react";
import { PlanView } from "@/components/PlanView";
import { Dashboard } from "@/components/Dashboard";
import { MonteCarloDialog } from "@/components/MonteCarloDialog";
import { SensitivityDialog } from "@/components/SensitivityDialog";
import { LiveSimDialog } from "@/components/LiveSimDialog";
import { ActualsDialog } from "@/components/ActualsDialog";
import { PlanDashboardView, ScenarioListView, AnalysesView } from "@/components/PlanViews";
import { SavedAnalysisView } from "@/components/SavedAnalysisView";
import { ReportsView } from "@/components/ReportsView";
import { buildViews } from "@/lib/dataview";
import type { ActualsSetInput, ElementOrigin } from "@/lib/actuals";
import { VersionHistoryDialog } from "@/components/VersionHistoryDialog";
import { SpecView } from "@/components/SpecView";
import { SystemParametersView } from "@/components/SystemParametersView";
import { PlanTraceDialog } from "@/components/DetailView";
import { computePlan } from "@/lib/calculations";
import { ProfileMenu } from "@/components/ProfileMenu";
import { PlanWizard } from "@/components/PlanWizard";
import { Tour, TOUR_DONE_KEY } from "@/components/Tour";
import { CommandPalette, type PaletteAction } from "@/components/CommandPalette";
import { PlanProfileFields, emptyProfileDraft, type ProfileDraft } from "@/components/PlanProfileFields";
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";
interface ScenarioDetail {
plan: PlanInput; // das Szenario selbst (berechenbare Einheit)
computed: PlanComputed;
base: PlanInput | null; // Eltern-Szenario als Vergleichsbasis
meta: ScenarioMeta & { planName: string };
// Effektive Werte des PLANS plus die Element-Herkunft aller Szenarien -- daraus entsteht
// im Browser der zweite Rechenlauf (siehe lib/dataview.ts).
actuals: ActualsSetInput[];
elementOrigins: ElementOrigin[];
}
// 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 [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);
const [showMonteCarlo, setShowMonteCarlo] = useState(false);
const [showSensitivity, setShowSensitivity] = useState(false);
const [showLiveSim, setShowLiveSim] = useState(false);
const [showHistory, setShowHistory] = useState(false);
const [showActuals, setShowActuals] = useState(false);
// Plan-Ebene: Dashboard / Szenarien-Liste / Analysen. Null = kein Plan-View aktiv.
const [planNav, setPlanNav] = useState<{ planId: string; tab: "dashboard" | "scenarios" | "actuals" | "analyses" | "reports" } | null>(null);
// Welche Szenario-Bäume in der Seitenleiste aufgeklappt sind. Standard: eingeklappt.
const [expandedTrees, setExpandedTrees] = useState<Record<string, boolean>>({});
const [savedAnalysisId, setSavedAnalysisId] = useState<string | null>(null);
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);
// Tour (seit dem Layout-Umbau hier statt in PlanView -- sie liest die data-tour-Ziele im
// DOM der Szenario-Ansicht). Zwei Auslöser:
// * pendingTourRef: nach JEDER Plan-Erstellung genau einmal erzwingen (Roadmap-Feedback F14).
// * Erst-Öffnen eines Plans mit Phasen, solange die Tour noch nie beendet wurde.
const [showTour, setShowTour] = useState(false);
const pendingTourRef = useRef(false);
const autoTourCheckedRef = useRef<Set<string>>(new Set());
useEffect(() => {
if (!detail || detail.plan.phases.length === 0) return;
const sid = detail.meta.id;
if (pendingTourRef.current) {
pendingTourRef.current = false;
autoTourCheckedRef.current.add(sid);
setShowTour(true);
return;
}
if (autoTourCheckedRef.current.has(sid)) return; // pro Szenario nur einmal, nicht bei Refresh
autoTourCheckedRef.current.add(sid);
try {
// eslint-disable-next-line react-hooks/set-state-in-effect -- einmalige Initialisierung aus localStorage
if (!localStorage.getItem(TOUR_DONE_KEY)) setShowTour(true);
} catch {
/* localStorage nicht verfügbar */
}
}, [detail]);
function openSpecAt(anchor: string) {
setSpecAnchor(anchor);
setShowSpec(true);
setShowSystemParams(false);
setSelectedScenarioId(null);
setPlanNav(null);
}
const loadPlans = useCallback(async () => {
const data = await api.get<{ plans: PlanListItem[] }>("/api/plans");
setPlans(data.plans);
return data.plans;
}, []);
// silent = Hintergrund-Refresh ohne Loading-Umschaltung: die PlanView bleibt montiert,
// 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 {
const d = await api.get<ScenarioDetail>(`/api/scenarios/${scenarioId}`);
setDetail(d);
return d;
} finally {
if (!silent) setLoading(false);
}
}, []);
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- asynchroner Datenabruf beim Mount
loadPlans().finally(() => setLoading(false));
}, [loadPlans]);
useEffect(() => {
if (selectedScenarioId) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- asynchroner Datenabruf
loadDetail(selectedScenarioId);
}
// Kein `else setDetail(null)`: In der Plan-Ebene (planNav) wird `detail` fürs Basisszenario
// geladen, damit die Werkzeuge starten können -- das darf hier nicht weggeräumt werden.
}, [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);
}
function openScenario(id: string) {
setSelectedScenarioId(id);
setPlanNav(null);
setShowSpec(false);
setShowSystemParams(false);
setSidebarOpen(false);
}
// Plan-Ebene öffnen (Dashboard / Szenarien / Analysen). Räumt die Szenario- und Wissens-
// Ansichten weg -- es kann immer nur eine Hauptansicht aktiv sein.
function openPlanTab(planId: string, tab: "dashboard" | "scenarios" | "actuals" | "analyses" | "reports") {
setPlanNav({ planId, tab });
setSelectedScenarioId(null);
setShowSpec(false);
setShowSystemParams(false);
setSidebarOpen(false);
}
// Ein Analysewerkzeug aus der Analysen-Ansicht heraus starten. Die Werkzeuge arbeiten auf
// einem Szenario -- ohne ausgewähltes nehmen wir das Basisszenario; im Werkzeug selbst
// lässt sich Szenario/Version weiterhin umstellen.
async function launchTool(planId: string, kind: "CHART" | "MONTE_CARLO" | "SENSITIVITY" | "LIVESIM") {
const plan = plans.find((p) => p.id === planId);
const baseId = plan?.scenarios.find((s) => s.isBase)?.id ?? plan?.scenarios[0]?.id;
if (!baseId) return;
if (detail?.meta.id !== baseId) await loadDetail(baseId, true);
if (kind === "CHART") setShowCharts(true);
else if (kind === "MONTE_CARLO") setShowMonteCarlo(true);
else if (kind === "SENSITIVITY") setShowSensitivity(true);
else setShowLiveSim(true);
}
// Effektive-Werte-Ansicht (Plan-Ebene, eingebettet). Lädt das Basisszenario, das der
// eingebettete Dialog als Ausgangspunkt braucht, und öffnet dann den Tab.
async function openActualsTab(planId: string) {
const plan = plans.find((p) => p.id === planId);
const baseId = plan?.scenarios.find((s) => s.isBase)?.id ?? plan?.scenarios[0]?.id;
if (baseId && detail?.meta.id !== baseId) await loadDetail(baseId, true);
openPlanTab(planId, "actuals");
}
async function handleDeletePlan(id: string) {
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);
}
}
async function handleDeleteScenario(s: ScenarioMeta) {
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) {
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);
pendingTourRef.current = true; // Tour bei jeder Plan-Erstellung (F14)
openScenario(sid);
toast("success", "Beispielplan angelegt die Tour zeigt dir gleich die wichtigsten Stellen.");
} catch (e) {
toast("error", e instanceof Error ? e.message : "Beispielplan konnte nicht angelegt werden.");
} finally {
setCreatingDemo(false);
}
}
const activePlan =
plans.find((p) => p.scenarios.some((s) => s.id === selectedScenarioId)) ??
(planNav ? plans.find((p) => p.id === planNav.planId) ?? null : null);
// Plan-Sicht und Ist-Sicht in einem Zug. Ohne erfasste Ist-Werte bleibt `actual` null und
// die Oberflaeche verhaelt sich exakt wie bisher.
const views = useMemo(
() =>
detail ? buildViews(detail.plan, detail.actuals ?? [], detail.elementOrigins ?? []) : null,
[detail]
);
// 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); setPlanNav(null); setShowSpec(false); setShowSystemParams(false); } },
{ id: "a-spec", label: "So rechnet FPT (Spezifikation)", hint: "Aktion", run: () => { setShowSpec(true); setSpecAnchor(null); setShowSystemParams(false); setSelectedScenarioId(null); setPlanNav(null); } },
{ id: "a-params", label: "Systemparameter", hint: "Aktion", run: () => { setShowSystemParams(true); setShowSpec(false); setSelectedScenarioId(null); setPlanNav(null); } },
];
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">
<div className="flex h-8 w-8 items-center justify-center rounded-xl bg-accent">
<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">
<button
type="button"
onClick={() => {
setSelectedScenarioId(null);
setPlanNav(null);
setShowSpec(false);
setShowSystemParams(false);
setSidebarOpen(false);
}}
className={`flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium transition-colors ${
selectedScenarioId === null && !planNav && !showSpec && !showSystemParams ? "bg-accent-soft text-accent-soft-fg" : "text-muted hover:bg-surface-2"
}`}
>
<LayoutDashboard className="h-4 w-4" />
Übersicht
</button>
<div className="mt-4 flex items-center justify-between px-3">
<span className="text-[11px] font-semibold uppercase tracking-wide text-faint">Meine Pläne</span>
<button
type="button"
onClick={() => setShowNewPlanChooser(true)}
aria-label="Neuen Plan erstellen"
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 Pläne.</p>}
{plans.map((p) => {
const navHere = planNav?.planId === p.id;
// Tour-Ziel «menu»: die Menü-Gruppe des gerade offenen Plans.
const isActivePlan = p.scenarios.some((s) => s.id === selectedScenarioId);
const subItem = (tab: "dashboard" | "scenarios" | "actuals" | "analyses" | "reports", label: string, Icon: typeof FolderKanban) => (
<button
type="button"
onClick={() => openPlanTab(p.id, tab)}
className={`flex w-full items-center gap-2 rounded-lg py-1.5 pl-8 pr-3 text-left text-xs font-medium transition-colors ${
navHere && planNav?.tab === tab ? "bg-accent-soft text-accent-soft-fg" : "text-muted hover:bg-surface-2"
}`}
>
<Icon className="h-3.5 w-3.5 shrink-0" />
{label}
</button>
);
return (
<div key={p.id} className="mb-1" data-tour={isActivePlan ? "menu" : undefined}>
{/* Plan-Name: Klick öffnet das Plan-Dashboard. */}
<div className="group flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-semibold text-fg">
<button
type="button"
onClick={() => openPlanTab(p.id, "dashboard")}
className="flex min-w-0 flex-1 items-center gap-1.5 text-left"
title={p.name}
>
<FolderKanban className={`h-3.5 w-3.5 shrink-0 ${navHere && planNav?.tab === "dashboard" ? "text-accent" : "text-faint"}`} />
<span className="min-w-0 flex-1 truncate">{p.name}</span>
</button>
<button
type="button"
aria-label="Plan löschen"
onClick={() => handleDeletePlan(p.id)}
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>
</div>
{/* «Szenarien»: Chevron klappt den Baum auf/zu (Standard: zu), das Label führt
in die Szenario-Liste. */}
<div
className={`group flex w-full items-center gap-1 rounded-lg py-1.5 pr-3 pl-1 text-xs font-medium transition-colors ${
navHere && planNav?.tab === "scenarios" ? "bg-accent-soft text-accent-soft-fg" : "text-muted hover:bg-surface-2"
}`}
>
<button
type="button"
aria-label={expandedTrees[p.id] ? "Szenarien einklappen" : "Szenarien aufklappen"}
onClick={() => setExpandedTrees((prev) => ({ ...prev, [p.id]: !prev[p.id] }))}
className="rounded p-0.5 text-faint hover:text-fg"
>
{expandedTrees[p.id] ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
</button>
<button
type="button"
onClick={() => openPlanTab(p.id, "scenarios")}
className="flex min-w-0 flex-1 items-center gap-2 text-left"
>
<Layers className="h-3.5 w-3.5 shrink-0" />
Szenarien
</button>
</div>
{/* Der Baum erscheint nur aufgeklappt -- alle Szenarien gleich eingerückt. */}
{expandedTrees[p.id] && (
<ScenarioTree
scenarios={p.scenarios}
selectedId={selectedScenarioId}
onSelect={openScenario}
onCopy={setCopyFrom}
onDelete={handleDeleteScenario}
/>
)}
<button
type="button"
onClick={() => void openActualsTab(p.id)}
className={`flex w-full items-center gap-2 rounded-lg py-1.5 pl-8 pr-3 text-left text-xs font-medium transition-colors ${
navHere && planNav?.tab === "actuals" ? "bg-accent-soft text-accent-soft-fg" : "text-muted hover:bg-surface-2"
}`}
>
<CalendarClock className="h-3.5 w-3.5 shrink-0" />
Effektive Werte
</button>
{subItem("analyses", "Analysen", BarChart3)}
{subItem("reports", "Berichte", FileSpreadsheet)}
</div>
);
})}
<div className="mt-4 flex flex-col gap-1 border-t border-border pt-3">
<span className="px-3 pb-1 text-[11px] font-semibold uppercase tracking-wide text-faint">Wissen</span>
<button
type="button"
onClick={() => {
setShowSpec(true);
setSpecAnchor(null);
setShowSystemParams(false);
setSelectedScenarioId(null);
setPlanNav(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 ${
showSpec ? "bg-accent-soft text-accent-soft-fg" : "text-muted hover:bg-surface-2"
}`}
>
<FileText className="h-4 w-4 shrink-0" />
So rechnet FPT
</button>
<button
type="button"
onClick={() => {
setShowSystemParams(true);
setShowSpec(false);
setSelectedScenarioId(null);
setPlanNav(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>
</div>
);
const diff = detail ? computeScenarioDiff(detail.plan, detail.base) : null;
return (
<div className="flex min-h-screen w-full">
<aside className="hidden w-64 shrink-0 border-r border-border bg-surface lg:block">{sidebar}</aside>
{sidebarOpen && (
<div className="fixed inset-0 z-40 lg:hidden">
<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="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" />
</button>
{sidebar}
</aside>
</div>
)}
<div className="flex min-w-0 flex-1 flex-col">
<header className="flex items-center gap-3 border-b border-border bg-surface px-4 py-3">
<button
type="button"
onClick={() => setSidebarOpen(true)}
aria-label="Menü öffnen"
className="rounded-lg border border-border p-2 text-muted lg:hidden"
>
<Menu className="h-4 w-4" />
</button>
<h1 className="min-w-0 flex-1 truncate text-base font-semibold text-fg">
{showSystemParams
? "Systemparameter"
: showSpec
? "So rechnet FPT"
: planNav
? `${activePlan?.name ?? "Plan"} · ${
{ dashboard: "Dashboard", scenarios: "Szenarien", actuals: "Effektive Werte", analyses: "Analysen", reports: "Berichte" }[
planNav.tab
]
}`
: selectedScenarioId && detail
? `${detail.meta.planName} · ${detail.meta.name}`
: "Übersicht"}
</h1>
<ProfileMenu username={username} />
</header>
<main className="flex-1 px-4 py-6 lg:px-8">
{showSystemParams && <SystemParametersView />}
{showSpec && <SpecView anchor={specAnchor} />}
{!showSpec && !showSystemParams && loading && !planNav && <PlanSkeleton />}
{/* Plan-Ebene: Dashboard / Szenarien / Analysen */}
{!showSpec && !showSystemParams && planNav?.tab === "dashboard" && (
<PlanDashboardView planId={planNav.planId} />
)}
{!showSpec && !showSystemParams && planNav?.tab === "scenarios" && (
<ScenarioListView
planId={planNav.planId}
onOpenMatrix={openScenario}
onOpenHistory={(sid) => {
void loadDetail(sid, true).then(() => setShowHistory(true));
}}
onNew={() => {
const base = activePlan?.scenarios.find((s) => s.isBase) ?? activePlan?.scenarios[0];
if (base) setCopyFrom({ id: base.id, planId: base.planId, planName: activePlan?.name ?? "", name: base.name, isBase: base.isBase, parentScenarioId: base.parentScenarioId } as ScenarioMeta);
}}
/>
)}
{!showSpec && !showSystemParams && planNav?.tab === "actuals" && activePlan && (
detail && detail.meta.planId === planNav.planId ? (
<ActualsDialog
key={planNav.planId}
embedded
planId={planNav.planId}
planName={activePlan.name}
scenarioMetas={activePlan.scenarios}
initial={{ id: detail.meta.id, name: detail.meta.name, isBase: detail.meta.isBase, plan: detail.plan }}
onClose={() => {}}
onChanged={refreshCurrent}
/>
) : (
<p className="text-sm text-muted">Wird geladen</p>
)
)}
{!showSpec && !showSystemParams && planNav?.tab === "analyses" && (
<AnalysesView
planId={planNav.planId}
onLaunch={(kind) => void launchTool(planNav.planId, kind)}
onOpenSaved={(id) => setSavedAnalysisId(id)}
/>
)}
{!showSpec && !showSystemParams && planNav?.tab === "reports" && activePlan && (
<ReportsView
planId={planNav.planId}
scenarios={activePlan.scenarios.map((s) => ({ id: s.id, name: s.name, isBase: s.isBase }))}
hasActuals={(activePlan._count?.actuals ?? 0) > 0}
/>
)}
{!showSpec && !showSystemParams && !loading && !planNav && selectedScenarioId === null && (
<DashboardHome
username={username}
plans={plans}
creatingDemo={creatingDemo}
onOpenPlan={(id) => openPlanTab(id, "dashboard")}
onCreateGuided={() => setShowWizard(true)}
onCreateChooser={() => setShowNewPlanChooser(true)}
onCreateDemo={handleCreateDemo}
onDelete={handleDeletePlan}
/>
)}
{!showSpec && !showSystemParams && !loading && detail && selectedScenarioId && (
<div className="flex flex-col gap-6">
{/* Funktions-Buttons der Szenario-Ansicht. Grafiken/Effektive Werte/Live-Sim/
Monte-Carlo/Einflussfaktoren sind hier bewusst NICHT mehr -- sie laufen über
die eigenen Menüpunkte (Analysen / Effektive Werte). Hier bleiben die reinen
Szenario-Aktionen plus Tour und CSV-Export. */}
<div className="flex flex-wrap items-center gap-2" data-tour="toolbar">
{detail.plan.phases.length > 0 && (
<Button variant="secondary" onClick={() => setShowHistory(true)}>
<History className="h-4 w-4" />
Änderungshistorie
</Button>
)}
{detail.plan.phases.length > 0 && (
<Button variant="secondary" onClick={() => setShowTour(true)} title="Kurze Tour durch die Ansicht">
<HelpCircle className="h-4 w-4" />
Tour
</Button>
)}
<Button variant="secondary" onClick={() => setCopyFrom(detail.meta)}>
<Copy className="h-4 w-4" />
Neues Szenario aus diesem
</Button>
{detail.plan.phases.length > 0 && (
<Button
variant="secondary"
onClick={() => setShowPlanTraces(true)}
title="Wie wird gerechnet? Plan-weite Grössen wie Deflatoren, AHV-Karriere und Ruinalter"
>
<BookOpen className="h-4 w-4" />
Rechenwege
</Button>
)}
{detail.plan.phases.length > 0 && (
<a
href={`/api/scenarios/${detail.meta.id}/export`}
className="inline-flex items-center justify-center gap-1.5 rounded-lg border border-border bg-surface px-4 py-2 text-sm font-medium text-muted transition-all duration-150 hover:bg-surface-2 hover:text-fg"
>
<Download className="h-4 w-4" />
CSV-Export
</a>
)}
{!detail.meta.isBase && (
<Button variant="danger" onClick={() => handleDeleteScenario(detail.meta)}>
<Trash2 className="h-4 w-4" />
Szenario löschen
</Button>
)}
{diff && detail.base && (
<span className="ml-auto flex items-center gap-1.5 rounded-lg border border-diff bg-diff-soft px-3 py-1.5 text-xs font-medium text-diff">
<GitBranch className="h-3.5 w-3.5" />
{diff.total === 0
? "Unverändert gegenüber der Vorlage"
: `${diff.total} Abweichung${diff.total === 1 ? "" : "en"} gegenüber der Vorlage`}
</span>
)}
</div>
<PlanView
plan={detail.plan}
computed={detail.computed}
actualComputed={views?.actual ?? null}
actualYears={views?.actualYears ?? []}
diff={diff}
onChanged={refreshCurrent}
onOpenSpec={openSpecAt}
onOpenSensitivity={() => setShowSensitivity(true)}
/>
</div>
)}
</main>
</div>
{showNewPlanChooser && (
<NewPlanChooser
creatingDemo={creatingDemo}
onGuided={() => {
setShowNewPlanChooser(false);
setShowWizard(true);
}}
onEmpty={() => {
setShowNewPlanChooser(false);
setShowEmptyPlanDialog(true);
}}
onDemo={handleCreateDemo}
onClose={() => setShowNewPlanChooser(false)}
/>
)}
{showWizard && (
<PlanWizard
onCreated={async (sid) => {
setShowWizard(false);
await loadPlans();
pendingTourRef.current = true; // Tour bei jeder Plan-Erstellung (F14)
openScenario(sid);
toast("success", "Plan erstellt die Tour zeigt dir gleich die wichtigsten Stellen.");
}}
onClose={() => setShowWizard(false)}
/>
)}
{showEmptyPlanDialog && (
<PlanDialog
onCreate={async (name, profile) => {
const { scenario } = await api.post<{ plan: { id: string }; scenario: { id: string } }>(
"/api/plans",
{ name, ...profile }
);
setShowEmptyPlanDialog(false);
await loadPlans();
pendingTourRef.current = true; // Tour bei jeder Plan-Erstellung (F14)
openScenario(scenario.id);
toast("success", "Plan erstellt die Tour zeigt dir gleich die wichtigsten Stellen.");
}}
onClose={() => setShowEmptyPlanDialog(false)}
/>
)}
{showCharts && detail && (
<ChartsDialog onClose={() => setShowCharts(false)} title={`Grafiken · ${detail.meta.name}`}>
<Dashboard
plan={detail.plan}
planId={detail.meta.planId}
computed={detail.computed}
siblings={(activePlan?.scenarios ?? []).filter((s) => s.id !== detail.meta.id)}
actuals={detail.actuals ?? []}
origins={detail.elementOrigins ?? []}
/>
</ChartsDialog>
)}
{showMonteCarlo && detail && (
<MonteCarloDialog
plan={detail.plan}
computed={detail.computed}
meta={detail.meta}
scenarios={activePlan?.scenarios ?? [detail.meta]}
actuals={detail.actuals ?? []}
origins={detail.elementOrigins ?? []}
onClose={() => setShowMonteCarlo(false)}
/>
)}
{showSensitivity && detail && (
<SensitivityDialog
plan={detail.plan}
planId={detail.meta.planId}
actuals={detail.actuals ?? []}
origins={detail.elementOrigins ?? []}
onClose={() => setShowSensitivity(false)}
/>
)}
{showLiveSim && detail && (
<LiveSimDialog
plan={detail.plan}
actuals={detail.actuals ?? []}
origins={detail.elementOrigins ?? []}
onClose={() => setShowLiveSim(false)}
/>
)}
{savedAnalysisId && planNav && (
<SavedAnalysisView
planId={planNav.planId}
analysisId={savedAnalysisId}
onClose={() => setSavedAnalysisId(null)}
/>
)}
{showActuals && detail && activePlan && (
<ActualsDialog
planId={detail.meta.planId}
planName={detail.meta.planName}
scenarioMetas={activePlan.scenarios}
initial={{ id: detail.meta.id, name: detail.meta.name, isBase: detail.meta.isBase, plan: detail.plan }}
onClose={() => setShowActuals(false)}
onChanged={refreshCurrent}
/>
)}
{showHistory && detail && (
<VersionHistoryDialog
scenarioId={detail.meta.id}
scenarioName={detail.meta.name}
onClose={() => setShowHistory(false)}
onRestored={refreshCurrent}
/>
)}
{showPlanTraces && detail && (
<PlanTraceDialog
computed={computePlan(detail.plan, undefined, { explain: true })}
onClose={() => setShowPlanTraces(false)}
onOpenSpec={openSpecAt}
/>
)}
{copyFrom && (
<CopyScenarioDialog
source={copyFrom}
onClose={() => setCopyFrom(null)}
onCreate={async (name) => {
const { scenarioId } = await api.post<{ scenarioId: string }>(
`/api/scenarios/${copyFrom.id}/copy`,
{ name }
);
setCopyFrom(null);
await loadPlans();
openScenario(scenarioId);
toast("success", "Szenario erstellt.");
}}
/>
)}
<CommandPalette
open={showPalette}
onClose={() => setShowPalette(false)}
plans={plans}
actions={paletteActions}
onOpenScenario={openScenario}
/>
{showTour && <Tour onClose={() => setShowTour(false)} />}
</div>
);
}
// 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,
onClose,
}: {
title: string;
children: React.ReactNode;
onClose: () => void;
}) {
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
onClick={(e) => e.stopPropagation()}
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">
<BarChart3 className="h-5 w-5 text-accent" /> {title}
</h2>
<button type="button" onClick={onClose} aria-label="Schliessen" className="rounded-md p-1 text-faint hover:bg-surface-2">
<X className="h-4 w-4" />
</button>
</div>
{children}
</div>
</div>
);
}
// 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,
selectedId,
onSelect,
onCopy,
onDelete,
}: {
scenarios: ScenarioMeta[];
selectedId: string | null;
onSelect: (id: string) => void;
onCopy: (s: ScenarioMeta) => void;
onDelete: (s: ScenarioMeta) => void;
}) {
// Flache, gleichmässig eingerückte Liste: Basisszenario zuoberst, danach die Varianten.
// Die Herkunfts-Verschachtelung wird in der Szenario-Liste (Spalte «aus …») gezeigt, nicht
// mehr durch die Einrückung in der Seitenleiste.
const ordered = [...scenarios].sort((a, b) => (a.isBase === b.isBase ? 0 : a.isBase ? -1 : 1));
if (ordered.length === 0) return null;
return (
<>
{ordered.map((s) => (
<div
key={s.id}
className={`group flex items-center gap-1.5 rounded-lg py-1.5 pr-1.5 pl-9 text-sm transition-colors ${
selectedId === s.id ? "bg-accent-soft font-medium text-accent-soft-fg" : "text-muted hover:bg-surface-2"
}`}
>
<button type="button" onClick={() => onSelect(s.id)} className="flex min-w-0 flex-1 items-center gap-1.5 text-left">
{s.isBase ? <PiggyBank className="h-3.5 w-3.5 shrink-0" /> : <GitBranch className="h-3.5 w-3.5 shrink-0" />}
<span className="min-w-0 flex-1 truncate" title={s.name}>{s.name}</span>
</button>
<button
type="button"
aria-label="Kopie erstellen"
title="Neues Szenario aus diesem"
onClick={() => onCopy(s)}
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 löschen"
onClick={() => onDelete(s)}
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>
)}
</div>
))}
</>
);
}
// Startansicht: Begrüssung + Plan-Kacheln (Klick öffnet das Basisszenario).
function DashboardHome({
username,
plans,
creatingDemo,
onOpenPlan,
onCreateGuided,
onCreateChooser,
onCreateDemo,
onDelete,
}: {
username: string;
plans: PlanListItem[];
creatingDemo: boolean;
onOpenPlan: (planId: string) => void;
onCreateGuided: () => void;
onCreateChooser: () => void;
onCreateDemo: () => void;
onDelete: (id: string) => void;
}) {
return (
<div className="flex flex-col gap-6">
<div>
<h2 className="text-xl font-semibold text-fg">Willkommen, {username}</h2>
<p className="mt-1 text-sm text-muted">
Wähle einen Plan oder erstelle einen neuen. Jeder Plan enthält ein Basisszenario und beliebig
viele Varianten davon.
</p>
</div>
{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."
>
<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 others = p.scenarios.length - 1;
const persons = (p.persons ?? [])
.map((pe) => pe.name || (pe.role === "PERSON_A" ? "Person A" : "Person B"))
.join(" · ");
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={() => onOpenPlan(p.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">
{persons || "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>
{/* Ein paar Kennzahlen pro Plan direkt in der Übersicht. */}
<div className="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-[11px] text-muted">
<span>{p.scenarios.length} Szenario{p.scenarios.length === 1 ? "" : "s"}</span>
{p.startYear && <span>ab {p.startYear}</span>}
{p._count && p._count.actuals > 0 && <span>{p._count.actuals} Ist-Datensatz{p._count.actuals === 1 ? "" : "e"}</span>}
{p._count && p._count.analyses > 0 && <span>{p._count.analyses} Analyse{p._count.analyses === 1 ? "" : "n"}</span>}
</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>
);
}
function PlanDialog({
onCreate,
onClose,
}: {
onCreate: (name: string, profile: ProfileDraft) => void;
onClose: () => void;
}) {
const [name, setName] = useState("Meine Planung");
const [draft, setDraft] = useState<ProfileDraft>(emptyProfileDraft);
const [saving, setSaving] = useState(false);
return (
<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>
<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>
);
}
function CopyScenarioDialog({
source,
onCreate,
onClose,
}: {
source: ScenarioMeta;
onCreate: (name: string) => void;
onClose: () => void;
}) {
const [name, setName] = useState(`${source.name} Variante`);
const [saving, setSaving] = useState(false);
return (
<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>
</Modal>
);
}