QuickNav.jsx 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371
  1. "use client";
  2. import React from "react";
  3. import Link from "next/link";
  4. import { usePathname, useRouter } from "next/navigation";
  5. import {
  6. FolderOpen,
  7. Search as SearchIcon,
  8. TriangleAlert,
  9. CornerDownLeft,
  10. } from "lucide-react";
  11. import { useAuth } from "@/components/auth/authContext";
  12. import { getBranches } from "@/lib/frontend/apiClient";
  13. import { branchPath, searchPath } from "@/lib/frontend/routes";
  14. import { isValidBranchParam } from "@/lib/frontend/params";
  15. import {
  16. buildNextUrlForBranchSwitch,
  17. readRouteBranchFromPathname,
  18. safeReadLocalStorageBranch,
  19. safeWriteLocalStorageBranch,
  20. } from "@/lib/frontend/quickNav/branchSwitch";
  21. import {
  22. getPrimaryNavFromPathname,
  23. PRIMARY_NAV,
  24. } from "@/lib/frontend/nav/activeRoute";
  25. import { isAdminLike as isAdminLikeRole } from "@/lib/frontend/auth/roles";
  26. import { Button } from "@/components/ui/button";
  27. import {
  28. DropdownMenu,
  29. DropdownMenuContent,
  30. DropdownMenuItem,
  31. DropdownMenuLabel,
  32. DropdownMenuRadioGroup,
  33. DropdownMenuRadioItem,
  34. DropdownMenuSeparator,
  35. DropdownMenuTrigger,
  36. } from "@/components/ui/dropdown-menu";
  37. import {
  38. Tooltip,
  39. TooltipContent,
  40. TooltipTrigger,
  41. } from "@/components/ui/tooltip";
  42. const STORAGE_KEY_LAST_BRANCH = "rhl_last_branch";
  43. const BRANCH_LIST_STATE = Object.freeze({
  44. IDLE: "idle",
  45. LOADING: "loading",
  46. READY: "ready",
  47. ERROR: "error",
  48. });
  49. // Header polish:
  50. // - remove subtle outline shadow (crisp header UI)
  51. // - normalize padding when an icon is present
  52. const TOPNAV_BUTTON_CLASS = "shadow-none has-[>svg]:px-3";
  53. // Active nav style (blue like multi-branch selection)
  54. const ACTIVE_NAV_BUTTON_CLASS =
  55. "border-blue-600 bg-blue-50 text-blue-900 hover:bg-blue-50 " +
  56. "dark:border-blue-900 dark:bg-blue-950 dark:text-blue-50 dark:hover:bg-blue-950";
  57. export default function QuickNav() {
  58. const router = useRouter();
  59. const pathname = usePathname() || "/";
  60. const { status, user, retry } = useAuth();
  61. const isAuthenticated = status === "authenticated" && user;
  62. const isAdminLike = isAuthenticated && isAdminLikeRole(user.role);
  63. const isBranchUser = isAuthenticated && user.role === "branch";
  64. const canRevalidate = typeof retry === "function";
  65. // Persisted selection for admin-like users:
  66. const [selectedBranch, setSelectedBranch] = React.useState(null);
  67. // Init gate: prevent “localStorage sync” from running on every state change
  68. const initRef = React.useRef({ userId: null });
  69. const [branchList, setBranchList] = React.useState({
  70. status: BRANCH_LIST_STATE.IDLE,
  71. branches: null,
  72. });
  73. const activePrimaryNav = React.useMemo(() => {
  74. return getPrimaryNavFromPathname(pathname);
  75. }, [pathname]);
  76. const isExplorerActive = activePrimaryNav?.active === PRIMARY_NAV.EXPLORER;
  77. const isSearchActive = activePrimaryNav?.active === PRIMARY_NAV.SEARCH;
  78. const routeBranch = React.useMemo(() => {
  79. return readRouteBranchFromPathname(pathname);
  80. }, [pathname]);
  81. const knownBranches =
  82. branchList.status === BRANCH_LIST_STATE.READY &&
  83. Array.isArray(branchList.branches)
  84. ? branchList.branches
  85. : null;
  86. const hasInvalidRouteBranch = Boolean(
  87. isAdminLike &&
  88. routeBranch &&
  89. knownBranches &&
  90. !knownBranches.includes(routeBranch),
  91. );
  92. // A) Initialize selectedBranch once per authenticated admin-like user
  93. React.useEffect(() => {
  94. if (!isAuthenticated) {
  95. initRef.current.userId = null;
  96. setSelectedBranch(null);
  97. return;
  98. }
  99. if (isBranchUser) {
  100. const own = user.branchId;
  101. setSelectedBranch(own && isValidBranchParam(own) ? own : null);
  102. return;
  103. }
  104. if (!isAdminLike) return;
  105. const uid = String(user.userId || "");
  106. if (!uid) return;
  107. if (initRef.current.userId === uid) return;
  108. initRef.current.userId = uid;
  109. const fromStorage = safeReadLocalStorageBranch(STORAGE_KEY_LAST_BRANCH);
  110. setSelectedBranch(fromStorage || null);
  111. }, [
  112. isAuthenticated,
  113. isBranchUser,
  114. isAdminLike,
  115. user?.userId,
  116. user?.branchId,
  117. ]);
  118. // B) Fetch branch list once for admin-like users
  119. React.useEffect(() => {
  120. if (!isAdminLike) return;
  121. let cancelled = false;
  122. setBranchList({ status: BRANCH_LIST_STATE.LOADING, branches: null });
  123. (async () => {
  124. try {
  125. const res = await getBranches();
  126. if (cancelled) return;
  127. const branches = Array.isArray(res?.branches) ? res.branches : [];
  128. setBranchList({ status: BRANCH_LIST_STATE.READY, branches });
  129. } catch (err) {
  130. if (cancelled) return;
  131. console.error("[QuickNav] getBranches failed:", err);
  132. setBranchList({ status: BRANCH_LIST_STATE.ERROR, branches: null });
  133. }
  134. })();
  135. return () => {
  136. cancelled = true;
  137. };
  138. }, [isAdminLike, user?.userId]);
  139. // C) Ensure selectedBranch is valid once we have the list
  140. React.useEffect(() => {
  141. if (!isAdminLike) return;
  142. if (!knownBranches || knownBranches.length === 0) return;
  143. if (selectedBranch && knownBranches.includes(selectedBranch)) return;
  144. const next = knownBranches[0];
  145. setSelectedBranch(next);
  146. safeWriteLocalStorageBranch(STORAGE_KEY_LAST_BRANCH, next);
  147. }, [isAdminLike, knownBranches, selectedBranch]);
  148. // D) Sync selectedBranch to the current route branch ONLY if it exists
  149. React.useEffect(() => {
  150. if (!isAdminLike) return;
  151. if (!routeBranch) return;
  152. if (!knownBranches) return;
  153. if (!knownBranches.includes(routeBranch)) return;
  154. if (routeBranch === selectedBranch) return;
  155. setSelectedBranch(routeBranch);
  156. safeWriteLocalStorageBranch(STORAGE_KEY_LAST_BRANCH, routeBranch);
  157. }, [isAdminLike, routeBranch, knownBranches, selectedBranch]);
  158. if (!isAuthenticated) return null;
  159. const effectiveBranch = isBranchUser ? user.branchId : selectedBranch;
  160. const canNavigate = Boolean(
  161. effectiveBranch && isValidBranchParam(effectiveBranch),
  162. );
  163. function navigateToBranchKeepingContext(nextBranch) {
  164. if (!isValidBranchParam(nextBranch)) return;
  165. const currentPathname =
  166. typeof window !== "undefined"
  167. ? window.location.pathname || pathname
  168. : pathname;
  169. const currentSearch =
  170. typeof window !== "undefined" ? window.location.search || "" : "";
  171. const nextUrl = buildNextUrlForBranchSwitch({
  172. pathname: currentPathname,
  173. search: currentSearch,
  174. nextBranch,
  175. });
  176. if (!nextUrl) return;
  177. if (canRevalidate) retry();
  178. router.push(nextUrl);
  179. }
  180. const branchTooltipText = hasInvalidRouteBranch
  181. ? `Achtung: ${routeBranch} existiert nicht. Bitte eine gültige Niederlassung wählen.`
  182. : "Niederlassung auswählen";
  183. return (
  184. <div className="hidden items-center gap-2 md:flex">
  185. {isAdminLike ? (
  186. <DropdownMenu>
  187. <Tooltip>
  188. <TooltipTrigger asChild>
  189. <DropdownMenuTrigger asChild>
  190. <Button
  191. variant="outline"
  192. size="sm"
  193. type="button"
  194. className={TOPNAV_BUTTON_CLASS}
  195. aria-label={branchTooltipText}
  196. >
  197. {canNavigate ? effectiveBranch : "Niederlassung wählen"}
  198. {hasInvalidRouteBranch ? (
  199. <TriangleAlert
  200. className="h-4 w-4 text-destructive"
  201. aria-hidden="true"
  202. />
  203. ) : null}
  204. </Button>
  205. </DropdownMenuTrigger>
  206. </TooltipTrigger>
  207. <TooltipContent side="bottom">{branchTooltipText}</TooltipContent>
  208. </Tooltip>
  209. <DropdownMenuContent align="end" className="min-w-64">
  210. <DropdownMenuLabel>Niederlassung</DropdownMenuLabel>
  211. <DropdownMenuSeparator />
  212. {hasInvalidRouteBranch ? (
  213. <>
  214. <div className="px-2 py-2 text-xs text-destructive">
  215. Die URL-Niederlassung <strong>{routeBranch}</strong> existiert
  216. nicht. Bitte wählen Sie eine gültige Niederlassung aus.
  217. </div>
  218. <DropdownMenuItem
  219. disabled={!canNavigate}
  220. onSelect={(e) => {
  221. e.preventDefault();
  222. if (!canNavigate) return;
  223. navigateToBranchKeepingContext(effectiveBranch);
  224. }}
  225. >
  226. <CornerDownLeft className="h-4 w-4" aria-hidden="true" />
  227. Zur letzten gültigen Niederlassung
  228. <span className="ml-auto text-xs text-muted-foreground">
  229. {canNavigate ? effectiveBranch : ""}
  230. </span>
  231. </DropdownMenuItem>
  232. <DropdownMenuSeparator />
  233. </>
  234. ) : null}
  235. {branchList.status === BRANCH_LIST_STATE.ERROR ? (
  236. <div className="px-2 py-2 text-xs text-muted-foreground">
  237. Konnte nicht geladen werden.
  238. </div>
  239. ) : (
  240. <DropdownMenuRadioGroup
  241. value={canNavigate ? effectiveBranch : ""}
  242. onValueChange={(value) => {
  243. if (!value) return;
  244. if (!isValidBranchParam(value)) return;
  245. setSelectedBranch(value);
  246. safeWriteLocalStorageBranch(STORAGE_KEY_LAST_BRANCH, value);
  247. navigateToBranchKeepingContext(value);
  248. }}
  249. >
  250. {(Array.isArray(branchList.branches)
  251. ? branchList.branches
  252. : []
  253. ).map((b) => (
  254. <DropdownMenuRadioItem key={b} value={b}>
  255. {b}
  256. </DropdownMenuRadioItem>
  257. ))}
  258. </DropdownMenuRadioGroup>
  259. )}
  260. </DropdownMenuContent>
  261. </DropdownMenu>
  262. ) : null}
  263. <Tooltip>
  264. <TooltipTrigger asChild>
  265. <Button
  266. variant="outline"
  267. size="sm"
  268. asChild
  269. disabled={!canNavigate}
  270. className={[
  271. TOPNAV_BUTTON_CLASS,
  272. isExplorerActive ? ACTIVE_NAV_BUTTON_CLASS : "",
  273. ].join(" ")}
  274. aria-label="Explorer öffnen"
  275. >
  276. <Link
  277. href={canNavigate ? branchPath(effectiveBranch) : "#"}
  278. aria-current={isExplorerActive ? "page" : undefined}
  279. >
  280. <FolderOpen className="h-4 w-4" />
  281. Explorer
  282. </Link>
  283. </Button>
  284. </TooltipTrigger>
  285. <TooltipContent side="bottom">Explorer öffnen</TooltipContent>
  286. </Tooltip>
  287. <Tooltip>
  288. <TooltipTrigger asChild>
  289. <Button
  290. variant="outline"
  291. size="sm"
  292. asChild
  293. disabled={!canNavigate}
  294. className={[
  295. TOPNAV_BUTTON_CLASS,
  296. isSearchActive ? ACTIVE_NAV_BUTTON_CLASS : "",
  297. ].join(" ")}
  298. aria-label="Suche öffnen"
  299. >
  300. <Link
  301. href={canNavigate ? searchPath(effectiveBranch) : "#"}
  302. aria-current={isSearchActive ? "page" : undefined}
  303. >
  304. <SearchIcon className="h-4 w-4" />
  305. Suche
  306. </Link>
  307. </Button>
  308. </TooltipTrigger>
  309. <TooltipContent side="bottom">Suche öffnen</TooltipContent>
  310. </Tooltip>
  311. </div>
  312. );
  313. }