@eintrek/erp-shell 0.1.59 → 0.1.61

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.esm.js CHANGED
@@ -2,10 +2,10 @@
2
2
  import * as React from 'react';
3
3
  import React__default, { useRef, useEffect, useMemo, useState, useCallback, createContext, useContext, Fragment, useSyncExternalStore, useTransition, Suspense } from 'react';
4
4
  import { useSession, signOut } from 'next-auth/react';
5
- import { useQueryClient, useQuery, QueryClientContext, QueryClient, MutationCache, QueryCache, QueryClientProvider } from '@tanstack/react-query';
5
+ import { useQueryClient, useQuery, QueryClientContext, QueryClient, hashKey, MutationCache, QueryCache, QueryClientProvider } from '@tanstack/react-query';
6
6
  import { jsx, jsxs, Fragment as Fragment$1 } from 'react/jsx-runtime';
7
- import { cn, Button, Breadcrumb, BreadcrumbList, BreadcrumbItem, BreadcrumbLink, BreadcrumbSeparator, BreadcrumbPage, Avatar, AvatarImage, AvatarFallback, Collapsible, CollapsibleTrigger, CollapsibleContent, Badge, DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuLabel, DropdownMenuItem, DropdownMenuSeparator, Skeleton, Dialog, DialogContent, DialogHeader, DialogTitle, Label, Textarea, DialogFooter, Card, CardHeader, CardContent } from '@eintrek/erp-theme';
8
- import { PanelLeftClose, Menu, ChevronRight, Building2, Palette, ChevronUp, Sun, Moon, Monitor, Globe, User, LayoutGrid, ExternalLink, CircleHelp, Loader2, LogOut, Calculator, Warehouse, FolderKanban, Users, Store, Headset, ChevronDown, Plus, ChevronsUpDown, ImagePlus, X, CircleCheck, ArrowLeft, ArrowRight, Compass, ArrowUpRight, Ban, Home, ContactRound, UserPlus } from 'lucide-react';
7
+ import { cn, Button, Tooltip, TooltipTrigger, TooltipContent, Breadcrumb, BreadcrumbList, BreadcrumbItem, BreadcrumbLink, BreadcrumbSeparator, BreadcrumbPage, Avatar, AvatarImage, AvatarFallback, Collapsible, CollapsibleTrigger, CollapsibleContent, Badge, DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuLabel, DropdownMenuItem, DropdownMenuSeparator, Skeleton, Dialog, DialogContent, DialogHeader, DialogTitle, Label, Textarea, DialogFooter, Card, CardHeader, CardContent } from '@eintrek/erp-theme';
8
+ import { PanelLeftClose, Menu, ChevronRight, Building2, Palette, ChevronUp, Sun, Moon, Monitor, Globe, User, LayoutGrid, ExternalLink, CircleHelp, Loader2, LogOut, Calculator, Warehouse, FolderKanban, Users, Store, Headset, ChevronDown, Plus, ChevronsUpDown, ImagePlus, X, CircleCheck, ArrowLeft, Ban, Home, ArrowRight, Compass, ArrowUpRight, ContactRound, UserPlus } from 'lucide-react';
9
9
  import { create } from 'zustand';
10
10
  import { persist, createJSONStorage } from 'zustand/middleware';
11
11
  import { useRouter, usePathname, useParams } from 'next/navigation';
@@ -240,6 +240,221 @@ async function runWithTransientRetry(fn, attempts = 3) {
240
240
  throw last;
241
241
  }
242
242
 
243
+ /**
244
+ * The organisation this browser tab is working in.
245
+ *
246
+ * Every request carries an X-Organization-ID header, and every cached query
247
+ * belongs to one organisation. Both used to come from the `erp.organizationId`
248
+ * cookie (then localStorage), which is the organisation the BROWSER last had
249
+ * active — shared by every tab and, on antniti.com, by every app. A tab
250
+ * showing /O26000021/… therefore sent whatever another tab, or another app,
251
+ * had picked last, and the URL lost.
252
+ *
253
+ * The tab's own value is set by `useOrganizationIdState` — the state that
254
+ * AppLayoutShell drives to the organisation the URL names — and read first by
255
+ * `getRequestOrganizationId()`. Until a tab has one (a cold load before the
256
+ * provider mounts), localStorage and then the cookie answer as before.
257
+ *
258
+ * Module state, so browser-only: on the server one module instance serves
259
+ * every request, and a value written there would leak between users.
260
+ */
261
+ const ORGANIZATION_ID_KEY = "erp.organizationId";
262
+ let tabOrganizationId = null;
263
+ const isBrowser$1 = () => typeof window !== "undefined";
264
+ function normalise(id) {
265
+ if (id === null || id === undefined)
266
+ return null;
267
+ const value = String(id).trim();
268
+ return value === "" ? null : value;
269
+ }
270
+ /** The organisation this tab is working in, or null before it has one. */
271
+ function getTabOrganizationId() {
272
+ return isBrowser$1() ? tabOrganizationId : null;
273
+ }
274
+ /** Record the organisation this tab is working in (browser only). */
275
+ function setTabOrganizationId(id) {
276
+ if (!isBrowser$1())
277
+ return;
278
+ tabOrganizationId = normalise(id);
279
+ }
280
+ function readOrganizationCookie() {
281
+ if (!isBrowser$1())
282
+ return null;
283
+ const prefix = `${ORGANIZATION_ID_KEY}=`;
284
+ const entry = document.cookie
285
+ .split(";")
286
+ .map(part => part.trim())
287
+ .find(part => part.startsWith(prefix));
288
+ if (!entry)
289
+ return null;
290
+ const raw = entry.slice(prefix.length);
291
+ try {
292
+ return normalise(decodeURIComponent(raw));
293
+ }
294
+ catch {
295
+ return normalise(raw);
296
+ }
297
+ }
298
+ function readOrganizationStorage() {
299
+ if (!isBrowser$1())
300
+ return null;
301
+ try {
302
+ return normalise(localStorage.getItem(ORGANIZATION_ID_KEY));
303
+ }
304
+ catch {
305
+ return null;
306
+ }
307
+ }
308
+ /**
309
+ * The organisations the signed-in user belongs to, once their list has
310
+ * loaded (AppLayoutShell reports it); null until then.
311
+ */
312
+ let knownOrganizationIds = null;
313
+ /**
314
+ * Record the user's organisations (browser only). From then on a stored or
315
+ * tab organisation that is not among them loses to one that is: a browser
316
+ * that still remembers a deleted organisation — or one the user has left —
317
+ * must not keep sending it while the cookie names a good one.
318
+ */
319
+ function setKnownOrganizationIds(ids) {
320
+ if (!isBrowser$1())
321
+ return;
322
+ if (ids === null) {
323
+ knownOrganizationIds = null;
324
+ return;
325
+ }
326
+ const set = new Set();
327
+ for (const id of ids) {
328
+ const value = normalise(id);
329
+ if (value)
330
+ set.add(value);
331
+ }
332
+ knownOrganizationIds = set;
333
+ }
334
+ /** Whether the loaded list says the user is NOT a member of `id`. */
335
+ function isKnownStaleOrganizationId(id) {
336
+ const value = normalise(id);
337
+ return (value !== null &&
338
+ knownOrganizationIds !== null &&
339
+ !knownOrganizationIds.has(value));
340
+ }
341
+ /**
342
+ * Marks a request that must not carry X-Organization-ID: the user's own list
343
+ * of organisations is how a stale organisation id is found out, so it cannot
344
+ * depend on one. An app's interceptor honours it by passing the request's
345
+ * config to getRequestOrganizationId(config).
346
+ *
347
+ * axiosInstance.get("/organizations", withoutOrganization({ params }))
348
+ */
349
+ const WITHOUT_ORGANIZATION_FLAG = "erpWithoutOrganization";
350
+ function withoutOrganization(config) {
351
+ return { ...(config ?? {}), [WITHOUT_ORGANIZATION_FLAG]: true };
352
+ }
353
+ /**
354
+ * Marks a request for one particular organisation, whatever this tab has
355
+ * active. Used to retry the organisation list under each stored id when an
356
+ * API refuses it without one (see http/organization-fallback.ts).
357
+ */
358
+ const FOR_ORGANIZATION_FIELD = "erpOrganizationId";
359
+ function isWithoutOrganization(config) {
360
+ return (!!config &&
361
+ typeof config === "object" &&
362
+ config[WITHOUT_ORGANIZATION_FLAG] === true);
363
+ }
364
+ function explicitOrganization(config) {
365
+ if (!config || typeof config !== "object")
366
+ return null;
367
+ const value = config[FOR_ORGANIZATION_FIELD];
368
+ return typeof value === "string" || typeof value === "number"
369
+ ? normalise(value)
370
+ : null;
371
+ }
372
+ /**
373
+ * Every organisation id this browser has stored for the tab — the tab's
374
+ * own, localStorage, the cookie — distinct, in that order.
375
+ */
376
+ function getStoredOrganizationIds() {
377
+ const ids = [
378
+ getTabOrganizationId(),
379
+ readOrganizationStorage(),
380
+ readOrganizationCookie(),
381
+ ].filter((id) => id !== null);
382
+ return [...new Set(ids)];
383
+ }
384
+ /**
385
+ * The organisation a request from this tab belongs to: the tab's own, then
386
+ * localStorage, then the cookie. Use it for the X-Organization-ID header —
387
+ * and read it before any `await` in the interceptor, so the header names the
388
+ * organisation that was active when the request was made.
389
+ *
390
+ * Pass the request's config: a request marked withoutOrganization() gets
391
+ * null, so no header.
392
+ *
393
+ * localStorage before the cookie: setOrganizationId writes localStorage
394
+ * synchronously and then awaits a server action for the cookie, so during
395
+ * that round trip the cookie still names the previous organisation.
396
+ *
397
+ * Once the user's organisations are known (setKnownOrganizationIds), the
398
+ * first of those that is one of them wins: 0.1.60 let a deleted
399
+ * organisation's id left in localStorage beat a valid cookie, and every
400
+ * request — the organisation list included — was refused. If none is a
401
+ * member the first is still sent, as before: the list may simply be behind
402
+ * (an organisation created a moment ago).
403
+ */
404
+ function getRequestOrganizationId(config) {
405
+ const explicit = explicitOrganization(config);
406
+ if (explicit)
407
+ return explicit;
408
+ if (isWithoutOrganization(config))
409
+ return null;
410
+ const candidates = [
411
+ getTabOrganizationId(),
412
+ readOrganizationStorage(),
413
+ readOrganizationCookie(),
414
+ ];
415
+ if (knownOrganizationIds !== null) {
416
+ const member = candidates.find(id => id !== null && knownOrganizationIds.has(id));
417
+ if (member)
418
+ return member;
419
+ }
420
+ return candidates.find(id => id !== null) ?? null;
421
+ }
422
+ function expireOrganizationCookie() {
423
+ // The cookie is set by the host's server action, host-only or on a parent
424
+ // domain (AUTH_COOKIE_DOMAIN, e.g. .antniti.com). The browser does not say
425
+ // which, so expire it on the host and on every parent domain.
426
+ const base = `${ORGANIZATION_ID_KEY}=; Max-Age=0; path=/; SameSite=Lax`;
427
+ try {
428
+ document.cookie = base;
429
+ const labels = window.location.hostname.split(".");
430
+ for (let i = 0; i < labels.length - 1; i++) {
431
+ document.cookie = `${base}; domain=.${labels.slice(i).join(".")}`;
432
+ }
433
+ }
434
+ catch {
435
+ // Cookies unavailable: nothing to clear.
436
+ }
437
+ }
438
+ /**
439
+ * Forget the active organisation everywhere this browser keeps it: the tab,
440
+ * localStorage and the `erp.organizationId` cookie. Call on sign-out, so the
441
+ * next person to sign in on this browser does not start in the previous
442
+ * person's organisation.
443
+ */
444
+ function clearStoredOrganization() {
445
+ if (!isBrowser$1())
446
+ return;
447
+ tabOrganizationId = null;
448
+ knownOrganizationIds = null;
449
+ try {
450
+ localStorage.removeItem(ORGANIZATION_ID_KEY);
451
+ }
452
+ catch {
453
+ // Storage unavailable: nothing to clear.
454
+ }
455
+ expireOrganizationCookie();
456
+ }
457
+
243
458
  const SELECTED_ORG_STORAGE_KEY = "erp.organizationId";
244
459
  const SYSTEM_ROUTE_FIRST_SEGMENTS = [
245
460
  "auth",
@@ -257,10 +472,15 @@ const SYSTEM_ROUTE_FIRST_SEGMENTS = [
257
472
  *
258
473
  * On set, writes through to localStorage + cookie before flipping React state
259
474
  * so all readers see a consistent value across navigations.
475
+ *
476
+ * The tab's own organisation (organization/active-organization.ts) follows
477
+ * this state, and it is written synchronously — in the initialiser and at the
478
+ * start of every set — because requests read it: an effect would run after
479
+ * the children's effects, which is where their queries start.
260
480
  */
261
481
  function useOrganizationIdState(initialOrganizationId, actions) {
262
482
  const previousOrgIdRef = useRef(initialOrganizationId || null);
263
- const [organizationIdState, setOrganizationIdState] = useState(() => {
483
+ function readInitialOrganizationId() {
264
484
  if (initialOrganizationId) {
265
485
  if (typeof window !== "undefined") {
266
486
  setOrganizationId(initialOrganizationId);
@@ -292,8 +512,16 @@ function useOrganizationIdState(initialOrganizationId, actions) {
292
512
  console.error("[useOrganizationIdState] Error reading localStorage:", error);
293
513
  }
294
514
  return null;
515
+ }
516
+ const [organizationIdState, setOrganizationIdState] = useState(() => {
517
+ const initial = readInitialOrganizationId();
518
+ setTabOrganizationId(initial);
519
+ return initial;
295
520
  });
296
521
  const setOrganizationId$1 = useCallback(async (id) => {
522
+ // Before the cookie round trip: from here on this tab's requests are
523
+ // the new organisation's, whatever the shared cookie still says.
524
+ setTabOrganizationId(id);
297
525
  try {
298
526
  if (id === null) {
299
527
  setOrganizationId(null);
@@ -316,6 +544,7 @@ function useOrganizationIdState(initialOrganizationId, actions) {
316
544
  useEffect(() => {
317
545
  if (initialOrganizationId &&
318
546
  initialOrganizationId !== String(organizationIdState)) {
547
+ setTabOrganizationId(initialOrganizationId);
319
548
  setOrganizationIdState(initialOrganizationId);
320
549
  previousOrgIdRef.current = initialOrganizationId;
321
550
  }
@@ -587,6 +816,46 @@ const TapMenu = ({ tabDatas, starterTab, currentTabIndex, variant = "main", }) =
587
816
  }) }), tabDatas[currentTab]?.page] }));
588
817
  };
589
818
 
819
+ /**
820
+ * How a sidebar row looks, apart from its layout.
821
+ *
822
+ * Exactly one row is lit — the page's own (resolveSidebarNav). Until 0.1.60 a
823
+ * row under the pointer took the lit row's fill and colour, and an open group
824
+ * (or a one-line row once clicked, which was a collapsible too) took its
825
+ * colour, so the menu showed several "current pages" at once.
826
+ */
827
+ /** A row under the pointer: a faint neutral tint, never the lit row's look. */
828
+ const NAV_ROW_HOVER = "hover:bg-sidebar-foreground/5 hover:text-sidebar-foreground";
829
+ /** The lit row: the accent fill and text. The pointer changes nothing. */
830
+ const NAV_ROW_LIT = "bg-sidebar-accent text-sidebar-accent-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground";
831
+ /** Fill and colour for a row, lit or not. */
832
+ function navRowTone(lit) {
833
+ return lit ? NAV_ROW_LIT : NAV_ROW_HOVER;
834
+ }
835
+ /**
836
+ * A group opens and shuts; any other row is a plain link with no open state.
837
+ */
838
+ function isNavGroup(item) {
839
+ return (item.subModules?.length ?? 0) > 0;
840
+ }
841
+ /**
842
+ * The open groups after `id` is opened or shut. An id that is not a group's
843
+ * is ignored — a one-line row has nothing to open.
844
+ */
845
+ function setNavGroupOpen(openIds, items, id, isOpen) {
846
+ if (!id || !items.some(item => item.id === id && isNavGroup(item))) {
847
+ return openIds;
848
+ }
849
+ if (openIds.has(id) === isOpen)
850
+ return openIds;
851
+ const next = new Set(openIds);
852
+ if (isOpen)
853
+ next.add(id);
854
+ else
855
+ next.delete(id);
856
+ return next;
857
+ }
858
+
590
859
  /**
591
860
  * Lightweight sidebar primitives — plain styled wrappers, no `useSidebar()`
592
861
  * context dependency. The full `@eintrek/erp-theme` sidebar suite ships its
@@ -610,21 +879,25 @@ const SidebarMenuButton = React.forwardRef(({ className, asChild = false, isActi
610
879
  default: "h-8 text-sm",
611
880
  lg: "h-12 text-sm",
612
881
  };
613
- const baseClassName = cn("flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left outline-none transition-colors hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 focus-visible:ring-sidebar-ring disabled:pointer-events-none disabled:opacity-50", sizeClasses[size], isActive &&
614
- "bg-sidebar-accent font-medium text-sidebar-accent-foreground", className);
882
+ const baseClassName = cn("flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left outline-none transition-colors focus-visible:ring-2 focus-visible:ring-sidebar-ring disabled:pointer-events-none disabled:opacity-50", sizeClasses[size], navRowTone(isActive), isActive && "font-medium", className);
883
+ let row;
615
884
  if (asChild) {
616
885
  if (!children || !React.isValidElement(children)) {
617
886
  console.error("SidebarMenuButton: Invalid children for asChild prop", children);
618
887
  return null;
619
888
  }
620
- return React.cloneElement(children, {
889
+ row = React.cloneElement(children, {
621
890
  className: cn(baseClassName, children.props
622
891
  ?.className),
623
- title: tooltip,
624
892
  ...props,
625
893
  });
626
894
  }
627
- return (jsx("button", { ref: ref, className: baseClassName, title: tooltip, ...props, children: children }));
895
+ else {
896
+ row = (jsx("button", { ref: ref, className: baseClassName, ...props, children: children }));
897
+ }
898
+ if (!tooltip)
899
+ return row;
900
+ return (jsxs(Tooltip, { children: [jsx(TooltipTrigger, { asChild: true, children: row }), jsx(TooltipContent, { side: "right", sideOffset: 10, children: tooltip })] }));
628
901
  });
629
902
  SidebarMenuButton.displayName = "SidebarMenuButton";
630
903
  const SidebarMenuSub = React.forwardRef(({ className, ...props }, ref) => (jsx("ul", { ref: ref, className: cn("mx-3.5 flex min-w-0 bg-sidebar translate-x-px flex-col gap-1 px-2.5 py-0.5", className), ...props })));
@@ -632,7 +905,7 @@ SidebarMenuSub.displayName = "SidebarMenuSub";
632
905
  const SidebarMenuSubItem = React.forwardRef(({ ...props }, ref) => jsx("li", { ref: ref, ...props }));
633
906
  SidebarMenuSubItem.displayName = "SidebarMenuSubItem";
634
907
  const SidebarMenuSubButton = React.forwardRef(({ asChild = false, size = "md", isActive, className, children, ...props }, ref) => {
635
- const baseClassName = cn("flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground outline-none transition-colors hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 focus-visible:ring-sidebar-ring disabled:pointer-events-none disabled:opacity-50", isActive && "bg-sidebar-accent text-sidebar-accent-foreground", size === "sm" && "text-xs", size === "md" && "text-sm", className);
908
+ const baseClassName = cn("flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground outline-none transition-colors focus-visible:ring-2 focus-visible:ring-sidebar-ring disabled:pointer-events-none disabled:opacity-50", navRowTone(!!isActive), size === "sm" && "text-xs", size === "md" && "text-sm", className);
636
909
  if (asChild) {
637
910
  return React.cloneElement(children, {
638
911
  className: cn(baseClassName, children.props
@@ -947,7 +1220,7 @@ function NavTools({ tools, closeOnNavigation = false }) {
947
1220
  // /reports/approval doesn't light up the /reports tool.
948
1221
  const isToolActive = pathname === toolUrl;
949
1222
  const Icon = tool.icon;
950
- return (jsx(SidebarMenuItem, { children: jsx(SidebarMenuButton, { asChild: true, isActive: isToolActive, className: "font-thai data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground", children: jsxs(Link, { href: toolUrl, className: "flex items-center gap-2 w-full", onClick: handleNavLinkClick, children: [jsx(Icon, { className: "sidebar-module-icon" }), jsx("span", { children: tool.titleTh })] }) }) }, tool.id));
1223
+ return (jsx(SidebarMenuItem, { children: jsx(SidebarMenuButton, { asChild: true, isActive: isToolActive, className: "font-thai data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground", children: jsxs(Link, { href: toolUrl, className: "flex items-center gap-2 w-full", onClick: handleNavLinkClick, "aria-current": isToolActive ? "page" : undefined, children: [jsx(Icon, { className: "sidebar-module-icon" }), jsx("span", { children: tool.titleTh })] }) }) }, tool.id));
951
1224
  }) }) }));
952
1225
  }
953
1226
 
@@ -1273,25 +1546,53 @@ const runGlobalRequestPrecheck = (config) => {
1273
1546
  return validateGoodsReceiptChecks(config.data);
1274
1547
  };
1275
1548
 
1549
+ /**
1550
+ * The organisation list is asked for without an organisation (see
1551
+ * withoutOrganization), but some APIs still refuse it without one: Peoplx's
1552
+ * GET /organizations sits behind its module.hr plan gate, which answers 400
1553
+ * "organization context required" to no header and 403 to an organisation
1554
+ * that does not exist. Until those routes stop needing one, a refused list is
1555
+ * asked again under each organisation this browser has stored — the tab's,
1556
+ * localStorage's, the cookie's — so one good id among stale ones still gets
1557
+ * the user their list, and with it the way back to a working organisation.
1558
+ *
1559
+ * `send` re-sends the request (an axios instance call). Returns the first
1560
+ * answer that succeeds, or undefined when none did or the request is not a
1561
+ * refused organisation-less one. Each retry is marked, so it never retries
1562
+ * itself.
1563
+ */
1564
+ const RETRIED = "_erpOrganizationFallback";
1565
+ async function retryWithStoredOrganizations(config, status, send) {
1566
+ if (!config ||
1567
+ config[WITHOUT_ORGANIZATION_FLAG] !== true ||
1568
+ config[RETRIED] === true ||
1569
+ (status !== 400 && status !== 403)) {
1570
+ return undefined;
1571
+ }
1572
+ for (const id of getStoredOrganizationIds()) {
1573
+ try {
1574
+ return await send({
1575
+ ...config,
1576
+ [RETRIED]: true,
1577
+ [FOR_ORGANIZATION_FIELD]: id,
1578
+ });
1579
+ }
1580
+ catch {
1581
+ // That one was refused too; try the next.
1582
+ }
1583
+ }
1584
+ return undefined;
1585
+ }
1586
+
1276
1587
  const isBrowser = () => typeof window !== "undefined";
1277
1588
  const isAuthEndpoint = (url) => !!url && url.includes("/api/auth/");
1278
1589
  const isAuthPage = () => isBrowser() && window.location.pathname.startsWith("/auth/");
1279
- const getOrgFromCookie = () => {
1280
- if (!isBrowser())
1281
- return null;
1282
- const cookies = document.cookie.split(";").map(cookie => cookie.trim());
1283
- const orgCookie = cookies.find(cookie => cookie.startsWith("erp.organizationId="));
1284
- if (!orgCookie)
1285
- return null;
1286
- const raw = orgCookie.substring("erp.organizationId=".length);
1287
- return raw ? decodeURIComponent(raw) : null;
1288
- };
1289
1590
  const redirectToLogout = async () => {
1290
1591
  if (!isBrowser() || isAuthPage())
1291
1592
  return;
1292
1593
  localStorage.clear();
1293
1594
  sessionStorage.clear();
1294
- setOrganizationId(null);
1595
+ clearStoredOrganization();
1295
1596
  clearAccessTokenCache();
1296
1597
  await signOut({ callbackUrl: "/auth/signin" });
1297
1598
  };
@@ -1320,23 +1621,16 @@ instance.interceptors.request.use(async (config) => {
1320
1621
  }
1321
1622
  if (!isBrowser())
1322
1623
  return config;
1624
+ // Read before the token await: the header must name the organisation that
1625
+ // was active when the request was made (and under which its query is
1626
+ // cached), not whatever is active once the token resolves.
1627
+ const organizationId = getRequestOrganizationId(config);
1323
1628
  const token = await getAccessToken(false);
1324
1629
  if (token) {
1325
1630
  config.headers.Authorization = `Bearer ${token}`;
1326
1631
  }
1327
- // localStorage first, cookie second.
1328
- //
1329
- // Both are written by the same state machine, but not at the same moment:
1330
- // setOrganizationId writes localStorage synchronously and then awaits a
1331
- // server action for the cookie. Preferring the cookie meant every request
1332
- // fired during that round trip carried the previous organisation — while
1333
- // the query params, built from React state, already carried the new one.
1334
- //
1335
- // The backend scopes by this header and ignores organization_id in the
1336
- // query, so those requests asked for organisation A and were handed B, and
1337
- // the answer landed in the cache under A's key. No amount of cache clearing
1338
- // reaches that: the entry is wrong, not stale.
1339
- const organizationId = getOrganizationId() || getOrgFromCookie();
1632
+ // The tab's own organisation, then localStorage, then the cookie — see
1633
+ // getRequestOrganizationId.
1340
1634
  if (organizationId) {
1341
1635
  config.headers["X-Organization-ID"] = organizationId;
1342
1636
  }
@@ -1390,6 +1684,9 @@ instance.interceptors.response.use(response => {
1390
1684
  window.dispatchEvent(new CustomEvent("erp:employee-record-required"));
1391
1685
  }
1392
1686
  }
1687
+ const listed = await retryWithStoredOrganizations(originalRequest, error.response?.status, config => instance(config));
1688
+ if (listed !== undefined)
1689
+ return listed;
1393
1690
  return Promise.reject(toApiError(error));
1394
1691
  });
1395
1692
 
@@ -1416,7 +1713,7 @@ function platformApiBase(app) {
1416
1713
  * This package's version, attached to help messages so the back office can
1417
1714
  * tell which shell a report came from. Keep in step with package.json.
1418
1715
  */
1419
- const SHELL_VERSION = "0.1.59";
1716
+ const SHELL_VERSION = "0.1.61";
1420
1717
 
1421
1718
  const HELP_APP_LABEL = {
1422
1719
  platform: "แพลตฟอร์ม",
@@ -1739,6 +2036,9 @@ function NavUser({ organization, avatarSrc, profileHref, selectOrganizationHref
1739
2036
  };
1740
2037
  const handleSignOut = async () => {
1741
2038
  setIsSigningOut(true);
2039
+ // The next person to sign in on this browser must not start in this
2040
+ // person's organisation: forget it in the tab, localStorage and cookie.
2041
+ clearStoredOrganization();
1742
2042
  try {
1743
2043
  await signOut({ callbackUrl: signInRedirectHref, redirect: true });
1744
2044
  }
@@ -2021,30 +2321,18 @@ function NavMain({ items, orgCode, closeOnNavigation = false, approvalCounts, ch
2021
2321
  // A group opens when the page moves into it and otherwise stays as the
2022
2322
  // user left it. defaultOpen alone decided once, on the first render — and
2023
2323
  // on a direct load the sub-items arrive after permissions do, so the group
2024
- // holding the current page stayed shut.
2025
- const openId = open ? (items[open.item]?.id ?? "") : "";
2324
+ // holding the current page stayed shut. Only groups have an open state: a
2325
+ // one-line row is a plain link (0.1.60 — it was a collapsible too, and a
2326
+ // click left it "open", drawn in the accent colour for the session).
2327
+ const openItem = open ? items[open.item] : undefined;
2328
+ const openId = openItem && isNavGroup(openItem) ? openItem.id : "";
2026
2329
  const [openIds, setOpenIds] = React.useState(() => new Set(openId ? [openId] : []));
2027
2330
  React.useEffect(() => {
2028
2331
  if (!openId)
2029
2332
  return;
2030
- setOpenIds(prev => {
2031
- if (prev.has(openId))
2032
- return prev;
2033
- const next = new Set(prev);
2034
- next.add(openId);
2035
- return next;
2036
- });
2037
- }, [openId]);
2038
- const setItemOpen = (id, isOpen) => setOpenIds(prev => {
2039
- if (prev.has(id) === isOpen)
2040
- return prev;
2041
- const next = new Set(prev);
2042
- if (isOpen)
2043
- next.add(id);
2044
- else
2045
- next.delete(id);
2046
- return next;
2047
- });
2333
+ setOpenIds(prev => setNavGroupOpen(prev, items, openId, true));
2334
+ }, [openId, items]);
2335
+ const setItemOpen = (id, isOpen) => setOpenIds(prev => setNavGroupOpen(prev, items, id, isOpen));
2048
2336
  return (jsx(SidebarGroup, { "data-sidebar": true, className: workspace ? "p-0" : undefined, children: jsx(SidebarMenu, { className: workspace ? "gap-[3px]" : undefined, children: items.map((item, index) => {
2049
2337
  const groupHeading = workspace &&
2050
2338
  item.group &&
@@ -2060,9 +2348,15 @@ function NavMain({ items, orgCode, closeOnNavigation = false, approvalCounts, ch
2060
2348
  : "";
2061
2349
  const icon = workspace && IconComp ? (jsx(IconComp, { className: "size-[18px] shrink-0" })) : null;
2062
2350
  const badge = workspace && item.badge !== undefined && item.badge !== "" ? (jsx("span", { className: "ml-auto min-w-5 rounded-[5px] bg-primary/15 px-1 text-center text-xs leading-5 font-bold tabular-nums text-primary", children: item.badge })) : null;
2063
- return (jsxs(React.Fragment, { children: [groupHeading, jsx(Collapsible, { asChild: true, open: openIds.has(item.id), onOpenChange: isOpen => setItemOpen(item.id, isOpen), className: "group/collapsible", children: jsxs(SidebarMenuItem, { children: [jsx(CollapsibleTrigger, { asChild: true, children: jsx(SidebarMenuButton, { asChild: !item.subModules || item.subModules.length === 0, isActive: isItemActive, className: "data-[state=open]:text-sidebar-accent-foreground " +
2064
- workspaceButton, children: item.subModules && item.subModules.length > 0 ? (jsxs(Fragment$1, { children: [icon, jsx("span", { id: item.id, children: item.titleTh }), jsx(ChevronRight, { className: "ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" })] })) : (jsxs(Link, { href: itemUrl, prefetch: false, className: "flex items-center w-full min-w-0 flex-1 " +
2065
- (workspace ? "gap-3" : "gap-2"), onClick: handleNavLinkClick, "aria-current": isItemActive ? "page" : undefined, children: [icon, jsx("span", { id: item.id, className: "flex-1 truncate", title: item.titleTh, children: item.titleTh }), badge] })) }) }), item.subModules && item.subModules.length > 0 && (jsx(CollapsibleContent, { children: jsx(SidebarMenuSub, { className: workspace
2351
+ if (!isNavGroup(item)) {
2352
+ // A one-line row: a link, lit on its own page only. No open
2353
+ // state, no title tooltip.
2354
+ return (jsxs(React.Fragment, { children: [groupHeading, jsx(SidebarMenuItem, { children: jsx(SidebarMenuButton, { asChild: true, isActive: isItemActive, className: workspaceButton, children: jsxs(Link, { href: itemUrl, prefetch: false, className: "flex items-center w-full min-w-0 flex-1 " +
2355
+ (workspace ? "gap-3" : "gap-2"), onClick: handleNavLinkClick, "aria-current": isItemActive ? "page" : undefined, children: [icon, jsx("span", { id: item.id, className: "flex-1 truncate", children: item.titleTh }), badge] }) }) })] }, item.id));
2356
+ }
2357
+ // A group: its header opens and shuts it (the chevron says which).
2358
+ // An open group is not the current page — only a row in it is.
2359
+ return (jsxs(React.Fragment, { children: [groupHeading, jsx(Collapsible, { asChild: true, open: openIds.has(item.id), onOpenChange: isOpen => setItemOpen(item.id, isOpen), className: "group/collapsible", children: jsxs(SidebarMenuItem, { children: [jsx(CollapsibleTrigger, { asChild: true, children: jsxs(SidebarMenuButton, { isActive: isItemActive, className: workspaceButton, children: [icon, jsx("span", { id: item.id, className: "flex-1 truncate text-left", children: item.titleTh }), jsx(ChevronRight, { className: "ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" })] }) }), item.subModules && (jsx(CollapsibleContent, { children: jsx(SidebarMenuSub, { className: workspace
2066
2360
  ? "mx-0 mt-1 mb-3 ml-[21px] gap-0.5 border-l border-sidebar-border pl-2.5 [&_a]:h-auto [&_a]:gap-[11px] [&_a]:px-3 [&_a]:py-2 [&_a]:text-xs [&_svg]:!size-[17px]"
2067
2361
  : undefined, children: item.subModules.map((subItem, subIndex) => {
2068
2362
  const subItemUrl = buildUrlWithCode(code, subItem.url);
@@ -2074,20 +2368,24 @@ function NavMain({ items, orgCode, closeOnNavigation = false, approvalCounts, ch
2074
2368
  if (subItem.isCollapsible &&
2075
2369
  subItem.subPages &&
2076
2370
  subItem.subPages.length > 0) {
2077
- return (jsx(Collapsible, { asChild: true, defaultOpen: isSubItemActive || openSub === subIndex, className: "group/sub-collapsible", children: jsxs(SidebarMenuSubItem, { children: [jsx(CollapsibleTrigger, { asChild: true, children: jsxs(SidebarMenuSubButton, { className: "w-full", isActive: isSubItemActive, children: [SubIcon && (jsx(SubIcon, { className: "sidebar-submodule-icon flex-shrink-0" })), jsx("span", { className: "flex-1 truncate", title: subItem.titleTh, children: subItem.titleTh }), jsx(ChevronDown, { className: "ml-auto size-4 transition-transform duration-200 group-data-[state=open]/sub-collapsible:rotate-180" })] }) }), jsx(CollapsibleContent, { children: jsx(SidebarMenuSub, { className: "ml-4", children: subItem.subPages.map((subPage, subPageIndex) => {
2371
+ return (jsx(Collapsible, { asChild: true, defaultOpen: isSubItemActive || openSub === subIndex, className: "group/sub-collapsible", children: jsxs(SidebarMenuSubItem, { children: [jsx(CollapsibleTrigger, { asChild: true, children: jsxs(SidebarMenuSubButton, { className: "w-full", isActive: isSubItemActive &&
2372
+ activeSubPage === null, children: [SubIcon && (jsx(SubIcon, { className: "sidebar-submodule-icon flex-shrink-0" })), jsx("span", { className: "flex-1 truncate", children: subItem.titleTh }), jsx(ChevronDown, { className: "ml-auto size-4 transition-transform duration-200 group-data-[state=open]/sub-collapsible:rotate-180" })] }) }), jsx(CollapsibleContent, { children: jsx(SidebarMenuSub, { className: "ml-4", children: subItem.subPages.map((subPage, subPageIndex) => {
2078
2373
  const subPageUrl = buildUrlWithCode(code, subPage.url);
2079
2374
  const isSubPageActive = isSubItemActive &&
2080
2375
  activeSubPage === subPageIndex;
2081
2376
  const SubPageIcon = subPage.icon;
2082
- return (jsx(SidebarMenuSubItem, { children: jsx(SidebarMenuSubButton, { asChild: true, className: `font-thai w-full text-sm ${isSubPageActive
2083
- ? "text-sidebar-accent-foreground font-medium"
2084
- : "text-sidebar-foreground"}`, isActive: false, children: jsxs(Link, { href: subPageUrl, prefetch: false, className: "w-full flex items-center", onClick: handleNavLinkClick, children: [SubPageIcon && (jsx(SubPageIcon, { className: "sidebar-subpage-icon flex-shrink-0" })), jsx("span", { className: "flex-1 truncate", title: subPage.titleTh, children: subPage.titleTh })] }) }) }, subPage.id));
2377
+ return (jsx(SidebarMenuSubItem, { children: jsx(SidebarMenuSubButton, { asChild: true, className: "font-thai w-full text-sm" +
2378
+ (isSubPageActive
2379
+ ? " font-medium"
2380
+ : ""), isActive: isSubPageActive, children: jsxs(Link, { href: subPageUrl, prefetch: false, className: "w-full flex items-center", onClick: handleNavLinkClick, "aria-current": isSubPageActive
2381
+ ? "page"
2382
+ : undefined, children: [SubPageIcon && (jsx(SubPageIcon, { className: "sidebar-subpage-icon flex-shrink-0" })), jsx("span", { className: "flex-1 truncate", children: subPage.titleTh })] }) }) }, subPage.id));
2085
2383
  }) }) })] }) }, subItem.id));
2086
2384
  }
2087
2385
  return (jsx(SidebarMenuSubItem, { children: jsx(SidebarMenuSubButton, { asChild: true, className: "w-full" +
2088
2386
  (workspace && isSubItemActive
2089
2387
  ? " font-semibold"
2090
- : ""), isActive: isSubItemActive, children: jsxs(Link, { href: subItemUrl, prefetch: false, className: "w-full flex items-center", onClick: handleNavLinkClick, children: [SubIcon && (jsx(SubIcon, { className: "sidebar-submodule-icon flex-shrink-0" })), jsx("span", { className: "flex-1 truncate", title: subItem.titleTh, children: subItem.titleTh }), approvalCount > 0 && (jsx(Badge, { variant: "destructive", className: "ml-auto h-5 min-w-5 px-1.5 text-xs font-medium tabular-nums", children: approvalCount > 99
2388
+ : ""), isActive: isSubItemActive, children: jsxs(Link, { href: subItemUrl, prefetch: false, className: "w-full flex items-center", onClick: handleNavLinkClick, "aria-current": isSubItemActive ? "page" : undefined, children: [SubIcon && (jsx(SubIcon, { className: "sidebar-submodule-icon flex-shrink-0" })), jsx("span", { className: "flex-1 truncate", children: subItem.titleTh }), approvalCount > 0 && (jsx(Badge, { variant: "destructive", className: "ml-auto h-5 min-w-5 px-1.5 text-xs font-medium tabular-nums", children: approvalCount > 99
2091
2389
  ? "99+"
2092
2390
  : approvalCount }))] }) }) }, subItem.id));
2093
2391
  }) }) }))] }) })] }, item.id));
@@ -3055,6 +3353,111 @@ function ThreadBubble({ mine, label, at, body, images, }) {
3055
3353
  (mine ? "border border-primary/30 bg-primary/5" : "bg-muted"), children: [jsxs("p", { className: "text-xs text-muted-foreground", children: [label, " \u00B7 ", when(at)] }), jsx("p", { className: "mt-1 whitespace-pre-wrap break-words text-sm", children: body }), jsx(HelpImageStrip, { images: images })] }) }));
3056
3354
  }
3057
3355
 
3356
+ /**
3357
+ * Central "no access" screen shared by every policy check (route-level
3358
+ * PermissionProvider and page-level PermissionGateShell). One look everywhere:
3359
+ * an illustration, a 403 headline, the page-specific reason, and recovery
3360
+ * actions.
3361
+ */
3362
+ function AccessDenied({ title = "ขออภัย, คุณไม่มีสิทธิ์เข้าถึงหน้านี้ (403)", message = "คุณไม่มีสิทธิ์เข้าถึงหน้านี้ ขออภัยในความไม่สะดวก", homeHref, homeLabel = "กลับไปหน้าหลัก", supportHref, supportLabel = "ติดต่อฝ่ายสนับสนุน", fullScreen = true, className, }) {
3363
+ // "/" is not the home page inside an app — it bounces to organization
3364
+ // selection, so the button read "กลับไปหน้าหลัก" and dropped the user into a
3365
+ // picker for the org they were already in. Send them to that org's home.
3366
+ const params = useParams();
3367
+ const code = typeof params?.code === "string" ? params.code : "";
3368
+ const homeTarget = homeHref ?? (code ? `/${code}` : "/");
3369
+ const go = (href) => {
3370
+ if (typeof window !== "undefined")
3371
+ window.location.href = href;
3372
+ };
3373
+ const wrapperClass = [
3374
+ "flex min-w-0 flex-1 items-center justify-center p-4",
3375
+ fullScreen ? "min-h-[70vh]" : "",
3376
+ className ?? "",
3377
+ ]
3378
+ .filter(Boolean)
3379
+ .join(" ");
3380
+ return (jsx("div", { className: wrapperClass, children: jsx("div", { className: "relative w-full max-w-3xl overflow-hidden rounded-2xl border border-border/60 bg-card/60 p-8 shadow-sm backdrop-blur sm:p-10", children: jsxs("div", { className: "flex flex-col items-center gap-8 text-center sm:flex-row sm:text-left", children: [jsxs("div", { className: "relative shrink-0", children: [jsx("div", { className: "absolute inset-0 rounded-full bg-destructive/20 blur-2xl" }), jsx("div", { className: "relative flex h-28 w-28 items-center justify-center rounded-3xl border border-border/60 bg-gradient-to-br from-muted/40 to-background shadow-inner", children: jsx("div", { className: "flex h-16 w-16 items-center justify-center rounded-full border border-destructive/40 bg-destructive/10", children: jsx(Ban, { className: "h-8 w-8 text-destructive", strokeWidth: 2.5 }) }) })] }), jsxs("div", { className: "min-w-0 flex-1", children: [jsx("h1", { className: "text-xl font-bold text-foreground sm:text-2xl", children: title }), message ? (jsx("p", { className: "mt-3 whitespace-pre-line text-sm leading-relaxed text-muted-foreground sm:text-base", children: message })) : null, jsxs("div", { className: "mt-6 flex flex-col gap-3 sm:flex-row", children: [jsxs(Button, { type: "button", onClick: () => go(homeTarget), className: "w-full sm:w-auto", children: [jsx(Home, { className: "mr-2 h-4 w-4" }), homeLabel] }), supportHref ? (jsxs(Button, { type: "button", variant: "outline", onClick: () => go(supportHref), className: "w-full sm:w-auto", children: [jsx(Headset, { className: "mr-2 h-4 w-4" }), supportLabel] })) : null] })] })] }) }) }));
3381
+ }
3382
+
3383
+ /**
3384
+ * What AppLayoutShell does with the page while the organisation in the URL and
3385
+ * the tab's active organisation are being reconciled. Pure, so it is tested
3386
+ * without React (url-org-gate.test.ts).
3387
+ *
3388
+ * The URL's `[code]` is authoritative. The page — and with it every request
3389
+ * the page makes — mounts only once the active organisation IS the one the
3390
+ * URL names:
3391
+ *
3392
+ * ready the active organisation is the URL's: render the page.
3393
+ * resolving the caller's organisations are still loading, so the code
3394
+ * cannot be resolved yet: hold the page.
3395
+ * switching the code is one of the caller's organisations but not the
3396
+ * active one: switch to it (`targetId`), hold the page meanwhile.
3397
+ * not-member the list loaded and the code is not in it: refuse.
3398
+ * unverified the list could not be loaded: render as before the gate
3399
+ * existed (the API still scopes every request), rather than
3400
+ * leave the page on a loader for good.
3401
+ */
3402
+ /** The organisation a `[code]` segment names, among the caller's. */
3403
+ function findOrganizationForCode(code, organizations) {
3404
+ if (!code)
3405
+ return null;
3406
+ return (organizations.find(org => org.code === code || String(org.id) === code) ??
3407
+ null);
3408
+ }
3409
+ function resolveUrlOrgGate({ code, organizations, organizationsStatus, organizationId, }) {
3410
+ // No code in the path: the shell sends the user to pick an organisation.
3411
+ if (!code)
3412
+ return { state: "ready", targetId: null };
3413
+ const target = findOrganizationForCode(code, organizations);
3414
+ if (target) {
3415
+ const targetId = String(target.id);
3416
+ const activeId = organizationId === null || organizationId === undefined
3417
+ ? ""
3418
+ : String(organizationId).trim();
3419
+ return activeId === targetId
3420
+ ? { state: "ready", targetId }
3421
+ : { state: "switching", targetId };
3422
+ }
3423
+ const status = organizationsStatus ?? (organizations.length > 0 ? "success" : undefined);
3424
+ switch (status) {
3425
+ case "success":
3426
+ return { state: "not-member" };
3427
+ case "pending":
3428
+ return { state: "resolving" };
3429
+ default:
3430
+ // "error", or a host that does not report the list's status and has
3431
+ // an empty one.
3432
+ return { state: "unverified" };
3433
+ }
3434
+ }
3435
+ function resolveStaleOrganization({ code, organizations, organizationsStatus, organizationId, }) {
3436
+ const status = organizationsStatus ?? (organizations.length > 0 ? "success" : undefined);
3437
+ if (status !== "success")
3438
+ return { action: "none" };
3439
+ const activeId = organizationId === null || organizationId === undefined
3440
+ ? ""
3441
+ : String(organizationId).trim();
3442
+ if (activeId === "")
3443
+ return { action: "none" };
3444
+ if (organizations.some(org => String(org.id) === activeId)) {
3445
+ return { action: "none" };
3446
+ }
3447
+ const urlOrg = findOrganizationForCode(code, organizations);
3448
+ if (urlOrg) {
3449
+ return { action: "use", targetId: String(urlOrg.id), fromUrl: true };
3450
+ }
3451
+ if (organizations.length === 1) {
3452
+ return {
3453
+ action: "use",
3454
+ targetId: String(organizations[0].id),
3455
+ fromUrl: false,
3456
+ };
3457
+ }
3458
+ return { action: "forget" };
3459
+ }
3460
+
3058
3461
  /**
3059
3462
  * Top-level app chrome: sticky header, sidebar slot, content gutter and the
3060
3463
  * session/org-context plumbing that both Accounting and HR need. Apps wrap
@@ -3067,7 +3470,7 @@ function ThreadBubble({ mine, label, at, body, images, }) {
3067
3470
  * an erp-shell bump — domain components must NOT add their own root padding
3068
3471
  * or content gets indented twice.
3069
3472
  */
3070
- function AppLayoutShell({ children, sidebar, loadingComponent, breadcrumb, permissionProvider, organizationId, setOrganizationId, organizations, signInUrl, selectOrganizationUrl, chrome = "brand", workspaceHeader = "brand", headerEnd, mobileBrand, showHelp = true, helpApp, }) {
3473
+ function AppLayoutShell({ children, sidebar, loadingComponent, breadcrumb, permissionProvider, organizationId, setOrganizationId, organizations, organizationsStatus, notMemberComponent, signInUrl, selectOrganizationUrl, chrome = "brand", workspaceHeader = "brand", headerEnd, mobileBrand, showHelp = true, helpApp, }) {
3071
3474
  const { data: session, status } = useSession();
3072
3475
  const { open, setOpen } = useSidebar();
3073
3476
  const router = useRouter();
@@ -3084,33 +3487,75 @@ function AppLayoutShell({ children, sidebar, loadingComponent, breadcrumb, permi
3084
3487
  if (extendedSession?.accessToken)
3085
3488
  hasSeenAccessTokenRef.current = true;
3086
3489
  }, [extendedSession?.accessToken]);
3087
- // Find the org from the URL `[code]` so we can detect a context mismatch
3088
- // (the user bookmarked a different org than the cookie remembers).
3089
- const orgFromCode = React.useMemo(() => {
3090
- if (!code || !organizations.length)
3091
- return null;
3092
- return (organizations.find(org => org.code === code || String(org.id) === code) ?? null);
3093
- }, [code, organizations]);
3094
- // Sync state: when the URL code points at a different org than the cookie,
3095
- // trust the URL and update the cookie. Otherwise do nothing — the user
3096
- // wins ties via the in-app org switcher.
3490
+ // The URL's `[code]` is authoritative: the page mounts only once the active
3491
+ // organisation is the one it names (see url-org-gate.ts).
3492
+ //
3493
+ // The gate used to start only when the code had resolved AND an active id
3494
+ // existed, which left two holes. While the organisation list loaded, the
3495
+ // page mounted at once and its first requests carried the previous
3496
+ // organisation — /O26000021/procurement/purchase-requisition/<id> fetched
3497
+ // the document from O26000019 before the switch. And a code naming none of
3498
+ // the user's organisations never resolved, so the page rendered the
3499
+ // previous organisation's data under it.
3500
+ const urlOrgGate = resolveUrlOrgGate({
3501
+ code,
3502
+ organizations,
3503
+ organizationsStatus,
3504
+ organizationId,
3505
+ });
3506
+ const switchTargetId = urlOrgGate.state === "switching" ? urlOrgGate.targetId : null;
3507
+ // Switch the active organisation to the URL's. Once per target: the setter
3508
+ // writes the cookie through a server action, and re-firing it on every
3509
+ // render while that round trip is in flight only queues duplicates.
3510
+ const requestedSwitchRef = useRef(null);
3097
3511
  useEffect(() => {
3098
- if (!code)
3099
- return;
3100
- if (!organizations.length)
3512
+ if (!switchTargetId) {
3513
+ requestedSwitchRef.current = null;
3101
3514
  return;
3102
- if (!orgFromCode)
3515
+ }
3516
+ if (requestedSwitchRef.current === switchTargetId)
3103
3517
  return;
3104
- if (String(organizationId) !== String(orgFromCode.id)) {
3105
- void setOrganizationId(orgFromCode.id);
3518
+ requestedSwitchRef.current = switchTargetId;
3519
+ void setOrganizationId(switchTargetId);
3520
+ }, [switchTargetId, setOrganizationId]);
3521
+ // Once the user's organisations are known, requests prefer a stored
3522
+ // organisation that is one of them (getRequestOrganizationId).
3523
+ const organizationsLoaded = (organizationsStatus ??
3524
+ (organizations.length > 0 ? "success" : undefined)) === "success";
3525
+ useEffect(() => {
3526
+ if (organizationsLoaded) {
3527
+ setKnownOrganizationIds(organizations.map(org => org.id));
3106
3528
  }
3107
- }, [
3108
- orgFromCode,
3109
- organizationId,
3110
- setOrganizationId,
3529
+ }, [organizationsLoaded, organizations]);
3530
+ // An active organisation that is not one of the user's — deleted, or left —
3531
+ // is replaced, so it stops being sent with every request. The URL's
3532
+ // organisation, when the user is a member, is the gate's switch above;
3533
+ // otherwise the user's only organisation, or none (they pick one: the
3534
+ // refusal below offers it, and a path without a code goes to the picker).
3535
+ // Once per stale id and target, so a setter that does not take cannot loop.
3536
+ const staleRecovery = resolveStaleOrganization({
3111
3537
  code,
3112
- organizations.length,
3113
- ]);
3538
+ organizations,
3539
+ organizationsStatus,
3540
+ organizationId,
3541
+ });
3542
+ const staleRecoveryTarget = staleRecovery.action === "forget"
3543
+ ? null
3544
+ : staleRecovery.action === "use" && !staleRecovery.fromUrl
3545
+ ? staleRecovery.targetId
3546
+ : undefined;
3547
+ const staleRecoveryKey = staleRecoveryTarget === undefined
3548
+ ? null
3549
+ : `${String(organizationId)}>${staleRecoveryTarget ?? ""}`;
3550
+ const handledStaleRecoveryRef = useRef(null);
3551
+ useEffect(() => {
3552
+ if (staleRecoveryKey === null || staleRecoveryTarget === undefined)
3553
+ return;
3554
+ if (handledStaleRecoveryRef.current === staleRecoveryKey)
3555
+ return;
3556
+ handledStaleRecoveryRef.current = staleRecoveryKey;
3557
+ void setOrganizationId(staleRecoveryTarget);
3558
+ }, [staleRecoveryKey, staleRecoveryTarget, setOrganizationId]);
3114
3559
  // Key for the content subtree. It exists so switching organizations throws
3115
3560
  // away the previous org's component state — but it must react ONLY to a real
3116
3561
  // switch. organizationId legitimately passes through null while the context
@@ -3132,21 +3577,20 @@ function AppLayoutShell({ children, sidebar, loadingComponent, breadcrumb, permi
3132
3577
  const contentOrgKey = lastOrgKeyRef.current;
3133
3578
  // Hold the page back until the active organisation is the one the URL names.
3134
3579
  //
3135
- // The sync effect above writes the cookie through a server action, which is
3136
- // a round trip. Until it lands, getOrgFromCookie() still answers with the
3137
- // previous organisation and every request the page fires on mount carries
3138
- // it — so /O26000021/work-record asked about O26000033 and was correctly
3139
- // told there was nothing there. An empty table is the mild version: the
3140
- // create form posts to the same header, so a record could be filed against
3141
- // a company the user was not looking at.
3580
+ // The switch writes the cookie through a server action, which is a round
3581
+ // trip. Until it lands, every request the page fires on mount would carry
3582
+ // the previous organisation — so /O26000021/work-record asked about
3583
+ // O26000033 and was correctly told there was nothing there. An empty table
3584
+ // is the mild version: the create form posts to the same header, so a
3585
+ // record could be filed against a company the user was not looking at.
3142
3586
  //
3143
- // Only while the two genuinely disagree. A code naming no organisation of
3144
- // the caller's is left to the app to refuse, and a page with no code in its
3145
- // path is unaffected.
3146
- const orgContextIsStale = !!orgFromCode &&
3147
- organizationId !== null &&
3148
- organizationId !== undefined &&
3149
- String(organizationId) !== String(orgFromCode.id);
3587
+ // The whole layout waits, not just the content: the chrome reads the URL
3588
+ // too. The breadcrumb looks up the document in the path to print its
3589
+ // number, so with only the content held it fetched
3590
+ // /purchase-requests/<id> under the previous organisation and printed that
3591
+ // organisation's document number under the new one's URL.
3592
+ const holdLayout = urlOrgGate.state === "resolving" || urlOrgGate.state === "switching";
3593
+ const refusePage = urlOrgGate.state === "not-member";
3150
3594
  // ≤1024px: sidebar renders as a modal overlay (backdrop dims content). >1024px:
3151
3595
  // sidebar is docked and the content gets a left margin to clear it.
3152
3596
  const [isSidebarModalLayout, setIsSidebarModalLayout] = React.useState(false);
@@ -3195,6 +3639,9 @@ function AppLayoutShell({ children, sidebar, loadingComponent, breadcrumb, permi
3195
3639
  router.push(selectOrganizationUrl);
3196
3640
  return jsx(Fragment$1, { children: loadingComponent });
3197
3641
  }
3642
+ if (holdLayout) {
3643
+ return jsx(Fragment$1, { children: loadingComponent });
3644
+ }
3198
3645
  // When the sidebar is docked-open it consumes 21.5rem on the left; otherwise
3199
3646
  // the content takes the full width and we add a left gutter inside.
3200
3647
  const workspace = chrome === "workspace";
@@ -3205,6 +3652,9 @@ function AppLayoutShell({ children, sidebar, loadingComponent, breadcrumb, permi
3205
3652
  : "";
3206
3653
  const needsLeftGutter = workspace || !(open && !isSidebarModalLayout);
3207
3654
  const wrap = permissionProvider ?? ((c) => c);
3655
+ // No trail over a refusal: it would look up the path's document under the
3656
+ // active organisation, which is not the URL's.
3657
+ const trail = refusePage ? null : breadcrumb;
3208
3658
  return (jsxs("div", { className: "flex min-h-screen w-full bg-background", children: [open && (jsx("div", { className: "fixed left-0 top-0 z-50 h-screen", children: sidebar })), open && isSidebarModalLayout && (jsx("button", { type: "button", "aria-label": "\u0E1B\u0E34\u0E14\u0E40\u0E21\u0E19\u0E39", className: "fixed inset-0 z-40 bg-black/50", onClick: () => setOpen(false) })), jsx("div", { className: cx("relative flex min-w-0 flex-1 flex-col transition-all", contentMargin), children: jsxs("main", { className: cx("flex flex-1 flex-col min-h-screen", workspace ? "gap-0" : "gap-4"), children: [workspace ? (jsxs("div", { className: cx("sticky top-0 z-30 shrink-0 w-full", workspaceHeader === "light" &&
3209
3659
  "border-b border-border bg-card/85 backdrop-blur-sm"), style: workspaceHeader === "light"
3210
3660
  ? WORKSPACE_LIGHT_HEADER_VARS
@@ -3214,9 +3664,11 @@ function AppLayoutShell({ children, sidebar, loadingComponent, breadcrumb, permi
3214
3664
  // As in the design: the mark, then a menu button, and the
3215
3665
  // organisation on the right — no trail, no title, so the
3216
3666
  // organisation's name has the room it needs on a phone.
3217
- jsxs(Fragment$1, { children: [mobileBrand ? (jsx("div", { className: "shrink-0", children: mobileBrand })) : null, jsx("button", { type: "button", onClick: () => setOpen(!open), "aria-label": open ? "ปิดเมนู" : "เปิดเมนู", className: "grid size-[34px] shrink-0 place-items-center rounded-lg text-[var(--header-fg-muted)] transition-colors hover:bg-[var(--header-hover)] hover:text-[var(--header-fg)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--header-fg-muted)]", children: jsx(LayoutGrid, { className: "size-5", strokeWidth: 1.65 }) })] })) : (jsx(SidebarTrigger, { className: "shrink-0 text-[var(--header-fg-muted)] hover:bg-[var(--header-hover)] hover:text-[var(--header-fg)] [&_svg]:!text-current" })), jsxs("div", { className: "flex min-w-0 flex-1 flex-col justify-center gap-0.5", children: [jsx("div", { className: "hidden min-w-0 sm:block", children: breadcrumb }), jsx("div", { id: "header-title", className: "min-w-0 max-sm:hidden" })] }), headerEnd || showHelp ? (jsxs("div", { className: "flex shrink-0 items-center gap-3 sm:gap-5", children: [headerEnd, showHelp ? jsx(HelpButton, {}) : null] })) : null] })] })) : (jsxs("div", { className: "sticky top-0 z-30 shrink-0 w-full", children: [jsx(HeaderBackground, {}), jsxs("header", { className: "relative shrink-0 gap-2 py-4 h-auto min-h-[123.5px] w-full z-10", children: [jsxs("div", { className: "flex w-full items-center gap-2 px-3 sm:px-4 lg:px-6", children: [jsx(SidebarTrigger, { className: "shrink-0 text-white hover:bg-white/10" }), jsx("div", { className: "flex-1 min-w-0", children: breadcrumb }), headerEnd || showHelp ? (jsxs("div", { className: "flex shrink-0 items-center gap-3", children: [headerEnd, showHelp ? jsx(HelpButton, {}) : null] })) : null] }), jsx("div", { id: "header-title" })] })] })), jsx(Suspense, { fallback: jsx("div", { className: "relative z-10 min-h-[calc(100vh-8rem)]", children: jsx("div", { className: "flex h-full items-center justify-center", children: jsxs("div", { className: "space-y-4 text-center", children: [jsx("div", { className: "mx-auto h-8 w-8 animate-spin rounded-full border-b-2 border-primary" }), jsx("p", { className: "text-sm text-muted-foreground", children: "\u0E01\u0E33\u0E25\u0E31\u0E07\u0E42\u0E2B\u0E25\u0E14..." })] }) }) }), children: jsx("div", { className: workspace
3667
+ jsxs(Fragment$1, { children: [mobileBrand ? (jsx("div", { className: "shrink-0", children: mobileBrand })) : null, jsx("button", { type: "button", onClick: () => setOpen(!open), "aria-label": open ? "ปิดเมนู" : "เปิดเมนู", className: "grid size-[34px] shrink-0 place-items-center rounded-lg text-[var(--header-fg-muted)] transition-colors hover:bg-[var(--header-hover)] hover:text-[var(--header-fg)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--header-fg-muted)]", children: jsx(LayoutGrid, { className: "size-5", strokeWidth: 1.65 }) })] })) : (jsx(SidebarTrigger, { className: "shrink-0 text-[var(--header-fg-muted)] hover:bg-[var(--header-hover)] hover:text-[var(--header-fg)] [&_svg]:!text-current" })), jsxs("div", { className: "flex min-w-0 flex-1 flex-col justify-center gap-0.5", children: [jsx("div", { className: "hidden min-w-0 sm:block", children: trail }), jsx("div", { id: "header-title", className: "min-w-0 max-sm:hidden" })] }), headerEnd || showHelp ? (jsxs("div", { className: "flex shrink-0 items-center gap-3 sm:gap-5", children: [headerEnd, showHelp ? jsx(HelpButton, {}) : null] })) : null] })] })) : (jsxs("div", { className: "sticky top-0 z-30 shrink-0 w-full", children: [jsx(HeaderBackground, {}), jsxs("header", { className: "relative shrink-0 gap-2 py-4 h-auto min-h-[123.5px] w-full z-10", children: [jsxs("div", { className: "flex w-full items-center gap-2 px-3 sm:px-4 lg:px-6", children: [jsx(SidebarTrigger, { className: "shrink-0 text-white hover:bg-white/10" }), jsx("div", { className: "flex-1 min-w-0", children: trail }), headerEnd || showHelp ? (jsxs("div", { className: "flex shrink-0 items-center gap-3", children: [headerEnd, showHelp ? jsx(HelpButton, {}) : null] })) : null] }), jsx("div", { id: "header-title" })] })] })), jsx(Suspense, { fallback: jsx("div", { className: "relative z-10 min-h-[calc(100vh-8rem)]", children: jsx("div", { className: "flex h-full items-center justify-center", children: jsxs("div", { className: "space-y-4 text-center", children: [jsx("div", { className: "mx-auto h-8 w-8 animate-spin rounded-full border-b-2 border-primary" }), jsx("p", { className: "text-sm text-muted-foreground", children: "\u0E01\u0E33\u0E25\u0E31\u0E07\u0E42\u0E2B\u0E25\u0E14..." })] }) }) }), children: jsx("div", { className: workspace
3218
3668
  ? "relative z-10 px-[18px] pt-[26px] pb-4 sm:px-6 lg:px-10 lg:pt-[38px]"
3219
- : cx("relative z-10 pr-6", needsLeftGutter && "pl-6"), children: orgContextIsStale ? loadingComponent : wrap(children) }, `org-${contentOrgKey}`) })] }) }), showHelp ? jsx(HelpDialogHost, { app: helpApp, orgCode: code }) : null] }));
3669
+ : cx("relative z-10 pr-6", needsLeftGutter && "pl-6"), children: refusePage
3670
+ ? (notMemberComponent ?? (jsx(AccessDenied, { title: "\u0E44\u0E21\u0E48\u0E21\u0E35\u0E2A\u0E34\u0E17\u0E18\u0E34\u0E4C\u0E40\u0E02\u0E49\u0E32\u0E16\u0E36\u0E07\u0E2D\u0E07\u0E04\u0E4C\u0E01\u0E23\u0E19\u0E35\u0E49", message: `บัญชีนี้ไม่ได้เป็นสมาชิกขององค์กร ${code}`, homeHref: selectOrganizationUrl, homeLabel: "\u0E40\u0E25\u0E37\u0E2D\u0E01\u0E2D\u0E07\u0E04\u0E4C\u0E01\u0E23" })))
3671
+ : wrap(children) }, `org-${contentOrgKey}`) })] }) }), showHelp ? jsx(HelpDialogHost, { app: helpApp, orgCode: code }) : null] }));
3220
3672
  }
3221
3673
  /** Local tiny classnames helper — avoids pulling clsx/tailwind-merge into
3222
3674
  * the shell just for the layout file. Falsy values are skipped. */
@@ -3255,33 +3707,6 @@ function SidebarHelpLink({ label, href, icon, className }) {
3255
3707
  return external ? (jsx("a", { href: href, target: "_blank", rel: "noopener noreferrer", className: cls, children: body })) : (jsx(Link, { href: href, className: cls, children: body }));
3256
3708
  }
3257
3709
 
3258
- /**
3259
- * Central "no access" screen shared by every policy check (route-level
3260
- * PermissionProvider and page-level PermissionGateShell). One look everywhere:
3261
- * an illustration, a 403 headline, the page-specific reason, and recovery
3262
- * actions.
3263
- */
3264
- function AccessDenied({ title = "ขออภัย, คุณไม่มีสิทธิ์เข้าถึงหน้านี้ (403)", message = "คุณไม่มีสิทธิ์เข้าถึงหน้านี้ ขออภัยในความไม่สะดวก", homeHref, homeLabel = "กลับไปหน้าหลัก", supportHref, supportLabel = "ติดต่อฝ่ายสนับสนุน", fullScreen = true, className, }) {
3265
- // "/" is not the home page inside an app — it bounces to organization
3266
- // selection, so the button read "กลับไปหน้าหลัก" and dropped the user into a
3267
- // picker for the org they were already in. Send them to that org's home.
3268
- const params = useParams();
3269
- const code = typeof params?.code === "string" ? params.code : "";
3270
- const homeTarget = homeHref ?? (code ? `/${code}` : "/");
3271
- const go = (href) => {
3272
- if (typeof window !== "undefined")
3273
- window.location.href = href;
3274
- };
3275
- const wrapperClass = [
3276
- "flex min-w-0 flex-1 items-center justify-center p-4",
3277
- fullScreen ? "min-h-[70vh]" : "",
3278
- className ?? "",
3279
- ]
3280
- .filter(Boolean)
3281
- .join(" ");
3282
- return (jsx("div", { className: wrapperClass, children: jsx("div", { className: "relative w-full max-w-3xl overflow-hidden rounded-2xl border border-border/60 bg-card/60 p-8 shadow-sm backdrop-blur sm:p-10", children: jsxs("div", { className: "flex flex-col items-center gap-8 text-center sm:flex-row sm:text-left", children: [jsxs("div", { className: "relative shrink-0", children: [jsx("div", { className: "absolute inset-0 rounded-full bg-destructive/20 blur-2xl" }), jsx("div", { className: "relative flex h-28 w-28 items-center justify-center rounded-3xl border border-border/60 bg-gradient-to-br from-muted/40 to-background shadow-inner", children: jsx("div", { className: "flex h-16 w-16 items-center justify-center rounded-full border border-destructive/40 bg-destructive/10", children: jsx(Ban, { className: "h-8 w-8 text-destructive", strokeWidth: 2.5 }) }) })] }), jsxs("div", { className: "min-w-0 flex-1", children: [jsx("h1", { className: "text-xl font-bold text-foreground sm:text-2xl", children: title }), message ? (jsx("p", { className: "mt-3 whitespace-pre-line text-sm leading-relaxed text-muted-foreground sm:text-base", children: message })) : null, jsxs("div", { className: "mt-6 flex flex-col gap-3 sm:flex-row", children: [jsxs(Button, { type: "button", onClick: () => go(homeTarget), className: "w-full sm:w-auto", children: [jsx(Home, { className: "mr-2 h-4 w-4" }), homeLabel] }), supportHref ? (jsxs(Button, { type: "button", variant: "outline", onClick: () => go(supportHref), className: "w-full sm:w-auto", children: [jsx(Headset, { className: "mr-2 h-4 w-4" }), supportLabel] })) : null] })] })] }) }) }));
3283
- }
3284
-
3285
3710
  const EMPLOYEE_RECORD_REQUIRED = "EMPLOYEE_RECORD_REQUIRED";
3286
3711
  const EMPLOYEE_RECORD_REQUIRED_EVENT = "erp:employee-record-required";
3287
3712
  const EMPLOYEE_RECORD_OK_EVENT = "erp:employee-record-ok";
@@ -3674,6 +4099,51 @@ function useFeatureFlag(flag) {
3674
4099
  return flags[flag];
3675
4100
  }
3676
4101
 
4102
+ // No relative imports: node --test loads this file as is (query-scope.test.ts).
4103
+ /**
4104
+ * Every query is cached per organisation.
4105
+ *
4106
+ * Requests are scoped by the X-Organization-ID header, so almost no query key
4107
+ * names the organisation — ["purchase-requests", "detail", id] is the same key
4108
+ * in every company. A key that is the same in two organisations is one cache
4109
+ * entry: what was fetched in one was a fresh cache hit in the other, and a
4110
+ * page opened under another organisation's URL rendered the first one's
4111
+ * document until something refetched it.
4112
+ *
4113
+ * The hash — the identity of a cache entry — therefore carries the
4114
+ * organisation requests are sent with right now (getQueryClientConfig passes
4115
+ * getRequestOrganizationId()). The key itself is untouched,
4116
+ * so partial-key invalidation and every existing key keep working; a switch
4117
+ * simply lands on a different set of entries.
4118
+ */
4119
+ function organizationScopedQueryHash(queryKey, organizationId) {
4120
+ return `${hashKey(queryKey)}@org:${organizationId ?? ""}`;
4121
+ }
4122
+ function errorStatus(error) {
4123
+ if (!error || typeof error !== "object")
4124
+ return undefined;
4125
+ const e = error;
4126
+ const status = typeof e.status === "number" ? e.status : e.response?.status;
4127
+ return typeof status === "number" ? status : undefined;
4128
+ }
4129
+ /**
4130
+ * Retry a failed query at most twice — unless the API answered with a 4xx.
4131
+ * A 404 for a document that is not in this organisation will not appear on a
4132
+ * second ask; retrying held the page on its skeleton for three seconds before
4133
+ * it could say "not found". 408 and 429 are transient and still retried.
4134
+ */
4135
+ function shouldRetryQuery(failureCount, error) {
4136
+ const status = errorStatus(error);
4137
+ if (status !== undefined &&
4138
+ status >= 400 &&
4139
+ status < 500 &&
4140
+ status !== 408 &&
4141
+ status !== 429) {
4142
+ return false;
4143
+ }
4144
+ return failureCount < 2;
4145
+ }
4146
+
3677
4147
  const getAxiosErrorMessage$1 = (error) => {
3678
4148
  if (error.response?.data?.message) {
3679
4149
  return error.response.data.message;
@@ -3697,7 +4167,9 @@ const formatError = (handlerName, error) => {
3697
4167
  };
3698
4168
  /**
3699
4169
  * Default React Query config used across ERP apps:
3700
- * - 2 retries on queries / 1 on mutations, exponential backoff
4170
+ * - cache entries scoped by organisation (organizationScopedQueryHash)
4171
+ * - 2 retries on queries except 4xx answers / none on mutations,
4172
+ * exponential backoff
3701
4173
  * - 5 min staleTime, refetchOnWindowFocus
3702
4174
  * - swallows "no org context" errors to silence noise on the
3703
4175
  * select-organization page
@@ -3708,7 +4180,8 @@ const formatError = (handlerName, error) => {
3708
4180
  const getQueryClientConfig = () => ({
3709
4181
  defaultOptions: {
3710
4182
  queries: {
3711
- retry: 2,
4183
+ queryKeyHashFn: queryKey => organizationScopedQueryHash(queryKey, getRequestOrganizationId()),
4184
+ retry: shouldRetryQuery,
3712
4185
  retryDelay: attemptIndex => Math.min(1000 * 2 ** attemptIndex, 30000),
3713
4186
  staleTime: 5 * 60 * 1000,
3714
4187
  refetchOnWindowFocus: true,
@@ -3793,5 +4266,5 @@ const getAxiosErrorMessage = (error) => {
3793
4266
  return String(error);
3794
4267
  };
3795
4268
 
3796
- export { AccessDenied, ApiError, AppBreadcrumb, AppLayoutShell, AppSidebar, EMPLOYEE_RECORD_OK_EVENT, EMPLOYEE_RECORD_REQUIRED, EMPLOYEE_RECORD_REQUIRED_EVENT, EmployeeRequiredGate, FeatureFlagsProvider, HeaderBackground, HelpButton, HelpDialogHost, NavMain, NavTools, NavUser, OWNER_CHIP, OrganizationSwitcher, PLAN_FEATURES, PermissionGateShell, PermissionProvider, PermissionRulesProvider, ReactQueryProvider, SHELL_VERSION, SessionBackedProfileCacheSeeder, SessionRefreshTrigger, SidebarContent, SidebarFooter, SidebarHeader, SidebarHelpCard, SidebarHelpLink, SidebarProvider, SidebarTrigger, TapMenu, ThemeProvider, WORKSPACE_HEADER_VARS, WORKSPACE_LIGHT_HEADER_VARS, WorkspaceBrand, instance as axiosInstance, buildSystemLinks, buildUrlWithCode, canAccessNavPermissionRoute, clearAccessTokenCache, clearOrganizationId, closeHelp, decodeJWT, describeAttached, describeBrowser, detectHelpApp, filterNavigationModulesByPermission, filterNavigationToolsByPermission, getAccessToken, getActiveModule, getActiveSubModule, getAxiosErrorMessage, getOrganizationId, getQueryClientConfig, getRequiredChildGroups, hasPlanFeature, isLikelyTransientNetworkError, isPathActive, myRolesQueryKey, normalizePathnameForPermissionRules, openHelp, parseMyRoles, resolveActiveNav, resolveSidebarNav, resolveUiRoutePermission, roleSummary, runGlobalRequestPrecheck, runWithTransientRetry, setOrganizationId, toApiError, unwrapApiResponse, updateOrganizationId, useClearOrgOnUserChange, useFeatureFlag, useFeatureFlags, useInvalidateQueriesOnOrgChange, useKeycloakRoles, useMyRoles, useOrganizationIdState, usePermissionRules, useSessionRefresh, useSidebar, useSidebarToggle };
4269
+ export { AccessDenied, ApiError, AppBreadcrumb, AppLayoutShell, AppSidebar, EMPLOYEE_RECORD_OK_EVENT, EMPLOYEE_RECORD_REQUIRED, EMPLOYEE_RECORD_REQUIRED_EVENT, EmployeeRequiredGate, FOR_ORGANIZATION_FIELD, FeatureFlagsProvider, HeaderBackground, HelpButton, HelpDialogHost, NavMain, NavTools, NavUser, OWNER_CHIP, OrganizationSwitcher, PLAN_FEATURES, PermissionGateShell, PermissionProvider, PermissionRulesProvider, ReactQueryProvider, SHELL_VERSION, SessionBackedProfileCacheSeeder, SessionRefreshTrigger, SidebarContent, SidebarFooter, SidebarHeader, SidebarHelpCard, SidebarHelpLink, SidebarProvider, SidebarTrigger, TapMenu, ThemeProvider, WITHOUT_ORGANIZATION_FLAG, WORKSPACE_HEADER_VARS, WORKSPACE_LIGHT_HEADER_VARS, WorkspaceBrand, instance as axiosInstance, buildSystemLinks, buildUrlWithCode, canAccessNavPermissionRoute, clearAccessTokenCache, clearOrganizationId, clearStoredOrganization, closeHelp, decodeJWT, describeAttached, describeBrowser, detectHelpApp, filterNavigationModulesByPermission, filterNavigationToolsByPermission, findOrganizationForCode, getAccessToken, getActiveModule, getActiveSubModule, getAxiosErrorMessage, getOrganizationId, getQueryClientConfig, getRequestOrganizationId, getRequiredChildGroups, getStoredOrganizationIds, getTabOrganizationId, hasPlanFeature, isKnownStaleOrganizationId, isLikelyTransientNetworkError, isPathActive, myRolesQueryKey, normalizePathnameForPermissionRules, openHelp, organizationScopedQueryHash, parseMyRoles, resolveActiveNav, resolveSidebarNav, resolveStaleOrganization, resolveUiRoutePermission, resolveUrlOrgGate, retryWithStoredOrganizations, roleSummary, runGlobalRequestPrecheck, runWithTransientRetry, setKnownOrganizationIds, setOrganizationId, setTabOrganizationId, shouldRetryQuery, toApiError, unwrapApiResponse, updateOrganizationId, useClearOrgOnUserChange, useFeatureFlag, useFeatureFlags, useInvalidateQueriesOnOrgChange, useKeycloakRoles, useMyRoles, useOrganizationIdState, usePermissionRules, useSessionRefresh, useSidebar, useSidebarToggle, withoutOrganization };
3797
4270
  //# sourceMappingURL=index.esm.js.map