QuickNav.jsx 9.9 KB

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