QuickNav.jsx 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354
  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. const TOPNAV_BUTTON_CLASS = "shadow-none has-[>svg]:px-3";
  49. const ACTIVE_NAV_BUTTON_CLASS =
  50. "border-blue-600 bg-blue-50 text-blue-900 hover:bg-blue-50 " +
  51. "dark:border-blue-900 dark:bg-blue-950 dark:text-blue-50 dark:hover:bg-blue-950";
  52. export default function QuickNav() {
  53. const router = useRouter();
  54. const pathname = usePathname() || "/";
  55. const { status, user, retry } = useAuth();
  56. const isAuthenticated = status === "authenticated" && user;
  57. const isAdminDev =
  58. isAuthenticated && (user.role === "admin" || user.role === "dev");
  59. const isBranchUser = isAuthenticated && user.role === "branch";
  60. const canRevalidate = typeof retry === "function";
  61. const [selectedBranch, setSelectedBranch] = React.useState(null);
  62. const [branchList, setBranchList] = React.useState({
  63. status: BRANCH_LIST_STATE.IDLE,
  64. branches: null,
  65. });
  66. const activePrimaryNav = React.useMemo(() => {
  67. return getPrimaryNavFromPathname(pathname);
  68. }, [pathname]);
  69. const isExplorerActive = activePrimaryNav?.active === PRIMARY_NAV.EXPLORER;
  70. const isSearchActive = activePrimaryNav?.active === PRIMARY_NAV.SEARCH;
  71. const routeBranch = React.useMemo(() => {
  72. return readRouteBranchFromPathname(pathname);
  73. }, [pathname]);
  74. const knownBranches =
  75. branchList.status === BRANCH_LIST_STATE.READY &&
  76. Array.isArray(branchList.branches)
  77. ? branchList.branches
  78. : null;
  79. const hasInvalidRouteBranch = Boolean(
  80. isAdminDev &&
  81. routeBranch &&
  82. knownBranches &&
  83. !knownBranches.includes(routeBranch),
  84. );
  85. React.useEffect(() => {
  86. if (!isAuthenticated) return;
  87. if (isBranchUser) {
  88. const own = user.branchId;
  89. setSelectedBranch(own && isValidBranchParam(own) ? own : null);
  90. return;
  91. }
  92. const fromStorage = safeReadLocalStorageBranch(STORAGE_KEY_LAST_BRANCH);
  93. if (fromStorage && fromStorage !== selectedBranch) {
  94. setSelectedBranch(fromStorage);
  95. }
  96. }, [isAuthenticated, isBranchUser, user?.branchId, selectedBranch]);
  97. React.useEffect(() => {
  98. if (!isAdminDev) return;
  99. let cancelled = false;
  100. setBranchList({ status: BRANCH_LIST_STATE.LOADING, branches: null });
  101. (async () => {
  102. try {
  103. const res = await getBranches();
  104. if (cancelled) return;
  105. const branches = Array.isArray(res?.branches) ? res.branches : [];
  106. setBranchList({ status: BRANCH_LIST_STATE.READY, branches });
  107. } catch (err) {
  108. if (cancelled) return;
  109. console.error("[QuickNav] getBranches failed:", err);
  110. setBranchList({ status: BRANCH_LIST_STATE.ERROR, branches: null });
  111. }
  112. })();
  113. return () => {
  114. cancelled = true;
  115. };
  116. }, [isAdminDev, user?.userId]);
  117. React.useEffect(() => {
  118. if (!isAdminDev) return;
  119. if (!knownBranches || knownBranches.length === 0) return;
  120. if (!selectedBranch || !knownBranches.includes(selectedBranch)) {
  121. const next = knownBranches[0];
  122. setSelectedBranch(next);
  123. safeWriteLocalStorageBranch(STORAGE_KEY_LAST_BRANCH, next);
  124. }
  125. }, [isAdminDev, knownBranches, selectedBranch]);
  126. React.useEffect(() => {
  127. if (!isAdminDev) return;
  128. if (!routeBranch) return;
  129. if (!knownBranches) return;
  130. const isKnownRouteBranch = knownBranches.includes(routeBranch);
  131. if (!isKnownRouteBranch) return;
  132. if (routeBranch !== selectedBranch) {
  133. setSelectedBranch(routeBranch);
  134. safeWriteLocalStorageBranch(STORAGE_KEY_LAST_BRANCH, routeBranch);
  135. }
  136. }, [isAdminDev, routeBranch, knownBranches, selectedBranch]);
  137. React.useEffect(() => {
  138. if (!isAdminDev) return;
  139. if (!selectedBranch) return;
  140. safeWriteLocalStorageBranch(STORAGE_KEY_LAST_BRANCH, selectedBranch);
  141. }, [isAdminDev, selectedBranch]);
  142. if (!isAuthenticated) return null;
  143. const effectiveBranch = isBranchUser ? user.branchId : selectedBranch;
  144. const canNavigate = Boolean(
  145. effectiveBranch && isValidBranchParam(effectiveBranch),
  146. );
  147. function navigateToBranchKeepingContext(nextBranch) {
  148. if (!isValidBranchParam(nextBranch)) return;
  149. const currentPathname =
  150. typeof window !== "undefined"
  151. ? window.location.pathname || pathname
  152. : pathname;
  153. const currentSearch =
  154. typeof window !== "undefined" ? window.location.search || "" : "";
  155. const nextUrl = buildNextUrlForBranchSwitch({
  156. pathname: currentPathname,
  157. search: currentSearch,
  158. nextBranch,
  159. });
  160. if (!nextUrl) return;
  161. if (canRevalidate) retry();
  162. router.push(nextUrl);
  163. }
  164. const branchTooltipText = hasInvalidRouteBranch
  165. ? `Achtung: ${routeBranch} existiert nicht. Bitte eine gültige Niederlassung wählen.`
  166. : "Niederlassung auswählen";
  167. return (
  168. <div className="hidden items-center gap-2 md:flex">
  169. {isAdminDev ? (
  170. <DropdownMenu>
  171. <Tooltip>
  172. <TooltipTrigger asChild>
  173. <DropdownMenuTrigger asChild>
  174. <Button
  175. variant="outline"
  176. size="sm"
  177. type="button"
  178. className={TOPNAV_BUTTON_CLASS}
  179. aria-label={branchTooltipText}
  180. >
  181. {canNavigate ? effectiveBranch : "Niederlassung wählen"}
  182. {hasInvalidRouteBranch ? (
  183. <TriangleAlert
  184. className="h-4 w-4 text-destructive"
  185. aria-hidden="true"
  186. />
  187. ) : null}
  188. </Button>
  189. </DropdownMenuTrigger>
  190. </TooltipTrigger>
  191. <TooltipContent side="bottom">{branchTooltipText}</TooltipContent>
  192. </Tooltip>
  193. <DropdownMenuContent align="end" className="min-w-64">
  194. <DropdownMenuLabel>Niederlassung</DropdownMenuLabel>
  195. <DropdownMenuSeparator />
  196. {hasInvalidRouteBranch ? (
  197. <>
  198. <div className="px-2 py-2 text-xs text-destructive">
  199. Die URL-Niederlassung <strong>{routeBranch}</strong> existiert
  200. nicht. Bitte wählen Sie eine gültige Niederlassung aus.
  201. </div>
  202. <DropdownMenuItem
  203. disabled={!canNavigate}
  204. onSelect={(e) => {
  205. e.preventDefault();
  206. if (!canNavigate) return;
  207. navigateToBranchKeepingContext(effectiveBranch);
  208. }}
  209. >
  210. <CornerDownLeft className="h-4 w-4" aria-hidden="true" />
  211. Zur letzten gültigen Niederlassung
  212. <span className="ml-auto text-xs text-muted-foreground">
  213. {canNavigate ? effectiveBranch : ""}
  214. </span>
  215. </DropdownMenuItem>
  216. <DropdownMenuSeparator />
  217. </>
  218. ) : null}
  219. {branchList.status === BRANCH_LIST_STATE.ERROR ? (
  220. <div className="px-2 py-2 text-xs text-muted-foreground">
  221. Konnte nicht geladen werden.
  222. </div>
  223. ) : (
  224. <DropdownMenuRadioGroup
  225. value={canNavigate ? effectiveBranch : ""}
  226. onValueChange={(value) => {
  227. if (!value) return;
  228. if (!isValidBranchParam(value)) return;
  229. setSelectedBranch(value);
  230. safeWriteLocalStorageBranch(STORAGE_KEY_LAST_BRANCH, value);
  231. navigateToBranchKeepingContext(value);
  232. }}
  233. >
  234. {(Array.isArray(branchList.branches)
  235. ? branchList.branches
  236. : []
  237. ).map((b) => (
  238. <DropdownMenuRadioItem key={b} value={b}>
  239. {b}
  240. </DropdownMenuRadioItem>
  241. ))}
  242. </DropdownMenuRadioGroup>
  243. )}
  244. </DropdownMenuContent>
  245. </DropdownMenu>
  246. ) : null}
  247. <Tooltip>
  248. <TooltipTrigger asChild>
  249. <Button
  250. variant="outline"
  251. size="sm"
  252. asChild
  253. disabled={!canNavigate}
  254. className={[
  255. TOPNAV_BUTTON_CLASS,
  256. isExplorerActive ? ACTIVE_NAV_BUTTON_CLASS : "",
  257. ].join(" ")}
  258. aria-label="Explorer öffnen"
  259. >
  260. <Link
  261. href={canNavigate ? branchPath(effectiveBranch) : "#"}
  262. aria-current={isExplorerActive ? "page" : undefined}
  263. >
  264. <FolderOpen className="h-4 w-4" />
  265. Explorer
  266. </Link>
  267. </Button>
  268. </TooltipTrigger>
  269. <TooltipContent side="bottom">Explorer öffnen</TooltipContent>
  270. </Tooltip>
  271. <Tooltip>
  272. <TooltipTrigger asChild>
  273. <Button
  274. variant="outline"
  275. size="sm"
  276. asChild
  277. disabled={!canNavigate}
  278. className={[
  279. TOPNAV_BUTTON_CLASS,
  280. isSearchActive ? ACTIVE_NAV_BUTTON_CLASS : "",
  281. ].join(" ")}
  282. aria-label="Suche öffnen"
  283. >
  284. <Link
  285. href={canNavigate ? searchPath(effectiveBranch) : "#"}
  286. aria-current={isSearchActive ? "page" : undefined}
  287. >
  288. <SearchIcon className="h-4 w-4" />
  289. Suche
  290. </Link>
  291. </Button>
  292. </TooltipTrigger>
  293. <TooltipContent side="bottom">Suche öffnen</TooltipContent>
  294. </Tooltip>
  295. </div>
  296. );
  297. }