5842a84b20
Deploy App / deploy (push) Successful in 1m9s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1375 lines
52 KiB
TypeScript
1375 lines
52 KiB
TypeScript
"use client";
|
||
|
||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||
import {
|
||
BarChart3,
|
||
ChevronDown,
|
||
ChevronRight,
|
||
Copy,
|
||
FileText,
|
||
FileSpreadsheet,
|
||
FolderKanban,
|
||
CalendarClock,
|
||
GitBranch,
|
||
Layers,
|
||
LayoutDashboard,
|
||
Menu,
|
||
Pin,
|
||
PinOff,
|
||
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";
|
||
|
||
// Angepinnte Menueleiste: eine Arbeitsgewohnheit des Benutzers, keine Eigenschaft des Plans.
|
||
const RAIL_PINNED_KEY = "fpt.rail.pinned";
|
||
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);
|
||
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);
|
||
|
||
// Menueleiste links: eingeklappt auf eine Icon-Schiene (56 px) statt 240 px.
|
||
//
|
||
// Beim Ueberfahren faehrt sie aus und SCHIEBT den Inhalt -- sie legt sich nicht darueber.
|
||
// Der erste Anlauf (0.42) liess sie als Overlay ausfahren, um im Ruhezustand keinen Platz
|
||
// zu kosten; dabei verschwand aber jedes Mal ein Streifen des Inhalts, und das las sich wie
|
||
// ein Fehler. Aufgeklappt sieht es damit genau so aus wie angepinnt, und das Anpinnen
|
||
// entscheidet nur noch, ob es so BLEIBT. Die Wahl gehoert zum Benutzer und ueberlebt die
|
||
// Sitzung.
|
||
const [railPinned, setRailPinned] = useState(false);
|
||
const [railHover, setRailHover] = useState(false);
|
||
const [planMenuOpen, setPlanMenuOpen] = useState(false);
|
||
useEffect(() => {
|
||
try {
|
||
// eslint-disable-next-line react-hooks/set-state-in-effect -- einmalige Initialisierung
|
||
if (localStorage.getItem(RAIL_PINNED_KEY) === "1") setRailPinned(true);
|
||
} catch {
|
||
/* localStorage nicht verfuegbar */
|
||
}
|
||
}, []);
|
||
function toggleRailPin() {
|
||
setRailPinned((v) => {
|
||
try {
|
||
localStorage.setItem(RAIL_PINNED_KEY, v ? "0" : "1");
|
||
} catch {
|
||
/* ignorieren */
|
||
}
|
||
return !v;
|
||
});
|
||
}
|
||
const railWide = railPinned || railHover;
|
||
|
||
// 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(() => {
|
||
// Bis 0.39 stand hier zusätzlich `plan.phases.length === 0`: Die alte Spotlight-Tour
|
||
// beleuchtete echte DOM-Ziele in der Matrix und hatte ohne Phasen nichts zu zeigen. Die
|
||
// Tour ist seit 0.36 eine Attrappe mit erfundenen Zahlen -- sie braucht nichts, und sie
|
||
// gehört genau an den Anfang, wo man noch gar nicht weiss, wie der Plan aufgebaut ist.
|
||
if (!detail) 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 diff = detail ? computeScenarioDiff(detail.plan, detail.base) : null;
|
||
|
||
return (
|
||
<div className="flex min-h-screen w-full">
|
||
{/* Linke Leiste. Eingeklappt eine Icon-Schiene; beim Ueberfahren faehrt sie als Overlay
|
||
aus (kostet keinen Platz), angepinnt bleibt sie stehen und schiebt den Inhalt.
|
||
Icon-only braucht zwingend Tooltip UND aria-label -- sonst ist die Schiene fuer
|
||
Screenreader und fuer Neulinge gleichermassen stumm. */}
|
||
{/* Linke Leiste. EINE Komponente in zwei Breiten: Die Symbole bleiben an Ort und
|
||
Stelle, rechts daneben faehrt die Beschriftung aus. Ein Wechsel der Darstellung
|
||
(Icons hier, Baum dort) waere kein Uebergang, sondern ein Sprung -- und genau das
|
||
hat sich falsch angefuehlt. */}
|
||
<div
|
||
className="relative hidden shrink-0 lg:block"
|
||
style={{ width: railWide ? "15rem" : "3.5rem", transition: "width 200ms" }}
|
||
onMouseEnter={() => setRailHover(true)}
|
||
onMouseLeave={() => {
|
||
setRailHover(false);
|
||
setPlanMenuOpen(false);
|
||
}}
|
||
>
|
||
<Rail
|
||
wide={railWide}
|
||
pinned={railPinned}
|
||
onTogglePin={toggleRailPin}
|
||
plans={plans}
|
||
activeScenarioId={selectedScenarioId}
|
||
planNav={planNav}
|
||
atHome={selectedScenarioId === null && !planNav && !showSpec && !showSystemParams}
|
||
showSpec={showSpec}
|
||
showSystemParams={showSystemParams}
|
||
planMenuOpen={planMenuOpen}
|
||
onTogglePlanMenu={() => setPlanMenuOpen((o) => !o)}
|
||
onNewPlan={() => {
|
||
setPlanMenuOpen(false);
|
||
setShowEmptyPlanDialog(true);
|
||
}}
|
||
onDeletePlan={handleDeletePlan}
|
||
onHome={() => {
|
||
setSelectedScenarioId(null);
|
||
setPlanNav(null);
|
||
setShowSpec(false);
|
||
setShowSystemParams(false);
|
||
}}
|
||
onOpenTab={(planId, tab) => {
|
||
setPlanMenuOpen(false);
|
||
if (tab === "actuals") void openActualsTab(planId);
|
||
else openPlanTab(planId, tab);
|
||
}}
|
||
onOpenScenario={openScenario}
|
||
onCopyScenario={setCopyFrom}
|
||
onDeleteScenario={handleDeleteScenario}
|
||
onSpec={() => {
|
||
setShowSpec(true);
|
||
setSpecAnchor(null);
|
||
setShowSystemParams(false);
|
||
setSelectedScenarioId(null);
|
||
setPlanNav(null);
|
||
}}
|
||
onParams={() => {
|
||
setShowSystemParams(true);
|
||
setShowSpec(false);
|
||
setSelectedScenarioId(null);
|
||
setPlanNav(null);
|
||
}}
|
||
onSearch={() => setShowPalette(true)}
|
||
/>
|
||
</div>
|
||
|
||
{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>
|
||
<Rail
|
||
wide
|
||
pinned
|
||
mobile
|
||
onTogglePin={() => setSidebarOpen(false)}
|
||
plans={plans}
|
||
activeScenarioId={selectedScenarioId}
|
||
planNav={planNav}
|
||
atHome={selectedScenarioId === null && !planNav && !showSpec && !showSystemParams}
|
||
showSpec={showSpec}
|
||
showSystemParams={showSystemParams}
|
||
planMenuOpen={planMenuOpen}
|
||
onTogglePlanMenu={() => setPlanMenuOpen((o) => !o)}
|
||
onNewPlan={() => {
|
||
setPlanMenuOpen(false);
|
||
setShowEmptyPlanDialog(true);
|
||
}}
|
||
onDeletePlan={handleDeletePlan}
|
||
onHome={() => {
|
||
setSelectedScenarioId(null);
|
||
setPlanNav(null);
|
||
setShowSpec(false);
|
||
setShowSystemParams(false);
|
||
setSidebarOpen(false);
|
||
}}
|
||
onOpenTab={(planId, tab) => {
|
||
setPlanMenuOpen(false);
|
||
setSidebarOpen(false);
|
||
if (tab === "actuals") void openActualsTab(planId);
|
||
else openPlanTab(planId, tab);
|
||
}}
|
||
onOpenScenario={(id) => {
|
||
openScenario(id);
|
||
setSidebarOpen(false);
|
||
}}
|
||
onCopyScenario={setCopyFrom}
|
||
onDeleteScenario={handleDeleteScenario}
|
||
onSpec={() => {
|
||
setShowSpec(true);
|
||
setSpecAnchor(null);
|
||
setShowSystemParams(false);
|
||
setSelectedScenarioId(null);
|
||
setPlanNav(null);
|
||
setSidebarOpen(false);
|
||
}}
|
||
onParams={() => {
|
||
setShowSystemParams(true);
|
||
setShowSpec(false);
|
||
setSelectedScenarioId(null);
|
||
setPlanNav(null);
|
||
setSidebarOpen(false);
|
||
}}
|
||
onSearch={() => setShowPalette(true)}
|
||
/>
|
||
</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>
|
||
);
|
||
}
|
||
|
||
// --- Die linke Leiste ------------------------------------------------------------------------
|
||
//
|
||
// EINE Komponente in zwei Breiten. Die Symbole stehen immer an derselben Stelle -- in einer
|
||
// festen 3.5-rem-Spalte --, rechts daneben faehrt die Beschriftung aus. Bis 0.42 wechselte die
|
||
// Leiste beim Ueberfahren die Darstellung: eingeklappt Icons, ausgefahren ein Plan-Baum. Das
|
||
// war kein Uebergang, sondern ein Sprung, und man verlor jedes Mal die Orientierung.
|
||
//
|
||
// Der Preis ist der Verlust des Baums ALLER Plaene. Er wird nicht vermisst: Der Plan wird ueber
|
||
// den Waehler oben gewechselt, und die vier Bereiche darunter beziehen sich immer auf den
|
||
// AKTIVEN Plan. Statt einer Liste von Listen bleiben vier Knoepfe.
|
||
function Rail({
|
||
wide,
|
||
pinned,
|
||
mobile,
|
||
onTogglePin,
|
||
plans,
|
||
activeScenarioId,
|
||
planNav,
|
||
atHome,
|
||
showSpec,
|
||
showSystemParams,
|
||
planMenuOpen,
|
||
onTogglePlanMenu,
|
||
onNewPlan,
|
||
onDeletePlan,
|
||
onHome,
|
||
onOpenTab,
|
||
onOpenScenario,
|
||
onCopyScenario,
|
||
onDeleteScenario,
|
||
onSpec,
|
||
onParams,
|
||
onSearch,
|
||
}: {
|
||
wide: boolean;
|
||
pinned: boolean;
|
||
mobile?: boolean;
|
||
onTogglePin: () => void;
|
||
plans: PlanListItem[];
|
||
activeScenarioId: string | null;
|
||
planNav: { planId: string; tab: string } | null;
|
||
atHome: boolean;
|
||
showSpec: boolean;
|
||
showSystemParams: boolean;
|
||
planMenuOpen: boolean;
|
||
onTogglePlanMenu: () => void;
|
||
onNewPlan: () => void;
|
||
onDeletePlan: (planId: string) => void;
|
||
onHome: () => void;
|
||
onOpenTab: (planId: string, tab: "dashboard" | "scenarios" | "actuals" | "analyses" | "reports") => void;
|
||
onOpenScenario: (scenarioId: string) => void;
|
||
onCopyScenario: (meta: ScenarioMeta) => void;
|
||
onDeleteScenario: (s: ScenarioMeta) => void;
|
||
onSpec: () => void;
|
||
onParams: () => void;
|
||
onSearch: () => void;
|
||
}) {
|
||
// Der Szenario-Baum haengt unter «Szenarien» und braucht Text -- eingeklappt gibt es ihn
|
||
// nicht. Standard: zu, sonst waere die Leiste beim Ueberfahren sofort lang.
|
||
const [treeOpen, setTreeOpen] = useState(false);
|
||
|
||
// Welcher Plan ist im Zugriff? Erst der offene, sonst der zuletzt gewaehlte Bereich, sonst
|
||
// der einzige. Ohne diesen Bezug waeren die vier Knoepfe darunter gegenstandslos.
|
||
const active =
|
||
plans.find((p) => p.scenarios.some((sc) => sc.id === activeScenarioId)) ??
|
||
plans.find((p) => p.id === planNav?.planId) ??
|
||
(plans.length === 1 ? plans[0] : undefined);
|
||
|
||
const initials = active
|
||
? active.name.split(/\s+/).slice(0, 2).map((w) => w[0]).join("").toUpperCase()
|
||
: "–";
|
||
|
||
return (
|
||
<aside
|
||
className={`${
|
||
mobile ? "relative h-full w-full" : "absolute inset-y-0 left-0 z-40"
|
||
} flex flex-col overflow-hidden border-r border-border bg-surface transition-[width] duration-200 ${
|
||
mobile ? "" : wide ? "w-60" : "w-14"
|
||
}`}
|
||
>
|
||
{/* Klebt am oberen Bildschirmrand: Die Leiste ist so hoch wie die SEITE, und mit einer
|
||
aufgeklappten Karte wird die sehr lang -- ohne sticky waeren die Knoepfe nach ein
|
||
wenig Scrollen ausserhalb des Blickfelds. */}
|
||
<div className="sticky top-0 flex max-h-screen flex-col gap-0.5 overflow-y-auto py-3">
|
||
<RailRow wide={wide} icon={<PiggyBank className="h-[18px] w-[18px] text-accent-fg" />} iconBox="bg-accent" label="FPT" />
|
||
|
||
<div className="my-1 h-px w-6 self-center bg-border" />
|
||
|
||
<RailRow wide={wide} active={atHome} label="Übersicht" onClick={onHome} icon={<LayoutDashboard className="h-[18px] w-[18px]" />} />
|
||
|
||
{/* Plan-Wähler. Eingeklappt die Initialen -- ohne diesen Bezug wüsste man nach dem
|
||
Umschalten nicht mehr, worin man gerade ist. */}
|
||
<div className="relative">
|
||
<RailRow
|
||
wide={wide}
|
||
label={active?.name ?? "Plan wählen"}
|
||
onClick={onTogglePlanMenu}
|
||
trailing={<ChevronDown className="h-3.5 w-3.5 text-faint" />}
|
||
icon={
|
||
<span
|
||
className={`flex h-[26px] w-[26px] items-center justify-center rounded-md border text-[10px] font-semibold ${
|
||
active ? "border-border text-fg" : "border-dashed border-border text-faint"
|
||
}`}
|
||
>
|
||
{initials}
|
||
</span>
|
||
}
|
||
/>
|
||
{planMenuOpen && (
|
||
<div
|
||
className={`ui-fade absolute z-50 w-56 rounded-xl border border-border bg-surface p-1 shadow-xl ${
|
||
wide ? "left-3 top-full mt-1" : "left-12 top-0"
|
||
}`}
|
||
>
|
||
{plans.length === 0 && <p className="px-2 py-1.5 text-xs text-faint">Noch keine Pläne.</p>}
|
||
{plans.map((p) => (
|
||
<div key={p.id} className="group flex items-center gap-1">
|
||
<button
|
||
type="button"
|
||
onClick={() => onOpenTab(p.id, "dashboard")}
|
||
className={`flex min-w-0 flex-1 items-center gap-2 rounded-lg px-2 py-1.5 text-left text-xs font-medium transition-colors ${
|
||
p.id === active?.id ? "bg-accent-soft text-accent-soft-fg" : "text-muted hover:bg-surface-2"
|
||
}`}
|
||
>
|
||
<FolderKanban className="h-3.5 w-3.5 shrink-0" />
|
||
<span className="min-w-0 truncate">{p.name}</span>
|
||
</button>
|
||
<button
|
||
type="button"
|
||
aria-label={`${p.name} löschen`}
|
||
onClick={() => onDeletePlan(p.id)}
|
||
className="rounded p-1 text-faint opacity-0 transition-opacity hover:bg-danger-soft hover:text-danger group-hover:opacity-100"
|
||
>
|
||
<Trash2 className="h-3.5 w-3.5" />
|
||
</button>
|
||
</div>
|
||
))}
|
||
<button
|
||
type="button"
|
||
onClick={onNewPlan}
|
||
className="mt-1 flex w-full items-center gap-2 rounded-lg border-t border-border px-2 py-1.5 pt-2 text-left text-xs font-medium text-accent transition-colors hover:bg-accent-soft"
|
||
>
|
||
<Plus className="h-3.5 w-3.5 shrink-0" /> Neuer Plan
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Die vier Bereiche des aktiven Plans. Mehr braucht es nicht. */}
|
||
<RailRow
|
||
wide={wide}
|
||
disabled={!active}
|
||
active={planNav?.tab === "scenarios" || !!activeScenarioId}
|
||
label="Szenarien"
|
||
onClick={() => active && onOpenTab(active.id, "scenarios")}
|
||
icon={<Layers className="h-[18px] w-[18px]" />}
|
||
trailing={
|
||
wide && active ? (
|
||
<span
|
||
role="button"
|
||
tabIndex={0}
|
||
aria-label={treeOpen ? "Szenarien einklappen" : "Szenarien aufklappen"}
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
setTreeOpen((o) => !o);
|
||
}}
|
||
onKeyDown={(e) => {
|
||
if (e.key === "Enter" || e.key === " ") {
|
||
e.stopPropagation();
|
||
setTreeOpen((o) => !o);
|
||
}
|
||
}}
|
||
className="rounded p-0.5 text-faint hover:text-fg"
|
||
>
|
||
{treeOpen ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
|
||
</span>
|
||
) : undefined
|
||
}
|
||
/>
|
||
{wide && treeOpen && active && (
|
||
<div className="pl-3 pr-2">
|
||
<ScenarioTree
|
||
scenarios={active.scenarios}
|
||
selectedId={activeScenarioId}
|
||
onSelect={onOpenScenario}
|
||
onCopy={onCopyScenario}
|
||
onDelete={onDeleteScenario}
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
<RailRow
|
||
wide={wide}
|
||
disabled={!active}
|
||
active={planNav?.tab === "actuals"}
|
||
label="Effektive Werte"
|
||
onClick={() => active && onOpenTab(active.id, "actuals")}
|
||
icon={<CalendarClock className="h-[18px] w-[18px]" />}
|
||
/>
|
||
<RailRow
|
||
wide={wide}
|
||
disabled={!active}
|
||
active={planNav?.tab === "analyses"}
|
||
label="Analysen"
|
||
onClick={() => active && onOpenTab(active.id, "analyses")}
|
||
icon={<BarChart3 className="h-[18px] w-[18px]" />}
|
||
/>
|
||
<RailRow
|
||
wide={wide}
|
||
disabled={!active}
|
||
active={planNav?.tab === "reports"}
|
||
label="Berichte"
|
||
onClick={() => active && onOpenTab(active.id, "reports")}
|
||
icon={<FileSpreadsheet className="h-[18px] w-[18px]" />}
|
||
/>
|
||
|
||
<div className="my-1 h-px w-6 self-center bg-border" />
|
||
|
||
<RailRow wide={wide} active={showSpec} label="So rechnet FPT" onClick={onSpec} icon={<FileText className="h-[18px] w-[18px]" />} />
|
||
<RailRow
|
||
wide={wide}
|
||
active={showSystemParams}
|
||
label="Systemparameter"
|
||
onClick={onParams}
|
||
icon={<SlidersHorizontal className="h-[18px] w-[18px]" />}
|
||
/>
|
||
|
||
{/* Suchen und Anpinnen stehen DIREKT hier, nicht am unteren Rand. Die Leiste ist so
|
||
hoch wie die Seite -- und mit einer aufgeklappten Karte wird die sehr lang. Am Fuss
|
||
waeren die beiden dann ausserhalb des Blickfelds, obwohl sie zu den am haeufigsten
|
||
gebrauchten Knoepfen gehoeren. */}
|
||
<div className="my-1 h-px w-6 self-center bg-border" />
|
||
|
||
<RailRow
|
||
wide={wide}
|
||
label="Suchen und springen (Ctrl+K)"
|
||
shortLabel="Suchen"
|
||
onClick={onSearch}
|
||
icon={<Search className="h-[18px] w-[18px]" />}
|
||
/>
|
||
{!mobile && (
|
||
<RailRow
|
||
wide={wide}
|
||
active={pinned}
|
||
label={pinned ? "Menü loslösen" : "Menü anpinnen"}
|
||
onClick={onTogglePin}
|
||
icon={pinned ? <PinOff className="h-[18px] w-[18px]" /> : <Pin className="h-[18px] w-[18px]" />}
|
||
/>
|
||
)}
|
||
</div>
|
||
</aside>
|
||
);
|
||
}
|
||
|
||
// Eine Zeile der Leiste. Das Symbol sitzt in einer FESTEN Spalte von 3.5 rem -- dadurch bleibt
|
||
// es beim Ausfahren exakt an seinem Platz, und es kommt nur die Beschriftung dazu.
|
||
function RailRow({
|
||
wide,
|
||
icon,
|
||
iconBox,
|
||
label,
|
||
shortLabel,
|
||
onClick,
|
||
active,
|
||
disabled,
|
||
trailing,
|
||
}: {
|
||
wide: boolean;
|
||
icon: React.ReactNode;
|
||
iconBox?: string;
|
||
label: string;
|
||
shortLabel?: string;
|
||
onClick?: () => void;
|
||
active?: boolean;
|
||
disabled?: boolean;
|
||
trailing?: React.ReactNode;
|
||
}) {
|
||
const cls = `flex w-full items-center rounded-lg pr-2 text-left transition-colors ${
|
||
disabled ? "opacity-30" : ""
|
||
} ${active ? "bg-accent-soft text-accent-soft-fg" : onClick ? "text-muted hover:bg-surface-2 hover:text-fg" : "text-fg"}`;
|
||
|
||
const inner = (
|
||
<>
|
||
<span className="flex h-9 w-14 shrink-0 items-center justify-center">
|
||
<span className={iconBox ? `flex h-7 w-7 items-center justify-center rounded-lg ${iconBox}` : ""}>{icon}</span>
|
||
</span>
|
||
{wide && (
|
||
<>
|
||
<span className="min-w-0 flex-1 truncate text-sm font-medium">{shortLabel ?? label}</span>
|
||
{trailing}
|
||
</>
|
||
)}
|
||
</>
|
||
);
|
||
|
||
if (!onClick) return <div className={cls}>{inner}</div>;
|
||
return (
|
||
<button
|
||
type="button"
|
||
onClick={onClick}
|
||
disabled={disabled}
|
||
aria-label={label}
|
||
title={wide ? undefined : label}
|
||
className={cls}
|
||
>
|
||
{inner}
|
||
</button>
|
||
);
|
||
}
|