QuickNav.jsx 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289
  1. "use client";
  2. import React from "react";
  3. import Link from "next/link";
  4. import { usePathname, useRouter } from "next/navigation";
  5. import { FolderOpen, Search as SearchIcon } from "lucide-react";
  6. import { useAuth } from "@/components/auth/authContext";
  7. import { getBranches } from "@/lib/frontend/apiClient";
  8. import { branchPath, searchPath } from "@/lib/frontend/routes";
  9. import { isValidBranchParam } from "@/lib/frontend/params";
  10. import {
  11. buildNextUrlForBranchSwitch,
  12. readRouteBranchFromPathname,
  13. safeReadLocalStorageBranch,
  14. safeWriteLocalStorageBranch,
  15. } from "@/lib/frontend/quickNav/branchSwitch";
  16. import {
  17. getPrimaryNavFromPathname,
  18. PRIMARY_NAV,
  19. } from "@/lib/frontend/nav/activeRoute";
  20. import { Button } from "@/components/ui/button";
  21. import {
  22. DropdownMenu,
  23. DropdownMenuContent,
  24. DropdownMenuLabel,
  25. DropdownMenuRadioGroup,
  26. DropdownMenuRadioItem,
  27. DropdownMenuSeparator,
  28. DropdownMenuTrigger,
  29. } from "@/components/ui/dropdown-menu";
  30. const STORAGE_KEY_LAST_BRANCH = "rhl_last_branch";
  31. const BRANCH_LIST_STATE = Object.freeze({
  32. IDLE: "idle",
  33. LOADING: "loading",
  34. READY: "ready",
  35. ERROR: "error",
  36. });
  37. export default function QuickNav() {
  38. const router = useRouter();
  39. const pathname = usePathname() || "/";
  40. const { status, user, retry } = useAuth();
  41. const isAuthenticated = status === "authenticated" && user;
  42. const isAdminDev =
  43. isAuthenticated && (user.role === "admin" || user.role === "dev");
  44. const isBranchUser = isAuthenticated && user.role === "branch";
  45. const canRevalidate = typeof retry === "function";
  46. // Persisted selection for admin/dev:
  47. // - Used as the "safe fallback" when the route branch is invalid/non-existent.
  48. const [selectedBranch, setSelectedBranch] = React.useState(null);
  49. const [branchList, setBranchList] = React.useState({
  50. status: BRANCH_LIST_STATE.IDLE,
  51. branches: null,
  52. });
  53. const activePrimaryNav = React.useMemo(() => {
  54. return getPrimaryNavFromPathname(pathname);
  55. }, [pathname]);
  56. const isExplorerActive = activePrimaryNav?.active === PRIMARY_NAV.EXPLORER;
  57. const isSearchActive = activePrimaryNav?.active === PRIMARY_NAV.SEARCH;
  58. const routeBranch = React.useMemo(() => {
  59. return readRouteBranchFromPathname(pathname);
  60. }, [pathname]);
  61. const knownBranches =
  62. branchList.status === BRANCH_LIST_STATE.READY &&
  63. Array.isArray(branchList.branches)
  64. ? branchList.branches
  65. : null;
  66. const isKnownRouteBranch = React.useMemo(() => {
  67. if (!routeBranch) return false;
  68. if (!knownBranches) return false; // do not "trust" route until we know the branch list
  69. return knownBranches.includes(routeBranch);
  70. }, [routeBranch, knownBranches]);
  71. React.useEffect(() => {
  72. if (!isAuthenticated) return;
  73. // Branch users: fixed branch, no persisted selection needed.
  74. if (isBranchUser) {
  75. const own = user.branchId;
  76. setSelectedBranch(own && isValidBranchParam(own) ? own : null);
  77. return;
  78. }
  79. // Admin/dev: initialize from localStorage only.
  80. // IMPORTANT:
  81. // We do NOT initialize from the route branch, because invalid-but-syntactically-valid
  82. // branches (e.g. NL200) would pollute state and cause thrashing.
  83. const fromStorage = safeReadLocalStorageBranch(STORAGE_KEY_LAST_BRANCH);
  84. if (fromStorage && fromStorage !== selectedBranch) {
  85. setSelectedBranch(fromStorage);
  86. }
  87. }, [isAuthenticated, isBranchUser, user?.branchId, selectedBranch]);
  88. React.useEffect(() => {
  89. // Fetch the branch list once for admin/dev users (or when the user changes).
  90. if (!isAdminDev) return;
  91. let cancelled = false;
  92. setBranchList({ status: BRANCH_LIST_STATE.LOADING, branches: null });
  93. (async () => {
  94. try {
  95. const res = await getBranches();
  96. if (cancelled) return;
  97. const branches = Array.isArray(res?.branches) ? res.branches : [];
  98. setBranchList({ status: BRANCH_LIST_STATE.READY, branches });
  99. } catch (err) {
  100. if (cancelled) return;
  101. // Fail open: do not block navigation if validation fails.
  102. console.error("[QuickNav] getBranches failed:", err);
  103. setBranchList({ status: BRANCH_LIST_STATE.ERROR, branches: null });
  104. }
  105. })();
  106. return () => {
  107. cancelled = true;
  108. };
  109. }, [isAdminDev, user?.userId]);
  110. React.useEffect(() => {
  111. // Once we know the branch list, keep selectedBranch valid and stable.
  112. if (!isAdminDev) return;
  113. if (!knownBranches || knownBranches.length === 0) return;
  114. if (!selectedBranch || !knownBranches.includes(selectedBranch)) {
  115. const next = knownBranches[0];
  116. setSelectedBranch(next);
  117. safeWriteLocalStorageBranch(STORAGE_KEY_LAST_BRANCH, next);
  118. }
  119. }, [isAdminDev, knownBranches, selectedBranch]);
  120. React.useEffect(() => {
  121. // Sync selectedBranch to the current route branch ONLY when that route branch is known to exist.
  122. // This prevents the "NL200 thrash" while still keeping the dropdown in sync for valid routes.
  123. if (!isAdminDev) return;
  124. if (!isKnownRouteBranch) return;
  125. if (!routeBranch) return;
  126. if (routeBranch !== selectedBranch) {
  127. setSelectedBranch(routeBranch);
  128. safeWriteLocalStorageBranch(STORAGE_KEY_LAST_BRANCH, routeBranch);
  129. }
  130. }, [isAdminDev, isKnownRouteBranch, routeBranch, selectedBranch]);
  131. React.useEffect(() => {
  132. if (!isAdminDev) return;
  133. if (!selectedBranch) return;
  134. // Keep localStorage in sync (defense-in-depth).
  135. safeWriteLocalStorageBranch(STORAGE_KEY_LAST_BRANCH, selectedBranch);
  136. }, [isAdminDev, selectedBranch]);
  137. if (!isAuthenticated) return null;
  138. // Effective branch for navigation:
  139. // - Branch users: always their own branch
  140. // - Admin/dev: always the persisted/validated selection (NOT the route branch)
  141. // This guarantees that nav buttons still work even when the user is on /NL200.
  142. const effectiveBranch = isBranchUser ? user.branchId : selectedBranch;
  143. const canNavigate = Boolean(
  144. effectiveBranch && isValidBranchParam(effectiveBranch),
  145. );
  146. function navigateToBranchKeepingContext(nextBranch) {
  147. if (!isValidBranchParam(nextBranch)) return;
  148. const currentPathname =
  149. typeof window !== "undefined"
  150. ? window.location.pathname || pathname
  151. : pathname;
  152. const currentSearch =
  153. typeof window !== "undefined" ? window.location.search || "" : "";
  154. const nextUrl = buildNextUrlForBranchSwitch({
  155. pathname: currentPathname,
  156. search: currentSearch,
  157. nextBranch,
  158. });
  159. if (!nextUrl) return;
  160. // Trigger a session revalidation without causing content flicker.
  161. if (canRevalidate) retry();
  162. router.push(nextUrl);
  163. }
  164. const explorerVariant = isExplorerActive ? "secondary" : "ghost";
  165. const searchVariant = isSearchActive ? "secondary" : "ghost";
  166. return (
  167. <div className="hidden items-center gap-2 md:flex">
  168. {isAdminDev ? (
  169. <DropdownMenu>
  170. <DropdownMenuTrigger asChild>
  171. <Button
  172. variant="outline"
  173. size="sm"
  174. type="button"
  175. title="Niederlassung auswählen"
  176. >
  177. {canNavigate ? effectiveBranch : "Niederlassung wählen"}
  178. </Button>
  179. </DropdownMenuTrigger>
  180. <DropdownMenuContent align="end" className="min-w-56">
  181. <DropdownMenuLabel>Niederlassung</DropdownMenuLabel>
  182. <DropdownMenuSeparator />
  183. {branchList.status === BRANCH_LIST_STATE.ERROR ? (
  184. <div className="px-2 py-2 text-xs text-muted-foreground">
  185. Konnte nicht geladen werden.
  186. </div>
  187. ) : (
  188. <DropdownMenuRadioGroup
  189. value={canNavigate ? effectiveBranch : ""}
  190. onValueChange={(value) => {
  191. if (!value) return;
  192. if (!isValidBranchParam(value)) return;
  193. setSelectedBranch(value);
  194. safeWriteLocalStorageBranch(STORAGE_KEY_LAST_BRANCH, value);
  195. navigateToBranchKeepingContext(value);
  196. }}
  197. >
  198. {(Array.isArray(branchList.branches)
  199. ? branchList.branches
  200. : []
  201. ).map((b) => (
  202. <DropdownMenuRadioItem key={b} value={b}>
  203. {b}
  204. </DropdownMenuRadioItem>
  205. ))}
  206. </DropdownMenuRadioGroup>
  207. )}
  208. </DropdownMenuContent>
  209. </DropdownMenu>
  210. ) : null}
  211. <Button
  212. variant={explorerVariant}
  213. size="sm"
  214. asChild
  215. disabled={!canNavigate}
  216. >
  217. <Link
  218. href={canNavigate ? branchPath(effectiveBranch) : "#"}
  219. title="Explorer öffnen"
  220. aria-current={isExplorerActive ? "page" : undefined}
  221. >
  222. <FolderOpen className="h-4 w-4" />
  223. Explorer
  224. </Link>
  225. </Button>
  226. <Button variant={searchVariant} size="sm" asChild disabled={!canNavigate}>
  227. <Link
  228. href={canNavigate ? searchPath(effectiveBranch) : "#"}
  229. title="Suche öffnen"
  230. aria-current={isSearchActive ? "page" : undefined}
  231. >
  232. <SearchIcon className="h-4 w-4" />
  233. Suche
  234. </Link>
  235. </Button>
  236. </div>
  237. );
  238. }