Kartenansicht je Lebensphase, Icon-Schiene links, Steuerung rechts
Deploy App / deploy (push) Successful in 1m10s

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-17 21:29:42 +02:00
parent 113616cc1f
commit f41c3614b5
6 changed files with 1294 additions and 46 deletions
+242 -3
View File
@@ -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>
);
}