@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.js CHANGED
@@ -259,6 +259,87 @@ async function runWithTransientRetry(fn, attempts = 3) {
259
259
  throw last;
260
260
  }
261
261
 
262
+ /**
263
+ * The organisation this browser tab is working in.
264
+ *
265
+ * Every request carries an X-Organization-ID header, and every cached query
266
+ * belongs to one organisation. Both used to come from the `erp.organizationId`
267
+ * cookie (then localStorage), which is the organisation the BROWSER last had
268
+ * active — shared by every tab and, on antniti.com, by every app. A tab
269
+ * showing /O26000021/… therefore sent whatever another tab, or another app,
270
+ * had picked last, and the URL lost.
271
+ *
272
+ * The tab's own value is set by `useOrganizationIdState` — the state that
273
+ * AppLayoutShell drives to the organisation the URL names — and read first by
274
+ * `getRequestOrganizationId()`. Until a tab has one (a cold load before the
275
+ * provider mounts), localStorage and then the cookie answer as before.
276
+ *
277
+ * Module state, so browser-only: on the server one module instance serves
278
+ * every request, and a value written there would leak between users.
279
+ */
280
+ const ORGANIZATION_ID_KEY = "erp.organizationId";
281
+ let tabOrganizationId = null;
282
+ const isBrowser$1 = () => typeof window !== "undefined";
283
+ function normalise(id) {
284
+ if (id === null || id === undefined)
285
+ return null;
286
+ const value = String(id).trim();
287
+ return value === "" ? null : value;
288
+ }
289
+ /** The organisation this tab is working in, or null before it has one. */
290
+ function getTabOrganizationId() {
291
+ return isBrowser$1() ? tabOrganizationId : null;
292
+ }
293
+ /** Record the organisation this tab is working in (browser only). */
294
+ function setTabOrganizationId(id) {
295
+ if (!isBrowser$1())
296
+ return;
297
+ tabOrganizationId = normalise(id);
298
+ }
299
+ function readOrganizationCookie() {
300
+ if (!isBrowser$1())
301
+ return null;
302
+ const prefix = `${ORGANIZATION_ID_KEY}=`;
303
+ const entry = document.cookie
304
+ .split(";")
305
+ .map(part => part.trim())
306
+ .find(part => part.startsWith(prefix));
307
+ if (!entry)
308
+ return null;
309
+ const raw = entry.slice(prefix.length);
310
+ try {
311
+ return normalise(decodeURIComponent(raw));
312
+ }
313
+ catch {
314
+ return normalise(raw);
315
+ }
316
+ }
317
+ function readOrganizationStorage() {
318
+ if (!isBrowser$1())
319
+ return null;
320
+ try {
321
+ return normalise(localStorage.getItem(ORGANIZATION_ID_KEY));
322
+ }
323
+ catch {
324
+ return null;
325
+ }
326
+ }
327
+ /**
328
+ * The organisation a request from this tab belongs to: the tab's own, then
329
+ * localStorage, then the cookie. Use it for the X-Organization-ID header —
330
+ * and read it before any `await` in the interceptor, so the header names the
331
+ * organisation that was active when the request was made.
332
+ *
333
+ * localStorage before the cookie: setOrganizationId writes localStorage
334
+ * synchronously and then awaits a server action for the cookie, so during
335
+ * that round trip the cookie still names the previous organisation.
336
+ */
337
+ function getRequestOrganizationId() {
338
+ return (getTabOrganizationId() ??
339
+ readOrganizationStorage() ??
340
+ readOrganizationCookie());
341
+ }
342
+
262
343
  const SELECTED_ORG_STORAGE_KEY = "erp.organizationId";
263
344
  const SYSTEM_ROUTE_FIRST_SEGMENTS = [
264
345
  "auth",
@@ -276,10 +357,15 @@ const SYSTEM_ROUTE_FIRST_SEGMENTS = [
276
357
  *
277
358
  * On set, writes through to localStorage + cookie before flipping React state
278
359
  * so all readers see a consistent value across navigations.
360
+ *
361
+ * The tab's own organisation (organization/active-organization.ts) follows
362
+ * this state, and it is written synchronously — in the initialiser and at the
363
+ * start of every set — because requests read it: an effect would run after
364
+ * the children's effects, which is where their queries start.
279
365
  */
280
366
  function useOrganizationIdState(initialOrganizationId, actions) {
281
367
  const previousOrgIdRef = React.useRef(initialOrganizationId || null);
282
- const [organizationIdState, setOrganizationIdState] = React.useState(() => {
368
+ function readInitialOrganizationId() {
283
369
  if (initialOrganizationId) {
284
370
  if (typeof window !== "undefined") {
285
371
  setOrganizationId(initialOrganizationId);
@@ -311,8 +397,16 @@ function useOrganizationIdState(initialOrganizationId, actions) {
311
397
  console.error("[useOrganizationIdState] Error reading localStorage:", error);
312
398
  }
313
399
  return null;
400
+ }
401
+ const [organizationIdState, setOrganizationIdState] = React.useState(() => {
402
+ const initial = readInitialOrganizationId();
403
+ setTabOrganizationId(initial);
404
+ return initial;
314
405
  });
315
406
  const setOrganizationId$1 = React.useCallback(async (id) => {
407
+ // Before the cookie round trip: from here on this tab's requests are
408
+ // the new organisation's, whatever the shared cookie still says.
409
+ setTabOrganizationId(id);
316
410
  try {
317
411
  if (id === null) {
318
412
  setOrganizationId(null);
@@ -335,6 +429,7 @@ function useOrganizationIdState(initialOrganizationId, actions) {
335
429
  React.useEffect(() => {
336
430
  if (initialOrganizationId &&
337
431
  initialOrganizationId !== String(organizationIdState)) {
432
+ setTabOrganizationId(initialOrganizationId);
338
433
  setOrganizationIdState(initialOrganizationId);
339
434
  previousOrgIdRef.current = initialOrganizationId;
340
435
  }
@@ -606,6 +701,46 @@ const TapMenu = ({ tabDatas, starterTab, currentTabIndex, variant = "main", }) =
606
701
  }) }), tabDatas[currentTab]?.page] }));
607
702
  };
608
703
 
704
+ /**
705
+ * How a sidebar row looks, apart from its layout.
706
+ *
707
+ * Exactly one row is lit — the page's own (resolveSidebarNav). Until 0.1.60 a
708
+ * row under the pointer took the lit row's fill and colour, and an open group
709
+ * (or a one-line row once clicked, which was a collapsible too) took its
710
+ * colour, so the menu showed several "current pages" at once.
711
+ */
712
+ /** A row under the pointer: a faint neutral tint, never the lit row's look. */
713
+ const NAV_ROW_HOVER = "hover:bg-sidebar-foreground/5 hover:text-sidebar-foreground";
714
+ /** The lit row: the accent fill and text. The pointer changes nothing. */
715
+ const NAV_ROW_LIT = "bg-sidebar-accent text-sidebar-accent-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground";
716
+ /** Fill and colour for a row, lit or not. */
717
+ function navRowTone(lit) {
718
+ return lit ? NAV_ROW_LIT : NAV_ROW_HOVER;
719
+ }
720
+ /**
721
+ * A group opens and shuts; any other row is a plain link with no open state.
722
+ */
723
+ function isNavGroup(item) {
724
+ return (item.subModules?.length ?? 0) > 0;
725
+ }
726
+ /**
727
+ * The open groups after `id` is opened or shut. An id that is not a group's
728
+ * is ignored — a one-line row has nothing to open.
729
+ */
730
+ function setNavGroupOpen(openIds, items, id, isOpen) {
731
+ if (!id || !items.some(item => item.id === id && isNavGroup(item))) {
732
+ return openIds;
733
+ }
734
+ if (openIds.has(id) === isOpen)
735
+ return openIds;
736
+ const next = new Set(openIds);
737
+ if (isOpen)
738
+ next.add(id);
739
+ else
740
+ next.delete(id);
741
+ return next;
742
+ }
743
+
609
744
  /**
610
745
  * Lightweight sidebar primitives — plain styled wrappers, no `useSidebar()`
611
746
  * context dependency. The full `@eintrek/erp-theme` sidebar suite ships its
@@ -629,21 +764,25 @@ const SidebarMenuButton = React__namespace.forwardRef(({ className, asChild = fa
629
764
  default: "h-8 text-sm",
630
765
  lg: "h-12 text-sm",
631
766
  };
632
- const baseClassName = erpTheme.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 &&
633
- "bg-sidebar-accent font-medium text-sidebar-accent-foreground", className);
767
+ const baseClassName = erpTheme.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);
768
+ let row;
634
769
  if (asChild) {
635
770
  if (!children || !React__namespace.isValidElement(children)) {
636
771
  console.error("SidebarMenuButton: Invalid children for asChild prop", children);
637
772
  return null;
638
773
  }
639
- return React__namespace.cloneElement(children, {
774
+ row = React__namespace.cloneElement(children, {
640
775
  className: erpTheme.cn(baseClassName, children.props
641
776
  ?.className),
642
- title: tooltip,
643
777
  ...props,
644
778
  });
645
779
  }
646
- return (jsxRuntime.jsx("button", { ref: ref, className: baseClassName, title: tooltip, ...props, children: children }));
780
+ else {
781
+ row = (jsxRuntime.jsx("button", { ref: ref, className: baseClassName, ...props, children: children }));
782
+ }
783
+ if (!tooltip)
784
+ return row;
785
+ return (jsxRuntime.jsxs(erpTheme.Tooltip, { children: [jsxRuntime.jsx(erpTheme.TooltipTrigger, { asChild: true, children: row }), jsxRuntime.jsx(erpTheme.TooltipContent, { side: "right", sideOffset: 10, children: tooltip })] }));
647
786
  });
648
787
  SidebarMenuButton.displayName = "SidebarMenuButton";
649
788
  const SidebarMenuSub = React__namespace.forwardRef(({ className, ...props }, ref) => (jsxRuntime.jsx("ul", { ref: ref, className: erpTheme.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 })));
@@ -651,7 +790,7 @@ SidebarMenuSub.displayName = "SidebarMenuSub";
651
790
  const SidebarMenuSubItem = React__namespace.forwardRef(({ ...props }, ref) => jsxRuntime.jsx("li", { ref: ref, ...props }));
652
791
  SidebarMenuSubItem.displayName = "SidebarMenuSubItem";
653
792
  const SidebarMenuSubButton = React__namespace.forwardRef(({ asChild = false, size = "md", isActive, className, children, ...props }, ref) => {
654
- const baseClassName = erpTheme.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);
793
+ const baseClassName = erpTheme.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);
655
794
  if (asChild) {
656
795
  return React__namespace.cloneElement(children, {
657
796
  className: erpTheme.cn(baseClassName, children.props
@@ -966,7 +1105,7 @@ function NavTools({ tools, closeOnNavigation = false }) {
966
1105
  // /reports/approval doesn't light up the /reports tool.
967
1106
  const isToolActive = pathname === toolUrl;
968
1107
  const Icon = tool.icon;
969
- return (jsxRuntime.jsx(SidebarMenuItem, { children: jsxRuntime.jsx(SidebarMenuButton, { asChild: true, isActive: isToolActive, className: "font-thai data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground", children: jsxRuntime.jsxs(Link, { href: toolUrl, className: "flex items-center gap-2 w-full", onClick: handleNavLinkClick, children: [jsxRuntime.jsx(Icon, { className: "sidebar-module-icon" }), jsxRuntime.jsx("span", { children: tool.titleTh })] }) }) }, tool.id));
1108
+ return (jsxRuntime.jsx(SidebarMenuItem, { children: jsxRuntime.jsx(SidebarMenuButton, { asChild: true, isActive: isToolActive, className: "font-thai data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground", children: jsxRuntime.jsxs(Link, { href: toolUrl, className: "flex items-center gap-2 w-full", onClick: handleNavLinkClick, "aria-current": isToolActive ? "page" : undefined, children: [jsxRuntime.jsx(Icon, { className: "sidebar-module-icon" }), jsxRuntime.jsx("span", { children: tool.titleTh })] }) }) }, tool.id));
970
1109
  }) }) }));
971
1110
  }
972
1111
 
@@ -1295,16 +1434,6 @@ const runGlobalRequestPrecheck = (config) => {
1295
1434
  const isBrowser = () => typeof window !== "undefined";
1296
1435
  const isAuthEndpoint = (url) => !!url && url.includes("/api/auth/");
1297
1436
  const isAuthPage = () => isBrowser() && window.location.pathname.startsWith("/auth/");
1298
- const getOrgFromCookie = () => {
1299
- if (!isBrowser())
1300
- return null;
1301
- const cookies = document.cookie.split(";").map(cookie => cookie.trim());
1302
- const orgCookie = cookies.find(cookie => cookie.startsWith("erp.organizationId="));
1303
- if (!orgCookie)
1304
- return null;
1305
- const raw = orgCookie.substring("erp.organizationId=".length);
1306
- return raw ? decodeURIComponent(raw) : null;
1307
- };
1308
1437
  const redirectToLogout = async () => {
1309
1438
  if (!isBrowser() || isAuthPage())
1310
1439
  return;
@@ -1339,23 +1468,16 @@ instance.interceptors.request.use(async (config) => {
1339
1468
  }
1340
1469
  if (!isBrowser())
1341
1470
  return config;
1471
+ // Read before the token await: the header must name the organisation that
1472
+ // was active when the request was made (and under which its query is
1473
+ // cached), not whatever is active once the token resolves.
1474
+ const organizationId = getRequestOrganizationId();
1342
1475
  const token = await getAccessToken(false);
1343
1476
  if (token) {
1344
1477
  config.headers.Authorization = `Bearer ${token}`;
1345
1478
  }
1346
- // localStorage first, cookie second.
1347
- //
1348
- // Both are written by the same state machine, but not at the same moment:
1349
- // setOrganizationId writes localStorage synchronously and then awaits a
1350
- // server action for the cookie. Preferring the cookie meant every request
1351
- // fired during that round trip carried the previous organisation — while
1352
- // the query params, built from React state, already carried the new one.
1353
- //
1354
- // The backend scopes by this header and ignores organization_id in the
1355
- // query, so those requests asked for organisation A and were handed B, and
1356
- // the answer landed in the cache under A's key. No amount of cache clearing
1357
- // reaches that: the entry is wrong, not stale.
1358
- const organizationId = getOrganizationId() || getOrgFromCookie();
1479
+ // The tab's own organisation, then localStorage, then the cookie — see
1480
+ // getRequestOrganizationId.
1359
1481
  if (organizationId) {
1360
1482
  config.headers["X-Organization-ID"] = organizationId;
1361
1483
  }
@@ -1435,7 +1557,7 @@ function platformApiBase(app) {
1435
1557
  * This package's version, attached to help messages so the back office can
1436
1558
  * tell which shell a report came from. Keep in step with package.json.
1437
1559
  */
1438
- const SHELL_VERSION = "0.1.58";
1560
+ const SHELL_VERSION = "0.1.60";
1439
1561
 
1440
1562
  const HELP_APP_LABEL = {
1441
1563
  platform: "แพลตฟอร์ม",
@@ -1443,6 +1565,8 @@ const HELP_APP_LABEL = {
1443
1565
  peoplx: "Peoplx",
1444
1566
  plannex: "Plannex",
1445
1567
  invenx: "Invenx",
1568
+ sellx: "Sellx",
1569
+ carex: "Carex",
1446
1570
  other: "อื่น ๆ",
1447
1571
  };
1448
1572
  function originOf(value) {
@@ -1477,6 +1601,10 @@ function detectHelpApp(explicit) {
1477
1601
  return "plannex";
1478
1602
  if (host.startsWith("invenx."))
1479
1603
  return "invenx";
1604
+ if (host.startsWith("sellx."))
1605
+ return "sellx";
1606
+ if (host.startsWith("carex."))
1607
+ return "carex";
1480
1608
  if (host.startsWith("platform.") || host === "antniti.com" || host === "www.antniti.com") {
1481
1609
  return "platform";
1482
1610
  }
@@ -1487,6 +1615,8 @@ function detectHelpApp(explicit) {
1487
1615
  ["invenx", process.env.NEXT_PUBLIC_INVENX_URL],
1488
1616
  ["plannex", process.env.NEXT_PUBLIC_PLANNEX_URL],
1489
1617
  ["peoplx", process.env.NEXT_PUBLIC_HR_URL],
1618
+ ["sellx", process.env.NEXT_PUBLIC_SELLX_URL],
1619
+ ["carex", process.env.NEXT_PUBLIC_CAREX_URL],
1490
1620
  ];
1491
1621
  for (const [id, url] of configured) {
1492
1622
  if (originOf(url) === here)
@@ -1874,6 +2004,10 @@ const PLAN_FEATURES = {
1874
2004
  MODULE_INVENTORY: "module.inventory",
1875
2005
  MODULE_PROJECT: "module.project",
1876
2006
  MODULE_SECRETARIAT: "module.secretariat",
2007
+ /** Sellx add-on: POS, members and chat commerce. */
2008
+ MODULE_SELLX: "module.sellx",
2009
+ /** Carex add-on: help desk, SLA and knowledge base. */
2010
+ MODULE_CAREX: "module.carex",
1877
2011
  /** AI pack (paid or trial): AI calls on AntNiti's key while credits remain. */
1878
2012
  AI: "ai",
1879
2013
  };
@@ -1958,6 +2092,20 @@ const APPS = [
1958
2092
  features: [PLAN_FEATURES.MODULE_HR],
1959
2093
  baseUrl: () => normaliseBase(process.env.NEXT_PUBLIC_HR_URL, "https://peoplx.antniti.com"),
1960
2094
  },
2095
+ {
2096
+ id: "sellx",
2097
+ label: "Sellx",
2098
+ icon: jsxRuntime.jsx(lucideReact.Store, { className: "h-4 w-4 shrink-0" }),
2099
+ features: [PLAN_FEATURES.MODULE_SELLX],
2100
+ baseUrl: () => normaliseBase(process.env.NEXT_PUBLIC_SELLX_URL, "https://sellx.antniti.com"),
2101
+ },
2102
+ {
2103
+ id: "carex",
2104
+ label: "Carex",
2105
+ icon: jsxRuntime.jsx(lucideReact.Headset, { className: "h-4 w-4 shrink-0" }),
2106
+ features: [PLAN_FEATURES.MODULE_CAREX],
2107
+ baseUrl: () => normaliseBase(process.env.NEXT_PUBLIC_CAREX_URL, "https://carex.antniti.com"),
2108
+ },
1961
2109
  ];
1962
2110
  /**
1963
2111
  * Builds the cross-app link list for <NavUser systemLinks>.
@@ -2014,30 +2162,18 @@ function NavMain({ items, orgCode, closeOnNavigation = false, approvalCounts, ch
2014
2162
  // A group opens when the page moves into it and otherwise stays as the
2015
2163
  // user left it. defaultOpen alone decided once, on the first render — and
2016
2164
  // on a direct load the sub-items arrive after permissions do, so the group
2017
- // holding the current page stayed shut.
2018
- const openId = open ? (items[open.item]?.id ?? "") : "";
2165
+ // holding the current page stayed shut. Only groups have an open state: a
2166
+ // one-line row is a plain link (0.1.60 — it was a collapsible too, and a
2167
+ // click left it "open", drawn in the accent colour for the session).
2168
+ const openItem = open ? items[open.item] : undefined;
2169
+ const openId = openItem && isNavGroup(openItem) ? openItem.id : "";
2019
2170
  const [openIds, setOpenIds] = React__namespace.useState(() => new Set(openId ? [openId] : []));
2020
2171
  React__namespace.useEffect(() => {
2021
2172
  if (!openId)
2022
2173
  return;
2023
- setOpenIds(prev => {
2024
- if (prev.has(openId))
2025
- return prev;
2026
- const next = new Set(prev);
2027
- next.add(openId);
2028
- return next;
2029
- });
2030
- }, [openId]);
2031
- const setItemOpen = (id, isOpen) => setOpenIds(prev => {
2032
- if (prev.has(id) === isOpen)
2033
- return prev;
2034
- const next = new Set(prev);
2035
- if (isOpen)
2036
- next.add(id);
2037
- else
2038
- next.delete(id);
2039
- return next;
2040
- });
2174
+ setOpenIds(prev => setNavGroupOpen(prev, items, openId, true));
2175
+ }, [openId, items]);
2176
+ const setItemOpen = (id, isOpen) => setOpenIds(prev => setNavGroupOpen(prev, items, id, isOpen));
2041
2177
  return (jsxRuntime.jsx(SidebarGroup, { "data-sidebar": true, className: workspace ? "p-0" : undefined, children: jsxRuntime.jsx(SidebarMenu, { className: workspace ? "gap-[3px]" : undefined, children: items.map((item, index) => {
2042
2178
  const groupHeading = workspace &&
2043
2179
  item.group &&
@@ -2053,9 +2189,15 @@ function NavMain({ items, orgCode, closeOnNavigation = false, approvalCounts, ch
2053
2189
  : "";
2054
2190
  const icon = workspace && IconComp ? (jsxRuntime.jsx(IconComp, { className: "size-[18px] shrink-0" })) : null;
2055
2191
  const badge = workspace && item.badge !== undefined && item.badge !== "" ? (jsxRuntime.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;
2056
- return (jsxRuntime.jsxs(React__namespace.Fragment, { children: [groupHeading, jsxRuntime.jsx(erpTheme.Collapsible, { asChild: true, open: openIds.has(item.id), onOpenChange: isOpen => setItemOpen(item.id, isOpen), className: "group/collapsible", children: jsxRuntime.jsxs(SidebarMenuItem, { children: [jsxRuntime.jsx(erpTheme.CollapsibleTrigger, { asChild: true, children: jsxRuntime.jsx(SidebarMenuButton, { asChild: !item.subModules || item.subModules.length === 0, isActive: isItemActive, className: "data-[state=open]:text-sidebar-accent-foreground " +
2057
- workspaceButton, children: item.subModules && item.subModules.length > 0 ? (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [icon, jsxRuntime.jsx("span", { id: item.id, children: item.titleTh }), jsxRuntime.jsx(lucideReact.ChevronRight, { className: "ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" })] })) : (jsxRuntime.jsxs(Link, { href: itemUrl, prefetch: false, className: "flex items-center w-full min-w-0 flex-1 " +
2058
- (workspace ? "gap-3" : "gap-2"), onClick: handleNavLinkClick, "aria-current": isItemActive ? "page" : undefined, children: [icon, jsxRuntime.jsx("span", { id: item.id, className: "flex-1 truncate", title: item.titleTh, children: item.titleTh }), badge] })) }) }), item.subModules && item.subModules.length > 0 && (jsxRuntime.jsx(erpTheme.CollapsibleContent, { children: jsxRuntime.jsx(SidebarMenuSub, { className: workspace
2192
+ if (!isNavGroup(item)) {
2193
+ // A one-line row: a link, lit on its own page only. No open
2194
+ // state, no title tooltip.
2195
+ return (jsxRuntime.jsxs(React__namespace.Fragment, { children: [groupHeading, jsxRuntime.jsx(SidebarMenuItem, { children: jsxRuntime.jsx(SidebarMenuButton, { asChild: true, isActive: isItemActive, className: workspaceButton, children: jsxRuntime.jsxs(Link, { href: itemUrl, prefetch: false, className: "flex items-center w-full min-w-0 flex-1 " +
2196
+ (workspace ? "gap-3" : "gap-2"), onClick: handleNavLinkClick, "aria-current": isItemActive ? "page" : undefined, children: [icon, jsxRuntime.jsx("span", { id: item.id, className: "flex-1 truncate", children: item.titleTh }), badge] }) }) })] }, item.id));
2197
+ }
2198
+ // A group: its header opens and shuts it (the chevron says which).
2199
+ // An open group is not the current page — only a row in it is.
2200
+ return (jsxRuntime.jsxs(React__namespace.Fragment, { children: [groupHeading, jsxRuntime.jsx(erpTheme.Collapsible, { asChild: true, open: openIds.has(item.id), onOpenChange: isOpen => setItemOpen(item.id, isOpen), className: "group/collapsible", children: jsxRuntime.jsxs(SidebarMenuItem, { children: [jsxRuntime.jsx(erpTheme.CollapsibleTrigger, { asChild: true, children: jsxRuntime.jsxs(SidebarMenuButton, { isActive: isItemActive, className: workspaceButton, children: [icon, jsxRuntime.jsx("span", { id: item.id, className: "flex-1 truncate text-left", children: item.titleTh }), jsxRuntime.jsx(lucideReact.ChevronRight, { className: "ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" })] }) }), item.subModules && (jsxRuntime.jsx(erpTheme.CollapsibleContent, { children: jsxRuntime.jsx(SidebarMenuSub, { className: workspace
2059
2201
  ? "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]"
2060
2202
  : undefined, children: item.subModules.map((subItem, subIndex) => {
2061
2203
  const subItemUrl = buildUrlWithCode(code, subItem.url);
@@ -2067,20 +2209,24 @@ function NavMain({ items, orgCode, closeOnNavigation = false, approvalCounts, ch
2067
2209
  if (subItem.isCollapsible &&
2068
2210
  subItem.subPages &&
2069
2211
  subItem.subPages.length > 0) {
2070
- return (jsxRuntime.jsx(erpTheme.Collapsible, { asChild: true, defaultOpen: isSubItemActive || openSub === subIndex, className: "group/sub-collapsible", children: jsxRuntime.jsxs(SidebarMenuSubItem, { children: [jsxRuntime.jsx(erpTheme.CollapsibleTrigger, { asChild: true, children: jsxRuntime.jsxs(SidebarMenuSubButton, { className: "w-full", isActive: isSubItemActive, children: [SubIcon && (jsxRuntime.jsx(SubIcon, { className: "sidebar-submodule-icon flex-shrink-0" })), jsxRuntime.jsx("span", { className: "flex-1 truncate", title: subItem.titleTh, children: subItem.titleTh }), jsxRuntime.jsx(lucideReact.ChevronDown, { className: "ml-auto size-4 transition-transform duration-200 group-data-[state=open]/sub-collapsible:rotate-180" })] }) }), jsxRuntime.jsx(erpTheme.CollapsibleContent, { children: jsxRuntime.jsx(SidebarMenuSub, { className: "ml-4", children: subItem.subPages.map((subPage, subPageIndex) => {
2212
+ return (jsxRuntime.jsx(erpTheme.Collapsible, { asChild: true, defaultOpen: isSubItemActive || openSub === subIndex, className: "group/sub-collapsible", children: jsxRuntime.jsxs(SidebarMenuSubItem, { children: [jsxRuntime.jsx(erpTheme.CollapsibleTrigger, { asChild: true, children: jsxRuntime.jsxs(SidebarMenuSubButton, { className: "w-full", isActive: isSubItemActive &&
2213
+ activeSubPage === null, children: [SubIcon && (jsxRuntime.jsx(SubIcon, { className: "sidebar-submodule-icon flex-shrink-0" })), jsxRuntime.jsx("span", { className: "flex-1 truncate", children: subItem.titleTh }), jsxRuntime.jsx(lucideReact.ChevronDown, { className: "ml-auto size-4 transition-transform duration-200 group-data-[state=open]/sub-collapsible:rotate-180" })] }) }), jsxRuntime.jsx(erpTheme.CollapsibleContent, { children: jsxRuntime.jsx(SidebarMenuSub, { className: "ml-4", children: subItem.subPages.map((subPage, subPageIndex) => {
2071
2214
  const subPageUrl = buildUrlWithCode(code, subPage.url);
2072
2215
  const isSubPageActive = isSubItemActive &&
2073
2216
  activeSubPage === subPageIndex;
2074
2217
  const SubPageIcon = subPage.icon;
2075
- return (jsxRuntime.jsx(SidebarMenuSubItem, { children: jsxRuntime.jsx(SidebarMenuSubButton, { asChild: true, className: `font-thai w-full text-sm ${isSubPageActive
2076
- ? "text-sidebar-accent-foreground font-medium"
2077
- : "text-sidebar-foreground"}`, isActive: false, children: jsxRuntime.jsxs(Link, { href: subPageUrl, prefetch: false, className: "w-full flex items-center", onClick: handleNavLinkClick, children: [SubPageIcon && (jsxRuntime.jsx(SubPageIcon, { className: "sidebar-subpage-icon flex-shrink-0" })), jsxRuntime.jsx("span", { className: "flex-1 truncate", title: subPage.titleTh, children: subPage.titleTh })] }) }) }, subPage.id));
2218
+ return (jsxRuntime.jsx(SidebarMenuSubItem, { children: jsxRuntime.jsx(SidebarMenuSubButton, { asChild: true, className: "font-thai w-full text-sm" +
2219
+ (isSubPageActive
2220
+ ? " font-medium"
2221
+ : ""), isActive: isSubPageActive, children: jsxRuntime.jsxs(Link, { href: subPageUrl, prefetch: false, className: "w-full flex items-center", onClick: handleNavLinkClick, "aria-current": isSubPageActive
2222
+ ? "page"
2223
+ : undefined, children: [SubPageIcon && (jsxRuntime.jsx(SubPageIcon, { className: "sidebar-subpage-icon flex-shrink-0" })), jsxRuntime.jsx("span", { className: "flex-1 truncate", children: subPage.titleTh })] }) }) }, subPage.id));
2078
2224
  }) }) })] }) }, subItem.id));
2079
2225
  }
2080
2226
  return (jsxRuntime.jsx(SidebarMenuSubItem, { children: jsxRuntime.jsx(SidebarMenuSubButton, { asChild: true, className: "w-full" +
2081
2227
  (workspace && isSubItemActive
2082
2228
  ? " font-semibold"
2083
- : ""), isActive: isSubItemActive, children: jsxRuntime.jsxs(Link, { href: subItemUrl, prefetch: false, className: "w-full flex items-center", onClick: handleNavLinkClick, children: [SubIcon && (jsxRuntime.jsx(SubIcon, { className: "sidebar-submodule-icon flex-shrink-0" })), jsxRuntime.jsx("span", { className: "flex-1 truncate", title: subItem.titleTh, children: subItem.titleTh }), approvalCount > 0 && (jsxRuntime.jsx(erpTheme.Badge, { variant: "destructive", className: "ml-auto h-5 min-w-5 px-1.5 text-xs font-medium tabular-nums", children: approvalCount > 99
2229
+ : ""), isActive: isSubItemActive, children: jsxRuntime.jsxs(Link, { href: subItemUrl, prefetch: false, className: "w-full flex items-center", onClick: handleNavLinkClick, "aria-current": isSubItemActive ? "page" : undefined, children: [SubIcon && (jsxRuntime.jsx(SubIcon, { className: "sidebar-submodule-icon flex-shrink-0" })), jsxRuntime.jsx("span", { className: "flex-1 truncate", children: subItem.titleTh }), approvalCount > 0 && (jsxRuntime.jsx(erpTheme.Badge, { variant: "destructive", className: "ml-auto h-5 min-w-5 px-1.5 text-xs font-medium tabular-nums", children: approvalCount > 99
2084
2230
  ? "99+"
2085
2231
  : approvalCount }))] }) }) }, subItem.id));
2086
2232
  }) }) }))] }) })] }, item.id));
@@ -3048,6 +3194,86 @@ function ThreadBubble({ mine, label, at, body, images, }) {
3048
3194
  (mine ? "border border-primary/30 bg-primary/5" : "bg-muted"), children: [jsxRuntime.jsxs("p", { className: "text-xs text-muted-foreground", children: [label, " \u00B7 ", when(at)] }), jsxRuntime.jsx("p", { className: "mt-1 whitespace-pre-wrap break-words text-sm", children: body }), jsxRuntime.jsx(HelpImageStrip, { images: images })] }) }));
3049
3195
  }
3050
3196
 
3197
+ /**
3198
+ * Central "no access" screen shared by every policy check (route-level
3199
+ * PermissionProvider and page-level PermissionGateShell). One look everywhere:
3200
+ * an illustration, a 403 headline, the page-specific reason, and recovery
3201
+ * actions.
3202
+ */
3203
+ function AccessDenied({ title = "ขออภัย, คุณไม่มีสิทธิ์เข้าถึงหน้านี้ (403)", message = "คุณไม่มีสิทธิ์เข้าถึงหน้านี้ ขออภัยในความไม่สะดวก", homeHref, homeLabel = "กลับไปหน้าหลัก", supportHref, supportLabel = "ติดต่อฝ่ายสนับสนุน", fullScreen = true, className, }) {
3204
+ // "/" is not the home page inside an app — it bounces to organization
3205
+ // selection, so the button read "กลับไปหน้าหลัก" and dropped the user into a
3206
+ // picker for the org they were already in. Send them to that org's home.
3207
+ const params = navigation.useParams();
3208
+ const code = typeof params?.code === "string" ? params.code : "";
3209
+ const homeTarget = homeHref ?? (code ? `/${code}` : "/");
3210
+ const go = (href) => {
3211
+ if (typeof window !== "undefined")
3212
+ window.location.href = href;
3213
+ };
3214
+ const wrapperClass = [
3215
+ "flex min-w-0 flex-1 items-center justify-center p-4",
3216
+ fullScreen ? "min-h-[70vh]" : "",
3217
+ className ?? "",
3218
+ ]
3219
+ .filter(Boolean)
3220
+ .join(" ");
3221
+ return (jsxRuntime.jsx("div", { className: wrapperClass, children: jsxRuntime.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: jsxRuntime.jsxs("div", { className: "flex flex-col items-center gap-8 text-center sm:flex-row sm:text-left", children: [jsxRuntime.jsxs("div", { className: "relative shrink-0", children: [jsxRuntime.jsx("div", { className: "absolute inset-0 rounded-full bg-destructive/20 blur-2xl" }), jsxRuntime.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: jsxRuntime.jsx("div", { className: "flex h-16 w-16 items-center justify-center rounded-full border border-destructive/40 bg-destructive/10", children: jsxRuntime.jsx(lucideReact.Ban, { className: "h-8 w-8 text-destructive", strokeWidth: 2.5 }) }) })] }), jsxRuntime.jsxs("div", { className: "min-w-0 flex-1", children: [jsxRuntime.jsx("h1", { className: "text-xl font-bold text-foreground sm:text-2xl", children: title }), message ? (jsxRuntime.jsx("p", { className: "mt-3 whitespace-pre-line text-sm leading-relaxed text-muted-foreground sm:text-base", children: message })) : null, jsxRuntime.jsxs("div", { className: "mt-6 flex flex-col gap-3 sm:flex-row", children: [jsxRuntime.jsxs(erpTheme.Button, { type: "button", onClick: () => go(homeTarget), className: "w-full sm:w-auto", children: [jsxRuntime.jsx(lucideReact.Home, { className: "mr-2 h-4 w-4" }), homeLabel] }), supportHref ? (jsxRuntime.jsxs(erpTheme.Button, { type: "button", variant: "outline", onClick: () => go(supportHref), className: "w-full sm:w-auto", children: [jsxRuntime.jsx(lucideReact.Headset, { className: "mr-2 h-4 w-4" }), supportLabel] })) : null] })] })] }) }) }));
3222
+ }
3223
+
3224
+ /**
3225
+ * What AppLayoutShell does with the page while the organisation in the URL and
3226
+ * the tab's active organisation are being reconciled. Pure, so it is tested
3227
+ * without React (url-org-gate.test.ts).
3228
+ *
3229
+ * The URL's `[code]` is authoritative. The page — and with it every request
3230
+ * the page makes — mounts only once the active organisation IS the one the
3231
+ * URL names:
3232
+ *
3233
+ * ready the active organisation is the URL's: render the page.
3234
+ * resolving the caller's organisations are still loading, so the code
3235
+ * cannot be resolved yet: hold the page.
3236
+ * switching the code is one of the caller's organisations but not the
3237
+ * active one: switch to it (`targetId`), hold the page meanwhile.
3238
+ * not-member the list loaded and the code is not in it: refuse.
3239
+ * unverified the list could not be loaded: render as before the gate
3240
+ * existed (the API still scopes every request), rather than
3241
+ * leave the page on a loader for good.
3242
+ */
3243
+ /** The organisation a `[code]` segment names, among the caller's. */
3244
+ function findOrganizationForCode(code, organizations) {
3245
+ if (!code)
3246
+ return null;
3247
+ return (organizations.find(org => org.code === code || String(org.id) === code) ??
3248
+ null);
3249
+ }
3250
+ function resolveUrlOrgGate({ code, organizations, organizationsStatus, organizationId, }) {
3251
+ // No code in the path: the shell sends the user to pick an organisation.
3252
+ if (!code)
3253
+ return { state: "ready", targetId: null };
3254
+ const target = findOrganizationForCode(code, organizations);
3255
+ if (target) {
3256
+ const targetId = String(target.id);
3257
+ const activeId = organizationId === null || organizationId === undefined
3258
+ ? ""
3259
+ : String(organizationId).trim();
3260
+ return activeId === targetId
3261
+ ? { state: "ready", targetId }
3262
+ : { state: "switching", targetId };
3263
+ }
3264
+ const status = organizationsStatus ?? (organizations.length > 0 ? "success" : undefined);
3265
+ switch (status) {
3266
+ case "success":
3267
+ return { state: "not-member" };
3268
+ case "pending":
3269
+ return { state: "resolving" };
3270
+ default:
3271
+ // "error", or a host that does not report the list's status and has
3272
+ // an empty one.
3273
+ return { state: "unverified" };
3274
+ }
3275
+ }
3276
+
3051
3277
  /**
3052
3278
  * Top-level app chrome: sticky header, sidebar slot, content gutter and the
3053
3279
  * session/org-context plumbing that both Accounting and HR need. Apps wrap
@@ -3060,7 +3286,7 @@ function ThreadBubble({ mine, label, at, body, images, }) {
3060
3286
  * an erp-shell bump — domain components must NOT add their own root padding
3061
3287
  * or content gets indented twice.
3062
3288
  */
3063
- function AppLayoutShell({ children, sidebar, loadingComponent, breadcrumb, permissionProvider, organizationId, setOrganizationId, organizations, signInUrl, selectOrganizationUrl, chrome = "brand", workspaceHeader = "brand", headerEnd, mobileBrand, showHelp = true, helpApp, }) {
3289
+ function AppLayoutShell({ children, sidebar, loadingComponent, breadcrumb, permissionProvider, organizationId, setOrganizationId, organizations, organizationsStatus, notMemberComponent, signInUrl, selectOrganizationUrl, chrome = "brand", workspaceHeader = "brand", headerEnd, mobileBrand, showHelp = true, helpApp, }) {
3064
3290
  const { data: session, status } = react.useSession();
3065
3291
  const { open, setOpen } = useSidebar();
3066
3292
  const router = navigation.useRouter();
@@ -3077,33 +3303,37 @@ function AppLayoutShell({ children, sidebar, loadingComponent, breadcrumb, permi
3077
3303
  if (extendedSession?.accessToken)
3078
3304
  hasSeenAccessTokenRef.current = true;
3079
3305
  }, [extendedSession?.accessToken]);
3080
- // Find the org from the URL `[code]` so we can detect a context mismatch
3081
- // (the user bookmarked a different org than the cookie remembers).
3082
- const orgFromCode = React__namespace.useMemo(() => {
3083
- if (!code || !organizations.length)
3084
- return null;
3085
- return (organizations.find(org => org.code === code || String(org.id) === code) ?? null);
3086
- }, [code, organizations]);
3087
- // Sync state: when the URL code points at a different org than the cookie,
3088
- // trust the URL and update the cookie. Otherwise do nothing — the user
3089
- // wins ties via the in-app org switcher.
3306
+ // The URL's `[code]` is authoritative: the page mounts only once the active
3307
+ // organisation is the one it names (see url-org-gate.ts).
3308
+ //
3309
+ // The gate used to start only when the code had resolved AND an active id
3310
+ // existed, which left two holes. While the organisation list loaded, the
3311
+ // page mounted at once and its first requests carried the previous
3312
+ // organisation — /O26000021/procurement/purchase-requisition/<id> fetched
3313
+ // the document from O26000019 before the switch. And a code naming none of
3314
+ // the user's organisations never resolved, so the page rendered the
3315
+ // previous organisation's data under it.
3316
+ const urlOrgGate = resolveUrlOrgGate({
3317
+ code,
3318
+ organizations,
3319
+ organizationsStatus,
3320
+ organizationId,
3321
+ });
3322
+ const switchTargetId = urlOrgGate.state === "switching" ? urlOrgGate.targetId : null;
3323
+ // Switch the active organisation to the URL's. Once per target: the setter
3324
+ // writes the cookie through a server action, and re-firing it on every
3325
+ // render while that round trip is in flight only queues duplicates.
3326
+ const requestedSwitchRef = React.useRef(null);
3090
3327
  React.useEffect(() => {
3091
- if (!code)
3328
+ if (!switchTargetId) {
3329
+ requestedSwitchRef.current = null;
3092
3330
  return;
3093
- if (!organizations.length)
3094
- return;
3095
- if (!orgFromCode)
3096
- return;
3097
- if (String(organizationId) !== String(orgFromCode.id)) {
3098
- void setOrganizationId(orgFromCode.id);
3099
3331
  }
3100
- }, [
3101
- orgFromCode,
3102
- organizationId,
3103
- setOrganizationId,
3104
- code,
3105
- organizations.length,
3106
- ]);
3332
+ if (requestedSwitchRef.current === switchTargetId)
3333
+ return;
3334
+ requestedSwitchRef.current = switchTargetId;
3335
+ void setOrganizationId(switchTargetId);
3336
+ }, [switchTargetId, setOrganizationId]);
3107
3337
  // Key for the content subtree. It exists so switching organizations throws
3108
3338
  // away the previous org's component state — but it must react ONLY to a real
3109
3339
  // switch. organizationId legitimately passes through null while the context
@@ -3125,21 +3355,20 @@ function AppLayoutShell({ children, sidebar, loadingComponent, breadcrumb, permi
3125
3355
  const contentOrgKey = lastOrgKeyRef.current;
3126
3356
  // Hold the page back until the active organisation is the one the URL names.
3127
3357
  //
3128
- // The sync effect above writes the cookie through a server action, which is
3129
- // a round trip. Until it lands, getOrgFromCookie() still answers with the
3130
- // previous organisation and every request the page fires on mount carries
3131
- // it — so /O26000021/work-record asked about O26000033 and was correctly
3132
- // told there was nothing there. An empty table is the mild version: the
3133
- // create form posts to the same header, so a record could be filed against
3134
- // a company the user was not looking at.
3358
+ // The switch writes the cookie through a server action, which is a round
3359
+ // trip. Until it lands, every request the page fires on mount would carry
3360
+ // the previous organisation — so /O26000021/work-record asked about
3361
+ // O26000033 and was correctly told there was nothing there. An empty table
3362
+ // is the mild version: the create form posts to the same header, so a
3363
+ // record could be filed against a company the user was not looking at.
3135
3364
  //
3136
- // Only while the two genuinely disagree. A code naming no organisation of
3137
- // the caller's is left to the app to refuse, and a page with no code in its
3138
- // path is unaffected.
3139
- const orgContextIsStale = !!orgFromCode &&
3140
- organizationId !== null &&
3141
- organizationId !== undefined &&
3142
- String(organizationId) !== String(orgFromCode.id);
3365
+ // The whole layout waits, not just the content: the chrome reads the URL
3366
+ // too. The breadcrumb looks up the document in the path to print its
3367
+ // number, so with only the content held it fetched
3368
+ // /purchase-requests/<id> under the previous organisation and printed that
3369
+ // organisation's document number under the new one's URL.
3370
+ const holdLayout = urlOrgGate.state === "resolving" || urlOrgGate.state === "switching";
3371
+ const refusePage = urlOrgGate.state === "not-member";
3143
3372
  // ≤1024px: sidebar renders as a modal overlay (backdrop dims content). >1024px:
3144
3373
  // sidebar is docked and the content gets a left margin to clear it.
3145
3374
  const [isSidebarModalLayout, setIsSidebarModalLayout] = React__namespace.useState(false);
@@ -3188,6 +3417,9 @@ function AppLayoutShell({ children, sidebar, loadingComponent, breadcrumb, permi
3188
3417
  router.push(selectOrganizationUrl);
3189
3418
  return jsxRuntime.jsx(jsxRuntime.Fragment, { children: loadingComponent });
3190
3419
  }
3420
+ if (holdLayout) {
3421
+ return jsxRuntime.jsx(jsxRuntime.Fragment, { children: loadingComponent });
3422
+ }
3191
3423
  // When the sidebar is docked-open it consumes 21.5rem on the left; otherwise
3192
3424
  // the content takes the full width and we add a left gutter inside.
3193
3425
  const workspace = chrome === "workspace";
@@ -3198,6 +3430,9 @@ function AppLayoutShell({ children, sidebar, loadingComponent, breadcrumb, permi
3198
3430
  : "";
3199
3431
  const needsLeftGutter = workspace || !(open && !isSidebarModalLayout);
3200
3432
  const wrap = permissionProvider ?? ((c) => c);
3433
+ // No trail over a refusal: it would look up the path's document under the
3434
+ // active organisation, which is not the URL's.
3435
+ const trail = refusePage ? null : breadcrumb;
3201
3436
  return (jsxRuntime.jsxs("div", { className: "flex min-h-screen w-full bg-background", children: [open && (jsxRuntime.jsx("div", { className: "fixed left-0 top-0 z-50 h-screen", children: sidebar })), open && isSidebarModalLayout && (jsxRuntime.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) })), jsxRuntime.jsx("div", { className: cx("relative flex min-w-0 flex-1 flex-col transition-all", contentMargin), children: jsxRuntime.jsxs("main", { className: cx("flex flex-1 flex-col min-h-screen", workspace ? "gap-0" : "gap-4"), children: [workspace ? (jsxRuntime.jsxs("div", { className: cx("sticky top-0 z-30 shrink-0 w-full", workspaceHeader === "light" &&
3202
3437
  "border-b border-border bg-card/85 backdrop-blur-sm"), style: workspaceHeader === "light"
3203
3438
  ? WORKSPACE_LIGHT_HEADER_VARS
@@ -3207,9 +3442,11 @@ function AppLayoutShell({ children, sidebar, loadingComponent, breadcrumb, permi
3207
3442
  // As in the design: the mark, then a menu button, and the
3208
3443
  // organisation on the right — no trail, no title, so the
3209
3444
  // organisation's name has the room it needs on a phone.
3210
- jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [mobileBrand ? (jsxRuntime.jsx("div", { className: "shrink-0", children: mobileBrand })) : null, jsxRuntime.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: jsxRuntime.jsx(lucideReact.LayoutGrid, { className: "size-5", strokeWidth: 1.65 }) })] })) : (jsxRuntime.jsx(SidebarTrigger, { className: "shrink-0 text-[var(--header-fg-muted)] hover:bg-[var(--header-hover)] hover:text-[var(--header-fg)] [&_svg]:!text-current" })), jsxRuntime.jsxs("div", { className: "flex min-w-0 flex-1 flex-col justify-center gap-0.5", children: [jsxRuntime.jsx("div", { className: "hidden min-w-0 sm:block", children: breadcrumb }), jsxRuntime.jsx("div", { id: "header-title", className: "min-w-0 max-sm:hidden" })] }), headerEnd || showHelp ? (jsxRuntime.jsxs("div", { className: "flex shrink-0 items-center gap-3 sm:gap-5", children: [headerEnd, showHelp ? jsxRuntime.jsx(HelpButton, {}) : null] })) : null] })] })) : (jsxRuntime.jsxs("div", { className: "sticky top-0 z-30 shrink-0 w-full", children: [jsxRuntime.jsx(HeaderBackground, {}), jsxRuntime.jsxs("header", { className: "relative shrink-0 gap-2 py-4 h-auto min-h-[123.5px] w-full z-10", children: [jsxRuntime.jsxs("div", { className: "flex w-full items-center gap-2 px-3 sm:px-4 lg:px-6", children: [jsxRuntime.jsx(SidebarTrigger, { className: "shrink-0 text-white hover:bg-white/10" }), jsxRuntime.jsx("div", { className: "flex-1 min-w-0", children: breadcrumb }), headerEnd || showHelp ? (jsxRuntime.jsxs("div", { className: "flex shrink-0 items-center gap-3", children: [headerEnd, showHelp ? jsxRuntime.jsx(HelpButton, {}) : null] })) : null] }), jsxRuntime.jsx("div", { id: "header-title" })] })] })), jsxRuntime.jsx(React.Suspense, { fallback: jsxRuntime.jsx("div", { className: "relative z-10 min-h-[calc(100vh-8rem)]", children: jsxRuntime.jsx("div", { className: "flex h-full items-center justify-center", children: jsxRuntime.jsxs("div", { className: "space-y-4 text-center", children: [jsxRuntime.jsx("div", { className: "mx-auto h-8 w-8 animate-spin rounded-full border-b-2 border-primary" }), jsxRuntime.jsx("p", { className: "text-sm text-muted-foreground", children: "\u0E01\u0E33\u0E25\u0E31\u0E07\u0E42\u0E2B\u0E25\u0E14..." })] }) }) }), children: jsxRuntime.jsx("div", { className: workspace
3445
+ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [mobileBrand ? (jsxRuntime.jsx("div", { className: "shrink-0", children: mobileBrand })) : null, jsxRuntime.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: jsxRuntime.jsx(lucideReact.LayoutGrid, { className: "size-5", strokeWidth: 1.65 }) })] })) : (jsxRuntime.jsx(SidebarTrigger, { className: "shrink-0 text-[var(--header-fg-muted)] hover:bg-[var(--header-hover)] hover:text-[var(--header-fg)] [&_svg]:!text-current" })), jsxRuntime.jsxs("div", { className: "flex min-w-0 flex-1 flex-col justify-center gap-0.5", children: [jsxRuntime.jsx("div", { className: "hidden min-w-0 sm:block", children: trail }), jsxRuntime.jsx("div", { id: "header-title", className: "min-w-0 max-sm:hidden" })] }), headerEnd || showHelp ? (jsxRuntime.jsxs("div", { className: "flex shrink-0 items-center gap-3 sm:gap-5", children: [headerEnd, showHelp ? jsxRuntime.jsx(HelpButton, {}) : null] })) : null] })] })) : (jsxRuntime.jsxs("div", { className: "sticky top-0 z-30 shrink-0 w-full", children: [jsxRuntime.jsx(HeaderBackground, {}), jsxRuntime.jsxs("header", { className: "relative shrink-0 gap-2 py-4 h-auto min-h-[123.5px] w-full z-10", children: [jsxRuntime.jsxs("div", { className: "flex w-full items-center gap-2 px-3 sm:px-4 lg:px-6", children: [jsxRuntime.jsx(SidebarTrigger, { className: "shrink-0 text-white hover:bg-white/10" }), jsxRuntime.jsx("div", { className: "flex-1 min-w-0", children: trail }), headerEnd || showHelp ? (jsxRuntime.jsxs("div", { className: "flex shrink-0 items-center gap-3", children: [headerEnd, showHelp ? jsxRuntime.jsx(HelpButton, {}) : null] })) : null] }), jsxRuntime.jsx("div", { id: "header-title" })] })] })), jsxRuntime.jsx(React.Suspense, { fallback: jsxRuntime.jsx("div", { className: "relative z-10 min-h-[calc(100vh-8rem)]", children: jsxRuntime.jsx("div", { className: "flex h-full items-center justify-center", children: jsxRuntime.jsxs("div", { className: "space-y-4 text-center", children: [jsxRuntime.jsx("div", { className: "mx-auto h-8 w-8 animate-spin rounded-full border-b-2 border-primary" }), jsxRuntime.jsx("p", { className: "text-sm text-muted-foreground", children: "\u0E01\u0E33\u0E25\u0E31\u0E07\u0E42\u0E2B\u0E25\u0E14..." })] }) }) }), children: jsxRuntime.jsx("div", { className: workspace
3211
3446
  ? "relative z-10 px-[18px] pt-[26px] pb-4 sm:px-6 lg:px-10 lg:pt-[38px]"
3212
- : cx("relative z-10 pr-6", needsLeftGutter && "pl-6"), children: orgContextIsStale ? loadingComponent : wrap(children) }, `org-${contentOrgKey}`) })] }) }), showHelp ? jsxRuntime.jsx(HelpDialogHost, { app: helpApp, orgCode: code }) : null] }));
3447
+ : cx("relative z-10 pr-6", needsLeftGutter && "pl-6"), children: refusePage
3448
+ ? (notMemberComponent ?? (jsxRuntime.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" })))
3449
+ : wrap(children) }, `org-${contentOrgKey}`) })] }) }), showHelp ? jsxRuntime.jsx(HelpDialogHost, { app: helpApp, orgCode: code }) : null] }));
3213
3450
  }
3214
3451
  /** Local tiny classnames helper — avoids pulling clsx/tailwind-merge into
3215
3452
  * the shell just for the layout file. Falsy values are skipped. */
@@ -3248,33 +3485,6 @@ function SidebarHelpLink({ label, href, icon, className }) {
3248
3485
  return external ? (jsxRuntime.jsx("a", { href: href, target: "_blank", rel: "noopener noreferrer", className: cls, children: body })) : (jsxRuntime.jsx(Link, { href: href, className: cls, children: body }));
3249
3486
  }
3250
3487
 
3251
- /**
3252
- * Central "no access" screen shared by every policy check (route-level
3253
- * PermissionProvider and page-level PermissionGateShell). One look everywhere:
3254
- * an illustration, a 403 headline, the page-specific reason, and recovery
3255
- * actions.
3256
- */
3257
- function AccessDenied({ title = "ขออภัย, คุณไม่มีสิทธิ์เข้าถึงหน้านี้ (403)", message = "คุณไม่มีสิทธิ์เข้าถึงหน้านี้ ขออภัยในความไม่สะดวก", homeHref, homeLabel = "กลับไปหน้าหลัก", supportHref, supportLabel = "ติดต่อฝ่ายสนับสนุน", fullScreen = true, className, }) {
3258
- // "/" is not the home page inside an app — it bounces to organization
3259
- // selection, so the button read "กลับไปหน้าหลัก" and dropped the user into a
3260
- // picker for the org they were already in. Send them to that org's home.
3261
- const params = navigation.useParams();
3262
- const code = typeof params?.code === "string" ? params.code : "";
3263
- const homeTarget = homeHref ?? (code ? `/${code}` : "/");
3264
- const go = (href) => {
3265
- if (typeof window !== "undefined")
3266
- window.location.href = href;
3267
- };
3268
- const wrapperClass = [
3269
- "flex min-w-0 flex-1 items-center justify-center p-4",
3270
- fullScreen ? "min-h-[70vh]" : "",
3271
- className ?? "",
3272
- ]
3273
- .filter(Boolean)
3274
- .join(" ");
3275
- return (jsxRuntime.jsx("div", { className: wrapperClass, children: jsxRuntime.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: jsxRuntime.jsxs("div", { className: "flex flex-col items-center gap-8 text-center sm:flex-row sm:text-left", children: [jsxRuntime.jsxs("div", { className: "relative shrink-0", children: [jsxRuntime.jsx("div", { className: "absolute inset-0 rounded-full bg-destructive/20 blur-2xl" }), jsxRuntime.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: jsxRuntime.jsx("div", { className: "flex h-16 w-16 items-center justify-center rounded-full border border-destructive/40 bg-destructive/10", children: jsxRuntime.jsx(lucideReact.Ban, { className: "h-8 w-8 text-destructive", strokeWidth: 2.5 }) }) })] }), jsxRuntime.jsxs("div", { className: "min-w-0 flex-1", children: [jsxRuntime.jsx("h1", { className: "text-xl font-bold text-foreground sm:text-2xl", children: title }), message ? (jsxRuntime.jsx("p", { className: "mt-3 whitespace-pre-line text-sm leading-relaxed text-muted-foreground sm:text-base", children: message })) : null, jsxRuntime.jsxs("div", { className: "mt-6 flex flex-col gap-3 sm:flex-row", children: [jsxRuntime.jsxs(erpTheme.Button, { type: "button", onClick: () => go(homeTarget), className: "w-full sm:w-auto", children: [jsxRuntime.jsx(lucideReact.Home, { className: "mr-2 h-4 w-4" }), homeLabel] }), supportHref ? (jsxRuntime.jsxs(erpTheme.Button, { type: "button", variant: "outline", onClick: () => go(supportHref), className: "w-full sm:w-auto", children: [jsxRuntime.jsx(lucideReact.Headset, { className: "mr-2 h-4 w-4" }), supportLabel] })) : null] })] })] }) }) }));
3276
- }
3277
-
3278
3488
  const EMPLOYEE_RECORD_REQUIRED = "EMPLOYEE_RECORD_REQUIRED";
3279
3489
  const EMPLOYEE_RECORD_REQUIRED_EVENT = "erp:employee-record-required";
3280
3490
  const EMPLOYEE_RECORD_OK_EVENT = "erp:employee-record-ok";
@@ -3667,6 +3877,51 @@ function useFeatureFlag(flag) {
3667
3877
  return flags[flag];
3668
3878
  }
3669
3879
 
3880
+ // No relative imports: node --test loads this file as is (query-scope.test.ts).
3881
+ /**
3882
+ * Every query is cached per organisation.
3883
+ *
3884
+ * Requests are scoped by the X-Organization-ID header, so almost no query key
3885
+ * names the organisation — ["purchase-requests", "detail", id] is the same key
3886
+ * in every company. A key that is the same in two organisations is one cache
3887
+ * entry: what was fetched in one was a fresh cache hit in the other, and a
3888
+ * page opened under another organisation's URL rendered the first one's
3889
+ * document until something refetched it.
3890
+ *
3891
+ * The hash — the identity of a cache entry — therefore carries the
3892
+ * organisation requests are sent with right now (getQueryClientConfig passes
3893
+ * getRequestOrganizationId()). The key itself is untouched,
3894
+ * so partial-key invalidation and every existing key keep working; a switch
3895
+ * simply lands on a different set of entries.
3896
+ */
3897
+ function organizationScopedQueryHash(queryKey, organizationId) {
3898
+ return `${reactQuery.hashKey(queryKey)}@org:${organizationId ?? ""}`;
3899
+ }
3900
+ function errorStatus(error) {
3901
+ if (!error || typeof error !== "object")
3902
+ return undefined;
3903
+ const e = error;
3904
+ const status = typeof e.status === "number" ? e.status : e.response?.status;
3905
+ return typeof status === "number" ? status : undefined;
3906
+ }
3907
+ /**
3908
+ * Retry a failed query at most twice — unless the API answered with a 4xx.
3909
+ * A 404 for a document that is not in this organisation will not appear on a
3910
+ * second ask; retrying held the page on its skeleton for three seconds before
3911
+ * it could say "not found". 408 and 429 are transient and still retried.
3912
+ */
3913
+ function shouldRetryQuery(failureCount, error) {
3914
+ const status = errorStatus(error);
3915
+ if (status !== undefined &&
3916
+ status >= 400 &&
3917
+ status < 500 &&
3918
+ status !== 408 &&
3919
+ status !== 429) {
3920
+ return false;
3921
+ }
3922
+ return failureCount < 2;
3923
+ }
3924
+
3670
3925
  const getAxiosErrorMessage$1 = (error) => {
3671
3926
  if (error.response?.data?.message) {
3672
3927
  return error.response.data.message;
@@ -3690,7 +3945,9 @@ const formatError = (handlerName, error) => {
3690
3945
  };
3691
3946
  /**
3692
3947
  * Default React Query config used across ERP apps:
3693
- * - 2 retries on queries / 1 on mutations, exponential backoff
3948
+ * - cache entries scoped by organisation (organizationScopedQueryHash)
3949
+ * - 2 retries on queries except 4xx answers / none on mutations,
3950
+ * exponential backoff
3694
3951
  * - 5 min staleTime, refetchOnWindowFocus
3695
3952
  * - swallows "no org context" errors to silence noise on the
3696
3953
  * select-organization page
@@ -3701,7 +3958,8 @@ const formatError = (handlerName, error) => {
3701
3958
  const getQueryClientConfig = () => ({
3702
3959
  defaultOptions: {
3703
3960
  queries: {
3704
- retry: 2,
3961
+ queryKeyHashFn: queryKey => organizationScopedQueryHash(queryKey, getRequestOrganizationId()),
3962
+ retry: shouldRetryQuery,
3705
3963
  retryDelay: attemptIndex => Math.min(1000 * 2 ** attemptIndex, 30000),
3706
3964
  staleTime: 5 * 60 * 1000,
3707
3965
  refetchOnWindowFocus: true,
@@ -3841,27 +4099,34 @@ exports.describeBrowser = describeBrowser;
3841
4099
  exports.detectHelpApp = detectHelpApp;
3842
4100
  exports.filterNavigationModulesByPermission = filterNavigationModulesByPermission;
3843
4101
  exports.filterNavigationToolsByPermission = filterNavigationToolsByPermission;
4102
+ exports.findOrganizationForCode = findOrganizationForCode;
3844
4103
  exports.getAccessToken = getAccessToken;
3845
4104
  exports.getActiveModule = getActiveModule;
3846
4105
  exports.getActiveSubModule = getActiveSubModule;
3847
4106
  exports.getAxiosErrorMessage = getAxiosErrorMessage;
3848
4107
  exports.getOrganizationId = getOrganizationId;
3849
4108
  exports.getQueryClientConfig = getQueryClientConfig;
4109
+ exports.getRequestOrganizationId = getRequestOrganizationId;
3850
4110
  exports.getRequiredChildGroups = getRequiredChildGroups;
4111
+ exports.getTabOrganizationId = getTabOrganizationId;
3851
4112
  exports.hasPlanFeature = hasPlanFeature;
3852
4113
  exports.isLikelyTransientNetworkError = isLikelyTransientNetworkError;
3853
4114
  exports.isPathActive = isPathActive;
3854
4115
  exports.myRolesQueryKey = myRolesQueryKey;
3855
4116
  exports.normalizePathnameForPermissionRules = normalizePathnameForPermissionRules;
3856
4117
  exports.openHelp = openHelp;
4118
+ exports.organizationScopedQueryHash = organizationScopedQueryHash;
3857
4119
  exports.parseMyRoles = parseMyRoles;
3858
4120
  exports.resolveActiveNav = resolveActiveNav;
3859
4121
  exports.resolveSidebarNav = resolveSidebarNav;
3860
4122
  exports.resolveUiRoutePermission = resolveUiRoutePermission;
4123
+ exports.resolveUrlOrgGate = resolveUrlOrgGate;
3861
4124
  exports.roleSummary = roleSummary;
3862
4125
  exports.runGlobalRequestPrecheck = runGlobalRequestPrecheck;
3863
4126
  exports.runWithTransientRetry = runWithTransientRetry;
3864
4127
  exports.setOrganizationId = setOrganizationId;
4128
+ exports.setTabOrganizationId = setTabOrganizationId;
4129
+ exports.shouldRetryQuery = shouldRetryQuery;
3865
4130
  exports.toApiError = toApiError;
3866
4131
  exports.unwrapApiResponse = unwrapApiResponse;
3867
4132
  exports.updateOrganizationId = updateOrganizationId;