Răsfoiți Sursa

RHL-032 feat(ui): implement theme toggle button and enhance user status dropdown with settings and logout options

Code_Uwe 1 săptămână în urmă
părinte
comite
b3408677ae

+ 58 - 14
components/app-shell/QuickNav.jsx

@@ -2,6 +2,8 @@
 
 import React from "react";
 import Link from "next/link";
+import { usePathname, useRouter } from "next/navigation";
+import { FolderOpen, Search as SearchIcon } from "lucide-react";
 
 import { useAuth } from "@/components/auth/authContext";
 import { getBranches } from "@/lib/frontend/apiClient";
@@ -13,6 +15,10 @@ import {
 	safeReadLocalStorageBranch,
 	safeWriteLocalStorageBranch,
 } from "@/lib/frontend/quickNav/branchSwitch";
+import {
+	getPrimaryNavFromPathname,
+	PRIMARY_NAV,
+} from "@/lib/frontend/nav/activeRoute";
 
 import { Button } from "@/components/ui/button";
 import {
@@ -35,6 +41,9 @@ const BRANCH_LIST_STATE = Object.freeze({
 });
 
 export default function QuickNav() {
+	const router = useRouter();
+	const pathname = usePathname() || "/";
+
 	const { status, user } = useAuth();
 
 	const isAuthenticated = status === "authenticated" && user;
@@ -49,26 +58,39 @@ export default function QuickNav() {
 		branches: null,
 	});
 
+	const activePrimaryNav = React.useMemo(() => {
+		return getPrimaryNavFromPathname(pathname);
+	}, [pathname]);
+
+	const isExplorerActive = activePrimaryNav?.active === PRIMARY_NAV.EXPLORER;
+	const isSearchActive = activePrimaryNav?.active === PRIMARY_NAV.SEARCH;
+
 	React.useEffect(() => {
 		if (!isAuthenticated) return;
 
+		// Branch users: selection is fixed to their own branch.
 		if (isBranchUser) {
 			const own = user.branchId;
 			setSelectedBranch(own && isValidBranchParam(own) ? own : null);
 			return;
 		}
 
-		const fromRoute = readRouteBranchFromPathname(window.location.pathname);
+		// Admin/dev: prefer current route branch, fallback to last-used localStorage.
+		const fromRoute = readRouteBranchFromPathname(pathname);
 		const fromStorage = safeReadLocalStorageBranch(STORAGE_KEY_LAST_BRANCH);
 
 		const initial = fromRoute || fromStorage || null;
-		if (initial) setSelectedBranch(initial);
-	}, [isAuthenticated, isBranchUser, user?.branchId]);
+
+		// Avoid unnecessary state updates.
+		if (initial && initial !== selectedBranch) {
+			setSelectedBranch(initial);
+			safeWriteLocalStorageBranch(STORAGE_KEY_LAST_BRANCH, initial);
+		}
+	}, [isAuthenticated, isBranchUser, user?.branchId, pathname, selectedBranch]);
 
 	React.useEffect(() => {
-		// B.4 fix:
-		// - Fetch the branch list once for admin/dev users (or when the user changes),
-		//   not on every selectedBranch change.
+		// Fetch the branch list once for admin/dev users (or when the user changes),
+		// not on every selectedBranch change.
 		if (!isAdminDev) return;
 
 		let cancelled = false;
@@ -106,7 +128,6 @@ export default function QuickNav() {
 			: [];
 		if (branches.length === 0) return;
 
-		// If no selection yet (or selection is no longer in the list), choose a stable default.
 		if (!selectedBranch || !branches.includes(selectedBranch)) {
 			const next = branches[0];
 			setSelectedBranch(next);
@@ -125,24 +146,38 @@ export default function QuickNav() {
 
 	const effectiveBranch = isBranchUser ? user.branchId : selectedBranch;
 	const canNavigate = Boolean(
-		effectiveBranch && isValidBranchParam(effectiveBranch)
+		effectiveBranch && isValidBranchParam(effectiveBranch),
 	);
 
 	function navigateToBranchKeepingContext(nextBranch) {
-		if (typeof window === "undefined") return;
 		if (!isValidBranchParam(nextBranch)) return;
 
+		// IMPORTANT:
+		// Avoid useSearchParams() here to prevent build-time prerender failures on static routes.
+		// We only need the current query string at click-time (client-only), so window is fine.
+		const currentPathname =
+			typeof window !== "undefined"
+				? window.location.pathname || pathname
+				: pathname;
+
+		const currentSearch =
+			typeof window !== "undefined" ? window.location.search || "" : "";
+
 		const nextUrl = buildNextUrlForBranchSwitch({
-			pathname: window.location.pathname || "/",
-			search: window.location.search || "",
+			pathname: currentPathname,
+			search: currentSearch,
 			nextBranch,
 		});
 
 		if (!nextUrl) return;
 
-		window.location.assign(nextUrl);
+		// Client navigation: keeps providers mounted and avoids hard reload flicker.
+		router.push(nextUrl);
 	}
 
+	const explorerVariant = isExplorerActive ? "secondary" : "ghost";
+	const searchVariant = isSearchActive ? "secondary" : "ghost";
+
 	return (
 		<div className="hidden items-center gap-2 md:flex">
 			{isAdminDev ? (
@@ -193,20 +228,29 @@ export default function QuickNav() {
 				</DropdownMenu>
 			) : null}
 
-			<Button variant="outline" size="sm" asChild disabled={!canNavigate}>
+			<Button
+				variant={explorerVariant}
+				size="sm"
+				asChild
+				disabled={!canNavigate}
+			>
 				<Link
 					href={canNavigate ? branchPath(effectiveBranch) : "#"}
 					title="Explorer öffnen"
+					aria-current={isExplorerActive ? "page" : undefined}
 				>
+					<FolderOpen className="h-4 w-4" />
 					Explorer
 				</Link>
 			</Button>
 
-			<Button variant="outline" size="sm" asChild disabled={!canNavigate}>
+			<Button variant={searchVariant} size="sm" asChild disabled={!canNavigate}>
 				<Link
 					href={canNavigate ? searchPath(effectiveBranch) : "#"}
 					title="Suche öffnen"
+					aria-current={isSearchActive ? "page" : undefined}
 				>
+					<SearchIcon className="h-4 w-4" />
 					Suche
 				</Link>
 			</Button>

+ 40 - 0
components/app-shell/ThemeToggleButton.jsx

@@ -0,0 +1,40 @@
+"use client";
+
+import React from "react";
+import { Moon, Sun } from "lucide-react";
+import { useTheme } from "next-themes";
+
+import { Button } from "@/components/ui/button";
+
+export default function ThemeToggleButton() {
+	const { setTheme } = useTheme();
+
+	function toggleTheme() {
+		// We intentionally read the current state from the DOM class to avoid
+		// hydration-unsafe theme reads during SSR/SSG. :contentReference[oaicite:2]{index=2}
+		const isDark =
+			typeof document !== "undefined" &&
+			document.documentElement.classList.contains("dark");
+
+		setTheme(isDark ? "light" : "dark");
+	}
+
+	return (
+		<Button
+			type="button"
+			variant="ghost"
+			size="icon-sm"
+			onClick={toggleTheme}
+			aria-label="Design umschalten"
+			title="Design umschalten"
+		>
+			{/* Light mode: show Moon (switch to dark) */}
+			<Moon className="h-4 w-4 dark:hidden" aria-hidden="true" />
+
+			{/* Dark mode: show Sun (switch to light) */}
+			<Sun className="hidden h-4 w-4 dark:block" aria-hidden="true" />
+
+			<span className="sr-only">Design umschalten</span>
+		</Button>
+	);
+}

+ 42 - 33
components/app-shell/TopNav.jsx

@@ -1,50 +1,59 @@
 import React from "react";
 import Link from "next/link";
+import Image from "next/image";
 
-import { Button } from "@/components/ui/button";
-import UserStatus from "@/components/app-shell/UserStatus";
-import LogoutButton from "@/components/auth/LogoutButton";
 import QuickNav from "@/components/app-shell/QuickNav";
+import UserStatus from "@/components/app-shell/UserStatus";
+import ThemeToggleButton from "@/components/app-shell/ThemeToggleButton";
 
 export default function TopNav() {
 	return (
 		<header className="sticky top-0 z-50 w-full border-b bg-background/80 backdrop-blur">
-			{/* 
-				TopNav alignment strategy (2xl+):
-				- Use the same 45% center column as the AppShell content grid.
-				- This keeps header content perfectly aligned with Explorer/Search.
-
-				Below 2xl:
-				- Full width for usability.
-			*/}
 			<div className="px-4">
 				<div className="mx-auto grid h-14 w-full items-center 2xl:grid-cols-[1fr_minmax(0,45%)_1fr]">
-					<div className="flex items-center justify-between gap-3 2xl:col-start-2">
-						<div className="flex items-center gap-3">
-							<Link href="/" className="font-semibold tracking-tight">
-								RHL Lieferscheine
-							</Link>
-							<span className="text-xs text-muted-foreground">
-								Lieferschein-Explorer
-							</span>
-						</div>
-
+					<div className="flex items-center justify-between gap-4 2xl:col-start-2">
 						<div className="flex items-center gap-2">
-							<UserStatus />
+							<Link
+								href="/"
+								title="Startseite"
+								className="hover:cursor-pointer"
+							>
+								{/* 
+									Logo rendering:
+									- Use `fill` so we don't need to hardcode width/height for string src.
+									- This avoids Next/Image runtime errors and keeps aspect ratio via object-contain. :contentReference[oaicite:2]{index=2}
+								*/}
+								<div className="relative h-10 w-16">
+									<Image
+										src="/brand/logo-blackNav.png"
+										alt="Firmenlogo"
+										fill
+										sizes="64px"
+										className="object-contain dark:hidden"
+										priority
+									/>
+									<Image
+										src="/brand/logo-whiteNav.png"
+										alt="Firmenlogo"
+										fill
+										sizes="64px"
+										className="hidden object-contain dark:block"
+										priority
+									/>
+								</div>
+							</Link>
 
-							<QuickNav />
+							<nav aria-label="Hauptnavigation" className="flex items-center">
+								<QuickNav />
+							</nav>
+						</div>
 
-							<Button
-								variant="outline"
-								size="sm"
-								disabled
-								aria-disabled="true"
-								title="Design-Umschaltung kommt später"
-							>
-								Design
-							</Button>
+						<div className="flex items-center gap-3">
+							{/* Icon-only theme toggle */}
+							<ThemeToggleButton />
 
-							<LogoutButton />
+							{/* User is now the dropdown trigger (settings/logout) */}
+							<UserStatus />
 						</div>
 					</div>
 				</div>

+ 88 - 11
components/app-shell/UserStatus.jsx

@@ -1,19 +1,32 @@
 "use client";
 
 import React from "react";
+import Link from "next/link";
+import { KeyRound, LogOut, Settings, User } from "lucide-react";
+
 import { useAuth } from "@/components/auth/authContext";
+import { logout } from "@/lib/frontend/apiClient";
+import { buildLoginUrl, LOGIN_REASONS } from "@/lib/frontend/authRedirect";
+
+import { Button } from "@/components/ui/button";
+import {
+	DropdownMenu,
+	DropdownMenuContent,
+	DropdownMenuItem,
+	DropdownMenuLabel,
+	DropdownMenuSeparator,
+	DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
 
 /**
  * UserStatus (RHL-020)
  *
- * Responsibilities:
+ * Updated responsibilities (RHL-032):
  * - Display minimal session info in the TopNav.
- *
- * Data source:
- * - AuthContext (provided by components/auth/AuthProvider.jsx)
+ * - Act as a user action menu trigger (settings/logout).
  *
  * UX rule:
- * - All user-facing text must be German.
+ * - All user-facing strings must be German.
  */
 export default function UserStatus() {
 	const { status, user } = useAuth();
@@ -25,20 +38,84 @@ export default function UserStatus() {
 		return role ? String(role) : "Unbekannt";
 	}
 
-	let text = "Nicht geladen";
+	const isAuthenticated = status === "authenticated" && user;
 
+	let text = "Nicht geladen";
 	if (status === "loading") text = "Lädt…";
-	if (status === "authenticated" && user) {
+	if (isAuthenticated) {
 		const roleLabel = formatRole(user.role);
 		text = user.branchId ? `${roleLabel} (${user.branchId})` : roleLabel;
 	}
 	if (status === "unauthenticated") text = "Abgemeldet";
 	if (status === "error") text = "Fehler";
 
+	async function handleLogout() {
+		try {
+			await logout();
+		} catch (err) {
+			// Logout is idempotent; we still redirect to login for predictable UX.
+			console.error("[UserStatus] logout failed:", err);
+		}
+
+		const loginUrl = buildLoginUrl({ reason: LOGIN_REASONS.LOGGED_OUT });
+		window.location.replace(loginUrl);
+	}
+
 	return (
-		<div className="hidden items-center gap-2 md:flex">
-			<span className="text-xs text-muted-foreground">Benutzer:</span>
-			<span className="text-xs">{text}</span>
-		</div>
+		<DropdownMenu>
+			<DropdownMenuTrigger asChild>
+				<Button
+					type="button"
+					variant="ghost"
+					size="sm"
+					className="gap-2"
+					aria-label="Benutzermenü öffnen"
+					title="Benutzermenü"
+				>
+					<User className="h-4 w-4" aria-hidden="true" />
+
+					{/* Keep the label visible on md+ like before, but keep menu accessible on mobile */}
+					<span className="hidden text-xs md:inline">{text}</span>
+				</Button>
+			</DropdownMenuTrigger>
+
+			<DropdownMenuContent align="end" className="min-w-56">
+				<DropdownMenuLabel>Benutzer</DropdownMenuLabel>
+
+				<div className="px-2 pb-2 text-xs text-muted-foreground">
+					{isAuthenticated
+						? `Angemeldet als: ${text}`
+						: "Keine aktive Sitzung."}
+				</div>
+
+				<DropdownMenuSeparator />
+
+				<DropdownMenuItem asChild disabled={!isAuthenticated}>
+					<Link href="/settings" className="flex w-full items-center gap-2">
+						<Settings className="h-4 w-4" aria-hidden="true" />
+						Einstellungen
+					</Link>
+				</DropdownMenuItem>
+
+				<DropdownMenuItem disabled title="Kommt später">
+					<KeyRound className="h-4 w-4" aria-hidden="true" />
+					Passwort ändern
+				</DropdownMenuItem>
+
+				<DropdownMenuSeparator />
+
+				<DropdownMenuItem
+					variant="destructive"
+					disabled={!isAuthenticated}
+					onSelect={(e) => {
+						e.preventDefault();
+						handleLogout();
+					}}
+				>
+					<LogOut className="h-4 w-4" aria-hidden="true" />
+					Abmelden
+				</DropdownMenuItem>
+			</DropdownMenuContent>
+		</DropdownMenu>
 	);
 }