Files
FPT/src/components/AppShell.tsx
T
2026-08-17 22:07:44 +02:00

1375 lines
52 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
BarChart3,
ChevronDown,
ChevronRight,
Copy,
FileText,
FileSpreadsheet,
FolderKanban,
CalendarClock,
GitBranch,
Layers,
LayoutDashboard,
Menu,
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>
);
}