@eintrek/erp-shell 0.1.58 → 0.1.60

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, ChevronDown, Plus, ChevronsUpDown, ImagePlus, X, CircleCheck, ArrowLeft, ArrowRight, Compass, ArrowUpRight, Ban, Home, Headset, 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,87 @@ 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 organisation a request from this tab belongs to: the tab's own, then
310
+ * localStorage, then the cookie. Use it for the X-Organization-ID header —
311
+ * and read it before any `await` in the interceptor, so the header names the
312
+ * organisation that was active when the request was made.
313
+ *
314
+ * localStorage before the cookie: setOrganizationId writes localStorage
315
+ * synchronously and then awaits a server action for the cookie, so during
316
+ * that round trip the cookie still names the previous organisation.
317
+ */
318
+ function getRequestOrganizationId() {
319
+ return (getTabOrganizationId() ??
320
+ readOrganizationStorage() ??
321
+ readOrganizationCookie());
322
+ }
323
+
243
324
  const SELECTED_ORG_STORAGE_KEY = "erp.organizationId";
244
325
  const SYSTEM_ROUTE_FIRST_SEGMENTS = [
245
326
  "auth",
@@ -257,10 +338,15 @@ const SYSTEM_ROUTE_FIRST_SEGMENTS = [
257
338
  *
258
339
  * On set, writes through to localStorage + cookie before flipping React state
259
340
  * so all readers see a consistent value across navigations.
341
+ *
342
+ * The tab's own organisation (organization/active-organization.ts) follows
343
+ * this state, and it is written synchronously — in the initialiser and at the
344
+ * start of every set — because requests read it: an effect would run after
345
+ * the children's effects, which is where their queries start.
260
346
  */
261
347
  function useOrganizationIdState(initialOrganizationId, actions) {
262
348
  const previousOrgIdRef = useRef(initialOrganizationId || null);
263
- const [organizationIdState, setOrganizationIdState] = useState(() => {
349
+ function readInitialOrganizationId() {
264
350
  if (initialOrganizationId) {
265
351
  if (typeof window !== "undefined") {
266
352
  setOrganizationId(initialOrganizationId);
@@ -292,8 +378,16 @@ function useOrganizationIdState(initialOrganizationId, actions) {
292
378
  console.error("[useOrganizationIdState] Error reading localStorage:", error);
293
379
  }
294
380
  return null;
381
+ }
382
+ const [organizationIdState, setOrganizationIdState] = useState(() => {
383
+ const initial = readInitialOrganizationId();
384
+ setTabOrganizationId(initial);
385
+ return initial;
295
386
  });
296
387
  const setOrganizationId$1 = useCallback(async (id) => {
388
+ // Before the cookie round trip: from here on this tab's requests are
389
+ // the new organisation's, whatever the shared cookie still says.
390
+ setTabOrganizationId(id);
297
391
  try {
298
392
  if (id === null) {
299
393
  setOrganizationId(null);
@@ -316,6 +410,7 @@ function useOrganizationIdState(initialOrganizationId, actions) {
316
410
  useEffect(() => {
317
411
  if (initialOrganizationId &&
318
412
  initialOrganizationId !== String(organizationIdState)) {
413
+ setTabOrganizationId(initialOrganizationId);
319
414
  setOrganizationIdState(initialOrganizationId);
320
415
  previousOrgIdRef.current = initialOrganizationId;
321
416
  }
@@ -587,6 +682,46 @@ const TapMenu = ({ tabDatas, starterTab, currentTabIndex, variant = "main", }) =
587
682
  }) }), tabDatas[currentTab]?.page] }));
588
683
  };
589
684
 
685
+ /**
686
+ * How a sidebar row looks, apart from its layout.
687
+ *
688
+ * Exactly one row is lit — the page's own (resolveSidebarNav). Until 0.1.60 a
689
+ * row under the pointer took the lit row's fill and colour, and an open group
690
+ * (or a one-line row once clicked, which was a collapsible too) took its
691
+ * colour, so the menu showed several "current pages" at once.
692
+ */
693
+ /** A row under the pointer: a faint neutral tint, never the lit row's look. */
694
+ const NAV_ROW_HOVER = "hover:bg-sidebar-foreground/5 hover:text-sidebar-foreground";
695
+ /** The lit row: the accent fill and text. The pointer changes nothing. */
696
+ const NAV_ROW_LIT = "bg-sidebar-accent text-sidebar-accent-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground";
697
+ /** Fill and colour for a row, lit or not. */
698
+ function navRowTone(lit) {
699
+ return lit ? NAV_ROW_LIT : NAV_ROW_HOVER;
700
+ }
701
+ /**
702
+ * A group opens and shuts; any other row is a plain link with no open state.
703
+ */
704
+ function isNavGroup(item) {
705
+ return (item.subModules?.length ?? 0) > 0;
706
+ }
707
+ /**
708
+ * The open groups after `id` is opened or shut. An id that is not a group's
709
+ * is ignored — a one-line row has nothing to open.
710
+ */
711
+ function setNavGroupOpen(openIds, items, id, isOpen) {
712
+ if (!id || !items.some(item => item.id === id && isNavGroup(item))) {
713
+ return openIds;
714
+ }
715
+ if (openIds.has(id) === isOpen)
716
+ return openIds;
717
+ const next = new Set(openIds);
718
+ if (isOpen)
719
+ next.add(id);
720
+ else
721
+ next.delete(id);
722
+ return next;
723
+ }
724
+
590
725
  /**
591
726
  * Lightweight sidebar primitives — plain styled wrappers, no `useSidebar()`
592
727
  * context dependency. The full `@eintrek/erp-theme` sidebar suite ships its
@@ -610,21 +745,25 @@ const SidebarMenuButton = React.forwardRef(({ className, asChild = false, isActi
610
745
  default: "h-8 text-sm",
611
746
  lg: "h-12 text-sm",
612
747
  };
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);
748
+ 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);
749
+ let row;
615
750
  if (asChild) {
616
751
  if (!children || !React.isValidElement(children)) {
617
752
  console.error("SidebarMenuButton: Invalid children for asChild prop", children);
618
753
  return null;
619
754
  }
620
- return React.cloneElement(children, {
755
+ row = React.cloneElement(children, {
621
756
  className: cn(baseClassName, children.props
622
757
  ?.className),
623
- title: tooltip,
624
758
  ...props,
625
759
  });
626
760
  }
627
- return (jsx("button", { ref: ref, className: baseClassName, title: tooltip, ...props, children: children }));
761
+ else {
762
+ row = (jsx("button", { ref: ref, className: baseClassName, ...props, children: children }));
763
+ }
764
+ if (!tooltip)
765
+ return row;
766
+ return (jsxs(Tooltip, { children: [jsx(TooltipTrigger, { asChild: true, children: row }), jsx(TooltipContent, { side: "right", sideOffset: 10, children: tooltip })] }));
628
767
  });
629
768
  SidebarMenuButton.displayName = "SidebarMenuButton";
630
769
  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 +771,7 @@ SidebarMenuSub.displayName = "SidebarMenuSub";
632
771
  const SidebarMenuSubItem = React.forwardRef(({ ...props }, ref) => jsx("li", { ref: ref, ...props }));
633
772
  SidebarMenuSubItem.displayName = "SidebarMenuSubItem";
634
773
  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);
774
+ 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
775
  if (asChild) {
637
776
  return React.cloneElement(children, {
638
777
  className: cn(baseClassName, children.props
@@ -947,7 +1086,7 @@ function NavTools({ tools, closeOnNavigation = false }) {
947
1086
  // /reports/approval doesn't light up the /reports tool.
948
1087
  const isToolActive = pathname === toolUrl;
949
1088
  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));
1089
+ 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
1090
  }) }) }));
952
1091
  }
953
1092
 
@@ -1276,16 +1415,6 @@ const runGlobalRequestPrecheck = (config) => {
1276
1415
  const isBrowser = () => typeof window !== "undefined";
1277
1416
  const isAuthEndpoint = (url) => !!url && url.includes("/api/auth/");
1278
1417
  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
1418
  const redirectToLogout = async () => {
1290
1419
  if (!isBrowser() || isAuthPage())
1291
1420
  return;
@@ -1320,23 +1449,16 @@ instance.interceptors.request.use(async (config) => {
1320
1449
  }
1321
1450
  if (!isBrowser())
1322
1451
  return config;
1452
+ // Read before the token await: the header must name the organisation that
1453
+ // was active when the request was made (and under which its query is
1454
+ // cached), not whatever is active once the token resolves.
1455
+ const organizationId = getRequestOrganizationId();
1323
1456
  const token = await getAccessToken(false);
1324
1457
  if (token) {
1325
1458
  config.headers.Authorization = `Bearer ${token}`;
1326
1459
  }
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();
1460
+ // The tab's own organisation, then localStorage, then the cookie — see
1461
+ // getRequestOrganizationId.
1340
1462
  if (organizationId) {
1341
1463
  config.headers["X-Organization-ID"] = organizationId;
1342
1464
  }
@@ -1416,7 +1538,7 @@ function platformApiBase(app) {
1416
1538
  * This package's version, attached to help messages so the back office can
1417
1539
  * tell which shell a report came from. Keep in step with package.json.
1418
1540
  */
1419
- const SHELL_VERSION = "0.1.58";
1541
+ const SHELL_VERSION = "0.1.60";
1420
1542
 
1421
1543
  const HELP_APP_LABEL = {
1422
1544
  platform: "แพลตฟอร์ม",
@@ -1424,6 +1546,8 @@ const HELP_APP_LABEL = {
1424
1546
  peoplx: "Peoplx",
1425
1547
  plannex: "Plannex",
1426
1548
  invenx: "Invenx",
1549
+ sellx: "Sellx",
1550
+ carex: "Carex",
1427
1551
  other: "อื่น ๆ",
1428
1552
  };
1429
1553
  function originOf(value) {
@@ -1458,6 +1582,10 @@ function detectHelpApp(explicit) {
1458
1582
  return "plannex";
1459
1583
  if (host.startsWith("invenx."))
1460
1584
  return "invenx";
1585
+ if (host.startsWith("sellx."))
1586
+ return "sellx";
1587
+ if (host.startsWith("carex."))
1588
+ return "carex";
1461
1589
  if (host.startsWith("platform.") || host === "antniti.com" || host === "www.antniti.com") {
1462
1590
  return "platform";
1463
1591
  }
@@ -1468,6 +1596,8 @@ function detectHelpApp(explicit) {
1468
1596
  ["invenx", process.env.NEXT_PUBLIC_INVENX_URL],
1469
1597
  ["plannex", process.env.NEXT_PUBLIC_PLANNEX_URL],
1470
1598
  ["peoplx", process.env.NEXT_PUBLIC_HR_URL],
1599
+ ["sellx", process.env.NEXT_PUBLIC_SELLX_URL],
1600
+ ["carex", process.env.NEXT_PUBLIC_CAREX_URL],
1471
1601
  ];
1472
1602
  for (const [id, url] of configured) {
1473
1603
  if (originOf(url) === here)
@@ -1855,6 +1985,10 @@ const PLAN_FEATURES = {
1855
1985
  MODULE_INVENTORY: "module.inventory",
1856
1986
  MODULE_PROJECT: "module.project",
1857
1987
  MODULE_SECRETARIAT: "module.secretariat",
1988
+ /** Sellx add-on: POS, members and chat commerce. */
1989
+ MODULE_SELLX: "module.sellx",
1990
+ /** Carex add-on: help desk, SLA and knowledge base. */
1991
+ MODULE_CAREX: "module.carex",
1858
1992
  /** AI pack (paid or trial): AI calls on AntNiti's key while credits remain. */
1859
1993
  AI: "ai",
1860
1994
  };
@@ -1939,6 +2073,20 @@ const APPS = [
1939
2073
  features: [PLAN_FEATURES.MODULE_HR],
1940
2074
  baseUrl: () => normaliseBase(process.env.NEXT_PUBLIC_HR_URL, "https://peoplx.antniti.com"),
1941
2075
  },
2076
+ {
2077
+ id: "sellx",
2078
+ label: "Sellx",
2079
+ icon: jsx(Store, { className: "h-4 w-4 shrink-0" }),
2080
+ features: [PLAN_FEATURES.MODULE_SELLX],
2081
+ baseUrl: () => normaliseBase(process.env.NEXT_PUBLIC_SELLX_URL, "https://sellx.antniti.com"),
2082
+ },
2083
+ {
2084
+ id: "carex",
2085
+ label: "Carex",
2086
+ icon: jsx(Headset, { className: "h-4 w-4 shrink-0" }),
2087
+ features: [PLAN_FEATURES.MODULE_CAREX],
2088
+ baseUrl: () => normaliseBase(process.env.NEXT_PUBLIC_CAREX_URL, "https://carex.antniti.com"),
2089
+ },
1942
2090
  ];
1943
2091
  /**
1944
2092
  * Builds the cross-app link list for <NavUser systemLinks>.
@@ -1995,30 +2143,18 @@ function NavMain({ items, orgCode, closeOnNavigation = false, approvalCounts, ch
1995
2143
  // A group opens when the page moves into it and otherwise stays as the
1996
2144
  // user left it. defaultOpen alone decided once, on the first render — and
1997
2145
  // on a direct load the sub-items arrive after permissions do, so the group
1998
- // holding the current page stayed shut.
1999
- const openId = open ? (items[open.item]?.id ?? "") : "";
2146
+ // holding the current page stayed shut. Only groups have an open state: a
2147
+ // one-line row is a plain link (0.1.60 — it was a collapsible too, and a
2148
+ // click left it "open", drawn in the accent colour for the session).
2149
+ const openItem = open ? items[open.item] : undefined;
2150
+ const openId = openItem && isNavGroup(openItem) ? openItem.id : "";
2000
2151
  const [openIds, setOpenIds] = React.useState(() => new Set(openId ? [openId] : []));
2001
2152
  React.useEffect(() => {
2002
2153
  if (!openId)
2003
2154
  return;
2004
- setOpenIds(prev => {
2005
- if (prev.has(openId))
2006
- return prev;
2007
- const next = new Set(prev);
2008
- next.add(openId);
2009
- return next;
2010
- });
2011
- }, [openId]);
2012
- const setItemOpen = (id, isOpen) => setOpenIds(prev => {
2013
- if (prev.has(id) === isOpen)
2014
- return prev;
2015
- const next = new Set(prev);
2016
- if (isOpen)
2017
- next.add(id);
2018
- else
2019
- next.delete(id);
2020
- return next;
2021
- });
2155
+ setOpenIds(prev => setNavGroupOpen(prev, items, openId, true));
2156
+ }, [openId, items]);
2157
+ const setItemOpen = (id, isOpen) => setOpenIds(prev => setNavGroupOpen(prev, items, id, isOpen));
2022
2158
  return (jsx(SidebarGroup, { "data-sidebar": true, className: workspace ? "p-0" : undefined, children: jsx(SidebarMenu, { className: workspace ? "gap-[3px]" : undefined, children: items.map((item, index) => {
2023
2159
  const groupHeading = workspace &&
2024
2160
  item.group &&
@@ -2034,9 +2170,15 @@ function NavMain({ items, orgCode, closeOnNavigation = false, approvalCounts, ch
2034
2170
  : "";
2035
2171
  const icon = workspace && IconComp ? (jsx(IconComp, { className: "size-[18px] shrink-0" })) : null;
2036
2172
  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;
2037
- 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 " +
2038
- 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 " +
2039
- (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
2173
+ if (!isNavGroup(item)) {
2174
+ // A one-line row: a link, lit on its own page only. No open
2175
+ // state, no title tooltip.
2176
+ 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 " +
2177
+ (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));
2178
+ }
2179
+ // A group: its header opens and shuts it (the chevron says which).
2180
+ // An open group is not the current page — only a row in it is.
2181
+ 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
2040
2182
  ? "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]"
2041
2183
  : undefined, children: item.subModules.map((subItem, subIndex) => {
2042
2184
  const subItemUrl = buildUrlWithCode(code, subItem.url);
@@ -2048,20 +2190,24 @@ function NavMain({ items, orgCode, closeOnNavigation = false, approvalCounts, ch
2048
2190
  if (subItem.isCollapsible &&
2049
2191
  subItem.subPages &&
2050
2192
  subItem.subPages.length > 0) {
2051
- 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) => {
2193
+ 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 &&
2194
+ 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) => {
2052
2195
  const subPageUrl = buildUrlWithCode(code, subPage.url);
2053
2196
  const isSubPageActive = isSubItemActive &&
2054
2197
  activeSubPage === subPageIndex;
2055
2198
  const SubPageIcon = subPage.icon;
2056
- return (jsx(SidebarMenuSubItem, { children: jsx(SidebarMenuSubButton, { asChild: true, className: `font-thai w-full text-sm ${isSubPageActive
2057
- ? "text-sidebar-accent-foreground font-medium"
2058
- : "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));
2199
+ return (jsx(SidebarMenuSubItem, { children: jsx(SidebarMenuSubButton, { asChild: true, className: "font-thai w-full text-sm" +
2200
+ (isSubPageActive
2201
+ ? " font-medium"
2202
+ : ""), isActive: isSubPageActive, children: jsxs(Link, { href: subPageUrl, prefetch: false, className: "w-full flex items-center", onClick: handleNavLinkClick, "aria-current": isSubPageActive
2203
+ ? "page"
2204
+ : undefined, children: [SubPageIcon && (jsx(SubPageIcon, { className: "sidebar-subpage-icon flex-shrink-0" })), jsx("span", { className: "flex-1 truncate", children: subPage.titleTh })] }) }) }, subPage.id));
2059
2205
  }) }) })] }) }, subItem.id));
2060
2206
  }
2061
2207
  return (jsx(SidebarMenuSubItem, { children: jsx(SidebarMenuSubButton, { asChild: true, className: "w-full" +
2062
2208
  (workspace && isSubItemActive
2063
2209
  ? " font-semibold"
2064
- : ""), 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
2210
+ : ""), 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
2065
2211
  ? "99+"
2066
2212
  : approvalCount }))] }) }) }, subItem.id));
2067
2213
  }) }) }))] }) })] }, item.id));
@@ -3029,6 +3175,86 @@ function ThreadBubble({ mine, label, at, body, images, }) {
3029
3175
  (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 })] }) }));
3030
3176
  }
3031
3177
 
3178
+ /**
3179
+ * Central "no access" screen shared by every policy check (route-level
3180
+ * PermissionProvider and page-level PermissionGateShell). One look everywhere:
3181
+ * an illustration, a 403 headline, the page-specific reason, and recovery
3182
+ * actions.
3183
+ */
3184
+ function AccessDenied({ title = "ขออภัย, คุณไม่มีสิทธิ์เข้าถึงหน้านี้ (403)", message = "คุณไม่มีสิทธิ์เข้าถึงหน้านี้ ขออภัยในความไม่สะดวก", homeHref, homeLabel = "กลับไปหน้าหลัก", supportHref, supportLabel = "ติดต่อฝ่ายสนับสนุน", fullScreen = true, className, }) {
3185
+ // "/" is not the home page inside an app — it bounces to organization
3186
+ // selection, so the button read "กลับไปหน้าหลัก" and dropped the user into a
3187
+ // picker for the org they were already in. Send them to that org's home.
3188
+ const params = useParams();
3189
+ const code = typeof params?.code === "string" ? params.code : "";
3190
+ const homeTarget = homeHref ?? (code ? `/${code}` : "/");
3191
+ const go = (href) => {
3192
+ if (typeof window !== "undefined")
3193
+ window.location.href = href;
3194
+ };
3195
+ const wrapperClass = [
3196
+ "flex min-w-0 flex-1 items-center justify-center p-4",
3197
+ fullScreen ? "min-h-[70vh]" : "",
3198
+ className ?? "",
3199
+ ]
3200
+ .filter(Boolean)
3201
+ .join(" ");
3202
+ 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] })] })] }) }) }));
3203
+ }
3204
+
3205
+ /**
3206
+ * What AppLayoutShell does with the page while the organisation in the URL and
3207
+ * the tab's active organisation are being reconciled. Pure, so it is tested
3208
+ * without React (url-org-gate.test.ts).
3209
+ *
3210
+ * The URL's `[code]` is authoritative. The page — and with it every request
3211
+ * the page makes — mounts only once the active organisation IS the one the
3212
+ * URL names:
3213
+ *
3214
+ * ready the active organisation is the URL's: render the page.
3215
+ * resolving the caller's organisations are still loading, so the code
3216
+ * cannot be resolved yet: hold the page.
3217
+ * switching the code is one of the caller's organisations but not the
3218
+ * active one: switch to it (`targetId`), hold the page meanwhile.
3219
+ * not-member the list loaded and the code is not in it: refuse.
3220
+ * unverified the list could not be loaded: render as before the gate
3221
+ * existed (the API still scopes every request), rather than
3222
+ * leave the page on a loader for good.
3223
+ */
3224
+ /** The organisation a `[code]` segment names, among the caller's. */
3225
+ function findOrganizationForCode(code, organizations) {
3226
+ if (!code)
3227
+ return null;
3228
+ return (organizations.find(org => org.code === code || String(org.id) === code) ??
3229
+ null);
3230
+ }
3231
+ function resolveUrlOrgGate({ code, organizations, organizationsStatus, organizationId, }) {
3232
+ // No code in the path: the shell sends the user to pick an organisation.
3233
+ if (!code)
3234
+ return { state: "ready", targetId: null };
3235
+ const target = findOrganizationForCode(code, organizations);
3236
+ if (target) {
3237
+ const targetId = String(target.id);
3238
+ const activeId = organizationId === null || organizationId === undefined
3239
+ ? ""
3240
+ : String(organizationId).trim();
3241
+ return activeId === targetId
3242
+ ? { state: "ready", targetId }
3243
+ : { state: "switching", targetId };
3244
+ }
3245
+ const status = organizationsStatus ?? (organizations.length > 0 ? "success" : undefined);
3246
+ switch (status) {
3247
+ case "success":
3248
+ return { state: "not-member" };
3249
+ case "pending":
3250
+ return { state: "resolving" };
3251
+ default:
3252
+ // "error", or a host that does not report the list's status and has
3253
+ // an empty one.
3254
+ return { state: "unverified" };
3255
+ }
3256
+ }
3257
+
3032
3258
  /**
3033
3259
  * Top-level app chrome: sticky header, sidebar slot, content gutter and the
3034
3260
  * session/org-context plumbing that both Accounting and HR need. Apps wrap
@@ -3041,7 +3267,7 @@ function ThreadBubble({ mine, label, at, body, images, }) {
3041
3267
  * an erp-shell bump — domain components must NOT add their own root padding
3042
3268
  * or content gets indented twice.
3043
3269
  */
3044
- function AppLayoutShell({ children, sidebar, loadingComponent, breadcrumb, permissionProvider, organizationId, setOrganizationId, organizations, signInUrl, selectOrganizationUrl, chrome = "brand", workspaceHeader = "brand", headerEnd, mobileBrand, showHelp = true, helpApp, }) {
3270
+ function AppLayoutShell({ children, sidebar, loadingComponent, breadcrumb, permissionProvider, organizationId, setOrganizationId, organizations, organizationsStatus, notMemberComponent, signInUrl, selectOrganizationUrl, chrome = "brand", workspaceHeader = "brand", headerEnd, mobileBrand, showHelp = true, helpApp, }) {
3045
3271
  const { data: session, status } = useSession();
3046
3272
  const { open, setOpen } = useSidebar();
3047
3273
  const router = useRouter();
@@ -3058,33 +3284,37 @@ function AppLayoutShell({ children, sidebar, loadingComponent, breadcrumb, permi
3058
3284
  if (extendedSession?.accessToken)
3059
3285
  hasSeenAccessTokenRef.current = true;
3060
3286
  }, [extendedSession?.accessToken]);
3061
- // Find the org from the URL `[code]` so we can detect a context mismatch
3062
- // (the user bookmarked a different org than the cookie remembers).
3063
- const orgFromCode = React.useMemo(() => {
3064
- if (!code || !organizations.length)
3065
- return null;
3066
- return (organizations.find(org => org.code === code || String(org.id) === code) ?? null);
3067
- }, [code, organizations]);
3068
- // Sync state: when the URL code points at a different org than the cookie,
3069
- // trust the URL and update the cookie. Otherwise do nothing — the user
3070
- // wins ties via the in-app org switcher.
3287
+ // The URL's `[code]` is authoritative: the page mounts only once the active
3288
+ // organisation is the one it names (see url-org-gate.ts).
3289
+ //
3290
+ // The gate used to start only when the code had resolved AND an active id
3291
+ // existed, which left two holes. While the organisation list loaded, the
3292
+ // page mounted at once and its first requests carried the previous
3293
+ // organisation — /O26000021/procurement/purchase-requisition/<id> fetched
3294
+ // the document from O26000019 before the switch. And a code naming none of
3295
+ // the user's organisations never resolved, so the page rendered the
3296
+ // previous organisation's data under it.
3297
+ const urlOrgGate = resolveUrlOrgGate({
3298
+ code,
3299
+ organizations,
3300
+ organizationsStatus,
3301
+ organizationId,
3302
+ });
3303
+ const switchTargetId = urlOrgGate.state === "switching" ? urlOrgGate.targetId : null;
3304
+ // Switch the active organisation to the URL's. Once per target: the setter
3305
+ // writes the cookie through a server action, and re-firing it on every
3306
+ // render while that round trip is in flight only queues duplicates.
3307
+ const requestedSwitchRef = useRef(null);
3071
3308
  useEffect(() => {
3072
- if (!code)
3073
- return;
3074
- if (!organizations.length)
3075
- return;
3076
- if (!orgFromCode)
3309
+ if (!switchTargetId) {
3310
+ requestedSwitchRef.current = null;
3077
3311
  return;
3078
- if (String(organizationId) !== String(orgFromCode.id)) {
3079
- void setOrganizationId(orgFromCode.id);
3080
3312
  }
3081
- }, [
3082
- orgFromCode,
3083
- organizationId,
3084
- setOrganizationId,
3085
- code,
3086
- organizations.length,
3087
- ]);
3313
+ if (requestedSwitchRef.current === switchTargetId)
3314
+ return;
3315
+ requestedSwitchRef.current = switchTargetId;
3316
+ void setOrganizationId(switchTargetId);
3317
+ }, [switchTargetId, setOrganizationId]);
3088
3318
  // Key for the content subtree. It exists so switching organizations throws
3089
3319
  // away the previous org's component state — but it must react ONLY to a real
3090
3320
  // switch. organizationId legitimately passes through null while the context
@@ -3106,21 +3336,20 @@ function AppLayoutShell({ children, sidebar, loadingComponent, breadcrumb, permi
3106
3336
  const contentOrgKey = lastOrgKeyRef.current;
3107
3337
  // Hold the page back until the active organisation is the one the URL names.
3108
3338
  //
3109
- // The sync effect above writes the cookie through a server action, which is
3110
- // a round trip. Until it lands, getOrgFromCookie() still answers with the
3111
- // previous organisation and every request the page fires on mount carries
3112
- // it — so /O26000021/work-record asked about O26000033 and was correctly
3113
- // told there was nothing there. An empty table is the mild version: the
3114
- // create form posts to the same header, so a record could be filed against
3115
- // a company the user was not looking at.
3339
+ // The switch writes the cookie through a server action, which is a round
3340
+ // trip. Until it lands, every request the page fires on mount would carry
3341
+ // the previous organisation — so /O26000021/work-record asked about
3342
+ // O26000033 and was correctly told there was nothing there. An empty table
3343
+ // is the mild version: the create form posts to the same header, so a
3344
+ // record could be filed against a company the user was not looking at.
3116
3345
  //
3117
- // Only while the two genuinely disagree. A code naming no organisation of
3118
- // the caller's is left to the app to refuse, and a page with no code in its
3119
- // path is unaffected.
3120
- const orgContextIsStale = !!orgFromCode &&
3121
- organizationId !== null &&
3122
- organizationId !== undefined &&
3123
- String(organizationId) !== String(orgFromCode.id);
3346
+ // The whole layout waits, not just the content: the chrome reads the URL
3347
+ // too. The breadcrumb looks up the document in the path to print its
3348
+ // number, so with only the content held it fetched
3349
+ // /purchase-requests/<id> under the previous organisation and printed that
3350
+ // organisation's document number under the new one's URL.
3351
+ const holdLayout = urlOrgGate.state === "resolving" || urlOrgGate.state === "switching";
3352
+ const refusePage = urlOrgGate.state === "not-member";
3124
3353
  // ≤1024px: sidebar renders as a modal overlay (backdrop dims content). >1024px:
3125
3354
  // sidebar is docked and the content gets a left margin to clear it.
3126
3355
  const [isSidebarModalLayout, setIsSidebarModalLayout] = React.useState(false);
@@ -3169,6 +3398,9 @@ function AppLayoutShell({ children, sidebar, loadingComponent, breadcrumb, permi
3169
3398
  router.push(selectOrganizationUrl);
3170
3399
  return jsx(Fragment$1, { children: loadingComponent });
3171
3400
  }
3401
+ if (holdLayout) {
3402
+ return jsx(Fragment$1, { children: loadingComponent });
3403
+ }
3172
3404
  // When the sidebar is docked-open it consumes 21.5rem on the left; otherwise
3173
3405
  // the content takes the full width and we add a left gutter inside.
3174
3406
  const workspace = chrome === "workspace";
@@ -3179,6 +3411,9 @@ function AppLayoutShell({ children, sidebar, loadingComponent, breadcrumb, permi
3179
3411
  : "";
3180
3412
  const needsLeftGutter = workspace || !(open && !isSidebarModalLayout);
3181
3413
  const wrap = permissionProvider ?? ((c) => c);
3414
+ // No trail over a refusal: it would look up the path's document under the
3415
+ // active organisation, which is not the URL's.
3416
+ const trail = refusePage ? null : breadcrumb;
3182
3417
  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" &&
3183
3418
  "border-b border-border bg-card/85 backdrop-blur-sm"), style: workspaceHeader === "light"
3184
3419
  ? WORKSPACE_LIGHT_HEADER_VARS
@@ -3188,9 +3423,11 @@ function AppLayoutShell({ children, sidebar, loadingComponent, breadcrumb, permi
3188
3423
  // As in the design: the mark, then a menu button, and the
3189
3424
  // organisation on the right — no trail, no title, so the
3190
3425
  // organisation's name has the room it needs on a phone.
3191
- 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
3426
+ 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
3192
3427
  ? "relative z-10 px-[18px] pt-[26px] pb-4 sm:px-6 lg:px-10 lg:pt-[38px]"
3193
- : cx("relative z-10 pr-6", needsLeftGutter && "pl-6"), children: orgContextIsStale ? loadingComponent : wrap(children) }, `org-${contentOrgKey}`) })] }) }), showHelp ? jsx(HelpDialogHost, { app: helpApp, orgCode: code }) : null] }));
3428
+ : cx("relative z-10 pr-6", needsLeftGutter && "pl-6"), children: refusePage
3429
+ ? (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" })))
3430
+ : wrap(children) }, `org-${contentOrgKey}`) })] }) }), showHelp ? jsx(HelpDialogHost, { app: helpApp, orgCode: code }) : null] }));
3194
3431
  }
3195
3432
  /** Local tiny classnames helper — avoids pulling clsx/tailwind-merge into
3196
3433
  * the shell just for the layout file. Falsy values are skipped. */
@@ -3229,33 +3466,6 @@ function SidebarHelpLink({ label, href, icon, className }) {
3229
3466
  return external ? (jsx("a", { href: href, target: "_blank", rel: "noopener noreferrer", className: cls, children: body })) : (jsx(Link, { href: href, className: cls, children: body }));
3230
3467
  }
3231
3468
 
3232
- /**
3233
- * Central "no access" screen shared by every policy check (route-level
3234
- * PermissionProvider and page-level PermissionGateShell). One look everywhere:
3235
- * an illustration, a 403 headline, the page-specific reason, and recovery
3236
- * actions.
3237
- */
3238
- function AccessDenied({ title = "ขออภัย, คุณไม่มีสิทธิ์เข้าถึงหน้านี้ (403)", message = "คุณไม่มีสิทธิ์เข้าถึงหน้านี้ ขออภัยในความไม่สะดวก", homeHref, homeLabel = "กลับไปหน้าหลัก", supportHref, supportLabel = "ติดต่อฝ่ายสนับสนุน", fullScreen = true, className, }) {
3239
- // "/" is not the home page inside an app — it bounces to organization
3240
- // selection, so the button read "กลับไปหน้าหลัก" and dropped the user into a
3241
- // picker for the org they were already in. Send them to that org's home.
3242
- const params = useParams();
3243
- const code = typeof params?.code === "string" ? params.code : "";
3244
- const homeTarget = homeHref ?? (code ? `/${code}` : "/");
3245
- const go = (href) => {
3246
- if (typeof window !== "undefined")
3247
- window.location.href = href;
3248
- };
3249
- const wrapperClass = [
3250
- "flex min-w-0 flex-1 items-center justify-center p-4",
3251
- fullScreen ? "min-h-[70vh]" : "",
3252
- className ?? "",
3253
- ]
3254
- .filter(Boolean)
3255
- .join(" ");
3256
- 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] })] })] }) }) }));
3257
- }
3258
-
3259
3469
  const EMPLOYEE_RECORD_REQUIRED = "EMPLOYEE_RECORD_REQUIRED";
3260
3470
  const EMPLOYEE_RECORD_REQUIRED_EVENT = "erp:employee-record-required";
3261
3471
  const EMPLOYEE_RECORD_OK_EVENT = "erp:employee-record-ok";
@@ -3648,6 +3858,51 @@ function useFeatureFlag(flag) {
3648
3858
  return flags[flag];
3649
3859
  }
3650
3860
 
3861
+ // No relative imports: node --test loads this file as is (query-scope.test.ts).
3862
+ /**
3863
+ * Every query is cached per organisation.
3864
+ *
3865
+ * Requests are scoped by the X-Organization-ID header, so almost no query key
3866
+ * names the organisation — ["purchase-requests", "detail", id] is the same key
3867
+ * in every company. A key that is the same in two organisations is one cache
3868
+ * entry: what was fetched in one was a fresh cache hit in the other, and a
3869
+ * page opened under another organisation's URL rendered the first one's
3870
+ * document until something refetched it.
3871
+ *
3872
+ * The hash — the identity of a cache entry — therefore carries the
3873
+ * organisation requests are sent with right now (getQueryClientConfig passes
3874
+ * getRequestOrganizationId()). The key itself is untouched,
3875
+ * so partial-key invalidation and every existing key keep working; a switch
3876
+ * simply lands on a different set of entries.
3877
+ */
3878
+ function organizationScopedQueryHash(queryKey, organizationId) {
3879
+ return `${hashKey(queryKey)}@org:${organizationId ?? ""}`;
3880
+ }
3881
+ function errorStatus(error) {
3882
+ if (!error || typeof error !== "object")
3883
+ return undefined;
3884
+ const e = error;
3885
+ const status = typeof e.status === "number" ? e.status : e.response?.status;
3886
+ return typeof status === "number" ? status : undefined;
3887
+ }
3888
+ /**
3889
+ * Retry a failed query at most twice — unless the API answered with a 4xx.
3890
+ * A 404 for a document that is not in this organisation will not appear on a
3891
+ * second ask; retrying held the page on its skeleton for three seconds before
3892
+ * it could say "not found". 408 and 429 are transient and still retried.
3893
+ */
3894
+ function shouldRetryQuery(failureCount, error) {
3895
+ const status = errorStatus(error);
3896
+ if (status !== undefined &&
3897
+ status >= 400 &&
3898
+ status < 500 &&
3899
+ status !== 408 &&
3900
+ status !== 429) {
3901
+ return false;
3902
+ }
3903
+ return failureCount < 2;
3904
+ }
3905
+
3651
3906
  const getAxiosErrorMessage$1 = (error) => {
3652
3907
  if (error.response?.data?.message) {
3653
3908
  return error.response.data.message;
@@ -3671,7 +3926,9 @@ const formatError = (handlerName, error) => {
3671
3926
  };
3672
3927
  /**
3673
3928
  * Default React Query config used across ERP apps:
3674
- * - 2 retries on queries / 1 on mutations, exponential backoff
3929
+ * - cache entries scoped by organisation (organizationScopedQueryHash)
3930
+ * - 2 retries on queries except 4xx answers / none on mutations,
3931
+ * exponential backoff
3675
3932
  * - 5 min staleTime, refetchOnWindowFocus
3676
3933
  * - swallows "no org context" errors to silence noise on the
3677
3934
  * select-organization page
@@ -3682,7 +3939,8 @@ const formatError = (handlerName, error) => {
3682
3939
  const getQueryClientConfig = () => ({
3683
3940
  defaultOptions: {
3684
3941
  queries: {
3685
- retry: 2,
3942
+ queryKeyHashFn: queryKey => organizationScopedQueryHash(queryKey, getRequestOrganizationId()),
3943
+ retry: shouldRetryQuery,
3686
3944
  retryDelay: attemptIndex => Math.min(1000 * 2 ** attemptIndex, 30000),
3687
3945
  staleTime: 5 * 60 * 1000,
3688
3946
  refetchOnWindowFocus: true,
@@ -3767,5 +4025,5 @@ const getAxiosErrorMessage = (error) => {
3767
4025
  return String(error);
3768
4026
  };
3769
4027
 
3770
- 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 };
4028
+ 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, findOrganizationForCode, getAccessToken, getActiveModule, getActiveSubModule, getAxiosErrorMessage, getOrganizationId, getQueryClientConfig, getRequestOrganizationId, getRequiredChildGroups, getTabOrganizationId, hasPlanFeature, isLikelyTransientNetworkError, isPathActive, myRolesQueryKey, normalizePathnameForPermissionRules, openHelp, organizationScopedQueryHash, parseMyRoles, resolveActiveNav, resolveSidebarNav, resolveUiRoutePermission, resolveUrlOrgGate, roleSummary, runGlobalRequestPrecheck, runWithTransientRetry, setOrganizationId, setTabOrganizationId, shouldRetryQuery, toApiError, unwrapApiResponse, updateOrganizationId, useClearOrgOnUserChange, useFeatureFlag, useFeatureFlags, useInvalidateQueriesOnOrgChange, useKeycloakRoles, useMyRoles, useOrganizationIdState, usePermissionRules, useSessionRefresh, useSidebar, useSidebarToggle };
3771
4029
  //# sourceMappingURL=index.esm.js.map