Menueleiste als eine Komponente in zwei Breiten, Kartenansicht wieder erreichbar
Deploy App / deploy (push) Successful in 1m15s
Deploy App / deploy (push) Successful in 1m15s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+357
-326
@@ -104,8 +104,6 @@ function AppShellInner({ username }: { username: string }) {
|
||||
const [showActuals, setShowActuals] = useState(false);
|
||||
// Plan-Ebene: Dashboard / Szenarien-Liste / Analysen. Null = kein Plan-View aktiv.
|
||||
const [planNav, setPlanNav] = useState<{ planId: string; tab: "dashboard" | "scenarios" | "actuals" | "analyses" | "reports" } | null>(null);
|
||||
// Welche Szenario-Bäume in der Seitenleiste aufgeklappt sind. Standard: eingeklappt.
|
||||
const [expandedTrees, setExpandedTrees] = useState<Record<string, boolean>>({});
|
||||
const [savedAnalysisId, setSavedAnalysisId] = useState<string | null>(null);
|
||||
const [showSystemParams, setShowSystemParams] = useState(false);
|
||||
const [showPlanTraces, setShowPlanTraces] = useState(false);
|
||||
@@ -342,184 +340,6 @@ function AppShellInner({ username }: { username: string }) {
|
||||
return base;
|
||||
}, [detail, selectedScenarioId]);
|
||||
|
||||
const sidebar = (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex items-center gap-2 px-4 py-4">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-xl bg-accent">
|
||||
<PiggyBank className="h-5 w-5 text-accent-fg" />
|
||||
</div>
|
||||
<span className="text-sm font-semibold text-fg">FPT</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPalette(true)}
|
||||
title="Suchen und springen (Ctrl+K)"
|
||||
className="ml-auto flex items-center gap-1 rounded-lg border border-border px-2 py-1 text-[10px] text-faint transition-colors hover:bg-surface-2 hover:text-fg"
|
||||
>
|
||||
<Search className="h-3 w-3" />
|
||||
Ctrl K
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<nav className="flex flex-1 flex-col gap-1 overflow-y-auto px-3 pb-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedScenarioId(null);
|
||||
setPlanNav(null);
|
||||
setShowSpec(false);
|
||||
setShowSystemParams(false);
|
||||
setSidebarOpen(false);
|
||||
}}
|
||||
className={`flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium transition-colors ${
|
||||
selectedScenarioId === null && !planNav && !showSpec && !showSystemParams ? "bg-accent-soft text-accent-soft-fg" : "text-muted hover:bg-surface-2"
|
||||
}`}
|
||||
>
|
||||
<LayoutDashboard className="h-4 w-4" />
|
||||
Übersicht
|
||||
</button>
|
||||
|
||||
<div className="mt-4 flex items-center justify-between px-3">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wide text-faint">Meine Pläne</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowEmptyPlanDialog(true)}
|
||||
aria-label="Neuen Plan erstellen"
|
||||
className="rounded-md p-1 text-accent transition-colors hover:bg-accent-soft"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
{plans.length === 0 && <p className="px-3 py-2 text-xs text-faint">Noch keine Pläne.</p>}
|
||||
|
||||
{plans.map((p) => {
|
||||
const navHere = planNav?.planId === p.id;
|
||||
// Tour-Ziel «menu»: die Menü-Gruppe des gerade offenen Plans.
|
||||
const isActivePlan = p.scenarios.some((s) => s.id === selectedScenarioId);
|
||||
const subItem = (tab: "dashboard" | "scenarios" | "actuals" | "analyses" | "reports", label: string, Icon: typeof FolderKanban) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openPlanTab(p.id, tab)}
|
||||
className={`flex w-full items-center gap-2 rounded-lg py-1.5 pl-[1.625rem] pr-3 text-left text-xs font-medium transition-colors ${
|
||||
navHere && planNav?.tab === tab ? "bg-accent-soft text-accent-soft-fg" : "text-muted hover:bg-surface-2"
|
||||
}`}
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5 shrink-0" />
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
return (
|
||||
<div key={p.id} className="mb-1" data-tour={isActivePlan ? "menu" : undefined}>
|
||||
{/* Plan-Name: Klick öffnet das Plan-Dashboard. */}
|
||||
<div className="group flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-semibold text-fg">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openPlanTab(p.id, "dashboard")}
|
||||
className="flex min-w-0 flex-1 items-center gap-1.5 text-left"
|
||||
title={p.name}
|
||||
>
|
||||
<FolderKanban className={`h-3.5 w-3.5 shrink-0 ${navHere && planNav?.tab === "dashboard" ? "text-accent" : "text-faint"}`} />
|
||||
<span className="min-w-0 flex-1 truncate">{p.name}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Plan löschen"
|
||||
onClick={() => handleDeletePlan(p.id)}
|
||||
className="rounded p-0.5 text-faint opacity-60 transition-opacity hover:bg-danger-soft hover:text-danger group-hover:opacity-100"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* «Szenarien»: Chevron klappt den Baum auf/zu (Standard: zu), das Label führt
|
||||
in die Szenario-Liste. */}
|
||||
<div
|
||||
className={`group flex w-full items-center gap-1 rounded-lg py-1.5 pr-3 pl-1 text-xs font-medium transition-colors ${
|
||||
navHere && planNav?.tab === "scenarios" ? "bg-accent-soft text-accent-soft-fg" : "text-muted hover:bg-surface-2"
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={expandedTrees[p.id] ? "Szenarien einklappen" : "Szenarien aufklappen"}
|
||||
onClick={() => setExpandedTrees((prev) => ({ ...prev, [p.id]: !prev[p.id] }))}
|
||||
className="rounded p-0.5 text-faint hover:text-fg"
|
||||
>
|
||||
{expandedTrees[p.id] ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openPlanTab(p.id, "scenarios")}
|
||||
className="flex min-w-0 flex-1 items-center gap-2 text-left"
|
||||
>
|
||||
<Layers className="h-3.5 w-3.5 shrink-0" />
|
||||
Szenarien
|
||||
</button>
|
||||
</div>
|
||||
{/* Ebene 3: nur unter "Szenarien", verschachtelt nach Herkunft. */}
|
||||
{expandedTrees[p.id] && (
|
||||
<ScenarioTree
|
||||
scenarios={p.scenarios}
|
||||
selectedId={selectedScenarioId}
|
||||
onSelect={openScenario}
|
||||
onCopy={setCopyFrom}
|
||||
onDelete={handleDeleteScenario}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void openActualsTab(p.id)}
|
||||
className={`flex w-full items-center gap-2 rounded-lg py-1.5 pl-[1.625rem] pr-3 text-left text-xs font-medium transition-colors ${
|
||||
navHere && planNav?.tab === "actuals" ? "bg-accent-soft text-accent-soft-fg" : "text-muted hover:bg-surface-2"
|
||||
}`}
|
||||
>
|
||||
<CalendarClock className="h-3.5 w-3.5 shrink-0" />
|
||||
Effektive Werte
|
||||
</button>
|
||||
{subItem("analyses", "Analysen", BarChart3)}
|
||||
{subItem("reports", "Berichte", FileSpreadsheet)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="mt-4 flex flex-col gap-1 border-t border-border pt-3">
|
||||
<span className="px-3 pb-1 text-[11px] font-semibold uppercase tracking-wide text-faint">Wissen</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowSpec(true);
|
||||
setSpecAnchor(null);
|
||||
setShowSystemParams(false);
|
||||
setSelectedScenarioId(null);
|
||||
setPlanNav(null);
|
||||
setSidebarOpen(false);
|
||||
}}
|
||||
className={`flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm font-medium transition-colors ${
|
||||
showSpec ? "bg-accent-soft text-accent-soft-fg" : "text-muted hover:bg-surface-2"
|
||||
}`}
|
||||
>
|
||||
<FileText className="h-4 w-4 shrink-0" />
|
||||
So rechnet FPT
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowSystemParams(true);
|
||||
setShowSpec(false);
|
||||
setSelectedScenarioId(null);
|
||||
setPlanNav(null);
|
||||
setSidebarOpen(false);
|
||||
}}
|
||||
className={`flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm font-medium transition-colors ${
|
||||
showSystemParams ? "bg-accent-soft text-accent-soft-fg" : "text-muted hover:bg-surface-2"
|
||||
}`}
|
||||
>
|
||||
<SlidersHorizontal className="h-4 w-4 shrink-0" />
|
||||
Systemparameter
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
|
||||
const diff = detail ? computeScenarioDiff(detail.plan, detail.base) : null;
|
||||
|
||||
return (
|
||||
@@ -528,76 +348,65 @@ function AppShellInner({ username }: { username: string }) {
|
||||
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: railPinned ? "16rem" : "3.5rem" }}
|
||||
style={{ width: railPinned ? "15rem" : "3.5rem" }}
|
||||
onMouseEnter={() => setRailHover(true)}
|
||||
onMouseLeave={() => {
|
||||
setRailHover(false);
|
||||
setPlanMenuOpen(false);
|
||||
}}
|
||||
>
|
||||
<aside
|
||||
className={`absolute inset-y-0 left-0 z-30 flex flex-col border-r border-border bg-surface transition-[width] duration-200 ${
|
||||
railWide ? "w-64 shadow-xl" : "w-14"
|
||||
} ${railPinned ? "shadow-none" : ""}`}
|
||||
>
|
||||
{railWide ? (
|
||||
<>
|
||||
<div className="flex items-center gap-1 px-3 pt-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleRailPin}
|
||||
aria-label={railPinned ? "Menü loslösen" : "Menü anpinnen"}
|
||||
title={railPinned ? "Menü loslösen" : "Menü anpinnen"}
|
||||
className={`ml-auto rounded-lg p-1.5 transition-colors ${
|
||||
railPinned ? "bg-accent-soft text-accent-soft-fg" : "text-faint hover:bg-surface-2 hover:text-fg"
|
||||
}`}
|
||||
>
|
||||
{railPinned ? <PinOff className="h-4 w-4" /> : <Pin className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1">{sidebar}</div>
|
||||
</>
|
||||
) : (
|
||||
<IconRail
|
||||
plans={plans}
|
||||
activeScenarioId={selectedScenarioId}
|
||||
planNav={planNav}
|
||||
atHome={selectedScenarioId === null && !planNav && !showSpec && !showSystemParams}
|
||||
showSpec={showSpec}
|
||||
showSystemParams={showSystemParams}
|
||||
planMenuOpen={planMenuOpen}
|
||||
onTogglePlanMenu={() => setPlanMenuOpen((o) => !o)}
|
||||
onHome={() => {
|
||||
setSelectedScenarioId(null);
|
||||
setPlanNav(null);
|
||||
setShowSpec(false);
|
||||
setShowSystemParams(false);
|
||||
}}
|
||||
onOpenTab={(planId, tab) => {
|
||||
setPlanMenuOpen(false);
|
||||
if (tab === "actuals") void openActualsTab(planId);
|
||||
else openPlanTab(planId, tab);
|
||||
}}
|
||||
onSpec={() => {
|
||||
setShowSpec(true);
|
||||
setSpecAnchor(null);
|
||||
setShowSystemParams(false);
|
||||
setSelectedScenarioId(null);
|
||||
setPlanNav(null);
|
||||
}}
|
||||
onParams={() => {
|
||||
setShowSystemParams(true);
|
||||
setShowSpec(false);
|
||||
setSelectedScenarioId(null);
|
||||
setPlanNav(null);
|
||||
}}
|
||||
onSearch={() => setShowPalette(true)}
|
||||
onPin={toggleRailPin}
|
||||
/>
|
||||
)}
|
||||
</aside>
|
||||
<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 && (
|
||||
@@ -612,7 +421,60 @@ function AppShellInner({ username }: { username: string }) {
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
{sidebar}
|
||||
<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>
|
||||
)}
|
||||
@@ -1197,12 +1059,21 @@ function CopyScenarioDialog({
|
||||
);
|
||||
}
|
||||
|
||||
// --- Die eingeklappte Icon-Schiene -----------------------------------------------------------
|
||||
// --- Die linke Leiste ------------------------------------------------------------------------
|
||||
//
|
||||
// Sie zeigt genau das, was man im Alltag braucht: nach Hause, den aktiven Plan, seine vier
|
||||
// Bereiche. Der Szenario-BAUM steht bewusst nicht hier -- er braucht Text, und Text braucht
|
||||
// Breite. Wer ihn will, faehrt die Leiste aus oder pinnt sie an.
|
||||
function IconRail({
|
||||
// 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,
|
||||
@@ -1211,13 +1082,21 @@ function IconRail({
|
||||
showSystemParams,
|
||||
planMenuOpen,
|
||||
onTogglePlanMenu,
|
||||
onNewPlan,
|
||||
onDeletePlan,
|
||||
onHome,
|
||||
onOpenTab,
|
||||
onOpenScenario,
|
||||
onCopyScenario,
|
||||
onDeleteScenario,
|
||||
onSpec,
|
||||
onParams,
|
||||
onSearch,
|
||||
onPin,
|
||||
}: {
|
||||
wide: boolean;
|
||||
pinned: boolean;
|
||||
mobile?: boolean;
|
||||
onTogglePin: () => void;
|
||||
plans: PlanListItem[];
|
||||
activeScenarioId: string | null;
|
||||
planNav: { planId: string; tab: string } | null;
|
||||
@@ -1226,107 +1105,259 @@ function IconRail({
|
||||
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;
|
||||
onPin: () => void;
|
||||
}) {
|
||||
// Welcher Plan ist gerade im Zugriff? Erst der offene, sonst der zuletzt gewaehlte Bereich,
|
||||
// sonst der einzige. Ohne diesen Bezug waeren die vier Icons darunter gegenstandslos.
|
||||
// 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()
|
||||
? active.name.split(/\s+/).slice(0, 2).map((w) => w[0]).join("").toUpperCase()
|
||||
: "–";
|
||||
|
||||
const item = (
|
||||
label: string,
|
||||
Icon: typeof LayoutDashboard,
|
||||
on: boolean,
|
||||
onClick: () => void,
|
||||
disabled?: boolean
|
||||
) => (
|
||||
<button
|
||||
key={label}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
className={`flex h-9 w-9 items-center justify-center rounded-lg transition-colors disabled:opacity-30 ${
|
||||
on ? "bg-accent-soft text-accent-soft-fg" : "text-muted hover:bg-surface-2 hover:text-fg"
|
||||
}`}
|
||||
>
|
||||
<Icon className="h-[18px] w-[18px]" />
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center gap-1 py-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-xl bg-accent" title="FPT">
|
||||
<PiggyBank className="h-5 w-5 text-accent-fg" />
|
||||
</div>
|
||||
<div className="my-1 h-px w-6 bg-border" />
|
||||
<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 shadow-xl" : "w-14"
|
||||
} ${pinned && !mobile ? "shadow-none" : ""}`}
|
||||
>
|
||||
<div className="flex min-h-0 flex-1 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" />
|
||||
|
||||
{item("Übersicht", LayoutDashboard, atHome, onHome)}
|
||||
<div className="my-1 h-px w-6 self-center bg-border" />
|
||||
|
||||
{/* Der Plan-Wähler. Eingeklappt zeigt er die Initialen -- sonst wüsste man nach dem
|
||||
Umschalten nicht mehr, worin man gerade ist. */}
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onTogglePlanMenu}
|
||||
aria-label={active ? `Plan: ${active.name}` : "Plan wählen"}
|
||||
title={active ? `Plan: ${active.name}` : "Plan wählen"}
|
||||
className={`flex h-9 w-9 items-center justify-center rounded-lg border text-[11px] font-semibold transition-colors ${
|
||||
active ? "border-border text-fg hover:bg-surface-2" : "border-dashed border-border text-faint"
|
||||
}`}
|
||||
>
|
||||
{initials}
|
||||
</button>
|
||||
{planMenuOpen && (
|
||||
<div className="ui-fade absolute left-11 top-0 z-50 w-56 rounded-xl border border-border bg-surface p-1 shadow-xl">
|
||||
{plans.length === 0 && <p className="px-2 py-1.5 text-xs text-faint">Noch keine Pläne.</p>}
|
||||
{plans.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
type="button"
|
||||
onClick={() => onOpenTab(p.id, "dashboard")}
|
||||
className={`flex w-full 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"
|
||||
<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"
|
||||
}`}
|
||||
>
|
||||
<FolderKanban className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="min-w-0 truncate">{p.name}</span>
|
||||
{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]" />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{item("Szenarien", Layers, planNav?.tab === "scenarios" || !!activeScenarioId, () => active && onOpenTab(active.id, "scenarios"), !active)}
|
||||
{item("Effektive Werte", CalendarClock, planNav?.tab === "actuals", () => active && onOpenTab(active.id, "actuals"), !active)}
|
||||
{item("Analysen", BarChart3, planNav?.tab === "analyses", () => active && onOpenTab(active.id, "analyses"), !active)}
|
||||
{item("Berichte", FileSpreadsheet, planNav?.tab === "reports", () => active && onOpenTab(active.id, "reports"), !active)}
|
||||
|
||||
<div className="my-1 h-px w-6 bg-border" />
|
||||
{item("So rechnet FPT", FileText, showSpec, onSpec)}
|
||||
{item("Systemparameter", SlidersHorizontal, showSystemParams, onParams)}
|
||||
|
||||
<div className="mt-auto flex flex-col items-center gap-1">
|
||||
{item("Suchen und springen (Ctrl+K)", Search, false, onSearch)}
|
||||
{item("Menü anpinnen", Pin, false, onPin)}
|
||||
<div className="border-t border-border py-1.5">
|
||||
<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>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user