فهرست منبع

RHL-024 feat(navigation): add QuickNav component to TopNav for branch selection and navigation

Code_Uwe 3 هفته پیش
والد
کامیت
331c77bd4a
2فایلهای تغییر یافته به همراه211 افزوده شده و 10 حذف شده
  1. 208 0
      components/app-shell/QuickNav.jsx
  2. 3 10
      components/app-shell/TopNav.jsx

+ 208 - 0
components/app-shell/QuickNav.jsx

@@ -0,0 +1,208 @@
+"use client";
+
+import React from "react";
+import Link from "next/link";
+
+import { useAuth } from "@/components/auth/authContext";
+import { getBranches } from "@/lib/frontend/apiClient";
+import { branchPath, searchPath } from "@/lib/frontend/routes";
+import { isValidBranchParam } from "@/lib/frontend/params";
+
+import { Button } from "@/components/ui/button";
+import {
+	DropdownMenu,
+	DropdownMenuContent,
+	DropdownMenuLabel,
+	DropdownMenuRadioGroup,
+	DropdownMenuRadioItem,
+	DropdownMenuSeparator,
+	DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+
+const STORAGE_KEY_LAST_BRANCH = "rhl_last_branch";
+
+const BRANCH_LIST_STATE = Object.freeze({
+	IDLE: "idle",
+	LOADING: "loading",
+	READY: "ready",
+	ERROR: "error",
+});
+
+function readRouteBranchFromPathname(pathname) {
+	if (typeof pathname !== "string" || !pathname.startsWith("/")) return null;
+
+	const seg = pathname.split("/").filter(Boolean)[0] || null;
+	return seg && isValidBranchParam(seg) ? seg : null;
+}
+
+function safeReadLocalStorageBranch() {
+	if (typeof window === "undefined") return null;
+
+	try {
+		const raw = window.localStorage.getItem(STORAGE_KEY_LAST_BRANCH);
+		return raw && isValidBranchParam(raw) ? raw : null;
+	} catch {
+		return null;
+	}
+}
+
+function safeWriteLocalStorageBranch(branch) {
+	if (typeof window === "undefined") return;
+
+	try {
+		window.localStorage.setItem(STORAGE_KEY_LAST_BRANCH, String(branch));
+	} catch {
+		// ignore
+	}
+}
+
+export default function QuickNav() {
+	const { status, user } = useAuth();
+
+	const isAuthenticated = status === "authenticated" && user;
+	const isAdminDev =
+		isAuthenticated && (user.role === "admin" || user.role === "dev");
+	const isBranchUser = isAuthenticated && user.role === "branch";
+
+	const [selectedBranch, setSelectedBranch] = React.useState(null);
+
+	const [branchList, setBranchList] = React.useState({
+		status: BRANCH_LIST_STATE.IDLE,
+		branches: null,
+	});
+
+	// Determine a good default branch:
+	// - branch users: always their own
+	// - admin/dev: prefer current route branch, else last stored branch, else first fetched branch
+	React.useEffect(() => {
+		if (!isAuthenticated) return;
+
+		if (isBranchUser) {
+			const own = user.branchId;
+			setSelectedBranch(own && isValidBranchParam(own) ? own : null);
+			return;
+		}
+
+		// admin/dev
+		const fromRoute = readRouteBranchFromPathname(window.location.pathname);
+		const fromStorage = safeReadLocalStorageBranch();
+
+		const initial = fromRoute || fromStorage || null;
+		if (initial) setSelectedBranch(initial);
+	}, [isAuthenticated, isBranchUser, user?.branchId]);
+
+	// Fetch branches for admin/dev dropdown (fail-open; used only for convenience).
+	React.useEffect(() => {
+		if (!isAdminDev) return;
+
+		let cancelled = false;
+
+		setBranchList({ status: BRANCH_LIST_STATE.LOADING, branches: null });
+
+		(async () => {
+			try {
+				const res = await getBranches();
+				if (cancelled) return;
+
+				const branches = Array.isArray(res?.branches) ? res.branches : [];
+				setBranchList({ status: BRANCH_LIST_STATE.READY, branches });
+
+				// If nothing selected yet, pick the first available branch as a convenience default.
+				if (!selectedBranch && branches.length > 0) {
+					setSelectedBranch(branches[0]);
+					safeWriteLocalStorageBranch(branches[0]);
+				}
+			} catch (err) {
+				if (cancelled) return;
+
+				console.error("[QuickNav] getBranches failed:", err);
+				setBranchList({ status: BRANCH_LIST_STATE.ERROR, branches: null });
+			}
+		})();
+
+		return () => {
+			cancelled = true;
+		};
+	}, [isAdminDev, selectedBranch]);
+
+	React.useEffect(() => {
+		// Persist selection for admin/dev convenience.
+		if (!isAdminDev) return;
+		if (!selectedBranch) return;
+
+		safeWriteLocalStorageBranch(selectedBranch);
+	}, [isAdminDev, selectedBranch]);
+
+	if (!isAuthenticated) return null;
+
+	const effectiveBranch = isBranchUser ? user.branchId : selectedBranch;
+	const canNavigate = Boolean(
+		effectiveBranch && isValidBranchParam(effectiveBranch)
+	);
+
+	// Keep TopNav clean on small screens.
+	return (
+		<div className="hidden items-center gap-2 md:flex">
+			{isAdminDev ? (
+				<DropdownMenu>
+					<DropdownMenuTrigger asChild>
+						<Button
+							variant="outline"
+							size="sm"
+							type="button"
+							title="Niederlassung auswählen"
+						>
+							{canNavigate ? effectiveBranch : "Niederlassung wählen"}
+						</Button>
+					</DropdownMenuTrigger>
+
+					<DropdownMenuContent align="end" className="min-w-[14rem]">
+						<DropdownMenuLabel>Niederlassung</DropdownMenuLabel>
+						<DropdownMenuSeparator />
+
+						{branchList.status === BRANCH_LIST_STATE.ERROR ? (
+							<div className="px-2 py-2 text-xs text-muted-foreground">
+								Konnte nicht geladen werden.
+							</div>
+						) : (
+							<DropdownMenuRadioGroup
+								value={canNavigate ? effectiveBranch : ""}
+								onValueChange={(value) => {
+									if (!value) return;
+									setSelectedBranch(value);
+								}}
+							>
+								{(Array.isArray(branchList.branches)
+									? branchList.branches
+									: []
+								).map((b) => (
+									<DropdownMenuRadioItem key={b} value={b}>
+										{b}
+									</DropdownMenuRadioItem>
+								))}
+							</DropdownMenuRadioGroup>
+						)}
+					</DropdownMenuContent>
+				</DropdownMenu>
+			) : null}
+
+			<Button variant="outline" size="sm" asChild disabled={!canNavigate}>
+				<Link
+					href={canNavigate ? branchPath(effectiveBranch) : "#"}
+					title="Explorer öffnen"
+				>
+					Explorer
+				</Link>
+			</Button>
+
+			<Button variant="outline" size="sm" asChild disabled={!canNavigate}>
+				<Link
+					href={canNavigate ? searchPath(effectiveBranch) : "#"}
+					title="Suche öffnen"
+				>
+					Suche
+				</Link>
+			</Button>
+		</div>
+	);
+}

+ 3 - 10
components/app-shell/TopNav.jsx

@@ -4,17 +4,8 @@ import Link from "next/link";
 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";
 
-/**
- * TopNav
- *
- * RHL-020:
- * - UserStatus displays session info (via AuthContext).
- * - Logout button is functional (calls apiClient.logout + redirects to /login).
- *
- * UX rule:
- * - All user-facing text must be German.
- */
 export default function TopNav() {
 	return (
 		<header className="sticky top-0 z-50 w-full border-b bg-background/80 backdrop-blur">
@@ -31,6 +22,8 @@ export default function TopNav() {
 				<div className="flex items-center gap-2">
 					<UserStatus />
 
+					<QuickNav />
+
 					<Button
 						variant="outline"
 						size="sm"