"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 ( ); } 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 [showNewPlanChooser, setShowNewPlanChooser] = useState(false); const [showWizard, setShowWizard] = useState(false); const [showEmptyPlanDialog, setShowEmptyPlanDialog] = useState(false); const [creatingDemo, setCreatingDemo] = 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); // 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(); 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(() => { 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 = (
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" && ( )} {!showSpec && !showSystemParams && planNav?.tab === "scenarios" && ( { 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 ? ( {}} 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")} onCreateGuided={() => setShowWizard(true)} onCreateChooser={() => setShowNewPlanChooser(true)} onCreateDemo={handleCreateDemo} onDelete={handleDeletePlan} /> )} {!showSpec && !showSystemParams && !loading && detail && selectedScenarioId && (
{/* 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. */}
{detail.plan.phases.length > 0 && ( )} {detail.plan.phases.length > 0 && ( )} {detail.plan.phases.length > 0 && ( )} {detail.plan.phases.length > 0 && ( CSV-Export )} {!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)} />
)}
{showNewPlanChooser && ( { setShowNewPlanChooser(false); setShowWizard(true); }} onEmpty={() => { setShowNewPlanChooser(false); setShowEmptyPlanDialog(true); }} onDemo={handleCreateDemo} onClose={() => setShowNewPlanChooser(false)} /> )} {showWizard && ( { 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 && ( { 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)} />}
); } // 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: , 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: , title: "Leer starten", badge: null, text: "Nur das Grundprofil erfassen, alles Weitere selbst aufbauen – für geübte Nutzer.", run: onEmpty, }, { icon: , 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 (
{options.map((o) => ( ))}
); } // 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; }) { // 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) => (
{!s.isBase && ( )}
))} ); } // 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 (

Willkommen, {username}

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

{plans.length === 0 ? ( } 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." > ) : (
{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 ? "" : "s"} {p.startYear && ab {p.startYear}} {p._count && p._count.actuals > 0 && {p._count.actuals} Ist-Datensatz{p._count.actuals === 1 ? "" : "e"}} {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" />
); }