Kartenansicht je Lebensphase, Icon-Schiene links, Steuerung rechts
Deploy App / deploy (push) Successful in 1m10s
Deploy App / deploy (push) Successful in 1m10s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+242
-3
@@ -14,6 +14,8 @@ import {
|
||||
Layers,
|
||||
LayoutDashboard,
|
||||
Menu,
|
||||
Pin,
|
||||
PinOff,
|
||||
PiggyBank,
|
||||
Plus,
|
||||
Search,
|
||||
@@ -39,8 +41,7 @@ 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 { Tour, TOUR_DONE_KEY } from "@/components/Tour";import { CommandPalette, type PaletteAction } from "@/components/CommandPalette";
|
||||
import { PlanProfileFields, emptyProfileDraft, type ProfileDraft } from "@/components/PlanProfileFields";
|
||||
import {
|
||||
Button,
|
||||
@@ -52,6 +53,9 @@ import {
|
||||
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";
|
||||
@@ -111,6 +115,33 @@ function AppShellInner({ username }: { username: string }) {
|
||||
// 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 256 px. Angepinnt
|
||||
// bleibt sie breit und schiebt den Inhalt; nicht angepinnt faehrt sie beim Ueberfahren als
|
||||
// Overlay aus und legt sich UEBER den Inhalt -- so kostet sie im Ruhezustand nichts.
|
||||
// Die Wahl gehoert zum Benutzer, nicht zum Plan, und ueberlebt deshalb 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).
|
||||
@@ -493,7 +524,81 @@ function AppShellInner({ username }: { username: string }) {
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen w-full">
|
||||
<aside className="hidden w-64 shrink-0 border-r border-border bg-surface lg:block">{sidebar}</aside>
|
||||
{/* 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. */}
|
||||
<div
|
||||
className="relative hidden shrink-0 lg:block"
|
||||
style={{ width: railPinned ? "16rem" : "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>
|
||||
</div>
|
||||
|
||||
{sidebarOpen && (
|
||||
<div className="fixed inset-0 z-40 lg:hidden">
|
||||
@@ -1091,3 +1196,137 @@ function CopyScenarioDialog({
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Die eingeklappte Icon-Schiene -----------------------------------------------------------
|
||||
//
|
||||
// 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({
|
||||
plans,
|
||||
activeScenarioId,
|
||||
planNav,
|
||||
atHome,
|
||||
showSpec,
|
||||
showSystemParams,
|
||||
planMenuOpen,
|
||||
onTogglePlanMenu,
|
||||
onHome,
|
||||
onOpenTab,
|
||||
onSpec,
|
||||
onParams,
|
||||
onSearch,
|
||||
onPin,
|
||||
}: {
|
||||
plans: PlanListItem[];
|
||||
activeScenarioId: string | null;
|
||||
planNav: { planId: string; tab: string } | null;
|
||||
atHome: boolean;
|
||||
showSpec: boolean;
|
||||
showSystemParams: boolean;
|
||||
planMenuOpen: boolean;
|
||||
onTogglePlanMenu: () => void;
|
||||
onHome: () => void;
|
||||
onOpenTab: (planId: string, tab: "dashboard" | "scenarios" | "actuals" | "analyses" | "reports") => 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.
|
||||
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()
|
||||
: "–";
|
||||
|
||||
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" />
|
||||
|
||||
{item("Übersicht", LayoutDashboard, atHome, onHome)}
|
||||
|
||||
{/* 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"
|
||||
}`}
|
||||
>
|
||||
<FolderKanban className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="min-w-0 truncate">{p.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user