Files
FPT/src/components/AppShell.tsx
T

1090 lines
43 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,
ChevronDown,
ChevronRight,
Copy,
FileText,
FileSpreadsheet,
FolderKanban,
CalendarClock,
GitBranch,
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 { 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 { 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 [showEmptyPlanDialog, setShowEmptyPlanDialog] = 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);
// Erhoehen erzwingt ein Neuladen der Szenario-Liste (bleibt bei einer Loeschung montiert).
const [scenarioListKey, setScenarioListKey] = useState(0);
// 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();
// Die Szenario-Liste bleibt beim Löschen aus der Seitenleiste montiert -- ohne dieses
// Signal zeigte sie den gelöschten Eintrag weiter.
setScenarioListKey((k) => k + 1);
if (selectedScenarioId === s.id) setSelectedScenarioId(null);
}
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: () => setShowEmptyPlanDialog(true) },
{ id: "a-home", label: "Übersicht öffnen", hint: "Aktion", run: () => { setSelectedScenarioId(null); setPlanNav(null); setShowSpec(false); setShowSystemParams(false); } },
{ id: "a-spec", label: "So rechnet FPT (Spezifikation)", hint: "Aktion", run: () => { setShowSpec(true); setSpecAnchor(null); setShowSystemParams(false); setSelectedScenarioId(null); setPlanNav(null); } },
{ id: "a-params", label: "Systemparameter", hint: "Aktion", run: () => { setShowSystemParams(true); setShowSpec(false); setSelectedScenarioId(null); setPlanNav(null); } },
];
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={() => setShowEmptyPlanDialog(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-[1.625rem] 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>
{/* Ebene 3: nur unter "Szenarien", verschachtelt nach Herkunft. */}
{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-[1.625rem] 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}
onNavigate={(tab) => (tab === "actuals" ? void openActualsTab(planNav.planId) : openPlanTab(planNav.planId, tab))}
onRenamed={() => void loadPlans()}
/>
)}
{!showSpec && !showSystemParams && planNav?.tab === "scenarios" && (
<ScenarioListView
planId={planNav.planId}
reloadKey={scenarioListKey}
onOpenMatrix={openScenario}
onOpenHistory={(sid) => {
void loadDetail(sid, true).then(() => setShowHistory(true));
}}
onCopyFrom={(sid) => {
const src = activePlan?.scenarios.find((s) => s.id === sid);
if (src) setCopyFrom(src);
}}
onDelete={(sid) => {
const s = activePlan?.scenarios.find((x) => x.id === sid);
if (s) void handleDeleteScenario(s);
}}
/>
)}
{!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}
onOpenPlan={(id) => openPlanTab(id, "dashboard")}
onCreatePlan={() => setShowEmptyPlanDialog(true)}
onDelete={handleDeletePlan}
/>
)}
{!showSpec && !showSystemParams && !loading && detail && selectedScenarioId && (
<div className="flex flex-col gap-6">
{/* Nur noch, was NICHT zum Szenario-Inhalt gehört: das Löschen des Szenarios
und der Hinweis auf Abweichungen zur Vorlage. Die inhaltlichen Aktionen
(Historie, Tour, Kopie, Rechenwege, Export) sitzen seit 0.36 als Kachel im
Szenario-Screen -- dort, wo man sie braucht. */}
{(!detail.meta.isBase || (diff && detail.base)) && (
<div className="flex flex-wrap items-center gap-2">
{!detail.meta.isBase && (
<Button variant="danger" onClick={() => handleDeleteScenario(detail.meta)}>
<Trash2 className="h-4 w-4" />
Szenario löschen
</Button>
)}
{diff && detail.base && (
<span className="ml-auto flex items-center gap-1.5 rounded-lg border border-diff bg-diff-soft px-3 py-1.5 text-xs font-medium text-diff">
<GitBranch className="h-3.5 w-3.5" />
{diff.total === 0
? "Unverändert gegenüber der Vorlage"
: `${diff.total} Abweichung${diff.total === 1 ? "" : "en"} gegenüber der Vorlage`}
</span>
)}
</div>
)}
<PlanView
plan={detail.plan}
computed={detail.computed}
actualComputed={views?.actual ?? null}
actualYears={views?.actualYears ?? []}
diff={diff}
onChanged={refreshCurrent}
onOpenSpec={openSpecAt}
onOpenSensitivity={() => setShowSensitivity(true)}
onOpenHistory={() => setShowHistory(true)}
onStartTour={() => setShowTour(true)}
onCopyScenario={() => setCopyFrom(detail.meta)}
onOpenTraces={() => setShowPlanTraces(true)}
exportHref={`/api/scenarios/${detail.meta.id}/export`}
/>
</div>
)}
</main>
</div>
{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>
);
}
// 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;
}) {
// Echte Verschachtelung: Ein Szenario wird unter seiner Vorlage eingerückt (Tiefe = Länge
// der Herkunftskette). So ist in der Seitenleiste sofort sichtbar, woraus ein Szenario
// entstanden ist -- die Szenario-Liste zeigt dasselbe zusätzlich als Spalte «aus …».
const byId = new Map(scenarios.map((s) => [s.id, s]));
const depthOf = (s: ScenarioMeta): number => {
let d = 0;
let cur = s.parentScenarioId ? byId.get(s.parentScenarioId) : undefined;
// Deckel gegen eine (theoretisch) zyklische Kette.
while (cur && d < 8) {
d += 1;
cur = cur.parentScenarioId ? byId.get(cur.parentScenarioId) : undefined;
}
return d;
};
// Vorordnung: Basis zuoberst, Kinder direkt unter ihrer Vorlage.
const ordered: ScenarioMeta[] = [];
const visit = (parentId: string | null) => {
for (const s of scenarios) {
const pid = s.isBase ? null : s.parentScenarioId;
if ((pid ?? null) !== parentId) continue;
ordered.push(s);
visit(s.id);
}
};
visit(null);
// Szenarien, deren Vorlage gelöscht wurde, hängen sonst nirgends -- ans Ende.
for (const s of scenarios) if (!ordered.includes(s)) ordered.push(s);
if (ordered.length === 0) return null;
return (
<>
{ordered.map((s) => (
<div
key={s.id}
style={{ paddingLeft: `${2.5 + depthOf(s) * 0.85}rem` }}
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"
}`}
>
<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,
onOpenPlan,
onCreatePlan,
onDelete,
}: {
username: string;
plans: PlanListItem[];
onOpenPlan: (planId: string) => void;
onCreatePlan: () => 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 ? (
/* Genau EIN Weg hinein. Bis 0.35 standen hier drei Knöpfe (geführt / Beispielplan /
leer) -- eine Wahl, die niemand treffen kann, der das Tool noch nicht kennt. Der
Assistent führt jetzt IM Plan statt davor; die Tour zeigt vorab den Aufbau. */
<EmptyState
icon={<Sparkles className="h-6 w-6" />}
title="Deine erste Finanzplanung"
text="Ein paar Angaben zu dir danach zeigt dir eine kurze Tour, wie FPT aufgebaut ist, und der Assistent führt dich Schritt für Schritt durch die Planung."
>
<Button onClick={onCreatePlan}>
<Plus className="h-4 w-4" /> Meinen ersten Finanzplan anlegen
</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 ? "" : "en"}</span>
{p.startYear && <span>ab {p.startYear}</span>}
{p._count && p._count.actuals > 0 && <span>{p._count.actuals} {p._count.actuals === 1 ? "Ist-Datensatz" : "Ist-Datensätze"}</span>}
{p._count && p._count.analyses > 0 && <span>{p._count.analyses} Analyse{p._count.analyses === 1 ? "" : "n"}</span>}
</div>
</div>
);
})}
<button
type="button"
onClick={onCreatePlan}
className="flex min-h-20 items-center justify-center gap-2 rounded-xl border border-dashed border-accent bg-accent-soft text-sm font-medium text-accent-soft-fg transition-colors hover:bg-accent-soft"
>
<Plus className="h-4 w-4" />
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>
);
}