"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 ( ); } function AppShellInner({ username }: { username: string }) { const toast = useToast(); const confirm = useConfirm(); const [plans, setPlans] = useState([]); const [selectedScenarioId, setSelectedScenarioId] = useState(null); const [detail, setDetail] = useState(null); const [loading, setLoading] = useState(true); const [sidebarOpen, setSidebarOpen] = useState(false); const [showEmptyPlanDialog, setShowEmptyPlanDialog] = useState(false); const [copyFrom, setCopyFrom] = useState(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>({}); const [savedAnalysisId, setSavedAnalysisId] = useState(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(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>(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(`/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(() => { 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 = (
FPT
); const diff = detail ? computeScenarioDiff(detail.plan, detail.base) : null; return (
{sidebarOpen && (
setSidebarOpen(false)} />
)}

{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"}

{showSystemParams && } {showSpec && } {!showSpec && !showSystemParams && loading && !planNav && } {/* Plan-Ebene: Dashboard / Szenarien / Analysen */} {!showSpec && !showSystemParams && planNav?.tab === "dashboard" && ( (tab === "actuals" ? void openActualsTab(planNav.planId) : openPlanTab(planNav.planId, tab))} onRenamed={() => void loadPlans()} /> )} {!showSpec && !showSystemParams && planNav?.tab === "scenarios" && ( { 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 ? ( {}} onChanged={refreshCurrent} /> ) : (

Wird geladen…

) )} {!showSpec && !showSystemParams && planNav?.tab === "analyses" && ( void launchTool(planNav.planId, kind)} onOpenSaved={(id) => setSavedAnalysisId(id)} /> )} {!showSpec && !showSystemParams && planNav?.tab === "reports" && activePlan && ( ({ id: s.id, name: s.name, isBase: s.isBase }))} hasActuals={(activePlan._count?.actuals ?? 0) > 0} /> )} {!showSpec && !showSystemParams && !loading && !planNav && selectedScenarioId === null && ( openPlanTab(id, "dashboard")} onCreatePlan={() => setShowEmptyPlanDialog(true)} onDelete={handleDeletePlan} /> )} {!showSpec && !showSystemParams && !loading && detail && selectedScenarioId && (
{/* 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)) && (
{!detail.meta.isBase && ( )} {diff && detail.base && ( {diff.total === 0 ? "Unverändert gegenüber der Vorlage" : `${diff.total} Abweichung${diff.total === 1 ? "" : "en"} gegenüber der Vorlage`} )}
)} setShowSensitivity(true)} onOpenHistory={() => setShowHistory(true)} onStartTour={() => setShowTour(true)} onCopyScenario={() => setCopyFrom(detail.meta)} onOpenTraces={() => setShowPlanTraces(true)} exportHref={`/api/scenarios/${detail.meta.id}/export`} />
)}
{showEmptyPlanDialog && ( { 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 && ( setShowCharts(false)} title={`Grafiken · ${detail.meta.name}`}> s.id !== detail.meta.id)} actuals={detail.actuals ?? []} origins={detail.elementOrigins ?? []} /> )} {showMonteCarlo && detail && ( setShowMonteCarlo(false)} /> )} {showSensitivity && detail && ( setShowSensitivity(false)} /> )} {showLiveSim && detail && ( setShowLiveSim(false)} /> )} {savedAnalysisId && planNav && ( setSavedAnalysisId(null)} /> )} {showActuals && detail && activePlan && ( setShowActuals(false)} onChanged={refreshCurrent} /> )} {showHistory && detail && ( setShowHistory(false)} onRestored={refreshCurrent} /> )} {showPlanTraces && detail && ( setShowPlanTraces(false)} onOpenSpec={openSpecAt} /> )} {copyFrom && ( 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."); }} /> )} setShowPalette(false)} plans={plans} actions={paletteActions} onOpenScenario={openScenario} /> {showTour && setShowTour(false)} />}
); } // Breiter Dialog für den Analyse-Bereich (Grafiken). function ChartsDialog({ title, children, onClose, }: { title: string; children: React.ReactNode; onClose: () => void; }) { return (
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" >

{title}

{children}
); } // 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) => (
{!s.isBase && ( )}
))} ); } // 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 (

Willkommen, {username}

Wähle einen Plan oder erstelle einen neuen. Jeder Plan enthält ein Basisszenario und beliebig viele Varianten davon.

{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. */ } 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." > ) : (
{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 (
onOpenPlan(p.id)} >
{p.name}
{persons || "Basisszenario"} {others > 0 ? ` · +${others} Variante${others === 1 ? "" : "n"}` : ""}
{/* Ein paar Kennzahlen pro Plan direkt in der Übersicht. */}
{p.scenarios.length} Szenario{p.scenarios.length === 1 ? "" : "en"} {p.startYear && ab {p.startYear}} {p._count && p._count.actuals > 0 && {p._count.actuals} {p._count.actuals === 1 ? "Ist-Datensatz" : "Ist-Datensätze"}} {p._count && p._count.analyses > 0 && {p._count.analyses} Analyse{p._count.analyses === 1 ? "" : "n"}}
); })}
)}
); } function PlanDialog({ onCreate, onClose, }: { onCreate: (name: string, profile: ProfileDraft) => void; onClose: () => void; }) { const [name, setName] = useState("Meine Planung"); const [draft, setDraft] = useState(emptyProfileDraft); const [saving, setSaving] = useState(false); return (

Es wird automatisch ein Basisszenario angelegt. Weitere Szenarien entstehen später als Kopien davon.

setName(e.target.value)} placeholder="Name des Plans" />
); } 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 (

Vollständige Kopie von {source.name}. Änderungen darin werden anschliessend farblich hervorgehoben.

setName(e.target.value)} placeholder="Name des Szenarios" />
); }